diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 2b53483f..49f74ab5 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -29,21 +29,28 @@ from itertools import starmap from typing import Any -from . import _meta from ._collections import FreezableDefaultDict, Pair from ._compat import ( NullFinder, install, + localize, ) from ._context import ExceptionTrap -from ._functools import method_cache, noop, pass_none, passthrough +from ._functools import apply, compose, method_cache, noop, pass_none, passthrough from ._itertools import always_iterable, bucket, unique_everseen -from ._meta import PackageMetadata, SimplePath +from ._meta import ( + IDistribution, + IPackagePath, + PackageMetadata, + SimplePath, +) from .compat import py311 __all__ = [ 'Distribution', 'DistributionFinder', + 'IDistribution', + 'IPackagePath', 'MetadataNotFound', 'PackageMetadata', 'PackageNotFoundError', @@ -215,7 +222,7 @@ class EntryPoint: value: str group: str - dist: Distribution | None = None + dist: IDistribution | None = None def __init__(self, name: str, value: str, group: str) -> None: vars(self).update(name=name, value=value, group=group) @@ -380,7 +387,7 @@ class PackagePath(pathlib.PurePosixPath): hash: FileHash | None size: int - dist: Distribution + dist: IDistribution def read_text(self, encoding: str = 'utf-8') -> str: return self.locate().read_text(encoding=encoding) @@ -473,6 +480,7 @@ def locate_file(self, path: str | os.PathLike[str]) -> SimplePath: """ @classmethod + @apply(localize.dist) def from_name(cls, name: str) -> Distribution: """Return the Distribution for the given package name. @@ -491,6 +499,7 @@ def from_name(cls, name: str) -> Distribution: raise PackageNotFoundError(name) from None @classmethod + @apply(functools.partial(map, localize.dist)) def discover( cls, *, context: DistributionFinder.Context | None = None, **kwargs ) -> Iterable[Distribution]: @@ -543,7 +552,8 @@ def _discover_resolvers(): return filter(None, declared) @property - def metadata(self) -> _meta.PackageMetadata: + @apply(localize.message) + def metadata(self) -> PackageMetadata: """Return the parsed metadata for this Distribution. The returned object will have keys that name the various bits of @@ -567,7 +577,7 @@ def metadata(self) -> _meta.PackageMetadata: return self._assemble_message(self._ensure_metadata_present(text)) @staticmethod - def _assemble_message(text: str) -> _meta.PackageMetadata: + def _assemble_message(text: str) -> PackageMetadata: # deferred for performance (python/cpython#109829) from . import _adapters @@ -607,10 +617,11 @@ def entry_points(self) -> EntryPoints: return EntryPoints._from_text_for(self.read_text('entry_points.txt'), self) @property - def files(self) -> list[PackagePath] | None: + @apply(pass_none(compose(list, functools.partial(map, localize.package_path)))) + def files(self) -> list[IPackagePath] | None: """Files in this distribution. - :return: List of PackagePath for this distribution or None + :return: List of PackagePath-like objects for this distribution or None Result is `None` if the metadata file that enumerates files (i.e. RECORD for dist-info, or installed-files.txt or @@ -1108,7 +1119,7 @@ def _name_from_stem(stem): return name -def distribution(distribution_name: str) -> Distribution: +def distribution(distribution_name: str) -> IDistribution: """Get the ``Distribution`` instance for the named package. :param distribution_name: The name of the distribution package as a string. @@ -1117,7 +1128,7 @@ def distribution(distribution_name: str) -> Distribution: return Distribution.from_name(distribution_name) -def distributions(**kwargs) -> Iterable[Distribution]: +def distributions(**kwargs) -> Iterable[IDistribution]: """Get all ``Distribution`` instances in the current environment. :return: An iterable of ``Distribution`` instances. @@ -1125,7 +1136,7 @@ def distributions(**kwargs) -> Iterable[Distribution]: return Distribution.discover(**kwargs) -def metadata(distribution_name: str) -> _meta.PackageMetadata: +def metadata(distribution_name: str) -> PackageMetadata: """Get the metadata for the named package. :param distribution_name: The name of the distribution package to query. @@ -1169,7 +1180,7 @@ def entry_points(**params) -> EntryPoints: return EntryPoints(eps).select(**params) -def files(distribution_name: str) -> list[PackagePath] | None: +def files(distribution_name: str) -> list[IPackagePath] | None: """Return a list of files for the named package. :param distribution_name: The name of the distribution package to query. @@ -1209,7 +1220,7 @@ def _top_level_declared(dist): return (dist.read_text('top_level.txt') or '').split() -def _topmost(name: PackagePath) -> str | None: +def _topmost(name: IPackagePath) -> str | None: """ Return the top-most parent as long as there is a parent. """ @@ -1217,7 +1228,7 @@ def _topmost(name: PackagePath) -> str | None: return top if rest else None -def _get_toplevel_name(name: PackagePath) -> str: +def _get_toplevel_name(name: IPackagePath) -> str: """ Infer a possibly importable module name from a name presumed on sys.path. diff --git a/importlib_metadata/_compat.py b/importlib_metadata/_compat/__init__.py similarity index 100% rename from importlib_metadata/_compat.py rename to importlib_metadata/_compat/__init__.py diff --git a/importlib_metadata/_compat/localize.py b/importlib_metadata/_compat/localize.py new file mode 100644 index 00000000..b08b4fbd --- /dev/null +++ b/importlib_metadata/_compat/localize.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING, cast + +import importlib_metadata + +from ..compat import py312 + +if TYPE_CHECKING: + import email.message + import importlib.metadata + + from .. import _adapters + from .._meta import SimplePath + + +def dist( + dist: importlib_metadata.Distribution | importlib.metadata.Distribution, +) -> importlib_metadata.Distribution: + """ + Ensure dist is an :class:`importlib_metadata.Distribution`. + + >>> import importlib.metadata + >>> stdlib = importlib.metadata.PathDistribution('foo') + >>> type(stdlib) + + >>> local = dist(stdlib) + >>> type(local) + + + A provider of its own Distribution subclass is passed through, as + long as it implements the interface consumers expect. + + >>> class CustomDist(importlib.metadata.Distribution): + ... def read_text(self, name): + ... return + ... def locate_file(self, name): + ... return + >>> custom = CustomDist() + >>> dist(custom) is custom + True + + One that doesn't is passed through too, but names what it omits. + + >>> class Incomplete: + ... def read_text(self, name): + ... return + >>> import pytest + >>> with pytest.warns(UserWarning, match="is missing at, discover"): + ... local = dist(Incomplete()) + >>> local is not None + True + """ + if isinstance(dist, importlib_metadata.Distribution): + return dist + + # only a foreign dist gets this far, so defer the cost of the import + # (python/cpython#109829) + import importlib.metadata + + if isinstance(dist, importlib.metadata.PathDistribution): + return importlib_metadata.PathDistribution(cast('SimplePath', dist._path)) + # Warn on what the interface requires and the class omits, rather than + # on the class not being one of ours; a provider can act on the former. + # Also tolerates pytest having replaced importlib_metadata, as the + # replaced class still conforms. + if not py312.conforms(dist, importlib_metadata.IDistribution): + warnings.warn( + f"Distribution subclass {dist.__class__} is missing " + f"{', '.join(unimplemented(dist))}" + ) + return cast('importlib_metadata.Distribution', dist) + + +def unimplemented(dist) -> list[str]: + """ + The members IDistribution requires that dist's class doesn't supply. + """ + required = py312.required(importlib_metadata.IDistribution) + return sorted(attr for attr in required if not hasattr(type(dist), attr)) + + +def message( + input: _adapters.Message | email.message.Message, +) -> _adapters.Message: + """ + Ensure a message is adapted to an importlib_metadata.Message. + + >>> import email.message + >>> stdlib = email.message.Message() + >>> local = message(stdlib) + >>> type(local) + + """ + # deferred for performance (python/cpython#109829) + from .. import _adapters + + if isinstance(input, _adapters.Message): + return input + return _adapters.Message(input) + + +def package_path( + input: importlib_metadata.PackagePath | importlib.metadata.PackagePath, +) -> importlib_metadata.PackagePath: + """ + Ensure a package path is adapted to an importlib_metadata.PackagePath. + + >>> import importlib.metadata + >>> stdlib = importlib.metadata.PackagePath('foo') + >>> type(stdlib) + + >>> local = package_path(stdlib) + >>> type(local) + + """ + if isinstance(input, importlib_metadata.PackagePath): + return input + replacement = importlib_metadata.PackagePath(input) + vars(replacement).update(vars(input)) + return replacement diff --git a/importlib_metadata/_functools.py b/importlib_metadata/_functools.py index c159b46e..4af7f685 100644 --- a/importlib_metadata/_functools.py +++ b/importlib_metadata/_functools.py @@ -106,6 +106,60 @@ def wrapper(param, *args, **kwargs): return wrapper +# From jaraco.functools 4.0.2 +def compose(*funcs): + """ + Compose any number of unary functions into a single unary function. + + Comparable to + `function composition `_ + in mathematics: + + ``h = g ∘ f`` implies ``h(x) = g(f(x))``. + + In Python, ``h = compose(g, f)``. + + >>> import textwrap + >>> expected = str.strip(textwrap.dedent(compose.__doc__)) + >>> strip_and_dedent = compose(str.strip, textwrap.dedent) + >>> strip_and_dedent(compose.__doc__) == expected + True + + Compose also allows the innermost function to take arbitrary arguments. + + >>> round_three = lambda x: round(x, ndigits=3) + >>> f = compose(round_three, int.__truediv__) + >>> [f(3*x, x+1) for x in range(1,10)] + [1.5, 2.0, 2.25, 2.4, 2.5, 2.571, 2.625, 2.667, 2.7] + """ + + def compose_two(f1, f2): + return lambda *args, **kwargs: f1(f2(*args, **kwargs)) + + return functools.reduce(compose_two, funcs) + + +def apply(transform): + """ + Decorate a function with a transform function that is + invoked on results returned from the decorated function. + + >>> @apply(reversed) + ... def get_numbers(start): + ... "doc for get_numbers" + ... return range(start, start+3) + >>> list(get_numbers(4)) + [6, 5, 4] + >>> get_numbers.__doc__ + 'doc for get_numbers' + """ + + def wrap(func): + return functools.wraps(func)(compose(transform, func)) + + return wrap + + # From jaraco.functools 4.4 def noop(*args, **kwargs): """ diff --git a/importlib_metadata/_meta.py b/importlib_metadata/_meta.py index 219b2a86..d94b433f 100644 --- a/importlib_metadata/_meta.py +++ b/importlib_metadata/_meta.py @@ -1,17 +1,19 @@ from __future__ import annotations import os -from collections.abc import Iterator +from collections.abc import Iterable, Iterator from typing import ( Any, Protocol, TypeVar, overload, + runtime_checkable, ) _T = TypeVar("_T") +@runtime_checkable class PackageMetadata(Protocol): def __len__(self) -> int: ... # pragma: no cover @@ -69,3 +71,81 @@ def read_text(self, encoding=None) -> str: ... # pragma: no cover def read_bytes(self) -> bytes: ... # pragma: no cover def exists(self) -> bool: ... # pragma: no cover + + +@runtime_checkable +class IPackagePath(Protocol): + """ + The interface of the paths yielded by ``IDistribution.files``. + + Consumers should prefer this protocol over ``PackagePath``, + as it is also satisfied by the objects that ``importlib.metadata`` + supplies. + """ + + hash: Any | None + size: int | None + dist: IDistribution + + def read_text(self, encoding: str = 'utf-8') -> str: ... # pragma: no cover + + def read_binary(self) -> bytes: ... # pragma: no cover + + def locate(self) -> SimplePath: ... # pragma: no cover + + @property + def parts(self) -> tuple[str, ...]: ... # pragma: no cover + + def __fspath__(self) -> str: ... # pragma: no cover + + +@runtime_checkable +class IDistribution(Protocol): + """ + The interface of the distributions yielded by ``distributions()`` + and friends. + + Consumers should prefer this protocol over ``Distribution``, + as it is also satisfied by the objects that ``importlib.metadata`` + supplies. + """ + + def read_text( + self, filename: str | os.PathLike[str] + ) -> str | None: ... # pragma: no cover + + def locate_file( + self, path: str | os.PathLike[str] + ) -> SimplePath: ... # pragma: no cover + + @property + def metadata(self) -> PackageMetadata: ... # pragma: no cover + + @property + def name(self) -> str: ... # pragma: no cover + + @property + def version(self) -> str: ... # pragma: no cover + + @property + def entry_points(self) -> Any: ... # pragma: no cover + + @property + def files(self) -> list[IPackagePath] | None: ... # pragma: no cover + + @property + def requires(self) -> list[str] | None: ... # pragma: no cover + + @property + def origin(self) -> Any: ... # pragma: no cover + + @classmethod + def discover( + cls, *, context: Any | None = None, **kwargs: Any + ) -> Iterable[IDistribution]: ... # pragma: no cover + + @classmethod + def from_name(cls, name: str) -> IDistribution: ... # pragma: no cover + + @staticmethod + def at(path: str | os.PathLike[str]) -> IDistribution: ... # pragma: no cover diff --git a/importlib_metadata/compat/py311.py b/importlib_metadata/compat/py311.py index 3a532743..e082e197 100644 --- a/importlib_metadata/compat/py311.py +++ b/importlib_metadata/compat/py311.py @@ -1,7 +1,9 @@ +import operator import os import pathlib import sys import types +import typing def wrap(path): # pragma: no cover @@ -20,3 +22,11 @@ def relative_to(root, *, walk_up=False): relative_fix = wrap if sys.version_info < (3, 12) else lambda x: x + + +# __protocol_attrs__ is only present on Python 3.12 and later. +protocol_attrs = ( + typing._get_protocol_attrs # type: ignore[attr-defined] # private, 3.11 only + if sys.version_info < (3, 12) + else operator.attrgetter('__protocol_attrs__') +) diff --git a/importlib_metadata/compat/py312.py b/importlib_metadata/compat/py312.py new file mode 100644 index 00000000..056a5940 --- /dev/null +++ b/importlib_metadata/compat/py312.py @@ -0,0 +1,29 @@ +import sys + +from . import py311 + +# Distribution.origin was added in Python 3.13, so a provider built on +# importlib.metadata cannot supply it on every supported Python and must +# not be faulted for its absence. +exempt = frozenset({'origin'}) if sys.version_info < (3, 13) else frozenset() + + +def required(protocol): + """ + The members a protocol expects of a provider on this Python. + """ + return set(py311.protocol_attrs(protocol)) - exempt + + +def tolerant_conforms(instance, protocol): # pragma: no cover + """ + Determine conformance, overlooking the exempt members. + + Consults the type rather than the instance, which also keeps + isinstance() from evaluating property members on Python 3.11 + (https://github.com/python/cpython/issues/102433). + """ + return all(hasattr(type(instance), attr) for attr in required(protocol)) + + +conforms = tolerant_conforms if sys.version_info < (3, 13) else isinstance diff --git a/newsfragments/486.feature.rst b/newsfragments/486.feature.rst new file mode 100644 index 00000000..ff7c407e --- /dev/null +++ b/newsfragments/486.feature.rst @@ -0,0 +1 @@ +Added ``IDistribution`` and ``IPackagePath`` protocols so consumers can target a stable, shared interface across ``importlib.metadata`` and ``importlib_metadata``. A provider supplying its own ``Distribution`` subclass is warned about only when the class omits members that interface requires, not merely because it derives from ``importlib.metadata``. ``origin`` is exempt from that expectation until support for Python 3.12 is dropped, as there is none to inherit before Python 3.13. diff --git a/newsfragments/486.removal.rst b/newsfragments/486.removal.rst new file mode 100644 index 00000000..95b21ea9 --- /dev/null +++ b/newsfragments/486.removal.rst @@ -0,0 +1 @@ +When providers supply objects from ``importlib.metadata``, they are now adapted to the classes from ``importlib_metadata``. diff --git a/tests/compat/test_localize.py b/tests/compat/test_localize.py new file mode 100644 index 00000000..36ed50ac --- /dev/null +++ b/tests/compat/test_localize.py @@ -0,0 +1,161 @@ +""" +Tests for the adaptation of objects supplied by ``importlib.metadata`` +providers into their ``importlib_metadata`` counterparts (#486). +""" + +import importlib.metadata as stdlib +import pathlib +import sys +import unittest +import warnings + +import importlib_metadata +import importlib_metadata._adapters +from importlib_metadata import ( + IDistribution, + distribution, + distributions, + entry_points, + files, +) +from importlib_metadata._compat import localize +from importlib_metadata.compat import py312 + +from .. import fixtures + + +class StdlibProviderTests(fixtures.DistInfoPkgOffPath, unittest.TestCase): + def setUp(self): + super().setUp() + self.fixtures.enter_context(fixtures.install_finder(self._finder())) + + def _finder(self): + """ + A finder supplying distributions from the stdlib, as a third party + provider built on importlib.metadata would. + """ + site_dir = pathlib.Path(self.site_dir) + + class StdlibFinder: + @staticmethod + def find_spec(*args, **kwargs): + return None + + @staticmethod + def find_distributions(context=None): + return map(stdlib.PathDistribution, site_dir.glob('*.dist-info')) + + return StdlibFinder() + + def test_from_name_localized(self): + assert isinstance(distribution('distinfo-pkg'), importlib_metadata.Distribution) + + def test_discover_localized(self): + assert all( + isinstance(dist, importlib_metadata.Distribution) + for dist in distributions() + ) + + def test_metadata_localized(self): + meta = distribution('distinfo-pkg').metadata + assert isinstance(meta, importlib_metadata._adapters.Message) + assert meta.json['name'] == 'distinfo-pkg' + + def test_files_localized(self): + assert all( + isinstance(file, importlib_metadata.PackagePath) + for file in files('distinfo-pkg') + ) + + def test_entry_points_selectable(self): + """ + Entry points are selectable, even though the provider knows + nothing of EntryPoints. + """ + assert entry_points(group='entries')['main'].value == 'mod:main' + + +class CustomProviderTests(fixtures.DistInfoPkgOffPath, unittest.TestCase): + """ + A provider deriving its own class from importlib.metadata, as the + custom provider example in the CPython docs does. + """ + + def setUp(self): + super().setUp() + self.fixtures.enter_context(fixtures.install_finder(self._finder())) + + def _finder(self): + site_dir = pathlib.Path(self.site_dir) + + class CustomDistribution(stdlib.Distribution): + def __init__(self, path): + self._delegate = stdlib.PathDistribution(path) + + def read_text(self, filename): + return self._delegate.read_text(filename) + + def locate_file(self, path): + return self._delegate.locate_file(path) + + class CustomFinder: + @staticmethod + def find_spec(*args, **kwargs): + return None + + @staticmethod + def find_distributions(context=None): + return map(CustomDistribution, site_dir.glob('*.dist-info')) + + return CustomFinder() + + def test_conforming_provider_is_not_warned_about(self): + """ + Implementing the interface is enough; deriving from this package + is not required (#486). + """ + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + assert distribution('distinfo-pkg').version == '1.0.0' + assert not [str(warning.message) for warning in caught] + + +class ConformanceCheckTests(unittest.TestCase): + @unittest.skipIf( + sys.version_info >= (3, 13), + "origin is exempt only while Python 3.12 is supported", + ) + def test_exempt_members_are_not_required(self): + """ + There is no Distribution.origin for a provider to inherit before + Python 3.13, so its absence must not be faulted. + """ + assert not hasattr(stdlib.Distribution, 'origin') + assert 'origin' not in py312.required(IDistribution) + + def test_properties_are_not_evaluated(self): + """ + Deciding conformance consults the class, never the instance. + + Through Python 3.11, isinstance() against a runtime-checkable + protocol evaluates property members, so a property that raises + would escape from an ordinary distribution lookup. + See python/cpython#102433. + """ + + class Hostile(stdlib.Distribution): + def read_text(self, filename): + return None + + def locate_file(self, path): + return None + + @property + def metadata(self): + raise RuntimeError("must not be evaluated") + + hostile = Hostile() + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + assert localize.dist(hostile) is hostile + assert not [str(warning.message) for warning in caught] diff --git a/tests/compat/test_protocols.py b/tests/compat/test_protocols.py new file mode 100644 index 00000000..f97c2fe4 --- /dev/null +++ b/tests/compat/test_protocols.py @@ -0,0 +1,45 @@ +""" +The published protocols must also describe the objects supplied by +``importlib.metadata`` in the running Python (#486). +""" + +import importlib.metadata as stdlib +import pathlib +import sys +import tempfile +import unittest + +from importlib_metadata import IDistribution, IPackagePath, PackageMetadata +from importlib_metadata.compat import py312 + + +class StdlibProtocolTests(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.TemporaryDirectory() + self.addCleanup(self.tmpdir.cleanup) + tmp_path = pathlib.Path(self.tmpdir.name) + dist_info = tmp_path / 'protocol_sample-1.0.dist-info' + dist_info.mkdir() + (dist_info / 'METADATA').write_text( + 'Name: protocol-sample\nVersion: 1.0\n', encoding='utf-8' + ) + self.dist = stdlib.PathDistribution(dist_info) + + def test_stdlib_distribution_conforms(self): + assert py312.conforms(self.dist, IDistribution) + + @unittest.skipIf( + sys.version_info < (3, 13), "stdlib Distribution.origin added in Python 3.13" + ) + def test_stdlib_distribution_matches_protocol(self): + assert isinstance(self.dist, IDistribution) + + def test_stdlib_metadata_matches_protocol(self): + assert isinstance(self.dist.metadata, PackageMetadata) + + def test_stdlib_package_path_matches_protocol(self): + package_path = stdlib.PackagePath('protocol_sample/__init__.py') + package_path.hash = None + package_path.size = 0 + package_path.dist = self.dist + assert isinstance(package_path, IPackagePath) diff --git a/tests/test_protocols.py b/tests/test_protocols.py new file mode 100644 index 00000000..1b33632a --- /dev/null +++ b/tests/test_protocols.py @@ -0,0 +1,27 @@ +import unittest + +from importlib_metadata import ( + IDistribution, + IPackagePath, + PackageMetadata, + distribution, + files, + metadata, +) + +from . import fixtures + + +class ProtocolTests(fixtures.DistInfoPkg, unittest.TestCase): + """ + The objects supplied to consumers satisfy the published protocols. + """ + + def test_distribution_matches_protocol(self): + assert isinstance(distribution('distinfo-pkg'), IDistribution) + + def test_metadata_matches_protocol(self): + assert isinstance(metadata('distinfo-pkg'), PackageMetadata) + + def test_package_path_matches_protocol(self): + assert all(isinstance(file, IPackagePath) for file in files('distinfo-pkg'))