diff --git a/backend/src/hatchling/builders/wheel.py b/backend/src/hatchling/builders/wheel.py index 0a1b832f5..d2d7654bc 100644 --- a/backend/src/hatchling/builders/wheel.py +++ b/backend/src/hatchling/builders/wheel.py @@ -151,6 +151,11 @@ def add_extra_metadata_file(self, extra_metadata_file: IncludedFile) -> tuple[st ) return self.add_file(extra_metadata_file) + def add_sbom_file(self, sbom_file: IncludedFile) -> tuple[str, str, str]: + """Add SBOM file to .dist-info/sboms/ directory.""" + sbom_file.distribution_path = f"{self.metadata_directory}/sboms/{sbom_file.distribution_path}" + return self.add_file(sbom_file) + def write_file( self, relative_path: str, @@ -385,6 +390,25 @@ def extra_metadata(self) -> dict[str, str]: return self.__extra_metadata + @property + def sbom_files(self) -> list[str]: + """ + https://peps.python.org/pep-0770/ + """ + sbom_files = self.target_config.get("sbom-files", []) + if not isinstance(sbom_files, list): + message = f"Field `tool.hatch.build.targets.{self.plugin_name}.sbom-files` must be an array" + raise TypeError(message) + + for i, sbom_file in enumerate(sbom_files, 1): + if not isinstance(sbom_file, str): + message = ( + f"SBOM file #{i} in field `tool.hatch.build.targets.{self.plugin_name}.sbom-files` must be a string" + ) + raise TypeError(message) + + return sbom_files + @property def strict_naming(self) -> bool: if self.__strict_naming is None: @@ -660,6 +684,23 @@ def add_shared_scripts(self, archive: WheelArchive, records: RecordFile, build_d record = archive.write_shared_script(shared_script, content.getvalue()) records.write(record) + def add_sboms(self, archive: WheelArchive, records: RecordFile) -> None: + sbom_files = self.config.sbom_files + if not sbom_files: + return + + for sbom_file in sbom_files: + sbom_path = os.path.join(self.root, sbom_file) + if not os.path.isfile(sbom_path): + message = f"SBOM file not found: {sbom_file}" + raise FileNotFoundError(message) + + sbom_map = {os.path.join(self.root, sbom_file): os.path.basename(sbom_file) for sbom_file in sbom_files} + + for included_file in self.recurse_explicit_files(sbom_map): + record = archive.add_sbom_file(included_file) + records.write(record) + def write_metadata( self, archive: WheelArchive, @@ -682,6 +723,9 @@ def write_metadata( # licenses/ self.add_licenses(archive, records) + # sboms/ + self.add_sboms(archive, records) + # extra_metadata/ - write last self.add_extra_metadata(archive, records, build_data) diff --git a/docs/plugins/builder/wheel.md b/docs/plugins/builder/wheel.md index a538e8edb..0fb5e7513 100644 --- a/docs/plugins/builder/wheel.md +++ b/docs/plugins/builder/wheel.md @@ -23,6 +23,8 @@ The builder plugin name is `wheel`. | `strict-naming` | `true` | Whether or not file names should contain the normalized version of the project name | | `macos-max-compat` | `false` | Whether or not on macOS, when build hooks have set the `infer_tag` [build data](#build-data), the wheel name should signal broad support rather than specific versions for newer SDK versions.

Note: This option will eventually be removed. | | `bypass-selection` | `false` | Whether or not to suppress the error when one has not defined any file selection options and all heuristics have failed to determine what to ship | +| `sbom-files` | | A list of paths to [Software Bill of Materials](https://peps.python.org/pep-0770/) files that will be included in the `.dist-info/sboms/` directory of the wheel | + ## Versions @@ -60,3 +62,4 @@ This is data that can be modified by [build hooks](../build-hook/reference.md). | `shared_scripts` | | Additional [`shared-scripts`](#options) entries, which take precedence in case of conflicts | | `extra_metadata` | | Additional [`extra-metadata`](#options) entries, which take precedence in case of conflicts | | `force_include_editable` | | Similar to the [`force_include` option](../build-hook/reference.md#build-data) but specifically for the `editable` [version](#versions) and takes precedence | +| `sbom_files` | | This is a list of the sbom files that should be included under `.dist-info/sboms`. | diff --git a/tests/backend/builders/test_wheel.py b/tests/backend/builders/test_wheel.py index b670b69dd..f0303face 100644 --- a/tests/backend/builders/test_wheel.py +++ b/tests/backend/builders/test_wheel.py @@ -3753,3 +3753,172 @@ def test_file_permissions_normalized(self, hatch, temp_dir, config_file): # we assert that at minimum 644 is set, based on the platform (e.g.) # windows it may be higher assert file_stat.st_mode & 0o644 + + +class TestSBOMFiles: + def test_single_sbom_file(self, hatch, helpers, temp_dir, config_file): + config_file.model.template.plugins["default"]["tests"] = False + config_file.save() + + with temp_dir.as_cwd(): + result = hatch("new", "My.App") + + assert result.exit_code == 0, result.output + + project_path = temp_dir / "my-app" + sbom_file = project_path / "my-sbom.spdx.json" + sbom_file.write_text('{"spdxVersion": "SPDX-2.3"}') + + config = { + "project": {"name": "My.App", "dynamic": ["version"]}, + "tool": { + "hatch": { + "version": {"path": "src/my_app/__about__.py"}, + "build": {"targets": {"wheel": {"sbom-files": ["my-sbom.spdx.json"]}}}, + } + }, + } + builder = WheelBuilder(str(project_path), config=config) + + build_path = project_path / "dist" + build_path.mkdir() + + with project_path.as_cwd(): + artifacts = list(builder.build(directory=str(build_path))) + + assert len(artifacts) == 1 + + extraction_directory = temp_dir / "_archive" + extraction_directory.mkdir() + + with zipfile.ZipFile(str(artifacts[0]), "r") as zip_archive: + zip_archive.extractall(str(extraction_directory)) + + metadata_directory = f"{builder.project_id}.dist-info" + expected_files = helpers.get_template_files( + "wheel.standard_default_sbom", + "My.App", + metadata_directory=metadata_directory, + sbom_files=[("my-sbom.spdx.json", '{"spdxVersion": "SPDX-2.3"}')], + ) + helpers.assert_files(extraction_directory, expected_files) + + def test_multiple_sbom_files(self, hatch, helpers, temp_dir, config_file): + config_file.model.template.plugins["default"]["tests"] = False + config_file.save() + + with temp_dir.as_cwd(): + result = hatch("new", "My.App") + + assert result.exit_code == 0, result.output + + project_path = temp_dir / "my-app" + (project_path / "sbom1.spdx.json").write_text('{"spdxVersion": "SPDX-2.3"}') + (project_path / "sbom2.cyclonedx.json").write_text('{"bomFormat": "CycloneDX"}') + + config = { + "project": {"name": "My.App", "dynamic": ["version"]}, + "tool": { + "hatch": { + "version": {"path": "src/my_app/__about__.py"}, + "build": {"targets": {"wheel": {"sbom-files": ["sbom1.spdx.json", "sbom2.cyclonedx.json"]}}}, + } + }, + } + builder = WheelBuilder(str(project_path), config=config) + + build_path = project_path / "dist" + build_path.mkdir() + + with project_path.as_cwd(): + artifacts = list(builder.build(directory=str(build_path))) + + assert len(artifacts) == 1 + + extraction_directory = temp_dir / "_archive" + extraction_directory.mkdir() + + with zipfile.ZipFile(str(artifacts[0]), "r") as zip_archive: + zip_archive.extractall(str(extraction_directory)) + + metadata_directory = f"{builder.project_id}.dist-info" + expected_files = helpers.get_template_files( + "wheel.standard_default_sbom", + "My.App", + metadata_directory=metadata_directory, + sbom_files=[ + ("sbom1.spdx.json", '{"spdxVersion": "SPDX-2.3"}'), + ("sbom2.cyclonedx.json", '{"bomFormat": "CycloneDX"}'), + ], + ) + helpers.assert_files(extraction_directory, expected_files) + + def test_nested_sbom_file(self, hatch, helpers, temp_dir, config_file): + config_file.model.template.plugins["default"]["tests"] = False + config_file.save() + + with temp_dir.as_cwd(): + result = hatch("new", "My.App") + + assert result.exit_code == 0, result.output + + project_path = temp_dir / "my-app" + sbom_dir = project_path / "sboms" + sbom_dir.mkdir() + (sbom_dir / "vendor.spdx.json").write_text('{"spdxVersion": "SPDX-2.3"}') + + config = { + "project": {"name": "My.App", "dynamic": ["version"]}, + "tool": { + "hatch": { + "version": {"path": "src/my_app/__about__.py"}, + "build": {"targets": {"wheel": {"sbom-files": ["sboms/vendor.spdx.json"]}}}, + } + }, + } + builder = WheelBuilder(str(project_path), config=config) + + build_path = project_path / "dist" + build_path.mkdir() + + with project_path.as_cwd(): + artifacts = list(builder.build(directory=str(build_path))) + + assert len(artifacts) == 1 + + extraction_directory = temp_dir / "_archive" + extraction_directory.mkdir() + + with zipfile.ZipFile(str(artifacts[0]), "r") as zip_archive: + zip_archive.extractall(str(extraction_directory)) + + metadata_directory = f"{builder.project_id}.dist-info" + expected_files = helpers.get_template_files( + "wheel.standard_default_sbom", + "My.App", + metadata_directory=metadata_directory, + sbom_files=[("vendor.spdx.json", '{"spdxVersion": "SPDX-2.3"}')], + ) + helpers.assert_files(extraction_directory, expected_files) + + def test_sbom_files_invalid_type(self, isolation): + config = { + "project": {"name": "my-app", "version": "0.0.1"}, + "tool": {"hatch": {"build": {"targets": {"wheel": {"sbom-files": "not-a-list"}}}}}, + } + builder = WheelBuilder(str(isolation), config=config) + + with pytest.raises(TypeError, match="Field `tool.hatch.build.targets.wheel.sbom-files` must be an array"): + _ = builder.config.sbom_files + + def test_sbom_file_invalid_item(self, isolation): + config = { + "project": {"name": "my-app", "version": "0.0.1"}, + "tool": {"hatch": {"build": {"targets": {"wheel": {"sbom-files": [123]}}}}}, + } + builder = WheelBuilder(str(isolation), config=config) + + with pytest.raises( + TypeError, match="SBOM file #1 in field `tool.hatch.build.targets.wheel.sbom-files` must be a string" + ): + _ = builder.config.sbom_files diff --git a/tests/helpers/templates/wheel/standard_default_sbom.py b/tests/helpers/templates/wheel/standard_default_sbom.py new file mode 100644 index 000000000..62a75c5a3 --- /dev/null +++ b/tests/helpers/templates/wheel/standard_default_sbom.py @@ -0,0 +1,54 @@ +from hatch.template import File +from hatch.utils.fs import Path +from hatchling.__about__ import __version__ +from hatchling.metadata.spec import DEFAULT_METADATA_VERSION + +from ..new.default import get_files as get_template_files +from .utils import update_record_file_contents + + +def get_files(**kwargs): + metadata_directory = kwargs.get("metadata_directory", "") + sbom_files = kwargs.get("sbom_files", []) + + files = [] + for f in get_template_files(**kwargs): + if str(f.path) == "LICENSE.txt": + files.append(File(Path(metadata_directory, "licenses", f.path), f.contents)) + + if f.path.parts[0] != "src": + continue + + files.append(File(Path(*f.path.parts[1:]), f.contents)) + + # Add SBOM files + for sbom_path, sbom_content in sbom_files: + files.append(File(Path(metadata_directory, "sboms", sbom_path), sbom_content)) + + files.extend(( + File( + Path(metadata_directory, "WHEEL"), + f"""\ +Wheel-Version: 1.0 +Generator: hatchling {__version__} +Root-Is-Purelib: true +Tag: py2-none-any +Tag: py3-none-any +""", + ), + File( + Path(metadata_directory, "METADATA"), + f"""\ +Metadata-Version: {DEFAULT_METADATA_VERSION} +Name: {kwargs["project_name"]} +Version: 0.0.1 +License-File: LICENSE.txt +""", + ), + )) + + record_file = File(Path(metadata_directory, "RECORD"), "") + update_record_file_contents(record_file, files) + files.append(record_file) + + return files