Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions backend/src/hatchling/builders/wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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)

Expand Down
3 changes: 3 additions & 0 deletions docs/plugins/builder/wheel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<br><br>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

Expand Down Expand Up @@ -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`. |
169 changes: 169 additions & 0 deletions tests/backend/builders/test_wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
54 changes: 54 additions & 0 deletions tests/helpers/templates/wheel/standard_default_sbom.py
Original file line number Diff line number Diff line change
@@ -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