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.
This commit is contained in:
Jason R. Coombs 2020-12-31 12:56:43 -05:00 committed by GitHub
parent f4936ad1c4
commit dfdca85dfa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 286 additions and 154 deletions

View File

@ -115,8 +115,9 @@ Every distribution includes some metadata, which you can extract using the
>>> wheel_metadata = metadata('wheel') # doctest: +SKIP >>> wheel_metadata = metadata('wheel') # doctest: +SKIP
The keys of the returned data structure [#f1]_ name the metadata keywords, and The keys of the returned data structure, a ``PackageMetadata``,
their values are returned unparsed from the distribution metadata:: name the metadata keywords, and
the values are returned unparsed from the distribution metadata::
>>> wheel_metadata['Requires-Python'] # doctest: +SKIP >>> wheel_metadata['Requires-Python'] # doctest: +SKIP
'>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*' '>=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 .. 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.

View File

@ -1,4 +1,3 @@
import io
import os import os
import re import re
import abc import abc
@ -18,6 +17,7 @@ from contextlib import suppress
from importlib import import_module from importlib import import_module
from importlib.abc import MetaPathFinder from importlib.abc import MetaPathFinder
from itertools import starmap from itertools import starmap
from typing import Any, List, Optional, Protocol, TypeVar, Union
__all__ = [ __all__ = [
@ -43,7 +43,7 @@ class PackageNotFoundError(ModuleNotFoundError):
@property @property
def name(self): def name(self):
name, = self.args (name,) = self.args
return name return name
@ -77,6 +77,8 @@ class EntryPoint(
following the attr, and following any extras. following the attr, and following any extras.
""" """
dist: Optional['Distribution'] = None
def load(self): def load(self):
"""Load the entry point from its definition. If only a module """Load the entry point from its definition. If only a module
is indicated by the value, return that module. Otherwise, is indicated by the value, return that module. Otherwise,
@ -104,23 +106,27 @@ class EntryPoint(
@classmethod @classmethod
def _from_config(cls, config): def _from_config(cls, config):
return [ return (
cls(name, value, group) cls(name, value, group)
for group in config.sections() for group in config.sections()
for name, value in config.items(group) for name, value in config.items(group)
] )
@classmethod @classmethod
def _from_text(cls, text): def _from_text(cls, text):
config = ConfigParser(delimiters='=') config = ConfigParser(delimiters='=')
# case sensitive: https://stackoverflow.com/q/1611799/812183 # case sensitive: https://stackoverflow.com/q/1611799/812183
config.optionxform = str config.optionxform = str
try:
config.read_string(text) config.read_string(text)
except AttributeError: # pragma: nocover return cls._from_config(config)
# Python 2 has no read_string
config.readfp(io.StringIO(text)) @classmethod
return EntryPoint._from_config(config) 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): def __iter__(self):
""" """
@ -159,6 +165,25 @@ class FileHash:
return '<FileHash mode: {} value: {}>'.format(self.mode, self.value) 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: class Distribution:
"""A Python distribution package.""" """A Python distribution package."""
@ -210,8 +235,7 @@ class Distribution:
raise ValueError("cannot accept context and kwargs") raise ValueError("cannot accept context and kwargs")
context = context or DistributionFinder.Context(**kwargs) context = context or DistributionFinder.Context(**kwargs)
return itertools.chain.from_iterable( return itertools.chain.from_iterable(
resolver(context) resolver(context) for resolver in cls._discover_resolvers()
for resolver in cls._discover_resolvers()
) )
@staticmethod @staticmethod
@ -227,14 +251,14 @@ class Distribution:
def _discover_resolvers(): def _discover_resolvers():
"""Search the meta_path for resolvers.""" """Search the meta_path for resolvers."""
declared = ( declared = (
getattr(finder, 'find_distributions', None) getattr(finder, 'find_distributions', None) for finder in sys.meta_path
for finder in sys.meta_path
) )
return filter(None, declared) return filter(None, declared)
@classmethod @classmethod
def _local(cls, root='.'): def _local(cls, root='.'):
from pep517 import build, meta from pep517 import build, meta
system = build.compat_system(root) system = build.compat_system(root)
builder = functools.partial( builder = functools.partial(
meta.build, meta.build,
@ -244,7 +268,7 @@ class Distribution:
return PathDistribution(zipfile.Path(meta.build_as_zip(builder))) return PathDistribution(zipfile.Path(meta.build_as_zip(builder)))
@property @property
def metadata(self): def metadata(self) -> PackageMetadata:
"""Return the parsed metadata for this Distribution. """Return the parsed metadata for this Distribution.
The returned object will have keys that name the various bits of The returned object will have keys that name the various bits of
@ -260,6 +284,11 @@ class Distribution:
) )
return email.message_from_string(text) return email.message_from_string(text)
@property
def name(self):
"""Return the 'Name' metadata for the distribution package."""
return self.metadata['Name']
@property @property
def version(self): def version(self):
"""Return the 'Version' metadata for the distribution package.""" """Return the 'Version' metadata for the distribution package."""
@ -267,7 +296,7 @@ class Distribution:
@property @property
def entry_points(self): 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 @property
def files(self): def files(self):
@ -324,8 +353,9 @@ class Distribution:
section_pairs = cls._read_sections(source.splitlines()) section_pairs = cls._read_sections(source.splitlines())
sections = { sections = {
section: list(map(operator.itemgetter('line'), results)) section: list(map(operator.itemgetter('line'), results))
for section, results in for section, results in itertools.groupby(
itertools.groupby(section_pairs, operator.itemgetter('section')) section_pairs, operator.itemgetter('section')
)
} }
return cls._convert_egg_info_reqs_to_simple_reqs(sections) return cls._convert_egg_info_reqs_to_simple_reqs(sections)
@ -350,6 +380,7 @@ class Distribution:
requirement. This method converts the former to the requirement. This method converts the former to the
latter. See _test_deps_from_requires_text for an example. latter. See _test_deps_from_requires_text for an example.
""" """
def make_condition(name): def make_condition(name):
return name and 'extra == "{name}"'.format(name=name) return name and 'extra == "{name}"'.format(name=name)
@ -438,48 +469,69 @@ class FastPath:
names = zip_path.root.namelist() names = zip_path.root.namelist()
self.joinpath = zip_path.joinpath self.joinpath = zip_path.joinpath
return dict.fromkeys( return dict.fromkeys(child.split(posixpath.sep, 1)[0] for child in names)
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'))
def search(self, name): def search(self, name):
for child in self.children(): return (
n_low = child.lower() self.joinpath(child)
if (n_low in name.exact_matches for child in self.children()
or n_low.startswith(name.prefix) if name.matches(child, self.base)
and n_low.endswith(name.suffixes) )
# legacy case:
or self.is_egg(name) and n_low == 'egg-info'):
yield self.joinpath(child)
class Prepared: class Prepared:
""" """
A prepared search for metadata on a possibly-named package. A prepared search for metadata on a possibly-named package.
""" """
normalized = ''
prefix = '' normalized = None
suffixes = '.dist-info', '.egg-info' suffixes = '.dist-info', '.egg-info'
exact_matches = [''][:0] exact_matches = [''][:0]
versionless_egg_name = ''
def __init__(self, name): def __init__(self, name):
self.name = name self.name = name
if name is None: if name is None:
return return
self.normalized = name.lower().replace('-', '_') self.normalized = self.normalize(name)
self.prefix = self.normalized + '-' self.exact_matches = [self.normalized + suffix for suffix in self.suffixes]
self.exact_matches = [
self.normalized + suffix for suffix in self.suffixes] @staticmethod
self.versionless_egg_name = self.normalized + '.egg' 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): class MetadataPathFinder(DistributionFinder):
@ -500,8 +552,7 @@ class MetadataPathFinder(DistributionFinder):
def _search_paths(cls, name, paths): def _search_paths(cls, name, paths):
"""Find metadata directories in paths heuristically.""" """Find metadata directories in paths heuristically."""
return itertools.chain.from_iterable( return itertools.chain.from_iterable(
path.search(Prepared(name)) path.search(Prepared(name)) for path in map(FastPath, paths)
for path in map(FastPath, paths)
) )
@ -515,9 +566,15 @@ class PathDistribution(Distribution):
self._path = path self._path = path
def read_text(self, filename): def read_text(self, filename):
with suppress(FileNotFoundError, IsADirectoryError, KeyError, with suppress(
NotADirectoryError, PermissionError): FileNotFoundError,
IsADirectoryError,
KeyError,
NotADirectoryError,
PermissionError,
):
return self._path.joinpath(filename).read_text(encoding='utf-8') return self._path.joinpath(filename).read_text(encoding='utf-8')
read_text.__doc__ = Distribution.read_text.__doc__ read_text.__doc__ = Distribution.read_text.__doc__
def locate_file(self, path): def locate_file(self, path):
@ -541,11 +598,11 @@ def distributions(**kwargs):
return Distribution.discover(**kwargs) return Distribution.discover(**kwargs)
def metadata(distribution_name): def metadata(distribution_name) -> PackageMetadata:
"""Get the metadata for the named package. """Get the metadata for the named package.
:param distribution_name: The name of the distribution package to query. :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 return Distribution.from_name(distribution_name).metadata
@ -565,15 +622,11 @@ def entry_points():
:return: EntryPoint objects for all installed packages. :return: EntryPoint objects for all installed packages.
""" """
eps = itertools.chain.from_iterable( eps = itertools.chain.from_iterable(dist.entry_points for dist in distributions())
dist.entry_points for dist in distributions())
by_group = operator.attrgetter('group') by_group = operator.attrgetter('group')
ordered = sorted(eps, key=by_group) ordered = sorted(eps, key=by_group)
grouped = itertools.groupby(ordered, by_group) grouped = itertools.groupby(ordered, by_group)
return { return {group: tuple(eps) for group, eps in grouped}
group: tuple(eps)
for group, eps in grouped
}
def files(distribution_name): def files(distribution_name):

View File

@ -7,6 +7,7 @@ import textwrap
import contextlib import contextlib
from test.support.os_helper import FS_NONASCII from test.support.os_helper import FS_NONASCII
from typing import Dict, Union
@contextlib.contextmanager @contextlib.contextmanager
@ -71,8 +72,13 @@ class OnSysPath(Fixtures):
self.fixtures.enter_context(self.add_sys_path(self.site_dir)) 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): class DistInfoPkg(OnSysPath, SiteDir):
files = { files: FilesDef = {
"distinfo_pkg-1.0.0.dist-info": { "distinfo_pkg-1.0.0.dist-info": {
"METADATA": """ "METADATA": """
Name: distinfo-pkg Name: distinfo-pkg
@ -86,7 +92,7 @@ class DistInfoPkg(OnSysPath, SiteDir):
[entries] [entries]
main = mod:main main = mod:main
ns:sub = mod:main ns:sub = mod:main
""" """,
}, },
"mod.py": """ "mod.py": """
def main(): def main():
@ -99,6 +105,42 @@ class DistInfoPkg(OnSysPath, SiteDir):
build_files(DistInfoPkg.files, self.site_dir) 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): class DistInfoPkgOffPath(SiteDir):
def setUp(self): def setUp(self):
super(DistInfoPkgOffPath, self).setUp() super(DistInfoPkgOffPath, self).setUp()
@ -106,7 +148,7 @@ class DistInfoPkgOffPath(SiteDir):
class EggInfoPkg(OnSysPath, SiteDir): class EggInfoPkg(OnSysPath, SiteDir):
files = { files: FilesDef = {
"egginfo_pkg.egg-info": { "egginfo_pkg.egg-info": {
"PKG-INFO": """ "PKG-INFO": """
Name: egginfo-pkg Name: egginfo-pkg
@ -129,7 +171,7 @@ class EggInfoPkg(OnSysPath, SiteDir):
[test] [test]
pytest pytest
""", """,
"top_level.txt": "mod\n" "top_level.txt": "mod\n",
}, },
"mod.py": """ "mod.py": """
def main(): def main():
@ -143,7 +185,7 @@ class EggInfoPkg(OnSysPath, SiteDir):
class EggInfoFile(OnSysPath, SiteDir): class EggInfoFile(OnSysPath, SiteDir):
files = { files: FilesDef = {
"egginfo_file.egg-info": """ "egginfo_file.egg-info": """
Metadata-Version: 1.0 Metadata-Version: 1.0
Name: egginfo_file Name: egginfo_file
@ -164,7 +206,7 @@ class EggInfoFile(OnSysPath, SiteDir):
class LocalPackage: class LocalPackage:
files = { files: FilesDef = {
"setup.py": """ "setup.py": """
import setuptools import setuptools
setuptools.setup(name="local-pkg", version="2.0.1") setuptools.setup(name="local-pkg", version="2.0.1")
@ -214,8 +256,7 @@ def build_files(file_defs, prefix=pathlib.Path()):
class FileBuilder: class FileBuilder:
def unicode_filename(self): def unicode_filename(self):
return FS_NONASCII or \ return FS_NONASCII or self.skip("File system does not support non-ascii.")
self.skip("File system does not support non-ascii.")
def DALS(str): def DALS(str):

View File

@ -1,5 +1,3 @@
# coding: utf-8
import re import re
import json import json
import pickle import pickle
@ -14,9 +12,13 @@ except ImportError:
from . import fixtures from . import fixtures
from importlib.metadata import ( from importlib.metadata import (
Distribution, EntryPoint, Distribution,
PackageNotFoundError, distributions, EntryPoint,
entry_points, metadata, version, PackageNotFoundError,
distributions,
entry_points,
metadata,
version,
) )
@ -74,8 +76,7 @@ class ImportTests(fixtures.DistInfoPkg, unittest.TestCase):
assert ep.load() is importlib.metadata assert ep.load() is importlib.metadata
class NameNormalizationTests( class NameNormalizationTests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
@staticmethod @staticmethod
def pkg_with_dashes(site_dir): def pkg_with_dashes(site_dir):
""" """
@ -144,11 +145,15 @@ class NonASCIITests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
metadata_dir.mkdir() metadata_dir.mkdir()
metadata = metadata_dir / 'METADATA' metadata = metadata_dir / 'METADATA'
with metadata.open('w', encoding='utf-8') as fp: with metadata.open('w', encoding='utf-8') as fp:
fp.write(textwrap.dedent(""" fp.write(
textwrap.dedent(
"""
Name: portend Name: portend
pôrˈtend pôrˈtend
""").lstrip()) """
).lstrip()
)
return 'portend' return 'portend'
def test_metadata_loads(self): 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' assert meta.get_payload() == 'pôrˈtend\n'
class DiscoveryTests(fixtures.EggInfoPkg, class DiscoveryTests(fixtures.EggInfoPkg, fixtures.DistInfoPkg, unittest.TestCase):
fixtures.DistInfoPkg,
unittest.TestCase):
def test_package_discovery(self): def test_package_discovery(self):
dists = list(distributions()) dists = list(distributions())
assert all( assert all(isinstance(dist, Distribution) for dist in dists)
isinstance(dist, Distribution) assert any(dist.metadata['Name'] == 'egginfo-pkg' for dist in dists)
for dist in dists assert any(dist.metadata['Name'] == 'distinfo-pkg' 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): def test_invalid_usage(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
@ -265,10 +258,21 @@ class TestEntryPoints(unittest.TestCase):
def test_attr(self): def test_attr(self):
assert self.ep.attr is None 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( class FileSystem(
fixtures.OnSysPath, fixtures.SiteDir, fixtures.FileBuilder, fixtures.OnSysPath, fixtures.SiteDir, fixtures.FileBuilder, unittest.TestCase
unittest.TestCase): ):
def test_unicode_dir_on_sys_path(self): def test_unicode_dir_on_sys_path(self):
""" """
Ensure a Unicode subdirectory of a directory on sys.path Ensure a Unicode subdirectory of a directory on sys.path

View File

@ -2,20 +2,26 @@ import re
import textwrap import textwrap
import unittest import unittest
from collections.abc import Iterator
from . import fixtures from . import fixtures
from importlib.metadata import ( from importlib.metadata import (
Distribution, PackageNotFoundError, distribution, Distribution,
entry_points, files, metadata, requires, version, PackageNotFoundError,
distribution,
entry_points,
files,
metadata,
requires,
version,
) )
class APITests( class APITests(
fixtures.EggInfoPkg, fixtures.EggInfoPkg,
fixtures.DistInfoPkg, fixtures.DistInfoPkg,
fixtures.DistInfoPkgWithDot,
fixtures.EggInfoFile, fixtures.EggInfoFile,
unittest.TestCase): unittest.TestCase,
):
version_pattern = r'\d+\.\d+(\.\d)?' version_pattern = r'\d+\.\d+(\.\d)?'
@ -33,15 +39,27 @@ class APITests(
with self.assertRaises(PackageNotFoundError): with self.assertRaises(PackageNotFoundError):
distribution('does-not-exist') 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): def test_for_top_level(self):
self.assertEqual( self.assertEqual(
distribution('egginfo-pkg').read_text('top_level.txt').strip(), distribution('egginfo-pkg').read_text('top_level.txt').strip(), 'mod'
'mod') )
def test_read_text(self): def test_read_text(self):
top_level = [ top_level = [
path for path in files('egginfo-pkg') path for path in files('egginfo-pkg') if path.name == 'top_level.txt'
if path.name == 'top_level.txt'
][0] ][0]
self.assertEqual(top_level.read_text(), 'mod\n') self.assertEqual(top_level.read_text(), 'mod\n')
@ -51,6 +69,13 @@ class APITests(
self.assertEqual(ep.value, 'mod:main') self.assertEqual(ep.value, 'mod:main')
self.assertEqual(ep.extras, []) 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): def test_metadata_for_this_package(self):
md = metadata('egginfo-pkg') md = metadata('egginfo-pkg')
assert md['author'] == 'Steven Ma' assert md['author'] == 'Steven Ma'
@ -75,13 +100,8 @@ class APITests(
def test_file_hash_repr(self): def test_file_hash_repr(self):
assertRegex = self.assertRegex assertRegex = self.assertRegex
util = [ util = [p for p in files('distinfo-pkg') if p.name == 'mod.py'][0]
p for p in files('distinfo-pkg') assertRegex(repr(util.hash), '<FileHash mode: sha256 value: .*>')
if p.name == 'mod.py'
][0]
assertRegex(
repr(util.hash),
'<FileHash mode: sha256 value: .*>')
def test_files_dist_info(self): def test_files_dist_info(self):
self._test_files(files('distinfo-pkg')) self._test_files(files('distinfo-pkg'))
@ -99,10 +119,7 @@ class APITests(
def test_requires_egg_info(self): def test_requires_egg_info(self):
deps = requires('egginfo-pkg') deps = requires('egginfo-pkg')
assert len(deps) == 2 assert len(deps) == 2
assert any( assert any(dep == 'wheel >= 1.0; python_version >= "2.7"' for dep in deps)
dep == 'wheel >= 1.0; python_version >= "2.7"'
for dep in deps
)
def test_requires_dist_info(self): def test_requires_dist_info(self):
deps = requires('distinfo-pkg') deps = requires('distinfo-pkg')
@ -112,7 +129,8 @@ class APITests(
assert "pytest; extra == 'test'" in deps assert "pytest; extra == 'test'" in deps
def test_more_complex_deps_requires_text(self): def test_more_complex_deps_requires_text(self):
requires = textwrap.dedent(""" requires = textwrap.dedent(
"""
dep1 dep1
dep2 dep2
@ -124,7 +142,8 @@ class APITests(
[extra2:python_version < "3"] [extra2:python_version < "3"]
dep5 dep5
""") """
)
deps = sorted(Distribution._deps_from_requires_text(requires)) deps = sorted(Distribution._deps_from_requires_text(requires))
expected = [ expected = [
'dep1', 'dep1',
@ -140,17 +159,27 @@ class APITests(
assert deps == expected 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): class OffSysPathTests(fixtures.DistInfoPkgOffPath, unittest.TestCase):
def test_find_distributions_specified_path(self): def test_find_distributions_specified_path(self):
dists = Distribution.discover(path=[str(self.site_dir)]) dists = Distribution.discover(path=[str(self.site_dir)])
assert any( assert any(dist.metadata['Name'] == 'distinfo-pkg' for dist in dists)
dist.metadata['Name'] == 'distinfo-pkg'
for dist in dists
)
def test_distribution_at_pathlib(self): 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_info_path = self.site_dir / 'distinfo_pkg-1.0.0.dist-info'
dist = Distribution.at(dist_info_path) dist = Distribution.at(dist_info_path)
assert dist.version == '1.0.0' assert dist.version == '1.0.0'

View File

@ -3,8 +3,12 @@ import unittest
from contextlib import ExitStack from contextlib import ExitStack
from importlib.metadata import ( from importlib.metadata import (
distribution, entry_points, files, PackageNotFoundError, PackageNotFoundError,
version, distributions, distribution,
distributions,
entry_points,
files,
version,
) )
from importlib import resources from importlib import resources

View File

@ -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.