Skip to content

Commit 922e018

Browse files
committed
bpo-43926: Cleaner metadata with PEP 566 JSON support.
1 parent 91b69b7 commit 922e018

11 files changed

Lines changed: 534 additions & 101 deletions

File tree

Doc/library/importlib.metadata.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,12 @@ 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+
173179

174180
.. _version:
175181

Lines changed: 148 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,23 @@
77
import pathlib
88
import zipfile
99
import operator
10+
import textwrap
1011
import warnings
1112
import functools
1213
import itertools
1314
import posixpath
1415
import collections
1516

17+
from . import _adapters, _meta
18+
from ._collections import FreezableDefaultDict, Pair
19+
from ._functools import method_cache
1620
from ._itertools import unique_everseen
1721

18-
from configparser import ConfigParser
1922
from contextlib import suppress
2023
from importlib import import_module
2124
from importlib.abc import MetaPathFinder
2225
from itertools import starmap
23-
from typing import Any, List, Mapping, Optional, Protocol, TypeVar, Union
26+
from typing import List, Mapping, Optional, Union
2427

2528

2629
__all__ = [
@@ -51,6 +54,71 @@ def name(self):
5154
return name
5255

5356

57+
class Sectioned:
58+
"""
59+
A simple entry point config parser for performance
60+
61+
>>> for item in Sectioned.read(Sectioned._sample):
62+
... print(item)
63+
Pair(name='sec1', value='# comments ignored')
64+
Pair(name='sec1', value='a = 1')
65+
Pair(name='sec1', value='b = 2')
66+
Pair(name='sec2', value='a = 2')
67+
68+
>>> res = Sectioned.section_pairs(Sectioned._sample)
69+
>>> item = next(res)
70+
>>> item.name
71+
'sec1'
72+
>>> item.value
73+
Pair(name='a', value='1')
74+
>>> item = next(res)
75+
>>> item.value
76+
Pair(name='b', value='2')
77+
>>> item = next(res)
78+
>>> item.name
79+
'sec2'
80+
>>> item.value
81+
Pair(name='a', value='2')
82+
>>> list(res)
83+
[]
84+
"""
85+
86+
_sample = textwrap.dedent(
87+
"""
88+
[sec1]
89+
# comments ignored
90+
a = 1
91+
b = 2
92+
93+
[sec2]
94+
a = 2
95+
"""
96+
).lstrip()
97+
98+
@classmethod
99+
def section_pairs(cls, text):
100+
return (
101+
section._replace(value=Pair.parse(section.value))
102+
for section in cls.read(text, filter_=cls.valid)
103+
if section.name is not None
104+
)
105+
106+
@staticmethod
107+
def read(text, filter_=None):
108+
lines = filter(filter_, map(str.strip, text.splitlines()))
109+
name = None
110+
for value in lines:
111+
section_match = value.startswith('[') and value.endswith(']')
112+
if section_match:
113+
name = value.strip('[]')
114+
continue
115+
yield Pair(name, value)
116+
117+
@staticmethod
118+
def valid(line):
119+
return line and not line.startswith('#')
120+
121+
54122
class EntryPoint(
55123
collections.namedtuple('EntryPointBase', 'name value group')):
56124
"""An entry point as defined by Python packaging conventions.
@@ -108,22 +176,6 @@ def extras(self):
108176
match = self.pattern.match(self.value)
109177
return list(re.finditer(r'\w+', match.group('extras') or ''))
110178

111-
@classmethod
112-
def _from_config(cls, config):
113-
return (
114-
cls(name, value, group)
115-
for group in config.sections()
116-
for name, value in config.items(group)
117-
)
118-
119-
@classmethod
120-
def _from_text(cls, text):
121-
config = ConfigParser(delimiters='=')
122-
# case sensitive: https://stackoverflow.com/q/1611799/812183
123-
config.optionxform = str
124-
config.read_string(text)
125-
return cls._from_config(config)
126-
127179
def _for(self, dist):
128180
self.dist = dist
129181
return self
@@ -193,7 +245,18 @@ def groups(self):
193245

194246
@classmethod
195247
def _from_text_for(cls, text, dist):
196-
return cls(ep._for(dist) for ep in EntryPoint._from_text(text))
248+
return cls(ep._for(dist) for ep in cls._from_text(text))
249+
250+
@classmethod
251+
def _from_text(cls, text):
252+
return itertools.starmap(EntryPoint, cls._parse_groups(text or ''))
253+
254+
@staticmethod
255+
def _parse_groups(text):
256+
return (
257+
(item.value.name, item.value.value, item.name)
258+
for item in Sectioned.section_pairs(text)
259+
)
197260

198261

199262
def flake8_bypass(func):
@@ -259,7 +322,7 @@ def values(self):
259322
return super().values()
260323

261324

262-
class SelectableGroups(dict):
325+
class SelectableGroups(Deprecated, dict):
263326
"""
264327
A backward- and forward-compatible result from
265328
entry_points that fully implements the dict interface.
@@ -277,7 +340,8 @@ def _all(self):
277340
"""
278341
Reconstruct a list of all entrypoints from the groups.
279342
"""
280-
return EntryPoints(itertools.chain.from_iterable(self.values()))
343+
groups = super(Deprecated, self).values()
344+
return EntryPoints(itertools.chain.from_iterable(groups))
281345

282346
@property
283347
def groups(self):
@@ -322,25 +386,6 @@ def __repr__(self):
322386
return '<FileHash mode: {} value: {}>'.format(self.mode, self.value)
323387

324388

325-
_T = TypeVar("_T")
326-
327-
328-
class PackageMetadata(Protocol):
329-
def __len__(self) -> int:
330-
... # pragma: no cover
331-
332-
def __contains__(self, item: str) -> bool:
333-
... # pragma: no cover
334-
335-
def __getitem__(self, key: str) -> str:
336-
... # pragma: no cover
337-
338-
def get_all(self, name: str, failobj: _T = ...) -> Union[List[Any], _T]:
339-
"""
340-
Return all values associated with a possibly multi-valued key.
341-
"""
342-
343-
344389
class Distribution:
345390
"""A Python distribution package."""
346391

@@ -425,7 +470,7 @@ def _local(cls, root='.'):
425470
return PathDistribution(zipfile.Path(meta.build_as_zip(builder)))
426471

427472
@property
428-
def metadata(self) -> PackageMetadata:
473+
def metadata(self) -> _meta.PackageMetadata:
429474
"""Return the parsed metadata for this Distribution.
430475
431476
The returned object will have keys that name the various bits of
@@ -439,7 +484,7 @@ def metadata(self) -> PackageMetadata:
439484
# (which points to the egg-info file) attribute unchanged.
440485
or self.read_text('')
441486
)
442-
return email.message_from_string(text)
487+
return _adapters.Message(email.message_from_string(text))
443488

444489
@property
445490
def name(self):
@@ -507,24 +552,7 @@ def _read_egg_info_reqs(self):
507552

508553
@classmethod
509554
def _deps_from_requires_text(cls, source):
510-
section_pairs = cls._read_sections(source.splitlines())
511-
sections = {
512-
section: list(map(operator.itemgetter('line'), results))
513-
for section, results in itertools.groupby(
514-
section_pairs, operator.itemgetter('section')
515-
)
516-
}
517-
return cls._convert_egg_info_reqs_to_simple_reqs(sections)
518-
519-
@staticmethod
520-
def _read_sections(lines):
521-
section = None
522-
for line in filter(None, lines):
523-
section_match = re.match(r'\[(.*)\]$', line)
524-
if section_match:
525-
section = section_match.group(1)
526-
continue
527-
yield locals()
555+
return cls._convert_egg_info_reqs_to_simple_reqs(Sectioned.read(source))
528556

529557
@staticmethod
530558
def _convert_egg_info_reqs_to_simple_reqs(sections):
@@ -549,9 +577,8 @@ def parse_condition(section):
549577
conditions = list(filter(None, [markers, make_condition(extra)]))
550578
return '; ' + ' and '.join(conditions) if conditions else ''
551579

552-
for section, deps in sections.items():
553-
for dep in deps:
554-
yield dep + parse_condition(section)
580+
for section in sections:
581+
yield section.value + parse_condition(section.name)
555582

556583

557584
class DistributionFinder(MetaPathFinder):
@@ -607,6 +634,10 @@ class FastPath:
607634
children.
608635
"""
609636

637+
@functools.lru_cache() # type: ignore
638+
def __new__(cls, root):
639+
return super().__new__(cls)
640+
610641
def __init__(self, root):
611642
self.root = root
612643
self.base = os.path.basename(self.root).lower()
@@ -629,11 +660,53 @@ def zip_children(self):
629660
return dict.fromkeys(child.split(posixpath.sep, 1)[0] for child in names)
630661

631662
def search(self, name):
632-
return (
633-
self.joinpath(child)
634-
for child in self.children()
635-
if name.matches(child, self.base)
663+
return self.lookup(self.mtime).search(name)
664+
665+
@property
666+
def mtime(self):
667+
with suppress(OSError):
668+
return os.stat(self.root).st_mtime
669+
self.lookup.cache_clear()
670+
671+
@method_cache
672+
def lookup(self, mtime):
673+
return Lookup(self)
674+
675+
676+
class Lookup:
677+
def __init__(self, path: FastPath):
678+
base = os.path.basename(path.root).lower()
679+
base_is_egg = base.endswith(".egg")
680+
self.infos = FreezableDefaultDict(list)
681+
self.eggs = FreezableDefaultDict(list)
682+
683+
for child in path.children():
684+
low = child.lower()
685+
if low.endswith((".dist-info", ".egg-info")):
686+
# rpartition is faster than splitext and suitable for this purpose.
687+
name = low.rpartition(".")[0].partition("-")[0]
688+
normalized = Prepared.normalize(name)
689+
self.infos[normalized].append(path.joinpath(child))
690+
elif base_is_egg and low == "egg-info":
691+
name = base.rpartition(".")[0].partition("-")[0]
692+
legacy_normalized = Prepared.legacy_normalize(name)
693+
self.eggs[legacy_normalized].append(path.joinpath(child))
694+
695+
self.infos.freeze()
696+
self.eggs.freeze()
697+
698+
def search(self, prepared):
699+
infos = (
700+
self.infos[prepared.normalized]
701+
if prepared
702+
else itertools.chain.from_iterable(self.infos.values())
636703
)
704+
eggs = (
705+
self.eggs[prepared.legacy_normalized]
706+
if prepared
707+
else itertools.chain.from_iterable(self.eggs.values())
708+
)
709+
return itertools.chain(infos, eggs)
637710

638711

639712
class Prepared:
@@ -642,22 +715,14 @@ class Prepared:
642715
"""
643716

644717
normalized = None
645-
suffixes = 'dist-info', 'egg-info'
646-
exact_matches = [''][:0]
647-
egg_prefix = ''
648-
versionless_egg_name = ''
718+
legacy_normalized = None
649719

650720
def __init__(self, name):
651721
self.name = name
652722
if name is None:
653723
return
654724
self.normalized = self.normalize(name)
655-
self.exact_matches = [
656-
self.normalized + '.' + suffix for suffix in self.suffixes
657-
]
658-
legacy_normalized = self.legacy_normalize(self.name)
659-
self.egg_prefix = legacy_normalized + '-'
660-
self.versionless_egg_name = legacy_normalized + '.egg'
725+
self.legacy_normalized = self.legacy_normalize(name)
661726

662727
@staticmethod
663728
def normalize(name):
@@ -674,26 +739,8 @@ def legacy_normalize(name):
674739
"""
675740
return name.lower().replace('-', '_')
676741

677-
def matches(self, cand, base):
678-
low = cand.lower()
679-
# rpartition is faster than splitext and suitable for this purpose.
680-
pre, _, ext = low.rpartition('.')
681-
name, _, rest = pre.partition('-')
682-
return (
683-
low in self.exact_matches
684-
or ext in self.suffixes
685-
and (not self.normalized or name.replace('.', '_') == self.normalized)
686-
# legacy case:
687-
or self.is_egg(base)
688-
and low == 'egg-info'
689-
)
690-
691-
def is_egg(self, base):
692-
return (
693-
base == self.versionless_egg_name
694-
or base.startswith(self.egg_prefix)
695-
and base.endswith('.egg')
696-
)
742+
def __bool__(self):
743+
return bool(self.name)
697744

698745

699746
class MetadataPathFinder(DistributionFinder):
@@ -718,6 +765,9 @@ def _search_paths(cls, name, paths):
718765
path.search(prepared) for path in map(FastPath, paths)
719766
)
720767

768+
def invalidate_caches(cls):
769+
FastPath.__new__.cache_clear()
770+
721771

722772
class PathDistribution(Distribution):
723773
def __init__(self, path):
@@ -761,7 +811,7 @@ def distributions(**kwargs):
761811
return Distribution.discover(**kwargs)
762812

763813

764-
def metadata(distribution_name) -> PackageMetadata:
814+
def metadata(distribution_name) -> _meta.PackageMetadata:
765815
"""Get the metadata for the named package.
766816
767817
:param distribution_name: The name of the distribution package to query.

0 commit comments

Comments
 (0)