Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

bpo-43428: Sync with importlib_metadata 3.7. #24782

Merged
merged 3 commits into from
Mar 13, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will be good to document this as newly added in Python 3.10

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 690775a.

{'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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inspect is used only for flake8 related utility and moving it to a local import can save little time. I might be benchmarking import time wrong with importtime so please verify my results.

./python -X importtime -c 'import importlib.metadata'
import time: self [us] | cumulative | imported package
import time:       366 |        366 |   _io
import time:        89 |         89 |   marshal
import time:       538 |        538 |   posix
import time:      1212 |       2203 | _frozen_importlib_external
import time:       183 |        183 |   time
import time:       544 |        726 | zipimport
import time:       107 |        107 |     _codecs
import time:      1088 |       1194 |   codecs
import time:       686 |        686 |   encodings.aliases
import time:      1435 |       3313 | encodings
import time:       361 |        361 | encodings.utf_8
import time:       276 |        276 | _signal
import time:        78 |         78 |     _abc
import time:       605 |        682 |   abc
import time:       550 |       1231 | io
import time:       101 |        101 |       _stat
import time:       580 |        681 |     stat
import time:      2089 |       2089 |     _collections_abc
import time:       295 |        295 |       genericpath
import time:       584 |        878 |     posixpath
import time:      1471 |       5117 |   os
import time:       500 |        500 |   _sitebuiltins
import time:       434 |        434 |   sitecustomize
import time:       137 |        137 |   usercustomize
import time:      1304 |       7490 | site
import time:       451 |        451 |     warnings
import time:       558 |       1009 |   importlib
import time:       443 |        443 |       types
import time:      1834 |       2277 |     enum
import time:       119 |        119 |       _sre
import time:       513 |        513 |         sre_constants
import time:       715 |       1228 |       sre_parse
import time:       660 |       2005 |     sre_compile
import time:       183 |        183 |         itertools
import time:       279 |        279 |         keyword
import time:       244 |        244 |           _operator
import time:       602 |        846 |         operator
import time:       343 |        343 |         reprlib
import time:       123 |        123 |         _collections
import time:      1716 |       3488 |       collections
import time:       117 |        117 |       _functools
import time:      1214 |       4818 |     functools
import time:       145 |        145 |     _locale
import time:       298 |        298 |     copyreg
import time:      1864 |      11405 |   re
import time:       367 |        367 |     _csv
import time:       734 |       1100 |   csv
import time:       292 |        292 |   email
import time:       130 |        130 |       _ast
import time:       939 |        939 |       contextlib
import time:      2491 |       3559 |     ast
import time:       264 |        264 |         _opcode
import time:       635 |        898 |       opcode
import time:       852 |       1749 |     dis
import time:       385 |        385 |     collections.abc
import time:       260 |        260 |     importlib.machinery
import time:       312 |        312 |         token
import time:      1924 |       2235 |       tokenize
import time:       414 |       2649 |     linecache
import time:      4521 |       4521 |     typing
import time:      3599 |      16719 |   inspect
import time:       424 |        424 |     fnmatch
import time:       175 |        175 |       nt
import time:       143 |        143 |       nt
import time:       141 |        141 |       nt
import time:       133 |        133 |       nt
import time:       998 |       1589 |     ntpath
import time:       132 |        132 |     errno
import time:       282 |        282 |       urllib
import time:      1822 |       2103 |     urllib.parse
import time:      1827 |       6074 |   pathlib
import time:       486 |        486 |     binascii
import time:       271 |        271 |       importlib._abc
import time:      1936 |       2206 |     importlib.util
import time:       369 |        369 |       zlib
import time:       449 |        449 |         _compression
import time:       492 |        492 |           _weakrefset
import time:      1096 |       1588 |         threading
import time:       405 |        405 |         _bz2
import time:       929 |       3369 |       bz2
import time:       454 |        454 |         _lzma
import time:       573 |       1027 |       lzma
import time:       100 |        100 |       pwd
import time:       330 |        330 |       grp
import time:      1792 |       6984 |     shutil
import time:       326 |        326 |       _struct
import time:       372 |        697 |     struct
import time:      1840 |      12211 |   zipfile
import time:       244 |        244 |   importlib._itertools
import time:      3696 |       3696 |   configparser
import time:       951 |        951 |   importlib.abc
import time:     13867 |      67563 | importlib.metadata
  • Without import inspect
./python -X importtime -c 'import importlib.metadata'
import time: self [us] | cumulative | imported package
import time:       362 |        362 |   _io
import time:        72 |         72 |   marshal
import time:       556 |        556 |   posix
import time:      1321 |       2309 | _frozen_importlib_external
import time:       189 |        189 |   time
import time:       482 |        670 | zipimport
import time:        95 |         95 |     _codecs
import time:      1011 |       1105 |   codecs
import time:       633 |        633 |   encodings.aliases
import time:      1187 |       2924 | encodings
import time:       350 |        350 | encodings.utf_8
import time:       269 |        269 | _signal
import time:        79 |         79 |     _abc
import time:       572 |        651 |   abc
import time:       631 |       1281 | io
import time:        95 |         95 |       _stat
import time:       538 |        633 |     stat
import time:      1869 |       1869 |     _collections_abc
import time:       255 |        255 |       genericpath
import time:       473 |        727 |     posixpath
import time:      1181 |       4408 |   os
import time:       324 |        324 |   _sitebuiltins
import time:       408 |        408 |   sitecustomize
import time:       141 |        141 |   usercustomize
import time:      1182 |       6461 | site
import time:       555 |        555 |     warnings
import time:       560 |       1114 |   importlib
import time:       478 |        478 |       types
import time:      1852 |       2330 |     enum
import time:       121 |        121 |       _sre
import time:       515 |        515 |         sre_constants
import time:      1579 |       2094 |       sre_parse
import time:       724 |       2938 |     sre_compile
import time:       237 |        237 |         itertools
import time:       519 |        519 |         keyword
import time:       217 |        217 |           _operator
import time:      1186 |       1403 |         operator
import time:       345 |        345 |         reprlib
import time:       125 |        125 |         _collections
import time:      2238 |       4864 |       collections
import time:       109 |        109 |       _functools
import time:      1157 |       6129 |     functools
import time:       180 |        180 |     _locale
import time:       378 |        378 |     copyreg
import time:      1596 |      13549 |   re
import time:       408 |        408 |     _csv
import time:       912 |       1319 |   csv
import time:       313 |        313 |   email
import time:       289 |        289 |     fnmatch
import time:       233 |        233 |       nt
import time:       150 |        150 |       nt
import time:       139 |        139 |       nt
import time:       135 |        135 |       nt
import time:       777 |       1431 |     ntpath
import time:       121 |        121 |     errno
import time:       276 |        276 |       urllib
import time:      2085 |       2360 |     urllib.parse
import time:      1608 |       5808 |   pathlib
import time:       516 |        516 |     binascii
import time:       254 |        254 |       importlib._abc
import time:      1029 |       1029 |       contextlib
import time:       677 |       1958 |     importlib.util
import time:       351 |        351 |       zlib
import time:       410 |        410 |         _compression
import time:       655 |        655 |           _weakrefset
import time:      1484 |       2138 |         threading
import time:       406 |        406 |         _bz2
import time:       722 |       3675 |       bz2
import time:       484 |        484 |         _lzma
import time:       557 |       1040 |       lzma
import time:       108 |        108 |       pwd
import time:       384 |        384 |       grp
import time:      1506 |       7062 |     shutil
import time:       360 |        360 |       _struct
import time:       408 |        767 |     struct
import time:      1973 |      12275 |   zipfile
import time:       242 |        242 |   importlib._itertools
import time:       561 |        561 |     collections.abc
import time:      4142 |       4703 |   configparser
import time:       246 |        246 |     importlib.machinery
import time:      2303 |       2303 |         _ast
import time:      2311 |       4614 |       ast
import time:      4474 |       9087 |     typing
import time:      1303 |      10635 |   importlib.abc
import time:      3051 |      53004 | importlib.metadata

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import pathlib
import zipfile
import operator
import warnings
import functools
import itertools
import posixpath
import collections
import collections.abc
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

collections.abc seems to be unused and is present only in docstring as collections.abc.Sequence. collections.namedtuple and collections.defaultdict are used. I guess collections is the correct import here and is imported as a side effect of collections.abc .

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


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__ = [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be updated with packages_distributions as a public function.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added to python/importlib_metadata@7bdeaa4, to be included in a future sync.

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 = (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deprecation might be noted in docs since this was a valid usage before. The deprecation message is slightly unclear to me since EntryPoints object is not documented.

./python -Wall                             
Python 3.10.0a6+ (heads/master:f917efccf8, Mar 13 2021, 17:24:55) [GCC 7.5.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import importlib.metadata as me
>>> dict(me.entry_points()['setuptools.installation'])
/root/cpython/Lib/importlib/metadata.py:139: DeprecationWarning: Construction of dict of EntryPoints is deprecated in favor of EntryPoints.
  warnings.warn(msg, DeprecationWarning)
{'eggsecutable': EntryPoint(name='eggsecutable', value='setuptools.command.easy_install:bootstrap', group='setuptools.installation')}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This deprecation is unrelated to the introduction of EntryPoints or the selection interface. You can get the same warning with:

>>> dict(me.entry_points(group='setuptools.installation'))

Previously, an EntryPoint object allowed itself to be used as a name/value pair during construction of a dictionary from a series of EntryPoint objects. This behavior was somewhat obtuse, so I don't expect it to be in widespread use.

Oh, I see. It's unclear from a user's perspective what EntryPoints is. In python/importlib_metadata@bb24370, they are now documented.

Additionally, in python/importlib_metadata@bf777ae, I've added documentation for EntryPoints.

"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:
jaraco marked this conversation as resolved.
Show resolved Hide resolved
"""
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]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This represents a change in signature for the public function that selectors can be passed directly like below which is not possible before the update. There are no separate docs for entry_points as a function but it will be good to document this in the rst docs since docstring is updated but the rst docs still show entry_points() not accepting parameters.

./python -Wall                             
Python 3.10.0a6+ (heads/master:f917efccf8, Mar 13 2021, 17:24:55) [GCC 7.5.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import importlib.metadata as me
>>> me.entry_points(name="eggsecutable")
(EntryPoint(name='eggsecutable', value='setuptools.command.easy_install:bootstrap', group='setuptools.installation'),)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In python/importlib_metadata@bb24370, I expanded the docs to address this concern. It should be much clearer now what versions support the selection interface. Please take a look and let me know if that doesn't fully address the concern.

"""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