From 4b5baca5c04d78dc016cae236d628dba0811673c Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Fri, 14 Nov 2025 10:26:10 -0800 Subject: [PATCH 1/6] Add SBOM support - PEP770 --- backend/src/hatchling/builders/wheel.py | 25 +++++ backend/src/hatchling/metadata/core.py | 18 ++++ tests/backend/builders/test_wheel.py | 132 ++++++++++++++++++++++++ 3 files changed, 175 insertions(+) diff --git a/backend/src/hatchling/builders/wheel.py b/backend/src/hatchling/builders/wheel.py index cf7c236af..c5d1b429f 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, @@ -660,6 +665,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.metadata.core.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.exists(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 sbom_file in self.recurse_explicit_files(sbom_map): + record = archive.add_sbom_file(sbom_file) + records.write(record) + def write_metadata( self, archive: WheelArchive, @@ -682,6 +704,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/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py index 063a3d027..fccb48090 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -389,6 +389,7 @@ def __init__( self._optional_dependencies_complex: dict[str, dict[str, Requirement]] | None = None self._optional_dependencies: dict[str, list[str]] | None = None self._dynamic: list[str] | None = None + self._sbom_files: list[str] | None = None # Indicates that the version has been successfully set dynamically self._version_set: bool = False @@ -1357,6 +1358,23 @@ def dynamic(self) -> list[str]: return self._dynamic + @property + def sbom_files(self) -> list[str]: + """ + https://peps.python.org/pep-0770/ + """ + sbom_files = self.config.get("sbom-files", []) + if not isinstance(sbom_files, list): + message = "Field `project.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 `project.sbom-files` must be a string" + raise TypeError(message) + + return sbom_files + def add_known_classifiers(self, classifiers: list[str]) -> None: self._extra_classifiers.update(classifiers) diff --git a/tests/backend/builders/test_wheel.py b/tests/backend/builders/test_wheel.py index b670b69dd..19c28ebb6 100644 --- a/tests/backend/builders/test_wheel.py +++ b/tests/backend/builders/test_wheel.py @@ -3753,3 +3753,135 @@ 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, isolation, temp_dir): + config = { + "project": { + "name": "my-app", + "version": "0.0.1", + "sbom-files": ["my-sbom.spdx.json"], + }, + } + builder = WheelBuilder(str(isolation), config=config) + + # Create SBOM file + sbom_file = isolation / "my-sbom.spdx.json" + sbom_file.write_text('{"spdxVersion": "SPDX-2.3"}') + + # Create minimal package + (isolation / "my_app.py").write_text("def main(): pass") + + build_data = builder.get_default_build_data() + artifact = builder.build_standard(str(temp_dir), **build_data) + + # Extract and verify + extraction_dir = temp_dir / "_archive" + extraction_dir.mkdir() + extract_zip(artifact, extraction_dir) + + # Verify SBOM in dist-info/sboms/ + sbom_path = extraction_dir / "my_app-0.0.1.dist-info" / "sboms" / "my-sbom.spdx.json" + assert sbom_path.is_file() + assert "SPDX-2.3" in sbom_path.read_text() + + # Verify in RECORD + record_file = extraction_dir / "my_app-0.0.1.dist-info" / "RECORD" + record_contents = record_file.read_text() + assert "my_app-0.0.1.dist-info/sboms/my-sbom.spdx.json" in record_contents + + def test_multiple_sbom_files(self, isolation, temp_dir): + config = { + "project": { + "name": "my-app", + "version": "0.0.1", + "sbom-files": ["sbom1.spdx.json", "sbom2.cyclonedx.json"], + }, + } + builder = WheelBuilder(str(isolation), config=config) + + # Create SBOM files + (isolation / "sbom1.spdx.json").write_text('{"spdxVersion": "SPDX-2.3"}') + (isolation / "sbom2.cyclonedx.json").write_text('{"bomFormat": "CycloneDX"}') + + (isolation / "my_app.py").write_text("def main(): pass") + + build_data = builder.get_default_build_data() + artifact = builder.build_standard(str(temp_dir), **build_data) + + extraction_dir = temp_dir / "_archive" + extraction_dir.mkdir() + extract_zip(artifact, extraction_dir) + + # Verify both SBOMs + sbom1 = extraction_dir / "my_app-0.0.1.dist-info" / "sboms" / "sbom1.spdx.json" + sbom2 = extraction_dir / "my_app-0.0.1.dist-info" / "sboms" / "sbom2.cyclonedx.json" + assert sbom1.is_file() + assert sbom2.is_file() + assert "SPDX-2.3" in sbom1.read_text() + assert "CycloneDX" in sbom2.read_text() + + def test_no_sbom_files(self, isolation, temp_dir): + config = { + "project": { + "name": "my-app", + "version": "0.0.1", + }, + } + builder = WheelBuilder(str(isolation), config=config) + + (isolation / "my_app.py").write_text("def main(): pass") + + build_data = builder.get_default_build_data() + artifact = builder.build_standard(str(temp_dir), **build_data) + + extraction_dir = temp_dir / "_archive" + extraction_dir.mkdir() + extract_zip(artifact, extraction_dir) + + # Verify no sboms directory + sboms_dir = extraction_dir / "my_app-0.0.1.dist-info" / "sboms" + assert not sboms_dir.exists() + + def test_sbom_file_not_found(self, isolation, temp_dir): + config = { + "project": { + "name": "my-app", + "version": "0.0.1", + "sbom-files": ["nonexistent.spdx.json"], + }, + } + builder = WheelBuilder(str(isolation), config=config) + + (isolation / "my_app.py").write_text("def main(): pass") + + build_data = builder.get_default_build_data() + with pytest.raises(FileNotFoundError, match="nonexistent.spdx.json"): + builder.build_standard(str(temp_dir), **build_data) + + def test_sbom_files_invalid_type(self, isolation): + config = { + "project": { + "name": "my-app", + "version": "0.0.1", + "sbom-files": "not-a-list", + }, + } + + builder = WheelBuilder(str(isolation), config=config) + with pytest.raises(TypeError, match="Field `project.sbom-files` must be an array"): + _ = builder.metadata.core.sbom_files + + def test_sbom_file_invalid_item(self, isolation): + config = { + "project": { + "name": "my-app", + "version": "0.0.1", + "sbom-files": [123], + }, + } + + builder = WheelBuilder(str(isolation), config=config) + with pytest.raises(TypeError, match="SBOM file #1 in `project.sbom-files` must be a string"): + _ = builder.metadata.core.sbom_files From 49e5f2ada598a0764bdb1cbe62c661c54766b623 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Fri, 14 Nov 2025 10:52:20 -0800 Subject: [PATCH 2/6] Fix bad variable name shadowing --- backend/src/hatchling/builders/wheel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/hatchling/builders/wheel.py b/backend/src/hatchling/builders/wheel.py index c5d1b429f..b0ebab47d 100644 --- a/backend/src/hatchling/builders/wheel.py +++ b/backend/src/hatchling/builders/wheel.py @@ -678,8 +678,8 @@ def add_sboms(self, archive: WheelArchive, records: RecordFile) -> None: sbom_map = {os.path.join(self.root, sbom_file): os.path.basename(sbom_file) for sbom_file in sbom_files} - for sbom_file in self.recurse_explicit_files(sbom_map): - record = archive.add_sbom_file(sbom_file) + for included_file in self.recurse_explicit_files(sbom_map): + record = archive.add_sbom_file(included_file) records.write(record) def write_metadata( From 5fdf12e9938624adf6d21b62b9182af289c41f22 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Sat, 15 Nov 2025 09:31:54 -0800 Subject: [PATCH 3/6] Move configuration for sboms out of core metadata, add wheel templates and nested directories test --- backend/src/hatchling/builders/wheel.py | 21 +- backend/src/hatchling/metadata/core.py | 18 -- docs/plugins/builder/wheel.md | 18 +- tests/backend/builders/test_wheel.py | 217 ++++++++++-------- .../templates/wheel/standard_default_sbom.py | 54 +++++ 5 files changed, 211 insertions(+), 117 deletions(-) create mode 100644 tests/helpers/templates/wheel/standard_default_sbom.py diff --git a/backend/src/hatchling/builders/wheel.py b/backend/src/hatchling/builders/wheel.py index b0ebab47d..7fc191dd8 100644 --- a/backend/src/hatchling/builders/wheel.py +++ b/backend/src/hatchling/builders/wheel.py @@ -390,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: @@ -666,7 +685,7 @@ def add_shared_scripts(self, archive: WheelArchive, records: RecordFile, build_d records.write(record) def add_sboms(self, archive: WheelArchive, records: RecordFile) -> None: - sbom_files = self.metadata.core.sbom_files + sbom_files = self.config.sbom_files if not sbom_files: return diff --git a/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py index fccb48090..063a3d027 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -389,7 +389,6 @@ def __init__( self._optional_dependencies_complex: dict[str, dict[str, Requirement]] | None = None self._optional_dependencies: dict[str, list[str]] | None = None self._dynamic: list[str] | None = None - self._sbom_files: list[str] | None = None # Indicates that the version has been successfully set dynamically self._version_set: bool = False @@ -1358,23 +1357,6 @@ def dynamic(self) -> list[str]: return self._dynamic - @property - def sbom_files(self) -> list[str]: - """ - https://peps.python.org/pep-0770/ - """ - sbom_files = self.config.get("sbom-files", []) - if not isinstance(sbom_files, list): - message = "Field `project.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 `project.sbom-files` must be a string" - raise TypeError(message) - - return sbom_files - def add_known_classifiers(self, classifiers: list[str]) -> None: self._extra_classifiers.update(classifiers) diff --git a/docs/plugins/builder/wheel.md b/docs/plugins/builder/wheel.md index a538e8edb..0220dd862 100644 --- a/docs/plugins/builder/wheel.md +++ b/docs/plugins/builder/wheel.md @@ -14,15 +14,16 @@ The builder plugin name is `wheel`. ## Options -| Option | Default | Description | -| --- | --- | --- | -| `core-metadata-version` | `"2.4"` | The version of [core metadata](https://packaging.python.org/specifications/core-metadata/) to use | -| `shared-data` | | A mapping similar to the [forced inclusion](../../config/build.md#forced-inclusion) option corresponding to the `data` subdirectory within the standard [data directory](https://packaging.python.org/en/latest/specifications/binary-distribution-format/#the-data-directory) that will be installed globally in a given Python environment, usually under `#!python sys.prefix` | +| Option | Default | Description | +| --- | --- |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `core-metadata-version` | `"2.4"` | The version of [core metadata](https://packaging.python.org/specifications/core-metadata/) to use | +| `shared-data` | | A mapping similar to the [forced inclusion](../../config/build.md#forced-inclusion) option corresponding to the `data` subdirectory within the standard [data directory](https://packaging.python.org/en/latest/specifications/binary-distribution-format/#the-data-directory) that will be installed globally in a given Python environment, usually under `#!python sys.prefix` | | `shared-scripts` | | A mapping similar to the [forced inclusion](../../config/build.md#forced-inclusion) option corresponding to the `scripts` subdirectory within the standard [data directory](https://packaging.python.org/en/latest/specifications/binary-distribution-format/#the-data-directory) that will be installed in a given Python environment, usually under `Scripts` on Windows or `bin` otherwise, and would normally be available on PATH | -| `extra-metadata` | | A mapping similar to the [forced inclusion](../../config/build.md#forced-inclusion) option corresponding to extra [metadata](https://peps.python.org/pep-0427/#the-dist-info-directory) that will be shipped in a directory named `extra_metadata` | -| `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 | +| `extra-metadata` | | A mapping similar to the [forced inclusion](../../config/build.md#forced-inclusion) option corresponding to extra [metadata](https://peps.python.org/pep-0427/#the-dist-info-directory) that will be shipped in a directory named `extra_metadata` | +| `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 Software Bill of Materials(sbom) file locations to be included in the wheel. | ## Versions @@ -60,3 +61,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 19c28ebb6..f0303face 100644 --- a/tests/backend/builders/test_wheel.py +++ b/tests/backend/builders/test_wheel.py @@ -3756,132 +3756,169 @@ def test_file_permissions_normalized(self, hatch, temp_dir, config_file): class TestSBOMFiles: - def test_single_sbom_file(self, isolation, temp_dir): + 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", - "version": "0.0.1", - "sbom-files": ["my-sbom.spdx.json"], + "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(isolation), config=config) + builder = WheelBuilder(str(project_path), config=config) - # Create SBOM file - sbom_file = isolation / "my-sbom.spdx.json" - sbom_file.write_text('{"spdxVersion": "SPDX-2.3"}') + 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() - # Create minimal package - (isolation / "my_app.py").write_text("def main(): pass") + with zipfile.ZipFile(str(artifacts[0]), "r") as zip_archive: + zip_archive.extractall(str(extraction_directory)) - build_data = builder.get_default_build_data() - artifact = builder.build_standard(str(temp_dir), **build_data) + 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) - # Extract and verify - extraction_dir = temp_dir / "_archive" - extraction_dir.mkdir() - extract_zip(artifact, extraction_dir) + 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") - # Verify SBOM in dist-info/sboms/ - sbom_path = extraction_dir / "my_app-0.0.1.dist-info" / "sboms" / "my-sbom.spdx.json" - assert sbom_path.is_file() - assert "SPDX-2.3" in sbom_path.read_text() + assert result.exit_code == 0, result.output - # Verify in RECORD - record_file = extraction_dir / "my_app-0.0.1.dist-info" / "RECORD" - record_contents = record_file.read_text() - assert "my_app-0.0.1.dist-info/sboms/my-sbom.spdx.json" in record_contents + 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"}') - def test_multiple_sbom_files(self, isolation, temp_dir): config = { - "project": { - "name": "my-app", - "version": "0.0.1", - "sbom-files": ["sbom1.spdx.json", "sbom2.cyclonedx.json"], + "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(isolation), config=config) + builder = WheelBuilder(str(project_path), config=config) - # Create SBOM files - (isolation / "sbom1.spdx.json").write_text('{"spdxVersion": "SPDX-2.3"}') - (isolation / "sbom2.cyclonedx.json").write_text('{"bomFormat": "CycloneDX"}') + build_path = project_path / "dist" + build_path.mkdir() - (isolation / "my_app.py").write_text("def main(): pass") + with project_path.as_cwd(): + artifacts = list(builder.build(directory=str(build_path))) - build_data = builder.get_default_build_data() - artifact = builder.build_standard(str(temp_dir), **build_data) + assert len(artifacts) == 1 - extraction_dir = temp_dir / "_archive" - extraction_dir.mkdir() - extract_zip(artifact, extraction_dir) + extraction_directory = temp_dir / "_archive" + extraction_directory.mkdir() - # Verify both SBOMs - sbom1 = extraction_dir / "my_app-0.0.1.dist-info" / "sboms" / "sbom1.spdx.json" - sbom2 = extraction_dir / "my_app-0.0.1.dist-info" / "sboms" / "sbom2.cyclonedx.json" - assert sbom1.is_file() - assert sbom2.is_file() - assert "SPDX-2.3" in sbom1.read_text() - assert "CycloneDX" in sbom2.read_text() + with zipfile.ZipFile(str(artifacts[0]), "r") as zip_archive: + zip_archive.extractall(str(extraction_directory)) - def test_no_sbom_files(self, isolation, temp_dir): - config = { - "project": { - "name": "my-app", - "version": "0.0.1", - }, - } - builder = WheelBuilder(str(isolation), config=config) + 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) - (isolation / "my_app.py").write_text("def main(): pass") + def test_nested_sbom_file(self, hatch, helpers, temp_dir, config_file): + config_file.model.template.plugins["default"]["tests"] = False + config_file.save() - build_data = builder.get_default_build_data() - artifact = builder.build_standard(str(temp_dir), **build_data) + with temp_dir.as_cwd(): + result = hatch("new", "My.App") - extraction_dir = temp_dir / "_archive" - extraction_dir.mkdir() - extract_zip(artifact, extraction_dir) + assert result.exit_code == 0, result.output - # Verify no sboms directory - sboms_dir = extraction_dir / "my_app-0.0.1.dist-info" / "sboms" - assert not sboms_dir.exists() + 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"}') - def test_sbom_file_not_found(self, isolation, temp_dir): config = { - "project": { - "name": "my-app", - "version": "0.0.1", - "sbom-files": ["nonexistent.spdx.json"], + "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(isolation), config=config) + builder = WheelBuilder(str(project_path), config=config) - (isolation / "my_app.py").write_text("def main(): pass") + build_path = project_path / "dist" + build_path.mkdir() - build_data = builder.get_default_build_data() - with pytest.raises(FileNotFoundError, match="nonexistent.spdx.json"): - builder.build_standard(str(temp_dir), **build_data) + 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", - "sbom-files": "not-a-list", - }, + "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 `project.sbom-files` must be an array"): - _ = builder.metadata.core.sbom_files + + 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", - "sbom-files": [123], - }, + "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 `project.sbom-files` must be a string"): - _ = builder.metadata.core.sbom_files + + 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 From 46d8a0570ff0e1e4c546b365f22c1489e89d25d0 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Sat, 15 Nov 2025 13:47:52 -0800 Subject: [PATCH 4/6] Fix formatting changes in doc. --- docs/plugins/builder/wheel.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/plugins/builder/wheel.md b/docs/plugins/builder/wheel.md index 0220dd862..5cd14fdbd 100644 --- a/docs/plugins/builder/wheel.md +++ b/docs/plugins/builder/wheel.md @@ -14,16 +14,19 @@ The builder plugin name is `wheel`. ## Options -| Option | Default | Description | -| --- | --- |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `core-metadata-version` | `"2.4"` | The version of [core metadata](https://packaging.python.org/specifications/core-metadata/) to use | -| `shared-data` | | A mapping similar to the [forced inclusion](../../config/build.md#forced-inclusion) option corresponding to the `data` subdirectory within the standard [data directory](https://packaging.python.org/en/latest/specifications/binary-distribution-format/#the-data-directory) that will be installed globally in a given Python environment, usually under `#!python sys.prefix` | +## Options + +| Option | Default | Description | +| --- | --- | --- | +| `core-metadata-version` | `"2.4"` | The version of [core metadata](https://packaging.python.org/specifications/core-metadata/) to use | +| `shared-data` | | A mapping similar to the [forced inclusion](../../config/build.md#forced-inclusion) option corresponding to the `data` subdirectory within the standard [data directory](https://packaging.python.org/en/latest/specifications/binary-distribution-format/#the-data-directory) that will be installed globally in a given Python environment, usually under `#!python sys.prefix` | | `shared-scripts` | | A mapping similar to the [forced inclusion](../../config/build.md#forced-inclusion) option corresponding to the `scripts` subdirectory within the standard [data directory](https://packaging.python.org/en/latest/specifications/binary-distribution-format/#the-data-directory) that will be installed in a given Python environment, usually under `Scripts` on Windows or `bin` otherwise, and would normally be available on PATH | -| `extra-metadata` | | A mapping similar to the [forced inclusion](../../config/build.md#forced-inclusion) option corresponding to extra [metadata](https://peps.python.org/pep-0427/#the-dist-info-directory) that will be shipped in a directory named `extra_metadata` | -| `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 Software Bill of Materials(sbom) file locations to be included in the wheel. | +| `extra-metadata` | | A mapping similar to the [forced inclusion](../../config/build.md#forced-inclusion) option corresponding to extra [metadata](https://peps.python.org/pep-0427/#the-dist-info-directory) that will be shipped in a directory named `extra_metadata` | +| `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 From e19afb5e824c723576ef3a8560d01cb55407b675 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Sat, 15 Nov 2025 13:49:03 -0800 Subject: [PATCH 5/6] Fix added header --- docs/plugins/builder/wheel.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/plugins/builder/wheel.md b/docs/plugins/builder/wheel.md index 5cd14fdbd..0fb5e7513 100644 --- a/docs/plugins/builder/wheel.md +++ b/docs/plugins/builder/wheel.md @@ -14,8 +14,6 @@ The builder plugin name is `wheel`. ## Options -## Options - | Option | Default | Description | | --- | --- | --- | | `core-metadata-version` | `"2.4"` | The version of [core metadata](https://packaging.python.org/specifications/core-metadata/) to use | From ab4304060e66c32042a9c5d9c64ac40c5d81aead Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Sun, 16 Nov 2025 09:46:18 -0800 Subject: [PATCH 6/6] Update backend/src/hatchling/builders/wheel.py Co-authored-by: Ofek Lev --- backend/src/hatchling/builders/wheel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/hatchling/builders/wheel.py b/backend/src/hatchling/builders/wheel.py index e1878d4f0..d2d7654bc 100644 --- a/backend/src/hatchling/builders/wheel.py +++ b/backend/src/hatchling/builders/wheel.py @@ -691,7 +691,7 @@ def add_sboms(self, archive: WheelArchive, records: RecordFile) -> None: for sbom_file in sbom_files: sbom_path = os.path.join(self.root, sbom_file) - if not os.path.exists(sbom_path): + if not os.path.isfile(sbom_path): message = f"SBOM file not found: {sbom_file}" raise FileNotFoundError(message)