Skip to content
35 changes: 30 additions & 5 deletions Doc/library/importlib.metadata.rst
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ anywhere.
for this module's classes and functions,
as well as a `migration guide
<https://importlib-metadata.readthedocs.io/en/latest/migration.html>`__
for existing users of ``pkg_resources``.
for existing users of ``pkg_resources``
and `Caveats <https://importlib-metadata.readthedocs.io/en/latest/caveats.html>`__
on supplying and consuming metadata objects across the two libraries.


Overview
Expand Down Expand Up @@ -237,11 +239,11 @@ Distribution metadata

.. class:: PackageMetadata

A concrete implementation of the
`PackageMetadata protocol <https://importlib-metadata.readthedocs.io/en/latest/api.html#importlib_metadata.PackageMetadata>`_.
A protocol describing the interface distribution metadata presents. The
:pypi:`importlib_metadata` backport presents the same protocol.

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 <https://packaging.python.org/en/latest/glossary/#term-Distribution-Package>`_
includes some metadata, which you can extract using the :func:`!metadata` function::
Expand Down Expand Up @@ -332,6 +334,12 @@ 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.

.. versionadded:: 3.16

The :func:`!files` function takes a
`Distribution Package <https://packaging.python.org/en/latest/glossary/#term-Distribution-Package>`_
name and returns all of the files installed by this distribution. For example::
Expand Down Expand Up @@ -544,6 +552,18 @@ 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
`Caveats <https://importlib-metadata.readthedocs.io/en/latest/caveats.html>`__
in the backport's documentation.

.. versionadded:: 3.16

.. _distribution-discovery:

Distribution Discovery
Expand Down Expand Up @@ -735,3 +755,8 @@ 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 `Caveats
<https://importlib-metadata.readthedocs.io/en/latest/caveats.html>`__ in the backport's documentation.
34 changes: 20 additions & 14 deletions Lib/importlib/metadata/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -1105,15 +1111,15 @@ 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.
"""
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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1197,15 +1203,15 @@ 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.
"""
top, *rest = name.parts
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.
Expand Down
82 changes: 81 additions & 1 deletion Lib/importlib/metadata/_meta.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
27 changes: 27 additions & 0 deletions Lib/test/test_importlib/metadata/test_protocols.py
Original file line number Diff line number Diff line change
@@ -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'))
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Document the ``IDistribution`` and ``IPackagePath`` protocols, which
``importlib.metadata`` shares with the ``importlib_metadata`` backport, and
correct ``PackageMetadata``, which is itself such a protocol.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Applied changes to ``importlib.metadata`` from `importlib_metadata 10.0
<https://importlib-metadata.readthedocs.io/en/latest/history.html#v10-0-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.
Loading