gh-120492: Sync importlib_metadata 8.2.0 (#124033)

* Sync with importlib_metadata 8.2.0

Removes deprecated behaviors, including support for `PackageMetadata.__getitem__` returning None for missing keys and Distribution subclasses not implementing abstract methods.
Prioritizes valid dists to invalid dists when retrieving by name (python/cpython/#120492). Adds SimplePath to `importlib.metadata.__all__`.

* Add blurb
This commit is contained in:
Jason R. Coombs 2025-08-15 14:19:23 -07:00 committed by GitHub
parent d86c2257a6
commit ec4021c6d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 154 additions and 64 deletions

View file

@ -12,7 +12,6 @@
import zipfile import zipfile
import operator import operator
import textwrap import textwrap
import warnings
import functools import functools
import itertools import itertools
import posixpath import posixpath
@ -21,7 +20,7 @@
from . import _meta from . import _meta
from ._collections import FreezableDefaultDict, Pair from ._collections import FreezableDefaultDict, Pair
from ._functools import method_cache, pass_none from ._functools import method_cache, pass_none
from ._itertools import always_iterable, unique_everseen from ._itertools import always_iterable, bucket, unique_everseen
from ._meta import PackageMetadata, SimplePath from ._meta import PackageMetadata, SimplePath
from contextlib import suppress from contextlib import suppress
@ -35,6 +34,7 @@
'DistributionFinder', 'DistributionFinder',
'PackageMetadata', 'PackageMetadata',
'PackageNotFoundError', 'PackageNotFoundError',
'SimplePath',
'distribution', 'distribution',
'distributions', 'distributions',
'entry_points', 'entry_points',
@ -329,27 +329,7 @@ def __repr__(self) -> str:
return f'<FileHash mode: {self.mode} value: {self.value}>' return f'<FileHash mode: {self.mode} value: {self.value}>'
class DeprecatedNonAbstract: class Distribution(metaclass=abc.ABCMeta):
# Required until Python 3.14
def __new__(cls, *args, **kwargs):
all_names = {
name for subclass in inspect.getmro(cls) for name in vars(subclass)
}
abstract = {
name
for name in all_names
if getattr(getattr(cls, name), '__isabstractmethod__', False)
}
if abstract:
warnings.warn(
f"Unimplemented abstract methods {abstract}",
DeprecationWarning,
stacklevel=2,
)
return super().__new__(cls)
class Distribution(DeprecatedNonAbstract):
""" """
An abstract Python distribution package. An abstract Python distribution package.
@ -404,7 +384,7 @@ def from_name(cls, name: str) -> Distribution:
if not name: if not name:
raise ValueError("A distribution name is required.") raise ValueError("A distribution name is required.")
try: try:
return next(iter(cls.discover(name=name))) return next(iter(cls._prefer_valid(cls.discover(name=name))))
except StopIteration: except StopIteration:
raise PackageNotFoundError(name) raise PackageNotFoundError(name)
@ -428,6 +408,16 @@ def discover(
resolver(context) for resolver in cls._discover_resolvers() resolver(context) for resolver in cls._discover_resolvers()
) )
@staticmethod
def _prefer_valid(dists: Iterable[Distribution]) -> Iterable[Distribution]:
"""
Prefer (move to the front) distributions that have metadata.
Ref python/importlib_resources#489.
"""
buckets = bucket(dists, lambda dist: bool(dist.metadata))
return itertools.chain(buckets[True], buckets[False])
@staticmethod @staticmethod
def at(path: str | os.PathLike[str]) -> Distribution: def at(path: str | os.PathLike[str]) -> Distribution:
"""Return a Distribution for the indicated metadata path. """Return a Distribution for the indicated metadata path.

View file

@ -1,5 +1,3 @@
import functools
import warnings
import re import re
import textwrap import textwrap
import email.message import email.message
@ -7,15 +5,6 @@
from ._text import FoldedCase from ._text import FoldedCase
# Do not remove prior to 2024-01-01 or Python 3.14
_warn = functools.partial(
warnings.warn,
"Implicit None on return values is deprecated and will raise KeyErrors.",
DeprecationWarning,
stacklevel=2,
)
class Message(email.message.Message): class Message(email.message.Message):
multiple_use_keys = set( multiple_use_keys = set(
map( map(
@ -52,12 +41,17 @@ def __iter__(self):
def __getitem__(self, item): def __getitem__(self, item):
""" """
Warn users that a ``KeyError`` can be expected when a Override parent behavior to typical dict behavior.
missing key is supplied. Ref python/importlib_metadata#371.
``email.message.Message`` will emit None values for missing
keys. Typical mappings, including this ``Message``, will raise
a key error for missing keys.
Ref python/importlib_metadata#371.
""" """
res = super().__getitem__(item) res = super().__getitem__(item)
if res is None: if res is None:
_warn() raise KeyError(item)
return res return res
def _repair_headers(self): def _repair_headers(self):

View file

@ -1,3 +1,4 @@
from collections import defaultdict, deque
from itertools import filterfalse from itertools import filterfalse
@ -71,3 +72,100 @@ def always_iterable(obj, base_type=(str, bytes)):
return iter(obj) return iter(obj)
except TypeError: except TypeError:
return iter((obj,)) return iter((obj,))
# Copied from more_itertools 10.3
class bucket:
"""Wrap *iterable* and return an object that buckets the iterable into
child iterables based on a *key* function.
>>> iterable = ['a1', 'b1', 'c1', 'a2', 'b2', 'c2', 'b3']
>>> s = bucket(iterable, key=lambda x: x[0]) # Bucket by 1st character
>>> sorted(list(s)) # Get the keys
['a', 'b', 'c']
>>> a_iterable = s['a']
>>> next(a_iterable)
'a1'
>>> next(a_iterable)
'a2'
>>> list(s['b'])
['b1', 'b2', 'b3']
The original iterable will be advanced and its items will be cached until
they are used by the child iterables. This may require significant storage.
By default, attempting to select a bucket to which no items belong will
exhaust the iterable and cache all values.
If you specify a *validator* function, selected buckets will instead be
checked against it.
>>> from itertools import count
>>> it = count(1, 2) # Infinite sequence of odd numbers
>>> key = lambda x: x % 10 # Bucket by last digit
>>> validator = lambda x: x in {1, 3, 5, 7, 9} # Odd digits only
>>> s = bucket(it, key=key, validator=validator)
>>> 2 in s
False
>>> list(s[2])
[]
"""
def __init__(self, iterable, key, validator=None):
self._it = iter(iterable)
self._key = key
self._cache = defaultdict(deque)
self._validator = validator or (lambda x: True)
def __contains__(self, value):
if not self._validator(value):
return False
try:
item = next(self[value])
except StopIteration:
return False
else:
self._cache[value].appendleft(item)
return True
def _get_values(self, value):
"""
Helper to yield items from the parent iterator that match *value*.
Items that don't match are stored in the local cache as they
are encountered.
"""
while True:
# If we've cached some items that match the target value, emit
# the first one and evict it from the cache.
if self._cache[value]:
yield self._cache[value].popleft()
# Otherwise we need to advance the parent iterator to search for
# a matching item, caching the rest.
else:
while True:
try:
item = next(self._it)
except StopIteration:
return
item_value = self._key(item)
if item_value == value:
yield item
break
elif self._validator(item_value):
self._cache[item_value].append(item)
def __iter__(self):
for item in self._it:
item_value = self._key(item)
if self._validator(item_value):
self._cache[item_value].append(item)
yield from self._cache.keys()
def __getitem__(self, value):
if not self._validator(value):
return iter(())
return self._get_values(value)

View file

@ -1,9 +1,7 @@
import re import re
import textwrap import textwrap
import unittest import unittest
import warnings
import importlib import importlib
import contextlib
from . import fixtures from . import fixtures
from importlib.metadata import ( from importlib.metadata import (
@ -18,13 +16,6 @@
) )
@contextlib.contextmanager
def suppress_known_deprecation():
with warnings.catch_warnings(record=True) as ctx:
warnings.simplefilter('default', category=DeprecationWarning)
yield ctx
class APITests( class APITests(
fixtures.EggInfoPkg, fixtures.EggInfoPkg,
fixtures.EggInfoPkgPipInstalledNoToplevel, fixtures.EggInfoPkgPipInstalledNoToplevel,
@ -153,13 +144,13 @@ def test_metadata_for_this_package(self):
classifiers = md.get_all('Classifier') classifiers = md.get_all('Classifier')
assert 'Topic :: Software Development :: Libraries' in classifiers assert 'Topic :: Software Development :: Libraries' in classifiers
def test_missing_key_legacy(self): def test_missing_key(self):
""" """
Requesting a missing key will still return None, but warn. Requesting a missing key raises KeyError.
""" """
md = metadata('distinfo-pkg') md = metadata('distinfo-pkg')
with suppress_known_deprecation(): with self.assertRaises(KeyError):
assert md['does-not-exist'] is None md['does-not-exist']
def test_get_key(self): def test_get_key(self):
""" """

View file

@ -1,10 +1,8 @@
import re import re
import pickle import pickle
import unittest import unittest
import warnings
import importlib import importlib
import importlib.metadata import importlib.metadata
import contextlib
from test.support import os_helper from test.support import os_helper
try: try:
@ -13,7 +11,6 @@
from .stubs import fake_filesystem_unittest as ffs from .stubs import fake_filesystem_unittest as ffs
from . import fixtures from . import fixtures
from ._context import suppress
from ._path import Symlink from ._path import Symlink
from importlib.metadata import ( from importlib.metadata import (
Distribution, Distribution,
@ -28,13 +25,6 @@
) )
@contextlib.contextmanager
def suppress_known_deprecation():
with warnings.catch_warnings(record=True) as ctx:
warnings.simplefilter('default', category=DeprecationWarning)
yield ctx
class BasicTests(fixtures.DistInfoPkg, unittest.TestCase): class BasicTests(fixtures.DistInfoPkg, unittest.TestCase):
version_pattern = r'\d+\.\d+(\.\d)?' version_pattern = r'\d+\.\d+(\.\d)?'
@ -59,9 +49,6 @@ def test_package_not_found_mentions_metadata(self):
assert "metadata" in str(ctx.exception) assert "metadata" in str(ctx.exception)
# expected to fail until ABC is enforced
@suppress(AssertionError)
@suppress_known_deprecation()
def test_abc_enforced(self): def test_abc_enforced(self):
with self.assertRaises(TypeError): with self.assertRaises(TypeError):
type('DistributionSubclass', (Distribution,), {})() type('DistributionSubclass', (Distribution,), {})()
@ -146,6 +133,31 @@ def test_unique_distributions(self):
assert len(after) == len(before) assert len(after) == len(before)
class InvalidMetadataTests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
@staticmethod
def make_pkg(name, files=dict(METADATA="VERSION: 1.0")):
"""
Create metadata for a dist-info package with name and files.
"""
return {
f'{name}.dist-info': files,
}
def test_valid_dists_preferred(self):
"""
Dists with metadata should be preferred when discovered by name.
Ref python/importlib_metadata#489.
"""
# create three dists with the valid one in the middle (lexicographically)
# such that on most file systems, the valid one is never naturally first.
fixtures.build_files(self.make_pkg('foo-4.0', files={}), self.site_dir)
fixtures.build_files(self.make_pkg('foo-4.1'), self.site_dir)
fixtures.build_files(self.make_pkg('foo-4.2', files={}), self.site_dir)
dist = Distribution.from_name('foo')
assert dist.version == "1.0"
class NonASCIITests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase): class NonASCIITests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
@staticmethod @staticmethod
def pkg_with_non_ascii_description(site_dir): def pkg_with_non_ascii_description(site_dir):

View file

@ -0,0 +1,2 @@
``importlib.metadata`` now prioritizes valid dists to invalid dists when
retrieving by name.

View file

@ -0,0 +1,2 @@
``importlib.metadata`` now raises a ``KeyError`` instead of returning
``None`` when a key is missing from the metadata.

View file

@ -0,0 +1 @@
``SimplePath`` is now presented in ``importlib.metadata.__all__``.