Skip to content

Commit 8695db4

Browse files
cjames23ofek
andauthored
Add SBOM support - PEP770 (#2098)
* Add SBOM support - PEP770 * Fix bad variable name shadowing * Move configuration for sboms out of core metadata, add wheel templates and nested directories test * Fix formatting changes in doc. * Fix added header * Update backend/src/hatchling/builders/wheel.py Co-authored-by: Ofek Lev <ofekmeister@gmail.com> --------- Co-authored-by: Ofek Lev <ofekmeister@gmail.com>
1 parent b0609b3 commit 8695db4

File tree

4 files changed

+270
-0
lines changed

4 files changed

+270
-0
lines changed

backend/src/hatchling/builders/wheel.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,11 @@ def add_extra_metadata_file(self, extra_metadata_file: IncludedFile) -> tuple[st
151151
)
152152
return self.add_file(extra_metadata_file)
153153

154+
def add_sbom_file(self, sbom_file: IncludedFile) -> tuple[str, str, str]:
155+
"""Add SBOM file to .dist-info/sboms/ directory."""
156+
sbom_file.distribution_path = f"{self.metadata_directory}/sboms/{sbom_file.distribution_path}"
157+
return self.add_file(sbom_file)
158+
154159
def write_file(
155160
self,
156161
relative_path: str,
@@ -385,6 +390,25 @@ def extra_metadata(self) -> dict[str, str]:
385390

386391
return self.__extra_metadata
387392

393+
@property
394+
def sbom_files(self) -> list[str]:
395+
"""
396+
https://peps.python.org/pep-0770/
397+
"""
398+
sbom_files = self.target_config.get("sbom-files", [])
399+
if not isinstance(sbom_files, list):
400+
message = f"Field `tool.hatch.build.targets.{self.plugin_name}.sbom-files` must be an array"
401+
raise TypeError(message)
402+
403+
for i, sbom_file in enumerate(sbom_files, 1):
404+
if not isinstance(sbom_file, str):
405+
message = (
406+
f"SBOM file #{i} in field `tool.hatch.build.targets.{self.plugin_name}.sbom-files` must be a string"
407+
)
408+
raise TypeError(message)
409+
410+
return sbom_files
411+
388412
@property
389413
def strict_naming(self) -> bool:
390414
if self.__strict_naming is None:
@@ -660,6 +684,23 @@ def add_shared_scripts(self, archive: WheelArchive, records: RecordFile, build_d
660684
record = archive.write_shared_script(shared_script, content.getvalue())
661685
records.write(record)
662686

687+
def add_sboms(self, archive: WheelArchive, records: RecordFile) -> None:
688+
sbom_files = self.config.sbom_files
689+
if not sbom_files:
690+
return
691+
692+
for sbom_file in sbom_files:
693+
sbom_path = os.path.join(self.root, sbom_file)
694+
if not os.path.isfile(sbom_path):
695+
message = f"SBOM file not found: {sbom_file}"
696+
raise FileNotFoundError(message)
697+
698+
sbom_map = {os.path.join(self.root, sbom_file): os.path.basename(sbom_file) for sbom_file in sbom_files}
699+
700+
for included_file in self.recurse_explicit_files(sbom_map):
701+
record = archive.add_sbom_file(included_file)
702+
records.write(record)
703+
663704
def write_metadata(
664705
self,
665706
archive: WheelArchive,
@@ -682,6 +723,9 @@ def write_metadata(
682723
# licenses/
683724
self.add_licenses(archive, records)
684725

726+
# sboms/
727+
self.add_sboms(archive, records)
728+
685729
# extra_metadata/ - write last
686730
self.add_extra_metadata(archive, records, build_data)
687731

docs/plugins/builder/wheel.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ The builder plugin name is `wheel`.
2323
| `strict-naming` | `true` | Whether or not file names should contain the normalized version of the project name |
2424
| `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. |
2525
| `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 |
26+
| `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 |
27+
2628

2729
## Versions
2830

@@ -60,3 +62,4 @@ This is data that can be modified by [build hooks](../build-hook/reference.md).
6062
| `shared_scripts` | | Additional [`shared-scripts`](#options) entries, which take precedence in case of conflicts |
6163
| `extra_metadata` | | Additional [`extra-metadata`](#options) entries, which take precedence in case of conflicts |
6264
| `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 |
65+
| `sbom_files` | | This is a list of the sbom files that should be included under `.dist-info/sboms`. |

tests/backend/builders/test_wheel.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3753,3 +3753,172 @@ def test_file_permissions_normalized(self, hatch, temp_dir, config_file):
37533753
# we assert that at minimum 644 is set, based on the platform (e.g.)
37543754
# windows it may be higher
37553755
assert file_stat.st_mode & 0o644
3756+
3757+
3758+
class TestSBOMFiles:
3759+
def test_single_sbom_file(self, hatch, helpers, temp_dir, config_file):
3760+
config_file.model.template.plugins["default"]["tests"] = False
3761+
config_file.save()
3762+
3763+
with temp_dir.as_cwd():
3764+
result = hatch("new", "My.App")
3765+
3766+
assert result.exit_code == 0, result.output
3767+
3768+
project_path = temp_dir / "my-app"
3769+
sbom_file = project_path / "my-sbom.spdx.json"
3770+
sbom_file.write_text('{"spdxVersion": "SPDX-2.3"}')
3771+
3772+
config = {
3773+
"project": {"name": "My.App", "dynamic": ["version"]},
3774+
"tool": {
3775+
"hatch": {
3776+
"version": {"path": "src/my_app/__about__.py"},
3777+
"build": {"targets": {"wheel": {"sbom-files": ["my-sbom.spdx.json"]}}},
3778+
}
3779+
},
3780+
}
3781+
builder = WheelBuilder(str(project_path), config=config)
3782+
3783+
build_path = project_path / "dist"
3784+
build_path.mkdir()
3785+
3786+
with project_path.as_cwd():
3787+
artifacts = list(builder.build(directory=str(build_path)))
3788+
3789+
assert len(artifacts) == 1
3790+
3791+
extraction_directory = temp_dir / "_archive"
3792+
extraction_directory.mkdir()
3793+
3794+
with zipfile.ZipFile(str(artifacts[0]), "r") as zip_archive:
3795+
zip_archive.extractall(str(extraction_directory))
3796+
3797+
metadata_directory = f"{builder.project_id}.dist-info"
3798+
expected_files = helpers.get_template_files(
3799+
"wheel.standard_default_sbom",
3800+
"My.App",
3801+
metadata_directory=metadata_directory,
3802+
sbom_files=[("my-sbom.spdx.json", '{"spdxVersion": "SPDX-2.3"}')],
3803+
)
3804+
helpers.assert_files(extraction_directory, expected_files)
3805+
3806+
def test_multiple_sbom_files(self, hatch, helpers, temp_dir, config_file):
3807+
config_file.model.template.plugins["default"]["tests"] = False
3808+
config_file.save()
3809+
3810+
with temp_dir.as_cwd():
3811+
result = hatch("new", "My.App")
3812+
3813+
assert result.exit_code == 0, result.output
3814+
3815+
project_path = temp_dir / "my-app"
3816+
(project_path / "sbom1.spdx.json").write_text('{"spdxVersion": "SPDX-2.3"}')
3817+
(project_path / "sbom2.cyclonedx.json").write_text('{"bomFormat": "CycloneDX"}')
3818+
3819+
config = {
3820+
"project": {"name": "My.App", "dynamic": ["version"]},
3821+
"tool": {
3822+
"hatch": {
3823+
"version": {"path": "src/my_app/__about__.py"},
3824+
"build": {"targets": {"wheel": {"sbom-files": ["sbom1.spdx.json", "sbom2.cyclonedx.json"]}}},
3825+
}
3826+
},
3827+
}
3828+
builder = WheelBuilder(str(project_path), config=config)
3829+
3830+
build_path = project_path / "dist"
3831+
build_path.mkdir()
3832+
3833+
with project_path.as_cwd():
3834+
artifacts = list(builder.build(directory=str(build_path)))
3835+
3836+
assert len(artifacts) == 1
3837+
3838+
extraction_directory = temp_dir / "_archive"
3839+
extraction_directory.mkdir()
3840+
3841+
with zipfile.ZipFile(str(artifacts[0]), "r") as zip_archive:
3842+
zip_archive.extractall(str(extraction_directory))
3843+
3844+
metadata_directory = f"{builder.project_id}.dist-info"
3845+
expected_files = helpers.get_template_files(
3846+
"wheel.standard_default_sbom",
3847+
"My.App",
3848+
metadata_directory=metadata_directory,
3849+
sbom_files=[
3850+
("sbom1.spdx.json", '{"spdxVersion": "SPDX-2.3"}'),
3851+
("sbom2.cyclonedx.json", '{"bomFormat": "CycloneDX"}'),
3852+
],
3853+
)
3854+
helpers.assert_files(extraction_directory, expected_files)
3855+
3856+
def test_nested_sbom_file(self, hatch, helpers, temp_dir, config_file):
3857+
config_file.model.template.plugins["default"]["tests"] = False
3858+
config_file.save()
3859+
3860+
with temp_dir.as_cwd():
3861+
result = hatch("new", "My.App")
3862+
3863+
assert result.exit_code == 0, result.output
3864+
3865+
project_path = temp_dir / "my-app"
3866+
sbom_dir = project_path / "sboms"
3867+
sbom_dir.mkdir()
3868+
(sbom_dir / "vendor.spdx.json").write_text('{"spdxVersion": "SPDX-2.3"}')
3869+
3870+
config = {
3871+
"project": {"name": "My.App", "dynamic": ["version"]},
3872+
"tool": {
3873+
"hatch": {
3874+
"version": {"path": "src/my_app/__about__.py"},
3875+
"build": {"targets": {"wheel": {"sbom-files": ["sboms/vendor.spdx.json"]}}},
3876+
}
3877+
},
3878+
}
3879+
builder = WheelBuilder(str(project_path), config=config)
3880+
3881+
build_path = project_path / "dist"
3882+
build_path.mkdir()
3883+
3884+
with project_path.as_cwd():
3885+
artifacts = list(builder.build(directory=str(build_path)))
3886+
3887+
assert len(artifacts) == 1
3888+
3889+
extraction_directory = temp_dir / "_archive"
3890+
extraction_directory.mkdir()
3891+
3892+
with zipfile.ZipFile(str(artifacts[0]), "r") as zip_archive:
3893+
zip_archive.extractall(str(extraction_directory))
3894+
3895+
metadata_directory = f"{builder.project_id}.dist-info"
3896+
expected_files = helpers.get_template_files(
3897+
"wheel.standard_default_sbom",
3898+
"My.App",
3899+
metadata_directory=metadata_directory,
3900+
sbom_files=[("vendor.spdx.json", '{"spdxVersion": "SPDX-2.3"}')],
3901+
)
3902+
helpers.assert_files(extraction_directory, expected_files)
3903+
3904+
def test_sbom_files_invalid_type(self, isolation):
3905+
config = {
3906+
"project": {"name": "my-app", "version": "0.0.1"},
3907+
"tool": {"hatch": {"build": {"targets": {"wheel": {"sbom-files": "not-a-list"}}}}},
3908+
}
3909+
builder = WheelBuilder(str(isolation), config=config)
3910+
3911+
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets.wheel.sbom-files` must be an array"):
3912+
_ = builder.config.sbom_files
3913+
3914+
def test_sbom_file_invalid_item(self, isolation):
3915+
config = {
3916+
"project": {"name": "my-app", "version": "0.0.1"},
3917+
"tool": {"hatch": {"build": {"targets": {"wheel": {"sbom-files": [123]}}}}},
3918+
}
3919+
builder = WheelBuilder(str(isolation), config=config)
3920+
3921+
with pytest.raises(
3922+
TypeError, match="SBOM file #1 in field `tool.hatch.build.targets.wheel.sbom-files` must be a string"
3923+
):
3924+
_ = builder.config.sbom_files
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from hatch.template import File
2+
from hatch.utils.fs import Path
3+
from hatchling.__about__ import __version__
4+
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
5+
6+
from ..new.default import get_files as get_template_files
7+
from .utils import update_record_file_contents
8+
9+
10+
def get_files(**kwargs):
11+
metadata_directory = kwargs.get("metadata_directory", "")
12+
sbom_files = kwargs.get("sbom_files", [])
13+
14+
files = []
15+
for f in get_template_files(**kwargs):
16+
if str(f.path) == "LICENSE.txt":
17+
files.append(File(Path(metadata_directory, "licenses", f.path), f.contents))
18+
19+
if f.path.parts[0] != "src":
20+
continue
21+
22+
files.append(File(Path(*f.path.parts[1:]), f.contents))
23+
24+
# Add SBOM files
25+
for sbom_path, sbom_content in sbom_files:
26+
files.append(File(Path(metadata_directory, "sboms", sbom_path), sbom_content))
27+
28+
files.extend((
29+
File(
30+
Path(metadata_directory, "WHEEL"),
31+
f"""\
32+
Wheel-Version: 1.0
33+
Generator: hatchling {__version__}
34+
Root-Is-Purelib: true
35+
Tag: py2-none-any
36+
Tag: py3-none-any
37+
""",
38+
),
39+
File(
40+
Path(metadata_directory, "METADATA"),
41+
f"""\
42+
Metadata-Version: {DEFAULT_METADATA_VERSION}
43+
Name: {kwargs["project_name"]}
44+
Version: 0.0.1
45+
License-File: LICENSE.txt
46+
""",
47+
),
48+
))
49+
50+
record_file = File(Path(metadata_directory, "RECORD"), "")
51+
update_record_file_contents(record_file, files)
52+
files.append(record_file)
53+
54+
return files

0 commit comments

Comments
 (0)