From ea85e31ebf50d187a2631a923e4bee61d2f78b87 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Fri, 14 Feb 2025 16:04:24 -0500 Subject: [PATCH] feat: support PEP 771 Signed-off-by: Henry Schreiner --- pyproject_metadata/__init__.py | 17 +++ pyproject_metadata/constants.py | 7 +- pyproject_metadata/project_table.py | 2 + pyproject_metadata/pyproject.py | 30 +++++ tests/packages/default_extra/pyproject.toml | 14 +++ tests/test_standard_metadata.py | 118 ++++++++++++++++++++ 6 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 tests/packages/default_extra/pyproject.toml diff --git a/pyproject_metadata/__init__.py b/pyproject_metadata/__init__.py index e774560..25f5cab 100644 --- a/pyproject_metadata/__init__.py +++ b/pyproject_metadata/__init__.py @@ -225,6 +225,7 @@ class StandardMetadata: optional_dependencies: dict[str, list[Requirement]] = dataclasses.field( default_factory=dict ) + default_optional_dependency_keys: list[str] | None = None entrypoints: dict[str, dict[str, str]] = dataclasses.field(default_factory=dict) authors: list[tuple[str, str | None]] = dataclasses.field(default_factory=list) maintainers: list[tuple[str, str | None]] = dataclasses.field(default_factory=list) @@ -263,6 +264,8 @@ def auto_metadata_version(self) -> str: if self.metadata_version is not None: return self.metadata_version + if self.default_optional_dependency_keys is not None: + return "2.6" if isinstance(self.license, str) or self.license_files is not None: return "2.4" if self.dynamic_metadata: @@ -397,6 +400,9 @@ def from_pyproject( # noqa: C901 requires_python=requires_python, dependencies=pyproject.get_dependencies(project), optional_dependencies=pyproject.get_optional_dependencies(project), + default_optional_dependency_keys=pyproject.get_default_optional_dependency_keys( + project + ), entrypoints=pyproject.get_entrypoints(project), authors=pyproject.ensure_people( project.get("authors", []), "project.authors" @@ -527,6 +533,14 @@ 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.default_optional_dependency_keys is not None + and self.auto_metadata_version + in constants.PRE_DEFAULT_EXTRAS_METADATA_VERSIONS + ): + msg = "{key} is supported only when emitting metadata version >= 2.6" + errors.config_error(msg, key="project.default-optional-dependency-keys") + for name in self.urls: if len(name) > 32: msg = "{key} names cannot be more than 32 characters long" @@ -595,6 +609,9 @@ def _write_metadata( # noqa: C901 smart_message["Requires-Dist"] = str( _build_extra_req(norm_extra, requirement) ) + for default_extra in self.default_optional_dependency_keys or []: + norm_extra = default_extra.replace(".", "-").replace("_", "-").lower() + smart_message["Default-Extra"] = norm_extra if self.readme: if self.readme.content_type: smart_message["Description-Content-Type"] = self.readme.content_type diff --git a/pyproject_metadata/constants.py b/pyproject_metadata/constants.py index afe4281..7c30192 100644 --- a/pyproject_metadata/constants.py +++ b/pyproject_metadata/constants.py @@ -15,6 +15,7 @@ "KNOWN_MULTIUSE", "KNOWN_PROJECT_FIELDS", "KNOWN_TOPLEVEL_FIELDS", + "PRE_DEFAULT_EXTRAS_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", "2.6"} PRE_SPDX_METADATA_VERSIONS = {"2.1", "2.2", "2.3"} +PRE_DEFAULT_EXTRAS_METADATA_VERSIONS = {"2.1", "2.2", "2.3", "2.4", "2.5"} PROJECT_TO_METADATA = { "authors": frozenset(["Author", "Author-Email"]), @@ -41,6 +43,7 @@ def __dir__() -> list[str]: "maintainers": frozenset(["Maintainer", "Maintainer-Email"]), "name": frozenset(["Name"]), "optional-dependencies": frozenset(["Provides-Extra", "Requires-Dist"]), + "default-optional-dependency-keys": frozenset(["Default-Extra"]), "readme": frozenset(["Description", "Description-Content-Type"]), "requires-python": frozenset(["Requires-Python"]), "scripts": frozenset(), @@ -76,6 +79,7 @@ def __dir__() -> list[str]: "provides", # Deprecated "provides-dist", # Rarely used "provides-extra", + "default-extra", "requires", # Deprecated "requires-dist", "requires-external", # Not specified via pyproject standards @@ -89,6 +93,7 @@ def __dir__() -> list[str]: "dynamic", "platform", "provides-extra", + "default-extra", "supported-platform", "license-file", "classifier", diff --git a/pyproject_metadata/project_table.py b/pyproject_metadata/project_table.py index 093e3f2..86e5c0d 100644 --- a/pyproject_metadata/project_table.py +++ b/pyproject_metadata/project_table.py @@ -54,6 +54,7 @@ class LicenseTable(TypedDict, total=False): Dynamic = Literal[ "authors", "classifiers", + "default-optional-dependency-keys", "dependencies", "description", "dynamic", @@ -82,6 +83,7 @@ class LicenseTable(TypedDict, total=False): "requires-python": str, "dependencies": List[str], "optional-dependencies": Dict[str, List[str]], + "default-optional-dependency-keys": List[str], "entry-points": Dict[str, Dict[str, str]], "authors": List[ContactTable], "maintainers": List[ContactTable], diff --git a/pyproject_metadata/pyproject.py b/pyproject_metadata/pyproject.py index d1822e1..1cbffed 100644 --- a/pyproject_metadata/pyproject.py +++ b/pyproject_metadata/pyproject.py @@ -374,6 +374,36 @@ def get_optional_dependencies( return {} return dict(requirements_dict) + def get_default_optional_dependency_keys( + self, project: ProjectTable + ) -> list[str] | None: + """Get the default extras, or None if none provided""" + + default_extras = project.get("default-optional-dependency-keys") + if default_extras is None: + return None + + default_extra_list = self.ensure_list( + default_extras, key="project.default-optional-dependency-keys" + ) + if default_extra_list is None: + return None + + missing_keys = { + extra.replace(".", "-").replace("_", "-").lower() + for extra in default_extra_list + } - { + extra.replace(".", "-").replace("_", "-").lower() + for extra in self.get_optional_dependencies(project) + } + if missing_keys: + msg = 'Field {key} contains keys not in "project.optional-dependencies": {values!r}' + self.config_error( + msg, key="project.default-optional-dependency-keys", values=missing_keys + ) + + return default_extra_list + def get_entrypoints(self, project: ProjectTable) -> dict[str, dict[str, str]]: """Get the entrypoints from the project table.""" diff --git a/tests/packages/default_extra/pyproject.toml b/tests/packages/default_extra/pyproject.toml new file mode 100644 index 0000000..f7d65d7 --- /dev/null +++ b/tests/packages/default_extra/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "default_extras" +version = "0.1.2" +default-optional-dependency-keys = [ + "backend1", + "backend2", + "backend3" +] + +[project.optional-dependencies] +backend1 = ["a"] +backend2 = ["b"] +backend3 = ["c"] +backend4 = ["d"] diff --git a/tests/test_standard_metadata.py b/tests/test_standard_metadata.py index 26aa194..f3f584b 100644 --- a/tests/test_standard_metadata.py +++ b/tests/test_standard_metadata.py @@ -420,6 +420,66 @@ def all_errors(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) ), id="Invalid optional-dependencies item", ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + default-optional-dependency-keys = ["none"] + [project.optional-dependencies] + test = [ + "a", + ] + """, + ( + 'Field "project.default-optional-dependency-keys" contains keys ' + "not in \"project.optional-dependencies\": {'none'}" + ), + id="Invalid default-optional-dependency-keys item", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + default-optional-dependency-keys = ["none"] + """, + ( + 'Field "project.default-optional-dependency-keys" contains keys ' + "not in \"project.optional-dependencies\": {'none'}" + ), + id="Invalid default-optional-dependency-keys item without optional-dependencies", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + default-optional-dependency-keys = [1] + """, + ( + 'Field "project.default-optional-dependency-keys" contains item with invalid ' + "type, expecting a string (got int)" + ), + id="Invalid default-optional-dependency-keys item type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + default-optional-dependency-keys = "test" + [project.optional-dependencies] + test = [ + "a", + ] + """, + ( + 'Field "project.default-optional-dependency-keys" has an invalid type, ' + "expecting a list of strings (got str)" + ), + id="Invalid default-optional-dependency-keys not list", + ), pytest.param( """ [project] @@ -943,6 +1003,17 @@ def test_load_multierror( "2.3", id="license-files with metadata_version 2.3", ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + default-optional-dependency-keys = [] + """, + '"project.default-optional-dependency-keys" is supported only when emitting metadata version >= 2.6', + "2.5", + id=" with metadata_version 2.5", + ), ], ) def test_load_with_metadata_version( @@ -1183,6 +1254,53 @@ def test_as_rfc822(monkeypatch: pytest.MonkeyPatch) -> None: assert core_metadata.get_payload() == "some readme 👋\n" +def test_as_json_default_extra(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(DIR / "packages/default_extra") + + with open("pyproject.toml", "rb") as f: + metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f)) + core_metadata = metadata.as_json() + assert core_metadata == { + "metadata_version": "2.6", + "name": "default_extras", + "version": "0.1.2", + "default_extra": ["backend1", "backend2", "backend3"], + "provides_extra": ["backend1", "backend2", "backend3", "backend4"], + "requires_dist": [ + 'a; extra == "backend1"', + 'b; extra == "backend2"', + 'c; extra == "backend3"', + 'd; extra == "backend4"', + ], + } + + +def test_as_rfc822_default_extra(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(DIR / "packages/default_extra") + + 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.6"), + ("Name", "default_extras"), + ("Version", "0.1.2"), + ("Provides-Extra", "backend1"), + ("Requires-Dist", 'a; extra == "backend1"'), + ("Provides-Extra", "backend2"), + ("Requires-Dist", 'b; extra == "backend2"'), + ("Provides-Extra", "backend3"), + ("Requires-Dist", 'c; extra == "backend3"'), + ("Provides-Extra", "backend4"), + ("Requires-Dist", 'd; extra == "backend4"'), + ("Default-Extra", "backend1"), + ("Default-Extra", "backend2"), + ("Default-Extra", "backend3"), + ] + + assert core_metadata.get_payload() is None + + def test_as_json_spdx(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(DIR / "packages/spdx")