Skip to content

Commit ea85e31

Browse files
committed
feat: support PEP 771
Signed-off-by: Henry Schreiner <[email protected]>
1 parent 854c051 commit ea85e31

File tree

6 files changed

+187
-1
lines changed

6 files changed

+187
-1
lines changed

pyproject_metadata/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ class StandardMetadata:
225225
optional_dependencies: dict[str, list[Requirement]] = dataclasses.field(
226226
default_factory=dict
227227
)
228+
default_optional_dependency_keys: list[str] | None = None
228229
entrypoints: dict[str, dict[str, str]] = dataclasses.field(default_factory=dict)
229230
authors: list[tuple[str, str | None]] = dataclasses.field(default_factory=list)
230231
maintainers: list[tuple[str, str | None]] = dataclasses.field(default_factory=list)
@@ -263,6 +264,8 @@ def auto_metadata_version(self) -> str:
263264
if self.metadata_version is not None:
264265
return self.metadata_version
265266

267+
if self.default_optional_dependency_keys is not None:
268+
return "2.6"
266269
if isinstance(self.license, str) or self.license_files is not None:
267270
return "2.4"
268271
if self.dynamic_metadata:
@@ -397,6 +400,9 @@ def from_pyproject( # noqa: C901
397400
requires_python=requires_python,
398401
dependencies=pyproject.get_dependencies(project),
399402
optional_dependencies=pyproject.get_optional_dependencies(project),
403+
default_optional_dependency_keys=pyproject.get_default_optional_dependency_keys(
404+
project
405+
),
400406
entrypoints=pyproject.get_entrypoints(project),
401407
authors=pyproject.ensure_people(
402408
project.get("authors", []), "project.authors"
@@ -527,6 +533,14 @@ def validate(self, *, warn: bool = True) -> None: # noqa: C901
527533
msg = "{key} is supported only when emitting metadata version >= 2.4"
528534
errors.config_error(msg, key="project.license-files")
529535

536+
if (
537+
self.default_optional_dependency_keys is not None
538+
and self.auto_metadata_version
539+
in constants.PRE_DEFAULT_EXTRAS_METADATA_VERSIONS
540+
):
541+
msg = "{key} is supported only when emitting metadata version >= 2.6"
542+
errors.config_error(msg, key="project.default-optional-dependency-keys")
543+
530544
for name in self.urls:
531545
if len(name) > 32:
532546
msg = "{key} names cannot be more than 32 characters long"
@@ -595,6 +609,9 @@ def _write_metadata( # noqa: C901
595609
smart_message["Requires-Dist"] = str(
596610
_build_extra_req(norm_extra, requirement)
597611
)
612+
for default_extra in self.default_optional_dependency_keys or []:
613+
norm_extra = default_extra.replace(".", "-").replace("_", "-").lower()
614+
smart_message["Default-Extra"] = norm_extra
598615
if self.readme:
599616
if self.readme.content_type:
600617
smart_message["Description-Content-Type"] = self.readme.content_type

pyproject_metadata/constants.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"KNOWN_MULTIUSE",
1616
"KNOWN_PROJECT_FIELDS",
1717
"KNOWN_TOPLEVEL_FIELDS",
18+
"PRE_DEFAULT_EXTRAS_METADATA_VERSIONS",
1819
"PRE_SPDX_METADATA_VERSIONS",
1920
"PROJECT_TO_METADATA",
2021
]
@@ -24,8 +25,9 @@ def __dir__() -> list[str]:
2425
return __all__
2526

2627

27-
KNOWN_METADATA_VERSIONS = {"2.1", "2.2", "2.3", "2.4"}
28+
KNOWN_METADATA_VERSIONS = {"2.1", "2.2", "2.3", "2.4", "2.5", "2.6"}
2829
PRE_SPDX_METADATA_VERSIONS = {"2.1", "2.2", "2.3"}
30+
PRE_DEFAULT_EXTRAS_METADATA_VERSIONS = {"2.1", "2.2", "2.3", "2.4", "2.5"}
2931

3032
PROJECT_TO_METADATA = {
3133
"authors": frozenset(["Author", "Author-Email"]),
@@ -41,6 +43,7 @@ def __dir__() -> list[str]:
4143
"maintainers": frozenset(["Maintainer", "Maintainer-Email"]),
4244
"name": frozenset(["Name"]),
4345
"optional-dependencies": frozenset(["Provides-Extra", "Requires-Dist"]),
46+
"default-optional-dependency-keys": frozenset(["Default-Extra"]),
4447
"readme": frozenset(["Description", "Description-Content-Type"]),
4548
"requires-python": frozenset(["Requires-Python"]),
4649
"scripts": frozenset(),
@@ -76,6 +79,7 @@ def __dir__() -> list[str]:
7679
"provides", # Deprecated
7780
"provides-dist", # Rarely used
7881
"provides-extra",
82+
"default-extra",
7983
"requires", # Deprecated
8084
"requires-dist",
8185
"requires-external", # Not specified via pyproject standards
@@ -89,6 +93,7 @@ def __dir__() -> list[str]:
8993
"dynamic",
9094
"platform",
9195
"provides-extra",
96+
"default-extra",
9297
"supported-platform",
9398
"license-file",
9499
"classifier",

pyproject_metadata/project_table.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ class LicenseTable(TypedDict, total=False):
5454
Dynamic = Literal[
5555
"authors",
5656
"classifiers",
57+
"default-optional-dependency-keys",
5758
"dependencies",
5859
"description",
5960
"dynamic",
@@ -82,6 +83,7 @@ class LicenseTable(TypedDict, total=False):
8283
"requires-python": str,
8384
"dependencies": List[str],
8485
"optional-dependencies": Dict[str, List[str]],
86+
"default-optional-dependency-keys": List[str],
8587
"entry-points": Dict[str, Dict[str, str]],
8688
"authors": List[ContactTable],
8789
"maintainers": List[ContactTable],

pyproject_metadata/pyproject.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,36 @@ def get_optional_dependencies(
374374
return {}
375375
return dict(requirements_dict)
376376

377+
def get_default_optional_dependency_keys(
378+
self, project: ProjectTable
379+
) -> list[str] | None:
380+
"""Get the default extras, or None if none provided"""
381+
382+
default_extras = project.get("default-optional-dependency-keys")
383+
if default_extras is None:
384+
return None
385+
386+
default_extra_list = self.ensure_list(
387+
default_extras, key="project.default-optional-dependency-keys"
388+
)
389+
if default_extra_list is None:
390+
return None
391+
392+
missing_keys = {
393+
extra.replace(".", "-").replace("_", "-").lower()
394+
for extra in default_extra_list
395+
} - {
396+
extra.replace(".", "-").replace("_", "-").lower()
397+
for extra in self.get_optional_dependencies(project)
398+
}
399+
if missing_keys:
400+
msg = 'Field {key} contains keys not in "project.optional-dependencies": {values!r}'
401+
self.config_error(
402+
msg, key="project.default-optional-dependency-keys", values=missing_keys
403+
)
404+
405+
return default_extra_list
406+
377407
def get_entrypoints(self, project: ProjectTable) -> dict[str, dict[str, str]]:
378408
"""Get the entrypoints from the project table."""
379409

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[project]
2+
name = "default_extras"
3+
version = "0.1.2"
4+
default-optional-dependency-keys = [
5+
"backend1",
6+
"backend2",
7+
"backend3"
8+
]
9+
10+
[project.optional-dependencies]
11+
backend1 = ["a"]
12+
backend2 = ["b"]
13+
backend3 = ["c"]
14+
backend4 = ["d"]

tests/test_standard_metadata.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,66 @@ def all_errors(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch)
420420
),
421421
id="Invalid optional-dependencies item",
422422
),
423+
pytest.param(
424+
"""
425+
[project]
426+
name = "test"
427+
version = "0.1.0"
428+
default-optional-dependency-keys = ["none"]
429+
[project.optional-dependencies]
430+
test = [
431+
"a",
432+
]
433+
""",
434+
(
435+
'Field "project.default-optional-dependency-keys" contains keys '
436+
"not in \"project.optional-dependencies\": {'none'}"
437+
),
438+
id="Invalid default-optional-dependency-keys item",
439+
),
440+
pytest.param(
441+
"""
442+
[project]
443+
name = "test"
444+
version = "0.1.0"
445+
default-optional-dependency-keys = ["none"]
446+
""",
447+
(
448+
'Field "project.default-optional-dependency-keys" contains keys '
449+
"not in \"project.optional-dependencies\": {'none'}"
450+
),
451+
id="Invalid default-optional-dependency-keys item without optional-dependencies",
452+
),
453+
pytest.param(
454+
"""
455+
[project]
456+
name = "test"
457+
version = "0.1.0"
458+
default-optional-dependency-keys = [1]
459+
""",
460+
(
461+
'Field "project.default-optional-dependency-keys" contains item with invalid '
462+
"type, expecting a string (got int)"
463+
),
464+
id="Invalid default-optional-dependency-keys item type",
465+
),
466+
pytest.param(
467+
"""
468+
[project]
469+
name = "test"
470+
version = "0.1.0"
471+
default-optional-dependency-keys = "test"
472+
[project.optional-dependencies]
473+
test = [
474+
"a",
475+
]
476+
""",
477+
(
478+
'Field "project.default-optional-dependency-keys" has an invalid type, '
479+
"expecting a list of strings (got str)"
480+
),
481+
id="Invalid default-optional-dependency-keys not list",
482+
),
423483
pytest.param(
424484
"""
425485
[project]
@@ -943,6 +1003,17 @@ def test_load_multierror(
9431003
"2.3",
9441004
id="license-files with metadata_version 2.3",
9451005
),
1006+
pytest.param(
1007+
"""
1008+
[project]
1009+
name = "test"
1010+
version = "0.1.0"
1011+
default-optional-dependency-keys = []
1012+
""",
1013+
'"project.default-optional-dependency-keys" is supported only when emitting metadata version >= 2.6',
1014+
"2.5",
1015+
id=" with metadata_version 2.5",
1016+
),
9461017
],
9471018
)
9481019
def test_load_with_metadata_version(
@@ -1183,6 +1254,53 @@ def test_as_rfc822(monkeypatch: pytest.MonkeyPatch) -> None:
11831254
assert core_metadata.get_payload() == "some readme 👋\n"
11841255

11851256

1257+
def test_as_json_default_extra(monkeypatch: pytest.MonkeyPatch) -> None:
1258+
monkeypatch.chdir(DIR / "packages/default_extra")
1259+
1260+
with open("pyproject.toml", "rb") as f:
1261+
metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f))
1262+
core_metadata = metadata.as_json()
1263+
assert core_metadata == {
1264+
"metadata_version": "2.6",
1265+
"name": "default_extras",
1266+
"version": "0.1.2",
1267+
"default_extra": ["backend1", "backend2", "backend3"],
1268+
"provides_extra": ["backend1", "backend2", "backend3", "backend4"],
1269+
"requires_dist": [
1270+
'a; extra == "backend1"',
1271+
'b; extra == "backend2"',
1272+
'c; extra == "backend3"',
1273+
'd; extra == "backend4"',
1274+
],
1275+
}
1276+
1277+
1278+
def test_as_rfc822_default_extra(monkeypatch: pytest.MonkeyPatch) -> None:
1279+
monkeypatch.chdir(DIR / "packages/default_extra")
1280+
1281+
with open("pyproject.toml", "rb") as f:
1282+
metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f))
1283+
core_metadata = metadata.as_rfc822()
1284+
assert core_metadata.items() == [
1285+
("Metadata-Version", "2.6"),
1286+
("Name", "default_extras"),
1287+
("Version", "0.1.2"),
1288+
("Provides-Extra", "backend1"),
1289+
("Requires-Dist", 'a; extra == "backend1"'),
1290+
("Provides-Extra", "backend2"),
1291+
("Requires-Dist", 'b; extra == "backend2"'),
1292+
("Provides-Extra", "backend3"),
1293+
("Requires-Dist", 'c; extra == "backend3"'),
1294+
("Provides-Extra", "backend4"),
1295+
("Requires-Dist", 'd; extra == "backend4"'),
1296+
("Default-Extra", "backend1"),
1297+
("Default-Extra", "backend2"),
1298+
("Default-Extra", "backend3"),
1299+
]
1300+
1301+
assert core_metadata.get_payload() is None
1302+
1303+
11861304
def test_as_json_spdx(monkeypatch: pytest.MonkeyPatch) -> None:
11871305
monkeypatch.chdir(DIR / "packages/spdx")
11881306

0 commit comments

Comments
 (0)