8000 bpo-43428: Sync with importlib_metadata 3.7. (GH-24782) · python/cpython@f917efc · GitHub
[go: up one dir, main page]

Skip to content

Commit f917efc

Browse files
authored
bpo-43428: Sync with importlib_metadata 3.7. (GH-24782)
* bpo-43428: Sync with importlib_metadata 3.7.2 (67234b6) * Add blurb * Reformat blurb to create separate paragraphs for each change included.
1 parent 2256a28 commit f917efc

File tree

8 files changed

+343
-43
lines changed

8 files changed

+343
-43
lines changed

Doc/library/importlib.metadata.rst

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,18 +74,20 @@ This package provides the following functionality via its public API.
7474
Entry points
7575
------------
7676

77-
The ``entry_points()`` function returns a dictionary of all entry points,
78-
keyed by group. Entry points are represented by ``EntryPoint`` instances;
77+
The ``entry_points()`` function returns a collection of entry points.
78+
Entry points are represented by ``EntryPoint`` instances;
7979
each ``EntryPoint`` has a ``.name``, ``.group``, and ``.value`` attributes and
8080
a ``.load()`` method to resolve the value. There are also ``.module``,
8181
``.attr``, and ``.extras`` attributes for getting the components of the
8282
``.value`` attribute::
8383

8484
>>> eps = entry_points() # doctest: +SKIP
85-
>>> list(eps) # doctest: +SKIP
85+
>>> sorted(eps.groups) # doctest: +SKIP
8686
['console_scripts', 'distutils.commands', 'distutils.setup_keywords', 'egg_info.writers', 'setuptools.installation']
87-
>>> scripts = eps['console_scripts'] # doctest: +SKIP
88-
>>> wheel = [ep for ep in scripts if ep.name == 'wheel'][0] # doctest: +SKIP
87+
>>> scripts = eps.select(group='console_scripts') # doctest: +SKIP
88+
>>> 'wheel' in scripts.names # doctest: +SKIP
89+
True
90+
>>> wheel = scripts['wheel'] # doctest: +SKIP
8991
>>> wheel # doctest: +SKIP
9092
EntryPoint(name='wheel', value='wheel.cli:main', group='console_scripts')
9193
>>> wheel.module # doctest: +SKIP
@@ -187,6 +189,17 @@ function::
187189
["pytest (>=3.0.0) ; extra == 'test'", "pytest-cov ; extra == 'test'"]
188190

189191

192+
Package distributions
193+
---------------------
194+
195+
A convience method to resolve the distribution or
196+
distributions (in the case of a namespace package) for top-level
197+
Python packages or modules::
198+
199+
>>> packages_distributions()
200+
{'importlib_metadata': ['importlib-metadata'], 'yaml': ['PyYAML'], 'jaraco': ['jaraco.classes', 'jaraco.functools'], ...}
201+
202+
190203
Distributions
191204
=============
192205

Lib/importlib/_itertools.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from itertools import filterfalse
2+
3+
4+
def unique_everseen(iterable, key=None):
5+
"List unique elements, preserving order. Remember all elements ever seen."
6+
# unique_everseen('AAAABBBCCDAABBB') --> A B C D
7+
# unique_everseen('ABBCcAD', str.lower) --> A B C D
8+
seen = set()
9+
seen_add = seen.add
10+
if key is None:
11+
for element in filterfalse(seen.__contains__, iterable):
12+
seen_add(element)
13+
yield element
14+
else:
15+
for element in iterable:
16+
k = key(element)
17+
if k not in seen:
18+
seen_add(k)
19+
yield element

Lib/importlib/metadata.py

Lines changed: 201 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,24 @@
44
import csv
55
import sys
66
import email
7+
import inspect
78
import pathlib
89
import zipfile
910
import operator
11+
import warnings
1012
import functools
1113
import itertools
1214
import posixpath
13-
import collections
15+
import collections.abc
16+
17+
from ._itertools import unique_everseen
1418

1519
from configparser import ConfigParser
1620
from contextlib import suppress
1721
from importlib import import_module
1822
from importlib.abc import MetaPathFinder
1923
from itertools import starmap
20-
from typing import Any, List, Optional, Protocol, TypeVar, Union
24+
from typing import Any, List, Mapping, Optional, Protocol, TypeVar, Union
2125

2226

2327
__all__ = [
@@ -120,18 +124,19 @@ def _from_text(cls, text):
120124
config.read_string(text)
121125
return cls._from_config(config)
122126

123-
@classmethod
124-
def _from_text_for(cls, text, dist):
125-
return (ep._for(dist) for ep in cls._from_text(text))
126-
127127
def _for(self, dist):
128128
self.dist = dist
129129
return self
130130

131131
def __iter__(self):
132132
"""
133-
Supply iter so one may construct dicts of EntryPoints easily.
133+
Supply iter so one may construct dicts of EntryPoints by name.
134134
"""
135+
msg = (
136+
"Construction of dict of EntryPoints is deprecated in "
137+
"favor of EntryPoints."
138+
)
139+
warnings.warn(msg, DeprecationWarning)
135140
return iter((self.name, self))
136141

137142
def __reduce__(self):
@@ -140,6 +145,143 @@ def __reduce__(self):
140145
(self.name, self.value, self.group),
141146
)
142147

148+
def matches(self, **params):
149+
attrs = (getattr(self, param) for param in params)
150+
return all(map(operator.eq, params.values(), attrs))
151+
152+
153+
class EntryPoints(tuple):
154+
"""
155+
An immutable collection of selectable EntryPoint objects.
156+
"""
157+
158+
__slots__ = ()
159+
160+
def __getitem__(self, name): # -> EntryPoint:
161+
try:
162+
return next(iter(self.select(name=name)))
163+
except StopIteration:
164+
raise KeyError(name)
165+
166+
def select(self, **params):
167+
return EntryPoints(ep for ep in self if ep.matches(**params))
168+
169+
@property
170+
def names(self):
171+
return set(ep.name for ep in self)
172+
173+
@property
174+
def groups(self):
175+
"""
176+
For coverage while SelectableGroups is present.
177+
>>> EntryPoints().groups
178+
set()
179+
"""
180+
return set(ep.group for ep in self)
181+
182+
@classmethod
183+
def _from_text_for(cls, text, dist):
184+
return cls(ep._for(dist) for ep in EntryPoint._from_text(text))
185+
186+
187+
def flake8_bypass(func):
188+
is_flake8 = any('flake8' in str(frame.filename) for frame in inspect.stack()[:5])
189+
return func if not is_flake8 else lambda: None
190+
191+
192+
class Deprecated:
193+
"""
194+
Compatibility add-in for mapping to indicate that
195+
mapping behavior is deprecated.
196+
197+
>>> recwarn = getfixture('recwarn')
198+
>>> class DeprecatedDict(Deprecated, dict): pass
199+
>>> dd = DeprecatedDict(foo='bar')
200+
>>> dd.get('baz', None)
201+
>>> dd['foo']
202+
'bar'
203+
>>> list(dd)
204+
['foo']
205+
>>> list(dd.keys())
206+
['foo']
207+
>>> 'foo' in dd
208+
True
209+
>>> list(dd.values())
210+
['bar']
211+
>>> len(recwarn)
212+
1
213+
"""
214+
215+
_warn = functools.partial(
216+
warnings.warn,
217+
"SelectableGroups dict interface is deprecated. Use select.",
218+
DeprecationWarning,
219+
stacklevel=2,
220+
)
221+
222+
def __getitem__(self, name):
223+
self._warn()
224+
return super().__getitem__(name)
225+
226+
def get(self, name, default=None):
227+
flake8_bypass(self._warn)()
228+
return super().get(name, default)
229+
230+
def __iter__(self):
231+
self._warn()
232+
return super().__iter__()
233+
234+
def __contains__(self, *args):
235+
self._warn()
236+
return super().__contains__(*args)
237+
238+
def keys(self):
239+
self._warn()
240+
return super().keys()
241+
242+
def values(self):
243+
self._warn()
244+
return super().values()
245+
246+
247+
class SelectableGroups(dict):
248+
"""
249+
A backward- and forward-compatible result from
250+
entry_points that fully implements the dict interface.
251+
"""
252+
253+
@classmethod
254+
def load(cls, eps):
255+
by_group = operator.attrgetter('group')
256+
ordered = sorted(eps, key=by_group)
257+
grouped = itertools.groupby(ordered, by_group)
258+
return cls((group, EntryPoints(eps)) for group, eps in grouped)
259+
260+
@property
261+
def _all(self):
262+
"""
263+
Reconstruct a list of all entrypoints from the groups.
264+
"""
265+
return EntryPoints(itertools.chain.from_iterable(self.values()))
266+
267+
@property
268+
def groups(self):
269+
return self._all.groups
270+
271+
@property
272+
def names(self):
273+
"""
274+
for coverage:
275+
>>> SelectableGroups().names
276+
set()
277+
"""
278+
return self._all.names
279+
280+
def select(self, **params):
281+
if not params:
282+
return self
283+
return self._all.select(**params)
284+
143285

144286
class PackagePath(pathlib.PurePosixPath):
145287
"""A reference to a path in a package"""
@@ -296,7 +438,7 @@ def version(self):
296438

297439
@property
298440
def entry_points(self):
299-
return list(EntryPoint._from_text_for(self.read_text('entry_points.txt'), self))
441+
return EntryPoints._from_text_for(self.read_text('entry_points.txt'), self)
300442

301443
@property
302444
def files(self):
@@ -485,15 +627,22 @@ class Prepared:
485627
"""
486628

487629
normalized = None
488-
suffixes = '.dist-info', '.egg-info'
630+
suffixes = 'dist-info', 'egg-info'
489631
exact_matches = [''][:0]
632+
egg_prefix = ''
633+
versionless_egg_name = ''
490634

491635
def __init__(self, name):
492636
self.name = name
493637
if name is None:
494638
return
495639
self.normalized = self.normalize(name)
496-
self.exact_matches = [self.normalized + suffix for suffix in self.suffixes]
640+
self.exact_matches = [
641+
self.normalized + '.' + suffix for suffix in self.suffixes
642+
]
643+
legacy_normalized = self.legacy_normalize(self.name)
644+
self.egg_prefix = legacy_normalized + '-'
645+
self.versionless_egg_name = legacy_normalized + '.egg'
497646

498647
@staticmethod
499648
def normalize(name):
@@ -512,8 +661,9 @@ def legacy_normalize(name):
512661

513662
def matches(self, cand, base):
514663
low = cand.lower()
515-
pre, ext = os.path.splitext(low)
516-
name, sep, rest = pre.partition('-')
664+
# rpartition is faster than splitext and suitable for this purpose.
665+
pre, _, ext = low.rpartition('.')
666+
name, _, rest = pre.partition('-')
517667
return (
518668
low in self.exact_matches
519669
or ext in self.suffixes
@@ -524,12 +674,9 @@ def matches(self, cand, base):
524674
)
525675

526676
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 ''
530677
return (
531-
base == versionless_egg_name
532-
or base.startswith(prefix)
678+
base == self.versionless_egg_name
679+
or base.startswith(self.egg_prefix)
533680
and base.endswith('.egg')
534681
)
535682

@@ -551,8 +698,9 @@ def find_distributions(cls, context=DistributionFinder.Context()):
551698
@classmethod
552699
def _search_paths(cls, name, paths):
553700
"""Find metadata directories in paths heuristically."""
701+
prepared = Prepared(name)
554702
return itertools.chain.from_iterable(
555-
path.search(Prepared(name)) for path in map(FastPath, paths)
703+
path.search(prepared) for path in map(FastPath, paths)
556704
)
557705

558706

@@ -617,16 +765,28 @@ def version(distribution_name):
617765
return distribution(distribution_name).version
618766

619767

620-
def entry_points():
768+
def entry_points(**params) -> Union[EntryPoints, SelectableGroups]:
621769
"""Return EntryPoint objects for all installed packages.
622770
623-
:return: EntryPoint objects for all installed packages.
771+
Pass selection parameters (group or name) to filter the
772+
result to entry points matching those properties (see
773+
EntryPoints.select()).
774+
775+
For compatibility, returns ``SelectableGroups`` object unless
776+
selection parameters are supplied. In the future, this function
777+
will return ``EntryPoints`` instead of ``SelectableGroups``
778+
even when no selection parameters are supplied.
779+
780+
For maximum future compatibility, pass selection parameters
781+
or invoke ``.select`` with parameters on the result.
782+
783+
:return: EntryPoints or SelectableGroups for all installed packages.
624784
"""
625-
eps = itertools.chain.from_iterable(dist.entry_points for dist in distributions())
626-
by_group = operator.attrgetter('group')
627-
ordered = sorted(eps, key=by_group)
628-
grouped = itertools.groupby(ordered, by_group)
629-
return {group: tuple(eps) for group, eps in grouped}
785+
unique = functools.partial(unique_everseen, key=operator.attrgetter('name'))
786+
eps = itertools.chain.from_iterable(
787+
dist.entry_points for dist in unique(distributions())
788+
)
789+
return SelectableGroups.load(eps).select(**params)
630790

631791

632792
def files(distribution_name):
@@ -646,3 +806,19 @@ def requires(distribution_name):
646806
packaging.requirement.Requirement.
647807
"""
648808
return distribution(distribution_name).requires
809+
810+
811+
def packages_distributions() -> Mapping[str, List[str]]:
812+
"""
813+
Return a mapping of top-level packages to their
814+
distributions.
815+
816+
>>> pkgs = packages_distributions()
817+
>>> all(isinstance(dist, collections.abc.Sequence) for dist in pkgs.values())
818+
True
819+
"""
820+
pkg_to_dist = collections.defaultdict(list)
821+
for dist in distributions():
822+
for pkg in (dist.read_text('top_level.txt') or '').split():
823+
pkg_to_dist[pkg].append(dist.metadata['Name'])
824+
return dict(pkg_to_dist)

0 commit comments

Comments
 (0)
0