Skip to content
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: 17 additions & 0 deletions pyproject_metadata/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
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_DEFAULT_EXTRAS_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", "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"]),
Expand All @@ -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(),
Expand Down Expand Up @@ -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
Expand All @@ -89,6 +93,7 @@ def __dir__() -> list[str]:
"dynamic",
"platform",
"provides-extra",
"default-extra",
"supported-platform",
"license-file",
"classifier",
Expand Down
2 changes: 2 additions & 0 deletions pyproject_metadata/project_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ class LicenseTable(TypedDict, total=False):
Dynamic = Literal[
"authors",
"classifiers",
"default-optional-dependency-keys",
"dependencies",
"description",
"dynamic",
Expand Down Expand Up @@ -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],
Expand Down
30 changes: 30 additions & 0 deletions pyproject_metadata/pyproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
14 changes: 14 additions & 0 deletions tests/packages/default_extra/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"]
118 changes: 118 additions & 0 deletions tests/test_standard_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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")

Expand Down