Skip to content

Commit dfdca85

Browse files
authored
bpo-42382: In importlib.metadata, EntryPoint objects now expose dist (#23758)
* bpo-42382: In importlib.metadata, `EntryPoint` objects now expose a `.dist` object referencing the `Distribution` when constructed from a `Distribution`. Also, sync importlib_metadata 3.3: - Add support for package discovery under package normalization rules. - The object returned by `metadata()` now has a formally-defined protocol called `PackageMetadata` with declared support for the `.get_all()` method. * Add blurb * Remove latent footnote.
1 parent f4936ad commit dfdca85

File tree

7 files changed

+286
-154
lines changed

7 files changed

+286
-154
lines changed

Doc/library/importlib.metadata.rst

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,9 @@ Every distribution includes some metadata, which you can extract using the
115115

116116
>>> wheel_metadata = metadata('wheel') # doctest: +SKIP
117117

118-
The keys of the returned data structure [#f1]_ name the metadata keywords, and
119-
their values are returned unparsed from the distribution metadata::
118+
The keys of the returned data structure, a ``PackageMetadata``,
119+
name the metadata keywords, and
120+
the values are returned unparsed from the distribution metadata::
120121

121122
>>> wheel_metadata['Requires-Python'] # doctest: +SKIP
122123
'>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*'
@@ -259,9 +260,3 @@ a custom finder, return instances of this derived ``Distribution`` in the
259260

260261

261262
.. rubric:: Footnotes
262-
263-
.. [#f1] Technically, the returned distribution metadata object is an
264-
:class:`email.message.EmailMessage`
265-
instance, but this is an implementation detail, and not part of the
266-
stable API. You should only use dictionary-like methods and syntax
267-
to access the metadata contents.

Lib/importlib/metadata.py

Lines changed: 119 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import io
21
import os
32
import re
43
import abc
@@ -18,6 +17,7 @@
1817
from importlib import import_module
1918
from importlib.abc import MetaPathFinder
2019
from itertools import starmap
20+
from typing import Any, List, Optional, Protocol, TypeVar, Union
2121

2222

2323
__all__ = [
@@ -31,7 +31,7 @@
3131
'metadata',
3232
'requires',
3333
'version',
34-
]
34+
]
3535

3636

3737
class PackageNotFoundError(ModuleNotFoundError):
@@ -43,7 +43,7 @@ def __str__(self):
4343

4444
@property
4545
def name(self):
46-
name, = self.args
46+
(name,) = self.args
4747
return name
4848

4949

@@ -60,7 +60,7 @@ class EntryPoint(
6060
r'(?P<module>[\w.]+)\s*'
6161
r'(:\s*(?P<attr>[\w.]+))?\s*'
6262
r'(?P<extras>\[.*\])?\s*$'
63-
)
63+
)
6464
"""
6565
A regular expression describing the syntax for an entry point,
6666
which might look like:
@@ -77,6 +77,8 @@ class EntryPoint(
7777
following the attr, and following any extras.
7878
"""
7979

80+
dist: Optional['Distribution'] = None
81+
8082
def load(self):
8183
"""Load the entry point from its definition. If only a module
8284
is indicated by the value, return that module. Otherwise,
@@ -104,23 +106,27 @@ def extras(self):
104106

105107
@classmethod
106108
def _from_config(cls, config):
107-
return [
109+
return (
108110
cls(name, value, group)
109111
for group in config.sections()
110112
for name, value in config.items(group)
111-
]
113+
)
112114

113115
@classmethod
114116
def _from_text(cls, text):
115117
config = ConfigParser(delimiters='=')
116118
# case sensitive: https://stackoverflow.com/q/1611799/812183
117119
config.optionxform = str
118-
try:
119-
config.read_string(text)
120-
except AttributeError: # pragma: nocover
121-
# Python 2 has no read_string
122-
config.readfp(io.StringIO(text))
123-
return EntryPoint._from_config(config)
120+
config.read_string(text)
121+
return cls._from_config(config)
122+
123+
@classmethod
124+
def _from_text_for(cls, text, dist):
125+
return (ep._for(dist) for ep in cls._from_text(text))
126+
127+
def _for(self, dist):
128+
self.dist = dist
129+
return self
124130

125131
def __iter__(self):
126132
"""
@@ -132,7 +138,7 @@ def __reduce__(self):
132138
return (
133139
self.__class__,
134140
(self.name, self.value, self.group),
135-
)
141+
)
136142

137143

138144
class PackagePath(pathlib.PurePosixPath):
@@ -159,6 +165,25 @@ def __repr__(self):
159165
return '<FileHash mode: {} value: {}>'.format(self.mode, self.value)
160166

161167

168+
_T = TypeVar("_T")
169+
170+
171+
class PackageMetadata(Protocol):
172+
def __len__(self) -> int:
173+
... # pragma: no cover
174+
175+
def __contains__(self, item: str) -> bool:
176+
... # pragma: no cover
177+
178+
def __getitem__(self, key: str) -> str:
179+
... # pragma: no cover
180+
181+
def get_all(self, name: str, failobj: _T = ...) -> Union[List[Any], _T]:
182+
"""
183+
Return all values associated with a possibly multi-valued key.
184+
"""
185+
186+
162187
class Distribution:
163188
"""A Python distribution package."""
164189

@@ -210,9 +235,8 @@ def discover(cls, **kwargs):
210235
raise ValueError("cannot accept context and kwargs")
211236
context = context or DistributionFinder.Context(**kwargs)
212237
return itertools.chain.from_iterable(
213-
resolver(context)
214-
for resolver in cls._discover_resolvers()
215-
)
238+
resolver(context) for resolver in cls._discover_resolvers()
239+
)
216240

217241
@staticmethod
218242
def at(path):
@@ -227,24 +251,24 @@ def at(path):
227251
def _discover_resolvers():
228252
"""Search the meta_path for resolvers."""
229253
declared = (
230-
getattr(finder, 'find_distributions', None)
231-
for finder in sys.meta_path
232-
)
254+
getattr(finder, 'find_distributions', None) for finder in sys.meta_path
255+
)
233256
return filter(None, declared)
234257

235258
@classmethod
236259
def _local(cls, root='.'):
237260
from pep517 import build, meta
261+
238262
system = build.compat_system(root)
239263
builder = functools.partial(
240264
meta.build,
241265
source_dir=root,
242266
system=system,
243-
)
267+
)
244268
return PathDistribution(zipfile.Path(meta.build_as_zip(builder)))
245269

246270
@property
247-
def metadata(self):
271+
def metadata(self) -> PackageMetadata:
248272
"""Return the parsed metadata for this Distribution.
249273
250274
The returned object will have keys that name the various bits of
@@ -257,17 +281,22 @@ def metadata(self):
257281
# effect is to just end up using the PathDistribution's self._path
258282
# (which points to the egg-info file) attribute unchanged.
259283
or self.read_text('')
260-
)
284+
)
261285
return email.message_from_string(text)
262286

287+
@property
288+
def name(self):
289+
"""Return the 'Name' metadata for the distribution package."""
290+
return self.metadata['Name']
291+
263292
@property
264293
def version(self):
265294
"""Return the 'Version' metadata for the distribution package."""
266295
return self.metadata['Version']
267296

268297
@property
269298
def entry_points(self):
270-
return EntryPoint._from_text(self.read_text('entry_points.txt'))
299+
return list(EntryPoint._from_text_for(self.read_text('entry_points.txt'), self))
271300

272301
@property
273302
def files(self):
@@ -324,9 +353,10 @@ def _deps_from_requires_text(cls, source):
324353
section_pairs = cls._read_sections(source.splitlines())
325354
sections = {
326355
section: list(map(operator.itemgetter('line'), results))
327-
for section, results in
328-
itertools.groupby(section_pairs, operator.itemgetter('section'))
329-
}
356+
for section, results in itertools.groupby(
357+
section_pairs, operator.itemgetter('section')
358+
)
359+
}
330360
return cls._convert_egg_info_reqs_to_simple_reqs(sections)
331361

332362
@staticmethod
@@ -350,6 +380,7 @@ def _convert_egg_info_reqs_to_simple_reqs(sections):
350380
requirement. This method converts the former to the
351381
latter. See _test_deps_from_requires_text for an example.
352382
"""
383+
353384
def make_condition(name):
354385
return name and 'extra == "{name}"'.format(name=name)
355386

@@ -438,48 +469,69 @@ def zip_children(self):
438469
names = zip_path.root.namelist()
439470
self.joinpath = zip_path.joinpath
440471

441-
return dict.fromkeys(
442-
child.split(posixpath.sep, 1)[0]
443-
for child in names
444-
)
445-
446-
def is_egg(self, search):
447-
base = self.base
448-
return (
449-
base == search.versionless_egg_name
450-
or base.startswith(search.prefix)
451-
and base.endswith('.egg'))
472+
return dict.fromkeys(child.split(posixpath.sep, 1)[0] for child in names)
452473

453474
def search(self, name):
454-
for child in self.children():
455-
n_low = child.lower()
456-
if (n_low in name.exact_matches
457-
or n_low.startswith(name.prefix)
458-
and n_low.endswith(name.suffixes)
459-
# legacy case:
460-
or self.is_egg(name) and n_low == 'egg-info'):
461-
yield self.joinpath(child)
475+
return (
476+
self.joinpath(child)
477+
for child in self.children()
478+
if name.matches(child, self.base)
479+
)
462480

463481

464482
class Prepared:
465483
"""
466484
A prepared search for metadata on a possibly-named package.
467485
"""
468-
normalized = ''
469-
prefix = ''
486+
487+
normalized = None
470488
suffixes = '.dist-info', '.egg-info'
471489
exact_matches = [''][:0]
472-
versionless_egg_name = ''
473490

474491
def __init__(self, name):
475492
self.name = name
476493
if name is None:
477494
return
478-
self.normalized = name.lower().replace('-', '_')
479-
self.prefix = self.normalized + '-'
480-
self.exact_matches = [
481-
self.normalized + suffix for suffix in self.suffixes]
482-
self.versionless_egg_name = self.normalized + '.egg'
495+
self.normalized = self.normalize(name)
496+
self.exact_matches = [self.normalized + suffix for suffix in self.suffixes]
497+
498+
@staticmethod
499+
def normalize(name):
500+
"""
501+
PEP 503 normalization plus dashes as underscores.
502+
"""
503+
return re.sub(r"[-_.]+", "-", name).lower().replace('-', '_')
504+
505+
@staticmethod
506+
def legacy_normalize(name):
507+
"""
508+
Normalize the package name as found in the convention in
509+
older packaging tools versions and specs.
510+
"""
511+
return name.lower().replace('-', '_')
512+
513+
def matches(self, cand, base):
514+
low = cand.lower()
515+
pre, ext = os.path.splitext(low)
516+
name, sep, rest = pre.partition('-')
517+
return (
518+
low in self.exact_matches
519+
or ext in self.suffixes
520+
and (not self.normalized or name.replace('.', '_') == self.normalized)
521+
# legacy case:
522+
or self.is_egg(base)
523+
and low == 'egg-info'
524+
)
525+
526+
def is_egg(self, base):
527+
normalized = self.legacy_normalize(self.name or '')
528+
prefix = normalized + '-' if normalized else ''
529+
versionless_egg_name = normalized + '.egg' if self.name else ''
530+
return (
531+
base == versionless_egg_name
532+
or base.startswith(prefix)
533+
and base.endswith('.egg')
534+
)
483535

484536

485537
class MetadataPathFinder(DistributionFinder):
@@ -500,9 +552,8 @@ def find_distributions(cls, context=DistributionFinder.Context()):
500552
def _search_paths(cls, name, paths):
501553
"""Find metadata directories in paths heuristically."""
502554
return itertools.chain.from_iterable(
503-
path.search(Prepared(name))
504-
for path in map(FastPath, paths)
505-
)
555+
path.search(Prepared(name)) for path in map(FastPath, paths)
556+
)
506557

507558

508559
class PathDistribution(Distribution):
@@ -515,9 +566,15 @@ def __init__(self, path):
515566
self._path = path
516567

517568
def read_text(self, filename):
518-
with suppress(FileNotFoundError, IsADirectoryError, KeyError,
519-
NotADirectoryError, PermissionError):
569+
with suppress(
570+
FileNotFoundError,
571+
IsADirectoryError,
572+
KeyError,
573+
NotADirectoryError,
574+
PermissionError,
575+
):
520576
return self._path.joinpath(filename).read_text(encoding='utf-8')
577+
521578
read_text.__doc__ = Distribution.read_text.__doc__
522579

523580
def locate_file(self, path):
@@ -541,11 +598,11 @@ def distributions(**kwargs):
541598
return Distribution.discover(**kwargs)
542599

543600

544-
def metadata(distribution_name):
601+
def metadata(distribution_name) -> PackageMetadata:
545602
"""Get the metadata for the named package.
546603
547604
:param distribution_name: The name of the distribution package to query.
548-
:return: An email.Message containing the parsed metadata.
605+
:return: A PackageMetadata containing the parsed metadata.
549606
"""
550607
return Distribution.from_name(distribution_name).metadata
551608

@@ -565,15 +622,11 @@ def entry_points():
565622
566623
:return: EntryPoint objects for all installed packages.
567624
"""
568-
eps = itertools.chain.from_iterable(
569-
dist.entry_points for dist in distributions())
625+
eps = itertools.chain.from_iterable(dist.entry_points for dist in distributions())
570626
by_group = operator.attrgetter('group')
571627
ordered = sorted(eps, key=by_group)
572628
grouped = itertools.groupby(ordered, by_group)
573-
return {
574-
group: tuple(eps)
575-
for group, eps in grouped
576-
}
629+
return {group: tuple(eps) for group, eps in grouped}
577630

578631

579632
def files(distribution_name):

0 commit comments

Comments
 (0)