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 | |
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.
-rw-r--r-- | Doc/library/importlib.metadata.rst | 23 | ||||
-rw-r--r-- | Lib/importlib/_itertools.py | 19 | ||||
-rw-r--r-- | Lib/importlib/metadata.py | 226 | ||||
-rw-r--r-- | Lib/test/test_importlib/fixtures.py | 5 | ||||
-rw-r--r-- | Lib/test/test_importlib/test_main.py | 10 | ||||
-rw-r--r-- | Lib/test/test_importlib/test_metadata_api.py | 84 | ||||
-rw-r--r-- | Lib/test/test_importlib/test_zip.py | 2 | ||||
-rw-r--r-- | Misc/NEWS.d/next/Library/2021-03-07-18-54-39.bpo-43428.br0XmX.rst | 17 |
8 files changed, 343 insertions, 43 deletions
diff --git a/Doc/library/importlib.metadata.rst b/Doc/library/importlib.metadata.rst index 7f154ea..ffce1ba 100644 --- a/Doc/library/importlib.metadata.rst +++ b/Doc/library/importlib.metadata.rst @@ -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 @@ -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 ============= 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) diff --git a/Lib/test/test_importlib/fixtures.py b/Lib/test/test_importlib/fixtures.py index acf6bc8..429313e 100644 --- a/Lib/test/test_importlib/fixtures.py +++ b/Lib/test/test_importlib/fixtures.py @@ -5,7 +5,6 @@ import pathlib import tempfile import textwrap import contextlib -import unittest from test.support.os_helper import FS_NONASCII from typing import Dict, Union @@ -221,7 +220,6 @@ class LocalPackage: build_files(self.files) - def build_files(file_defs, prefix=pathlib.Path()): """Build a set of files/directories, as described by the @@ -260,9 +258,6 @@ class FileBuilder: def unicode_filename(self): return FS_NONASCII or self.skip("File system does not support non-ascii.") - def skip(self, reason): - raise unittest.SkipTest(reason) - def DALS(str): "Dedent and left-strip" diff --git a/Lib/test/test_importlib/test_main.py b/Lib/test/test_importlib/test_main.py index c937361..02e8a57 100644 --- a/Lib/test/test_importlib/test_main.py +++ b/Lib/test/test_importlib/test_main.py @@ -3,6 +3,7 @@ import json import pickle import textwrap import unittest +import warnings import importlib.metadata try: @@ -58,13 +59,11 @@ class ImportTests(fixtures.DistInfoPkg, unittest.TestCase): importlib.import_module('does_not_exist') def test_resolve(self): - entries = dict(entry_points()['entries']) - ep = entries['main'] + ep = entry_points(group='entries')['main'] self.assertEqual(ep.load().__name__, "main") def test_entrypoint_with_colon_in_name(self): - entries = dict(entry_points()['entries']) - ep = entries['ns:sub'] + ep = entry_points(group='entries')['ns:sub'] self.assertEqual(ep.value, 'mod:main') def test_resolve_without_attr(self): @@ -250,7 +249,8 @@ class TestEntryPoints(unittest.TestCase): json should not expect to be able to dump an EntryPoint """ with self.assertRaises(Exception): - json.dumps(self.ep) + with warnings.catch_warnings(record=True): + json.dumps(self.ep) def test_module(self): assert self.ep.module == 'value' diff --git a/Lib/test/test_importlib/test_metadata_api.py b/Lib/test/test_importlib/test_metadata_api.py index df00ae9..a0f9d51 100644 --- a/Lib/test/test_importlib/test_metadata_api.py +++ b/Lib/test/test_importlib/test_metadata_api.py @@ -1,6 +1,7 @@ import re import textwrap import unittest +import warnings from . import fixtures from importlib.metadata import ( @@ -64,18 +65,97 @@ class APITests( self.assertEqual(top_level.read_text(), 'mod\n') def test_entry_points(self): - entries = dict(entry_points()['entries']) + eps = entry_points() + assert 'entries' in eps.groups + entries = eps.select(group='entries') + assert 'main' in entries.names ep = entries['main'] self.assertEqual(ep.value, 'mod:main') self.assertEqual(ep.extras, []) def test_entry_points_distribution(self): - entries = dict(entry_points()['entries']) + entries = entry_points(group='entries') for entry in ("main", "ns:sub"): ep = entries[entry] self.assertIn(ep.dist.name, ('distinfo-pkg', 'egginfo-pkg')) self.assertEqual(ep.dist.version, "1.0.0") + def test_entry_points_unique_packages(self): + """ + Entry points should only be exposed for the first package + on sys.path with a given name. + """ + alt_site_dir = self.fixtures.enter_context(fixtures.tempdir()) + self.fixtures.enter_context(self.add_sys_path(alt_site_dir)) + alt_pkg = { + "distinfo_pkg-1.1.0.dist-info": { + "METADATA": """ + Name: distinfo-pkg + Version: 1.1.0 + """, + "entry_points.txt": """ + [entries] + main = mod:altmain + """, + }, + } + fixtures.build_files(alt_pkg, alt_site_dir) + entries = entry_points(group='entries') + assert not any( + ep.dist.name == 'distinfo-pkg' and ep.dist.version == '1.0.0' + for ep in entries + ) + # ns:sub doesn't exist in alt_pkg + assert 'ns:sub' not in entries + + def test_entry_points_missing_name(self): + with self.assertRaises(KeyError): + entry_points(group='entries')['missing'] + + def test_entry_points_missing_group(self): + assert entry_points(group='missing') == () + + def test_entry_points_dict_construction(self): + """ + Prior versions of entry_points() returned simple lists and + allowed casting those lists into maps by name using ``dict()``. + Capture this now deprecated use-case. + """ + with warnings.catch_warnings(record=True) as caught: + warnings.filterwarnings("default", category=DeprecationWarning) + eps = dict(entry_points(group='entries')) + + assert 'main' in eps + assert eps['main'] == entry_points(group='entries')['main'] + + # check warning + expected = next(iter(caught)) + assert expected.category is DeprecationWarning + assert "Construction of dict of EntryPoints is deprecated" in str(expected) + + def test_entry_points_groups_getitem(self): + """ + Prior versions of entry_points() returned a dict. Ensure + that callers using '.__getitem__()' are supported but warned to + migrate. + """ + with warnings.catch_warnings(record=True): + entry_points()['entries'] == entry_points(group='entries') + + with self.assertRaises(KeyError): + entry_points()['missing'] + + def test_entry_points_groups_get(self): + """ + Prior versions of entry_points() returned a dict. Ensure + that callers using '.get()' are supported but warned to + migrate. + """ + with warnings.catch_warnings(record=True): + entry_points().get('missing', 'default') == 'default' + entry_points().get('entries', 'default') == entry_points()['entries'] + entry_points().get('missing', ()) == () + def test_metadata_for_this_package(self): md = metadata('egginfo-pkg') assert md['author'] == 'Steven Ma' diff --git a/Lib/test/test_importlib/test_zip.py b/Lib/test/test_importlib/test_zip.py index 74783fc..83e0413 100644 --- a/Lib/test/test_importlib/test_zip.py +++ b/Lib/test/test_importlib/test_zip.py @@ -41,7 +41,7 @@ class TestZip(unittest.TestCase): version('definitely-not-installed') def test_zip_entry_points(self): - scripts = dict(entry_points()['console_scripts']) + scripts = entry_points(group='console_scripts') entry_point = scripts['example'] self.assertEqual(entry_point.value, 'example:main') entry_point = scripts['Example'] diff --git a/Misc/NEWS.d/next/Library/2021-03-07-18-54-39.bpo-43428.br0XmX.rst b/Misc/NEWS.d/next/Library/2021-03-07-18-54-39.bpo-43428.br0XmX.rst new file mode 100644 index 0000000..3836107 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2021-03-07-18-54-39.bpo-43428.br0XmX.rst @@ -0,0 +1,17 @@ +Include changes from `importlib_metadata 3.7 +<https://importlib-metadata.readthedocs.io/en/latest/history.html#v3-7-0>`_: + +Performance enhancements to distribution discovery. + +``entry_points`` only returns unique distributions. + +Introduces new ``EntryPoints`` object +for containing a set of entry points with convenience methods for selecting +entry points by group or name. ``entry_points`` now returns this object if +selection parameters are supplied but continues to return a dict object for +compatibility. Users are encouraged to rely on the selection interface. The +dict object result is likely to be deprecated in the future. + +Added +packages_distributions function to return a mapping of packages to the +distributions that provide them. |