From 0b3ec6a1e61bb43edb303503787f3e61492d4049 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Fri, 22 May 2026 20:48:59 -0400 Subject: [PATCH 1/2] feat: add METADATA 2.6 support (PEP 808) Add support for METADATA 2.6 as defined by PEP 808 - Including static values in dynamic metadata. This PEP relaxes constraints on dynamic metadata by allowing static portions of list/table fields to be defined alongside dynamic metadata. Since PEP 808 does not introduce any new metadata fields (only changes the semantic behavior of the Dynamic field in source distributions), the library only needs to recognize 2.6 as a valid metadata version. Changes: - Add "2.6" to _VALID_METADATA_VERSIONS and _MetadataVersion - Add Metadata 2.6 comment in RawMetadata - Update test fixtures and examples to use 2.6 - Update docs example to reference current version Assisted-by: OpenCode:NRP/kimi --- docs/metadata.rst | 4 ++-- src/packaging/metadata.py | 18 ++++++++++++++++-- tests/metadata/everything.metadata | 2 +- tests/test_metadata.py | 22 ++++++++++++---------- 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/docs/metadata.rst b/docs/metadata.rst index c1c2767cd..6f18026d0 100644 --- a/docs/metadata.rst +++ b/docs/metadata.rst @@ -17,10 +17,10 @@ Usage .. doctest:: >>> from packaging.metadata import parse_email - >>> metadata = "Metadata-Version: 2.3\nName: packaging\nVersion: 24.0" + >>> metadata = "Metadata-Version: 2.6\nName: packaging\nVersion: 24.0" >>> raw, unparsed = parse_email(metadata) >>> raw["metadata_version"] - '2.3' + '2.6' >>> raw["name"] 'packaging' >>> raw["version"] diff --git a/src/packaging/metadata.py b/src/packaging/metadata.py index af91241c1..59ad15d3e 100644 --- a/src/packaging/metadata.py +++ b/src/packaging/metadata.py @@ -132,6 +132,8 @@ class RawMetadata(TypedDict, total=False): import_names: list[str] import_namespaces: list[str] + # Metadata 2.6 - PEP 808 (no new fields, behavior change for Dynamic) + # 'keywords' is special as it's a string in the core metadata spec, but we # represent it as a list. @@ -512,8 +514,20 @@ def parse_email(data: bytes | str) -> tuple[RawMetadata, dict[str, list[str]]]: # Keep the two values in sync. -_VALID_METADATA_VERSIONS = ["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4", "2.5"] -_MetadataVersion = Literal["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4", "2.5"] +_VALID_METADATA_VERSIONS = [ + "1.0", + "1.1", + "1.2", + "2.1", + "2.2", + "2.3", + "2.4", + "2.5", + "2.6", +] +_MetadataVersion = Literal[ + "1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6" +] _REQUIRED_ATTRS = frozenset(["metadata_version", "name", "version"]) diff --git a/tests/metadata/everything.metadata b/tests/metadata/everything.metadata index 83e8aa669..e1645ad2a 100644 --- a/tests/metadata/everything.metadata +++ b/tests/metadata/everything.metadata @@ -1,4 +1,4 @@ -Metadata-Version: 2.5 +Metadata-Version: 2.6 Name: BeagleVote Version: 1.0a2 Platform: ObscureUnix diff --git a/tests/test_metadata.py b/tests/test_metadata.py index f6783f93e..62fad6977 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -197,7 +197,7 @@ def test_complete(self) -> None: assert len(unparsed) == 1 # "ThisIsNotReal" key assert unparsed["thisisnotreal"] == ["Hello!"] assert len(raw) == 28 - assert raw["metadata_version"] == "2.5" + assert raw["metadata_version"] == "2.6" assert raw["name"] == "BeagleVote" assert raw["version"] == "1.0a2" assert raw["platforms"] == ["ObscureUnix", "RareDOS"] @@ -274,7 +274,7 @@ def test_repr(self) -> None: _RAW_EXAMPLE: RawMetadata = { - "metadata_version": "2.5", + "metadata_version": "2.6", "name": "packaging", "version": "2023.0.0", } @@ -301,7 +301,7 @@ def _invalid_with_cause( assert isinstance(exc.__cause__, cause) def test_from_email(self) -> None: - metadata_version = "2.5" + metadata_version = "2.6" meta = metadata.Metadata.from_email( f"Metadata-Version: {metadata_version}", validate=False ) @@ -311,7 +311,7 @@ def test_from_email(self) -> None: def test_from_email_empty_import_name(self) -> None: meta = metadata.Metadata.from_email( - "Metadata-Version: 2.5\nImport-Name:\n", validate=False + "Metadata-Version: 2.6\nImport-Name:\n", validate=False ) assert meta.import_names == [] @@ -447,7 +447,9 @@ def test_multi_value_unvalidated_attribute(self, attribute: str) -> None: assert getattr(meta, attribute) == values - @pytest.mark.parametrize("version", ["1.0", "1.1", "1.2", "2.1", "2.2", "2.3"]) + @pytest.mark.parametrize( + "version", ["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6"] + ) def test_valid_metadata_version(self, version: str) -> None: meta = metadata.Metadata.from_raw({"metadata_version": version}, validate=False) @@ -855,7 +857,7 @@ def test_write_metadata(self) -> None: meta = metadata.Metadata.from_raw(_RAW_EXAMPLE) written = meta.as_rfc822().as_string() assert ( - written == "metadata-version: 2.5\nname: packaging\nversion: 2023.0.0\n\n" + written == "metadata-version: 2.6\nname: packaging\nversion: 2023.0.0\n\n" ) def test_write_metadata_with_description(self) -> None: @@ -1005,7 +1007,7 @@ def test_modern_license(self) -> None: def test__import_names(self) -> None: meta = metadata.Metadata.from_raw( { - "metadata_version": "2.5", + "metadata_version": "2.6", "name": "full_metadata", "version": "3.2.1", "import_names": ["one", "two"], @@ -1015,7 +1017,7 @@ def test__import_names(self) -> None: core_metadata = meta.as_rfc822() assert core_metadata.items() == [ - ("metadata-version", "2.5"), + ("metadata-version", "2.6"), ("name", "full_metadata"), ("version", "3.2.1"), ("import-name", "one"), @@ -1028,7 +1030,7 @@ def test__import_names(self) -> None: def test_empty_import_names(self) -> None: meta = metadata.Metadata.from_raw( { - "metadata_version": "2.5", + "metadata_version": "2.6", "name": "full_metadata", "version": "3.2.1", "import_names": [], @@ -1037,7 +1039,7 @@ def test_empty_import_names(self) -> None: core_metadata = meta.as_rfc822() assert core_metadata.items() == [ - ("metadata-version", "2.5"), + ("metadata-version", "2.6"), ("name", "full_metadata"), ("version", "3.2.1"), ("import-name", ""), From 1351523789747d9e2df1c3fefdf553b3157cd578 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 26 May 2026 01:27:51 -0400 Subject: [PATCH 2/2] tests: add a test for dynamic metadata entries Assisted-by: OpenCode:Kimi-K2.6 Signed-off-by: Henry Schreiner --- tests/test_metadata.py | 59 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 62fad6977..f2b759323 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -646,6 +646,65 @@ def test_invalid_dynamic_value(self) -> None: with pytest.raises(metadata.InvalidMetadata): meta.dynamic # noqa: B018 + def test_pep808_dynamic_with_static_values(self) -> None: + """PEP 808 allows list/table fields to have static values + while their key is also listed in ``Dynamic``.""" + raw: RawMetadata = { + "metadata_version": "2.6", + "name": "packaging", + "version": "1.0.0", + "classifiers": ["Development Status :: 4 - Beta"], + "requires_dist": ["pytest"], + "provides_extra": ["test"], + "license_files": ["LICENSE"], + "project_urls": {"homepage": "example.com"}, + "import_names": ["packaging"], + "import_namespaces": ["pkg.ns"], + "dynamic": [ + "classifier", + "requires-dist", + "provides-extra", + "license-file", + "project-url", + "import-name", + "import-namespace", + ], + } + + meta = metadata.Metadata.from_raw(raw, validate=True) + assert meta.classifiers == ["Development Status :: 4 - Beta"] + assert meta.requires_dist == [requirements.Requirement("pytest")] + assert meta.provides_extra == [utils.canonicalize_name("test")] + assert meta.license_files == ["LICENSE"] + assert meta.project_urls == {"homepage": "example.com"} + assert meta.import_names == ["packaging"] + assert meta.import_namespaces == ["pkg.ns"] + assert meta.dynamic == [ + "classifier", + "requires-dist", + "provides-extra", + "license-file", + "project-url", + "import-name", + "import-namespace", + ] + + rfc822 = meta.as_rfc822() + assert ("classifier", "Development Status :: 4 - Beta") in rfc822.items() + assert ("requires-dist", "pytest") in rfc822.items() + assert ("provides-extra", "test") in rfc822.items() + assert ("license-file", "LICENSE") in rfc822.items() + assert ("project-url", "homepage, example.com") in rfc822.items() + assert ("import-name", "packaging") in rfc822.items() + assert ("import-namespace", "pkg.ns") in rfc822.items() + assert ("dynamic", "classifier") in rfc822.items() + assert ("dynamic", "requires-dist") in rfc822.items() + assert ("dynamic", "provides-extra") in rfc822.items() + assert ("dynamic", "license-file") in rfc822.items() + assert ("dynamic", "project-url") in rfc822.items() + assert ("dynamic", "import-name") in rfc822.items() + assert ("dynamic", "import-namespace") in rfc822.items() + @pytest.mark.parametrize("field_name", ["name", "version", "metadata-version"]) def test_disallowed_dynamic(self, field_name: str) -> None: meta = metadata.Metadata.from_raw({"dynamic": [field_name]}, validate=False)