https://github.com/python/cpython/commit/ec4021c6d73407fd4d22ee1a4f49d68835ef0770
commit: ec4021c6d73407fd4d22ee1a4f49d68835ef0770
branch: main
author: Jason R. Coombs <[email protected]>
committer: jaraco <[email protected]>
date: 2025-08-15T14:19:23-07:00
summary:

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

files:
A Misc/NEWS.d/next/Library/2024-09-13-09-43-15.gh-issue-120492.Mm6CJ6.rst
A Misc/NEWS.d/next/Library/2024-09-13-09-46-47.gh-issue-91216.LuOsF4.rst
A Misc/NEWS.d/next/Library/2024-09-13-09-48-25.gh-issue-124033.WNudS0.rst
M Lib/importlib/metadata/__init__.py
M Lib/importlib/metadata/_adapters.py
M Lib/importlib/metadata/_itertools.py
M Lib/test/test_importlib/metadata/test_api.py
M Lib/test/test_importlib/metadata/test_main.py

diff --git a/Lib/importlib/metadata/__init__.py 
b/Lib/importlib/metadata/__init__.py
index 8ce62dd864fc27..b59587e80165e5 100644
--- a/Lib/importlib/metadata/__init__.py
+++ b/Lib/importlib/metadata/__init__.py
@@ -12,7 +12,6 @@
 import zipfile
 import operator
 import textwrap
-import warnings
 import functools
 import itertools
 import posixpath
@@ -21,7 +20,7 @@
 from . import _meta
 from ._collections import FreezableDefaultDict, Pair
 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 contextlib import suppress
@@ -35,6 +34,7 @@
     'DistributionFinder',
     'PackageMetadata',
     'PackageNotFoundError',
+    'SimplePath',
     'distribution',
     'distributions',
     'entry_points',
@@ -329,27 +329,7 @@ def __repr__(self) -> str:
         return f'<FileHash mode: {self.mode} value: {self.value}>'
 
 
-class DeprecatedNonAbstract:
-    # 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):
+class Distribution(metaclass=abc.ABCMeta):
     """
     An abstract Python distribution package.
 
@@ -404,7 +384,7 @@ def from_name(cls, name: str) -> Distribution:
         if not name:
             raise ValueError("A distribution name is required.")
         try:
-            return next(iter(cls.discover(name=name)))
+            return next(iter(cls._prefer_valid(cls.discover(name=name))))
         except StopIteration:
             raise PackageNotFoundError(name)
 
@@ -428,6 +408,16 @@ def discover(
             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
     def at(path: str | os.PathLike[str]) -> Distribution:
         """Return a Distribution for the indicated metadata path.
diff --git a/Lib/importlib/metadata/_adapters.py 
b/Lib/importlib/metadata/_adapters.py
index 591168808953ba..6223263ed53f22 100644
--- a/Lib/importlib/metadata/_adapters.py
+++ b/Lib/importlib/metadata/_adapters.py
@@ -1,5 +1,3 @@
-import functools
-import warnings
 import re
 import textwrap
 import email.message
@@ -7,15 +5,6 @@
 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):
     multiple_use_keys = set(
         map(
@@ -52,12 +41,17 @@ def __iter__(self):
 
     def __getitem__(self, item):
         """
-        Warn users that a ``KeyError`` can be expected when a
-        missing key is supplied. Ref python/importlib_metadata#371.
+        Override parent behavior to typical dict behavior.
+
+        ``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)
         if res is None:
-            _warn()
+            raise KeyError(item)
         return res
 
     def _repair_headers(self):
diff --git a/Lib/importlib/metadata/_itertools.py 
b/Lib/importlib/metadata/_itertools.py
index d4ca9b9140e3f0..79d37198ce7aff 100644
--- a/Lib/importlib/metadata/_itertools.py
+++ b/Lib/importlib/metadata/_itertools.py
@@ -1,3 +1,4 @@
+from collections import defaultdict, deque
 from itertools import filterfalse
 
 
@@ -71,3 +72,100 @@ def always_iterable(obj, base_type=(str, bytes)):
         return iter(obj)
     except TypeError:
         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)
diff --git a/Lib/test/test_importlib/metadata/test_api.py 
b/Lib/test/test_importlib/metadata/test_api.py
index 2256e0c502e46f..813febf269593b 100644
--- a/Lib/test/test_importlib/metadata/test_api.py
+++ b/Lib/test/test_importlib/metadata/test_api.py
@@ -1,9 +1,7 @@
 import re
 import textwrap
 import unittest
-import warnings
 import importlib
-import contextlib
 
 from . import fixtures
 from importlib.metadata import (
@@ -18,13 +16,6 @@
 )
 
 
[email protected]
-def suppress_known_deprecation():
-    with warnings.catch_warnings(record=True) as ctx:
-        warnings.simplefilter('default', category=DeprecationWarning)
-        yield ctx
-
-
 class APITests(
     fixtures.EggInfoPkg,
     fixtures.EggInfoPkgPipInstalledNoToplevel,
@@ -153,13 +144,13 @@ def test_metadata_for_this_package(self):
         classifiers = md.get_all('Classifier')
         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')
-        with suppress_known_deprecation():
-            assert md['does-not-exist'] is None
+        with self.assertRaises(KeyError):
+            md['does-not-exist']
 
     def test_get_key(self):
         """
diff --git a/Lib/test/test_importlib/metadata/test_main.py 
b/Lib/test/test_importlib/metadata/test_main.py
index e4218076f8cb0e..a0bc8222d5ba24 100644
--- a/Lib/test/test_importlib/metadata/test_main.py
+++ b/Lib/test/test_importlib/metadata/test_main.py
@@ -1,10 +1,8 @@
 import re
 import pickle
 import unittest
-import warnings
 import importlib
 import importlib.metadata
-import contextlib
 from test.support import os_helper
 
 try:
@@ -13,7 +11,6 @@
     from .stubs import fake_filesystem_unittest as ffs
 
 from . import fixtures
-from ._context import suppress
 from ._path import Symlink
 from importlib.metadata import (
     Distribution,
@@ -28,13 +25,6 @@
 )
 
 
[email protected]
-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):
     version_pattern = r'\d+\.\d+(\.\d)?'
 
@@ -59,9 +49,6 @@ def test_package_not_found_mentions_metadata(self):
 
         assert "metadata" in str(ctx.exception)
 
-    # expected to fail until ABC is enforced
-    @suppress(AssertionError)
-    @suppress_known_deprecation()
     def test_abc_enforced(self):
         with self.assertRaises(TypeError):
             type('DistributionSubclass', (Distribution,), {})()
@@ -146,6 +133,31 @@ def test_unique_distributions(self):
         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):
     @staticmethod
     def pkg_with_non_ascii_description(site_dir):
diff --git 
a/Misc/NEWS.d/next/Library/2024-09-13-09-43-15.gh-issue-120492.Mm6CJ6.rst 
b/Misc/NEWS.d/next/Library/2024-09-13-09-43-15.gh-issue-120492.Mm6CJ6.rst
new file mode 100644
index 00000000000000..a9652b9fcfc354
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2024-09-13-09-43-15.gh-issue-120492.Mm6CJ6.rst
@@ -0,0 +1,2 @@
+``importlib.metadata`` now prioritizes valid dists to invalid dists when
+retrieving by name.
diff --git 
a/Misc/NEWS.d/next/Library/2024-09-13-09-46-47.gh-issue-91216.LuOsF4.rst 
b/Misc/NEWS.d/next/Library/2024-09-13-09-46-47.gh-issue-91216.LuOsF4.rst
new file mode 100644
index 00000000000000..bb90588b2e7a77
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2024-09-13-09-46-47.gh-issue-91216.LuOsF4.rst
@@ -0,0 +1,2 @@
+``importlib.metadata`` now raises a ``KeyError`` instead of returning
+``None`` when a key is missing from the metadata.
diff --git 
a/Misc/NEWS.d/next/Library/2024-09-13-09-48-25.gh-issue-124033.WNudS0.rst 
b/Misc/NEWS.d/next/Library/2024-09-13-09-48-25.gh-issue-124033.WNudS0.rst
new file mode 100644
index 00000000000000..f422ab01a5f113
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2024-09-13-09-48-25.gh-issue-124033.WNudS0.rst
@@ -0,0 +1 @@
+``SimplePath`` is now presented in ``importlib.metadata.__all__``.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to