Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
4261109
Add a couple of functions from jaraco.functools.
jaraco Sep 11, 2024
e36808d
Create 'localize_dist' function to convert stdlib to local versions o…
jaraco Sep 11, 2024
6ff671d
Create 'localize_metadata' function to normalize the outputs for .met…
jaraco Sep 11, 2024
5cd7d14
Create 'localize_package_path' function to convert stdlib to local ve…
jaraco Sep 11, 2024
374bd98
Add news fragment.
jaraco Sep 11, 2024
9000268
In localize_dist, cast the result to paper over an error.
jaraco Sep 11, 2024
1a30e01
Stop trying to differentiate between importlib.metadata._adapters.Mes…
jaraco Sep 11, 2024
5a67414
Suppress warnings when 'importlib_metadata' has been imported twice.
jaraco Sep 11, 2024
5a67a0d
Suppress warning when CustomDistribution, derived from importlib.meta…
jaraco Sep 11, 2024
3dad754
Move _compat to its own package.
jaraco Sep 11, 2024
0ad266a
Extract localize functions into their own module.
jaraco Sep 11, 2024
4a350a5
Rename 'localize.metadata' to 'localize.message'.
jaraco Sep 11, 2024
3d464ad
Merge branch 'main' into bugfix/486-local-objects
jaraco Dec 26, 2025
28c50a7
Add some doctests to cover all the changes.
jaraco Dec 26, 2025
1a14f99
Implement interfaces for Distribution and PackagePath suitable for do…
jaraco Dec 28, 2025
95967ae
🚡 Toil the docs.
jaraco Dec 28, 2025
c571e94
Add tests asserting that protocols match stdlib also.
jaraco Dec 28, 2025
fc381dd
Merge remote-tracking branch 'origin/main' into bugfix/486-local-objects
jaraco Oct 22, 2026
89d5dc8
Adapt IDistribution to the metadata property that no longer returns N…
jaraco Oct 22, 2026
da7125f
Defer the expensive imports in localize.
jaraco Oct 22, 2026
bfcf9bd
Describe the protocols for the consumers expected to rely on them.
jaraco Oct 22, 2026
13aa7e8
Separate the stdlib conformance checks from the local ones.
jaraco Oct 22, 2026
de9f36d
Add tests for a provider supplying importlib.metadata objects.
jaraco Oct 22, 2026
b5cda44
Drop the nitpick workaround, no longer needed, and add a missing newl…
jaraco Oct 22, 2026
af0007a
Leave 'origin' out of IDistribution.
jaraco Oct 22, 2026
b28cfd3
Add a protocol conformance check that consults the type.
jaraco Oct 22, 2026
619d534
Warn on conformance rather than on class identity.
jaraco Oct 22, 2026
3130095
Note the provider-facing half of the change.
jaraco Oct 22, 2026
c8c15a9
Exempt from the conformance warning what the stdlib cannot supply.
jaraco Oct 22, 2026
82f4d7c
Restore 'origin' to IDistribution.
jaraco Oct 22, 2026
9e59038
Mention the exemption in the release notes.
jaraco Oct 22, 2026
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
41 changes: 26 additions & 15 deletions importlib_metadata/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,28 @@
from itertools import starmap
from typing import Any

from . import _meta
from ._collections import FreezableDefaultDict, Pair
from ._compat import (
NullFinder,
install,
localize,
)
from ._context import ExceptionTrap
from ._functools import method_cache, noop, pass_none, passthrough
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 .compat import py311

__all__ = [
'Distribution',
'DistributionFinder',
'IDistribution',
'IPackagePath',
'MetadataNotFound',
'PackageMetadata',
'PackageNotFoundError',
Expand Down Expand Up @@ -215,7 +222,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 @@ -380,7 +387,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 @@ -473,6 +480,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.

Expand All @@ -491,6 +499,7 @@ def from_name(cls, name: str) -> Distribution:
raise PackageNotFoundError(name) from None

@classmethod
@apply(functools.partial(map, localize.dist))
def discover(
cls, *, context: DistributionFinder.Context | None = None, **kwargs
) -> Iterable[Distribution]:
Expand Down Expand Up @@ -543,7 +552,8 @@ def _discover_resolvers():
return filter(None, declared)

@property
def metadata(self) -> _meta.PackageMetadata:
@apply(localize.message)
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 @@ -567,7 +577,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 @@ -607,10 +617,11 @@ def entry_points(self) -> EntryPoints:
return EntryPoints._from_text_for(self.read_text('entry_points.txt'), self)

@property
def files(self) -> list[PackagePath] | None:
@apply(pass_none(compose(list, functools.partial(map, localize.package_path))))
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 @@ -1108,7 +1119,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 @@ -1117,15 +1128,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 @@ -1169,7 +1180,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 @@ -1209,15 +1220,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
File renamed without changes.
122 changes: 122 additions & 0 deletions importlib_metadata/_compat/localize.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
from __future__ import annotations

import warnings
from typing import TYPE_CHECKING, cast

import importlib_metadata

from ..compat import py312

if TYPE_CHECKING:
import email.message
import importlib.metadata

from .. import _adapters
from .._meta import SimplePath


def dist(
dist: importlib_metadata.Distribution | importlib.metadata.Distribution,
) -> importlib_metadata.Distribution:
"""
Ensure dist is an :class:`importlib_metadata.Distribution`.

>>> import importlib.metadata
>>> stdlib = importlib.metadata.PathDistribution('foo')
>>> type(stdlib)
<class 'importlib.metadata.PathDistribution'>
>>> local = dist(stdlib)
>>> type(local)
<class 'importlib_metadata.PathDistribution'>

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
>>> 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="is missing at, discover"):
... local = dist(Incomplete())
>>> local is not None
True
"""
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('SimplePath', dist._path))
# 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 py312.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.
"""
required = py312.required(importlib_metadata.IDistribution)
return sorted(attr for attr in required if not hasattr(type(dist), attr))


def 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)
<class 'importlib_metadata._adapters.Message'>
"""
# deferred for performance (python/cpython#109829)
from .. import _adapters

if isinstance(input, _adapters.Message):
return input
return _adapters.Message(input)


def package_path(
input: importlib_metadata.PackagePath | importlib.metadata.PackagePath,
) -> importlib_metadata.PackagePath:
"""
Ensure a package path is adapted to an importlib_metadata.PackagePath.

>>> import importlib.metadata
>>> stdlib = importlib.metadata.PackagePath('foo')
>>> type(stdlib)
<class 'importlib.metadata.PackagePath'>
>>> local = package_path(stdlib)
>>> type(local)
<class 'importlib_metadata.PackagePath'>
"""
if isinstance(input, importlib_metadata.PackagePath):
return input
replacement = importlib_metadata.PackagePath(input)
vars(replacement).update(vars(input))
return replacement
54 changes: 54 additions & 0 deletions importlib_metadata/_functools.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,60 @@ def wrapper(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 <https://en.wikipedia.org/wiki/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 jaraco.functools 4.4
def noop(*args, **kwargs):
"""
Expand Down
Loading
Loading