]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-113174: Sync with importlib_metadata 7.0 (#113175)
authorJason R. Coombs <jaraco@jaraco.com>
Thu, 21 Dec 2023 20:04:05 +0000 (15:04 -0500)
committerGitHub <noreply@github.com>
Thu, 21 Dec 2023 20:04:05 +0000 (15:04 -0500)
* Sync with importlib_metadata 7.0.0

* Add blurb

* Update docs to reflect changes.

* Link datamodel docs for object.__getitem__

Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
* Add what's new for removed __getattr__

* Link datamodel docs for object.__getitem__

Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
* Add exclamation point, as that seems to be used for other classes.

---------

Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
Doc/library/importlib.metadata.rst
Doc/whatsnew/3.13.rst
Lib/importlib/metadata/__init__.py
Lib/importlib/metadata/_adapters.py
Lib/importlib/metadata/_meta.py
Lib/importlib/metadata/diagnose.py [new file with mode: 0644]
Lib/test/test_importlib/_path.py
Lib/test/test_importlib/fixtures.py
Lib/test/test_importlib/test_main.py
Misc/NEWS.d/next/Library/2023-12-15-09-51-41.gh-issue-113175.RHsNwE.rst [new file with mode: 0644]

index 1df7d8d772a2747403dca91a3ef1f40736c7ebae..cc4a0da92da60a9e34c090030fab5dcc0a28813e 100644 (file)
@@ -171,16 +171,18 @@ group.  Read `the setuptools docs
 <https://setuptools.pypa.io/en/latest/userguide/entry_point.html>`_
 for more information on entry points, their definition, and usage.
 
-*Compatibility Note*
-
-The "selectable" entry points were introduced in ``importlib_metadata``
-3.6 and Python 3.10. Prior to those changes, ``entry_points`` accepted
-no parameters and always returned a dictionary of entry points, keyed
-by group. With ``importlib_metadata`` 5.0 and Python 3.12,
-``entry_points`` always returns an ``EntryPoints`` object. See
-`backports.entry_points_selectable <https://pypi.org/project/backports.entry-points-selectable>`_
-for compatibility options.
-
+.. versionchanged:: 3.12
+   The "selectable" entry points were introduced in ``importlib_metadata``
+   3.6 and Python 3.10. Prior to those changes, ``entry_points`` accepted
+   no parameters and always returned a dictionary of entry points, keyed
+   by group. With ``importlib_metadata`` 5.0 and Python 3.12,
+   ``entry_points`` always returns an ``EntryPoints`` object. See
+   `backports.entry_points_selectable <https://pypi.org/project/backports.entry-points-selectable>`_
+   for compatibility options.
+
+.. versionchanged:: 3.13
+   ``EntryPoint`` objects no longer present a tuple-like interface
+   (:meth:`~object.__getitem__`).
 
 .. _metadata:
 
@@ -342,9 +344,17 @@ instance::
     >>> dist.metadata['License']  # doctest: +SKIP
     'MIT'
 
+For editable packages, an origin property may present :pep:`610`
+metadata::
+
+    >>> dist.origin.url
+    'file:///path/to/wheel-0.32.3.editable-py3-none-any.whl'
+
 The full set of available metadata is not described here.
 See the `Core metadata specifications <https://packaging.python.org/en/latest/specifications/core-metadata/#core-metadata>`_ for additional details.
 
+.. versionadded:: 3.13
+   The ``.origin`` property was added.
 
 Distribution Discovery
 ======================
index 2c869cbe11396b07691d0d6a73031f4206e79d46..7dc02dacdc68f7434b44be239107127737db8216 100644 (file)
@@ -1001,6 +1001,10 @@ importlib
   for migration advice.
   (Contributed by Jason R. Coombs in :gh:`106532`.)
 
+* Remove deprecated :meth:`~object.__getitem__` access for
+  :class:`!importlib.metadata.EntryPoint` objects.
+  (Contributed by Jason R. Coombs in :gh:`113175`.)
+
 locale
 ------
 
index 5c09666b6a40d9c2e9ec4e2dd4b70987b752479a..7b142e786e829efbb53c9af7b3a81194951a54d7 100644 (file)
@@ -3,7 +3,10 @@ import re
 import abc
 import csv
 import sys
+import json
 import email
+import types
+import inspect
 import pathlib
 import zipfile
 import operator
@@ -13,7 +16,6 @@ import functools
 import itertools
 import posixpath
 import collections
-import inspect
 
 from . import _adapters, _meta
 from ._collections import FreezableDefaultDict, Pair
@@ -25,8 +27,7 @@ from contextlib import suppress
 from importlib import import_module
 from importlib.abc import MetaPathFinder
 from itertools import starmap
-from typing import List, Mapping, Optional, cast
-
+from typing import Iterable, List, Mapping, Optional, Set, Union, cast
 
 __all__ = [
     'Distribution',
@@ -47,11 +48,11 @@ __all__ = [
 class PackageNotFoundError(ModuleNotFoundError):
     """The package was not found."""
 
-    def __str__(self):
+    def __str__(self) -> str:
         return f"No package metadata was found for {self.name}"
 
     @property
-    def name(self):
+    def name(self) -> str:  # type: ignore[override]
         (name,) = self.args
         return name
 
@@ -117,38 +118,11 @@ class Sectioned:
             yield Pair(name, value)
 
     @staticmethod
-    def valid(line):
+    def valid(line: str):
         return line and not line.startswith('#')
 
 
-class DeprecatedTuple:
-    """
-    Provide subscript item access for backward compatibility.
-
-    >>> recwarn = getfixture('recwarn')
-    >>> ep = EntryPoint(name='name', value='value', group='group')
-    >>> ep[:]
-    ('name', 'value', 'group')
-    >>> ep[0]
-    'name'
-    >>> len(recwarn)
-    1
-    """
-
-    # Do not remove prior to 2023-05-01 or Python 3.13
-    _warn = functools.partial(
-        warnings.warn,
-        "EntryPoint tuple interface is deprecated. Access members by name.",
-        DeprecationWarning,
-        stacklevel=2,
-    )
-
-    def __getitem__(self, item):
-        self._warn()
-        return self._key()[item]
-
-
-class EntryPoint(DeprecatedTuple):
+class EntryPoint:
     """An entry point as defined by Python packaging conventions.
 
     See `the packaging docs on entry points
@@ -192,7 +166,7 @@ class EntryPoint(DeprecatedTuple):
 
     dist: Optional['Distribution'] = None
 
-    def __init__(self, name, value, group):
+    def __init__(self, name: str, value: str, group: str) -> None:
         vars(self).update(name=name, value=value, group=group)
 
     def load(self):
@@ -206,18 +180,21 @@ class EntryPoint(DeprecatedTuple):
         return functools.reduce(getattr, attrs, module)
 
     @property
-    def module(self):
+    def module(self) -> str:
         match = self.pattern.match(self.value)
+        assert match is not None
         return match.group('module')
 
     @property
-    def attr(self):
+    def attr(self) -> str:
         match = self.pattern.match(self.value)
+        assert match is not None
         return match.group('attr')
 
     @property
-    def extras(self):
+    def extras(self) -> List[str]:
         match = self.pattern.match(self.value)
+        assert match is not None
         return re.findall(r'\w+', match.group('extras') or '')
 
     def _for(self, dist):
@@ -265,7 +242,7 @@ class EntryPoint(DeprecatedTuple):
             f'group={self.group!r})'
         )
 
-    def __hash__(self):
+    def __hash__(self) -> int:
         return hash(self._key())
 
 
@@ -276,7 +253,7 @@ class EntryPoints(tuple):
 
     __slots__ = ()
 
-    def __getitem__(self, name):  # -> EntryPoint:
+    def __getitem__(self, name: str) -> EntryPoint:  # type: ignore[override]
         """
         Get the EntryPoint in self matching name.
         """
@@ -285,6 +262,13 @@ class EntryPoints(tuple):
         except StopIteration:
             raise KeyError(name)
 
+    def __repr__(self):
+        """
+        Repr with classname and tuple constructor to
+        signal that we deviate from regular tuple behavior.
+        """
+        return '%s(%r)' % (self.__class__.__name__, tuple(self))
+
     def select(self, **params):
         """
         Select entry points from self that match the
@@ -293,14 +277,14 @@ class EntryPoints(tuple):
         return EntryPoints(ep for ep in self if ep.matches(**params))
 
     @property
-    def names(self):
+    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):
+    def groups(self) -> Set[str]:
         """
         Return the set of all groups of all entry points.
         """
@@ -321,24 +305,28 @@ class EntryPoints(tuple):
 class PackagePath(pathlib.PurePosixPath):
     """A reference to a path in a package"""
 
-    def read_text(self, encoding='utf-8'):
+    hash: Optional["FileHash"]
+    size: int
+    dist: "Distribution"
+
+    def read_text(self, encoding: str = 'utf-8') -> str:  # type: ignore[override]
         with self.locate().open(encoding=encoding) as stream:
             return stream.read()
 
-    def read_binary(self):
+    def read_binary(self) -> bytes:
         with self.locate().open('rb') as stream:
             return stream.read()
 
-    def locate(self):
+    def locate(self) -> pathlib.Path:
         """Return a path-like object for this path"""
         return self.dist.locate_file(self)
 
 
 class FileHash:
-    def __init__(self, spec):
+    def __init__(self, spec: str) -> None:
         self.mode, _, self.value = spec.partition('=')
 
-    def __repr__(self):
+    def __repr__(self) -> str:
         return f'<FileHash mode: {self.mode} value: {self.value}>'
 
 
@@ -373,14 +361,14 @@ class Distribution(DeprecatedNonAbstract):
         """
 
     @abc.abstractmethod
-    def locate_file(self, path):
+    def locate_file(self, path: Union[str, os.PathLike[str]]) -> pathlib.Path:
         """
         Given a path to a file in this distribution, return a path
         to it.
         """
 
     @classmethod
-    def from_name(cls, name: str):
+    def from_name(cls, name: str) -> "Distribution":
         """Return the Distribution for the given package name.
 
         :param name: The name of the distribution package to search for.
@@ -393,12 +381,12 @@ class Distribution(DeprecatedNonAbstract):
         if not name:
             raise ValueError("A distribution name is required.")
         try:
-            return next(cls.discover(name=name))
+            return next(iter(cls.discover(name=name)))
         except StopIteration:
             raise PackageNotFoundError(name)
 
     @classmethod
-    def discover(cls, **kwargs):
+    def discover(cls, **kwargs) -> Iterable["Distribution"]:
         """Return an iterable of Distribution objects for all packages.
 
         Pass a ``context`` or pass keyword arguments for constructing
@@ -416,7 +404,7 @@ class Distribution(DeprecatedNonAbstract):
         )
 
     @staticmethod
-    def at(path):
+    def at(path: Union[str, os.PathLike[str]]) -> "Distribution":
         """Return a Distribution for the indicated metadata path
 
         :param path: a string or path-like object
@@ -451,7 +439,7 @@ class Distribution(DeprecatedNonAbstract):
         return _adapters.Message(email.message_from_string(text))
 
     @property
-    def name(self):
+    def name(self) -> str:
         """Return the 'Name' metadata for the distribution package."""
         return self.metadata['Name']
 
@@ -461,16 +449,16 @@ class Distribution(DeprecatedNonAbstract):
         return Prepared.normalize(self.name)
 
     @property
-    def version(self):
+    def version(self) -> str:
         """Return the 'Version' metadata for the distribution package."""
         return self.metadata['Version']
 
     @property
-    def entry_points(self):
+    def entry_points(self) -> EntryPoints:
         return EntryPoints._from_text_for(self.read_text('entry_points.txt'), self)
 
     @property
-    def files(self):
+    def files(self) -> Optional[List[PackagePath]]:
         """Files in this distribution.
 
         :return: List of PackagePath for this distribution or None
@@ -555,7 +543,7 @@ class Distribution(DeprecatedNonAbstract):
         return text and map('"{}"'.format, text.splitlines())
 
     @property
-    def requires(self):
+    def requires(self) -> Optional[List[str]]:
         """Generated requirements specified for this Distribution"""
         reqs = self._read_dist_info_reqs() or self._read_egg_info_reqs()
         return reqs and list(reqs)
@@ -606,6 +594,16 @@ class Distribution(DeprecatedNonAbstract):
             space = url_req_space(section.value)
             yield section.value + space + quoted_marker(section.name)
 
+    @property
+    def origin(self):
+        return self._load_json('direct_url.json')
+
+    def _load_json(self, filename):
+        return pass_none(json.loads)(
+            self.read_text(filename),
+            object_hook=lambda data: types.SimpleNamespace(**data),
+        )
+
 
 class DistributionFinder(MetaPathFinder):
     """
@@ -634,7 +632,7 @@ class DistributionFinder(MetaPathFinder):
             vars(self).update(kwargs)
 
         @property
-        def path(self):
+        def path(self) -> List[str]:
             """
             The sequence of directory path that a distribution finder
             should search.
@@ -645,7 +643,7 @@ class DistributionFinder(MetaPathFinder):
             return vars(self).get('path', sys.path)
 
     @abc.abstractmethod
-    def find_distributions(self, context=Context()):
+    def find_distributions(self, context=Context()) -> Iterable[Distribution]:
         """
         Find distributions.
 
@@ -774,7 +772,9 @@ class Prepared:
 
 class MetadataPathFinder(DistributionFinder):
     @classmethod
-    def find_distributions(cls, context=DistributionFinder.Context()):
+    def find_distributions(
+        cls, context=DistributionFinder.Context()
+    ) -> Iterable["PathDistribution"]:
         """
         Find distributions.
 
@@ -794,19 +794,19 @@ class MetadataPathFinder(DistributionFinder):
             path.search(prepared) for path in map(FastPath, paths)
         )
 
-    def invalidate_caches(cls):
+    def invalidate_caches(cls) -> None:
         FastPath.__new__.cache_clear()
 
 
 class PathDistribution(Distribution):
-    def __init__(self, path: SimplePath):
+    def __init__(self, path: SimplePath) -> None:
         """Construct a distribution.
 
         :param path: SimplePath indicating the metadata directory.
         """
         self._path = path
 
-    def read_text(self, filename):
+    def read_text(self, filename: Union[str, os.PathLike[str]]) -> Optional[str]:
         with suppress(
             FileNotFoundError,
             IsADirectoryError,
@@ -816,9 +816,11 @@ class PathDistribution(Distribution):
         ):
             return self._path.joinpath(filename).read_text(encoding='utf-8')
 
+        return None
+
     read_text.__doc__ = Distribution.read_text.__doc__
 
-    def locate_file(self, path):
+    def locate_file(self, path: Union[str, os.PathLike[str]]) -> pathlib.Path:
         return self._path.parent / path
 
     @property
@@ -851,7 +853,7 @@ class PathDistribution(Distribution):
         return name
 
 
-def distribution(distribution_name):
+def distribution(distribution_name: str) -> Distribution:
     """Get the ``Distribution`` instance for the named package.
 
     :param distribution_name: The name of the distribution package as a string.
@@ -860,7 +862,7 @@ def distribution(distribution_name):
     return Distribution.from_name(distribution_name)
 
 
-def distributions(**kwargs):
+def distributions(**kwargs) -> Iterable[Distribution]:
     """Get all ``Distribution`` instances in the current environment.
 
     :return: An iterable of ``Distribution`` instances.
@@ -868,7 +870,7 @@ def distributions(**kwargs):
     return Distribution.discover(**kwargs)
 
 
-def metadata(distribution_name) -> _meta.PackageMetadata:
+def metadata(distribution_name: str) -> _meta.PackageMetadata:
     """Get the metadata for the named package.
 
     :param distribution_name: The name of the distribution package to query.
@@ -877,7 +879,7 @@ def metadata(distribution_name) -> _meta.PackageMetadata:
     return Distribution.from_name(distribution_name).metadata
 
 
-def version(distribution_name):
+def version(distribution_name: str) -> str:
     """Get the version string for the named package.
 
     :param distribution_name: The name of the distribution package to query.
@@ -911,7 +913,7 @@ def entry_points(**params) -> EntryPoints:
     return EntryPoints(eps).select(**params)
 
 
-def files(distribution_name):
+def files(distribution_name: str) -> Optional[List[PackagePath]]:
     """Return a list of files for the named package.
 
     :param distribution_name: The name of the distribution package to query.
@@ -920,11 +922,11 @@ def files(distribution_name):
     return distribution(distribution_name).files
 
 
-def requires(distribution_name):
+def requires(distribution_name: str) -> Optional[List[str]]:
     """
     Return a list of requirements for the named package.
 
-    :return: An iterator of requirements, suitable for
+    :return: An iterable of requirements, suitable for
         packaging.requirement.Requirement.
     """
     return distribution(distribution_name).requires
@@ -951,13 +953,42 @@ def _top_level_declared(dist):
     return (dist.read_text('top_level.txt') or '').split()
 
 
+def _topmost(name: PackagePath) -> Optional[str]:
+    """
+    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:
+    """
+    Infer a possibly importable module name from a name presumed on
+    sys.path.
+
+    >>> _get_toplevel_name(PackagePath('foo.py'))
+    'foo'
+    >>> _get_toplevel_name(PackagePath('foo'))
+    'foo'
+    >>> _get_toplevel_name(PackagePath('foo.pyc'))
+    'foo'
+    >>> _get_toplevel_name(PackagePath('foo/__init__.py'))
+    'foo'
+    >>> _get_toplevel_name(PackagePath('foo.pth'))
+    'foo.pth'
+    >>> _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)
+    )
+
+
 def _top_level_inferred(dist):
-    opt_names = {
-        f.parts[0] if len(f.parts) > 1 else inspect.getmodulename(f)
-        for f in always_iterable(dist.files)
-    }
+    opt_names = set(map(_get_toplevel_name, always_iterable(dist.files)))
 
-    @pass_none
     def importable_name(name):
         return '.' not in name
 
index 6aed69a30857e422cce2b1431c732e3dc163542a..591168808953ba8ec54b47f14584a3ed7bc073f2 100644 (file)
@@ -53,7 +53,7 @@ class Message(email.message.Message):
     def __getitem__(self, item):
         """
         Warn users that a ``KeyError`` can be expected when a
-        mising key is supplied. Ref python/importlib_metadata#371.
+        missing key is supplied. Ref python/importlib_metadata#371.
         """
         res = super().__getitem__(item)
         if res is None:
index c9a7ef906a8a8c2889a5ef5f8df6cc0f34c49f8a..f670016de7fef207636726e51c2754846a157ca8 100644 (file)
@@ -49,7 +49,7 @@ class SimplePath(Protocol[_T]):
     A minimal subset of pathlib.Path required by PathDistribution.
     """
 
-    def joinpath(self) -> _T:
+    def joinpath(self, other: Union[str, _T]) -> _T:
         ...  # pragma: no cover
 
     def __truediv__(self, other: Union[str, _T]) -> _T:
diff --git a/Lib/importlib/metadata/diagnose.py b/Lib/importlib/metadata/diagnose.py
new file mode 100644 (file)
index 0000000..e405471
--- /dev/null
@@ -0,0 +1,21 @@
+import sys
+
+from . import Distribution
+
+
+def inspect(path):
+    print("Inspecting", path)
+    dists = list(Distribution.discover(path=[path]))
+    if not dists:
+        return
+    print("Found", len(dists), "packages:", end=' ')
+    print(', '.join(dist.name for dist in dists))
+
+
+def run():
+    for path in sys.path:
+        inspect(path)
+
+
+if __name__ == '__main__':
+    run()
index 71a704389b986ea8ae9c047aefd8da6fdddf047b..25c799fa44cd55676dcb866e26dfbefa8a677e94 100644 (file)
@@ -1,17 +1,18 @@
-# from jaraco.path 3.5
+# from jaraco.path 3.7
 
 import functools
 import pathlib
-from typing import Dict, Union
+from typing import Dict, Protocol, Union
+from typing import runtime_checkable
 
-try:
-    from typing import Protocol, runtime_checkable
-except ImportError:  # pragma: no cover
-    # Python 3.7
-    from typing_extensions import Protocol, runtime_checkable  # type: ignore
+
+class Symlink(str):
+    """
+    A string indicating the target of a symlink.
+    """
 
 
-FilesSpec = Dict[str, Union[str, bytes, 'FilesSpec']]  # type: ignore
+FilesSpec = Dict[str, Union[str, bytes, Symlink, 'FilesSpec']]  # type: ignore
 
 
 @runtime_checkable
@@ -28,6 +29,9 @@ class TreeMaker(Protocol):
     def write_bytes(self, content):
         ...  # pragma: no cover
 
+    def symlink_to(self, target):
+        ...  # pragma: no cover
+
 
 def _ensure_tree_maker(obj: Union[str, TreeMaker]) -> TreeMaker:
     return obj if isinstance(obj, TreeMaker) else pathlib.Path(obj)  # type: ignore
@@ -51,12 +55,16 @@ def build(
     ...             "__init__.py": "",
     ...         },
     ...         "baz.py": "# Some code",
-    ...     }
+    ...         "bar.py": Symlink("baz.py"),
+    ...     },
+    ...     "bing": Symlink("foo"),
     ... }
     >>> target = getfixture('tmp_path')
     >>> build(spec, target)
     >>> target.joinpath('foo/baz.py').read_text(encoding='utf-8')
     '# Some code'
+    >>> target.joinpath('bing/bar.py').read_text(encoding='utf-8')
+    '# Some code'
     """
     for name, contents in spec.items():
         create(contents, _ensure_tree_maker(prefix) / name)
@@ -79,8 +87,8 @@ def _(content: str, path):
 
 
 @create.register
-def _(content: str, path):
-    path.write_text(content, encoding='utf-8')
+def _(content: Symlink, path):
+    path.symlink_to(content)
 
 
 class Recording:
@@ -107,3 +115,6 @@ class Recording:
 
     def mkdir(self, **kwargs):
         return
+
+    def symlink_to(self, target):
+        pass
index 73e5da2ba9227914df47b89c234807b68840aee6..8c973356b5660d3ce134b7c4d25bb268009c2d43 100644 (file)
@@ -1,6 +1,7 @@
 import os
 import sys
 import copy
+import json
 import shutil
 import pathlib
 import tempfile
@@ -86,7 +87,15 @@ class OnSysPath(Fixtures):
         self.fixtures.enter_context(self.add_sys_path(self.site_dir))
 
 
-class DistInfoPkg(OnSysPath, SiteDir):
+class SiteBuilder(SiteDir):
+    def setUp(self):
+        super().setUp()
+        for cls in self.__class__.mro():
+            with contextlib.suppress(AttributeError):
+                build_files(cls.files, prefix=self.site_dir)
+
+
+class DistInfoPkg(OnSysPath, SiteBuilder):
     files: FilesSpec = {
         "distinfo_pkg-1.0.0.dist-info": {
             "METADATA": """
@@ -113,10 +122,6 @@ class DistInfoPkg(OnSysPath, SiteDir):
             """,
     }
 
-    def setUp(self):
-        super().setUp()
-        build_files(DistInfoPkg.files, self.site_dir)
-
     def make_uppercase(self):
         """
         Rewrite metadata with everything uppercase.
@@ -128,7 +133,28 @@ class DistInfoPkg(OnSysPath, SiteDir):
         build_files(files, self.site_dir)
 
 
-class DistInfoPkgWithDot(OnSysPath, SiteDir):
+class DistInfoPkgEditable(DistInfoPkg):
+    """
+    Package with a PEP 660 direct_url.json.
+    """
+
+    some_hash = '524127ce937f7cb65665130c695abd18ca386f60bb29687efb976faa1596fdcc'
+    files: FilesSpec = {
+        'distinfo_pkg-1.0.0.dist-info': {
+            'direct_url.json': json.dumps(
+                {
+                    "archive_info": {
+                        "hash": f"sha256={some_hash}",
+                        "hashes": {"sha256": f"{some_hash}"},
+                    },
+                    "url": "file:///path/to/distinfo_pkg-1.0.0.editable-py3-none-any.whl",
+                }
+            )
+        },
+    }
+
+
+class DistInfoPkgWithDot(OnSysPath, SiteBuilder):
     files: FilesSpec = {
         "pkg_dot-1.0.0.dist-info": {
             "METADATA": """
@@ -138,12 +164,8 @@ class DistInfoPkgWithDot(OnSysPath, SiteDir):
         },
     }
 
-    def setUp(self):
-        super().setUp()
-        build_files(DistInfoPkgWithDot.files, self.site_dir)
-
 
-class DistInfoPkgWithDotLegacy(OnSysPath, SiteDir):
+class DistInfoPkgWithDotLegacy(OnSysPath, SiteBuilder):
     files: FilesSpec = {
         "pkg.dot-1.0.0.dist-info": {
             "METADATA": """
@@ -159,18 +181,12 @@ class DistInfoPkgWithDotLegacy(OnSysPath, SiteDir):
         },
     }
 
-    def setUp(self):
-        super().setUp()
-        build_files(DistInfoPkgWithDotLegacy.files, self.site_dir)
-
 
-class DistInfoPkgOffPath(SiteDir):
-    def setUp(self):
-        super().setUp()
-        build_files(DistInfoPkg.files, self.site_dir)
+class DistInfoPkgOffPath(SiteBuilder):
+    files = DistInfoPkg.files
 
 
-class EggInfoPkg(OnSysPath, SiteDir):
+class EggInfoPkg(OnSysPath, SiteBuilder):
     files: FilesSpec = {
         "egginfo_pkg.egg-info": {
             "PKG-INFO": """
@@ -205,12 +221,8 @@ class EggInfoPkg(OnSysPath, SiteDir):
             """,
     }
 
-    def setUp(self):
-        super().setUp()
-        build_files(EggInfoPkg.files, prefix=self.site_dir)
 
-
-class EggInfoPkgPipInstalledNoToplevel(OnSysPath, SiteDir):
+class EggInfoPkgPipInstalledNoToplevel(OnSysPath, SiteBuilder):
     files: FilesSpec = {
         "egg_with_module_pkg.egg-info": {
             "PKG-INFO": "Name: egg_with_module-pkg",
@@ -240,12 +252,8 @@ class EggInfoPkgPipInstalledNoToplevel(OnSysPath, SiteDir):
             """,
     }
 
-    def setUp(self):
-        super().setUp()
-        build_files(EggInfoPkgPipInstalledNoToplevel.files, prefix=self.site_dir)
-
 
-class EggInfoPkgPipInstalledNoModules(OnSysPath, SiteDir):
+class EggInfoPkgPipInstalledNoModules(OnSysPath, SiteBuilder):
     files: FilesSpec = {
         "egg_with_no_modules_pkg.egg-info": {
             "PKG-INFO": "Name: egg_with_no_modules-pkg",
@@ -270,12 +278,8 @@ class EggInfoPkgPipInstalledNoModules(OnSysPath, SiteDir):
         },
     }
 
-    def setUp(self):
-        super().setUp()
-        build_files(EggInfoPkgPipInstalledNoModules.files, prefix=self.site_dir)
-
 
-class EggInfoPkgSourcesFallback(OnSysPath, SiteDir):
+class EggInfoPkgSourcesFallback(OnSysPath, SiteBuilder):
     files: FilesSpec = {
         "sources_fallback_pkg.egg-info": {
             "PKG-INFO": "Name: sources_fallback-pkg",
@@ -296,12 +300,8 @@ class EggInfoPkgSourcesFallback(OnSysPath, SiteDir):
             """,
     }
 
-    def setUp(self):
-        super().setUp()
-        build_files(EggInfoPkgSourcesFallback.files, prefix=self.site_dir)
 
-
-class EggInfoFile(OnSysPath, SiteDir):
+class EggInfoFile(OnSysPath, SiteBuilder):
     files: FilesSpec = {
         "egginfo_file.egg-info": """
             Metadata-Version: 1.0
@@ -317,10 +317,6 @@ class EggInfoFile(OnSysPath, SiteDir):
             """,
     }
 
-    def setUp(self):
-        super().setUp()
-        build_files(EggInfoFile.files, prefix=self.site_dir)
-
 
 # dedent all text strings before writing
 orig = _path.create.registry[str]
index 3b49227255eb58d10dabb9f387157659e19eff11..1d3817151edf640303bdab14e3ae2a992080382e 100644 (file)
@@ -12,6 +12,7 @@ except ImportError:
 
 from . import fixtures
 from ._context import suppress
+from ._path import Symlink
 from importlib.metadata import (
     Distribution,
     EntryPoint,
@@ -68,7 +69,7 @@ class BasicTests(fixtures.DistInfoPkg, unittest.TestCase):
         dict(name=''),
     )
     def test_invalid_inputs_to_from_name(self, name):
-        with self.assertRaises(ValueError):
+        with self.assertRaises(Exception):
             Distribution.from_name(name)
 
 
@@ -207,6 +208,20 @@ class DiscoveryTests(
         with self.assertRaises(ValueError):
             list(distributions(context='something', name='else'))
 
+    def test_interleaved_discovery(self):
+        """
+        Ensure interleaved searches are safe.
+
+        When the search is cached, it is possible for searches to be
+        interleaved, so make sure those use-cases are safe.
+
+        Ref #293
+        """
+        dists = distributions()
+        next(dists)
+        version('egginfo-pkg')
+        next(dists)
+
 
 class DirectoryTest(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
     def test_egg_info(self):
@@ -388,6 +403,27 @@ class PackagesDistributionsTest(
 
         assert not any(name.endswith('.dist-info') for name in distributions)
 
+    def test_packages_distributions_symlinked_top_level(self) -> None:
+        """
+        Distribution is resolvable from a simple top-level symlink in RECORD.
+        See #452.
+        """
+
+        files: fixtures.FilesSpec = {
+            "symlinked_pkg-1.0.0.dist-info": {
+                "METADATA": """
+                    Name: symlinked-pkg
+                    Version: 1.0.0
+                    """,
+                "RECORD": "symlinked,,\n",
+            },
+            ".symlink.target": {},
+            "symlinked": Symlink(".symlink.target"),
+        }
+
+        fixtures.build_files(files, self.site_dir)
+        assert packages_distributions()['symlinked'] == ['symlinked-pkg']
+
 
 class PackagesDistributionsEggTest(
     fixtures.EggInfoPkg,
@@ -424,3 +460,10 @@ class PackagesDistributionsEggTest(
         # sources_fallback-pkg has one import ('sources_fallback') inferred from
         # SOURCES.txt (top_level.txt and installed-files.txt is missing)
         assert import_names_from_package('sources_fallback-pkg') == {'sources_fallback'}
+
+
+class EditableDistributionTest(fixtures.DistInfoPkgEditable, unittest.TestCase):
+    def test_origin(self):
+        dist = Distribution.from_name('distinfo-pkg')
+        assert dist.origin.url.endswith('.whl')
+        assert dist.origin.archive_info.hashes.sha256
diff --git a/Misc/NEWS.d/next/Library/2023-12-15-09-51-41.gh-issue-113175.RHsNwE.rst b/Misc/NEWS.d/next/Library/2023-12-15-09-51-41.gh-issue-113175.RHsNwE.rst
new file mode 100644 (file)
index 0000000..1b43803
--- /dev/null
@@ -0,0 +1,5 @@
+Sync with importlib_metadata 7.0, including improved type annotations, fixed
+issue with symlinked packages in ``package_distributions``, added
+``EntryPoints.__repr__``, introduced the ``diagnose`` script, added
+``Distribution.origin`` property, and removed deprecated ``EntryPoint``
+access by numeric index (tuple behavior).