Skip to content
Merged
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
54 changes: 49 additions & 5 deletions src/packaging/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import email.message
import email.parser
import email.policy
import keyword
import pathlib
import sys
import typing
Expand Down Expand Up @@ -132,7 +133,13 @@ class RawMetadata(TypedDict, total=False):
license_expression: str
license_files: list[str]

# Metadata 2.5 - PEP 794
import_names: list[str]
import_namespaces: list[str]


# 'keywords' is special as it's a string in the core metadata spec, but we
# represent it as a list.
_STRING_FIELDS = {
"author",
"author_email",
Expand Down Expand Up @@ -165,6 +172,8 @@ class RawMetadata(TypedDict, total=False):
"requires_dist",
"requires_external",
"supported_platforms",
"import_names",
"import_namespaces",
}

_DICT_FIELDS = {
Expand Down Expand Up @@ -257,6 +266,8 @@ def _get_payload(msg: email.message.Message, source: bytes | str) -> str:
"download-url": "download_url",
"dynamic": "dynamic",
"home-page": "home_page",
"import-name": "import_names",
"import-namespace": "import_namespaces",
"keywords": "keywords",
"license": "license",
"license-expression": "license_expression",
Expand Down Expand Up @@ -436,6 +447,11 @@ def parse_email(data: bytes | str) -> tuple[RawMetadata, dict[str, list[str]]]:
# of unparsed stuff.
if raw_name in _STRING_FIELDS and len(value) == 1:
raw[raw_name] = value[0]
# If this is import_names, we need to special case the empty field
# case, which converts to an empty list instead of None. We can't let
# the empty case slip through, as it will fail validation.
elif raw_name == "import_names" and value == [""]:
raw[raw_name] = []
# If this is one of our list of string fields, then we can just assign
# the value, since email *only* has strings, and our get_all() call
# above ensures that this is a list.
Expand Down Expand Up @@ -502,8 +518,8 @@ 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"]
_MetadataVersion = Literal["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4"]
_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"]

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

Expand Down Expand Up @@ -686,9 +702,7 @@ def _process_requires_dist(
else:
return reqs

def _process_license_expression(
self, value: str
) -> NormalizedLicenseExpression | None:
def _process_license_expression(self, value: str) -> NormalizedLicenseExpression:
try:
return licenses.canonicalize_license_expression(value)
except ValueError as exc:
Expand Down Expand Up @@ -722,6 +736,30 @@ def _process_license_files(self, value: list[str]) -> list[str]:
paths.append(path)
return paths

def _process_import_names(self, value: list[str]) -> list[str]:
for import_name in value:
name, semicolon, private = import_name.partition(";")
name = name.rstrip()
for identifier in name.split("."):
if not identifier.isidentifier():
raise self._invalid_metadata(
f"{name!r} is invalid for {{field}}; "
f"{identifier!r} is not a valid identifier"
)
elif keyword.iskeyword(identifier):
raise self._invalid_metadata(
f"{name!r} is invalid for {{field}}; "
f"{identifier!r} is a keyword"
)
if semicolon and private.lstrip() != "private":
raise self._invalid_metadata(
f"{import_name!r} is invalid for {{field}}; "
"the only valid option is 'private'"
)
return value

_process_import_namespaces = _process_import_names


class Metadata:
"""Representation of distribution metadata.
Expand Down Expand Up @@ -893,6 +931,10 @@ def from_email(cls, data: bytes | str, *, validate: bool = True) -> Metadata:
""":external:ref:`core-metadata-provides-dist`"""
obsoletes_dist: _Validator[list[str] | None] = _Validator(added="1.2")
""":external:ref:`core-metadata-obsoletes-dist`"""
import_names: _Validator[list[str] | None] = _Validator(added="2.5")
""":external:ref:`core-metadata-import-name`"""
import_namespaces: _Validator[list[str] | None] = _Validator(added="2.5")
""":external:ref:`core-metadata-import-namespace`"""
requires: _Validator[list[str] | None] = _Validator(added="1.1")
"""``Requires`` (deprecated)"""
provides: _Validator[list[str] | None] = _Validator(added="1.1")
Expand Down Expand Up @@ -922,6 +964,8 @@ def _write_metadata(self, message: RFC822Message) -> None:
message[email_name] = f"{label}, {url}"
elif email_name == "keywords":
message[email_name] = ",".join(value)
elif email_name == "import-name" and value == []:
message[email_name] = ""
elif isinstance(value, list):
for item in value:
message[email_name] = str(item)
Expand Down
6 changes: 5 additions & 1 deletion tests/metadata/everything.metadata
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Metadata-Version: 2.4
Metadata-Version: 2.5
Name: BeagleVote
Version: 1.0a2
Platform: ObscureUnix
Expand Down Expand Up @@ -40,6 +40,10 @@ Provides-Dist: OtherProject
Provides-Dist: AnotherProject (3.4)
Provides-Dist: virtual_package; python_version >= "3.4"
Dynamic: Obsoletes-Dist
Import-Name: beaglevote
Import-Name: _beaglevote ; private
Import-Namespace: spam
Import-Namespace: _bacon ; private
ThisIsNotReal: Hello!

This description intentionally left blank.
94 changes: 88 additions & 6 deletions tests/test_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,10 @@ def test_complete(self):
with path.open("r", encoding="utf-8") as file:
metadata_contents = file.read()
raw, unparsed = metadata.parse_email(metadata_contents)
assert len(unparsed) == 1
assert len(unparsed) == 1 # "ThisIsNotReal" key
assert unparsed["thisisnotreal"] == ["Hello!"]
assert len(raw) == 26
assert raw["metadata_version"] == "2.4"
assert len(raw) == 28
assert raw["metadata_version"] == "2.5"
assert raw["name"] == "BeagleVote"
assert raw["version"] == "1.0a2"
assert raw["platforms"] == ["ObscureUnix", "RareDOS"]
Expand Down Expand Up @@ -251,6 +251,8 @@ def test_complete(self):
]
assert raw["dynamic"] == ["Obsoletes-Dist"]
assert raw["description"] == "This description intentionally left blank.\n"
assert raw["import_names"] == ["beaglevote", "_beaglevote ; private"]
assert raw["import_namespaces"] == ["spam", "_bacon ; private"]


class TestExceptionGroup:
Expand All @@ -267,7 +269,7 @@ def test_repr(self):


_RAW_EXAMPLE = {
"metadata_version": "2.3",
"metadata_version": "2.5",
"name": "packaging",
"version": "2023.0.0",
}
Expand All @@ -287,12 +289,19 @@ def _invalid_with_cause(self, meta, attr, cause=None, *, field=None):
assert isinstance(exc.__cause__, cause)

def test_from_email(self):
metadata_version = "2.3"
metadata_version = "2.5"
meta = metadata.Metadata.from_email(
f"Metadata-Version: {metadata_version}", validate=False
)

assert meta.metadata_version == metadata_version
assert meta.import_names is None

def test_from_email_empty_import_name(self):
meta = metadata.Metadata.from_email(
"Metadata-Version: 2.5\nImport-Name:\n", validate=False
)
assert meta.import_names == []

def test_from_email_unparsed(self):
with pytest.raises(ExceptionGroup) as exc_info:
Expand Down Expand Up @@ -778,13 +787,43 @@ def test_invalid_license_files(self, license_files):
with pytest.raises(metadata.InvalidMetadata):
meta.license_files # noqa: B018

@pytest.mark.parametrize("key", ["import_namespaces", "import_names"])
def test_valid_import_names(self, key):
import_names = [
"packaging",
"packaging.metadata",
"_utils ; private",
"_stuff;private",
]
meta = metadata.Metadata.from_raw({key: import_names}, validate=False)

assert getattr(meta, key) == import_names

@pytest.mark.parametrize("key", ["import_namespaces", "import_names"])
@pytest.mark.parametrize(
"name", ["not-valid", "still.not-valid", "stuff;", "stuff; extra"]
)
def test_invalid_import_names_identifier(self, key, name):
meta = metadata.Metadata.from_raw({key: [name]}, validate=False)

with pytest.raises(metadata.InvalidMetadata):
getattr(meta, key)

@pytest.mark.parametrize("key", ["import_namespaces", "import_names"])
def test_invalid_import_names_keyword(self, key):
import_names = ["class"]
meta = metadata.Metadata.from_raw({key: import_names}, validate=False)

with pytest.raises(metadata.InvalidMetadata):
getattr(meta, key)


class TestMetadataWriting:
def test_write_metadata(self):
meta = metadata.Metadata.from_raw(_RAW_EXAMPLE)
written = meta.as_rfc822().as_string()
assert (
written == "metadata-version: 2.3\nname: packaging\nversion: 2023.0.0\n\n"
written == "metadata-version: 2.5\nname: packaging\nversion: 2023.0.0\n\n"
)

def test_write_metadata_with_description(self):
Expand Down Expand Up @@ -931,6 +970,49 @@ def test_modern_license(self):

assert core_metadata.get_payload() is None

def test__import_names(self):
meta = metadata.Metadata.from_raw(
{
"metadata_version": "2.5",
"name": "full_metadata",
"version": "3.2.1",
"import_names": ["one", "two"],
"import_namespaces": ["three"],
}
)

core_metadata = meta.as_rfc822()
assert core_metadata.items() == [
("metadata-version", "2.5"),
("name", "full_metadata"),
("version", "3.2.1"),
("import-name", "one"),
("import-name", "two"),
("import-namespace", "three"),
]

assert core_metadata.get_payload() is None

def test_empty_import_names(self):
meta = metadata.Metadata.from_raw(
{
"metadata_version": "2.5",
"name": "full_metadata",
"version": "3.2.1",
"import_names": [],
}
)

core_metadata = meta.as_rfc822()
assert core_metadata.items() == [
("metadata-version", "2.5"),
("name", "full_metadata"),
("version", "3.2.1"),
("import-name", ""),
]

assert core_metadata.get_payload() is None

@pytest.mark.parametrize(
("items", "data"),
[
Expand Down
Loading