diff options
author | Jason R. Coombs <jaraco@jaraco.com> | 2021-03-13 16:31:45 (GMT) |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-03-13 16:31:45 (GMT) |
commit | f917efccf8d5aa2b8315d2a832a520339e668187 (patch) | |
tree | 25319b76b7c107939278df8ebaaeed52619462bb /Lib/importlib | |
parent | 2256a2876b5214a5a7492bf78bd86cf8beb690bf (diff) | |
download | cpython-f917efccf8d5aa2b8315d2a832a520339e668187.zip cpython-f917efccf8d5aa2b8315d2a832a520339e668187.tar.gz cpython-f917efccf8d5aa2b8315d2a832a520339e668187.tar.bz2 |
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.
Diffstat (limited to 'Lib/importlib')
-rw-r--r-- | Lib/importlib/_itertools.py | 19 | ||||
-rw-r--r-- | Lib/importlib/metadata.py | 226 |
2 files changed, 220 insertions, 25 deletions
diff --git a/Lib/importlib/_itertools.py b/Lib/importlib/_itertools.py new file mode 100644 index 0000000..dd45f2f --- /dev/null +++ b/Lib/importlib/_itertools.py @@ -0,0 +1,19 @@ +from itertools import filterfalse + + +def unique_everseen(iterable, key=None): + "List unique elements, preserving order. Remember all elements ever seen." + # unique_everseen('AAAABBBCCDAABBB') --> A B C D + # unique_everseen('ABBCcAD', str.lower) --> A B C D + seen = set() + seen_add = seen.add + if key is None: + for element in filterfalse(seen.__contains__, iterable): + seen_add(element) + yield element + else: + for element in iterable: + k = key(element) + if k not in seen: + seen_add(k) + yield element diff --git a/Lib/importlib/metadata.py b/Lib/importlib/metadata.py index 36bb42e..8a73185 100644 --- a/Lib/importlib/metadata.py +++ b/Lib/importlib/metadata.py @@ -4,20 +4,24 @@ import abc import csv import sys import email +import inspect import pathlib import zipfile import operator +import warnings import functools import itertools import posixpath -import collections +import collections.abc + +from ._itertools import unique_everseen from configparser import ConfigParser from contextlib import suppress from importlib import import_module from importlib.abc import MetaPathFinder from itertools import starmap -from typing import Any, List, Optional, Protocol, TypeVar, Union +from typing import Any, List, Mapping, Optional, Protocol, TypeVar, Union __all__ = [ @@ -120,18 +124,19 @@ class EntryPoint( config.read_string(text) return cls._from_config(config) - @classmethod - def _from_text_for(cls, text, dist): - return (ep._for(dist) for ep in cls._from_text(text)) - def _for(self, dist): self.dist = dist return self def __iter__(self): """ - Supply iter so one may construct dicts of EntryPoints easily. + Supply iter so one may construct dicts of EntryPoints by name. """ + msg = ( + "Construction of dict of EntryPoints is deprecated in " + "favor of EntryPoints." + ) + warnings.warn(msg, DeprecationWarning) return iter((self.name, self)) def __reduce__(self): @@ -140,6 +145,143 @@ class EntryPoint( (self.name, self.value, self.group), ) + def matches(self, **params): + attrs = (getattr(self, param) for param in params) + return all(map(operator.eq, params.values(), attrs)) + + +class EntryPoints(tuple): + """ + An immutable collection of selectable EntryPoint objects. + """ + + __slots__ = () + + def __getitem__(self, name): # -> EntryPoint: + try: + return next(iter(self.select(name=name))) + except StopIteration: + raise KeyError(name) + + def select(self, **params): + return EntryPoints(ep for ep in self if ep.matches(**params)) + + @property + def names(self): + return set(ep.name for ep in self) + + @property + def groups(self): + """ + For coverage while SelectableGroups is present. + >>> EntryPoints().groups + set() + """ + return set(ep.group for ep in self) + + @classmethod + def _from_text_for(cls, text, dist): + return cls(ep._for(dist) for ep in EntryPoint._from_text(text)) + + +def flake8_bypass(func): + is_flake8 = any('flake8' in str(frame.filename) for frame in inspect.stack()[:5]) + return func if not is_flake8 else lambda: None + + +class Deprecated: + """ + Compatibility add-in for mapping to indicate that + mapping behavior is deprecated. + + >>> recwarn = getfixture('recwarn') + >>> class DeprecatedDict(Deprecated, dict): pass + >>> dd = DeprecatedDict(foo='bar') + >>> dd.get('baz', None) + >>> dd['foo'] + 'bar' + >>> list(dd) + ['foo'] + >>> list(dd.keys()) + ['foo'] + >>> 'foo' in dd + True + >>> list(dd.values()) + ['bar'] + >>> len(recwarn) + 1 + """ + + _warn = functools.partial( + warnings.warn, + "SelectableGroups dict interface is deprecated. Use select.", + DeprecationWarning, + stacklevel=2, + ) + + def __getitem__(self, name): + self._warn() + return super().__getitem__(name) + + def get(self, name, default=None): + flake8_bypass(self._warn)() + return super().get(name, default) + + def __iter__(self): + self._warn() + return super().__iter__() + + def __contains__(self, *args): + self._warn() + return super().__contains__(*args) + + def keys(self): + self._warn() + return super().keys() + + def values(self): + self._warn() + return super().values() + + +class SelectableGroups(dict): + """ + A backward- and forward-compatible result from + entry_points that fully implements the dict interface. + """ + + @classmethod + def load(cls, eps): + by_group = operator.attrgetter('group') + ordered = sorted(eps, key=by_group) + grouped = itertools.groupby(ordered, by_group) + return cls((group, EntryPoints(eps)) for group, eps in grouped) + + @property + def _all(self): + """ + Reconstruct a list of all entrypoints from the groups. + """ + return EntryPoints(itertools.chain.from_iterable(self.values())) + + @property + def groups(self): + return self._all.groups + + @property + def names(self): + """ + for coverage: + >>> SelectableGroups().names + set() + """ + return self._all.names + + def select(self, **params): + if not params: + return self + return self._all.select(**params) + class PackagePath(pathlib.PurePosixPath): """A reference to a path in a package""" @@ -296,7 +438,7 @@ class Distribution: @property def entry_points(self): - return list(EntryPoint._from_text_for(self.read_text('entry_points.txt'), self)) + return EntryPoints._from_text_for(self.read_text('entry_points.txt'), self) @property def files(self): @@ -485,15 +627,22 @@ class Prepared: """ normalized = None - suffixes = '.dist-info', '.egg-info' + suffixes = 'dist-info', 'egg-info' exact_matches = [''][:0] + egg_prefix = '' + versionless_egg_name = '' def __init__(self, name): self.name = name if name is None: return self.normalized = self.normalize(name) - self.exact_matches = [self.normalized + suffix for suffix in self.suffixes] + self.exact_matches = [ + self.normalized + '.' + suffix for suffix in self.suffixes + ] + legacy_normalized = self.legacy_normalize(self.name) + self.egg_prefix = legacy_normalized + '-' + self.versionless_egg_name = legacy_normalized + '.egg' @staticmethod def normalize(name): @@ -512,8 +661,9 @@ class Prepared: def matches(self, cand, base): low = cand.lower() - pre, ext = os.path.splitext(low) - name, sep, rest = pre.partition('-') + # rpartition is faster than splitext and suitable for this purpose. + pre, _, ext = low.rpartition('.') + name, _, rest = pre.partition('-') return ( low in self.exact_matches or ext in self.suffixes @@ -524,12 +674,9 @@ class Prepared: ) def is_egg(self, base): - normalized = self.legacy_normalize(self.name or '') - prefix = normalized + '-' if normalized else '' - versionless_egg_name = normalized + '.egg' if self.name else '' return ( - base == versionless_egg_name - or base.startswith(prefix) + base == self.versionless_egg_name + or base.startswith(self.egg_prefix) and base.endswith('.egg') ) @@ -551,8 +698,9 @@ class MetadataPathFinder(DistributionFinder): @classmethod def _search_paths(cls, name, paths): """Find metadata directories in paths heuristically.""" + prepared = Prepared(name) return itertools.chain.from_iterable( - path.search(Prepared(name)) for path in map(FastPath, paths) + path.search(prepared) for path in map(FastPath, paths) ) @@ -617,16 +765,28 @@ def version(distribution_name): return distribution(distribution_name).version -def entry_points(): +def entry_points(**params) -> Union[EntryPoints, SelectableGroups]: """Return EntryPoint objects for all installed packages. - :return: EntryPoint objects for all installed packages. + Pass selection parameters (group or name) to filter the + result to entry points matching those properties (see + EntryPoints.select()). + + For compatibility, returns ``SelectableGroups`` object unless + selection parameters are supplied. In the future, this function + will return ``EntryPoints`` instead of ``SelectableGroups`` + even when no selection parameters are supplied. + + For maximum future compatibility, pass selection parameters + or invoke ``.select`` with parameters on the result. + + :return: EntryPoints or SelectableGroups for all installed packages. """ - eps = itertools.chain.from_iterable(dist.entry_points for dist in distributions()) - by_group = operator.attrgetter('group') - ordered = sorted(eps, key=by_group) - grouped = itertools.groupby(ordered, by_group) - return {group: tuple(eps) for group, eps in grouped} + unique = functools.partial(unique_everseen, key=operator.attrgetter('name')) + eps = itertools.chain.from_iterable( + dist.entry_points for dist in unique(distributions()) + ) + return SelectableGroups.load(eps).select(**params) def files(distribution_name): @@ -646,3 +806,19 @@ def requires(distribution_name): packaging.requirement.Requirement. """ return distribution(distribution_name).requires + + +def packages_distributions() -> Mapping[str, List[str]]: + """ + Return a mapping of top-level packages to their + distributions. + + >>> pkgs = packages_distributions() + >>> all(isinstance(dist, collections.abc.Sequence) for dist in pkgs.values()) + True + """ + pkg_to_dist = collections.defaultdict(list) + for dist in distributions(): + for pkg in (dist.read_text('top_level.txt') or '').split(): + pkg_to_dist[pkg].append(dist.metadata['Name']) + return dict(pkg_to_dist) |