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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] =?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/16] 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)