From 4261109a9f6a21d93081cf376b465d16d8e73ecc Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Wed, 11 Sep 2024 11:46:31 -0400 Subject: [PATCH 01/29] Add a couple of functions from jaraco.functools. --- importlib_metadata/_functools.py | 54 ++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/importlib_metadata/_functools.py b/importlib_metadata/_functools.py index 5dda6a21..dfe1f746 100644 --- a/importlib_metadata/_functools.py +++ b/importlib_metadata/_functools.py @@ -102,3 +102,57 @@ def wrapper(param, *args, **kwargs): return func(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 e36808d31bcf3848e30702eb4a487eb4b8f8b4ae Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Wed, 11 Sep 2024 11:47:30 -0400 Subject: [PATCH 02/29] Create 'localize_dist' function to convert stdlib to local versions of Distributions. --- importlib_metadata/__init__.py | 5 ++++- importlib_metadata/_compat.py | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 46a14e64..416d5a25 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -33,8 +33,9 @@ from ._compat import ( NullFinder, install, + localize_dist, ) -from ._functools import method_cache, pass_none +from ._functools import apply, method_cache, pass_none from ._itertools import always_iterable, bucket, unique_everseen from ._meta import PackageMetadata, SimplePath from .compat import py39, py311 @@ -409,6 +410,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. @@ -427,6 +429,7 @@ def from_name(cls, name: str) -> Distribution: raise PackageNotFoundError(name) @classmethod + @apply(functools.partial(map, localize_dist)) def discover( cls, *, context: Optional[DistributionFinder.Context] = None, **kwargs ) -> Iterable[Distribution]: diff --git a/importlib_metadata/_compat.py b/importlib_metadata/_compat.py index 01356d69..ebd163db 100644 --- a/importlib_metadata/_compat.py +++ b/importlib_metadata/_compat.py @@ -1,5 +1,13 @@ +from __future__ import annotations + +import importlib.metadata import platform import sys +import warnings +from typing import cast + +import importlib_metadata + __all__ = ['install', 'NullFinder'] @@ -54,3 +62,17 @@ def pypy_partial(val): """ is_pypy = platform.python_implementation() == 'PyPy' return val + is_pypy + + +def localize_dist( + dist: importlib_metadata.Distribution | importlib.metadata.Distribution, +) -> importlib_metadata.Distribution: + """ + Ensure dist is an :class:`importlib_metadata.Distribution`. + """ + if isinstance(dist, importlib_metadata.Distribution): + return dist + if isinstance(dist, importlib.metadata.PathDistribution): + return importlib_metadata.PathDistribution(dist._path) + warnings.warn(f"Unrecognized distribution subclass {dist.__class__}") + return cast(importlib_metadata.Distribution, dist) From 6ff671d97e7f807cfc082c2753c93dcd01c0b2e3 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Wed, 11 Sep 2024 11:43:37 -0400 Subject: [PATCH 03/29] Create 'localize_metadata' function to normalize the outputs for .metadata. --- importlib_metadata/__init__.py | 2 ++ importlib_metadata/_compat.py | 17 ++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 416d5a25..6962eee2 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -34,6 +34,7 @@ NullFinder, install, localize_dist, + localize_metadata, ) from ._functools import apply, method_cache, pass_none from ._itertools import always_iterable, bucket, unique_everseen @@ -477,6 +478,7 @@ def _discover_resolvers(): return filter(None, declared) @property + @apply(localize_metadata) def metadata(self) -> _meta.PackageMetadata: """Return the parsed metadata for this Distribution. diff --git a/importlib_metadata/_compat.py b/importlib_metadata/_compat.py index ebd163db..84937ff5 100644 --- a/importlib_metadata/_compat.py +++ b/importlib_metadata/_compat.py @@ -1,12 +1,13 @@ from __future__ import annotations +import email.message import importlib.metadata import platform import sys import warnings from typing import cast -import importlib_metadata +import importlib_metadata._adapters __all__ = ['install', 'NullFinder'] @@ -76,3 +77,17 @@ def localize_dist( return importlib_metadata.PathDistribution(dist._path) warnings.warn(f"Unrecognized distribution subclass {dist.__class__}") return cast(importlib_metadata.Distribution, dist) + + +if sys.version_info >= (3, 10): + StdlibMessage = importlib.metadata._adapters.Message +else: + StdlibMessage = email.message.Message + + +def localize_metadata( + input: importlib_metadata._adapters.Message | StdlibMessage, +) -> importlib_metadata._adapters.Message: + if isinstance(input, importlib_metadata._adapters.Message): + return input + return importlib_metadata._adapters.Message(input) From 5cd7d14974ccd3c565274f46fb2a939184ea990d Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Wed, 11 Sep 2024 12:04:29 -0400 Subject: [PATCH 04/29] Create 'localize_package_path' function to convert stdlib to local versions of PackagePath. --- importlib_metadata/__init__.py | 4 +++- importlib_metadata/_compat.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 6962eee2..0933d433 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -35,8 +35,9 @@ install, localize_dist, localize_metadata, + localize_package_path, ) -from ._functools import apply, method_cache, pass_none +from ._functools import apply, compose, method_cache, pass_none from ._itertools import always_iterable, bucket, unique_everseen from ._meta import PackageMetadata, SimplePath from .compat import py39, py311 @@ -529,6 +530,7 @@ def entry_points(self) -> EntryPoints: return EntryPoints._from_text_for(self.read_text('entry_points.txt'), self) @property + @apply(pass_none(compose(list, functools.partial(map, localize_package_path)))) def files(self) -> Optional[List[PackagePath]]: """Files in this distribution. diff --git a/importlib_metadata/_compat.py b/importlib_metadata/_compat.py index 84937ff5..1d34cce6 100644 --- a/importlib_metadata/_compat.py +++ b/importlib_metadata/_compat.py @@ -91,3 +91,13 @@ def localize_metadata( if isinstance(input, importlib_metadata._adapters.Message): return input return importlib_metadata._adapters.Message(input) + + +def localize_package_path( + input: importlib_metadata.PackagePath | importlib.metadata.PackagePath, +) -> importlib_metadata.PackagePath: + if isinstance(input, importlib_metadata.PackagePath): + return input + replacement = importlib_metadata.PackagePath(input) + vars(replacement).update(vars(input)) + return replacement From 374bd980308e65c5e17b881d79fd52b4cc3219e7 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Wed, 11 Sep 2024 12:08:55 -0400 Subject: [PATCH 05/29] Add news fragment. --- newsfragments/486.removal.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 newsfragments/486.removal.rst diff --git a/newsfragments/486.removal.rst b/newsfragments/486.removal.rst new file mode 100644 index 00000000..1adc1dda --- /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``. \ No newline at end of file From 90002689b46ab7606cc6e7b40986fec3cdc82b42 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Wed, 11 Sep 2024 12:40:37 -0400 Subject: [PATCH 06/29] In localize_dist, cast the result to paper over an error. --- importlib_metadata/_compat.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/importlib_metadata/_compat.py b/importlib_metadata/_compat.py index 1d34cce6..128d35e9 100644 --- a/importlib_metadata/_compat.py +++ b/importlib_metadata/_compat.py @@ -74,7 +74,9 @@ def localize_dist( if isinstance(dist, importlib_metadata.Distribution): return dist if isinstance(dist, importlib.metadata.PathDistribution): - return importlib_metadata.PathDistribution(dist._path) + return importlib_metadata.PathDistribution( + cast(importlib_metadata._meta.SimplePath, dist._path) + ) warnings.warn(f"Unrecognized distribution subclass {dist.__class__}") return cast(importlib_metadata.Distribution, dist) From 1a30e01f5bdc64f1b2b097c670faeeeac24a2ff6 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Wed, 11 Sep 2024 12:42:20 -0400 Subject: [PATCH 07/29] Stop trying to differentiate between importlib.metadata._adapters.Message and email.message.Message. --- importlib_metadata/_compat.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/importlib_metadata/_compat.py b/importlib_metadata/_compat.py index 128d35e9..e242e3ce 100644 --- a/importlib_metadata/_compat.py +++ b/importlib_metadata/_compat.py @@ -81,14 +81,8 @@ def localize_dist( return cast(importlib_metadata.Distribution, dist) -if sys.version_info >= (3, 10): - StdlibMessage = importlib.metadata._adapters.Message -else: - StdlibMessage = email.message.Message - - def localize_metadata( - input: importlib_metadata._adapters.Message | StdlibMessage, + input: importlib_metadata._adapters.Message | email.message.Message, ) -> importlib_metadata._adapters.Message: if isinstance(input, importlib_metadata._adapters.Message): return input From 5a67414802e7e863fa6e52a6cc96d8d23bdc41b6 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Wed, 11 Sep 2024 14:03:38 -0400 Subject: [PATCH 08/29] Suppress warnings when 'importlib_metadata' has been imported twice. --- importlib_metadata/_compat.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/importlib_metadata/_compat.py b/importlib_metadata/_compat.py index e242e3ce..04311fb9 100644 --- a/importlib_metadata/_compat.py +++ b/importlib_metadata/_compat.py @@ -77,7 +77,10 @@ def localize_dist( return importlib_metadata.PathDistribution( cast(importlib_metadata._meta.SimplePath, dist._path) ) - warnings.warn(f"Unrecognized distribution subclass {dist.__class__}") + # workaround for when pytest has replaced importlib_metadata + # https://github.com/python/importlib_metadata/pull/505#issuecomment-2344329001 + if dist.__class__.__module__ != 'importlib_metadata': + warnings.warn(f"Unrecognized distribution subclass {dist.__class__}") return cast(importlib_metadata.Distribution, dist) From 5a67a0d8f53e2bb478d46f57a1c9b151b59a6964 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Wed, 11 Sep 2024 14:14:36 -0400 Subject: [PATCH 09/29] Suppress warning when CustomDistribution, derived from importlib.metadata, is present. --- tests/compat/test_py39_compat.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/compat/test_py39_compat.py b/tests/compat/test_py39_compat.py index db9fb1b7..dd5dcfc0 100644 --- a/tests/compat/test_py39_compat.py +++ b/tests/compat/test_py39_compat.py @@ -1,6 +1,8 @@ +import contextlib import pathlib import sys import unittest +import warnings from importlib_metadata import ( distribution, @@ -63,6 +65,9 @@ def test_compatibility_with_old_stdlib_path_distribution(self): Ref python/importlib_metadata#396. """ self.fixtures.enter_context(fixtures.install_finder(self._meta_path_finder())) + self.fixtures.enter_context( + suppress_unrecognized_distribution_subclass_warning() + ) assert list(distributions()) assert distribution("distinfo_pkg") @@ -72,3 +77,15 @@ def test_compatibility_with_old_stdlib_path_distribution(self): assert list(metadata("distinfo_pkg")) assert list(metadata("distinfo_pkg_custom")) assert list(entry_points(group="entries")) + + +@contextlib.contextmanager +def suppress_unrecognized_distribution_subclass_warning(): + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + category=UserWarning, + message="Unrecognized distribution subclass", + append=True, + ) + yield From 3dad7541b5b99194ada5dc5411efa8857379779b Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Wed, 11 Sep 2024 14:20:00 -0400 Subject: [PATCH 10/29] Move _compat to its own package. --- importlib_metadata/{_compat.py => _compat/__init__.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename importlib_metadata/{_compat.py => _compat/__init__.py} (100%) 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 From 0ad266afdfa1411b3b34e621e6df1088ce3e1f5c Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Wed, 11 Sep 2024 14:22:56 -0400 Subject: [PATCH 11/29] Extract localize functions into their own module. --- importlib_metadata/__init__.py | 12 +++---- importlib_metadata/_compat/__init__.py | 46 -------------------------- importlib_metadata/_compat/localize.py | 45 +++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 53 deletions(-) create mode 100644 importlib_metadata/_compat/localize.py diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 0933d433..9a673358 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -33,9 +33,7 @@ from ._compat import ( NullFinder, install, - localize_dist, - localize_metadata, - localize_package_path, + localize, ) from ._functools import apply, compose, method_cache, pass_none from ._itertools import always_iterable, bucket, unique_everseen @@ -412,7 +410,7 @@ def locate_file(self, path: str | os.PathLike[str]) -> SimplePath: """ @classmethod - @apply(localize_dist) + @apply(localize.dist) def from_name(cls, name: str) -> Distribution: """Return the Distribution for the given package name. @@ -431,7 +429,7 @@ def from_name(cls, name: str) -> Distribution: raise PackageNotFoundError(name) @classmethod - @apply(functools.partial(map, localize_dist)) + @apply(functools.partial(map, localize.dist)) def discover( cls, *, context: Optional[DistributionFinder.Context] = None, **kwargs ) -> Iterable[Distribution]: @@ -479,7 +477,7 @@ def _discover_resolvers(): return filter(None, declared) @property - @apply(localize_metadata) + @apply(localize.metadata) def metadata(self) -> _meta.PackageMetadata: """Return the parsed metadata for this Distribution. @@ -530,7 +528,7 @@ def entry_points(self) -> EntryPoints: return EntryPoints._from_text_for(self.read_text('entry_points.txt'), self) @property - @apply(pass_none(compose(list, functools.partial(map, localize_package_path)))) + @apply(pass_none(compose(list, functools.partial(map, localize.package_path)))) def files(self) -> Optional[List[PackagePath]]: """Files in this distribution. diff --git a/importlib_metadata/_compat/__init__.py b/importlib_metadata/_compat/__init__.py index 04311fb9..01356d69 100644 --- a/importlib_metadata/_compat/__init__.py +++ b/importlib_metadata/_compat/__init__.py @@ -1,14 +1,5 @@ -from __future__ import annotations - -import email.message -import importlib.metadata import platform import sys -import warnings -from typing import cast - -import importlib_metadata._adapters - __all__ = ['install', 'NullFinder'] @@ -63,40 +54,3 @@ def pypy_partial(val): """ is_pypy = platform.python_implementation() == 'PyPy' return val + is_pypy - - -def localize_dist( - dist: importlib_metadata.Distribution | importlib.metadata.Distribution, -) -> importlib_metadata.Distribution: - """ - Ensure dist is an :class:`importlib_metadata.Distribution`. - """ - if isinstance(dist, importlib_metadata.Distribution): - return dist - if isinstance(dist, importlib.metadata.PathDistribution): - return importlib_metadata.PathDistribution( - cast(importlib_metadata._meta.SimplePath, dist._path) - ) - # workaround for when pytest has replaced importlib_metadata - # https://github.com/python/importlib_metadata/pull/505#issuecomment-2344329001 - if dist.__class__.__module__ != 'importlib_metadata': - warnings.warn(f"Unrecognized distribution subclass {dist.__class__}") - return cast(importlib_metadata.Distribution, dist) - - -def localize_metadata( - input: importlib_metadata._adapters.Message | email.message.Message, -) -> importlib_metadata._adapters.Message: - if isinstance(input, importlib_metadata._adapters.Message): - return input - return importlib_metadata._adapters.Message(input) - - -def localize_package_path( - input: importlib_metadata.PackagePath | importlib.metadata.PackagePath, -) -> importlib_metadata.PackagePath: - 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/_compat/localize.py b/importlib_metadata/_compat/localize.py new file mode 100644 index 00000000..9062ff01 --- /dev/null +++ b/importlib_metadata/_compat/localize.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import email.message +import importlib.metadata +import warnings +from typing import cast + +import importlib_metadata._adapters + + +def dist( + dist: importlib_metadata.Distribution | importlib.metadata.Distribution, +) -> importlib_metadata.Distribution: + """ + Ensure dist is an :class:`importlib_metadata.Distribution`. + """ + if isinstance(dist, importlib_metadata.Distribution): + return dist + if isinstance(dist, importlib.metadata.PathDistribution): + return importlib_metadata.PathDistribution( + cast(importlib_metadata._meta.SimplePath, dist._path) + ) + # workaround for when pytest has replaced importlib_metadata + # https://github.com/python/importlib_metadata/pull/505#issuecomment-2344329001 + if dist.__class__.__module__ != 'importlib_metadata': + warnings.warn(f"Unrecognized distribution subclass {dist.__class__}") + return cast(importlib_metadata.Distribution, dist) + + +def metadata( + input: importlib_metadata._adapters.Message | email.message.Message, +) -> importlib_metadata._adapters.Message: + if isinstance(input, importlib_metadata._adapters.Message): + return input + return importlib_metadata._adapters.Message(input) + + +def package_path( + input: importlib_metadata.PackagePath | importlib.metadata.PackagePath, +) -> importlib_metadata.PackagePath: + if isinstance(input, importlib_metadata.PackagePath): + return input + replacement = importlib_metadata.PackagePath(input) + vars(replacement).update(vars(input)) + return replacement From 4a350a53b8e83d12db595dc3fb3f511bea4a4993 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Wed, 11 Sep 2024 14:24:05 -0400 Subject: [PATCH 12/29] Rename 'localize.metadata' to 'localize.message'. --- importlib_metadata/__init__.py | 2 +- importlib_metadata/_compat/localize.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 9a673358..c35fe108 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -477,7 +477,7 @@ def _discover_resolvers(): return filter(None, declared) @property - @apply(localize.metadata) + @apply(localize.message) def metadata(self) -> _meta.PackageMetadata: """Return the parsed metadata for this Distribution. diff --git a/importlib_metadata/_compat/localize.py b/importlib_metadata/_compat/localize.py index 9062ff01..d90da1a3 100644 --- a/importlib_metadata/_compat/localize.py +++ b/importlib_metadata/_compat/localize.py @@ -27,7 +27,7 @@ def dist( return cast(importlib_metadata.Distribution, dist) -def metadata( +def message( input: importlib_metadata._adapters.Message | email.message.Message, ) -> importlib_metadata._adapters.Message: if isinstance(input, importlib_metadata._adapters.Message): From 28c50a7146eaa49a58dc376158fc35cc6c0e3e62 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Fri, 26 Dec 2025 12:06:04 -0500 Subject: [PATCH 13/29] Add some doctests to cover all the changes. --- importlib_metadata/_compat/localize.py | 39 ++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/importlib_metadata/_compat/localize.py b/importlib_metadata/_compat/localize.py index d90da1a3..e9e7fb8e 100644 --- a/importlib_metadata/_compat/localize.py +++ b/importlib_metadata/_compat/localize.py @@ -13,6 +13,27 @@ def dist( ) -> importlib_metadata.Distribution: """ Ensure dist is an :class:`importlib_metadata.Distribution`. + + >>> stdlib = importlib.metadata.PathDistribution('foo') + >>> type(stdlib) + + >>> local = dist(stdlib) + >>> type(local) + + + >>> class CustomDist(importlib.metadata.Distribution): + ... def read_text(self, name): + ... return + ... def locate_file(self, name): + ... return + >>> subclass = CustomDist() + >>> type(subclass) + + >>> import pytest + >>> with pytest.warns(UserWarning, match="Unrecognized distribution subclass "): + ... local = dist(subclass) + >>> type(local) is type(subclass) + True """ if isinstance(dist, importlib_metadata.Distribution): return dist @@ -30,6 +51,14 @@ def dist( def message( input: importlib_metadata._adapters.Message | email.message.Message, ) -> importlib_metadata._adapters.Message: + """ + Ensure a message is adapted to an importlib_metadata.Message. + + >>> stdlib = email.message.Message() + >>> local = message(stdlib) + >>> type(local) + + """ if isinstance(input, importlib_metadata._adapters.Message): return input return importlib_metadata._adapters.Message(input) @@ -38,6 +67,16 @@ def message( def package_path( input: importlib_metadata.PackagePath | importlib.metadata.PackagePath, ) -> importlib_metadata.PackagePath: + """ + Ensure a package path is adapted to an importlib_metadata.PackagePath. + + >>> 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) From 1a14f9962cf3f7d3cc9582d793fa4122f8495f7b Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sun, 28 Dec 2025 10:31:55 -0500 Subject: [PATCH 14/29] Implement interfaces for Distribution and PackagePath suitable for downstream consumers. --- importlib_metadata/__init__.py | 35 +++++++++++-------- importlib_metadata/_meta.py | 61 +++++++++++++++++++++++++++++++++- newsfragments/486.feature.rst | 1 + 3 files changed, 82 insertions(+), 15 deletions(-) create mode 100644 newsfragments/486.feature.rst diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index eefb5669..f18b451b 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -29,7 +29,6 @@ from itertools import starmap from typing import Any -from . import _meta from ._collections import FreezableDefaultDict, Pair from ._compat import ( NullFinder, @@ -38,16 +37,24 @@ ) 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 ._typing import md_none from .compat import py39, py311 __all__ = [ 'Distribution', 'DistributionFinder', + 'IDistribution', 'PackageMetadata', 'PackageNotFoundError', 'SimplePath', + 'PackagePath', + 'IPackagePath', 'distribution', 'distributions', 'entry_points', @@ -208,7 +215,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) @@ -374,7 +381,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) @@ -516,7 +523,7 @@ def _discover_resolvers(): @property @apply(pass_none(localize.message)) - def metadata(self) -> _meta.PackageMetadata | None: + def metadata(self) -> PackageMetadata | None: """Return the parsed metadata for this Distribution. The returned object will have keys that name the various bits of @@ -539,7 +546,7 @@ def metadata(self) -> _meta.PackageMetadata | None: @staticmethod @pass_none - def _assemble_message(text: str) -> _meta.PackageMetadata: + def _assemble_message(text: str) -> PackageMetadata: # deferred for performance (python/cpython#109829) from . import _adapters @@ -572,10 +579,10 @@ def entry_points(self) -> EntryPoints: @property @apply(pass_none(compose(list, functools.partial(map, localize.package_path)))) - def files(self) -> list[PackagePath] | None: + 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 @@ -1055,7 +1062,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. @@ -1064,7 +1071,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. @@ -1072,7 +1079,7 @@ def distributions(**kwargs) -> Iterable[Distribution]: return Distribution.discover(**kwargs) -def metadata(distribution_name: str) -> _meta.PackageMetadata | None: +def metadata(distribution_name: str) -> PackageMetadata | None: """Get the metadata for the named package. :param distribution_name: The name of the distribution package to query. @@ -1115,7 +1122,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. @@ -1155,7 +1162,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. """ @@ -1163,7 +1170,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/_meta.py b/importlib_metadata/_meta.py index 0c20eff3..ab477253 100644 --- a/importlib_metadata/_meta.py +++ b/importlib_metadata/_meta.py @@ -1,7 +1,7 @@ from __future__ import annotations import os -from collections.abc import Iterator +from collections.abc import Iterable, Iterator from typing import ( Any, Protocol, @@ -69,3 +69,62 @@ 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 + + +class IPackagePath(Protocol): + 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 + + +class IDistribution(Protocol): + 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 | None: ... # 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/newsfragments/486.feature.rst b/newsfragments/486.feature.rst new file mode 100644 index 00000000..03b03bf3 --- /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``. From 95967ae40da4c11798369fc7613a1bb2614b7c32 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sun, 28 Dec 2025 10:35:39 -0500 Subject: [PATCH 15/29] =?UTF-8?q?=F0=9F=9A=A1=20Toil=20the=20docs.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conf.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 32528f86..a3f7bda2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -87,4 +87,6 @@ ('py:class', 'importlib_metadata._meta._T'), # Workaround for #435 ('py:class', '_T'), + # encountered in #505 + ('py:class', 'importlib_metadata.FileHash'), ] From c571e948b84123ec1192772892ea99af86680243 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sun, 28 Dec 2025 10:51:43 -0500 Subject: [PATCH 16/29] Add tests asserting that protocols match stdlib also. --- importlib_metadata/_meta.py | 4 ++++ tests/test_protocols.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 tests/test_protocols.py diff --git a/importlib_metadata/_meta.py b/importlib_metadata/_meta.py index ab477253..102bb1fa 100644 --- a/importlib_metadata/_meta.py +++ b/importlib_metadata/_meta.py @@ -7,11 +7,13 @@ Protocol, TypeVar, overload, + runtime_checkable, ) _T = TypeVar("_T") +@runtime_checkable class PackageMetadata(Protocol): def __len__(self) -> int: ... # pragma: no cover @@ -71,6 +73,7 @@ def read_bytes(self) -> bytes: ... # pragma: no cover def exists(self) -> bool: ... # pragma: no cover +@runtime_checkable class IPackagePath(Protocol): hash: Any | None size: int | None @@ -88,6 +91,7 @@ def parts(self) -> tuple[str, ...]: ... # pragma: no cover def __fspath__(self) -> str: ... # pragma: no cover +@runtime_checkable class IDistribution(Protocol): def read_text( self, filename: str | os.PathLike[str] diff --git a/tests/test_protocols.py b/tests/test_protocols.py new file mode 100644 index 00000000..61b1f9c7 --- /dev/null +++ b/tests/test_protocols.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import importlib.metadata as stdlib +import pathlib +import tempfile +import unittest + +from importlib_metadata import IDistribution, IPackagePath, PackageMetadata + + +class ProtocolTests(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_matches_protocol(self): + assert isinstance(self.dist, IDistribution) + + def test_stdlib_metadata_matches_protocol(self): + meta = self.dist.metadata + assert meta is not None + assert isinstance(meta, 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) From 89d5dc88037eb8746b6f651309d98fad703cccf7 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Thu, 22 Oct 2026 12:58:57 -0700 Subject: [PATCH 17/29] Adapt IDistribution to the metadata property that no longer returns None. Co-Authored-By: Claude Opus 5 --- importlib_metadata/_meta.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/importlib_metadata/_meta.py b/importlib_metadata/_meta.py index 3c3f3fe6..5554d325 100644 --- a/importlib_metadata/_meta.py +++ b/importlib_metadata/_meta.py @@ -102,7 +102,7 @@ def locate_file( ) -> SimplePath: ... # pragma: no cover @property - def metadata(self) -> PackageMetadata | None: ... # pragma: no cover + def metadata(self) -> PackageMetadata: ... # pragma: no cover @property def name(self) -> str: ... # pragma: no cover From da7125f43e80ecf60935201f879efc66291048d1 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Thu, 22 Oct 2026 12:58:58 -0700 Subject: [PATCH 18/29] Defer the expensive imports in localize. Importing importlib.metadata and _adapters eagerly regressed import time by 17 msec (366%), largely defeating python/cpython#109829. Defer them to the paths that need them, leaving a 4% cost for the module itself. Co-Authored-By: Claude Opus 5 --- importlib_metadata/_compat/localize.py | 38 ++++++++++++++++++-------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/importlib_metadata/_compat/localize.py b/importlib_metadata/_compat/localize.py index e9e7fb8e..28d788c1 100644 --- a/importlib_metadata/_compat/localize.py +++ b/importlib_metadata/_compat/localize.py @@ -1,11 +1,16 @@ from __future__ import annotations -import email.message -import importlib.metadata import warnings -from typing import cast +from typing import TYPE_CHECKING, cast -import importlib_metadata._adapters +import importlib_metadata + +if TYPE_CHECKING: + import email.message + import importlib.metadata + + from .. import _adapters + from .._meta import SimplePath def dist( @@ -14,6 +19,7 @@ def dist( """ Ensure dist is an :class:`importlib_metadata.Distribution`. + >>> import importlib.metadata >>> stdlib = importlib.metadata.PathDistribution('foo') >>> type(stdlib) @@ -37,31 +43,38 @@ def dist( """ 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(importlib_metadata._meta.SimplePath, dist._path) - ) + return importlib_metadata.PathDistribution(cast('SimplePath', dist._path)) # workaround for when pytest has replaced importlib_metadata # https://github.com/python/importlib_metadata/pull/505#issuecomment-2344329001 if dist.__class__.__module__ != 'importlib_metadata': warnings.warn(f"Unrecognized distribution subclass {dist.__class__}") - return cast(importlib_metadata.Distribution, dist) + return cast('importlib_metadata.Distribution', dist) def message( - input: importlib_metadata._adapters.Message | email.message.Message, -) -> importlib_metadata._adapters.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) """ - if isinstance(input, importlib_metadata._adapters.Message): + # deferred for performance (python/cpython#109829) + from .. import _adapters + + if isinstance(input, _adapters.Message): return input - return importlib_metadata._adapters.Message(input) + return _adapters.Message(input) def package_path( @@ -70,6 +83,7 @@ def package_path( """ Ensure a package path is adapted to an importlib_metadata.PackagePath. + >>> import importlib.metadata >>> stdlib = importlib.metadata.PackagePath('foo') >>> type(stdlib) From bfcf9bdccf1d170e2ef20e28804a8a5f847c38fb Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Thu, 22 Oct 2026 12:58:58 -0700 Subject: [PATCH 19/29] Describe the protocols for the consumers expected to rely on them. Co-Authored-By: Claude Opus 5 --- importlib_metadata/_meta.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/importlib_metadata/_meta.py b/importlib_metadata/_meta.py index 5554d325..d94b433f 100644 --- a/importlib_metadata/_meta.py +++ b/importlib_metadata/_meta.py @@ -75,6 +75,14 @@ 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 @@ -93,6 +101,15 @@ 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 From 13aa7e879aab6c940158abba17b3f566aeb96dc0 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Thu, 22 Oct 2026 12:59:05 -0700 Subject: [PATCH 20/29] Separate the stdlib conformance checks from the local ones. Asserting that importlib.metadata satisfies the protocols is a concern of the backport alone, so it moves to the compat package, where it can also account for Distribution.origin arriving in Python 3.13. What remains asserts that the objects this package supplies conform. Co-Authored-By: Claude Opus 5 --- tests/compat/test_protocols.py | 41 ++++++++++++++++++++++++++++ tests/test_protocols.py | 49 ++++++++++++++-------------------- 2 files changed, 61 insertions(+), 29 deletions(-) create mode 100644 tests/compat/test_protocols.py diff --git a/tests/compat/test_protocols.py b/tests/compat/test_protocols.py new file mode 100644 index 00000000..1ec35eba --- /dev/null +++ b/tests/compat/test_protocols.py @@ -0,0 +1,41 @@ +""" +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 + + +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) + + @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 index 61b1f9c7..1b33632a 100644 --- a/tests/test_protocols.py +++ b/tests/test_protocols.py @@ -1,36 +1,27 @@ -from __future__ import annotations - -import importlib.metadata as stdlib -import pathlib -import tempfile import unittest -from importlib_metadata import IDistribution, IPackagePath, PackageMetadata +from importlib_metadata import ( + IDistribution, + IPackagePath, + PackageMetadata, + distribution, + files, + metadata, +) + +from . import fixtures -class ProtocolTests(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) +class ProtocolTests(fixtures.DistInfoPkg, unittest.TestCase): + """ + The objects supplied to consumers satisfy the published protocols. + """ - def test_stdlib_distribution_matches_protocol(self): - assert isinstance(self.dist, IDistribution) + def test_distribution_matches_protocol(self): + assert isinstance(distribution('distinfo-pkg'), IDistribution) - def test_stdlib_metadata_matches_protocol(self): - meta = self.dist.metadata - assert meta is not None - assert isinstance(meta, PackageMetadata) + def test_metadata_matches_protocol(self): + assert isinstance(metadata('distinfo-pkg'), 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) + def test_package_path_matches_protocol(self): + assert all(isinstance(file, IPackagePath) for file in files('distinfo-pkg')) From de9f36d03095b70b6d50c9f7d0466077aa52b0ad Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Thu, 22 Oct 2026 12:59:05 -0700 Subject: [PATCH 21/29] Add tests for a provider supplying importlib.metadata objects. Covers the scenario reported in #486, restoring the coverage lost when the Python 3.9 compatibility tests were removed. Co-Authored-By: Claude Opus 5 --- tests/compat/test_localize.py | 65 +++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 tests/compat/test_localize.py diff --git a/tests/compat/test_localize.py b/tests/compat/test_localize.py new file mode 100644 index 00000000..4c9b4ed5 --- /dev/null +++ b/tests/compat/test_localize.py @@ -0,0 +1,65 @@ +""" +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 unittest + +import importlib_metadata +import importlib_metadata._adapters +from importlib_metadata import distribution, distributions, entry_points, files + +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' From b5cda44bbf3ea883320bf3a5c6357e69a0f50030 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Thu, 22 Oct 2026 12:59:06 -0700 Subject: [PATCH 22/29] Drop the nitpick workaround, no longer needed, and add a missing newline. Co-Authored-By: Claude Opus 5 --- docs/conf.py | 2 -- newsfragments/486.removal.rst | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index aae2a1cf..aed40cd2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -88,6 +88,4 @@ ('py:class', 'importlib_metadata._meta._T'), # Workaround for #435 ('py:class', '_T'), - # encountered in #505 - ('py:class', 'importlib_metadata.FileHash'), ] diff --git a/newsfragments/486.removal.rst b/newsfragments/486.removal.rst index 1adc1dda..95b21ea9 100644 --- a/newsfragments/486.removal.rst +++ b/newsfragments/486.removal.rst @@ -1 +1 @@ -When providers supply objects from ``importlib.metadata``, they are now adapted to the classes from ``importlib_metadata``. \ No newline at end of file +When providers supply objects from ``importlib.metadata``, they are now adapted to the classes from ``importlib_metadata``. From af0007aeabd886d2a483cda1ccab2bdba0c7a877 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Thu, 22 Oct 2026 14:30:00 -0700 Subject: [PATCH 23/29] Leave 'origin' out of IDistribution. The stdlib only grew Distribution.origin in Python 3.13, so declaring it makes the protocol describe something importlib.metadata does not supply on 3.11 or 3.12, which in turn forced the conformance test to skip there. Co-Authored-By: Claude Opus 5 --- importlib_metadata/_meta.py | 3 --- tests/compat/test_protocols.py | 4 ---- 2 files changed, 7 deletions(-) diff --git a/importlib_metadata/_meta.py b/importlib_metadata/_meta.py index d94b433f..2ec3dc40 100644 --- a/importlib_metadata/_meta.py +++ b/importlib_metadata/_meta.py @@ -136,9 +136,6 @@ 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 diff --git a/tests/compat/test_protocols.py b/tests/compat/test_protocols.py index 1ec35eba..35450e5e 100644 --- a/tests/compat/test_protocols.py +++ b/tests/compat/test_protocols.py @@ -5,7 +5,6 @@ import importlib.metadata as stdlib import pathlib -import sys import tempfile import unittest @@ -24,9 +23,6 @@ def setUp(self): ) self.dist = stdlib.PathDistribution(dist_info) - @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) From b28cfd32c86ec91b2cffd9526da8be60ba340064 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Thu, 22 Oct 2026 14:30:01 -0700 Subject: [PATCH 24/29] Add a protocol conformance check that consults the type. Through Python 3.11, isinstance() against a runtime-checkable protocol calls hasattr() on the instance, so asking whether a distribution conforms would read its METADATA, and a property that raises would escape the question. Python 3.12 resolved that in python/cpython#102433, so defer to isinstance there. Co-Authored-By: Claude Opus 5 --- importlib_metadata/compat/py311.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/importlib_metadata/compat/py311.py b/importlib_metadata/compat/py311.py index 3a532743..098f0816 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,27 @@ 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__') +) + + +def static_isinstance(instance, protocol): # pragma: no cover + """ + Determine protocol conformance from the type alone. + + Workaround for https://github.com/python/cpython/issues/102433. + Through Python 3.11, isinstance() against a runtime-checkable + protocol calls hasattr() on the instance, so deciding the question + evaluates every property member and anything one raises propagates. + Python 3.12 consults the type instead. + """ + return all(hasattr(type(instance), attr) for attr in protocol_attrs(protocol)) + + +conforms = static_isinstance if sys.version_info < (3, 12) else isinstance From 619d5349da94e1d52184b191778d457e75c909d6 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Thu, 22 Oct 2026 14:30:01 -0700 Subject: [PATCH 25/29] Warn on conformance rather than on class identity. A provider that implements the whole interface was warned about anyway for deriving from importlib.metadata, and the person who saw the warning was not the person who could act on it; clearing it meant depending on this package, the coupling #486 set out to avoid. Ask instead what the interface requires and the class omits, and name it. This subsumes the __module__ check that worked around pytest replacing importlib_metadata, since a replaced class still conforms. Co-Authored-By: Claude Opus 5 --- importlib_metadata/_compat/localize.py | 44 +++++++++++---- tests/compat/test_localize.py | 76 ++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 10 deletions(-) diff --git a/importlib_metadata/_compat/localize.py b/importlib_metadata/_compat/localize.py index 28d788c1..0eb74176 100644 --- a/importlib_metadata/_compat/localize.py +++ b/importlib_metadata/_compat/localize.py @@ -5,6 +5,8 @@ import importlib_metadata +from ..compat import py311 + if TYPE_CHECKING: import email.message import importlib.metadata @@ -27,18 +29,27 @@ def dist( >>> 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 - >>> subclass = CustomDist() - >>> type(subclass) - + >>> 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="Unrecognized distribution subclass "): - ... local = dist(subclass) - >>> type(local) is type(subclass) + >>> with pytest.warns(UserWarning, match="is missing at, discover"): + ... local = dist(Incomplete()) + >>> local is not None True """ if isinstance(dist, importlib_metadata.Distribution): @@ -50,13 +61,26 @@ def dist( if isinstance(dist, importlib.metadata.PathDistribution): return importlib_metadata.PathDistribution(cast('SimplePath', dist._path)) - # workaround for when pytest has replaced importlib_metadata - # https://github.com/python/importlib_metadata/pull/505#issuecomment-2344329001 - if dist.__class__.__module__ != 'importlib_metadata': - warnings.warn(f"Unrecognized distribution subclass {dist.__class__}") + # 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 py311.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. + """ + attrs = py311.protocol_attrs(importlib_metadata.IDistribution) + return sorted(attr for attr in attrs if not hasattr(type(dist), attr)) + + def message( input: _adapters.Message | email.message.Message, ) -> _adapters.Message: diff --git a/tests/compat/test_localize.py b/tests/compat/test_localize.py index 4c9b4ed5..9e514961 100644 --- a/tests/compat/test_localize.py +++ b/tests/compat/test_localize.py @@ -6,10 +6,12 @@ import importlib.metadata as stdlib import pathlib import unittest +import warnings import importlib_metadata import importlib_metadata._adapters from importlib_metadata import distribution, distributions, entry_points, files +from importlib_metadata._compat import localize from .. import fixtures @@ -63,3 +65,77 @@ def test_entry_points_selectable(self): 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): + 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] From 3130095059a7de65796655eedcc35c570d7ec959 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Thu, 22 Oct 2026 14:30:02 -0700 Subject: [PATCH 26/29] Note the provider-facing half of the change. Co-Authored-By: Claude Opus 5 --- newsfragments/486.feature.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/newsfragments/486.feature.rst b/newsfragments/486.feature.rst index 03b03bf3..f6248dbd 100644 --- a/newsfragments/486.feature.rst +++ b/newsfragments/486.feature.rst @@ -1 +1 @@ -Added ``IDistribution`` and ``IPackagePath`` protocols so consumers can target a stable, shared interface across ``importlib.metadata`` and ``importlib_metadata``. +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``. From c8c15a9b844d1d05ddfcbba20d4cb9fbb2476a66 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Thu, 22 Oct 2026 14:44:17 -0700 Subject: [PATCH 27/29] Exempt from the conformance warning what the stdlib cannot supply. A provider cannot implement a member its importlib.metadata has no version of, so faulting it for the absence would reintroduce the warning nobody can clear. Collect those members in compat/py312, to be deleted along with support for the Python that needs them. The decision splits at 3.13 rather than 3.12 as a result, so the type-only check moves here; plain isinstance still answers it wherever nothing is exempt. Co-Authored-By: Claude Opus 5 --- importlib_metadata/_compat/localize.py | 8 +++---- importlib_metadata/compat/py311.py | 16 -------------- importlib_metadata/compat/py312.py | 29 ++++++++++++++++++++++++++ tests/compat/test_localize.py | 22 ++++++++++++++++++- 4 files changed, 54 insertions(+), 21 deletions(-) create mode 100644 importlib_metadata/compat/py312.py diff --git a/importlib_metadata/_compat/localize.py b/importlib_metadata/_compat/localize.py index 0eb74176..b08b4fbd 100644 --- a/importlib_metadata/_compat/localize.py +++ b/importlib_metadata/_compat/localize.py @@ -5,7 +5,7 @@ import importlib_metadata -from ..compat import py311 +from ..compat import py312 if TYPE_CHECKING: import email.message @@ -65,7 +65,7 @@ def dist( # 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 py311.conforms(dist, importlib_metadata.IDistribution): + if not py312.conforms(dist, importlib_metadata.IDistribution): warnings.warn( f"Distribution subclass {dist.__class__} is missing " f"{', '.join(unimplemented(dist))}" @@ -77,8 +77,8 @@ def unimplemented(dist) -> list[str]: """ The members IDistribution requires that dist's class doesn't supply. """ - attrs = py311.protocol_attrs(importlib_metadata.IDistribution) - return sorted(attr for attr in attrs if not hasattr(type(dist), attr)) + required = py312.required(importlib_metadata.IDistribution) + return sorted(attr for attr in required if not hasattr(type(dist), attr)) def message( diff --git a/importlib_metadata/compat/py311.py b/importlib_metadata/compat/py311.py index 098f0816..e082e197 100644 --- a/importlib_metadata/compat/py311.py +++ b/importlib_metadata/compat/py311.py @@ -30,19 +30,3 @@ def relative_to(root, *, walk_up=False): if sys.version_info < (3, 12) else operator.attrgetter('__protocol_attrs__') ) - - -def static_isinstance(instance, protocol): # pragma: no cover - """ - Determine protocol conformance from the type alone. - - Workaround for https://github.com/python/cpython/issues/102433. - Through Python 3.11, isinstance() against a runtime-checkable - protocol calls hasattr() on the instance, so deciding the question - evaluates every property member and anything one raises propagates. - Python 3.12 consults the type instead. - """ - return all(hasattr(type(instance), attr) for attr in protocol_attrs(protocol)) - - -conforms = static_isinstance if sys.version_info < (3, 12) else isinstance 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/tests/compat/test_localize.py b/tests/compat/test_localize.py index 9e514961..36ed50ac 100644 --- a/tests/compat/test_localize.py +++ b/tests/compat/test_localize.py @@ -5,13 +5,21 @@ import importlib.metadata as stdlib import pathlib +import sys import unittest import warnings import importlib_metadata import importlib_metadata._adapters -from importlib_metadata import distribution, distributions, entry_points, files +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 @@ -113,6 +121,18 @@ def test_conforming_provider_is_not_warned_about(self): 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. From 82f4d7cc759273b5a04fb39c213d850e2b517b15 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Thu, 22 Oct 2026 14:44:17 -0700 Subject: [PATCH 28/29] Restore 'origin' to IDistribution. Consumers should be able to rely on it, and the exemption now keeps a provider on Python 3.11 or 3.12, whose stdlib has no origin to inherit, from being warned about its absence. Co-Authored-By: Claude Opus 5 --- importlib_metadata/_meta.py | 3 +++ tests/compat/test_protocols.py | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/importlib_metadata/_meta.py b/importlib_metadata/_meta.py index 2ec3dc40..d94b433f 100644 --- a/importlib_metadata/_meta.py +++ b/importlib_metadata/_meta.py @@ -136,6 +136,9 @@ 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 diff --git a/tests/compat/test_protocols.py b/tests/compat/test_protocols.py index 35450e5e..f97c2fe4 100644 --- a/tests/compat/test_protocols.py +++ b/tests/compat/test_protocols.py @@ -5,10 +5,12 @@ 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): @@ -23,6 +25,12 @@ def setUp(self): ) 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) From 9e590381b68a58f47850260e62194281333ba349 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Thu, 22 Oct 2026 14:44:18 -0700 Subject: [PATCH 29/29] Mention the exemption in the release notes. Co-Authored-By: Claude Opus 5 --- newsfragments/486.feature.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/newsfragments/486.feature.rst b/newsfragments/486.feature.rst index f6248dbd..ff7c407e 100644 --- a/newsfragments/486.feature.rst +++ b/newsfragments/486.feature.rst @@ -1 +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``. +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.