Skip to content

Commit 615044d

Browse files
committed
Merge pull request #304.
2 parents ed2b2c8 + c8b753e commit 615044d

File tree

8 files changed

+259
-27
lines changed

8 files changed

+259
-27
lines changed

CHANGES.rst

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,24 @@
1+
v4.0.0
2+
=======
3+
4+
* #304: ``PackageMetadata`` as returned by ``metadata()``
5+
and ``Distribution.metadata()`` now provides normalized
6+
metadata honoring PEP 566:
7+
8+
- If a long description is provided in the payload of the
9+
RFC 822 value, it can be retrieved as the ``Description``
10+
field.
11+
- Any multi-line values in the metadata will be returned as
12+
such.
13+
- For any multi-line values, line continuation characters
14+
are removed. This backward-incompatible change means
15+
that any projects relying on the RFC 822 line continuation
16+
characters being present must be tolerant to them having
17+
been removed.
18+
- Add a ``json`` property that provides the metadata
19+
converted to a JSON-compatible form per PEP 566.
20+
21+
122
v3.10.1
223
=======
324

importlib_metadata/__init__.py

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@
1414
import posixpath
1515
import collections
1616

17+
from . import _adapters, _meta
1718
from ._collections import FreezableDefaultDict, Pair
1819
from ._compat import (
1920
NullFinder,
20-
Protocol,
2121
PyPy_repr,
2222
install,
2323
)
@@ -28,7 +28,7 @@
2828
from importlib import import_module
2929
from importlib.abc import MetaPathFinder
3030
from itertools import starmap
31-
from typing import Any, List, Mapping, Optional, TypeVar, Union
31+
from typing import List, Mapping, Optional, Union
3232

3333

3434
__all__ = [
@@ -392,25 +392,6 @@ def __repr__(self):
392392
return '<FileHash mode: {} value: {}>'.format(self.mode, self.value)
393393

394394

395-
_T = TypeVar("_T")
396-
397-
398-
class PackageMetadata(Protocol):
399-
def __len__(self) -> int:
400-
... # pragma: no cover
401-
402-
def __contains__(self, item: str) -> bool:
403-
... # pragma: no cover
404-
405-
def __getitem__(self, key: str) -> str:
406-
... # pragma: no cover
407-
408-
def get_all(self, name: str, failobj: _T = ...) -> Union[List[Any], _T]:
409-
"""
410-
Return all values associated with a possibly multi-valued key.
411-
"""
412-
413-
414395
class Distribution:
415396
"""A Python distribution package."""
416397

@@ -495,7 +476,7 @@ def _local(cls, root='.'):
495476
return PathDistribution(zipp.Path(meta.build_as_zip(builder)))
496477

497478
@property
498-
def metadata(self) -> PackageMetadata:
479+
def metadata(self) -> _meta.PackageMetadata:
499480
"""Return the parsed metadata for this Distribution.
500481
501482
The returned object will have keys that name the various bits of
@@ -509,7 +490,7 @@ def metadata(self) -> PackageMetadata:
509490
# (which points to the egg-info file) attribute unchanged.
510491
or self.read_text('')
511492
)
512-
return email.message_from_string(text)
493+
return _adapters.Message(email.message_from_string(text))
513494

514495
@property
515496
def name(self):
@@ -841,7 +822,7 @@ def distributions(**kwargs):
841822
return Distribution.discover(**kwargs)
842823

843824

844-
def metadata(distribution_name) -> PackageMetadata:
825+
def metadata(distribution_name) -> _meta.PackageMetadata:
845826
"""Get the metadata for the named package.
846827
847828
:param distribution_name: The name of the distribution package to query.

importlib_metadata/_adapters.py

Lines changed: 67 additions & 0 deletions
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)))

importlib_metadata/_meta.py

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

importlib_metadata/_text.py

Lines changed: 99 additions & 0 deletions
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)

tests/fixtures.py

Lines changed: 11 additions & 0 deletions
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 = {

tests/test_api.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,29 @@ def test_more_complex_deps_requires_text(self):
246246

247247
assert deps == expected
248248

249+
def test_as_json(self):
250+
md = metadata('distinfo-pkg').json
251+
assert 'name' in md
252+
assert md['keywords'] == ['sample', 'package']
253+
desc = md['description']
254+
assert desc.startswith('Once upon a time\nThere was')
255+
assert len(md['requires_dist']) == 2
256+
257+
def test_as_json_egg_info(self):
258+
md = metadata('egginfo-pkg').json
259+
assert 'name' in md
260+
assert md['keywords'] == ['sample', 'package']
261+
desc = md['description']
262+
assert desc.startswith('Once upon a time\nThere was')
263+
assert len(md['classifier']) == 2
264+
265+
def test_as_json_odd_case(self):
266+
self.make_uppercase()
267+
md = metadata('distinfo-pkg').json
268+
assert 'name' in md
269+
assert len(md['requires_dist']) == 2
270+
assert md['keywords'] == ['SAMPLE', 'PACKAGE']
271+
249272

250273
class LegacyDots(fixtures.DistInfoPkgWithDotLegacy, unittest.TestCase):
251274
def test_name_normalization(self):

tests/test_main.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ def pkg_with_non_ascii_description(site_dir):
130130
metadata_dir.mkdir()
131131
metadata = metadata_dir / 'METADATA'
132132
with metadata.open('w', encoding='utf-8') as fp:
133-
fp.write('Description: pôrˈtend\n')
133+
fp.write('Description: pôrˈtend')
134134
return 'portend'
135135

136136
@staticmethod
@@ -150,7 +150,7 @@ def pkg_with_non_ascii_description_egg_info(site_dir):
150150
151151
pôrˈtend
152152
"""
153-
).lstrip()
153+
).strip()
154154
)
155155
return 'portend'
156156

@@ -162,7 +162,7 @@ def test_metadata_loads(self):
162162
def test_metadata_loads_egg_info(self):
163163
pkg_name = self.pkg_with_non_ascii_description_egg_info(self.site_dir)
164164
meta = metadata(pkg_name)
165-
assert meta.get_payload() == 'pôrˈtend\n'
165+
assert meta['Description'] == 'pôrˈtend'
166166

167167

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

0 commit comments

Comments
 (0)