Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit dfdca85

Browse filesBrowse 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
Copy full SHA for dfdca85

File tree

Expand file treeCollapse file tree

7 files changed

+286
-154
lines changed
Filter options
Expand file treeCollapse file tree

7 files changed

+286
-154
lines changed

‎Doc/library/importlib.metadata.rst

Copy file name to clipboardExpand all lines: Doc/library/importlib.metadata.rst
+3-8Lines 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

Copy file name to clipboardExpand all lines: Lib/importlib/metadata.py
+119-66Lines 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 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.