bpo-43428: Sync with importlib_metadata 3.7. (GH-24782)

* bpo-43428: Sync with importlib_metadata 3.7.2 (67234b6)

* Add blurb

* Reformat blurb to create separate paragraphs for each change included.
diff --git a/Lib/importlib/metadata.py b/Lib/importlib/metadata.py
index 36bb42e..8a73185 100644
--- a/Lib/importlib/metadata.py
+++ b/Lib/importlib/metadata.py
@@ -4,20 +4,24 @@
 import csv
 import sys
 import email
+import inspect
 import pathlib
 import zipfile
 import operator
+import warnings
 import functools
 import itertools
 import posixpath
-import collections
+import collections.abc
+
+from ._itertools import unique_everseen
 
 from configparser import ConfigParser
 from contextlib import suppress
 from importlib import import_module
 from importlib.abc import MetaPathFinder
 from itertools import starmap
-from typing import Any, List, Optional, Protocol, TypeVar, Union
+from typing import Any, List, Mapping, Optional, Protocol, TypeVar, Union
 
 
 __all__ = [
@@ -120,18 +124,19 @@ def _from_text(cls, text):
         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):
         """
-        Supply iter so one may construct dicts of EntryPoints easily.
+        Supply iter so one may construct dicts of EntryPoints by name.
         """
+        msg = (
+            "Construction of dict of EntryPoints is deprecated in "
+            "favor of EntryPoints."
+        )
+        warnings.warn(msg, DeprecationWarning)
         return iter((self.name, self))
 
     def __reduce__(self):
@@ -140,6 +145,143 @@ def __reduce__(self):
             (self.name, self.value, self.group),
         )
 
+    def matches(self, **params):
+        attrs = (getattr(self, param) for param in params)
+        return all(map(operator.eq, params.values(), attrs))
+
+
+class EntryPoints(tuple):
+    """
+    An immutable collection of selectable EntryPoint objects.
+    """
+
+    __slots__ = ()
+
+    def __getitem__(self, name):  # -> EntryPoint:
+        try:
+            return next(iter(self.select(name=name)))
+        except StopIteration:
+            raise KeyError(name)
+
+    def select(self, **params):
+        return EntryPoints(ep for ep in self if ep.matches(**params))
+
+    @property
+    def names(self):
+        return set(ep.name for ep in self)
+
+    @property
+    def groups(self):
+        """
+        For coverage while SelectableGroups is present.
+        >>> EntryPoints().groups
+        set()
+        """
+        return set(ep.group for ep in self)
+
+    @classmethod
+    def _from_text_for(cls, text, dist):
+        return cls(ep._for(dist) for ep in EntryPoint._from_text(text))
+
+
+def flake8_bypass(func):
+    is_flake8 = any('flake8' in str(frame.filename) for frame in inspect.stack()[:5])
+    return func if not is_flake8 else lambda: None
+
+
+class Deprecated:
+    """
+    Compatibility add-in for mapping to indicate that
+    mapping behavior is deprecated.
+
+    >>> recwarn = getfixture('recwarn')
+    >>> class DeprecatedDict(Deprecated, dict): pass
+    >>> dd = DeprecatedDict(foo='bar')
+    >>> dd.get('baz', None)
+    >>> dd['foo']
+    'bar'
+    >>> list(dd)
+    ['foo']
+    >>> list(dd.keys())
+    ['foo']
+    >>> 'foo' in dd
+    True
+    >>> list(dd.values())
+    ['bar']
+    >>> len(recwarn)
+    1
+    """
+
+    _warn = functools.partial(
+        warnings.warn,
+        "SelectableGroups dict interface is deprecated. Use select.",
+        DeprecationWarning,
+        stacklevel=2,
+    )
+
+    def __getitem__(self, name):
+        self._warn()
+        return super().__getitem__(name)
+
+    def get(self, name, default=None):
+        flake8_bypass(self._warn)()
+        return super().get(name, default)
+
+    def __iter__(self):
+        self._warn()
+        return super().__iter__()
+
+    def __contains__(self, *args):
+        self._warn()
+        return super().__contains__(*args)
+
+    def keys(self):
+        self._warn()
+        return super().keys()
+
+    def values(self):
+        self._warn()
+        return super().values()
+
+
+class SelectableGroups(dict):
+    """
+    A backward- and forward-compatible result from
+    entry_points that fully implements the dict interface.
+    """
+
+    @classmethod
+    def load(cls, eps):
+        by_group = operator.attrgetter('group')
+        ordered = sorted(eps, key=by_group)
+        grouped = itertools.groupby(ordered, by_group)
+        return cls((group, EntryPoints(eps)) for group, eps in grouped)
+
+    @property
+    def _all(self):
+        """
+        Reconstruct a list of all entrypoints from the groups.
+        """
+        return EntryPoints(itertools.chain.from_iterable(self.values()))
+
+    @property
+    def groups(self):
+        return self._all.groups
+
+    @property
+    def names(self):
+        """
+        for coverage:
+        >>> SelectableGroups().names
+        set()
+        """
+        return self._all.names
+
+    def select(self, **params):
+        if not params:
+            return self
+        return self._all.select(**params)
+
 
 class PackagePath(pathlib.PurePosixPath):
     """A reference to a path in a package"""
@@ -296,7 +438,7 @@ def version(self):
 
     @property
     def entry_points(self):
-        return list(EntryPoint._from_text_for(self.read_text('entry_points.txt'), self))
+        return EntryPoints._from_text_for(self.read_text('entry_points.txt'), self)
 
     @property
     def files(self):
@@ -485,15 +627,22 @@ class Prepared:
     """
 
     normalized = None
-    suffixes = '.dist-info', '.egg-info'
+    suffixes = 'dist-info', 'egg-info'
     exact_matches = [''][:0]
+    egg_prefix = ''
+    versionless_egg_name = ''
 
     def __init__(self, name):
         self.name = name
         if name is None:
             return
         self.normalized = self.normalize(name)
-        self.exact_matches = [self.normalized + suffix for suffix in self.suffixes]
+        self.exact_matches = [
+            self.normalized + '.' + suffix for suffix in self.suffixes
+        ]
+        legacy_normalized = self.legacy_normalize(self.name)
+        self.egg_prefix = legacy_normalized + '-'
+        self.versionless_egg_name = legacy_normalized + '.egg'
 
     @staticmethod
     def normalize(name):
@@ -512,8 +661,9 @@ def legacy_normalize(name):
 
     def matches(self, cand, base):
         low = cand.lower()
-        pre, ext = os.path.splitext(low)
-        name, sep, rest = pre.partition('-')
+        # rpartition is faster than splitext and suitable for this purpose.
+        pre, _, ext = low.rpartition('.')
+        name, _, rest = pre.partition('-')
         return (
             low in self.exact_matches
             or ext in self.suffixes
@@ -524,12 +674,9 @@ def matches(self, cand, base):
         )
 
     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)
+            base == self.versionless_egg_name
+            or base.startswith(self.egg_prefix)
             and base.endswith('.egg')
         )
 
@@ -551,8 +698,9 @@ def find_distributions(cls, context=DistributionFinder.Context()):
     @classmethod
     def _search_paths(cls, name, paths):
         """Find metadata directories in paths heuristically."""
+        prepared = Prepared(name)
         return itertools.chain.from_iterable(
-            path.search(Prepared(name)) for path in map(FastPath, paths)
+            path.search(prepared) for path in map(FastPath, paths)
         )
 
 
@@ -617,16 +765,28 @@ def version(distribution_name):
     return distribution(distribution_name).version
 
 
-def entry_points():
+def entry_points(**params) -> Union[EntryPoints, SelectableGroups]:
     """Return EntryPoint objects for all installed packages.
 
-    :return: EntryPoint objects for all installed packages.
+    Pass selection parameters (group or name) to filter the
+    result to entry points matching those properties (see
+    EntryPoints.select()).
+
+    For compatibility, returns ``SelectableGroups`` object unless
+    selection parameters are supplied. In the future, this function
+    will return ``EntryPoints`` instead of ``SelectableGroups``
+    even when no selection parameters are supplied.
+
+    For maximum future compatibility, pass selection parameters
+    or invoke ``.select`` with parameters on the result.
+
+    :return: EntryPoints or SelectableGroups for all installed packages.
     """
-    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}
+    unique = functools.partial(unique_everseen, key=operator.attrgetter('name'))
+    eps = itertools.chain.from_iterable(
+        dist.entry_points for dist in unique(distributions())
+    )
+    return SelectableGroups.load(eps).select(**params)
 
 
 def files(distribution_name):
@@ -646,3 +806,19 @@ def requires(distribution_name):
     packaging.requirement.Requirement.
     """
     return distribution(distribution_name).requires
+
+
+def packages_distributions() -> Mapping[str, List[str]]:
+    """
+    Return a mapping of top-level packages to their
+    distributions.
+
+    >>> pkgs = packages_distributions()
+    >>> all(isinstance(dist, collections.abc.Sequence) for dist in pkgs.values())
+    True
+    """
+    pkg_to_dist = collections.defaultdict(list)
+    for dist in distributions():
+        for pkg in (dist.read_text('top_level.txt') or '').split():
+            pkg_to_dist[pkg].append(dist.metadata['Name'])
+    return dict(pkg_to_dist)