Skip to content

Commit 4ecf68e

Browse files
authored
Merge pull request #11095 from sbidoul/json-metadata-sbi
Add json_metadata property to BaseDistribution
2 parents 4227198 + ae67371 commit 4ecf68e

File tree

3 files changed

+120
-1
lines changed

3 files changed

+120
-1
lines changed

src/pip/_internal/metadata/_json.py

+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Extracted from https://github.com/pfmoore/pkg_metadata
2+
3+
from email.header import Header, decode_header, make_header
4+
from email.message import Message
5+
from typing import Any, Dict, List, Union
6+
7+
METADATA_FIELDS = [
8+
# Name, Multiple-Use
9+
("Metadata-Version", False),
10+
("Name", False),
11+
("Version", False),
12+
("Dynamic", True),
13+
("Platform", True),
14+
("Supported-Platform", True),
15+
("Summary", False),
16+
("Description", False),
17+
("Description-Content-Type", False),
18+
("Keywords", False),
19+
("Home-page", False),
20+
("Download-URL", False),
21+
("Author", False),
22+
("Author-email", False),
23+
("Maintainer", False),
24+
("Maintainer-email", False),
25+
("License", False),
26+
("Classifier", True),
27+
("Requires-Dist", True),
28+
("Requires-Python", False),
29+
("Requires-External", True),
30+
("Project-URL", True),
31+
("Provides-Extra", True),
32+
("Provides-Dist", True),
33+
("Obsoletes-Dist", True),
34+
]
35+
36+
37+
def json_name(field: str) -> str:
38+
return field.lower().replace("-", "_")
39+
40+
41+
def msg_to_json(msg: Message) -> Dict[str, Any]:
42+
"""Convert a Message object into a JSON-compatible dictionary."""
43+
44+
def sanitise_header(h: Union[Header, str]) -> str:
45+
if isinstance(h, Header):
46+
chunks = []
47+
for bytes, encoding in decode_header(h):
48+
if encoding == "unknown-8bit":
49+
try:
50+
# See if UTF-8 works
51+
bytes.decode("utf-8")
52+
encoding = "utf-8"
53+
except UnicodeDecodeError:
54+
# If not, latin1 at least won't fail
55+
encoding = "latin1"
56+
chunks.append((bytes, encoding))
57+
return str(make_header(chunks))
58+
return str(h)
59+
60+
result = {}
61+
for field, multi in METADATA_FIELDS:
62+
if field not in msg:
63+
continue
64+
key = json_name(field)
65+
if multi:
66+
value: Union[str, List[str]] = [
67+
sanitise_header(v) for v in msg.get_all(field)
68+
]
69+
else:
70+
value = sanitise_header(msg.get(field))
71+
if key == "keywords":
72+
# Accept both comma-separated and space-separated
73+
# forms, for better compatibility with old data.
74+
if "," in value:
75+
value = [v.strip() for v in value.split(",")]
76+
else:
77+
value = value.split()
78+
result[key] = value
79+
80+
payload = msg.get_payload()
81+
if payload:
82+
result["description"] = payload
83+
84+
return result

src/pip/_internal/metadata/base.py

+15
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
from typing import (
1010
IO,
1111
TYPE_CHECKING,
12+
Any,
1213
Collection,
1314
Container,
15+
Dict,
1416
Iterable,
1517
Iterator,
1618
List,
@@ -38,6 +40,8 @@
3840
from pip._internal.utils.packaging import safe_extra
3941
from pip._internal.utils.urls import url_to_path
4042

43+
from ._json import msg_to_json
44+
4145
if TYPE_CHECKING:
4246
from typing import Protocol
4347
else:
@@ -379,6 +383,17 @@ def metadata(self) -> email.message.Message:
379383
"""
380384
return self._metadata_cached()
381385

386+
@property
387+
def metadata_dict(self) -> Dict[str, Any]:
388+
"""PEP 566 compliant JSON-serializable representation of METADATA or PKG-INFO.
389+
390+
This should return an empty dict if the metadata file is unavailable.
391+
392+
:raises NoneMetadataError: If the metadata file is available, but does
393+
not contain valid metadata.
394+
"""
395+
return msg_to_json(self.metadata)
396+
382397
@property
383398
def metadata_version(self) -> Optional[str]:
384399
"""Value of "Metadata-Version:" in distribution metadata, if available."""

tests/unit/metadata/test_metadata.py

+21-1
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,14 @@
66
import pytest
77
from pip._vendor.packaging.utils import NormalizedName
88

9-
from pip._internal.metadata import BaseDistribution, get_directory_distribution
9+
from pip._internal.metadata import (
10+
BaseDistribution,
11+
get_directory_distribution,
12+
get_wheel_distribution,
13+
)
14+
from pip._internal.metadata.base import FilesystemWheel
1015
from pip._internal.models.direct_url import DIRECT_URL_METADATA_NAME, ArchiveInfo
16+
from tests.lib.wheel import make_wheel
1117

1218

1319
@mock.patch.object(BaseDistribution, "read_text", side_effect=FileNotFoundError)
@@ -82,3 +88,17 @@ class FakeDistribution(BaseDistribution):
8288
mock_read_text.assert_called_once_with(DIRECT_URL_METADATA_NAME)
8389
assert direct_url.url == "https://e.c/p.tgz"
8490
assert isinstance(direct_url.info, ArchiveInfo)
91+
92+
93+
def test_metadata_dict(tmp_path: Path) -> None:
94+
"""Basic test of BaseDistribution metadata_dict.
95+
96+
More tests are available in the original pkg_metadata project where this
97+
function comes from, and which we may vendor in the future.
98+
"""
99+
wheel_path = make_wheel(name="pkga", version="1.0.1").save_to_dir(tmp_path)
100+
wheel = FilesystemWheel(wheel_path)
101+
dist = get_wheel_distribution(wheel, "pkga")
102+
metadata_dict = dist.metadata_dict
103+
assert metadata_dict["name"] == "pkga"
104+
assert metadata_dict["version"] == "1.0.1"

0 commit comments

Comments
 (0)