diff --git a/pyproject_metadata/__init__.py b/pyproject_metadata/__init__.py index e774560..670a9a3 100644 --- a/pyproject_metadata/__init__.py +++ b/pyproject_metadata/__init__.py @@ -219,6 +219,7 @@ class StandardMetadata: description: str | None = None license: License | str | None = None license_files: list[pathlib.Path] | None = None + sbom_files: list[pathlib.Path] | None = None readme: Readme | None = None requires_python: packaging.specifiers.SpecifierSet | None = None dependencies: list[Requirement] = dataclasses.field(default_factory=list) @@ -262,7 +263,8 @@ def auto_metadata_version(self) -> str: """ if self.metadata_version is not None: return self.metadata_version - + if self.sbom_files is not None: + return "2.5" if isinstance(self.license, str) or self.license_files is not None: return "2.4" if self.dynamic_metadata: @@ -393,6 +395,7 @@ def from_pyproject( # noqa: C901 description=description, license=pyproject.get_license(project, project_dir), license_files=pyproject.get_license_files(project, project_dir), + sbom_files=pyproject.get_sbom_files(project, project_dir), readme=pyproject.get_readme(project, project_dir), requires_python=requires_python, dependencies=pyproject.get_dependencies(project), @@ -465,6 +468,7 @@ def validate(self, *, warn: bool = True) -> None: # noqa: C901 - License classifiers deprecated for metadata_version >= 2.4 (warning) - ``license`` is an SPDX license expression if metadata_version >= 2.4 - ``license_files`` is supported only for metadata_version >= 2.4 + - ``sbom-files`` is supported only for metadata_version >= 2.5 - ``project_url`` can't contain keys over 32 characters """ errors = ErrorCollector(collect_errors=self.all_errors) @@ -527,6 +531,13 @@ def validate(self, *, warn: bool = True) -> None: # noqa: C901 msg = "{key} is supported only when emitting metadata version >= 2.4" errors.config_error(msg, key="project.license-files") + if ( + self.sbom_files is not None + and self.auto_metadata_version in constants.PRE_SBOM_METADATA_VERSIONS + ): + msg = "{key} is supported only when emitting metadata version >= 2.5" + errors.config_error(msg, key="project.sbom-files") + for name in self.urls: if len(name) > 32: msg = "{key} names cannot be more than 32 characters long" @@ -577,6 +588,10 @@ def _write_metadata( # noqa: C901 ): smart_message["License-File"] = os.fspath(self.license.file.as_posix()) + if self.sbom_files is not None: + for sbom_file in sorted(set(self.sbom_files)): + smart_message["Sbom-File"] = os.fspath(sbom_file.as_posix()) + for classifier in self.classifiers: smart_message["Classifier"] = classifier # skip 'Provides-Dist' diff --git a/pyproject_metadata/constants.py b/pyproject_metadata/constants.py index afe4281..a890297 100644 --- a/pyproject_metadata/constants.py +++ b/pyproject_metadata/constants.py @@ -15,6 +15,7 @@ "KNOWN_MULTIUSE", "KNOWN_PROJECT_FIELDS", "KNOWN_TOPLEVEL_FIELDS", + "PRE_SBOM_METADATA_VERSIONS", "PRE_SPDX_METADATA_VERSIONS", "PROJECT_TO_METADATA", ] @@ -24,8 +25,9 @@ def __dir__() -> list[str]: return __all__ -KNOWN_METADATA_VERSIONS = {"2.1", "2.2", "2.3", "2.4"} +KNOWN_METADATA_VERSIONS = {"2.1", "2.2", "2.3", "2.4", "2.5"} PRE_SPDX_METADATA_VERSIONS = {"2.1", "2.2", "2.3"} +PRE_SBOM_METADATA_VERSIONS = {"2.1", "2.2", "2.3", "2.4"} PROJECT_TO_METADATA = { "authors": frozenset(["Author", "Author-Email"]), @@ -38,6 +40,7 @@ def __dir__() -> list[str]: "keywords": frozenset(["Keywords"]), "license": frozenset(["License", "License-Expression"]), "license-files": frozenset(["License-File"]), + "sbom-files": frozenset(["Sbom-File"]), "maintainers": frozenset(["Maintainer", "Maintainer-Email"]), "name": frozenset(["Name"]), "optional-dependencies": frozenset(["Provides-Extra", "Requires-Dist"]), @@ -65,6 +68,7 @@ def __dir__() -> list[str]: "license", "license-expression", "license-file", + "sbom-file", "maintainer", "maintainer-email", "metadata-version", @@ -91,6 +95,7 @@ def __dir__() -> list[str]: "provides-extra", "supported-platform", "license-file", + "sbom-file", "classifier", "requires-dist", "requires-external", diff --git a/pyproject_metadata/project_table.py b/pyproject_metadata/project_table.py index 093e3f2..ebff00e 100644 --- a/pyproject_metadata/project_table.py +++ b/pyproject_metadata/project_table.py @@ -61,10 +61,12 @@ class LicenseTable(TypedDict, total=False): "gui-scripts", "keywords", "license", + "license-files", "maintainers", "optional-dependencies", "readme", "requires-python", + "sbom-files", "scripts", "urls", "version", @@ -78,6 +80,7 @@ class LicenseTable(TypedDict, total=False): "description": str, "license": Union[LicenseTable, str], "license-files": List[str], + "sbom-files": List[str], "readme": Union[str, ReadmeTable], "requires-python": str, "dependencies": List[str], diff --git a/pyproject_metadata/pyproject.py b/pyproject_metadata/pyproject.py index d1822e1..89c74ee 100644 --- a/pyproject_metadata/pyproject.py +++ b/pyproject_metadata/pyproject.py @@ -216,7 +216,30 @@ def get_license_files( if self.ensure_list(license_files, "project.license-files") is None: return None - return list(self._get_files_from_globs(project_dir, license_files)) + return list( + self._get_files_from_globs( + project_dir, license_files, "project.license-files" + ) + ) + + def get_sbom_files( + self, project: ProjectTable, project_dir: pathlib.Path + ) -> list[pathlib.Path] | None: + """Get the sbom-files list of files from the project table. + + Returns None if an error occurred (including invalid globs, etc) or if + not present. + """ + + sbom_files = project.get("sbom-files") + if sbom_files is None: + return None + if self.ensure_list(sbom_files, "project.sbom-files") is None: + return None + + return list( + self._get_files_from_globs(project_dir, sbom_files, "project.sbom-files") + ) def get_readme( # noqa: C901 self, project: ProjectTable, project_dir: pathlib.Path @@ -432,19 +455,19 @@ def get_dynamic(self, project: ProjectTable) -> list[Dynamic]: return dynamic def _get_files_from_globs( - self, project_dir: pathlib.Path, globs: Iterable[str] + self, project_dir: pathlib.Path, globs: Iterable[str], key: str ) -> Generator[pathlib.Path, None, None]: """Given a list of globs, get files that match.""" for glob in globs: if glob.startswith(("..", "/")): msg = "{glob!r} is an invalid {key} glob: the pattern must match files within the project directory" - self.config_error(msg, key="project.license-files", glob=glob) + self.config_error(msg, key=key, glob=glob) break files = [f for f in project_dir.glob(glob) if f.is_file()] if not files: msg = "Every pattern in {key} must match at least one file: {glob!r} did not match any" - self.config_error(msg, key="project.license-files", glob=glob) + self.config_error(msg, key=key, glob=glob) break for f in files: yield f.relative_to(project_dir) diff --git a/tests/packages/sbom/bom.json b/tests/packages/sbom/bom.json new file mode 100644 index 0000000..e69de29 diff --git a/tests/packages/sbom/pyproject.toml b/tests/packages/sbom/pyproject.toml new file mode 100644 index 0000000..465fc8d --- /dev/null +++ b/tests/packages/sbom/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "test-sbom" +version = "0.1.0" +sbom-files = ["bom.json", "sboms/*"] diff --git a/tests/packages/sbom/sboms/newbom.json b/tests/packages/sbom/sboms/newbom.json new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_standard_metadata.py b/tests/test_standard_metadata.py index 26aa194..57c2fb2 100644 --- a/tests/test_standard_metadata.py +++ b/tests/test_standard_metadata.py @@ -780,6 +780,46 @@ def all_errors(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) "Setting \"project.license\" to an SPDX license expression is not compatible with 'License ::' classifiers", id="SPDX license and License trove classifiers", ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + sbom-files = ['../sbom.json'] + """, + "'../sbom.json' is an invalid \"project.sbom-files\" glob: the pattern must match files within the project directory", + id="Parent sbom-files glob", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + sbom-files = [12] + """, + 'Field "project.sbom-files" contains item with invalid type, expecting a string (got int)', + id="Parent sbom-files invalid type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + sbom-files = ['this', 12] + """, + 'Field "project.sbom-files" contains item with invalid type, expecting a string (got int)', + id="Parent sbom-files invalid type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + sbom-files = ['/sbom.json'] + """, + "'/sbom.json' is an invalid \"project.sbom-files\" glob: the pattern must match files within the project directory", + id="Absolute sbom-files glob", + ), ], ) def test_load( @@ -943,6 +983,17 @@ def test_load_multierror( "2.3", id="license-files with metadata_version 2.3", ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + sbom-files = ['README.md'] + """, + '"project.sbom-files" is supported only when emitting metadata version >= 2.5', + "2.4", + id="sbom-files with metadata_version 2.4", + ), ], ) def test_load_with_metadata_version( @@ -1281,6 +1332,40 @@ def test_license_file_24( assert "License-File: LICENSE.txt" in message +def test_as_json_sbom(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(DIR / "packages/sbom") + + with open("pyproject.toml", "rb") as f: + metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f)) + core_metadata = metadata.as_json() + assert core_metadata == { + "sbom_file": [ + "bom.json", + "sboms/newbom.json", + ], + "metadata_version": "2.5", + "name": "test-sbom", + "version": "0.1.0", + } + + +def test_as_rfc822_sbom(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(DIR / "packages/sbom") + + with open("pyproject.toml", "rb") as f: + metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f)) + core_metadata = metadata.as_rfc822() + assert core_metadata.items() == [ + ("Metadata-Version", "2.5"), + ("Name", "test-sbom"), + ("Version", "0.1.0"), + ("Sbom-File", "bom.json"), + ("Sbom-File", "sboms/newbom.json"), + ] + + assert core_metadata.get_payload() is None + + def test_as_rfc822_dynamic(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(DIR / "packages/dynamic-description")