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