Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
47 changes: 42 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 @@ -502,8 +513,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 +697,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 +731,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 +926,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
4 changes: 4 additions & 0 deletions tests/metadata/everything.metadata
Original file line number Diff line number Diff line change
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.
42 changes: 37 additions & 5 deletions tests/test_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,9 +189,9 @@ 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 len(raw) == 28
assert raw["metadata_version"] == "2.4"
assert raw["name"] == "BeagleVote"
assert raw["version"] == "1.0a2"
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,7 +289,7 @@ 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
)
Expand Down Expand Up @@ -778,13 +780,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
Loading