]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
Sync with importlib_metadata 6.5 (GH-103584)
authorJason R. Coombs <jaraco@jaraco.com>
Fri, 21 Apr 2023 02:12:48 +0000 (22:12 -0400)
committerGitHub <noreply@github.com>
Fri, 21 Apr 2023 02:12:48 +0000 (22:12 -0400)
Doc/library/importlib.metadata.rst
Lib/importlib/metadata/__init__.py
Lib/importlib/metadata/_adapters.py
Lib/importlib/metadata/_meta.py
Lib/test/test_importlib/_context.py [new file with mode: 0644]
Lib/test/test_importlib/_path.py [new file with mode: 0644]
Lib/test/test_importlib/fixtures.py
Lib/test/test_importlib/test_main.py
Lib/test/test_importlib/test_metadata_api.py
Misc/NEWS.d/next/Library/2023-04-16-19-48-21.gh-issue-103584.3mBTuM.rst [new file with mode: 0644]

index 6e084101995e2594cd9b75da944d5a506a9d4944..b306d5f55a714f22d2512674830bcab696cd98da 100644 (file)
@@ -308,6 +308,10 @@ Python module or `Import Package <https://packaging.python.org/en/latest/glossar
     >>> packages_distributions()
     {'importlib_metadata': ['importlib-metadata'], 'yaml': ['PyYAML'], 'jaraco': ['jaraco.classes', 'jaraco.functools'], ...}
 
+Some editable installs, `do not supply top-level names
+<https://github.com/pypa/packaging-problems/issues/609>`_, and thus this
+function is not reliable with such installs.
+
 .. versionadded:: 3.10
 
 .. _distributions:
index 40ab1a1aaac3289d1262f43a88360426047b2ca0..b8eb19d05dccaeb142318f030dcf4c8234cac2a5 100644 (file)
@@ -12,7 +12,9 @@ import warnings
 import functools
 import itertools
 import posixpath
+import contextlib
 import collections
+import inspect
 
 from . import _adapters, _meta
 from ._collections import FreezableDefaultDict, Pair
@@ -24,7 +26,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
+from typing import List, Mapping, Optional, cast
 
 
 __all__ = [
@@ -341,11 +343,30 @@ class FileHash:
         return f'<FileHash mode: {self.mode} value: {self.value}>'
 
 
-class Distribution:
+class DeprecatedNonAbstract:
+    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):
     """A Python distribution package."""
 
     @abc.abstractmethod
-    def read_text(self, filename):
+    def read_text(self, filename) -> Optional[str]:
         """Attempt to load metadata file given by the name.
 
         :param filename: The name of the file in the distribution info.
@@ -419,7 +440,7 @@ class Distribution:
         The returned object will have keys that name the various bits of
         metadata.  See PEP 566 for details.
         """
-        text = (
+        opt_text = (
             self.read_text('METADATA')
             or self.read_text('PKG-INFO')
             # This last clause is here to support old egg-info files.  Its
@@ -427,6 +448,7 @@ class Distribution:
             # (which points to the egg-info file) attribute unchanged.
             or self.read_text('')
         )
+        text = cast(str, opt_text)
         return _adapters.Message(email.message_from_string(text))
 
     @property
@@ -455,8 +477,8 @@ class Distribution:
         :return: List of PackagePath for this distribution or None
 
         Result is `None` if the metadata file that enumerates files
-        (i.e. RECORD for dist-info or SOURCES.txt for egg-info) is
-        missing.
+        (i.e. RECORD for dist-info, or installed-files.txt or
+        SOURCES.txt for egg-info) is missing.
         Result may be empty if the metadata exists but is empty.
         """
 
@@ -469,9 +491,19 @@ class Distribution:
 
         @pass_none
         def make_files(lines):
-            return list(starmap(make_file, csv.reader(lines)))
+            return starmap(make_file, csv.reader(lines))
 
-        return make_files(self._read_files_distinfo() or self._read_files_egginfo())
+        @pass_none
+        def skip_missing_files(package_paths):
+            return list(filter(lambda path: path.locate().exists(), package_paths))
+
+        return skip_missing_files(
+            make_files(
+                self._read_files_distinfo()
+                or self._read_files_egginfo_installed()
+                or self._read_files_egginfo_sources()
+            )
+        )
 
     def _read_files_distinfo(self):
         """
@@ -480,10 +512,43 @@ class Distribution:
         text = self.read_text('RECORD')
         return text and text.splitlines()
 
-    def _read_files_egginfo(self):
+    def _read_files_egginfo_installed(self):
+        """
+        Read installed-files.txt and return lines in a similar
+        CSV-parsable format as RECORD: each file must be placed
+        relative to the site-packages directory, and must also be
+        quoted (since file names can contain literal commas).
+
+        This file is written when the package is installed by pip,
+        but it might not be written for other installation methods.
+        Hence, even if we can assume that this file is accurate
+        when it exists, we cannot assume that it always exists.
         """
-        SOURCES.txt might contain literal commas, so wrap each line
-        in quotes.
+        text = self.read_text('installed-files.txt')
+        # We need to prepend the .egg-info/ subdir to the lines in this file.
+        # But this subdir is only available in the PathDistribution's self._path
+        # which is not easily accessible from this base class...
+        subdir = getattr(self, '_path', None)
+        if not text or not subdir:
+            return
+        with contextlib.suppress(Exception):
+            ret = [
+                str((subdir / line).resolve().relative_to(self.locate_file('')))
+                for line in text.splitlines()
+            ]
+            return map('"{}"'.format, ret)
+
+    def _read_files_egginfo_sources(self):
+        """
+        Read SOURCES.txt and return lines in a similar CSV-parsable
+        format as RECORD: each file name must be quoted (since it
+        might contain literal commas).
+
+        Note that SOURCES.txt is not a reliable source for what
+        files are installed by a package. This file is generated
+        for a source archive, and the files that are present
+        there (e.g. setup.py) may not correctly reflect the files
+        that are present after the package has been installed.
         """
         text = self.read_text('SOURCES.txt')
         return text and map('"{}"'.format, text.splitlines())
@@ -886,8 +951,13 @@ def _top_level_declared(dist):
 
 
 def _top_level_inferred(dist):
-    return {
-        f.parts[0] if len(f.parts) > 1 else f.with_suffix('').name
+    opt_names = {
+        f.parts[0] if len(f.parts) > 1 else inspect.getmodulename(f)
         for f in always_iterable(dist.files)
-        if f.suffix == ".py"
     }
+
+    @pass_none
+    def importable_name(name):
+        return '.' not in name
+
+    return filter(importable_name, opt_names)
index aa460d3eda50fbb174623a1b5bbca54645fd588a..6aed69a30857e422cce2b1431c732e3dc163542a 100644 (file)
@@ -1,3 +1,5 @@
+import functools
+import warnings
 import re
 import textwrap
 import email.message
@@ -5,6 +7,15 @@ import email.message
 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(
@@ -39,6 +50,16 @@ class Message(email.message.Message):
     def __iter__(self):
         return super().__iter__()
 
+    def __getitem__(self, item):
+        """
+        Warn users that a ``KeyError`` can be expected when a
+        mising key is supplied. Ref python/importlib_metadata#371.
+        """
+        res = super().__getitem__(item)
+        if res is None:
+            _warn()
+        return res
+
     def _repair_headers(self):
         def redent(value):
             "Correct for RFC822 indentation"
index d5c0576194ece238b6062b97dcdea443c5630bc8..c9a7ef906a8a8c2889a5ef5f8df6cc0f34c49f8a 100644 (file)
@@ -1,4 +1,5 @@
-from typing import Any, Dict, Iterator, List, Protocol, TypeVar, Union
+from typing import Protocol
+from typing import Any, Dict, Iterator, List, Optional, TypeVar, Union, overload
 
 
 _T = TypeVar("_T")
@@ -17,7 +18,21 @@ class PackageMetadata(Protocol):
     def __iter__(self) -> Iterator[str]:
         ...  # pragma: no cover
 
-    def get_all(self, name: str, failobj: _T = ...) -> Union[List[Any], _T]:
+    @overload
+    def get(self, name: str, failobj: None = None) -> Optional[str]:
+        ...  # pragma: no cover
+
+    @overload
+    def get(self, name: str, failobj: _T) -> Union[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
+
+    @overload
+    def get_all(self, name: str, failobj: _T) -> Union[List[Any], _T]:
         """
         Return all values associated with a possibly multi-valued key.
         """
@@ -29,18 +44,19 @@ class PackageMetadata(Protocol):
         """
 
 
-class SimplePath(Protocol):
+class SimplePath(Protocol[_T]):
     """
     A minimal subset of pathlib.Path required by PathDistribution.
     """
 
-    def joinpath(self) -> 'SimplePath':
+    def joinpath(self) -> _T:
         ...  # pragma: no cover
 
-    def __truediv__(self) -> 'SimplePath':
+    def __truediv__(self, other: Union[str, _T]) -> _T:
         ...  # pragma: no cover
 
-    def parent(self) -> 'SimplePath':
+    @property
+    def parent(self) -> _T:
         ...  # pragma: no cover
 
     def read_text(self) -> str:
diff --git a/Lib/test/test_importlib/_context.py b/Lib/test/test_importlib/_context.py
new file mode 100644 (file)
index 0000000..8a53eb5
--- /dev/null
@@ -0,0 +1,13 @@
+import contextlib
+
+
+# from jaraco.context 4.3
+class suppress(contextlib.suppress, contextlib.ContextDecorator):
+    """
+    A version of contextlib.suppress with decorator support.
+
+    >>> @suppress(KeyError)
+    ... def key_error():
+    ...     {}['']
+    >>> key_error()
+    """
diff --git a/Lib/test/test_importlib/_path.py b/Lib/test/test_importlib/_path.py
new file mode 100644 (file)
index 0000000..71a7043
--- /dev/null
@@ -0,0 +1,109 @@
+# from jaraco.path 3.5
+
+import functools
+import pathlib
+from typing import Dict, Union
+
+try:
+    from typing import Protocol, runtime_checkable
+except ImportError:  # pragma: no cover
+    # Python 3.7
+    from typing_extensions import Protocol, runtime_checkable  # type: ignore
+
+
+FilesSpec = Dict[str, Union[str, bytes, 'FilesSpec']]  # type: ignore
+
+
+@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 _ensure_tree_maker(obj: Union[str, TreeMaker]) -> TreeMaker:
+    return obj if isinstance(obj, TreeMaker) else pathlib.Path(obj)  # type: ignore
+
+
+def build(
+    spec: FilesSpec,
+    prefix: Union[str, TreeMaker] = pathlib.Path(),  # type: ignore
+):
+    """
+    Build a set of files/directories, as described by the spec.
+
+    Each key represents a pathname, and the value represents
+    the content. Content may be a nested directory.
+
+    >>> spec = {
+    ...     'README.txt': "A README file",
+    ...     "foo": {
+    ...         "__init__.py": "",
+    ...         "bar": {
+    ...             "__init__.py": "",
+    ...         },
+    ...         "baz.py": "# Some code",
+    ...     }
+    ... }
+    >>> target = getfixture('tmp_path')
+    >>> build(spec, target)
+    >>> target.joinpath('foo/baz.py').read_text(encoding='utf-8')
+    '# Some code'
+    """
+    for name, contents in spec.items():
+        create(contents, _ensure_tree_maker(prefix) / name)
+
+
+@functools.singledispatch
+def create(content: Union[str, bytes, FilesSpec], path):
+    path.mkdir(exist_ok=True)
+    build(content, prefix=path)  # type: ignore
+
+
+@create.register
+def _(content: bytes, path):
+    path.write_bytes(content)
+
+
+@create.register
+def _(content: str, path):
+    path.write_text(content, encoding='utf-8')
+
+
+@create.register
+def _(content: str, path):
+    path.write_text(content, encoding='utf-8')
+
+
+class Recording:
+    """
+    A TreeMaker object that records everything that would be written.
+
+    >>> r = Recording()
+    >>> build({'foo': {'foo1.txt': 'yes'}, 'bar.txt': 'abc'}, r)
+    >>> r.record
+    ['foo/foo1.txt', 'bar.txt']
+    """
+
+    def __init__(self, loc=pathlib.PurePosixPath(), record=None):
+        self.loc = loc
+        self.record = record if record is not None else []
+
+    def __truediv__(self, other):
+        return Recording(self.loc / other, self.record)
+
+    def write_text(self, content, **kwargs):
+        self.record.append(str(self.loc))
+
+    write_bytes = write_text
+
+    def mkdir(self, **kwargs):
+        return
index e7be77b3957c67067e1c1b561939695e2424c937..a364a977bce781edeb957c1ee0a73ce460fa8298 100644 (file)
@@ -10,7 +10,10 @@ import contextlib
 
 from test.support.os_helper import FS_NONASCII
 from test.support import requires_zlib
-from typing import Dict, Union
+
+from . import _path
+from ._path import FilesSpec
+
 
 try:
     from importlib import resources  # type: ignore
@@ -83,13 +86,8 @@ class OnSysPath(Fixtures):
         self.fixtures.enter_context(self.add_sys_path(self.site_dir))
 
 
-# Except for python/mypy#731, prefer to define
-# FilesDef = Dict[str, Union['FilesDef', str]]
-FilesDef = Dict[str, Union[Dict[str, Union[Dict[str, str], str]], str]]
-
-
 class DistInfoPkg(OnSysPath, SiteDir):
-    files: FilesDef = {
+    files: FilesSpec = {
         "distinfo_pkg-1.0.0.dist-info": {
             "METADATA": """
                 Name: distinfo-pkg
@@ -131,7 +129,7 @@ class DistInfoPkg(OnSysPath, SiteDir):
 
 
 class DistInfoPkgWithDot(OnSysPath, SiteDir):
-    files: FilesDef = {
+    files: FilesSpec = {
         "pkg_dot-1.0.0.dist-info": {
             "METADATA": """
                 Name: pkg.dot
@@ -146,7 +144,7 @@ class DistInfoPkgWithDot(OnSysPath, SiteDir):
 
 
 class DistInfoPkgWithDotLegacy(OnSysPath, SiteDir):
-    files: FilesDef = {
+    files: FilesSpec = {
         "pkg.dot-1.0.0.dist-info": {
             "METADATA": """
                 Name: pkg.dot
@@ -173,7 +171,7 @@ class DistInfoPkgOffPath(SiteDir):
 
 
 class EggInfoPkg(OnSysPath, SiteDir):
-    files: FilesDef = {
+    files: FilesSpec = {
         "egginfo_pkg.egg-info": {
             "PKG-INFO": """
                 Name: egginfo-pkg
@@ -212,8 +210,99 @@ class EggInfoPkg(OnSysPath, SiteDir):
         build_files(EggInfoPkg.files, prefix=self.site_dir)
 
 
+class EggInfoPkgPipInstalledNoToplevel(OnSysPath, SiteDir):
+    files: FilesSpec = {
+        "egg_with_module_pkg.egg-info": {
+            "PKG-INFO": "Name: egg_with_module-pkg",
+            # SOURCES.txt is made from the source archive, and contains files
+            # (setup.py) that are not present after installation.
+            "SOURCES.txt": """
+                egg_with_module.py
+                setup.py
+                egg_with_module_pkg.egg-info/PKG-INFO
+                egg_with_module_pkg.egg-info/SOURCES.txt
+                egg_with_module_pkg.egg-info/top_level.txt
+            """,
+            # installed-files.txt is written by pip, and is a strictly more
+            # accurate source than SOURCES.txt as to the installed contents of
+            # the package.
+            "installed-files.txt": """
+                ../egg_with_module.py
+                PKG-INFO
+                SOURCES.txt
+                top_level.txt
+            """,
+            # missing top_level.txt (to trigger fallback to installed-files.txt)
+        },
+        "egg_with_module.py": """
+            def main():
+                print("hello world")
+            """,
+    }
+
+    def setUp(self):
+        super().setUp()
+        build_files(EggInfoPkgPipInstalledNoToplevel.files, prefix=self.site_dir)
+
+
+class EggInfoPkgPipInstalledNoModules(OnSysPath, SiteDir):
+    files: FilesSpec = {
+        "egg_with_no_modules_pkg.egg-info": {
+            "PKG-INFO": "Name: egg_with_no_modules-pkg",
+            # SOURCES.txt is made from the source archive, and contains files
+            # (setup.py) that are not present after installation.
+            "SOURCES.txt": """
+                setup.py
+                egg_with_no_modules_pkg.egg-info/PKG-INFO
+                egg_with_no_modules_pkg.egg-info/SOURCES.txt
+                egg_with_no_modules_pkg.egg-info/top_level.txt
+            """,
+            # installed-files.txt is written by pip, and is a strictly more
+            # accurate source than SOURCES.txt as to the installed contents of
+            # the package.
+            "installed-files.txt": """
+                PKG-INFO
+                SOURCES.txt
+                top_level.txt
+            """,
+            # top_level.txt correctly reflects that no modules are installed
+            "top_level.txt": b"\n",
+        },
+    }
+
+    def setUp(self):
+        super().setUp()
+        build_files(EggInfoPkgPipInstalledNoModules.files, prefix=self.site_dir)
+
+
+class EggInfoPkgSourcesFallback(OnSysPath, SiteDir):
+    files: FilesSpec = {
+        "sources_fallback_pkg.egg-info": {
+            "PKG-INFO": "Name: sources_fallback-pkg",
+            # SOURCES.txt is made from the source archive, and contains files
+            # (setup.py) that are not present after installation.
+            "SOURCES.txt": """
+                sources_fallback.py
+                setup.py
+                sources_fallback_pkg.egg-info/PKG-INFO
+                sources_fallback_pkg.egg-info/SOURCES.txt
+            """,
+            # missing installed-files.txt (i.e. not installed by pip) and
+            # missing top_level.txt (to trigger fallback to SOURCES.txt)
+        },
+        "sources_fallback.py": """
+            def main():
+                print("hello world")
+            """,
+    }
+
+    def setUp(self):
+        super().setUp()
+        build_files(EggInfoPkgSourcesFallback.files, prefix=self.site_dir)
+
+
 class EggInfoFile(OnSysPath, SiteDir):
-    files: FilesDef = {
+    files: FilesSpec = {
         "egginfo_file.egg-info": """
             Metadata-Version: 1.0
             Name: egginfo_file
@@ -233,38 +322,22 @@ class EggInfoFile(OnSysPath, SiteDir):
         build_files(EggInfoFile.files, prefix=self.site_dir)
 
 
-def build_files(file_defs, prefix=pathlib.Path()):
-    """Build a set of files/directories, as described by the
+# dedent all text strings before writing
+orig = _path.create.registry[str]
+_path.create.register(str, lambda content, path: orig(DALS(content), path))
 
-    file_defs dictionary.  Each key/value pair in the dictionary is
-    interpreted as a filename/contents pair.  If the contents value is a
-    dictionary, a directory is created, and the dictionary interpreted
-    as the files within it, recursively.
 
-    For example:
+build_files = _path.build
 
-    {"README.txt": "A README file",
-     "foo": {
-        "__init__.py": "",
-        "bar": {
-            "__init__.py": "",
-        },
-        "baz.py": "# Some code",
-     }
-    }
-    """
-    for name, contents in file_defs.items():
-        full_name = prefix / name
-        if isinstance(contents, dict):
-            full_name.mkdir()
-            build_files(contents, prefix=full_name)
-        else:
-            if isinstance(contents, bytes):
-                with full_name.open('wb') as f:
-                    f.write(contents)
-            else:
-                with full_name.open('w', encoding='utf-8') as f:
-                    f.write(DALS(contents))
+
+def build_record(file_defs):
+    return ''.join(f'{name},,\n' for name in record_names(file_defs))
+
+
+def record_names(file_defs):
+    recording = _path.Recording()
+    _path.build(file_defs, recording)
+    return recording.record
 
 
 class FileBuilder:
index 30b68b6ae7d86edfcfa85103e14d4840faf805c2..46cd2b696d4cc8c8e9d99d16d8880ef09648f4fa 100644 (file)
@@ -1,7 +1,10 @@
 import re
 import pickle
 import unittest
+import warnings
 import importlib.metadata
+import contextlib
+import itertools
 
 try:
     import pyfakefs.fake_filesystem_unittest as ffs
@@ -9,6 +12,7 @@ except ImportError:
     from .stubs import fake_filesystem_unittest as ffs
 
 from . import fixtures
+from ._context import suppress
 from importlib.metadata import (
     Distribution,
     EntryPoint,
@@ -22,6 +26,13 @@ from importlib.metadata import (
 )
 
 
+@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):
     version_pattern = r'\d+\.\d+(\.\d)?'
 
@@ -37,7 +48,7 @@ class BasicTests(fixtures.DistInfoPkg, unittest.TestCase):
     def test_package_not_found_mentions_metadata(self):
         """
         When a package is not found, that could indicate that the
-        packgae is not installed or that it is installed without
+        package is not installed or that it is installed without
         metadata. Ensure the exception mentions metadata to help
         guide users toward the cause. See #124.
         """
@@ -46,8 +57,12 @@ class BasicTests(fixtures.DistInfoPkg, unittest.TestCase):
 
         assert "metadata" in str(ctx.exception)
 
-    def test_new_style_classes(self):
-        self.assertIsInstance(Distribution, type)
+    # expected to fail until ABC is enforced
+    @suppress(AssertionError)
+    @suppress_known_deprecation()
+    def test_abc_enforced(self):
+        with self.assertRaises(TypeError):
+            type('DistributionSubclass', (Distribution,), {})()
 
     @fixtures.parameterize(
         dict(name=None),
@@ -172,11 +187,21 @@ class NonASCIITests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
         assert meta['Description'] == 'pôrˈtend'
 
 
-class DiscoveryTests(fixtures.EggInfoPkg, fixtures.DistInfoPkg, unittest.TestCase):
+class DiscoveryTests(
+    fixtures.EggInfoPkg,
+    fixtures.EggInfoPkgPipInstalledNoToplevel,
+    fixtures.EggInfoPkgPipInstalledNoModules,
+    fixtures.EggInfoPkgSourcesFallback,
+    fixtures.DistInfoPkg,
+    unittest.TestCase,
+):
     def test_package_discovery(self):
         dists = list(distributions())
         assert all(isinstance(dist, Distribution) for dist in dists)
         assert any(dist.metadata['Name'] == 'egginfo-pkg' for dist in dists)
+        assert any(dist.metadata['Name'] == 'egg_with_module-pkg' for dist in dists)
+        assert any(dist.metadata['Name'] == 'egg_with_no_modules-pkg' for dist in dists)
+        assert any(dist.metadata['Name'] == 'sources_fallback-pkg' for dist in dists)
         assert any(dist.metadata['Name'] == 'distinfo-pkg' for dist in dists)
 
     def test_invalid_usage(self):
@@ -324,3 +349,79 @@ class PackagesDistributionsTest(
             prefix=self.site_dir,
         )
         packages_distributions()
+
+    def test_packages_distributions_all_module_types(self):
+        """
+        Test top-level modules detected on a package without 'top-level.txt'.
+        """
+        suffixes = importlib.machinery.all_suffixes()
+        metadata = dict(
+            METADATA="""
+                Name: all_distributions
+                Version: 1.0.0
+                """,
+        )
+        files = {
+            'all_distributions-1.0.0.dist-info': metadata,
+        }
+        for i, suffix in enumerate(suffixes):
+            files.update(
+                {
+                    f'importable-name {i}{suffix}': '',
+                    f'in_namespace_{i}': {
+                        f'mod{suffix}': '',
+                    },
+                    f'in_package_{i}': {
+                        '__init__.py': '',
+                        f'mod{suffix}': '',
+                    },
+                }
+            )
+        metadata.update(RECORD=fixtures.build_record(files))
+        fixtures.build_files(files, prefix=self.site_dir)
+
+        distributions = packages_distributions()
+
+        for i in range(len(suffixes)):
+            assert distributions[f'importable-name {i}'] == ['all_distributions']
+            assert distributions[f'in_namespace_{i}'] == ['all_distributions']
+            assert distributions[f'in_package_{i}'] == ['all_distributions']
+
+        assert not any(name.endswith('.dist-info') for name in distributions)
+
+
+class PackagesDistributionsEggTest(
+    fixtures.EggInfoPkg,
+    fixtures.EggInfoPkgPipInstalledNoToplevel,
+    fixtures.EggInfoPkgPipInstalledNoModules,
+    fixtures.EggInfoPkgSourcesFallback,
+    unittest.TestCase,
+):
+    def test_packages_distributions_on_eggs(self):
+        """
+        Test old-style egg packages with a variation of 'top_level.txt',
+        'SOURCES.txt', and 'installed-files.txt', available.
+        """
+        distributions = packages_distributions()
+
+        def import_names_from_package(package_name):
+            return {
+                import_name
+                for import_name, package_names in distributions.items()
+                if package_name in package_names
+            }
+
+        # egginfo-pkg declares one import ('mod') via top_level.txt
+        assert import_names_from_package('egginfo-pkg') == {'mod'}
+
+        # egg_with_module-pkg has one import ('egg_with_module') inferred from
+        # installed-files.txt (top_level.txt is missing)
+        assert import_names_from_package('egg_with_module-pkg') == {'egg_with_module'}
+
+        # egg_with_no_modules-pkg should not be associated with any import names
+        # (top_level.txt is empty, and installed-files.txt has no .py files)
+        assert import_names_from_package('egg_with_no_modules-pkg') == set()
+
+        # 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'}
index 71c47e62d271248b4acf9a88ebd9862c3c9eb2c6..33c6e85ee94753503e8f6fbb853bff7f5e59a838 100644 (file)
@@ -27,12 +27,14 @@ def suppress_known_deprecation():
 
 class APITests(
     fixtures.EggInfoPkg,
+    fixtures.EggInfoPkgPipInstalledNoToplevel,
+    fixtures.EggInfoPkgPipInstalledNoModules,
+    fixtures.EggInfoPkgSourcesFallback,
     fixtures.DistInfoPkg,
     fixtures.DistInfoPkgWithDot,
     fixtures.EggInfoFile,
     unittest.TestCase,
 ):
-
     version_pattern = r'\d+\.\d+(\.\d)?'
 
     def test_retrieves_version_of_self(self):
@@ -63,15 +65,28 @@ class APITests(
                     distribution(prefix)
 
     def test_for_top_level(self):
-        self.assertEqual(
-            distribution('egginfo-pkg').read_text('top_level.txt').strip(), 'mod'
-        )
+        tests = [
+            ('egginfo-pkg', 'mod'),
+            ('egg_with_no_modules-pkg', ''),
+        ]
+        for pkg_name, expect_content in tests:
+            with self.subTest(pkg_name):
+                self.assertEqual(
+                    distribution(pkg_name).read_text('top_level.txt').strip(),
+                    expect_content,
+                )
 
     def test_read_text(self):
-        top_level = [
-            path for path in files('egginfo-pkg') if path.name == 'top_level.txt'
-        ][0]
-        self.assertEqual(top_level.read_text(), 'mod\n')
+        tests = [
+            ('egginfo-pkg', 'mod\n'),
+            ('egg_with_no_modules-pkg', '\n'),
+        ]
+        for pkg_name, expect_content in tests:
+            with self.subTest(pkg_name):
+                top_level = [
+                    path for path in files(pkg_name) if path.name == 'top_level.txt'
+                ][0]
+                self.assertEqual(top_level.read_text(), expect_content)
 
     def test_entry_points(self):
         eps = entry_points()
@@ -137,6 +152,28 @@ class APITests(
         classifiers = md.get_all('Classifier')
         assert 'Topic :: Software Development :: Libraries' in classifiers
 
+    def test_missing_key_legacy(self):
+        """
+        Requesting a missing key will still return None, but warn.
+        """
+        md = metadata('distinfo-pkg')
+        with suppress_known_deprecation():
+            assert md['does-not-exist'] is None
+
+    def test_get_key(self):
+        """
+        Getting a key gets the key.
+        """
+        md = metadata('egginfo-pkg')
+        assert md.get('Name') == 'egginfo-pkg'
+
+    def test_get_missing_key(self):
+        """
+        Requesting a missing key will return None.
+        """
+        md = metadata('distinfo-pkg')
+        assert md.get('does-not-exist') is None
+
     @staticmethod
     def _test_files(files):
         root = files[0].root
@@ -159,6 +196,9 @@ class APITests(
 
     def test_files_egg_info(self):
         self._test_files(files('egginfo-pkg'))
+        self._test_files(files('egg_with_module-pkg'))
+        self._test_files(files('egg_with_no_modules-pkg'))
+        self._test_files(files('sources_fallback-pkg'))
 
     def test_version_egg_info_file(self):
         self.assertEqual(version('egginfo-file'), '0.1')
diff --git a/Misc/NEWS.d/next/Library/2023-04-16-19-48-21.gh-issue-103584.3mBTuM.rst b/Misc/NEWS.d/next/Library/2023-04-16-19-48-21.gh-issue-103584.3mBTuM.rst
new file mode 100644 (file)
index 0000000..6d7c93a
--- /dev/null
@@ -0,0 +1,12 @@
+Updated ``importlib.metadata`` with changes from ``importlib_metadata`` 5.2
+through 6.5.0, including: Support ``installed-files.txt`` for
+``Distribution.files`` when present. ``PackageMetadata`` now stipulates an
+additional ``get`` method allowing for easy querying of metadata keys that
+may not be present. ``packages_distributions`` now honors packages and
+modules with Python modules that not ``.py`` sources (e.g. ``.pyc``,
+``.so``). Expand protocol for ``PackageMetadata.get_all`` to match the
+upstream implementation of ``email.message.Message.get_all`` in
+python/typeshed#9620. Deprecated use of ``Distribution`` without defining
+abstract methods. Deprecated expectation that
+``PackageMetadata.__getitem__`` will return ``None`` for missing keys. In
+the future, it will raise a ``KeyError``.