diff --git a/docs/reference/configs.md b/docs/reference/configs.md index d1b690672..e100f0184 100644 --- a/docs/reference/configs.md +++ b/docs/reference/configs.md @@ -571,6 +571,15 @@ print(mk_skbuild_docs()) Must not be set if ``project.license-files`` is set. ``` +```{eval-rst} +.. confval:: wheel.sbom-files + :type: ``list[str]`` + + A list of Software Bill of Materials files to include in the wheel. + + Files are copied into the ``*.dist-info/sboms`` directory. +``` + ```{eval-rst} .. confval:: wheel.packages :type: ``list[str]`` diff --git a/src/scikit_build_core/build/wheel.py b/src/scikit_build_core/build/wheel.py index ea8fb2ff6..b63ff29bd 100644 --- a/src/scikit_build_core/build/wheel.py +++ b/src/scikit_build_core/build/wheel.py @@ -364,6 +364,19 @@ def _build_wheel_impl_impl( "No license files found, set wheel.license-files to [] to suppress this warning" ) + sbom_paths: list[Path] = [] + for sbom_file in settings.wheel.sbom_files or []: + sbom_path = Path(sbom_file) + if not sbom_path.is_file(): + msg = f"SBOM file not found: {sbom_file}" + raise FileNotFoundError(msg) + sbom_paths.append(sbom_path) + + for sbom_path in sbom_paths: + path = wheel_dirs["metadata"] / "sboms" / sbom_path.name + path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(sbom_path, path) + for gen in settings.generate: if gen.location == "source": contents = generate_file_contents(gen, metadata) diff --git a/src/scikit_build_core/resources/scikit-build.schema.json b/src/scikit_build_core/resources/scikit-build.schema.json index 3336845f9..7188b5805 100644 --- a/src/scikit_build_core/resources/scikit-build.schema.json +++ b/src/scikit_build_core/resources/scikit-build.schema.json @@ -237,6 +237,13 @@ }, "description": "A list of license files to include in the wheel. Supports glob patterns." }, + "sbom-files": { + "type": "array", + "items": { + "type": "string" + }, + "description": "A list of Software Bill of Materials files to include in the wheel." + }, "cmake": { "type": "boolean", "default": true, @@ -585,6 +592,9 @@ "license-files": { "$ref": "#/$defs/inherit" }, + "sbom-files": { + "$ref": "#/$defs/inherit" + }, "exclude": { "$ref": "#/$defs/inherit" }, diff --git a/src/scikit_build_core/settings/skbuild_model.py b/src/scikit_build_core/settings/skbuild_model.py index 567fa0f97..5a5551d7d 100644 --- a/src/scikit_build_core/settings/skbuild_model.py +++ b/src/scikit_build_core/settings/skbuild_model.py @@ -331,6 +331,13 @@ class WheelSettings: Must not be set if ``project.license-files`` is set. """ + sbom_files: Optional[List[str]] = None + """ + A list of Software Bill of Materials files to include in the wheel. + + Files are copied into the ``*.dist-info/sboms`` directory. + """ + cmake: bool = True """ Run CMake as part of building the wheel. diff --git a/tests/test_pyproject_pep770.py b/tests/test_pyproject_pep770.py new file mode 100644 index 000000000..bd912d980 --- /dev/null +++ b/tests/test_pyproject_pep770.py @@ -0,0 +1,46 @@ +import zipfile +from pathlib import Path + +import pytest + +from scikit_build_core.build import build_wheel + +SPDX_CONTENT = '{"spdxVersion": "SPDX-2.3", "SPDXID": "SPDXRef-DOCUMENT"}' +CYCLONEDX_CONTENT = '{"bomFormat": "CycloneDX", "specVersion": "1.5"}' + + +@pytest.mark.parametrize( + ("filename", "content"), + [ + ("project.spdx.json", SPDX_CONTENT), + ("bom.json", CYCLONEDX_CONTENT), + ], + ids=["spdx", "cyclonedx"], +) +@pytest.mark.parametrize("package", ["simple_purelib_package"], indirect=True) +@pytest.mark.usefixtures("package") +def test_pep770_wheel_sbom_files(tmp_path: Path, filename: str, content: str): + sbom_dir = Path("sbom_inputs") + sbom_dir.mkdir() + sbom_file = sbom_dir / filename + sbom_file.write_text(content, encoding="utf-8") + + dist = tmp_path / "dist" + build_wheel(str(dist), {"wheel.sbom-files": [str(sbom_file)]}) + + (wheel,) = dist.glob("purelib_example-0.0.1-*.whl") + wheel = wheel.resolve() + with zipfile.ZipFile(wheel) as zf: + sbom_path = f"purelib_example-0.0.1.dist-info/sboms/{filename}" + assert sbom_path in set(zf.namelist()) + assert zf.read(sbom_path).decode("utf-8") == content + + +@pytest.mark.parametrize("package", ["simple_purelib_package"], indirect=True) +@pytest.mark.usefixtures("package") +def test_pep770_wheel_sbom_file_missing(tmp_path: Path): + dist = tmp_path / "dist" + with pytest.raises( + FileNotFoundError, match=r"SBOM file not found: missing_sbom\.json" + ): + build_wheel(str(dist), {"wheel.sbom-files": ["missing_sbom.json"]}) diff --git a/tests/test_settings_overrides.py b/tests/test_settings_overrides.py index 9657c8476..9d0c9d3f7 100644 --- a/tests/test_settings_overrides.py +++ b/tests/test_settings_overrides.py @@ -596,6 +596,7 @@ def test_skbuild_overrides_inherit(inherit: str, tmp_path: Path): cmake.targets = ["a", "b"] wheel.packages = ["a", "b"] wheel.license-files = ["a.txt", "b.txt"] + wheel.sbom-files = ["a.spdx.json", "b.cdx.json"] wheel.exclude = ["x", "y"] install.components = ["a", "b"] cmake.define = {{a="A", b="B"}} @@ -606,6 +607,7 @@ def test_skbuild_overrides_inherit(inherit: str, tmp_path: Path): inherit.cmake.targets = "{inherit}" inherit.wheel.packages = "{inherit}" inherit.wheel.license-files = "{inherit}" + inherit.wheel.sbom-files = "{inherit}" inherit.wheel.exclude = "{inherit}" inherit.install.components = "{inherit}" inherit.cmake.define = "{inherit}" @@ -613,6 +615,7 @@ def test_skbuild_overrides_inherit(inherit: str, tmp_path: Path): cmake.targets = ["c", "d"] wheel.packages = ["c", "d"] wheel.license-files = ["c.txt", "d.txt"] + wheel.sbom-files = ["c.spdx.json", "d.cdx.json"] wheel.exclude = ["xx", "yy"] install.components = ["c", "d"] cmake.define = {{b="X", c="C"}} @@ -629,6 +632,7 @@ def test_skbuild_overrides_inherit(inherit: str, tmp_path: Path): assert settings.cmake.targets == ["c", "d"] assert settings.wheel.packages == ["c", "d"] assert settings.wheel.license_files == ["c.txt", "d.txt"] + assert settings.wheel.sbom_files == ["c.spdx.json", "d.cdx.json"] assert settings.wheel.exclude == ["xx", "yy"] assert settings.install.components == ["c", "d"] assert settings.cmake.define == {"b": "X", "c": "C"} @@ -637,6 +641,12 @@ def test_skbuild_overrides_inherit(inherit: str, tmp_path: Path): assert settings.cmake.targets == ["a", "b", "c", "d"] assert settings.wheel.packages == ["a", "b", "c", "d"] assert settings.wheel.license_files == ["a.txt", "b.txt", "c.txt", "d.txt"] + assert settings.wheel.sbom_files == [ + "a.spdx.json", + "b.cdx.json", + "c.spdx.json", + "d.cdx.json", + ] assert settings.wheel.exclude == ["x", "y", "xx", "yy"] assert settings.install.components == ["a", "b", "c", "d"] assert settings.cmake.define == {"a": "A", "b": "X", "c": "C"} @@ -645,6 +655,12 @@ def test_skbuild_overrides_inherit(inherit: str, tmp_path: Path): assert settings.cmake.targets == ["c", "d", "a", "b"] assert settings.wheel.packages == ["c", "d", "a", "b"] assert settings.wheel.license_files == ["c.txt", "d.txt", "a.txt", "b.txt"] + assert settings.wheel.sbom_files == [ + "c.spdx.json", + "d.cdx.json", + "a.spdx.json", + "b.cdx.json", + ] assert settings.wheel.exclude == ["xx", "yy", "x", "y"] assert settings.install.components == ["c", "d", "a", "b"] assert settings.cmake.define == {"a": "A", "b": "B", "c": "C"} diff --git a/tests/test_skbuild_settings.py b/tests/test_skbuild_settings.py index 48af6109c..f737181e4 100644 --- a/tests/test_skbuild_settings.py +++ b/tests/test_skbuild_settings.py @@ -187,6 +187,7 @@ def test_skbuild_settings_config_settings( "wheel.py-api": "cp39", "wheel.expand-macos-universal-tags": "True", "wheel.license-files": ["a", "b", "c"], + "wheel.sbom-files": ["sbom1.spdx.json", "sbom2.cdx.json"], "wheel.exclude": ["b", "y", "e"], "wheel.build-tag": "1foo", "backport.find-python": "0", @@ -231,6 +232,7 @@ def test_skbuild_settings_config_settings( assert settings.wheel.py_api == "cp39" assert settings.wheel.expand_macos_universal_tags assert settings.wheel.license_files == ["a", "b", "c"] + assert settings.wheel.sbom_files == ["sbom1.spdx.json", "sbom2.cdx.json"] assert settings.wheel.exclude == ["b", "y", "e"] assert settings.wheel.build_tag == "1foo" assert settings.backport.find_python == Version("0")