diff --git a/Doc/library/importlib.metadata.rst b/Doc/library/importlib.metadata.rst index 1378a131f0ce65..3ab8505e1e9ebc 100644 --- a/Doc/library/importlib.metadata.rst +++ b/Doc/library/importlib.metadata.rst @@ -237,11 +237,12 @@ Distribution metadata .. class:: PackageMetadata - A concrete implementation of the - `PackageMetadata protocol `_. + A protocol describing the interface distribution metadata presents. The + :pypi:`importlib_metadata` backport presents the same protocol; see + :ref:`metadata-backport-caveats`. - In addition to providing the defined protocol methods and attributes, subscripting - the instance is equivalent to calling the :meth:`!get` method. + In addition to the protocol methods and attributes, subscripting an + instance is equivalent to calling the :meth:`!get` method. Every `Distribution Package `_ includes some metadata, which you can extract using the :func:`!metadata` function:: @@ -332,6 +333,13 @@ Distribution files check if it ``exists()``, to traverse using ``joinpath()`` and ``parent``, and to retrieve data using ``read_text()`` and ``read_bytes()``. +.. class:: IPackagePath + + A protocol describing the interface a :class:`PackagePath` presents. See + :ref:`metadata-backport-caveats`. + + .. versionadded:: 3.16 + The :func:`!files` function takes a `Distribution Package `_ name and returns all of the files installed by this distribution. For example:: @@ -544,6 +552,17 @@ The same applies for :func:`entry_points` and :func:`files`. A shortcut for ``distribution.locate_file(filename).read_text()``. +.. class:: IDistribution + + A protocol describing the interface a :class:`Distribution` presents. + + A consumer that targets this protocol, rather than a concrete class, works + with distributions from either this module or the + :pypi:`importlib_metadata` backport, however they were supplied. See + :ref:`metadata-backport-caveats`. + + .. versionadded:: 3.16 + .. _distribution-discovery: Distribution Discovery @@ -735,3 +754,60 @@ packages served by the ``DatabaseImporter``, assuming that the The ``DatabaseDistribution`` may also provide other metadata files, like ``RECORD`` (required for :attr:`!Distribution.files`) or override the implementation of :attr:`!Distribution.files`. See the source for more inspiration. + +``DatabaseDistribution`` derives from :class:`Distribution` as defined here, +which has consequences for consumers that use the :pypi:`importlib_metadata` +backport instead of this module. See :ref:`metadata-backport-providers`. + + +.. _metadata-backport-caveats: + +Caveats for backports +===================== + +The :pypi:`importlib_metadata` package backports this module and supplies its +own :class:`!Distribution`, :class:`!PackagePath`, and metadata classes. Those +classes are separate from the ones here; neither set derives from the other. +Both libraries discover distributions by querying the finders on +:data:`sys.meta_path`, so one custom provider serves consumers of both and has +no say in which of the two a given consumer uses. + +Both libraries present the same protocols, :class:`IDistribution`, +:class:`IPackagePath`, and :class:`PackageMetadata`, and the classes of both +satisfy them. Those protocols, rather than any concrete class, are the +interface to write against. + +For consumers +------------- + +A consumer calling ``importlib_metadata.distributions()`` expects that +package's objects, but a custom provider may supply objects from the hierarchy +documented here. The backport settles most of this on the consumer's behalf, +substituting its own class for each object it recognizes, such as a +:class:`!PathDistribution`, a :class:`PackagePath`, or an +:class:`email.message.Message`. It has no way to reconstruct an arbitrary +:class:`Distribution` subclass from a custom provider, so it passes such an +object through unchanged. + +Annotate against the protocols, then, and do not test an object with +:func:`isinstance` against a concrete class. Code that needs the protocols on +earlier Python versions can import them from the backport, which presents them +there too. + +.. _metadata-backport-providers: + +For custom providers +-------------------- + +A provider's objects reach consumers of both libraries, so supply objects that +satisfy :class:`IDistribution` whole, not in part. Deriving from +:class:`Distribution`, as the example above does, achieves this: the base +class builds the rest of the interface on :meth:`~Distribution.read_text` and +:meth:`~Distribution.locate_file`. Where a class omits a member the protocol +requires, the backport emits a :exc:`UserWarning` naming what is missing; a +provider satisfying the protocol whole draws no warning. + +Deriving from whichever of the two libraries happens to be importable is not a +general answer. A provider cannot know whether a consumer will import the +backport, and importing it to find out activates it in environments that would +otherwise never have used it. diff --git a/Lib/importlib/metadata/__init__.py b/Lib/importlib/metadata/__init__.py index 3d6f9cdf6965bb..5680616b68acd2 100644 --- a/Lib/importlib/metadata/__init__.py +++ b/Lib/importlib/metadata/__init__.py @@ -29,16 +29,22 @@ from itertools import starmap from typing import Any -from . import _meta from ._collections import FreezableDefaultDict, Pair from ._context import ExceptionTrap from ._functools import 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, +) __all__ = [ 'Distribution', 'DistributionFinder', + 'IDistribution', + 'IPackagePath', 'MetadataNotFound', 'PackageMetadata', 'PackageNotFoundError', @@ -210,7 +216,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) @@ -375,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) @@ -538,7 +544,7 @@ def _discover_resolvers(): return filter(None, declared) @property - def metadata(self) -> _meta.PackageMetadata: + def metadata(self) -> PackageMetadata: """Return the parsed metadata for this Distribution. The returned object will have keys that name the various bits of @@ -562,7 +568,7 @@ def metadata(self) -> _meta.PackageMetadata: return self._assemble_message(self._ensure_metadata_present(text)) @staticmethod - def _assemble_message(text: str) -> _meta.PackageMetadata: + def _assemble_message(text: str) -> PackageMetadata: # deferred for performance (python/cpython#109829) from . import _adapters @@ -602,10 +608,10 @@ def entry_points(self) -> EntryPoints: return EntryPoints._from_text_for(self.read_text('entry_points.txt'), self) @property - 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 @@ -1096,7 +1102,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. @@ -1105,7 +1111,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. @@ -1113,7 +1119,7 @@ def distributions(**kwargs) -> Iterable[Distribution]: return Distribution.discover(**kwargs) -def metadata(distribution_name: str) -> _meta.PackageMetadata: +def metadata(distribution_name: str) -> PackageMetadata: """Get the metadata for the named package. :param distribution_name: The name of the distribution package to query. @@ -1157,7 +1163,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. @@ -1197,7 +1203,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. """ @@ -1205,7 +1211,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/Lib/importlib/metadata/_meta.py b/Lib/importlib/metadata/_meta.py index 219b2a86d1755c..d94b433f6ee9ec 100644 --- a/Lib/importlib/metadata/_meta.py +++ b/Lib/importlib/metadata/_meta.py @@ -1,17 +1,19 @@ from __future__ import annotations import os -from collections.abc import Iterator +from collections.abc import Iterable, Iterator from typing import ( Any, Protocol, TypeVar, overload, + runtime_checkable, ) _T = TypeVar("_T") +@runtime_checkable class PackageMetadata(Protocol): def __len__(self) -> int: ... # pragma: no cover @@ -69,3 +71,81 @@ def read_text(self, encoding=None) -> str: ... # pragma: no cover def read_bytes(self) -> bytes: ... # pragma: no cover def exists(self) -> bool: ... # pragma: no cover + + +@runtime_checkable +class IPackagePath(Protocol): + """ + The interface of the paths yielded by ``IDistribution.files``. + + Consumers should prefer this protocol over ``PackagePath``, + as it is also satisfied by the objects that ``importlib.metadata`` + supplies. + """ + + hash: Any | None + size: int | None + dist: IDistribution + + def read_text(self, encoding: str = 'utf-8') -> str: ... # pragma: no cover + + def read_binary(self) -> bytes: ... # pragma: no cover + + def locate(self) -> SimplePath: ... # pragma: no cover + + @property + def parts(self) -> tuple[str, ...]: ... # pragma: no cover + + def __fspath__(self) -> str: ... # pragma: no cover + + +@runtime_checkable +class IDistribution(Protocol): + """ + The interface of the distributions yielded by ``distributions()`` + and friends. + + Consumers should prefer this protocol over ``Distribution``, + as it is also satisfied by the objects that ``importlib.metadata`` + supplies. + """ + + def read_text( + self, filename: str | os.PathLike[str] + ) -> str | None: ... # pragma: no cover + + def locate_file( + self, path: str | os.PathLike[str] + ) -> SimplePath: ... # pragma: no cover + + @property + def metadata(self) -> PackageMetadata: ... # pragma: no cover + + @property + def name(self) -> str: ... # pragma: no cover + + @property + def version(self) -> str: ... # pragma: no cover + + @property + def entry_points(self) -> Any: ... # pragma: no cover + + @property + def files(self) -> list[IPackagePath] | None: ... # pragma: no cover + + @property + def requires(self) -> list[str] | None: ... # pragma: no cover + + @property + def origin(self) -> Any: ... # pragma: no cover + + @classmethod + def discover( + cls, *, context: Any | None = None, **kwargs: Any + ) -> Iterable[IDistribution]: ... # pragma: no cover + + @classmethod + def from_name(cls, name: str) -> IDistribution: ... # pragma: no cover + + @staticmethod + def at(path: str | os.PathLike[str]) -> IDistribution: ... # pragma: no cover diff --git a/Lib/test/test_importlib/metadata/test_protocols.py b/Lib/test/test_importlib/metadata/test_protocols.py new file mode 100644 index 00000000000000..fb9b10131e6663 --- /dev/null +++ b/Lib/test/test_importlib/metadata/test_protocols.py @@ -0,0 +1,27 @@ +import unittest + +from importlib.metadata import ( + IDistribution, + IPackagePath, + PackageMetadata, + distribution, + files, + metadata, +) + +from . import fixtures + + +class ProtocolTests(fixtures.DistInfoPkg, unittest.TestCase): + """ + The objects supplied to consumers satisfy the published protocols. + """ + + def test_distribution_matches_protocol(self): + assert isinstance(distribution('distinfo-pkg'), IDistribution) + + def test_metadata_matches_protocol(self): + assert isinstance(metadata('distinfo-pkg'), PackageMetadata) + + def test_package_path_matches_protocol(self): + assert all(isinstance(file, IPackagePath) for file in files('distinfo-pkg')) diff --git a/Misc/NEWS.d/next/Documentation/2026-10-22-18-51-05.gh-issue-123976.GKLjFT.rst b/Misc/NEWS.d/next/Documentation/2026-10-22-18-51-05.gh-issue-123976.GKLjFT.rst new file mode 100644 index 00000000000000..614d2c6595c324 --- /dev/null +++ b/Misc/NEWS.d/next/Documentation/2026-10-22-18-51-05.gh-issue-123976.GKLjFT.rst @@ -0,0 +1,3 @@ +Document the ``IDistribution``, ``IPackagePath``, and ``PackageMetadata`` +protocols shared with the ``importlib_metadata`` backport, and how custom +providers and consumers of ``importlib.metadata`` should use them. diff --git a/Misc/NEWS.d/next/Library/2026-10-31-15-50-12.gh-issue-160386.qRrnCh.rst b/Misc/NEWS.d/next/Library/2026-10-31-15-50-12.gh-issue-160386.qRrnCh.rst new file mode 100644 index 00000000000000..f17149706d85c9 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-31-15-50-12.gh-issue-160386.qRrnCh.rst @@ -0,0 +1,6 @@ +Applied changes to ``importlib.metadata`` from `importlib_metadata 10.0 +`_: +added the ``IDistribution`` and ``IPackagePath`` protocols, so a consumer can +target one interface across ``importlib.metadata`` and the backport rather +than either library's concrete classes, and the functions returning +distributions and files are annotated with them.