diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 58b61aef..3e20ae63 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -20,6 +20,10 @@ jobs: include: - python: pypy3.9 platform: ubuntu-latest + - platform: ubuntu-latest + python: "3.8" + - platform: ubuntu-latest + python: "3.9" runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v3 diff --git a/CHANGES.rst b/CHANGES.rst index 84684eec..f48b9d20 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,9 @@ +v4.13.0 +======= + +* #396: Added compatibility for ``PathDistributions`` originating + from Python 3.8 and 3.9. + v4.12.0 ======= diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 8761307a..eab5d4c1 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -14,7 +14,7 @@ import posixpath import collections -from . import _adapters, _meta +from . import _adapters, _meta, _py39compat from ._collections import FreezableDefaultDict, Pair from ._compat import ( NullFinder, @@ -189,6 +189,10 @@ class EntryPoint(DeprecatedTuple): following the attr, and following any extras. """ + name: str + value: str + group: str + dist: Optional['Distribution'] = None def __init__(self, name, value, group): @@ -378,7 +382,8 @@ def select(self, **params): Select entry points from self that match the given parameters (typically group and/or name). """ - return EntryPoints(ep for ep in self if ep.matches(**params)) + candidates = (_py39compat.ep_matches(ep, **params) for ep in self) + return EntryPoints(ep for ep, predicate in candidates if predicate) @property def names(self): @@ -1017,7 +1022,7 @@ def version(distribution_name): _unique = functools.partial( unique_everseen, - key=operator.attrgetter('_normalized_name'), + key=_py39compat.normalized_name, ) """ Wrapper for ``distributions`` to return unique distributions by name. diff --git a/importlib_metadata/_py39compat.py b/importlib_metadata/_py39compat.py new file mode 100644 index 00000000..cf9cc124 --- /dev/null +++ b/importlib_metadata/_py39compat.py @@ -0,0 +1,48 @@ +""" +Compatibility layer with Python 3.8/3.9 +""" +from typing import TYPE_CHECKING, Any, Optional, Tuple + +if TYPE_CHECKING: # pragma: no cover + # Prevent circular imports on runtime. + from . import Distribution, EntryPoint +else: + Distribution = EntryPoint = Any + + +def normalized_name(dist: Distribution) -> Optional[str]: + """ + Honor name normalization for distributions that don't provide ``_normalized_name``. + """ + try: + return dist._normalized_name + except AttributeError: + from . import Prepared # -> delay to prevent circular imports. + + return Prepared.normalize(getattr(dist, "name", None) or dist.metadata['Name']) + + +def ep_matches(ep: EntryPoint, **params) -> Tuple[EntryPoint, bool]: + """ + Workaround for ``EntryPoint`` objects without the ``matches`` method. + For the sake of convenience, a tuple is returned containing not only the + boolean value corresponding to the predicate evalutation, but also a compatible + ``EntryPoint`` object that can be safely used at a later stage. + + For example, the following sequences of expressions should be compatible: + + # Sequence 1: using the compatibility layer + candidates = (_py39compat.ep_matches(ep, **params) for ep in entry_points) + [ep for ep, predicate in candidates if predicate] + + # Sequence 2: using Python 3.9+ + [ep for ep in entry_points if ep.matches(**params)] + """ + try: + return ep, ep.matches(**params) + except AttributeError: + from . import EntryPoint # -> delay to prevent circular imports. + + # Reconstruct the EntryPoint object to make sure it is compatible. + _ep = EntryPoint(ep.name, ep.value, ep.group) + return _ep, _ep.matches(**params) diff --git a/tests/test_py39compat.py b/tests/test_py39compat.py new file mode 100644 index 00000000..7e6235e4 --- /dev/null +++ b/tests/test_py39compat.py @@ -0,0 +1,74 @@ +import sys +import pathlib +import unittest + +from . import fixtures +from importlib_metadata import ( + distribution, + distributions, + entry_points, + metadata, + version, +) + + +class OldStdlibFinderTests(fixtures.DistInfoPkgOffPath, unittest.TestCase): + def setUp(self): + python_version = sys.version_info[:2] + if python_version < (3, 8) or python_version > (3, 9): + self.skipTest("Tests specific for Python 3.8/3.9") + super().setUp() + + def _meta_path_finder(self): + from importlib.metadata import ( + Distribution, + DistributionFinder, + PathDistribution, + ) + from importlib.util import spec_from_file_location + + path = pathlib.Path(self.site_dir) + + class CustomDistribution(Distribution): + def __init__(self, name, path): + self.name = name + self._path_distribution = PathDistribution(path) + + def read_text(self, filename): + return self._path_distribution.read_text(filename) + + def locate_file(self, path): + return self._path_distribution.locate_file(path) + + class CustomFinder: + @classmethod + def find_spec(cls, fullname, _path=None, _target=None): + candidate = pathlib.Path(path, *fullname.split(".")).with_suffix(".py") + if candidate.exists(): + return spec_from_file_location(fullname, candidate) + + @classmethod + def find_distributions(self, context=DistributionFinder.Context()): + for dist_info in path.glob("*.dist-info"): + yield PathDistribution(dist_info) + name, _, _ = str(dist_info).partition("-") + yield CustomDistribution(name + "_custom", dist_info) + + return CustomFinder + + def test_compatibility_with_old_stdlib_path_distribution(self): + """ + Given a custom finder that uses Python 3.8/3.9 importlib.metadata is installed, + when importlib_metadata functions are called, there should be no exceptions. + Ref python/importlib_metadata#396. + """ + self.fixtures.enter_context(fixtures.install_finder(self._meta_path_finder())) + + assert list(distributions()) + assert distribution("distinfo_pkg") + assert distribution("distinfo_pkg_custom") + assert version("distinfo_pkg") > "0" + assert version("distinfo_pkg_custom") > "0" + assert list(metadata("distinfo_pkg")) + assert list(metadata("distinfo_pkg_custom")) + assert list(entry_points(group="entries"))