]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-132947: Apply changes from importlib_metadata 8.7 (#137885)
authorJason R. Coombs <jaraco@jaraco.com>
Mon, 18 Aug 2025 17:54:10 +0000 (10:54 -0700)
committerGitHub <noreply@github.com>
Mon, 18 Aug 2025 17:54:10 +0000 (10:54 -0700)
* Copied files from python/importlib_metadata@b67ac80c49.

12 files changed:
Lib/importlib/metadata/__init__.py
Lib/importlib/metadata/_adapters.py
Lib/importlib/metadata/_collections.py
Lib/importlib/metadata/_functools.py
Lib/importlib/metadata/_meta.py
Lib/importlib/metadata/_typing.py [new file with mode: 0644]
Lib/test/test_importlib/metadata/_path.py
Lib/test/test_importlib/metadata/fixtures.py
Lib/test/test_importlib/metadata/test_api.py
Lib/test/test_importlib/metadata/test_main.py
Lib/test/test_importlib/metadata/test_zip.py
Misc/NEWS.d/next/Library/2025-08-17-10-22-31.gh-issue-132947.XR4MJ8.rst [new file with mode: 0644]

index b59587e80165e5bb1642541acfd8c2d841587e2d..1e2cea4009482a59891ef46bd677c13c7aa4da63 100644 (file)
@@ -1,33 +1,40 @@
+"""
+APIs exposing metadata from third-party Python packages.
+
+This codebase is shared between importlib.metadata in the stdlib
+and importlib_metadata in PyPI. See
+https://github.com/python/importlib_metadata/wiki/Development-Methodology
+for more detail.
+"""
+
 from __future__ import annotations
 
-import os
-import re
 import abc
-import sys
-import json
+import collections
 import email
-import types
-import inspect
-import pathlib
-import zipfile
-import operator
-import textwrap
 import functools
 import itertools
+import operator
+import os
+import pathlib
 import posixpath
-import collections
+import re
+import sys
+import textwrap
+import types
+from collections.abc import Iterable, Mapping
+from contextlib import suppress
+from importlib import import_module
+from importlib.abc import MetaPathFinder
+from itertools import starmap
+from typing import Any
 
 from . import _meta
 from ._collections import FreezableDefaultDict, Pair
 from ._functools import method_cache, pass_none
 from ._itertools import always_iterable, bucket, unique_everseen
 from ._meta import PackageMetadata, SimplePath
-
-from contextlib import suppress
-from importlib import import_module
-from importlib.abc import MetaPathFinder
-from itertools import starmap
-from typing import Any, Iterable, List, Mapping, Match, Optional, Set, cast
+from ._typing import md_none
 
 __all__ = [
     'Distribution',
@@ -53,7 +60,7 @@ class PackageNotFoundError(ModuleNotFoundError):
         return f"No package metadata was found for {self.name}"
 
     @property
-    def name(self) -> str:  # type: ignore[override]
+    def name(self) -> str:  # type: ignore[override] # make readonly
         (name,) = self.args
         return name
 
@@ -123,6 +130,12 @@ class Sectioned:
         return line and not line.startswith('#')
 
 
+class _EntryPointMatch(types.SimpleNamespace):
+    module: str
+    attr: str
+    extras: str
+
+
 class EntryPoint:
     """An entry point as defined by Python packaging conventions.
 
@@ -138,6 +151,30 @@ class EntryPoint:
     'attr'
     >>> ep.extras
     ['extra1', 'extra2']
+
+    If the value package or module are not valid identifiers, a
+    ValueError is raised on access.
+
+    >>> EntryPoint(name=None, group=None, value='invalid-name').module
+    Traceback (most recent call last):
+    ...
+    ValueError: ('Invalid object reference...invalid-name...
+    >>> EntryPoint(name=None, group=None, value='invalid-name').attr
+    Traceback (most recent call last):
+    ...
+    ValueError: ('Invalid object reference...invalid-name...
+    >>> EntryPoint(name=None, group=None, value='invalid-name').extras
+    Traceback (most recent call last):
+    ...
+    ValueError: ('Invalid object reference...invalid-name...
+
+    The same thing happens on construction.
+
+    >>> EntryPoint(name=None, group=None, value='invalid-name')
+    Traceback (most recent call last):
+    ...
+    ValueError: ('Invalid object reference...invalid-name...
+
     """
 
     pattern = re.compile(
@@ -165,38 +202,44 @@ class EntryPoint:
     value: str
     group: str
 
-    dist: Optional[Distribution] = None
+    dist: Distribution | None = None
 
     def __init__(self, name: str, value: str, group: str) -> None:
         vars(self).update(name=name, value=value, group=group)
+        self.module
 
     def load(self) -> Any:
         """Load the entry point from its definition. If only a module
         is indicated by the value, return that module. Otherwise,
         return the named object.
         """
-        match = cast(Match, self.pattern.match(self.value))
-        module = import_module(match.group('module'))
-        attrs = filter(None, (match.group('attr') or '').split('.'))
+        module = import_module(self.module)
+        attrs = filter(None, (self.attr or '').split('.'))
         return functools.reduce(getattr, attrs, module)
 
     @property
     def module(self) -> str:
-        match = self.pattern.match(self.value)
-        assert match is not None
-        return match.group('module')
+        return self._match.module
 
     @property
     def attr(self) -> str:
-        match = self.pattern.match(self.value)
-        assert match is not None
-        return match.group('attr')
+        return self._match.attr
 
     @property
-    def extras(self) -> List[str]:
+    def extras(self) -> list[str]:
+        return re.findall(r'\w+', self._match.extras or '')
+
+    @functools.cached_property
+    def _match(self) -> _EntryPointMatch:
         match = self.pattern.match(self.value)
-        assert match is not None
-        return re.findall(r'\w+', match.group('extras') or '')
+        if not match:
+            raise ValueError(
+                'Invalid object reference. '
+                'See https://packaging.python.org'
+                '/en/latest/specifications/entry-points/#data-model',
+                self.value,
+            )
+        return _EntryPointMatch(**match.groupdict())
 
     def _for(self, dist):
         vars(self).update(dist=dist)
@@ -222,9 +265,26 @@ class EntryPoint:
         >>> ep.matches(attr='bong')
         True
         """
+        self._disallow_dist(params)
         attrs = (getattr(self, param) for param in params)
         return all(map(operator.eq, params.values(), attrs))
 
+    @staticmethod
+    def _disallow_dist(params):
+        """
+        Querying by dist is not allowed (dist objects are not comparable).
+        >>> EntryPoint(name='fan', value='fav', group='fag').matches(dist='foo')
+        Traceback (most recent call last):
+        ...
+        ValueError: "dist" is not suitable for matching...
+        """
+        if "dist" in params:
+            raise ValueError(
+                '"dist" is not suitable for matching. '
+                "Instead, use Distribution.entry_points.select() on a "
+                "located distribution."
+            )
+
     def _key(self):
         return self.name, self.value, self.group
 
@@ -254,7 +314,7 @@ class EntryPoints(tuple):
 
     __slots__ = ()
 
-    def __getitem__(self, name: str) -> EntryPoint:  # type: ignore[override]
+    def __getitem__(self, name: str) -> EntryPoint:  # type: ignore[override] # Work with str instead of int
         """
         Get the EntryPoint in self matching name.
         """
@@ -278,14 +338,14 @@ class EntryPoints(tuple):
         return EntryPoints(ep for ep in self if ep.matches(**params))
 
     @property
-    def names(self) -> Set[str]:
+    def names(self) -> set[str]:
         """
         Return the set of all names of all entry points.
         """
         return {ep.name for ep in self}
 
     @property
-    def groups(self) -> Set[str]:
+    def groups(self) -> set[str]:
         """
         Return the set of all groups of all entry points.
         """
@@ -306,11 +366,11 @@ class EntryPoints(tuple):
 class PackagePath(pathlib.PurePosixPath):
     """A reference to a path in a package"""
 
-    hash: Optional[FileHash]
+    hash: FileHash | None
     size: int
     dist: Distribution
 
-    def read_text(self, encoding: str = 'utf-8') -> str:  # type: ignore[override]
+    def read_text(self, encoding: str = 'utf-8') -> str:
         return self.locate().read_text(encoding=encoding)
 
     def read_binary(self) -> bytes:
@@ -341,7 +401,7 @@ class Distribution(metaclass=abc.ABCMeta):
     """
 
     @abc.abstractmethod
-    def read_text(self, filename) -> Optional[str]:
+    def read_text(self, filename) -> str | None:
         """Attempt to load metadata file given by the name.
 
         Python distribution metadata is organized by blobs of text
@@ -368,6 +428,17 @@ class Distribution(metaclass=abc.ABCMeta):
         """
         Given a path to a file in this distribution, return a SimplePath
         to it.
+
+        This method is used by callers of ``Distribution.files()`` to
+        locate files within the distribution. If it's possible for a
+        Distribution to represent files in the distribution as
+        ``SimplePath`` objects, it should implement this method
+        to resolve such objects.
+
+        Some Distribution providers may elect not to resolve SimplePath
+        objects within the distribution by raising a
+        NotImplementedError, but consumers of such a Distribution would
+        be unable to invoke ``Distribution.files()``.
         """
 
     @classmethod
@@ -390,7 +461,7 @@ class Distribution(metaclass=abc.ABCMeta):
 
     @classmethod
     def discover(
-        cls, *, context: Optional[DistributionFinder.Context] = None, **kwargs
+        cls, *, context: DistributionFinder.Context | None = None, **kwargs
     ) -> Iterable[Distribution]:
         """Return an iterable of Distribution objects for all packages.
 
@@ -436,7 +507,7 @@ class Distribution(metaclass=abc.ABCMeta):
         return filter(None, declared)
 
     @property
-    def metadata(self) -> _meta.PackageMetadata:
+    def metadata(self) -> _meta.PackageMetadata | None:
         """Return the parsed metadata for this Distribution.
 
         The returned object will have keys that name the various bits of
@@ -446,10 +517,8 @@ class Distribution(metaclass=abc.ABCMeta):
         Custom providers may provide the METADATA file or override this
         property.
         """
-        # deferred for performance (python/cpython#109829)
-        from . import _adapters
 
-        opt_text = (
+        text = (
             self.read_text('METADATA')
             or self.read_text('PKG-INFO')
             # This last clause is here to support old egg-info files.  Its
@@ -457,13 +526,20 @@ class Distribution(metaclass=abc.ABCMeta):
             # (which points to the egg-info file) attribute unchanged.
             or self.read_text('')
         )
-        text = cast(str, opt_text)
+        return self._assemble_message(text)
+
+    @staticmethod
+    @pass_none
+    def _assemble_message(text: str) -> _meta.PackageMetadata:
+        # deferred for performance (python/cpython#109829)
+        from . import _adapters
+
         return _adapters.Message(email.message_from_string(text))
 
     @property
     def name(self) -> str:
         """Return the 'Name' metadata for the distribution package."""
-        return self.metadata['Name']
+        return md_none(self.metadata)['Name']
 
     @property
     def _normalized_name(self):
@@ -473,7 +549,7 @@ class Distribution(metaclass=abc.ABCMeta):
     @property
     def version(self) -> str:
         """Return the 'Version' metadata for the distribution package."""
-        return self.metadata['Version']
+        return md_none(self.metadata)['Version']
 
     @property
     def entry_points(self) -> EntryPoints:
@@ -486,7 +562,7 @@ class Distribution(metaclass=abc.ABCMeta):
         return EntryPoints._from_text_for(self.read_text('entry_points.txt'), self)
 
     @property
-    def files(self) -> Optional[List[PackagePath]]:
+    def files(self) -> list[PackagePath] | None:
         """Files in this distribution.
 
         :return: List of PackagePath for this distribution or None
@@ -579,7 +655,7 @@ class Distribution(metaclass=abc.ABCMeta):
         return text and map('"{}"'.format, text.splitlines())
 
     @property
-    def requires(self) -> Optional[List[str]]:
+    def requires(self) -> list[str] | None:
         """Generated requirements specified for this Distribution"""
         reqs = self._read_dist_info_reqs() or self._read_egg_info_reqs()
         return reqs and list(reqs)
@@ -635,6 +711,9 @@ class Distribution(metaclass=abc.ABCMeta):
         return self._load_json('direct_url.json')
 
     def _load_json(self, filename):
+        # Deferred for performance (python/importlib_metadata#503)
+        import json
+
         return pass_none(json.loads)(
             self.read_text(filename),
             object_hook=lambda data: types.SimpleNamespace(**data),
@@ -682,7 +761,7 @@ class DistributionFinder(MetaPathFinder):
             vars(self).update(kwargs)
 
         @property
-        def path(self) -> List[str]:
+        def path(self) -> list[str]:
             """
             The sequence of directory path that a distribution finder
             should search.
@@ -719,7 +798,7 @@ class FastPath:
     True
     """
 
-    @functools.lru_cache()  # type: ignore
+    @functools.lru_cache()  # type: ignore[misc]
     def __new__(cls, root):
         return super().__new__(cls)
 
@@ -737,6 +816,9 @@ class FastPath:
         return []
 
     def zip_children(self):
+        # deferred for performance (python/importlib_metadata#502)
+        import zipfile
+
         zip_path = zipfile.Path(self.root)
         names = zip_path.root.namelist()
         self.joinpath = zip_path.joinpath
@@ -831,7 +913,7 @@ class Prepared:
     normalized = None
     legacy_normalized = None
 
-    def __init__(self, name: Optional[str]):
+    def __init__(self, name: str | None):
         self.name = name
         if name is None:
             return
@@ -894,7 +976,7 @@ class PathDistribution(Distribution):
         """
         self._path = path
 
-    def read_text(self, filename: str | os.PathLike[str]) -> Optional[str]:
+    def read_text(self, filename: str | os.PathLike[str]) -> str | None:
         with suppress(
             FileNotFoundError,
             IsADirectoryError,
@@ -958,7 +1040,7 @@ def distributions(**kwargs) -> Iterable[Distribution]:
     return Distribution.discover(**kwargs)
 
 
-def metadata(distribution_name: str) -> _meta.PackageMetadata:
+def metadata(distribution_name: str) -> _meta.PackageMetadata | None:
     """Get the metadata for the named package.
 
     :param distribution_name: The name of the distribution package to query.
@@ -1001,7 +1083,7 @@ def entry_points(**params) -> EntryPoints:
     return EntryPoints(eps).select(**params)
 
 
-def files(distribution_name: str) -> Optional[List[PackagePath]]:
+def files(distribution_name: str) -> list[PackagePath] | None:
     """Return a list of files for the named package.
 
     :param distribution_name: The name of the distribution package to query.
@@ -1010,7 +1092,7 @@ def files(distribution_name: str) -> Optional[List[PackagePath]]:
     return distribution(distribution_name).files
 
 
-def requires(distribution_name: str) -> Optional[List[str]]:
+def requires(distribution_name: str) -> list[str] | None:
     """
     Return a list of requirements for the named package.
 
@@ -1020,7 +1102,7 @@ def requires(distribution_name: str) -> Optional[List[str]]:
     return distribution(distribution_name).requires
 
 
-def packages_distributions() -> Mapping[str, List[str]]:
+def packages_distributions() -> Mapping[str, list[str]]:
     """
     Return a mapping of top-level packages to their
     distributions.
@@ -1033,7 +1115,7 @@ def packages_distributions() -> Mapping[str, List[str]]:
     pkg_to_dist = collections.defaultdict(list)
     for dist in distributions():
         for pkg in _top_level_declared(dist) or _top_level_inferred(dist):
-            pkg_to_dist[pkg].append(dist.metadata['Name'])
+            pkg_to_dist[pkg].append(md_none(dist.metadata)['Name'])
     return dict(pkg_to_dist)
 
 
@@ -1041,7 +1123,7 @@ def _top_level_declared(dist):
     return (dist.read_text('top_level.txt') or '').split()
 
 
-def _topmost(name: PackagePath) -> Optional[str]:
+def _topmost(name: PackagePath) -> str | None:
     """
     Return the top-most parent as long as there is a parent.
     """
@@ -1067,11 +1149,10 @@ def _get_toplevel_name(name: PackagePath) -> str:
     >>> _get_toplevel_name(PackagePath('foo.dist-info'))
     'foo.dist-info'
     """
-    return _topmost(name) or (
-        # python/typeshed#10328
-        inspect.getmodulename(name)  # type: ignore
-        or str(name)
-    )
+    # Defer import of inspect for performance (python/cpython#118761)
+    import inspect
+
+    return _topmost(name) or inspect.getmodulename(name) or str(name)
 
 
 def _top_level_inferred(dist):
index 6223263ed53f22fc25c09de06789718d2cd3b6ea..f5b30dd92cde69ab7c4a4c6485c36d4dc38a551a 100644 (file)
@@ -1,11 +1,58 @@
+import email.message
+import email.policy
 import re
 import textwrap
-import email.message
 
 from ._text import FoldedCase
 
 
+class RawPolicy(email.policy.EmailPolicy):
+    def fold(self, name, value):
+        folded = self.linesep.join(
+            textwrap.indent(value, prefix=' ' * 8, predicate=lambda line: True)
+            .lstrip()
+            .splitlines()
+        )
+        return f'{name}: {folded}{self.linesep}'
+
+
 class Message(email.message.Message):
+    r"""
+    Specialized Message subclass to handle metadata naturally.
+
+    Reads values that may have newlines in them and converts the
+    payload to the Description.
+
+    >>> msg_text = textwrap.dedent('''
+    ...     Name: Foo
+    ...     Version: 3.0
+    ...     License: blah
+    ...             de-blah
+    ...     <BLANKLINE>
+    ...     First line of description.
+    ...     Second line of description.
+    ...     <BLANKLINE>
+    ...     Fourth line!
+    ...     ''').lstrip().replace('<BLANKLINE>', '')
+    >>> msg = Message(email.message_from_string(msg_text))
+    >>> msg['Description']
+    'First line of description.\nSecond line of description.\n\nFourth line!\n'
+
+    Message should render even if values contain newlines.
+
+    >>> print(msg)
+    Name: Foo
+    Version: 3.0
+    License: blah
+            de-blah
+    Description: First line of description.
+            Second line of description.
+    <BLANKLINE>
+            Fourth line!
+    <BLANKLINE>
+    <BLANKLINE>
+    """
+
     multiple_use_keys = set(
         map(
             FoldedCase,
@@ -57,15 +104,20 @@ class Message(email.message.Message):
     def _repair_headers(self):
         def redent(value):
             "Correct for RFC822 indentation"
-            if not value or '\n' not in value:
+            indent = ' ' * 8
+            if not value or '\n' + indent not in value:
                 return value
-            return textwrap.dedent(' ' * 8 + value)
+            return textwrap.dedent(indent + value)
 
         headers = [(key, redent(value)) for key, value in vars(self)['_headers']]
         if self._payload:
             headers.append(('Description', self.get_payload()))
+            self.set_payload('')
         return headers
 
+    def as_string(self):
+        return super().as_string(policy=RawPolicy())
+
     @property
     def json(self):
         """
index cf0954e1a30546d781bf25781ec716ef92a77e32..fc5045d36be57251000393b32baba7baa9cd8c4f 100644 (file)
@@ -1,4 +1,5 @@
 import collections
+import typing
 
 
 # from jaraco.collections 3.3
@@ -24,7 +25,10 @@ class FreezableDefaultDict(collections.defaultdict):
         self._frozen = lambda key: self.default_factory()
 
 
-class Pair(collections.namedtuple('Pair', 'name value')):
+class Pair(typing.NamedTuple):
+    name: str
+    value: str
+
     @classmethod
     def parse(cls, text):
         return cls(*map(str.strip, text.split("=", 1)))
index 71f66bd03cb713a2190853bdf7170c4ea80d2425..5dda6a2199ad0be79351899a583b98c48eda4938 100644 (file)
@@ -1,5 +1,5 @@
-import types
 import functools
+import types
 
 
 # from jaraco.functools 3.3
index 1927d0f624d82f2fa12f81c80cce91279f039e84..0c20eff3da75223a5ca76a1743b7c5b8fa1dc1f6 100644 (file)
@@ -1,9 +1,13 @@
 from __future__ import annotations
 
 import os
-from typing import Protocol
-from typing import Any, Dict, Iterator, List, Optional, TypeVar, Union, overload
-
+from collections.abc import Iterator
+from typing import (
+    Any,
+    Protocol,
+    TypeVar,
+    overload,
+)
 
 _T = TypeVar("_T")
 
@@ -20,25 +24,25 @@ class PackageMetadata(Protocol):
     @overload
     def get(
         self, name: str, failobj: None = None
-    ) -> Optional[str]: ...  # pragma: no cover
+    ) -> str | None: ...  # pragma: no cover
 
     @overload
-    def get(self, name: str, failobj: _T) -> Union[str, _T]: ...  # pragma: no cover
+    def get(self, name: str, failobj: _T) -> str | _T: ...  # pragma: no cover
 
     # overload per python/importlib_metadata#435
     @overload
     def get_all(
         self, name: str, failobj: None = None
-    ) -> Optional[List[Any]]: ...  # pragma: no cover
+    ) -> list[Any] | None: ...  # pragma: no cover
 
     @overload
-    def get_all(self, name: str, failobj: _T) -> Union[List[Any], _T]:
+    def get_all(self, name: str, failobj: _T) -> list[Any] | _T:
         """
         Return all values associated with a possibly multi-valued key.
         """
 
     @property
-    def json(self) -> Dict[str, Union[str, List[str]]]:
+    def json(self) -> dict[str, str | list[str]]:
         """
         A JSON-compatible form of the metadata.
         """
@@ -50,11 +54,11 @@ class SimplePath(Protocol):
     """
 
     def joinpath(
-        self, other: Union[str, os.PathLike[str]]
+        self, other: str | os.PathLike[str]
     ) -> SimplePath: ...  # pragma: no cover
 
     def __truediv__(
-        self, other: Union[str, os.PathLike[str]]
+        self, other: str | os.PathLike[str]
     ) -> SimplePath: ...  # pragma: no cover
 
     @property
diff --git a/Lib/importlib/metadata/_typing.py b/Lib/importlib/metadata/_typing.py
new file mode 100644 (file)
index 0000000..32b1d2b
--- /dev/null
@@ -0,0 +1,15 @@
+import functools
+import typing
+
+from ._meta import PackageMetadata
+
+md_none = functools.partial(typing.cast, PackageMetadata)
+"""
+Suppress type errors for optional metadata.
+
+Although Distribution.metadata can return None when metadata is corrupt
+and thus None, allow callers to assume it's not None and crash if
+that's the case.
+
+# python/importlib_metadata#493
+"""
index b3cfb9cd549d6cee133e211b0d3d9df188372ef1..e63d889f96bf13071cfd207dd2be392b9823cb84 100644 (file)
@@ -1,9 +1,14 @@
-# from jaraco.path 3.7
+# from jaraco.path 3.7.2
+
+from __future__ import annotations
 
 import functools
 import pathlib
-from typing import Dict, Protocol, Union
-from typing import runtime_checkable
+from collections.abc import Mapping
+from typing import TYPE_CHECKING, Protocol, Union, runtime_checkable
+
+if TYPE_CHECKING:
+    from typing_extensions import Self
 
 
 class Symlink(str):
@@ -12,29 +17,25 @@ class Symlink(str):
     """
 
 
-FilesSpec = Dict[str, Union[str, bytes, Symlink, 'FilesSpec']]  # type: ignore
+FilesSpec = Mapping[str, Union[str, bytes, Symlink, 'FilesSpec']]
 
 
 @runtime_checkable
 class TreeMaker(Protocol):
-    def __truediv__(self, *args, **kwargs): ...  # pragma: no cover
-
-    def mkdir(self, **kwargs): ...  # pragma: no cover
-
-    def write_text(self, content, **kwargs): ...  # pragma: no cover
-
-    def write_bytes(self, content): ...  # pragma: no cover
-
-    def symlink_to(self, target): ...  # pragma: no cover
+    def __truediv__(self, other, /) -> Self: ...
+    def mkdir(self, *, exist_ok) -> object: ...
+    def write_text(self, content, /, *, encoding) -> object: ...
+    def write_bytes(self, content, /) -> object: ...
+    def symlink_to(self, target, /) -> object: ...
 
 
-def _ensure_tree_maker(obj: Union[str, TreeMaker]) -> TreeMaker:
-    return obj if isinstance(obj, TreeMaker) else pathlib.Path(obj)  # type: ignore
+def _ensure_tree_maker(obj: str | TreeMaker) -> TreeMaker:
+    return obj if isinstance(obj, TreeMaker) else pathlib.Path(obj)
 
 
 def build(
     spec: FilesSpec,
-    prefix: Union[str, TreeMaker] = pathlib.Path(),  # type: ignore
+    prefix: str | TreeMaker = pathlib.Path(),
 ):
     """
     Build a set of files/directories, as described by the spec.
@@ -66,23 +67,24 @@ def build(
 
 
 @functools.singledispatch
-def create(content: Union[str, bytes, FilesSpec], path):
+def create(content: str | bytes | FilesSpec, path: TreeMaker) -> None:
     path.mkdir(exist_ok=True)
-    build(content, prefix=path)  # type: ignore
+    # Mypy only looks at the signature of the main singledispatch method. So it must contain the complete Union
+    build(content, prefix=path)  # type: ignore[arg-type] # python/mypy#11727
 
 
 @create.register
-def _(content: bytes, path):
+def _(content: bytes, path: TreeMaker) -> None:
     path.write_bytes(content)
 
 
 @create.register
-def _(content: str, path):
+def _(content: str, path: TreeMaker) -> None:
     path.write_text(content, encoding='utf-8')
 
 
 @create.register
-def _(content: Symlink, path):
+def _(content: Symlink, path: TreeMaker) -> None:
     path.symlink_to(content)
 
 
index 826b1b3259b4cdbd849667eab3f1f72e29b3ce94..494047dc98f9b6c2415a74845387666b1aef499e 100644 (file)
@@ -1,11 +1,11 @@
-import sys
+import contextlib
 import copy
+import functools
 import json
-import shutil
 import pathlib
+import shutil
+import sys
 import textwrap
-import functools
-import contextlib
 
 from test.support import import_helper
 from test.support import os_helper
@@ -14,14 +14,10 @@ from test.support import requires_zlib
 from . import _path
 from ._path import FilesSpec
 
-
-try:
-    from importlib import resources  # type: ignore
-
-    getattr(resources, 'files')
-    getattr(resources, 'as_file')
-except (ImportError, AttributeError):
-    import importlib_resources as resources  # type: ignore
+if sys.version_info >= (3, 9):
+    from importlib import resources
+else:
+    import importlib_resources as resources
 
 
 @contextlib.contextmanager
index 813febf269593bff7de4eab471536d7c6d1f31c9..9f6e12c87e859cbc87d9fd5a19e332566a5bc68d 100644 (file)
@@ -1,9 +1,8 @@
+import importlib
 import re
 import textwrap
 import unittest
-import importlib
 
-from . import fixtures
 from importlib.metadata import (
     Distribution,
     PackageNotFoundError,
@@ -15,6 +14,8 @@ from importlib.metadata import (
     version,
 )
 
+from . import fixtures
+
 
 class APITests(
     fixtures.EggInfoPkg,
index a0bc8222d5ba24a8037c2d564325f4e2dcb276a2..83b686babfdb7aebf6f83a35afed3fce560f95f0 100644 (file)
@@ -1,8 +1,7 @@
-import re
+import importlib
 import pickle
+import re
 import unittest
-import importlib
-import importlib.metadata
 from test.support import os_helper
 
 try:
@@ -10,8 +9,6 @@ try:
 except ImportError:
     from .stubs import fake_filesystem_unittest as ffs
 
-from . import fixtures
-from ._path import Symlink
 from importlib.metadata import (
     Distribution,
     EntryPoint,
@@ -24,6 +21,9 @@ from importlib.metadata import (
     version,
 )
 
+from . import fixtures
+from ._path import Symlink
+
 
 class BasicTests(fixtures.DistInfoPkg, unittest.TestCase):
     version_pattern = r'\d+\.\d+(\.\d)?'
@@ -157,6 +157,16 @@ class InvalidMetadataTests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCa
         dist = Distribution.from_name('foo')
         assert dist.version == "1.0"
 
+    def test_missing_metadata(self):
+        """
+        Dists with a missing metadata file should return None.
+
+        Ref python/importlib_metadata#493.
+        """
+        fixtures.build_files(self.make_pkg('foo-4.3', files={}), self.site_dir)
+        assert Distribution.from_name('foo').metadata is None
+        assert metadata('foo') is None
+
 
 class NonASCIITests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
     @staticmethod
index 276f6288c9159881469a4d49d7d5779b24a29589..fcb649f373607653dbc39ccff9cf64832a88e8bc 100644 (file)
@@ -1,7 +1,6 @@
 import sys
 import unittest
 
-from . import fixtures
 from importlib.metadata import (
     PackageNotFoundError,
     distribution,
@@ -11,6 +10,8 @@ from importlib.metadata import (
     version,
 )
 
+from . import fixtures
+
 
 class TestZip(fixtures.ZipFixtures, unittest.TestCase):
     def setUp(self):
diff --git a/Misc/NEWS.d/next/Library/2025-08-17-10-22-31.gh-issue-132947.XR4MJ8.rst b/Misc/NEWS.d/next/Library/2025-08-17-10-22-31.gh-issue-132947.XR4MJ8.rst
new file mode 100644 (file)
index 0000000..8a2b0a4
--- /dev/null
@@ -0,0 +1,6 @@
+Applied changes to ``importlib.metadata`` from `importlib_metadata 8.7
+<https://importlib-metadata.readthedocs.io/en/latest/history.html#v8-7-0>`_,
+including ``dist`` now disallowed for ``EntryPoints.select``; deferred
+imports for faster import times; added support for metadata with newlines
+(python/cpython#119650); and ``metadata()`` function now returns ``None``
+when a metadata directory is present but no metadata is present.