summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJason R. Coombs <jaraco@jaraco.com>2021-12-16 20:49:42 (GMT)
committerGitHub <noreply@github.com>2021-12-16 20:49:42 (GMT)
commit04deaee4c8d313717f3ea8f6a4fd70286d510d6e (patch)
tree16af6d5242d248eb93107332099485783599fd4b
parent109d96602199a91e94eb14b8cb3720841f22ded7 (diff)
downloadcpython-04deaee4c8d313717f3ea8f6a4fd70286d510d6e.zip
cpython-04deaee4c8d313717f3ea8f6a4fd70286d510d6e.tar.gz
cpython-04deaee4c8d313717f3ea8f6a4fd70286d510d6e.tar.bz2
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.
-rw-r--r--Lib/importlib/metadata/__init__.py163
-rw-r--r--Lib/importlib/metadata/_functools.py19
-rw-r--r--Lib/importlib/metadata/_itertools.py54
-rw-r--r--Lib/importlib/metadata/_meta.py2
-rw-r--r--Lib/importlib/metadata/_text.py4
-rw-r--r--Lib/test/test_importlib/data/example2-1.0.0-py3-none-any.whlbin0 -> 1167 bytes
-rw-r--r--Lib/test/test_importlib/fixtures.py42
-rw-r--r--Lib/test/test_importlib/test_main.py54
-rw-r--r--Lib/test/test_importlib/test_metadata_api.py8
-rw-r--r--Lib/test/test_importlib/test_zip.py28
-rw-r--r--Misc/NEWS.d/next/Library/2021-12-16-13-54-55.bpo-44893.I7aLiW.rst3
11 files changed, 269 insertions, 108 deletions
diff --git a/Lib/importlib/metadata/__init__.py b/Lib/importlib/metadata/__init__.py
index ec41ed3..d44541f 100644
--- a/Lib/importlib/metadata/__init__.py
+++ b/Lib/importlib/metadata/__init__.py
@@ -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 __delitem__(self, *args, **kwargs):
- self._warn()
- return super().__delitem__(*args, **kwargs)
-
- 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)
+ def _wrap_deprecated_method(method_name: str): # type: ignore
+ def wrapped(self, *args, **kwargs):
+ self._warn()
+ return getattr(super(), method_name)(*args, **kwargs)
+
+ return wrapped
+
+ 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"
+ }
diff --git a/Lib/importlib/metadata/_functools.py b/Lib/importlib/metadata/_functools.py
index 73f50d0..71f66bd 100644
--- a/Lib/importlib/metadata/_functools.py
+++ b/Lib/importlib/metadata/_functools.py
@@ -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
diff --git a/Lib/importlib/metadata/_itertools.py b/Lib/importlib/metadata/_itertools.py
index dd45f2f..d4ca9b9 100644
--- a/Lib/importlib/metadata/_itertools.py
+++ b/Lib/importlib/metadata/_itertools.py
@@ -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,))
diff --git a/Lib/importlib/metadata/_meta.py b/Lib/importlib/metadata/_meta.py
index 1a6edbf..d5c0576 100644
--- a/Lib/importlib/metadata/_meta.py
+++ b/Lib/importlib/metadata/_meta.py
@@ -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':
diff --git a/Lib/importlib/metadata/_text.py b/Lib/importlib/metadata/_text.py
index 766979d..c88cfbb 100644
--- a/Lib/importlib/metadata/_text.py
+++ b/Lib/importlib/metadata/_text.py
@@ -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())
diff --git a/Lib/test/test_importlib/data/example2-1.0.0-py3-none-any.whl b/Lib/test/test_importlib/data/example2-1.0.0-py3-none-any.whl
new file mode 100644
index 0000000..5ca9365
--- /dev/null
+++ b/Lib/test/test_importlib/data/example2-1.0.0-py3-none-any.whl
Binary files differ
diff --git a/Lib/test/test_importlib/fixtures.py b/Lib/test/test_importlib/fixtures.py
index 12ed07d..d7ed4e9 100644
--- a/Lib/test/test_importlib/fixtures.py
+++ b/Lib/test/test_importlib/fixtures.py
@@ -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)
diff --git a/Lib/test/test_importlib/test_main.py b/Lib/test/test_importlib/test_main.py
index 52cb637..2e120f7 100644
--- a/Lib/test/test_importlib/test_main.py
+++ b/Lib/test/test_importlib/test_main.py
@@ -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()
diff --git a/Lib/test/test_importlib/test_metadata_api.py b/Lib/test/test_importlib/test_metadata_api.py
index 4a45312..e16773a 100644
--- a/Lib/test/test_importlib/test_metadata_api.py
+++ b/Lib/test/test_importlib/test_metadata_api.py
@@ -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'))
diff --git a/Lib/test/test_importlib/test_zip.py b/Lib/test/test_importlib/test_zip.py
index bf16a3b..276f628 100644
--- a/Lib/test/test_importlib/test_zip.py
+++ b/Lib/test/test_importlib/test_zip.py
@@ -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):
diff --git a/Misc/NEWS.d/next/Library/2021-12-16-13-54-55.bpo-44893.I7aLiW.rst b/Misc/NEWS.d/next/Library/2021-12-16-13-54-55.bpo-44893.I7aLiW.rst
new file mode 100644
index 0000000..e77c6ad
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2021-12-16-13-54-55.bpo-44893.I7aLiW.rst
@@ -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.