← 输入框|github.com|刷新
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 80 additions & 4 deletions Doc/library/importlib.metadata.rst
Original file line number Diff line number Diff line change
Expand Up @@ -237,11 +237,12 @@ 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; 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 <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 +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 <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,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
Expand Down Expand Up @@ -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.
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``, ``IPackagePath``, and ``PackageMetadata``
protocols shared with the ``importlib_metadata`` backport, and how custom
providers and consumers of ``importlib.metadata`` should use them.
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