diff --git a/Doc/library/importlib.metadata.rst b/Doc/library/importlib.metadata.rst index 21da143f3be..858ed0a4838 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 302d61d505c..36bb42ee21d 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 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 __all__ = [ @@ -31,7 +31,7 @@ __all__ = [ 'metadata', 'requires', 'version', - ] +] class PackageNotFoundError(ModuleNotFoundError): @@ -43,7 +43,7 @@ class PackageNotFoundError(ModuleNotFoundError): @property def name(self): - name, = self.args + (name,) = self.args return name @@ -60,7 +60,7 @@ class EntryPoint( r'(?P[\w.]+)\s*' r'(:\s*(?P[\w.]+))?\s*' r'(?P\[.*\])?\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 @@ class EntryPoint( @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 @@ class EntryPoint( return ( self.__class__, (self.name, self.value, self.group), - ) + ) class PackagePath(pathlib.PurePosixPath): @@ -159,6 +165,25 @@ class FileHash: return ''.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 @@ class Distribution: 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 @@ class Distribution: 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 @@ class Distribution: # 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 @@ class Distribution: @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 @@ class Distribution: 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 @@ class Distribution: 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 @@ class FastPath: 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 @@ class MetadataPathFinder(DistributionFinder): 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 @@ class PathDistribution(Distribution): 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 8fa92909d58..429313e9efb 100644 --- a/Lib/test/test_importlib/fixtures.py +++ b/Lib/test/test_importlib/fixtures.py @@ -7,6 +7,7 @@ import textwrap import contextlib from test.support.os_helper import FS_NONASCII +from typing import Dict, Union @contextlib.contextmanager @@ -71,8 +72,13 @@ 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 = { + 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 @@ class DistInfoPkgOffPath(SiteDir): 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 @@ class EggInfoPkg(OnSysPath, SiteDir): 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 @@ class EggInfoFile(OnSysPath, SiteDir): 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 a26bab63615..c937361e8fd 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 @@ except ImportError: 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 @@ class ImportTests(fixtures.DistInfoPkg, unittest.TestCase): 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 @@ class NonASCIITests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase): 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 @@ class NonASCIITests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase): 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 @@ class TestEntryPoints(unittest.TestCase): 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 @@ class FileSystem( 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 1d7b29ae05f..df00ae9375b 100644 --- a/Lib/test/test_importlib/test_metadata_api.py +++ b/Lib/test/test_importlib/test_metadata_api.py @@ -2,20 +2,26 @@ import re 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 @@ class APITests( 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 @@ class APITests( 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 @@ class APITests( 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), - '') + util = [p for p in files('distinfo-pkg') if p.name == 'mod.py'][0] + assertRegex(repr(util.hash), '') def test_files_dist_info(self): self._test_files(files('distinfo-pkg')) @@ -99,10 +119,7 @@ class APITests( 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 @@ class APITests( 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 @@ class APITests( [extra2:python_version < "3"] dep5 - """) + """ + ) deps = sorted(Distribution._deps_from_requires_text(requires)) expected = [ 'dep1', @@ -132,7 +151,7 @@ class APITests( '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 @@ class APITests( 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 a5399c16682..74783fc98b9 100644 --- a/Lib/test/test_importlib/test_zip.py +++ b/Lib/test/test_importlib/test_zip.py @@ -3,8 +3,12 @@ import unittest 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 00000000000..5ccd5bbf5e9 --- /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.