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

Add compatibility for PathDistributions implemented with stdlib objects in Python 3.8/3.9 #397

Merged
merged 15 commits into from
Oct 1, 2022
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
v4.13.0
=======

* #396: Added compatibility for ``PathDistributions`` originating
from Python 3.8 and 3.9.

v4.12.0
=======

Expand Down
11 changes: 8 additions & 3 deletions importlib_metadata/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand Down
48 changes: 48 additions & 0 deletions importlib_metadata/_py39compat.py
Original file line number Diff line number Diff line change
@@ -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)
74 changes: 74 additions & 0 deletions tests/test_py39compat.py
Original file line number Diff line number Diff line change
@@ -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"))