diff --git a/src/fromager/packagesettings/_models.py b/src/fromager/packagesettings/_models.py index 7aa54e0c0..b244d0b0d 100644 --- a/src/fromager/packagesettings/_models.py +++ b/src/fromager/packagesettings/_models.py @@ -314,10 +314,15 @@ class PurlConfig(pydantic.BaseModel): """ version: str | None = None - """Override the purl version component (defaults to the resolved version) + """Override the purl version component (defaults to the wheel version) Also determines the version used in the SBOM document name and documentNamespace. + + .. versionchanged:: 0.102.0 + The default is now the version of the built wheel, including any + local version added by a patch, instead of the resolved upstream + version. """ repository_url: AnyUrl | None = None diff --git a/src/fromager/sbom.py b/src/fromager/sbom.py index 1ce7a4092..0c41dd984 100644 --- a/src/fromager/sbom.py +++ b/src/fromager/sbom.py @@ -91,6 +91,7 @@ def generate_sbom( ctx: context.WorkContext, req: Requirement, version: Version, + wheel_version: Version | None = None, ) -> dict[str, typing.Any]: """Generate a minimal SPDX 2.3 JSON document for a wheel. @@ -101,6 +102,13 @@ def generate_sbom( The document name and namespace are derived from the downstream purl (including any ``purl`` field overrides), so the document identity always matches the wheel package entry it describes. + + ``version`` is the resolved upstream version. ``wheel_version`` is + the version of the wheel actually built, which may differ when a + patch adds a local version (e.g. ``1.2.3+local.1``). The wheel + package entry, its purl, and the document identity use + ``wheel_version`` when given; the upstream package entry always + uses ``version``. """ sbom_settings = ctx.settings.sbom_settings if sbom_settings is None: @@ -116,7 +124,7 @@ def generate_sbom( downstream = _build_downstream_purl( name=name, - version=version, + version=wheel_version or version, pbi=pbi, sbom_settings=sbom_settings, ) @@ -134,7 +142,7 @@ def generate_sbom( # so the namespace is always a valid URI, even when purl overrides # contain URI-hostile characters. doc_name = downstream.name - doc_version = downstream.version or str(version) + doc_version = downstream.version or str(wheel_version or version) namespace = ( f"{sbom_settings.namespace!s}/{quote(doc_name, safe='')}" f"-{quote(doc_version, safe='')}-{uuid.uuid4()}.spdx.json" diff --git a/src/fromager/wheels.py b/src/fromager/wheels.py index 943edea8d..f5743aec3 100644 --- a/src/fromager/wheels.py +++ b/src/fromager/wheels.py @@ -320,6 +320,10 @@ def add_extra_metadata_to_wheels( ctx=ctx, req=req, version=version, + # dist_version comes from the wheel filename and includes + # any local version added by a patch, so the SBOM + # identifies the wheel that actually contains it + wheel_version=dist_version, ) sbom.write_sbom(sbom=sbom_doc, dist_info_dir=dist_info_dir) diff --git a/tests/test_sbom.py b/tests/test_sbom.py index b228c3fba..47e689a65 100644 --- a/tests/test_sbom.py +++ b/tests/test_sbom.py @@ -174,6 +174,58 @@ def test_generate_sbom_upstream_uses_source_version_without_upstream_override( _validate_spdx(doc) +def test_generate_sbom_wheel_version_used_without_purl_override( + tmp_path: pathlib.Path, +) -> None: + """Verify the wheel entry uses the actual wheel version automatically. + + When a patch adds a local version, the SBOM must identify the built + wheel without requiring a per-package ``purl.version`` override. + """ + ctx = make_sbom_ctx(tmp_path, sbom_settings=SbomSettings()) + doc = sbom.generate_sbom( + ctx=ctx, + req=Requirement("test-pkg==0.5.5"), + version=Version("0.5.5"), + wheel_version=Version("0.5.5+local.1"), + ) + + assert doc["name"] == "test-pkg-0.5.5+local.1" + wheel, upstream = doc["packages"] + assert wheel["versionInfo"] == "0.5.5+local.1" + assert wheel["externalRefs"][0]["referenceLocator"] == ( + "pkg:pypi/test-pkg@0.5.5%2Blocal.1" + ) + # Upstream still identifies the resolved source release + assert upstream["versionInfo"] == "0.5.5" + assert upstream["externalRefs"][0]["referenceLocator"] == ( + "pkg:pypi/test-pkg@0.5.5" + ) + _validate_spdx(doc) + + +def test_generate_sbom_purl_version_override_wins_over_wheel_version( + tmp_path: pathlib.Path, +) -> None: + """Verify an explicit purl.version override still takes precedence.""" + ctx = make_sbom_ctx( + tmp_path, + sbom_settings=SbomSettings(), + package_overrides={"purl": {"version": "0.5.5+corp.1"}}, + ) + doc = sbom.generate_sbom( + ctx=ctx, + req=Requirement("test-pkg==0.5.5"), + version=Version("0.5.5"), + wheel_version=Version("0.5.5+local.1"), + ) + + wheel = doc["packages"][0] + assert wheel["versionInfo"] == "0.5.5+corp.1" + assert doc["name"] == "test-pkg-0.5.5+corp.1" + _validate_spdx(doc) + + def test_generate_sbom_namespace_is_unique_per_document( tmp_path: pathlib.Path, ) -> None: diff --git a/tests/test_wheels.py b/tests/test_wheels.py index 91bb698e2..84f3cc8bf 100644 --- a/tests/test_wheels.py +++ b/tests/test_wheels.py @@ -1,3 +1,4 @@ +import json import pathlib import typing import zipfile @@ -139,7 +140,12 @@ def test_log_existing_sboms_when_absent( def test_add_extra_metadata_generates_sbom_when_enabled( mock_run: Mock, tmp_path: pathlib.Path ) -> None: - """Verify SBOM is generated in .dist-info/sboms/ when sbom settings are configured.""" + """Verify SBOM is generated in .dist-info/sboms/ when sbom settings are configured. + + The wheel carries a local version (as added by a patch) while the + resolved version has none, so the SBOM must describe the wheel it + is embedded in, not the upstream release. + """ sbom_ctx = make_sbom_ctx(tmp_path, sbom_settings=SbomSettings()) sbom_ctx.setup() req = Requirement("test_pkg==1.0.0") @@ -147,29 +153,30 @@ def test_add_extra_metadata_generates_sbom_when_enabled( wheel_dir = tmp_path / "wheel_build" wheel_dir.mkdir() - wheel_file = wheel_dir / "test_pkg-1.0.0-py3-none-any.whl" + wheel_file = wheel_dir / "test_pkg-1.0.0+local.1-py3-none-any.whl" with zipfile.ZipFile(wheel_file, "w") as zf: zf.writestr("test_pkg/__init__.py", "") zf.writestr( - "test_pkg-1.0.0.dist-info/METADATA", - "Name: test_pkg\nVersion: 1.0.0\n", + "test_pkg-1.0.0+local.1.dist-info/METADATA", + "Name: test_pkg\nVersion: 1.0.0+local.1\n", ) zf.writestr( - "test_pkg-1.0.0.dist-info/WHEEL", + "test_pkg-1.0.0+local.1.dist-info/WHEEL", "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", ) mock_run.return_value = "" # Create the repacked wheel that wheel pack would produce - repacked = wheel_dir / "test_pkg-1.0.0-0-py3-none-any.whl" + repacked = wheel_dir / "test_pkg-1.0.0+local.1-0-py3-none-any.whl" sdist_dir = tmp_path / "sdist" sdist_dir.mkdir() # Capture the wheel contents before repack by inspecting what wheel pack receives captured_contents: list[str] = [] + captured_sbom: dict[str, typing.Any] = {} def fake_run(cmd: list[str], **kwargs: object) -> str: # wheel pack is called with the unpacked dir as second arg @@ -177,6 +184,13 @@ def fake_run(cmd: list[str], **kwargs: object) -> str: for f in unpacked_dir.rglob("*"): if f.is_file(): captured_contents.append(str(f.relative_to(unpacked_dir))) + sbom_file = ( + unpacked_dir + / "test_pkg-1.0.0+local.1.dist-info" + / "sboms" + / ("fromager.spdx.json") + ) + captured_sbom.update(json.loads(sbom_file.read_text())) repacked.touch() return "" @@ -194,6 +208,19 @@ def fake_run(cmd: list[str], **kwargs: object) -> str: # Verify the SBOM file was added to the unpacked wheel before repacking assert any("sboms/fromager.spdx.json" in c for c in captured_contents) + # The SBOM must identify the wheel with its local version + assert captured_sbom["name"] == "test-pkg-1.0.0+local.1" + wheel_entry, upstream_entry = captured_sbom["packages"] + assert wheel_entry["versionInfo"] == "1.0.0+local.1" + assert wheel_entry["externalRefs"][0]["referenceLocator"] == ( + "pkg:pypi/test-pkg@1.0.0%2Blocal.1" + ) + # The upstream entry still identifies the resolved source release + assert upstream_entry["versionInfo"] == "1.0.0" + assert upstream_entry["externalRefs"][0]["referenceLocator"] == ( + "pkg:pypi/test-pkg@1.0.0" + ) + def test_download_wheel_unquotes_url_encoded_filenames(tmp_path: pathlib.Path) -> None: """Test that download_wheel properly unquotes URL-encoded characters in filenames."""