Skip to content

Commit 37e0c78

Browse files
authored
bpo-43926: Cleaner metadata with PEP 566 JSON support. (pythonGH-25565)
* bpo-43926: Cleaner metadata with PEP 566 JSON support. * Add blurb * Add versionchanged and versionadded declarations for changes to metadata. * Use descriptor for PEP 566
1 parent 0ad1e03 commit 37e0c78

File tree

12 files changed

+254
-26
lines changed

12 files changed

+254
-26
lines changed

Doc/library/importlib.metadata.rst

+13
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,19 @@ the values are returned unparsed from the distribution metadata::
170170
>>> wheel_metadata['Requires-Python'] # doctest: +SKIP
171171
'>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*'
172172

173+
``PackageMetadata`` also presents a ``json`` attribute that returns
174+
all the metadata in a JSON-compatible form per :PEP:`566`::
175+
176+
>>> wheel_metadata.json['requires_python']
177+
'>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*'
178+
179+
.. versionchanged:: 3.10
180+
The ``Description`` is now included in the metadata when presented
181+
through the payload. Line continuation characters have been removed.
182+
183+
.. versionadded:: 3.10
184+
The ``json`` attribute was added.
185+
173186

174187
.. _version:
175188

Lib/importlib/metadata.py renamed to Lib/importlib/metadata/__init__.py

+5-23
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import posixpath
1515
import collections
1616

17+
from . import _adapters, _meta
1718
from ._collections import FreezableDefaultDict, Pair
1819
from ._functools import method_cache
1920
from ._itertools import unique_everseen
@@ -22,7 +23,7 @@
2223
from importlib import import_module
2324
from importlib.abc import MetaPathFinder
2425
from itertools import starmap
25-
from typing import Any, List, Mapping, Optional, Protocol, TypeVar, Union
26+
from typing import List, Mapping, Optional, Union
2627

2728

2829
__all__ = [
@@ -385,25 +386,6 @@ def __repr__(self):
385386
return '<FileHash mode: {} value: {}>'.format(self.mode, self.value)
386387

387388

388-
_T = TypeVar("_T")
389-
390-
391-
class PackageMetadata(Protocol):
392-
def __len__(self) -> int:
393-
... # pragma: no cover
394-
395-
def __contains__(self, item: str) -> bool:
396-
... # pragma: no cover
397-
398-
def __getitem__(self, key: str) -> str:
399-
... # pragma: no cover
400-
401-
def get_all(self, name: str, failobj: _T = ...) -> Union[List[Any], _T]:
402-
"""
403-
Return all values associated with a possibly multi-valued key.
404-
"""
405-
406-
407389
class Distribution:
408390
"""A Python distribution package."""
409391

@@ -488,7 +470,7 @@ def _local(cls, root='.'):
488470
return PathDistribution(zipfile.Path(meta.build_as_zip(builder)))
489471

490472
@property
491-
def metadata(self) -> PackageMetadata:
473+
def metadata(self) -> _meta.PackageMetadata:
492474
"""Return the parsed metadata for this Distribution.
493475
494476
The returned object will have keys that name the various bits of
@@ -502,7 +484,7 @@ def metadata(self) -> PackageMetadata:
502484
# (which points to the egg-info file) attribute unchanged.
503485
or self.read_text('')
504486
)
505-
return email.message_from_string(text)
487+
return _adapters.Message(email.message_from_string(text))
506488

507489
@property
508490
def name(self):
@@ -829,7 +811,7 @@ def distributions(**kwargs):
829811
return Distribution.discover(**kwargs)
830812

831813

832-
def metadata(distribution_name) -> PackageMetadata:
814+
def metadata(distribution_name) -> _meta.PackageMetadata:
833815
"""Get the metadata for the named package.
834816
835817
:param distribution_name: The name of the distribution package to query.

Lib/importlib/metadata/_adapters.py

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import re
2+
import textwrap
3+
import email.message
4+
5+
from ._text import FoldedCase
6+
7+
8+
class Message(email.message.Message):
9+
multiple_use_keys = set(
10+
map(
11+
FoldedCase,
12+
[
13+
'Classifier',
14+
'Obsoletes-Dist',
15+
'Platform',
16+
'Project-URL',
17+
'Provides-Dist',
18+
'Provides-Extra',
19+
'Requires-Dist',
20+
'Requires-External',
21+
'Supported-Platform',
22+
],
23+
)
24+
)
25+
"""
26+
Keys that may be indicated multiple times per PEP 566.
27+
"""
28+
29+
def __new__(cls, orig: email.message.Message):
30+
res = super().__new__(cls)
31+
vars(res).update(vars(orig))
32+
return res
33+
34+
def __init__(self, *args, **kwargs):
35+
self._headers = self._repair_headers()
36+
37+
# suppress spurious error from mypy
38+
def __iter__(self):
39+
return super().__iter__()
40+
41+
def _repair_headers(self):
42+
def redent(value):
43+
"Correct for RFC822 indentation"
44+
if not value or '\n' not in value:
45+
return value
46+
return textwrap.dedent(' ' * 8 + value)
47+
48+
headers = [(key, redent(value)) for key, value in vars(self)['_headers']]
49+
if self._payload:
50+
headers.append(('Description', self.get_payload()))
51+
return headers
52+
53+
@property
54+
def json(self):
55+
"""
56+
Convert PackageMetadata to a JSON-compatible format
57+
per PEP 0566.
58+
"""
59+
60+
def transform(key):
61+
value = self.get_all(key) if key in self.multiple_use_keys else self[key]
62+
if key == 'Keywords':
63+
value = re.split(r'\s+', value)
64+
tk = key.lower().replace('-', '_')
65+
return tk, value
66+
67+
return dict(map(transform, map(FoldedCase, self)))
File renamed without changes.
File renamed without changes.

Lib/importlib/metadata/_meta.py

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from typing import Any, Dict, Iterator, List, Protocol, TypeVar, Union
2+
3+
4+
_T = TypeVar("_T")
5+
6+
7+
class PackageMetadata(Protocol):
8+
def __len__(self) -> int:
9+
... # pragma: no cover
10+
11+
def __contains__(self, item: str) -> bool:
12+
... # pragma: no cover
13+
14+
def __getitem__(self, key: str) -> str:
15+
... # pragma: no cover
16+
17+
def __iter__(self) -> Iterator[str]:
18+
... # pragma: no cover
19+
20+
def get_all(self, name: str, failobj: _T = ...) -> Union[List[Any], _T]:
21+
"""
22+
Return all values associated with a possibly multi-valued key.
23+
"""
24+
25+
@property
26+
def json(self) -> Dict[str, Union[str, List[str]]]:
27+
"""
28+
A JSON-compatible form of the metadata.
29+
"""

Lib/importlib/metadata/_text.py

+99
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import re
2+
3+
from ._functools import method_cache
4+
5+
6+
# from jaraco.text 3.5
7+
class FoldedCase(str):
8+
"""
9+
A case insensitive string class; behaves just like str
10+
except compares equal when the only variation is case.
11+
12+
>>> s = FoldedCase('hello world')
13+
14+
>>> s == 'Hello World'
15+
True
16+
17+
>>> 'Hello World' == s
18+
True
19+
20+
>>> s != 'Hello World'
21+
False
22+
23+
>>> s.index('O')
24+
4
25+
26+
>>> s.split('O')
27+
['hell', ' w', 'rld']
28+
29+
>>> sorted(map(FoldedCase, ['GAMMA', 'alpha', 'Beta']))
30+
['alpha', 'Beta', 'GAMMA']
31+
32+
Sequence membership is straightforward.
33+
34+
>>> "Hello World" in [s]
35+
True
36+
>>> s in ["Hello World"]
37+
True
38+
39+
You may test for set inclusion, but candidate and elements
40+
must both be folded.
41+
42+
>>> FoldedCase("Hello World") in {s}
43+
True
44+
>>> s in {FoldedCase("Hello World")}
45+
True
46+
47+
String inclusion works as long as the FoldedCase object
48+
is on the right.
49+
50+
>>> "hello" in FoldedCase("Hello World")
51+
True
52+
53+
But not if the FoldedCase object is on the left:
54+
55+
>>> FoldedCase('hello') in 'Hello World'
56+
False
57+
58+
In that case, use in_:
59+
60+
>>> FoldedCase('hello').in_('Hello World')
61+
True
62+
63+
>>> FoldedCase('hello') > FoldedCase('Hello')
64+
False
65+
"""
66+
67+
def __lt__(self, other):
68+
return self.lower() < other.lower()
69+
70+
def __gt__(self, other):
71+
return self.lower() > other.lower()
72+
73+
def __eq__(self, other):
74+
return self.lower() == other.lower()
75+
76+
def __ne__(self, other):
77+
return self.lower() != other.lower()
78+
79+
def __hash__(self):
80+
return hash(self.lower())
81+
82+
def __contains__(self, other):
83+
return super(FoldedCase, self).lower().__contains__(other.lower())
84+
85+
def in_(self, other):
86+
"Does self appear in other?"
87+
return self in FoldedCase(other)
88+
89+
# cache lower since it's likely to be called frequently.
90+
@method_cache
91+
def lower(self):
92+
return super(FoldedCase, self).lower()
93+
94+
def index(self, sub):
95+
return self.lower().index(sub.lower())
96+
97+
def split(self, splitter=' ', maxsplit=0):
98+
pattern = re.compile(re.escape(splitter), re.I)
99+
return pattern.split(self, maxsplit)

Lib/test/test_importlib/fixtures.py

+11
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
22
import sys
3+
import copy
34
import shutil
45
import pathlib
56
import tempfile
@@ -108,6 +109,16 @@ def setUp(self):
108109
super(DistInfoPkg, self).setUp()
109110
build_files(DistInfoPkg.files, self.site_dir)
110111

112+
def make_uppercase(self):
113+
"""
114+
Rewrite metadata with everything uppercase.
115+
"""
116+
shutil.rmtree(self.site_dir / "distinfo_pkg-1.0.0.dist-info")
117+
files = copy.deepcopy(DistInfoPkg.files)
118+
info = files["distinfo_pkg-1.0.0.dist-info"]
119+
info["METADATA"] = info["METADATA"].upper()
120+
build_files(files, self.site_dir)
121+
111122

112123
class DistInfoPkgWithDot(OnSysPath, SiteDir):
113124
files: FilesDef = {

Lib/test/test_importlib/test_main.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ def pkg_with_non_ascii_description(site_dir):
125125
metadata_dir.mkdir()
126126
metadata = metadata_dir / 'METADATA'
127127
with metadata.open('w', encoding='utf-8') as fp:
128-
fp.write('Description: pôrˈtend\n')
128+
fp.write('Description: pôrˈtend')
129129
return 'portend'
130130

131131
@staticmethod
@@ -145,7 +145,7 @@ def pkg_with_non_ascii_description_egg_info(site_dir):
145145
146146
pôrˈtend
147147
"""
148-
).lstrip()
148+
).strip()
149149
)
150150
return 'portend'
151151

@@ -157,7 +157,7 @@ def test_metadata_loads(self):
157157
def test_metadata_loads_egg_info(self):
158158
pkg_name = self.pkg_with_non_ascii_description_egg_info(self.site_dir)
159159
meta = metadata(pkg_name)
160-
assert meta.get_payload() == 'pôrˈtend\n'
160+
assert meta['Description'] == 'pôrˈtend'
161161

162162

163163
class DiscoveryTests(fixtures.EggInfoPkg, fixtures.DistInfoPkg, unittest.TestCase):

Lib/test/test_importlib/test_metadata_api.py

+23
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,29 @@ def test_more_complex_deps_requires_text(self):
231231

232232
assert deps == expected
233233

234+
def test_as_json(self):
235+
md = metadata('distinfo-pkg').json
236+
assert 'name' in md
237+
assert md['keywords'] == ['sample', 'package']
238+
desc = md['description']
239+
assert desc.startswith('Once upon a time\nThere was')
240+
assert len(md['requires_dist']) == 2
241+
242+
def test_as_json_egg_info(self):
243+
md = metadata('egginfo-pkg').json
244+
assert 'name' in md
245+
assert md['keywords'] == ['sample', 'package']
246+
desc = md['description']
247+
assert desc.startswith('Once upon a time\nThere was')
248+
assert len(md['classifier']) == 2
249+
250+
def test_as_json_odd_case(self):
251+
self.make_uppercase()
252+
md = metadata('distinfo-pkg').json
253+
assert 'name' in md
254+
assert len(md['requires_dist']) == 2
255+
assert md['keywords'] == ['SAMPLE', 'PACKAGE']
256+
234257

235258
class LegacyDots(fixtures.DistInfoPkgWithDotLegacy, unittest.TestCase):
236259
def test_name_normalization(self):
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
In ``importlib.metadata``, provide a uniform interface to ``Description``,
2+
allow for any field to be encoded with multiline values, remove continuation
3+
lines from multiline values, and add a ``.json`` property for easy access to
4+
the PEP 566 JSON-compatible form. Sync with ``importlib_metadata 4.0``.

0 commit comments

Comments
 (0)