Skip to content

Commit ec4021c

Browse files
authored
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
1 parent d86c225 commit ec4021c

File tree

8 files changed

+154
-64
lines changed

8 files changed

+154
-64
lines changed

Lib/importlib/metadata/__init__.py

Lines changed: 14 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
import zipfile
1313
import operator
1414
import textwrap
15-
import warnings
1615
import functools
1716
import itertools
1817
import posixpath
@@ -21,7 +20,7 @@
2120
from . import _meta
2221
from ._collections import FreezableDefaultDict, Pair
2322
from ._functools import method_cache, pass_none
24-
from ._itertools import always_iterable, unique_everseen
23+
from ._itertools import always_iterable, bucket, unique_everseen
2524
from ._meta import PackageMetadata, SimplePath
2625

2726
from contextlib import suppress
@@ -35,6 +34,7 @@
3534
'DistributionFinder',
3635
'PackageMetadata',
3736
'PackageNotFoundError',
37+
'SimplePath',
3838
'distribution',
3939
'distributions',
4040
'entry_points',
@@ -329,27 +329,7 @@ def __repr__(self) -> str:
329329
return f'<FileHash mode: {self.mode} value: {self.value}>'
330330

331331

332-
class DeprecatedNonAbstract:
333-
# Required until Python 3.14
334-
def __new__(cls, *args, **kwargs):
335-
all_names = {
336-
name for subclass in inspect.getmro(cls) for name in vars(subclass)
337-
}
338-
abstract = {
339-
name
340-
for name in all_names
341-
if getattr(getattr(cls, name), '__isabstractmethod__', False)
342-
}
343-
if abstract:
344-
warnings.warn(
345-
f"Unimplemented abstract methods {abstract}",
346-
DeprecationWarning,
347-
stacklevel=2,
348-
)
349-
return super().__new__(cls)
350-
351-
352-
class Distribution(DeprecatedNonAbstract):
332+
class Distribution(metaclass=abc.ABCMeta):
353333
"""
354334
An abstract Python distribution package.
355335
@@ -404,7 +384,7 @@ def from_name(cls, name: str) -> Distribution:
404384
if not name:
405385
raise ValueError("A distribution name is required.")
406386
try:
407-
return next(iter(cls.discover(name=name)))
387+
return next(iter(cls._prefer_valid(cls.discover(name=name))))
408388
except StopIteration:
409389
raise PackageNotFoundError(name)
410390

@@ -428,6 +408,16 @@ def discover(
428408
resolver(context) for resolver in cls._discover_resolvers()
429409
)
430410

411+
@staticmethod
412+
def _prefer_valid(dists: Iterable[Distribution]) -> Iterable[Distribution]:
413+
"""
414+
Prefer (move to the front) distributions that have metadata.
415+
416+
Ref python/importlib_resources#489.
417+
"""
418+
buckets = bucket(dists, lambda dist: bool(dist.metadata))
419+
return itertools.chain(buckets[True], buckets[False])
420+
431421
@staticmethod
432422
def at(path: str | os.PathLike[str]) -> Distribution:
433423
"""Return a Distribution for the indicated metadata path.

Lib/importlib/metadata/_adapters.py

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,10 @@
1-
import functools
2-
import warnings
31
import re
42
import textwrap
53
import email.message
64

75
from ._text import FoldedCase
86

97

10-
# Do not remove prior to 2024-01-01 or Python 3.14
11-
_warn = functools.partial(
12-
warnings.warn,
13-
"Implicit None on return values is deprecated and will raise KeyErrors.",
14-
DeprecationWarning,
15-
stacklevel=2,
16-
)
17-
18-
198
class Message(email.message.Message):
209
multiple_use_keys = set(
2110
map(
@@ -52,12 +41,17 @@ def __iter__(self):
5241

5342
def __getitem__(self, item):
5443
"""
55-
Warn users that a ``KeyError`` can be expected when a
56-
missing key is supplied. Ref python/importlib_metadata#371.
44+
Override parent behavior to typical dict behavior.
45+
46+
``email.message.Message`` will emit None values for missing
47+
keys. Typical mappings, including this ``Message``, will raise
48+
a key error for missing keys.
49+
50+
Ref python/importlib_metadata#371.
5751
"""
5852
res = super().__getitem__(item)
5953
if res is None:
60-
_warn()
54+
raise KeyError(item)
6155
return res
6256

6357
def _repair_headers(self):

Lib/importlib/metadata/_itertools.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from collections import defaultdict, deque
12
from itertools import filterfalse
23

34

@@ -71,3 +72,100 @@ def always_iterable(obj, base_type=(str, bytes)):
7172
return iter(obj)
7273
except TypeError:
7374
return iter((obj,))
75+
76+
77+
# Copied from more_itertools 10.3
78+
class bucket:
79+
"""Wrap *iterable* and return an object that buckets the iterable into
80+
child iterables based on a *key* function.
81+
82+
>>> iterable = ['a1', 'b1', 'c1', 'a2', 'b2', 'c2', 'b3']
83+
>>> s = bucket(iterable, key=lambda x: x[0]) # Bucket by 1st character
84+
>>> sorted(list(s)) # Get the keys
85+
['a', 'b', 'c']
86+
>>> a_iterable = s['a']
87+
>>> next(a_iterable)
88+
'a1'
89+
>>> next(a_iterable)
90+
'a2'
91+
>>> list(s['b'])
92+
['b1', 'b2', 'b3']
93+
94+
The original iterable will be advanced and its items will be cached until
95+
they are used by the child iterables. This may require significant storage.
96+
97+
By default, attempting to select a bucket to which no items belong will
98+
exhaust the iterable and cache all values.
99+
If you specify a *validator* function, selected buckets will instead be
100+
checked against it.
101+
102+
>>> from itertools import count
103+
>>> it = count(1, 2) # Infinite sequence of odd numbers
104+
>>> key = lambda x: x % 10 # Bucket by last digit
105+
>>> validator = lambda x: x in {1, 3, 5, 7, 9} # Odd digits only
106+
>>> s = bucket(it, key=key, validator=validator)
107+
>>> 2 in s
108+
False
109+
>>> list(s[2])
110+
[]
111+
112+
"""
113+
114+
def __init__(self, iterable, key, validator=None):
115+
self._it = iter(iterable)
116+
self._key = key
117+
self._cache = defaultdict(deque)
118+
self._validator = validator or (lambda x: True)
119+
120+
def __contains__(self, value):
121+
if not self._validator(value):
122+
return False
123+
124+
try:
125+
item = next(self[value])
126+
except StopIteration:
127+
return False
128+
else:
129+
self._cache[value].appendleft(item)
130+
131+
return True
132+
133+
def _get_values(self, value):
134+
"""
135+
Helper to yield items from the parent iterator that match *value*.
136+
Items that don't match are stored in the local cache as they
137+
are encountered.
138+
"""
139+
while True:
140+
# If we've cached some items that match the target value, emit
141+
# the first one and evict it from the cache.
142+
if self._cache[value]:
143+
yield self._cache[value].popleft()
144+
# Otherwise we need to advance the parent iterator to search for
145+
# a matching item, caching the rest.
146+
else:
147+
while True:
148+
try:
149+
item = next(self._it)
150+
except StopIteration:
151+
return
152+
item_value = self._key(item)
153+
if item_value == value:
154+
yield item
155+
break
156+
elif self._validator(item_value):
157+
self._cache[item_value].append(item)
158+
159+
def __iter__(self):
160+
for item in self._it:
161+
item_value = self._key(item)
162+
if self._validator(item_value):
163+
self._cache[item_value].append(item)
164+
165+
yield from self._cache.keys()
166+
167+
def __getitem__(self, value):
168+
if not self._validator(value):
169+
return iter(())
170+
171+
return self._get_values(value)

Lib/test/test_importlib/metadata/test_api.py

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import re
22
import textwrap
33
import unittest
4-
import warnings
54
import importlib
6-
import contextlib
75

86
from . import fixtures
97
from importlib.metadata import (
@@ -18,13 +16,6 @@
1816
)
1917

2018

21-
@contextlib.contextmanager
22-
def suppress_known_deprecation():
23-
with warnings.catch_warnings(record=True) as ctx:
24-
warnings.simplefilter('default', category=DeprecationWarning)
25-
yield ctx
26-
27-
2819
class APITests(
2920
fixtures.EggInfoPkg,
3021
fixtures.EggInfoPkgPipInstalledNoToplevel,
@@ -153,13 +144,13 @@ def test_metadata_for_this_package(self):
153144
classifiers = md.get_all('Classifier')
154145
assert 'Topic :: Software Development :: Libraries' in classifiers
155146

156-
def test_missing_key_legacy(self):
147+
def test_missing_key(self):
157148
"""
158-
Requesting a missing key will still return None, but warn.
149+
Requesting a missing key raises KeyError.
159150
"""
160151
md = metadata('distinfo-pkg')
161-
with suppress_known_deprecation():
162-
assert md['does-not-exist'] is None
152+
with self.assertRaises(KeyError):
153+
md['does-not-exist']
163154

164155
def test_get_key(self):
165156
"""

Lib/test/test_importlib/metadata/test_main.py

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import re
22
import pickle
33
import unittest
4-
import warnings
54
import importlib
65
import importlib.metadata
7-
import contextlib
86
from test.support import os_helper
97

108
try:
@@ -13,7 +11,6 @@
1311
from .stubs import fake_filesystem_unittest as ffs
1412

1513
from . import fixtures
16-
from ._context import suppress
1714
from ._path import Symlink
1815
from importlib.metadata import (
1916
Distribution,
@@ -28,13 +25,6 @@
2825
)
2926

3027

31-
@contextlib.contextmanager
32-
def suppress_known_deprecation():
33-
with warnings.catch_warnings(record=True) as ctx:
34-
warnings.simplefilter('default', category=DeprecationWarning)
35-
yield ctx
36-
37-
3828
class BasicTests(fixtures.DistInfoPkg, unittest.TestCase):
3929
version_pattern = r'\d+\.\d+(\.\d)?'
4030

@@ -59,9 +49,6 @@ def test_package_not_found_mentions_metadata(self):
5949

6050
assert "metadata" in str(ctx.exception)
6151

62-
# expected to fail until ABC is enforced
63-
@suppress(AssertionError)
64-
@suppress_known_deprecation()
6552
def test_abc_enforced(self):
6653
with self.assertRaises(TypeError):
6754
type('DistributionSubclass', (Distribution,), {})()
@@ -146,6 +133,31 @@ def test_unique_distributions(self):
146133
assert len(after) == len(before)
147134

148135

136+
class InvalidMetadataTests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
137+
@staticmethod
138+
def make_pkg(name, files=dict(METADATA="VERSION: 1.0")):
139+
"""
140+
Create metadata for a dist-info package with name and files.
141+
"""
142+
return {
143+
f'{name}.dist-info': files,
144+
}
145+
146+
def test_valid_dists_preferred(self):
147+
"""
148+
Dists with metadata should be preferred when discovered by name.
149+
150+
Ref python/importlib_metadata#489.
151+
"""
152+
# create three dists with the valid one in the middle (lexicographically)
153+
# such that on most file systems, the valid one is never naturally first.
154+
fixtures.build_files(self.make_pkg('foo-4.0', files={}), self.site_dir)
155+
fixtures.build_files(self.make_pkg('foo-4.1'), self.site_dir)
156+
fixtures.build_files(self.make_pkg('foo-4.2', files={}), self.site_dir)
157+
dist = Distribution.from_name('foo')
158+
assert dist.version == "1.0"
159+
160+
149161
class NonASCIITests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
150162
@staticmethod
151163
def pkg_with_non_ascii_description(site_dir):
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
``importlib.metadata`` now prioritizes valid dists to invalid dists when
2+
retrieving by name.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
``importlib.metadata`` now raises a ``KeyError`` instead of returning
2+
``None`` when a key is missing from the metadata.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
``SimplePath`` is now presented in ``importlib.metadata.__all__``.

0 commit comments

Comments
 (0)