Skip to content

Commit

Permalink
bpo-43428: Sync with importlib_metadata 3.7. (GH-24782)
Browse files Browse the repository at this point in the history
* bpo-43428: Sync with importlib_metadata 3.7.2 (67234b6)

* Add blurb

* Reformat blurb to create separate paragraphs for each change included.
  • Loading branch information
jaraco authored Mar 13, 2021
1 parent 2256a28 commit f917efc
Show file tree
Hide file tree
Showing 8 changed files with 343 additions and 43 deletions.
23 changes: 18 additions & 5 deletions Doc/library/importlib.metadata.rst
Original file line number Diff line number Diff line change
Expand Up @@ -74,18 +74,20 @@ This package provides the following functionality via its public API.
Entry points
------------

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

>>> eps = entry_points() # doctest: +SKIP
>>> list(eps) # doctest: +SKIP
>>> sorted(eps.groups) # doctest: +SKIP
['console_scripts', 'distutils.commands', 'distutils.setup_keywords', 'egg_info.writers', 'setuptools.installation']
>>> scripts = eps['console_scripts'] # doctest: +SKIP
>>> wheel = [ep for ep in scripts if ep.name == 'wheel'][0] # doctest: +SKIP
>>> scripts = eps.select(group='console_scripts') # doctest: +SKIP
>>> 'wheel' in scripts.names # doctest: +SKIP
True
>>> wheel = scripts['wheel'] # doctest: +SKIP
>>> wheel # doctest: +SKIP
EntryPoint(name='wheel', value='wheel.cli:main', group='console_scripts')
>>> wheel.module # doctest: +SKIP
Expand Down Expand Up @@ -187,6 +189,17 @@ function::
["pytest (>=3.0.0) ; extra == 'test'", "pytest-cov ; extra == 'test'"]


Package distributions
---------------------

A convience method to resolve the distribution or
distributions (in the case of a namespace package) for top-level
Python packages or modules::

>>> packages_distributions()
{'importlib_metadata': ['importlib-metadata'], 'yaml': ['PyYAML'], 'jaraco': ['jaraco.classes', 'jaraco.functools'], ...}


Distributions
=============

Expand Down
19 changes: 19 additions & 0 deletions Lib/importlib/_itertools.py
Original file line number Diff line number Diff line change
@@ -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
226 changes: 201 additions & 25 deletions Lib/importlib/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,24 @@
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__ = [
Expand Down Expand Up @@ -120,18 +124,19 @@ def _from_text(cls, text):
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):
Expand All @@ -140,6 +145,143 @@ def __reduce__(self):
(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"""
Expand Down Expand Up @@ -296,7 +438,7 @@ def version(self):

@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):
Expand Down Expand Up @@ -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):
Expand All @@ -512,8 +661,9 @@ def legacy_normalize(name):

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
Expand All @@ -524,12 +674,9 @@ def matches(self, cand, base):
)

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')
)

Expand All @@ -551,8 +698,9 @@ def find_distributions(cls, context=DistributionFinder.Context()):
@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)
)


Expand Down Expand Up @@ -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):
Expand All @@ -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)
Loading

0 comments on commit f917efc

Please sign in to comment.