Skip to content

Commit 10daf52

Browse files
authored
Merge pull request #977 from mprpic/generate-sbom
Generate minimal SBOM
2 parents dc9de54 + 06f5781 commit 10daf52

11 files changed

Lines changed: 535 additions & 1 deletion

File tree

docs/reference/config-reference.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ For example `flash_attn.yaml`.
2020

2121
.. autopydantic_model:: fromager.packagesettings.ProjectOverride
2222

23+
.. autopydantic_model:: fromager.packagesettings.SbomSettings
24+
2325
Global Settings
2426
---------------
2527

src/fromager/packagesettings/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
PackageSettings,
99
ProjectOverride,
1010
ResolverDist,
11+
SbomSettings,
1112
VariantInfo,
1213
)
1314
from ._pbi import PackageBuildInfo
@@ -47,6 +48,7 @@
4748
"ProjectOverride",
4849
"RawAnnotations",
4950
"ResolverDist",
51+
"SbomSettings",
5052
"Settings",
5153
"SettingsFile",
5254
"Template",

src/fromager/packagesettings/_models.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,33 @@
2929
logger = logging.getLogger(__name__)
3030

3131

32+
class SbomSettings(pydantic.BaseModel):
33+
"""Global SBOM generation settings
34+
35+
::
36+
37+
sbom:
38+
supplier: "Organization: ExampleCo"
39+
namespace: "https://www.example.com"
40+
creators:
41+
- "Organization: ExampleCo"
42+
"""
43+
44+
model_config = MODEL_CONFIG
45+
46+
supplier: str = "NOASSERTION"
47+
"""SPDX supplier field for the wheel package (e.g. ``Organization: ExampleCo``)"""
48+
49+
namespace: str = "https://spdx.org/spdxdocs"
50+
"""Base URL for the SPDX documentNamespace"""
51+
52+
creators: list[str] = Field(default_factory=list)
53+
"""Additional SPDX creator entries (e.g. ``Organization: ExampleCo``)
54+
55+
The fromager tool creator entry is always added automatically.
56+
"""
57+
58+
3259
class ResolverDist(pydantic.BaseModel):
3360
"""Packages resolver dist
3461
@@ -324,6 +351,14 @@ class PackageSettings(pydantic.BaseModel):
324351
download_source: DownloadSource = Field(default_factory=DownloadSource)
325352
"""Alternative source download settings"""
326353

354+
purl: str | None = None
355+
"""Package URL (purl) override for SBOM generation
356+
357+
When set, this value is used instead of the default ``pkg:pypi/<name>@<version>``
358+
purl. Useful for packages that are not on PyPI or are midstream forks.
359+
Supports ``{name}`` and ``{version}`` format substitution.
360+
"""
361+
327362
resolver_dist: ResolverDist = Field(default_factory=ResolverDist)
328363
"""Resolve distribution version"""
329364

src/fromager/packagesettings/_pbi.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ def variant(self) -> Variant:
6969
"""Variant name"""
7070
return self._variant
7171

72+
@property
73+
def purl(self) -> str | None:
74+
"""Package URL (purl) override for SBOM generation."""
75+
return self._ps.purl
76+
7277
@property
7378
def annotations(self) -> Annotations:
7479
"""Get Package and variant annotations

src/fromager/packagesettings/_settings.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from pydantic import Field
1414

1515
from .. import overrides
16-
from ._models import PackageSettings
16+
from ._models import PackageSettings, SbomSettings
1717
from ._pbi import PackageBuildInfo
1818
from ._typedefs import MODEL_CONFIG, GlobalChangelog, Package, Variant
1919

@@ -37,6 +37,14 @@ class SettingsFile(pydantic.BaseModel):
3737
changelog: GlobalChangelog = Field(default_factory=dict)
3838
"""Changelog entries"""
3939

40+
sbom: SbomSettings | None = None
41+
"""SBOM generation settings
42+
43+
When set, Fromager generates SPDX 2.3 SBOM documents and embeds
44+
them in built wheels per PEP 770. When absent (default), no SBOMs
45+
are generated.
46+
"""
47+
4048
@classmethod
4149
def from_string(
4250
cls,
@@ -162,6 +170,11 @@ def max_jobs(self, jobs: int | None) -> None:
162170
self._pbi_cache.clear()
163171
self._max_jobs = jobs
164172

173+
@property
174+
def sbom_settings(self) -> SbomSettings | None:
175+
"""Get global SBOM settings, or None if SBOM generation is disabled."""
176+
return self._settings.sbom
177+
165178
def variant_changelog(self) -> list[str]:
166179
"""Get global changelog for current variant"""
167180
return list(self._settings.changelog.get(self.variant, []))

src/fromager/sbom.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""Generate SPDX 2.3 SBOM documents for wheels built by Fromager.
2+
3+
Produces minimal SPDX 2.3 JSON documents conforming to PEP 770 for
4+
embedding in the ``.dist-info/sboms/`` directory of built wheels.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import importlib.metadata
10+
import json
11+
import logging
12+
import pathlib
13+
import typing
14+
from datetime import UTC, datetime
15+
16+
from packaging.requirements import Requirement
17+
from packaging.utils import canonicalize_name
18+
from packaging.version import Version
19+
20+
if typing.TYPE_CHECKING:
21+
from . import context
22+
23+
logger = logging.getLogger(__name__)
24+
25+
SBOM_FILENAME = "fromager.spdx.json"
26+
27+
28+
def _build_purl(
29+
*,
30+
package_name: str,
31+
package_version: Version,
32+
purl_override: str | None,
33+
) -> str:
34+
"""Build a package URL for the SBOM.
35+
36+
Returns ``pkg:pypi/<name>@<version>`` by default. If a purl override
37+
is set in per-package settings, it is used instead with
38+
``str.format()`` substitution for ``{name}`` and ``{version}``.
39+
"""
40+
if purl_override:
41+
try:
42+
return purl_override.format(name=package_name, version=package_version)
43+
except (KeyError, ValueError) as err:
44+
raise ValueError(
45+
f"invalid purl template {purl_override!r}: "
46+
"only {name} and {version} are supported"
47+
) from err
48+
return f"pkg:pypi/{package_name}@{package_version}"
49+
50+
51+
def generate_sbom(
52+
*,
53+
ctx: context.WorkContext,
54+
req: Requirement,
55+
version: Version,
56+
) -> dict[str, typing.Any]:
57+
"""Generate a minimal SPDX 2.3 JSON document for a wheel.
58+
59+
The document contains the wheel as the primary package and a
60+
DESCRIBES relationship from the document to the package.
61+
"""
62+
sbom_settings = ctx.settings.sbom_settings
63+
if sbom_settings is None:
64+
raise RuntimeError("generate_sbom called but SBOM settings are not configured")
65+
66+
pbi = ctx.package_build_info(req)
67+
name = canonicalize_name(req.name)
68+
fromager_version = importlib.metadata.version("fromager")
69+
timestamp = datetime.now(tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
70+
71+
creators = list(sbom_settings.creators)
72+
creators.append(f"Tool: fromager-{fromager_version}")
73+
74+
namespace = f"{sbom_settings.namespace}/{name}-{version}.spdx.json"
75+
76+
package_entry: dict[str, typing.Any] = {
77+
"SPDXID": "SPDXRef-wheel",
78+
"name": name,
79+
"versionInfo": str(version),
80+
"downloadLocation": "NOASSERTION",
81+
"supplier": sbom_settings.supplier,
82+
}
83+
84+
purl = _build_purl(
85+
package_name=name,
86+
package_version=version,
87+
purl_override=pbi.purl,
88+
)
89+
package_entry["externalRefs"] = [
90+
{
91+
"referenceCategory": "PACKAGE-MANAGER",
92+
"referenceType": "purl",
93+
"referenceLocator": purl,
94+
}
95+
]
96+
97+
doc: dict[str, typing.Any] = {
98+
"spdxVersion": "SPDX-2.3",
99+
"dataLicense": "CC0-1.0",
100+
"SPDXID": "SPDXRef-DOCUMENT",
101+
"name": f"{name}-{version}",
102+
"documentNamespace": namespace,
103+
"creationInfo": {
104+
"created": timestamp,
105+
"creators": creators,
106+
},
107+
"packages": [package_entry],
108+
"relationships": [
109+
{
110+
"spdxElementId": "SPDXRef-DOCUMENT",
111+
"relationshipType": "DESCRIBES",
112+
"relatedSpdxElement": "SPDXRef-wheel",
113+
},
114+
],
115+
}
116+
return doc
117+
118+
119+
def write_sbom(
120+
*,
121+
sbom: dict[str, typing.Any],
122+
dist_info_dir: pathlib.Path,
123+
) -> pathlib.Path:
124+
"""Write an SBOM document to the .dist-info/sboms/ directory.
125+
126+
Creates the sboms/ subdirectory if it does not already exist.
127+
Returns the path to the written file.
128+
"""
129+
sboms_dir = dist_info_dir / "sboms"
130+
sboms_dir.mkdir(exist_ok=True)
131+
# Fromager generates exactly one SBOM per wheel, so overwriting a
132+
# previous fromager.spdx.json from an earlier run is expected.
133+
# SBOMs from other tools (e.g. maturin's CycloneDX) use different
134+
# filenames and are not affected.
135+
sbom_path = sboms_dir / SBOM_FILENAME
136+
with sbom_path.open("w", encoding="utf-8") as f:
137+
json.dump(sbom, f, indent=2)
138+
f.write("\n")
139+
logger.info("wrote SBOM to %s", sbom_path)
140+
return sbom_path

src/fromager/wheels.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
packagesettings,
3131
requirements_file,
3232
resolver,
33+
sbom,
3334
sources,
3435
)
3536

@@ -44,6 +45,24 @@
4445
FROMAGER_BUILD_REQ_PREFIX = "fromager"
4546

4647

48+
def _log_existing_sboms(
49+
req: Requirement,
50+
dist_info_dir: pathlib.Path,
51+
) -> None:
52+
"""Log any existing SBOM files found in the wheel's .dist-info/sboms/ directory."""
53+
sboms_dir = dist_info_dir / "sboms"
54+
if not sboms_dir.is_dir():
55+
return
56+
sbom_files = sorted(sboms_dir.iterdir())
57+
if sbom_files:
58+
names = [f.name for f in sbom_files]
59+
logger.info(
60+
"%s: found existing SBOM files in wheel: %s",
61+
req.name,
62+
", ".join(names),
63+
)
64+
65+
4766
def _extra_metadata_elfdeps(
4867
ctx: context.WorkContext,
4968
req: Requirement,
@@ -182,6 +201,8 @@ def add_extra_metadata_to_wheels(
182201
if not dist_info_dir.is_dir():
183202
raise ValueError(f"{wheel_file} does not contain {dist_info_dir.name}")
184203

204+
_log_existing_sboms(req, dist_info_dir)
205+
185206
data_to_add = overrides.find_and_invoke(
186207
req.name,
187208
"add_extra_metadata_to_wheels",
@@ -231,6 +252,15 @@ def add_extra_metadata_to_wheels(
231252
else:
232253
logger.debug("%s is a purelib wheel", req.name)
233254

255+
sbom_settings = ctx.settings.sbom_settings
256+
if sbom_settings is not None:
257+
sbom_doc = sbom.generate_sbom(
258+
ctx=ctx,
259+
req=req,
260+
version=version,
261+
)
262+
sbom.write_sbom(sbom=sbom_doc, dist_info_dir=dist_info_dir)
263+
234264
build_tag_from_settings = pbi.build_tag(version)
235265
build_tag = build_tag_from_settings if build_tag_from_settings else (0, "")
236266

tests/conftest.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from click.testing import CliRunner
66

77
from fromager import context, packagesettings
8+
from fromager.packagesettings import SbomSettings
89

910
TESTDATA_PATH = pathlib.Path(__file__).parent.absolute() / "testdata"
1011
E2E_PATH = pathlib.Path(__file__).parent.parent.absolute() / "e2e"
@@ -82,6 +83,38 @@ def testdata_context(
8283
return ctx
8384

8485

86+
def make_sbom_ctx(
87+
tmp_path: pathlib.Path,
88+
sbom_settings: SbomSettings | None = None,
89+
purl: str | None = None,
90+
) -> context.WorkContext:
91+
"""Create a minimal WorkContext with SBOM settings."""
92+
settings_file = packagesettings.SettingsFile(sbom=sbom_settings)
93+
settings = packagesettings.Settings(
94+
settings=settings_file,
95+
package_settings=[],
96+
patches_dir=tmp_path / "patches",
97+
variant="cpu",
98+
max_jobs=None,
99+
)
100+
if purl is not None:
101+
ps = packagesettings.PackageSettings.from_mapping(
102+
"test-pkg",
103+
{"purl": purl},
104+
source="test",
105+
has_config=True,
106+
)
107+
settings._package_settings[ps.name] = ps
108+
return context.WorkContext(
109+
active_settings=settings,
110+
constraints_file=None,
111+
patches_dir=tmp_path / "patches",
112+
sdists_repo=tmp_path / "sdists-repo",
113+
wheels_repo=tmp_path / "wheels-repo",
114+
work_dir=tmp_path / "work-dir",
115+
)
116+
117+
85118
@pytest.fixture
86119
def cli_runner(
87120
tmp_path: pathlib.Path,

tests/test_packagesettings.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
},
7373
"name": "test-pkg",
7474
"has_config": True,
75+
"purl": None,
7576
"project_override": {
7677
"remove_build_requires": ["cmake"],
7778
"update_build_requires": ["setuptools>=68.0.0", "torch"],
@@ -132,6 +133,7 @@
132133
"submodule_paths": [],
133134
},
134135
"has_config": True,
136+
"purl": None,
135137
"project_override": {
136138
"remove_build_requires": [],
137139
"update_build_requires": [],
@@ -171,6 +173,7 @@
171173
"submodule_paths": [],
172174
},
173175
"has_config": True,
176+
"purl": None,
174177
"project_override": {
175178
"remove_build_requires": [],
176179
"update_build_requires": [],

0 commit comments

Comments
 (0)