bpo-44893: Implement EntryPoint as simple class with attributes. (GH-30150)

* bpo-44893: Implement EntryPoint as simple class and deprecate tuple access in favor of attribute access. Syncs with importlib_metadata 4.8.1.

* Apply refactorings found in importlib_metadata 4.8.2.
This commit is contained in:
Jason R. Coombs 2021-12-16 15:49:42 -05:00 committed by GitHub
parent 109d966021
commit 04deaee4c8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 267 additions and 106 deletions

View File

@ -15,10 +15,9 @@ import posixpath
import collections
from . import _adapters, _meta
from ._meta import PackageMetadata
from ._collections import FreezableDefaultDict, Pair
from ._functools import method_cache
from ._itertools import unique_everseen
from ._functools import method_cache, pass_none
from ._itertools import always_iterable, unique_everseen
from ._meta import PackageMetadata, SimplePath
from contextlib import suppress
@ -121,8 +120,33 @@ class Sectioned:
return line and not line.startswith('#')
class EntryPoint(
collections.namedtuple('EntryPointBase', 'name value group')):
class DeprecatedTuple:
"""
Provide subscript item access for backward compatibility.
>>> recwarn = getfixture('recwarn')
>>> ep = EntryPoint(name='name', value='value', group='group')
>>> ep[:]
('name', 'value', 'group')
>>> ep[0]
'name'
>>> len(recwarn)
1
"""
_warn = functools.partial(
warnings.warn,
"EntryPoint tuple interface is deprecated. Access members by name.",
DeprecationWarning,
stacklevel=2,
)
def __getitem__(self, item):
self._warn()
return self._key()[item]
class EntryPoint(DeprecatedTuple):
"""An entry point as defined by Python packaging conventions.
See `the packaging docs on entry points
@ -153,6 +177,9 @@ class EntryPoint(
dist: Optional['Distribution'] = None
def __init__(self, name, value, group):
vars(self).update(name=name, value=value, group=group)
def load(self):
"""Load the entry point from its definition. If only a module
is indicated by the value, return that module. Otherwise,
@ -179,7 +206,7 @@ class EntryPoint(
return list(re.finditer(r'\w+', match.group('extras') or ''))
def _for(self, dist):
self.dist = dist
vars(self).update(dist=dist)
return self
def __iter__(self):
@ -193,16 +220,31 @@ class EntryPoint(
warnings.warn(msg, DeprecationWarning)
return iter((self.name, self))
def __reduce__(self):
return (
self.__class__,
(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))
def _key(self):
return self.name, self.value, self.group
def __lt__(self, other):
return self._key() < other._key()
def __eq__(self, other):
return self._key() == other._key()
def __setattr__(self, name, value):
raise AttributeError("EntryPoint objects are immutable.")
def __repr__(self):
return (
f'EntryPoint(name={self.name!r}, value={self.value!r}, '
f'group={self.group!r})'
)
def __hash__(self):
return hash(self._key())
class DeprecatedList(list):
"""
@ -243,37 +285,26 @@ class DeprecatedList(list):
stacklevel=2,
)
def __setitem__(self, *args, **kwargs):
self._warn()
return super().__setitem__(*args, **kwargs)
def _wrap_deprecated_method(method_name: str): # type: ignore
def wrapped(self, *args, **kwargs):
self._warn()
return getattr(super(), method_name)(*args, **kwargs)
def __delitem__(self, *args, **kwargs):
self._warn()
return super().__delitem__(*args, **kwargs)
return wrapped
def append(self, *args, **kwargs):
self._warn()
return super().append(*args, **kwargs)
def reverse(self, *args, **kwargs):
self._warn()
return super().reverse(*args, **kwargs)
def extend(self, *args, **kwargs):
self._warn()
return super().extend(*args, **kwargs)
def pop(self, *args, **kwargs):
self._warn()
return super().pop(*args, **kwargs)
def remove(self, *args, **kwargs):
self._warn()
return super().remove(*args, **kwargs)
def __iadd__(self, *args, **kwargs):
self._warn()
return super().__iadd__(*args, **kwargs)
for method_name in [
'__setitem__',
'__delitem__',
'append',
'reverse',
'extend',
'pop',
'remove',
'__iadd__',
'insert',
'sort',
]:
locals()[method_name] = _wrap_deprecated_method(method_name)
def __add__(self, other):
if not isinstance(other, tuple):
@ -281,14 +312,6 @@ class DeprecatedList(list):
other = tuple(other)
return self.__class__(tuple(self) + other)
def insert(self, *args, **kwargs):
self._warn()
return super().insert(*args, **kwargs)
def sort(self, *args, **kwargs):
self._warn()
return super().sort(*args, **kwargs)
def __eq__(self, other):
if not isinstance(other, tuple):
self._warn()
@ -333,7 +356,7 @@ class EntryPoints(DeprecatedList):
"""
Return the set of all names of all entry points.
"""
return set(ep.name for ep in self)
return {ep.name for ep in self}
@property
def groups(self):
@ -344,21 +367,17 @@ class EntryPoints(DeprecatedList):
>>> EntryPoints().groups
set()
"""
return set(ep.group for ep in self)
return {ep.group for ep in self}
@classmethod
def _from_text_for(cls, text, dist):
return cls(ep._for(dist) for ep in cls._from_text(text))
@classmethod
def _from_text(cls, text):
return itertools.starmap(EntryPoint, cls._parse_groups(text or ''))
@staticmethod
def _parse_groups(text):
def _from_text(text):
return (
(item.value.name, item.value.value, item.name)
for item in Sectioned.section_pairs(text)
EntryPoint(name=item.value.name, value=item.value.value, group=item.name)
for item in Sectioned.section_pairs(text or '')
)
@ -611,7 +630,6 @@ class Distribution:
missing.
Result may be empty if the metadata exists but is empty.
"""
file_lines = self._read_files_distinfo() or self._read_files_egginfo()
def make_file(name, hash=None, size_str=None):
result = PackagePath(name)
@ -620,7 +638,11 @@ class Distribution:
result.dist = self
return result
return file_lines and list(starmap(make_file, csv.reader(file_lines)))
@pass_none
def make_files(lines):
return list(starmap(make_file, csv.reader(lines)))
return make_files(self._read_files_distinfo() or self._read_files_egginfo())
def _read_files_distinfo(self):
"""
@ -742,6 +764,9 @@ class FastPath:
"""
Micro-optimized class for searching a path for
children.
>>> FastPath('').children()
['...']
"""
@functools.lru_cache() # type: ignore
@ -1011,6 +1036,18 @@ def packages_distributions() -> Mapping[str, List[str]]:
"""
pkg_to_dist = collections.defaultdict(list)
for dist in distributions():
for pkg in (dist.read_text('top_level.txt') or '').split():
for pkg in _top_level_declared(dist) or _top_level_inferred(dist):
pkg_to_dist[pkg].append(dist.metadata['Name'])
return dict(pkg_to_dist)
def _top_level_declared(dist):
return (dist.read_text('top_level.txt') or '').split()
def _top_level_inferred(dist):
return {
f.parts[0] if len(f.parts) > 1 else f.with_suffix('').name
for f in always_iterable(dist.files)
if f.suffix == ".py"
}

View File

@ -83,3 +83,22 @@ def method_cache(method, cache_wrapper=None):
wrapper.cache_clear = lambda: None
return wrapper
# From jaraco.functools 3.3
def pass_none(func):
"""
Wrap func so it's not called if its first param is None
>>> print_text = pass_none(print)
>>> print_text('text')
text
>>> print_text(None)
"""
@functools.wraps(func)
def wrapper(param, *args, **kwargs):
if param is not None:
return func(param, *args, **kwargs)
return wrapper

View File

@ -17,3 +17,57 @@ def unique_everseen(iterable, key=None):
if k not in seen:
seen_add(k)
yield element
# copied from more_itertools 8.8
def always_iterable(obj, base_type=(str, bytes)):
"""If *obj* is iterable, return an iterator over its items::
>>> obj = (1, 2, 3)
>>> list(always_iterable(obj))
[1, 2, 3]
If *obj* is not iterable, return a one-item iterable containing *obj*::
>>> obj = 1
>>> list(always_iterable(obj))
[1]
If *obj* is ``None``, return an empty iterable:
>>> obj = None
>>> list(always_iterable(None))
[]
By default, binary and text strings are not considered iterable::
>>> obj = 'foo'
>>> list(always_iterable(obj))
['foo']
If *base_type* is set, objects for which ``isinstance(obj, base_type)``
returns ``True`` won't be considered iterable.
>>> obj = {'a': 1}
>>> list(always_iterable(obj)) # Iterate over the dict's keys
['a']
>>> list(always_iterable(obj, base_type=dict)) # Treat dicts as a unit
[{'a': 1}]
Set *base_type* to ``None`` to avoid any special handling and treat objects
Python considers iterable as iterable:
>>> obj = 'foo'
>>> list(always_iterable(obj, base_type=None))
['f', 'o', 'o']
"""
if obj is None:
return iter(())
if (base_type is not None) and isinstance(obj, base_type):
return iter((obj,))
try:
return iter(obj)
except TypeError:
return iter((obj,))

View File

@ -37,7 +37,7 @@ class SimplePath(Protocol):
def joinpath(self) -> 'SimplePath':
... # pragma: no cover
def __div__(self) -> 'SimplePath':
def __truediv__(self) -> 'SimplePath':
... # pragma: no cover
def parent(self) -> 'SimplePath':

View File

@ -80,7 +80,7 @@ class FoldedCase(str):
return hash(self.lower())
def __contains__(self, other):
return super(FoldedCase, self).lower().__contains__(other.lower())
return super().lower().__contains__(other.lower())
def in_(self, other):
"Does self appear in other?"
@ -89,7 +89,7 @@ class FoldedCase(str):
# cache lower since it's likely to be called frequently.
@method_cache
def lower(self):
return super(FoldedCase, self).lower()
return super().lower()
def index(self, sub):
return self.lower().index(sub.lower())

View File

@ -8,8 +8,17 @@ import textwrap
import contextlib
from test.support.os_helper import FS_NONASCII
from test.support import requires_zlib
from typing import Dict, Union
try:
from importlib import resources
getattr(resources, 'files')
getattr(resources, 'as_file')
except (ImportError, AttributeError):
import importlib_resources as resources # type: ignore
@contextlib.contextmanager
def tempdir():
@ -54,7 +63,7 @@ class Fixtures:
class SiteDir(Fixtures):
def setUp(self):
super(SiteDir, self).setUp()
super().setUp()
self.site_dir = self.fixtures.enter_context(tempdir())
@ -69,7 +78,7 @@ class OnSysPath(Fixtures):
sys.path.remove(str(dir))
def setUp(self):
super(OnSysPath, self).setUp()
super().setUp()
self.fixtures.enter_context(self.add_sys_path(self.site_dir))
@ -106,7 +115,7 @@ class DistInfoPkg(OnSysPath, SiteDir):
}
def setUp(self):
super(DistInfoPkg, self).setUp()
super().setUp()
build_files(DistInfoPkg.files, self.site_dir)
def make_uppercase(self):
@ -131,7 +140,7 @@ class DistInfoPkgWithDot(OnSysPath, SiteDir):
}
def setUp(self):
super(DistInfoPkgWithDot, self).setUp()
super().setUp()
build_files(DistInfoPkgWithDot.files, self.site_dir)
@ -152,13 +161,13 @@ class DistInfoPkgWithDotLegacy(OnSysPath, SiteDir):
}
def setUp(self):
super(DistInfoPkgWithDotLegacy, self).setUp()
super().setUp()
build_files(DistInfoPkgWithDotLegacy.files, self.site_dir)
class DistInfoPkgOffPath(SiteDir):
def setUp(self):
super(DistInfoPkgOffPath, self).setUp()
super().setUp()
build_files(DistInfoPkg.files, self.site_dir)
@ -198,7 +207,7 @@ class EggInfoPkg(OnSysPath, SiteDir):
}
def setUp(self):
super(EggInfoPkg, self).setUp()
super().setUp()
build_files(EggInfoPkg.files, prefix=self.site_dir)
@ -219,7 +228,7 @@ class EggInfoFile(OnSysPath, SiteDir):
}
def setUp(self):
super(EggInfoFile, self).setUp()
super().setUp()
build_files(EggInfoFile.files, prefix=self.site_dir)
@ -285,3 +294,20 @@ def DALS(str):
class NullFinder:
def find_module(self, name):
pass
@requires_zlib()
class ZipFixtures:
root = 'test.test_importlib.data'
def _fixture_on_path(self, filename):
pkg_file = resources.files(self.root).joinpath(filename)
file = self.resources.enter_context(resources.as_file(pkg_file))
assert file.name.startswith('example'), file.name
sys.path.insert(0, str(file))
self.resources.callback(sys.path.pop, 0)
def setUp(self):
# Add self.zip_name to the front of sys.path.
self.resources = contextlib.ExitStack()
self.addCleanup(self.resources.close)

View File

@ -19,6 +19,7 @@ from importlib.metadata import (
distributions,
entry_points,
metadata,
packages_distributions,
version,
)
@ -203,7 +204,7 @@ class InaccessibleSysPath(fixtures.OnSysPath, ffs.TestCase):
site_dir = '/access-denied'
def setUp(self):
super(InaccessibleSysPath, self).setUp()
super().setUp()
self.setUpPyfakefs()
self.fs.create_dir(self.site_dir, perm_bits=000)
@ -217,13 +218,21 @@ class InaccessibleSysPath(fixtures.OnSysPath, ffs.TestCase):
class TestEntryPoints(unittest.TestCase):
def __init__(self, *args):
super(TestEntryPoints, self).__init__(*args)
self.ep = importlib.metadata.EntryPoint('name', 'value', 'group')
super().__init__(*args)
self.ep = importlib.metadata.EntryPoint(
name='name', value='value', group='group'
)
def test_entry_point_pickleable(self):
revived = pickle.loads(pickle.dumps(self.ep))
assert revived == self.ep
def test_positional_args(self):
"""
Capture legacy (namedtuple) construction, discouraged.
"""
EntryPoint('name', 'value', 'group')
def test_immutable(self):
"""EntryPoints should be immutable"""
with self.assertRaises(AttributeError):
@ -254,8 +263,8 @@ class TestEntryPoints(unittest.TestCase):
# EntryPoint objects are sortable, but result is undefined.
sorted(
[
EntryPoint('b', 'val', 'group'),
EntryPoint('a', 'val', 'group'),
EntryPoint(name='b', value='val', group='group'),
EntryPoint(name='a', value='val', group='group'),
]
)
@ -271,3 +280,38 @@ class FileSystem(
prefix=self.site_dir,
)
list(distributions())
class PackagesDistributionsPrebuiltTest(fixtures.ZipFixtures, unittest.TestCase):
def test_packages_distributions_example(self):
self._fixture_on_path('example-21.12-py3-none-any.whl')
assert packages_distributions()['example'] == ['example']
def test_packages_distributions_example2(self):
"""
Test packages_distributions on a wheel built
by trampolim.
"""
self._fixture_on_path('example2-1.0.0-py3-none-any.whl')
assert packages_distributions()['example2'] == ['example2']
class PackagesDistributionsTest(
fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase
):
def test_packages_distributions_neither_toplevel_nor_files(self):
"""
Test a package built without 'top-level.txt' or a file list.
"""
fixtures.build_files(
{
'trim_example-1.0.0.dist-info': {
'METADATA': """
Name: trim_example
Version: 1.0.0
""",
}
},
prefix=self.site_dir,
)
packages_distributions()

View File

@ -21,7 +21,7 @@ from importlib.metadata import (
@contextlib.contextmanager
def suppress_known_deprecation():
with warnings.catch_warnings(record=True) as ctx:
warnings.simplefilter('default')
warnings.simplefilter('default', category=DeprecationWarning)
yield ctx
@ -113,7 +113,7 @@ class APITests(
for ep in entries
)
# ns:sub doesn't exist in alt_pkg
assert 'ns:sub' not in entries
assert 'ns:sub' not in entries.names
def test_entry_points_missing_name(self):
with self.assertRaises(KeyError):
@ -194,10 +194,8 @@ class APITests(
file.read_text()
def test_file_hash_repr(self):
assertRegex = self.assertRegex
util = [p for p in files('distinfo-pkg') if p.name == 'mod.py'][0]
assertRegex(repr(util.hash), '<FileHash mode: sha256 value: .*>')
self.assertRegex(repr(util.hash), '<FileHash mode: sha256 value: .*>')
def test_files_dist_info(self):
self._test_files(files('distinfo-pkg'))

View File

@ -1,7 +1,7 @@
import sys
import unittest
from contextlib import ExitStack
from . import fixtures
from importlib.metadata import (
PackageNotFoundError,
distribution,
@ -10,27 +10,11 @@ from importlib.metadata import (
files,
version,
)
from importlib import resources
from test.support import requires_zlib
@requires_zlib()
class TestZip(unittest.TestCase):
root = 'test.test_importlib.data'
def _fixture_on_path(self, filename):
pkg_file = resources.files(self.root).joinpath(filename)
file = self.resources.enter_context(resources.as_file(pkg_file))
assert file.name.startswith('example-'), file.name
sys.path.insert(0, str(file))
self.resources.callback(sys.path.pop, 0)
class TestZip(fixtures.ZipFixtures, unittest.TestCase):
def setUp(self):
# Find the path to the example-*.whl so we can add it to the front of
# sys.path, where we'll then try to find the metadata thereof.
self.resources = ExitStack()
self.addCleanup(self.resources.close)
super().setUp()
self._fixture_on_path('example-21.12-py3-none-any.whl')
def test_zip_version(self):
@ -63,13 +47,9 @@ class TestZip(unittest.TestCase):
assert len(dists) == 1
@requires_zlib()
class TestEgg(TestZip):
def setUp(self):
# Find the path to the example-*.egg so we can add it to the front of
# sys.path, where we'll then try to find the metadata thereof.
self.resources = ExitStack()
self.addCleanup(self.resources.close)
super().setUp()
self._fixture_on_path('example-21.12-py3.6.egg')
def test_files(self):

View File

@ -0,0 +1,3 @@
EntryPoint objects are no longer tuples. Recommended means to access is by
attribute ('.name', '.group') or accessor ('.load()'). Access by index is
deprecated and will raise deprecation warning.