diff --git a/Lib/importlib/metadata/__init__.py b/Lib/importlib/metadata/__init__.py index ec41ed39157..d44541fcbfb 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 _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" + } diff --git a/Lib/importlib/metadata/_functools.py b/Lib/importlib/metadata/_functools.py index 73f50d00bc0..71f66bd03cb 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 dd45f2f0966..d4ca9b9140e 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 1a6edbf957d..d5c0576194e 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 766979d93c1..c88cfbb2349 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 00000000000..5ca93657f81 Binary files /dev/null and b/Lib/test/test_importlib/data/example2-1.0.0-py3-none-any.whl differ diff --git a/Lib/test/test_importlib/fixtures.py b/Lib/test/test_importlib/fixtures.py index 12ed07d3374..d7ed4e9d56f 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 52cb63712a5..2e120f7ac50 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 4a45312e31c..e16773a7e87 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), '') + self.assertRegex(repr(util.hash), '') 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 bf16a3b95e1..276f6288c91 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 00000000000..e77c6ad2a48 --- /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.