Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support PEP 770 #225

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
17 changes: 16 additions & 1 deletion pyproject_metadata/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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'
Expand Down
7 changes: 6 additions & 1 deletion pyproject_metadata/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"KNOWN_MULTIUSE",
"KNOWN_PROJECT_FIELDS",
"KNOWN_TOPLEVEL_FIELDS",
"PRE_SBOM_METADATA_VERSIONS",
"PRE_SPDX_METADATA_VERSIONS",
"PROJECT_TO_METADATA",
]
Expand All @@ -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"]),
Expand All @@ -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"]),
Expand Down Expand Up @@ -65,6 +68,7 @@ def __dir__() -> list[str]:
"license",
"license-expression",
"license-file",
"sbom-file",
"maintainer",
"maintainer-email",
"metadata-version",
Expand All @@ -91,6 +95,7 @@ def __dir__() -> list[str]:
"provides-extra",
"supported-platform",
"license-file",
"sbom-file",
"classifier",
"requires-dist",
"requires-external",
Expand Down
3 changes: 3 additions & 0 deletions pyproject_metadata/project_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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],
Expand Down
31 changes: 27 additions & 4 deletions pyproject_metadata/pyproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Empty file added tests/packages/sbom/bom.json
Empty file.
4 changes: 4 additions & 0 deletions tests/packages/sbom/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[project]
name = "test-sbom"
version = "0.1.0"
sbom-files = ["bom.json", "sboms/*"]
Empty file.
85 changes: 85 additions & 0 deletions tests/test_standard_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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")

Expand Down