[Python-checkins] bpo-42382: In importlib.metadata, `EntryPoint` objects now expose `dist` (#23758)

jaraco webhook-mailer at python.org
Thu Dec 31 12:57:09 EST 2020


https://github.com/python/cpython/commit/dfdca85dfa64e72df385b3a486f85b773fc0f135
commit: dfdca85dfa64e72df385b3a486f85b773fc0f135
branch: master
author: Jason R. Coombs <jaraco at jaraco.com>
committer: jaraco <jaraco at jaraco.com>
date: 2020-12-31T12:56:43-05:00
summary:

bpo-42382: In importlib.metadata, `EntryPoint` objects now expose `dist` (#23758)

* bpo-42382: In importlib.metadata, `EntryPoint` objects now expose a `.dist` object referencing the `Distribution` when constructed from a `Distribution`.

Also, sync importlib_metadata 3.3:

- Add support for package discovery under package normalization rules.
- The object returned by `metadata()` now has a formally-defined protocol called `PackageMetadata` with declared support for the `.get_all()` method.

* Add blurb

* Remove latent footnote.

files:
A Misc/NEWS.d/next/Library/2020-12-13-22-05-35.bpo-42382.2YtKo5.rst
M Doc/library/importlib.metadata.rst
M Lib/importlib/metadata.py
M Lib/test/test_importlib/fixtures.py
M Lib/test/test_importlib/test_main.py
M Lib/test/test_importlib/test_metadata_api.py
M Lib/test/test_importlib/test_zip.py

diff --git a/Doc/library/importlib.metadata.rst b/Doc/library/importlib.metadata.rst
index 21da143f3bebf..858ed0a483874 100644
--- a/Doc/library/importlib.metadata.rst
+++ b/Doc/library/importlib.metadata.rst
@@ -115,8 +115,9 @@ Every distribution includes some metadata, which you can extract using the
 
     >>> wheel_metadata = metadata('wheel')  # doctest: +SKIP
 
-The keys of the returned data structure [#f1]_ name the metadata keywords, and
-their values are returned unparsed from the distribution metadata::
+The keys of the returned data structure, a ``PackageMetadata``,
+name the metadata keywords, and
+the values are returned unparsed from the distribution metadata::
 
     >>> wheel_metadata['Requires-Python']  # doctest: +SKIP
     '>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*'
@@ -259,9 +260,3 @@ a custom finder, return instances of this derived ``Distribution`` in the
 
 
 .. rubric:: Footnotes
-
-.. [#f1] Technically, the returned distribution metadata object is an
-         :class:`email.message.EmailMessage`
-         instance, but this is an implementation detail, and not part of the
-         stable API.  You should only use dictionary-like methods and syntax
-         to access the metadata contents.
diff --git a/Lib/importlib/metadata.py b/Lib/importlib/metadata.py
index 302d61d505cb3..36bb42ee21d9c 100644
--- a/Lib/importlib/metadata.py
+++ b/Lib/importlib/metadata.py
@@ -1,4 +1,3 @@
-import io
 import os
 import re
 import abc
@@ -18,6 +17,7 @@
 from importlib import import_module
 from importlib.abc import MetaPathFinder
 from itertools import starmap
+from typing import Any, List, Optional, Protocol, TypeVar, Union
 
 
 __all__ = [
@@ -31,7 +31,7 @@
     'metadata',
     'requires',
     'version',
-    ]
+]
 
 
 class PackageNotFoundError(ModuleNotFoundError):
@@ -43,7 +43,7 @@ def __str__(self):
 
     @property
     def name(self):
-        name, = self.args
+        (name,) = self.args
         return name
 
 
@@ -60,7 +60,7 @@ class EntryPoint(
         r'(?P<module>[\w.]+)\s*'
         r'(:\s*(?P<attr>[\w.]+))?\s*'
         r'(?P<extras>\[.*\])?\s*$'
-        )
+    )
     """
     A regular expression describing the syntax for an entry point,
     which might look like:
@@ -77,6 +77,8 @@ class EntryPoint(
     following the attr, and following any extras.
     """
 
+    dist: Optional['Distribution'] = None
+
     def load(self):
         """Load the entry point from its definition. If only a module
         is indicated by the value, return that module. Otherwise,
@@ -104,23 +106,27 @@ def extras(self):
 
     @classmethod
     def _from_config(cls, config):
-        return [
+        return (
             cls(name, value, group)
             for group in config.sections()
             for name, value in config.items(group)
-            ]
+        )
 
     @classmethod
     def _from_text(cls, text):
         config = ConfigParser(delimiters='=')
         # case sensitive: https://stackoverflow.com/q/1611799/812183
         config.optionxform = str
-        try:
-            config.read_string(text)
-        except AttributeError:  # pragma: nocover
-            # Python 2 has no read_string
-            config.readfp(io.StringIO(text))
-        return EntryPoint._from_config(config)
+        config.read_string(text)
+        return cls._from_config(config)
+
+    @classmethod
+    def _from_text_for(cls, text, dist):
+        return (ep._for(dist) for ep in cls._from_text(text))
+
+    def _for(self, dist):
+        self.dist = dist
+        return self
 
     def __iter__(self):
         """
@@ -132,7 +138,7 @@ def __reduce__(self):
         return (
             self.__class__,
             (self.name, self.value, self.group),
-            )
+        )
 
 
 class PackagePath(pathlib.PurePosixPath):
@@ -159,6 +165,25 @@ def __repr__(self):
         return '<FileHash mode: {} value: {}>'.format(self.mode, self.value)
 
 
+_T = TypeVar("_T")
+
+
+class PackageMetadata(Protocol):
+    def __len__(self) -> int:
+        ...  # pragma: no cover
+
+    def __contains__(self, item: str) -> bool:
+        ...  # pragma: no cover
+
+    def __getitem__(self, key: str) -> str:
+        ...  # pragma: no cover
+
+    def get_all(self, name: str, failobj: _T = ...) -> Union[List[Any], _T]:
+        """
+        Return all values associated with a possibly multi-valued key.
+        """
+
+
 class Distribution:
     """A Python distribution package."""
 
@@ -210,9 +235,8 @@ def discover(cls, **kwargs):
             raise ValueError("cannot accept context and kwargs")
         context = context or DistributionFinder.Context(**kwargs)
         return itertools.chain.from_iterable(
-            resolver(context)
-            for resolver in cls._discover_resolvers()
-            )
+            resolver(context) for resolver in cls._discover_resolvers()
+        )
 
     @staticmethod
     def at(path):
@@ -227,24 +251,24 @@ def at(path):
     def _discover_resolvers():
         """Search the meta_path for resolvers."""
         declared = (
-            getattr(finder, 'find_distributions', None)
-            for finder in sys.meta_path
-            )
+            getattr(finder, 'find_distributions', None) for finder in sys.meta_path
+        )
         return filter(None, declared)
 
     @classmethod
     def _local(cls, root='.'):
         from pep517 import build, meta
+
         system = build.compat_system(root)
         builder = functools.partial(
             meta.build,
             source_dir=root,
             system=system,
-            )
+        )
         return PathDistribution(zipfile.Path(meta.build_as_zip(builder)))
 
     @property
-    def metadata(self):
+    def metadata(self) -> PackageMetadata:
         """Return the parsed metadata for this Distribution.
 
         The returned object will have keys that name the various bits of
@@ -257,9 +281,14 @@ def metadata(self):
             # effect is to just end up using the PathDistribution's self._path
             # (which points to the egg-info file) attribute unchanged.
             or self.read_text('')
-            )
+        )
         return email.message_from_string(text)
 
+    @property
+    def name(self):
+        """Return the 'Name' metadata for the distribution package."""
+        return self.metadata['Name']
+
     @property
     def version(self):
         """Return the 'Version' metadata for the distribution package."""
@@ -267,7 +296,7 @@ def version(self):
 
     @property
     def entry_points(self):
-        return EntryPoint._from_text(self.read_text('entry_points.txt'))
+        return list(EntryPoint._from_text_for(self.read_text('entry_points.txt'), self))
 
     @property
     def files(self):
@@ -324,9 +353,10 @@ def _deps_from_requires_text(cls, source):
         section_pairs = cls._read_sections(source.splitlines())
         sections = {
             section: list(map(operator.itemgetter('line'), results))
-            for section, results in
-            itertools.groupby(section_pairs, operator.itemgetter('section'))
-            }
+            for section, results in itertools.groupby(
+                section_pairs, operator.itemgetter('section')
+            )
+        }
         return cls._convert_egg_info_reqs_to_simple_reqs(sections)
 
     @staticmethod
@@ -350,6 +380,7 @@ def _convert_egg_info_reqs_to_simple_reqs(sections):
         requirement. This method converts the former to the
         latter. See _test_deps_from_requires_text for an example.
         """
+
         def make_condition(name):
             return name and 'extra == "{name}"'.format(name=name)
 
@@ -438,48 +469,69 @@ def zip_children(self):
         names = zip_path.root.namelist()
         self.joinpath = zip_path.joinpath
 
-        return dict.fromkeys(
-            child.split(posixpath.sep, 1)[0]
-            for child in names
-            )
-
-    def is_egg(self, search):
-        base = self.base
-        return (
-            base == search.versionless_egg_name
-            or base.startswith(search.prefix)
-            and base.endswith('.egg'))
+        return dict.fromkeys(child.split(posixpath.sep, 1)[0] for child in names)
 
     def search(self, name):
-        for child in self.children():
-            n_low = child.lower()
-            if (n_low in name.exact_matches
-                    or n_low.startswith(name.prefix)
-                    and n_low.endswith(name.suffixes)
-                    # legacy case:
-                    or self.is_egg(name) and n_low == 'egg-info'):
-                yield self.joinpath(child)
+        return (
+            self.joinpath(child)
+            for child in self.children()
+            if name.matches(child, self.base)
+        )
 
 
 class Prepared:
     """
     A prepared search for metadata on a possibly-named package.
     """
-    normalized = ''
-    prefix = ''
+
+    normalized = None
     suffixes = '.dist-info', '.egg-info'
     exact_matches = [''][:0]
-    versionless_egg_name = ''
 
     def __init__(self, name):
         self.name = name
         if name is None:
             return
-        self.normalized = name.lower().replace('-', '_')
-        self.prefix = self.normalized + '-'
-        self.exact_matches = [
-            self.normalized + suffix for suffix in self.suffixes]
-        self.versionless_egg_name = self.normalized + '.egg'
+        self.normalized = self.normalize(name)
+        self.exact_matches = [self.normalized + suffix for suffix in self.suffixes]
+
+    @staticmethod
+    def normalize(name):
+        """
+        PEP 503 normalization plus dashes as underscores.
+        """
+        return re.sub(r"[-_.]+", "-", name).lower().replace('-', '_')
+
+    @staticmethod
+    def legacy_normalize(name):
+        """
+        Normalize the package name as found in the convention in
+        older packaging tools versions and specs.
+        """
+        return name.lower().replace('-', '_')
+
+    def matches(self, cand, base):
+        low = cand.lower()
+        pre, ext = os.path.splitext(low)
+        name, sep, rest = pre.partition('-')
+        return (
+            low in self.exact_matches
+            or ext in self.suffixes
+            and (not self.normalized or name.replace('.', '_') == self.normalized)
+            # legacy case:
+            or self.is_egg(base)
+            and low == 'egg-info'
+        )
+
+    def is_egg(self, base):
+        normalized = self.legacy_normalize(self.name or '')
+        prefix = normalized + '-' if normalized else ''
+        versionless_egg_name = normalized + '.egg' if self.name else ''
+        return (
+            base == versionless_egg_name
+            or base.startswith(prefix)
+            and base.endswith('.egg')
+        )
 
 
 class MetadataPathFinder(DistributionFinder):
@@ -500,9 +552,8 @@ def find_distributions(cls, context=DistributionFinder.Context()):
     def _search_paths(cls, name, paths):
         """Find metadata directories in paths heuristically."""
         return itertools.chain.from_iterable(
-            path.search(Prepared(name))
-            for path in map(FastPath, paths)
-            )
+            path.search(Prepared(name)) for path in map(FastPath, paths)
+        )
 
 
 class PathDistribution(Distribution):
@@ -515,9 +566,15 @@ def __init__(self, path):
         self._path = path
 
     def read_text(self, filename):
-        with suppress(FileNotFoundError, IsADirectoryError, KeyError,
-                      NotADirectoryError, PermissionError):
+        with suppress(
+            FileNotFoundError,
+            IsADirectoryError,
+            KeyError,
+            NotADirectoryError,
+            PermissionError,
+        ):
             return self._path.joinpath(filename).read_text(encoding='utf-8')
+
     read_text.__doc__ = Distribution.read_text.__doc__
 
     def locate_file(self, path):
@@ -541,11 +598,11 @@ def distributions(**kwargs):
     return Distribution.discover(**kwargs)
 
 
-def metadata(distribution_name):
+def metadata(distribution_name) -> PackageMetadata:
     """Get the metadata for the named package.
 
     :param distribution_name: The name of the distribution package to query.
-    :return: An email.Message containing the parsed metadata.
+    :return: A PackageMetadata containing the parsed metadata.
     """
     return Distribution.from_name(distribution_name).metadata
 
@@ -565,15 +622,11 @@ def entry_points():
 
     :return: EntryPoint objects for all installed packages.
     """
-    eps = itertools.chain.from_iterable(
-        dist.entry_points for dist in distributions())
+    eps = itertools.chain.from_iterable(dist.entry_points for dist in distributions())
     by_group = operator.attrgetter('group')
     ordered = sorted(eps, key=by_group)
     grouped = itertools.groupby(ordered, by_group)
-    return {
-        group: tuple(eps)
-        for group, eps in grouped
-        }
+    return {group: tuple(eps) for group, eps in grouped}
 
 
 def files(distribution_name):
diff --git a/Lib/test/test_importlib/fixtures.py b/Lib/test/test_importlib/fixtures.py
index 8fa92909d583e..429313e9efb98 100644
--- a/Lib/test/test_importlib/fixtures.py
+++ b/Lib/test/test_importlib/fixtures.py
@@ -7,6 +7,7 @@
 import contextlib
 
 from test.support.os_helper import FS_NONASCII
+from typing import Dict, Union
 
 
 @contextlib.contextmanager
@@ -71,8 +72,13 @@ def setUp(self):
         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 = {
+    files: FilesDef = {
         "distinfo_pkg-1.0.0.dist-info": {
             "METADATA": """
                 Name: distinfo-pkg
@@ -86,19 +92,55 @@ class DistInfoPkg(OnSysPath, SiteDir):
                 [entries]
                 main = mod:main
                 ns:sub = mod:main
-            """
-            },
+            """,
+        },
         "mod.py": """
             def main():
                 print("hello world")
             """,
-        }
+    }
 
     def setUp(self):
         super(DistInfoPkg, self).setUp()
         build_files(DistInfoPkg.files, self.site_dir)
 
 
+class DistInfoPkgWithDot(OnSysPath, SiteDir):
+    files: FilesDef = {
+        "pkg_dot-1.0.0.dist-info": {
+            "METADATA": """
+                Name: pkg.dot
+                Version: 1.0.0
+                """,
+        },
+    }
+
+    def setUp(self):
+        super(DistInfoPkgWithDot, self).setUp()
+        build_files(DistInfoPkgWithDot.files, self.site_dir)
+
+
+class DistInfoPkgWithDotLegacy(OnSysPath, SiteDir):
+    files: FilesDef = {
+        "pkg.dot-1.0.0.dist-info": {
+            "METADATA": """
+                Name: pkg.dot
+                Version: 1.0.0
+                """,
+        },
+        "pkg.lot.egg-info": {
+            "METADATA": """
+                Name: pkg.lot
+                Version: 1.0.0
+                """,
+        },
+    }
+
+    def setUp(self):
+        super(DistInfoPkgWithDotLegacy, self).setUp()
+        build_files(DistInfoPkgWithDotLegacy.files, self.site_dir)
+
+
 class DistInfoPkgOffPath(SiteDir):
     def setUp(self):
         super(DistInfoPkgOffPath, self).setUp()
@@ -106,7 +148,7 @@ def setUp(self):
 
 
 class EggInfoPkg(OnSysPath, SiteDir):
-    files = {
+    files: FilesDef = {
         "egginfo_pkg.egg-info": {
             "PKG-INFO": """
                 Name: egginfo-pkg
@@ -129,13 +171,13 @@ class EggInfoPkg(OnSysPath, SiteDir):
                 [test]
                 pytest
             """,
-            "top_level.txt": "mod\n"
-            },
+            "top_level.txt": "mod\n",
+        },
         "mod.py": """
             def main():
                 print("hello world")
             """,
-        }
+    }
 
     def setUp(self):
         super(EggInfoPkg, self).setUp()
@@ -143,7 +185,7 @@ def setUp(self):
 
 
 class EggInfoFile(OnSysPath, SiteDir):
-    files = {
+    files: FilesDef = {
         "egginfo_file.egg-info": """
             Metadata-Version: 1.0
             Name: egginfo_file
@@ -156,7 +198,7 @@ class EggInfoFile(OnSysPath, SiteDir):
             Description: UNKNOWN
             Platform: UNKNOWN
             """,
-        }
+    }
 
     def setUp(self):
         super(EggInfoFile, self).setUp()
@@ -164,12 +206,12 @@ def setUp(self):
 
 
 class LocalPackage:
-    files = {
+    files: FilesDef = {
         "setup.py": """
             import setuptools
             setuptools.setup(name="local-pkg", version="2.0.1")
             """,
-        }
+    }
 
     def setUp(self):
         self.fixtures = contextlib.ExitStack()
@@ -214,8 +256,7 @@ def build_files(file_defs, prefix=pathlib.Path()):
 
 class FileBuilder:
     def unicode_filename(self):
-        return FS_NONASCII or \
-            self.skip("File system does not support non-ascii.")
+        return FS_NONASCII or self.skip("File system does not support non-ascii.")
 
 
 def DALS(str):
diff --git a/Lib/test/test_importlib/test_main.py b/Lib/test/test_importlib/test_main.py
index a26bab6361548..c937361e8fdd0 100644
--- a/Lib/test/test_importlib/test_main.py
+++ b/Lib/test/test_importlib/test_main.py
@@ -1,5 +1,3 @@
-# coding: utf-8
-
 import re
 import json
 import pickle
@@ -14,10 +12,14 @@
 
 from . import fixtures
 from importlib.metadata import (
-    Distribution, EntryPoint,
-    PackageNotFoundError, distributions,
-    entry_points, metadata, version,
-    )
+    Distribution,
+    EntryPoint,
+    PackageNotFoundError,
+    distributions,
+    entry_points,
+    metadata,
+    version,
+)
 
 
 class BasicTests(fixtures.DistInfoPkg, unittest.TestCase):
@@ -70,12 +72,11 @@ def test_resolve_without_attr(self):
             name='ep',
             value='importlib.metadata',
             group='grp',
-            )
+        )
         assert ep.load() is importlib.metadata
 
 
-class NameNormalizationTests(
-        fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
+class NameNormalizationTests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
     @staticmethod
     def pkg_with_dashes(site_dir):
         """
@@ -144,11 +145,15 @@ def pkg_with_non_ascii_description_egg_info(site_dir):
         metadata_dir.mkdir()
         metadata = metadata_dir / 'METADATA'
         with metadata.open('w', encoding='utf-8') as fp:
-            fp.write(textwrap.dedent("""
+            fp.write(
+                textwrap.dedent(
+                    """
                 Name: portend
 
                 pôrˈtend
-                """).lstrip())
+                """
+                ).lstrip()
+            )
         return 'portend'
 
     def test_metadata_loads(self):
@@ -162,24 +167,12 @@ def test_metadata_loads_egg_info(self):
         assert meta.get_payload() == 'pôrˈtend\n'
 
 
-class DiscoveryTests(fixtures.EggInfoPkg,
-                     fixtures.DistInfoPkg,
-                     unittest.TestCase):
-
+class DiscoveryTests(fixtures.EggInfoPkg, 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'] == 'distinfo-pkg'
-            for dist in dists
-            )
+        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'] == 'distinfo-pkg' for dist in dists)
 
     def test_invalid_usage(self):
         with self.assertRaises(ValueError):
@@ -265,10 +258,21 @@ def test_module(self):
     def test_attr(self):
         assert self.ep.attr is None
 
+    def test_sortable(self):
+        """
+        EntryPoint objects are sortable, but result is undefined.
+        """
+        sorted(
+            [
+                EntryPoint('b', 'val', 'group'),
+                EntryPoint('a', 'val', 'group'),
+            ]
+        )
+
 
 class FileSystem(
-        fixtures.OnSysPath, fixtures.SiteDir, fixtures.FileBuilder,
-        unittest.TestCase):
+    fixtures.OnSysPath, fixtures.SiteDir, fixtures.FileBuilder, unittest.TestCase
+):
     def test_unicode_dir_on_sys_path(self):
         """
         Ensure a Unicode subdirectory of a directory on sys.path
@@ -277,5 +281,5 @@ def test_unicode_dir_on_sys_path(self):
         fixtures.build_files(
             {self.unicode_filename(): {}},
             prefix=self.site_dir,
-            )
+        )
         list(distributions())
diff --git a/Lib/test/test_importlib/test_metadata_api.py b/Lib/test/test_importlib/test_metadata_api.py
index 1d7b29ae05fd1..df00ae9375b86 100644
--- a/Lib/test/test_importlib/test_metadata_api.py
+++ b/Lib/test/test_importlib/test_metadata_api.py
@@ -2,20 +2,26 @@
 import textwrap
 import unittest
 
-from collections.abc import Iterator
-
 from . import fixtures
 from importlib.metadata import (
-    Distribution, PackageNotFoundError, distribution,
-    entry_points, files, metadata, requires, version,
-    )
+    Distribution,
+    PackageNotFoundError,
+    distribution,
+    entry_points,
+    files,
+    metadata,
+    requires,
+    version,
+)
 
 
 class APITests(
-        fixtures.EggInfoPkg,
-        fixtures.DistInfoPkg,
-        fixtures.EggInfoFile,
-        unittest.TestCase):
+    fixtures.EggInfoPkg,
+    fixtures.DistInfoPkg,
+    fixtures.DistInfoPkgWithDot,
+    fixtures.EggInfoFile,
+    unittest.TestCase,
+):
 
     version_pattern = r'\d+\.\d+(\.\d)?'
 
@@ -33,16 +39,28 @@ def test_for_name_does_not_exist(self):
         with self.assertRaises(PackageNotFoundError):
             distribution('does-not-exist')
 
+    def test_name_normalization(self):
+        names = 'pkg.dot', 'pkg_dot', 'pkg-dot', 'pkg..dot', 'Pkg.Dot'
+        for name in names:
+            with self.subTest(name):
+                assert distribution(name).metadata['Name'] == 'pkg.dot'
+
+    def test_prefix_not_matched(self):
+        prefixes = 'p', 'pkg', 'pkg.'
+        for prefix in prefixes:
+            with self.subTest(prefix):
+                with self.assertRaises(PackageNotFoundError):
+                    distribution(prefix)
+
     def test_for_top_level(self):
         self.assertEqual(
-            distribution('egginfo-pkg').read_text('top_level.txt').strip(),
-            'mod')
+            distribution('egginfo-pkg').read_text('top_level.txt').strip(), 'mod'
+        )
 
     def test_read_text(self):
         top_level = [
-            path for path in files('egginfo-pkg')
-            if path.name == 'top_level.txt'
-            ][0]
+            path for path in files('egginfo-pkg') if path.name == 'top_level.txt'
+        ][0]
         self.assertEqual(top_level.read_text(), 'mod\n')
 
     def test_entry_points(self):
@@ -51,6 +69,13 @@ def test_entry_points(self):
         self.assertEqual(ep.value, 'mod:main')
         self.assertEqual(ep.extras, [])
 
+    def test_entry_points_distribution(self):
+        entries = dict(entry_points()['entries'])
+        for entry in ("main", "ns:sub"):
+            ep = entries[entry]
+            self.assertIn(ep.dist.name, ('distinfo-pkg', 'egginfo-pkg'))
+            self.assertEqual(ep.dist.version, "1.0.0")
+
     def test_metadata_for_this_package(self):
         md = metadata('egginfo-pkg')
         assert md['author'] == 'Steven Ma'
@@ -75,13 +100,8 @@ def _test_files(files):
     def test_file_hash_repr(self):
         assertRegex = self.assertRegex
 
-        util = [
-            p for p in files('distinfo-pkg')
-            if p.name == 'mod.py'
-            ][0]
-        assertRegex(
-            repr(util.hash),
-            '<FileHash mode: sha256 value: .*>')
+        util = [p for p in files('distinfo-pkg') if p.name == 'mod.py'][0]
+        assertRegex(repr(util.hash), '<FileHash mode: sha256 value: .*>')
 
     def test_files_dist_info(self):
         self._test_files(files('distinfo-pkg'))
@@ -99,10 +119,7 @@ def test_requires_egg_info_file(self):
     def test_requires_egg_info(self):
         deps = requires('egginfo-pkg')
         assert len(deps) == 2
-        assert any(
-            dep == 'wheel >= 1.0; python_version >= "2.7"'
-            for dep in deps
-            )
+        assert any(dep == 'wheel >= 1.0; python_version >= "2.7"' for dep in deps)
 
     def test_requires_dist_info(self):
         deps = requires('distinfo-pkg')
@@ -112,7 +129,8 @@ def test_requires_dist_info(self):
         assert "pytest; extra == 'test'" in deps
 
     def test_more_complex_deps_requires_text(self):
-        requires = textwrap.dedent("""
+        requires = textwrap.dedent(
+            """
             dep1
             dep2
 
@@ -124,7 +142,8 @@ def test_more_complex_deps_requires_text(self):
 
             [extra2:python_version < "3"]
             dep5
-            """)
+            """
+        )
         deps = sorted(Distribution._deps_from_requires_text(requires))
         expected = [
             'dep1',
@@ -132,7 +151,7 @@ def test_more_complex_deps_requires_text(self):
             'dep3; python_version < "3"',
             'dep4; extra == "extra1"',
             'dep5; (python_version < "3") and extra == "extra2"',
-            ]
+        ]
         # It's important that the environment marker expression be
         # wrapped in parentheses to avoid the following 'and' binding more
         # tightly than some other part of the environment expression.
@@ -140,17 +159,27 @@ def test_more_complex_deps_requires_text(self):
         assert deps == expected
 
 
+class LegacyDots(fixtures.DistInfoPkgWithDotLegacy, unittest.TestCase):
+    def test_name_normalization(self):
+        names = 'pkg.dot', 'pkg_dot', 'pkg-dot', 'pkg..dot', 'Pkg.Dot'
+        for name in names:
+            with self.subTest(name):
+                assert distribution(name).metadata['Name'] == 'pkg.dot'
+
+    def test_name_normalization_versionless_egg_info(self):
+        names = 'pkg.lot', 'pkg_lot', 'pkg-lot', 'pkg..lot', 'Pkg.Lot'
+        for name in names:
+            with self.subTest(name):
+                assert distribution(name).metadata['Name'] == 'pkg.lot'
+
+
 class OffSysPathTests(fixtures.DistInfoPkgOffPath, unittest.TestCase):
     def test_find_distributions_specified_path(self):
         dists = Distribution.discover(path=[str(self.site_dir)])
-        assert any(
-            dist.metadata['Name'] == 'distinfo-pkg'
-            for dist in dists
-            )
+        assert any(dist.metadata['Name'] == 'distinfo-pkg' for dist in dists)
 
     def test_distribution_at_pathlib(self):
-        """Demonstrate how to load metadata direct from a directory.
-        """
+        """Demonstrate how to load metadata direct from a directory."""
         dist_info_path = self.site_dir / 'distinfo_pkg-1.0.0.dist-info'
         dist = Distribution.at(dist_info_path)
         assert dist.version == '1.0.0'
diff --git a/Lib/test/test_importlib/test_zip.py b/Lib/test/test_importlib/test_zip.py
index a5399c16682fb..74783fc98b99b 100644
--- a/Lib/test/test_importlib/test_zip.py
+++ b/Lib/test/test_importlib/test_zip.py
@@ -3,8 +3,12 @@
 
 from contextlib import ExitStack
 from importlib.metadata import (
-    distribution, entry_points, files, PackageNotFoundError,
-    version, distributions,
+    PackageNotFoundError,
+    distribution,
+    distributions,
+    entry_points,
+    files,
+    version,
 )
 from importlib import resources
 
diff --git a/Misc/NEWS.d/next/Library/2020-12-13-22-05-35.bpo-42382.2YtKo5.rst b/Misc/NEWS.d/next/Library/2020-12-13-22-05-35.bpo-42382.2YtKo5.rst
new file mode 100644
index 0000000000000..5ccd5bbf5e91f
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2020-12-13-22-05-35.bpo-42382.2YtKo5.rst
@@ -0,0 +1,6 @@
+In ``importlib.metadata``: -  ``EntryPoint`` objects now expose a ``.dist``
+object referencing the ``Distribution`` when constructed from a
+``Distribution``. - Add support for package discovery under package
+normalization rules. - The object returned by ``metadata()`` now has a
+formally-defined protocol called ``PackageMetadata`` with declared support
+for the ``.get_all()`` method. - Synced with importlib_metadata 3.3.



More information about the Python-checkins mailing list