Skip to content

Commit e83801f

Browse files
Implement PEP 794 (Import Name Metadata) (#948)
Signed-off-by: Henry Schreiner <[email protected]> Co-authored-by: Henry Schreiner <[email protected]>
1 parent 25b1f44 commit e83801f

File tree

3 files changed

+142
-12
lines changed

3 files changed

+142
-12
lines changed

src/packaging/metadata.py

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import email.message
66
import email.parser
77
import email.policy
8+
import keyword
89
import pathlib
910
import sys
1011
import typing
@@ -132,7 +133,13 @@ class RawMetadata(TypedDict, total=False):
132133
license_expression: str
133134
license_files: list[str]
134135

136+
# Metadata 2.5 - PEP 794
137+
import_names: list[str]
138+
import_namespaces: list[str]
135139

140+
141+
# 'keywords' is special as it's a string in the core metadata spec, but we
142+
# represent it as a list.
136143
_STRING_FIELDS = {
137144
"author",
138145
"author_email",
@@ -165,6 +172,8 @@ class RawMetadata(TypedDict, total=False):
165172
"requires_dist",
166173
"requires_external",
167174
"supported_platforms",
175+
"import_names",
176+
"import_namespaces",
168177
}
169178

170179
_DICT_FIELDS = {
@@ -257,6 +266,8 @@ def _get_payload(msg: email.message.Message, source: bytes | str) -> str:
257266
"download-url": "download_url",
258267
"dynamic": "dynamic",
259268
"home-page": "home_page",
269+
"import-name": "import_names",
270+
"import-namespace": "import_namespaces",
260271
"keywords": "keywords",
261272
"license": "license",
262273
"license-expression": "license_expression",
@@ -436,6 +447,11 @@ def parse_email(data: bytes | str) -> tuple[RawMetadata, dict[str, list[str]]]:
436447
# of unparsed stuff.
437448
if raw_name in _STRING_FIELDS and len(value) == 1:
438449
raw[raw_name] = value[0]
450+
# If this is import_names, we need to special case the empty field
451+
# case, which converts to an empty list instead of None. We can't let
452+
# the empty case slip through, as it will fail validation.
453+
elif raw_name == "import_names" and value == [""]:
454+
raw[raw_name] = []
439455
# If this is one of our list of string fields, then we can just assign
440456
# the value, since email *only* has strings, and our get_all() call
441457
# above ensures that this is a list.
@@ -502,8 +518,8 @@ def parse_email(data: bytes | str) -> tuple[RawMetadata, dict[str, list[str]]]:
502518

503519

504520
# Keep the two values in sync.
505-
_VALID_METADATA_VERSIONS = ["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4"]
506-
_MetadataVersion = Literal["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4"]
521+
_VALID_METADATA_VERSIONS = ["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4", "2.5"]
522+
_MetadataVersion = Literal["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4", "2.5"]
507523

508524
_REQUIRED_ATTRS = frozenset(["metadata_version", "name", "version"])
509525

@@ -686,9 +702,7 @@ def _process_requires_dist(
686702
else:
687703
return reqs
688704

689-
def _process_license_expression(
690-
self, value: str
691-
) -> NormalizedLicenseExpression | None:
705+
def _process_license_expression(self, value: str) -> NormalizedLicenseExpression:
692706
try:
693707
return licenses.canonicalize_license_expression(value)
694708
except ValueError as exc:
@@ -722,6 +736,30 @@ def _process_license_files(self, value: list[str]) -> list[str]:
722736
paths.append(path)
723737
return paths
724738

739+
def _process_import_names(self, value: list[str]) -> list[str]:
740+
for import_name in value:
741+
name, semicolon, private = import_name.partition(";")
742+
name = name.rstrip()
743+
for identifier in name.split("."):
744+
if not identifier.isidentifier():
745+
raise self._invalid_metadata(
746+
f"{name!r} is invalid for {{field}}; "
747+
f"{identifier!r} is not a valid identifier"
748+
)
749+
elif keyword.iskeyword(identifier):
750+
raise self._invalid_metadata(
751+
f"{name!r} is invalid for {{field}}; "
752+
f"{identifier!r} is a keyword"
753+
)
754+
if semicolon and private.lstrip() != "private":
755+
raise self._invalid_metadata(
756+
f"{import_name!r} is invalid for {{field}}; "
757+
"the only valid option is 'private'"
758+
)
759+
return value
760+
761+
_process_import_namespaces = _process_import_names
762+
725763

726764
class Metadata:
727765
"""Representation of distribution metadata.
@@ -893,6 +931,10 @@ def from_email(cls, data: bytes | str, *, validate: bool = True) -> Metadata:
893931
""":external:ref:`core-metadata-provides-dist`"""
894932
obsoletes_dist: _Validator[list[str] | None] = _Validator(added="1.2")
895933
""":external:ref:`core-metadata-obsoletes-dist`"""
934+
import_names: _Validator[list[str] | None] = _Validator(added="2.5")
935+
""":external:ref:`core-metadata-import-name`"""
936+
import_namespaces: _Validator[list[str] | None] = _Validator(added="2.5")
937+
""":external:ref:`core-metadata-import-namespace`"""
896938
requires: _Validator[list[str] | None] = _Validator(added="1.1")
897939
"""``Requires`` (deprecated)"""
898940
provides: _Validator[list[str] | None] = _Validator(added="1.1")
@@ -922,6 +964,8 @@ def _write_metadata(self, message: RFC822Message) -> None:
922964
message[email_name] = f"{label}, {url}"
923965
elif email_name == "keywords":
924966
message[email_name] = ",".join(value)
967+
elif email_name == "import-name" and value == []:
968+
message[email_name] = ""
925969
elif isinstance(value, list):
926970
for item in value:
927971
message[email_name] = str(item)

tests/metadata/everything.metadata

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
Metadata-Version: 2.4
1+
Metadata-Version: 2.5
22
Name: BeagleVote
33
Version: 1.0a2
44
Platform: ObscureUnix
@@ -40,6 +40,10 @@ Provides-Dist: OtherProject
4040
Provides-Dist: AnotherProject (3.4)
4141
Provides-Dist: virtual_package; python_version >= "3.4"
4242
Dynamic: Obsoletes-Dist
43+
Import-Name: beaglevote
44+
Import-Name: _beaglevote ; private
45+
Import-Namespace: spam
46+
Import-Namespace: _bacon ; private
4347
ThisIsNotReal: Hello!
4448

4549
This description intentionally left blank.

tests/test_metadata.py

Lines changed: 88 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -189,10 +189,10 @@ def test_complete(self):
189189
with path.open("r", encoding="utf-8") as file:
190190
metadata_contents = file.read()
191191
raw, unparsed = metadata.parse_email(metadata_contents)
192-
assert len(unparsed) == 1
192+
assert len(unparsed) == 1 # "ThisIsNotReal" key
193193
assert unparsed["thisisnotreal"] == ["Hello!"]
194-
assert len(raw) == 26
195-
assert raw["metadata_version"] == "2.4"
194+
assert len(raw) == 28
195+
assert raw["metadata_version"] == "2.5"
196196
assert raw["name"] == "BeagleVote"
197197
assert raw["version"] == "1.0a2"
198198
assert raw["platforms"] == ["ObscureUnix", "RareDOS"]
@@ -251,6 +251,8 @@ def test_complete(self):
251251
]
252252
assert raw["dynamic"] == ["Obsoletes-Dist"]
253253
assert raw["description"] == "This description intentionally left blank.\n"
254+
assert raw["import_names"] == ["beaglevote", "_beaglevote ; private"]
255+
assert raw["import_namespaces"] == ["spam", "_bacon ; private"]
254256

255257

256258
class TestExceptionGroup:
@@ -267,7 +269,7 @@ def test_repr(self):
267269

268270

269271
_RAW_EXAMPLE = {
270-
"metadata_version": "2.3",
272+
"metadata_version": "2.5",
271273
"name": "packaging",
272274
"version": "2023.0.0",
273275
}
@@ -287,12 +289,19 @@ def _invalid_with_cause(self, meta, attr, cause=None, *, field=None):
287289
assert isinstance(exc.__cause__, cause)
288290

289291
def test_from_email(self):
290-
metadata_version = "2.3"
292+
metadata_version = "2.5"
291293
meta = metadata.Metadata.from_email(
292294
f"Metadata-Version: {metadata_version}", validate=False
293295
)
294296

295297
assert meta.metadata_version == metadata_version
298+
assert meta.import_names is None
299+
300+
def test_from_email_empty_import_name(self):
301+
meta = metadata.Metadata.from_email(
302+
"Metadata-Version: 2.5\nImport-Name:\n", validate=False
303+
)
304+
assert meta.import_names == []
296305

297306
def test_from_email_unparsed(self):
298307
with pytest.raises(ExceptionGroup) as exc_info:
@@ -778,13 +787,43 @@ def test_invalid_license_files(self, license_files):
778787
with pytest.raises(metadata.InvalidMetadata):
779788
meta.license_files # noqa: B018
780789

790+
@pytest.mark.parametrize("key", ["import_namespaces", "import_names"])
791+
def test_valid_import_names(self, key):
792+
import_names = [
793+
"packaging",
794+
"packaging.metadata",
795+
"_utils ; private",
796+
"_stuff;private",
797+
]
798+
meta = metadata.Metadata.from_raw({key: import_names}, validate=False)
799+
800+
assert getattr(meta, key) == import_names
801+
802+
@pytest.mark.parametrize("key", ["import_namespaces", "import_names"])
803+
@pytest.mark.parametrize(
804+
"name", ["not-valid", "still.not-valid", "stuff;", "stuff; extra"]
805+
)
806+
def test_invalid_import_names_identifier(self, key, name):
807+
meta = metadata.Metadata.from_raw({key: [name]}, validate=False)
808+
809+
with pytest.raises(metadata.InvalidMetadata):
810+
getattr(meta, key)
811+
812+
@pytest.mark.parametrize("key", ["import_namespaces", "import_names"])
813+
def test_invalid_import_names_keyword(self, key):
814+
import_names = ["class"]
815+
meta = metadata.Metadata.from_raw({key: import_names}, validate=False)
816+
817+
with pytest.raises(metadata.InvalidMetadata):
818+
getattr(meta, key)
819+
781820

782821
class TestMetadataWriting:
783822
def test_write_metadata(self):
784823
meta = metadata.Metadata.from_raw(_RAW_EXAMPLE)
785824
written = meta.as_rfc822().as_string()
786825
assert (
787-
written == "metadata-version: 2.3\nname: packaging\nversion: 2023.0.0\n\n"
826+
written == "metadata-version: 2.5\nname: packaging\nversion: 2023.0.0\n\n"
788827
)
789828

790829
def test_write_metadata_with_description(self):
@@ -931,6 +970,49 @@ def test_modern_license(self):
931970

932971
assert core_metadata.get_payload() is None
933972

973+
def test__import_names(self):
974+
meta = metadata.Metadata.from_raw(
975+
{
976+
"metadata_version": "2.5",
977+
"name": "full_metadata",
978+
"version": "3.2.1",
979+
"import_names": ["one", "two"],
980+
"import_namespaces": ["three"],
981+
}
982+
)
983+
984+
core_metadata = meta.as_rfc822()
985+
assert core_metadata.items() == [
986+
("metadata-version", "2.5"),
987+
("name", "full_metadata"),
988+
("version", "3.2.1"),
989+
("import-name", "one"),
990+
("import-name", "two"),
991+
("import-namespace", "three"),
992+
]
993+
994+
assert core_metadata.get_payload() is None
995+
996+
def test_empty_import_names(self):
997+
meta = metadata.Metadata.from_raw(
998+
{
999+
"metadata_version": "2.5",
1000+
"name": "full_metadata",
1001+
"version": "3.2.1",
1002+
"import_names": [],
1003+
}
1004+
)
1005+
1006+
core_metadata = meta.as_rfc822()
1007+
assert core_metadata.items() == [
1008+
("metadata-version", "2.5"),
1009+
("name", "full_metadata"),
1010+
("version", "3.2.1"),
1011+
("import-name", ""),
1012+
]
1013+
1014+
assert core_metadata.get_payload() is None
1015+
9341016
@pytest.mark.parametrize(
9351017
("items", "data"),
9361018
[

0 commit comments

Comments
 (0)