gh-113174: Sync with importlib_metadata 7.0 (#113175)

* Sync with importlib_metadata 7.0.0

* Add blurb

* Update docs to reflect changes.

* Link datamodel docs for object.__getitem__

Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>

* Add what's new for removed __getattr__

* Link datamodel docs for object.__getitem__

Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>

* Add exclamation point, as that seems to be used for other classes.

---------

Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
This commit is contained in:
Jason R. Coombs 2023-12-21 15:04:05 -05:00 committed by GitHub
parent 6b70c3dc5a
commit 2d91409c69
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 267 additions and 146 deletions

View File

@ -171,16 +171,18 @@ group. Read `the setuptools docs
<https://setuptools.pypa.io/en/latest/userguide/entry_point.html>`_
for more information on entry points, their definition, and usage.
*Compatibility Note*
The "selectable" entry points were introduced in ``importlib_metadata``
3.6 and Python 3.10. Prior to those changes, ``entry_points`` accepted
no parameters and always returned a dictionary of entry points, keyed
by group. With ``importlib_metadata`` 5.0 and Python 3.12,
``entry_points`` always returns an ``EntryPoints`` object. See
`backports.entry_points_selectable <https://pypi.org/project/backports.entry-points-selectable>`_
for compatibility options.
.. versionchanged:: 3.12
The "selectable" entry points were introduced in ``importlib_metadata``
3.6 and Python 3.10. Prior to those changes, ``entry_points`` accepted
no parameters and always returned a dictionary of entry points, keyed
by group. With ``importlib_metadata`` 5.0 and Python 3.12,
``entry_points`` always returns an ``EntryPoints`` object. See
`backports.entry_points_selectable <https://pypi.org/project/backports.entry-points-selectable>`_
for compatibility options.
.. versionchanged:: 3.13
``EntryPoint`` objects no longer present a tuple-like interface
(:meth:`~object.__getitem__`).
.. _metadata:
@ -342,9 +344,17 @@ instance::
>>> dist.metadata['License'] # doctest: +SKIP
'MIT'
For editable packages, an origin property may present :pep:`610`
metadata::
>>> dist.origin.url
'file:///path/to/wheel-0.32.3.editable-py3-none-any.whl'
The full set of available metadata is not described here.
See the `Core metadata specifications <https://packaging.python.org/en/latest/specifications/core-metadata/#core-metadata>`_ for additional details.
.. versionadded:: 3.13
The ``.origin`` property was added.
Distribution Discovery
======================

View File

@ -1001,6 +1001,10 @@ importlib
for migration advice.
(Contributed by Jason R. Coombs in :gh:`106532`.)
* Remove deprecated :meth:`~object.__getitem__` access for
:class:`!importlib.metadata.EntryPoint` objects.
(Contributed by Jason R. Coombs in :gh:`113175`.)
locale
------

View File

@ -3,7 +3,10 @@ import re
import abc
import csv
import sys
import json
import email
import types
import inspect
import pathlib
import zipfile
import operator
@ -13,7 +16,6 @@ import functools
import itertools
import posixpath
import collections
import inspect
from . import _adapters, _meta
from ._collections import FreezableDefaultDict, Pair
@ -25,8 +27,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, cast
from typing import Iterable, List, Mapping, Optional, Set, Union, cast
__all__ = [
'Distribution',
@ -47,11 +48,11 @@ __all__ = [
class PackageNotFoundError(ModuleNotFoundError):
"""The package was not found."""
def __str__(self):
def __str__(self) -> str:
return f"No package metadata was found for {self.name}"
@property
def name(self):
def name(self) -> str: # type: ignore[override]
(name,) = self.args
return name
@ -117,38 +118,11 @@ class Sectioned:
yield Pair(name, value)
@staticmethod
def valid(line):
def valid(line: str):
return line and not line.startswith('#')
class DeprecatedTuple:
"""
Provide subscript item access for backward compatibility.
>>> recwarn = getfixture('recwarn')
>>> ep = EntryPoint(name='name', value='value', group='group')
>>> ep[:]
('name', 'value', 'group')
>>> ep[0]
'name'
>>> len(recwarn)
1
"""
# Do not remove prior to 2023-05-01 or Python 3.13
_warn = functools.partial(
warnings.warn,
"EntryPoint tuple interface is deprecated. Access members by name.",
DeprecationWarning,
stacklevel=2,
)
def __getitem__(self, item):
self._warn()
return self._key()[item]
class EntryPoint(DeprecatedTuple):
class EntryPoint:
"""An entry point as defined by Python packaging conventions.
See `the packaging docs on entry points
@ -192,7 +166,7 @@ class EntryPoint(DeprecatedTuple):
dist: Optional['Distribution'] = None
def __init__(self, name, value, group):
def __init__(self, name: str, value: str, group: str) -> None:
vars(self).update(name=name, value=value, group=group)
def load(self):
@ -206,18 +180,21 @@ class EntryPoint(DeprecatedTuple):
return functools.reduce(getattr, attrs, module)
@property
def module(self):
def module(self) -> str:
match = self.pattern.match(self.value)
assert match is not None
return match.group('module')
@property
def attr(self):
def attr(self) -> str:
match = self.pattern.match(self.value)
assert match is not None
return match.group('attr')
@property
def extras(self):
def extras(self) -> List[str]:
match = self.pattern.match(self.value)
assert match is not None
return re.findall(r'\w+', match.group('extras') or '')
def _for(self, dist):
@ -265,7 +242,7 @@ class EntryPoint(DeprecatedTuple):
f'group={self.group!r})'
)
def __hash__(self):
def __hash__(self) -> int:
return hash(self._key())
@ -276,7 +253,7 @@ class EntryPoints(tuple):
__slots__ = ()
def __getitem__(self, name): # -> EntryPoint:
def __getitem__(self, name: str) -> EntryPoint: # type: ignore[override]
"""
Get the EntryPoint in self matching name.
"""
@ -285,6 +262,13 @@ class EntryPoints(tuple):
except StopIteration:
raise KeyError(name)
def __repr__(self):
"""
Repr with classname and tuple constructor to
signal that we deviate from regular tuple behavior.
"""
return '%s(%r)' % (self.__class__.__name__, tuple(self))
def select(self, **params):
"""
Select entry points from self that match the
@ -293,14 +277,14 @@ class EntryPoints(tuple):
return EntryPoints(ep for ep in self if ep.matches(**params))
@property
def names(self):
def names(self) -> Set[str]:
"""
Return the set of all names of all entry points.
"""
return {ep.name for ep in self}
@property
def groups(self):
def groups(self) -> Set[str]:
"""
Return the set of all groups of all entry points.
"""
@ -321,24 +305,28 @@ class EntryPoints(tuple):
class PackagePath(pathlib.PurePosixPath):
"""A reference to a path in a package"""
def read_text(self, encoding='utf-8'):
hash: Optional["FileHash"]
size: int
dist: "Distribution"
def read_text(self, encoding: str = 'utf-8') -> str: # type: ignore[override]
with self.locate().open(encoding=encoding) as stream:
return stream.read()
def read_binary(self):
def read_binary(self) -> bytes:
with self.locate().open('rb') as stream:
return stream.read()
def locate(self):
def locate(self) -> pathlib.Path:
"""Return a path-like object for this path"""
return self.dist.locate_file(self)
class FileHash:
def __init__(self, spec):
def __init__(self, spec: str) -> None:
self.mode, _, self.value = spec.partition('=')
def __repr__(self):
def __repr__(self) -> str:
return f'<FileHash mode: {self.mode} value: {self.value}>'
@ -373,14 +361,14 @@ class Distribution(DeprecatedNonAbstract):
"""
@abc.abstractmethod
def locate_file(self, path):
def locate_file(self, path: Union[str, os.PathLike[str]]) -> pathlib.Path:
"""
Given a path to a file in this distribution, return a path
to it.
"""
@classmethod
def from_name(cls, name: str):
def from_name(cls, name: str) -> "Distribution":
"""Return the Distribution for the given package name.
:param name: The name of the distribution package to search for.
@ -393,12 +381,12 @@ class Distribution(DeprecatedNonAbstract):
if not name:
raise ValueError("A distribution name is required.")
try:
return next(cls.discover(name=name))
return next(iter(cls.discover(name=name)))
except StopIteration:
raise PackageNotFoundError(name)
@classmethod
def discover(cls, **kwargs):
def discover(cls, **kwargs) -> Iterable["Distribution"]:
"""Return an iterable of Distribution objects for all packages.
Pass a ``context`` or pass keyword arguments for constructing
@ -416,7 +404,7 @@ class Distribution(DeprecatedNonAbstract):
)
@staticmethod
def at(path):
def at(path: Union[str, os.PathLike[str]]) -> "Distribution":
"""Return a Distribution for the indicated metadata path
:param path: a string or path-like object
@ -451,7 +439,7 @@ class Distribution(DeprecatedNonAbstract):
return _adapters.Message(email.message_from_string(text))
@property
def name(self):
def name(self) -> str:
"""Return the 'Name' metadata for the distribution package."""
return self.metadata['Name']
@ -461,16 +449,16 @@ class Distribution(DeprecatedNonAbstract):
return Prepared.normalize(self.name)
@property
def version(self):
def version(self) -> str:
"""Return the 'Version' metadata for the distribution package."""
return self.metadata['Version']
@property
def entry_points(self):
def entry_points(self) -> EntryPoints:
return EntryPoints._from_text_for(self.read_text('entry_points.txt'), self)
@property
def files(self):
def files(self) -> Optional[List[PackagePath]]:
"""Files in this distribution.
:return: List of PackagePath for this distribution or None
@ -555,7 +543,7 @@ class Distribution(DeprecatedNonAbstract):
return text and map('"{}"'.format, text.splitlines())
@property
def requires(self):
def requires(self) -> Optional[List[str]]:
"""Generated requirements specified for this Distribution"""
reqs = self._read_dist_info_reqs() or self._read_egg_info_reqs()
return reqs and list(reqs)
@ -606,6 +594,16 @@ class Distribution(DeprecatedNonAbstract):
space = url_req_space(section.value)
yield section.value + space + quoted_marker(section.name)
@property
def origin(self):
return self._load_json('direct_url.json')
def _load_json(self, filename):
return pass_none(json.loads)(
self.read_text(filename),
object_hook=lambda data: types.SimpleNamespace(**data),
)
class DistributionFinder(MetaPathFinder):
"""
@ -634,7 +632,7 @@ class DistributionFinder(MetaPathFinder):
vars(self).update(kwargs)
@property
def path(self):
def path(self) -> List[str]:
"""
The sequence of directory path that a distribution finder
should search.
@ -645,7 +643,7 @@ class DistributionFinder(MetaPathFinder):
return vars(self).get('path', sys.path)
@abc.abstractmethod
def find_distributions(self, context=Context()):
def find_distributions(self, context=Context()) -> Iterable[Distribution]:
"""
Find distributions.
@ -774,7 +772,9 @@ class Prepared:
class MetadataPathFinder(DistributionFinder):
@classmethod
def find_distributions(cls, context=DistributionFinder.Context()):
def find_distributions(
cls, context=DistributionFinder.Context()
) -> Iterable["PathDistribution"]:
"""
Find distributions.
@ -794,19 +794,19 @@ class MetadataPathFinder(DistributionFinder):
path.search(prepared) for path in map(FastPath, paths)
)
def invalidate_caches(cls):
def invalidate_caches(cls) -> None:
FastPath.__new__.cache_clear()
class PathDistribution(Distribution):
def __init__(self, path: SimplePath):
def __init__(self, path: SimplePath) -> None:
"""Construct a distribution.
:param path: SimplePath indicating the metadata directory.
"""
self._path = path
def read_text(self, filename):
def read_text(self, filename: Union[str, os.PathLike[str]]) -> Optional[str]:
with suppress(
FileNotFoundError,
IsADirectoryError,
@ -816,9 +816,11 @@ class PathDistribution(Distribution):
):
return self._path.joinpath(filename).read_text(encoding='utf-8')
return None
read_text.__doc__ = Distribution.read_text.__doc__
def locate_file(self, path):
def locate_file(self, path: Union[str, os.PathLike[str]]) -> pathlib.Path:
return self._path.parent / path
@property
@ -851,7 +853,7 @@ class PathDistribution(Distribution):
return name
def distribution(distribution_name):
def distribution(distribution_name: str) -> Distribution:
"""Get the ``Distribution`` instance for the named package.
:param distribution_name: The name of the distribution package as a string.
@ -860,7 +862,7 @@ def distribution(distribution_name):
return Distribution.from_name(distribution_name)
def distributions(**kwargs):
def distributions(**kwargs) -> Iterable[Distribution]:
"""Get all ``Distribution`` instances in the current environment.
:return: An iterable of ``Distribution`` instances.
@ -868,7 +870,7 @@ def distributions(**kwargs):
return Distribution.discover(**kwargs)
def metadata(distribution_name) -> _meta.PackageMetadata:
def metadata(distribution_name: str) -> _meta.PackageMetadata:
"""Get the metadata for the named package.
:param distribution_name: The name of the distribution package to query.
@ -877,7 +879,7 @@ def metadata(distribution_name) -> _meta.PackageMetadata:
return Distribution.from_name(distribution_name).metadata
def version(distribution_name):
def version(distribution_name: str) -> str:
"""Get the version string for the named package.
:param distribution_name: The name of the distribution package to query.
@ -911,7 +913,7 @@ def entry_points(**params) -> EntryPoints:
return EntryPoints(eps).select(**params)
def files(distribution_name):
def files(distribution_name: str) -> Optional[List[PackagePath]]:
"""Return a list of files for the named package.
:param distribution_name: The name of the distribution package to query.
@ -920,11 +922,11 @@ def files(distribution_name):
return distribution(distribution_name).files
def requires(distribution_name):
def requires(distribution_name: str) -> Optional[List[str]]:
"""
Return a list of requirements for the named package.
:return: An iterator of requirements, suitable for
:return: An iterable of requirements, suitable for
packaging.requirement.Requirement.
"""
return distribution(distribution_name).requires
@ -951,13 +953,42 @@ def _top_level_declared(dist):
return (dist.read_text('top_level.txt') or '').split()
def _top_level_inferred(dist):
opt_names = {
f.parts[0] if len(f.parts) > 1 else inspect.getmodulename(f)
for f in always_iterable(dist.files)
}
def _topmost(name: PackagePath) -> Optional[str]:
"""
Return the top-most parent as long as there is a parent.
"""
top, *rest = name.parts
return top if rest else None
def _get_toplevel_name(name: PackagePath) -> str:
"""
Infer a possibly importable module name from a name presumed on
sys.path.
>>> _get_toplevel_name(PackagePath('foo.py'))
'foo'
>>> _get_toplevel_name(PackagePath('foo'))
'foo'
>>> _get_toplevel_name(PackagePath('foo.pyc'))
'foo'
>>> _get_toplevel_name(PackagePath('foo/__init__.py'))
'foo'
>>> _get_toplevel_name(PackagePath('foo.pth'))
'foo.pth'
>>> _get_toplevel_name(PackagePath('foo.dist-info'))
'foo.dist-info'
"""
return _topmost(name) or (
# python/typeshed#10328
inspect.getmodulename(name) # type: ignore
or str(name)
)
def _top_level_inferred(dist):
opt_names = set(map(_get_toplevel_name, always_iterable(dist.files)))
@pass_none
def importable_name(name):
return '.' not in name

View File

@ -53,7 +53,7 @@ class Message(email.message.Message):
def __getitem__(self, item):
"""
Warn users that a ``KeyError`` can be expected when a
mising key is supplied. Ref python/importlib_metadata#371.
missing key is supplied. Ref python/importlib_metadata#371.
"""
res = super().__getitem__(item)
if res is None:

View File

@ -49,7 +49,7 @@ class SimplePath(Protocol[_T]):
A minimal subset of pathlib.Path required by PathDistribution.
"""
def joinpath(self) -> _T:
def joinpath(self, other: Union[str, _T]) -> _T:
... # pragma: no cover
def __truediv__(self, other: Union[str, _T]) -> _T:

View File

@ -0,0 +1,21 @@
import sys
from . import Distribution
def inspect(path):
print("Inspecting", path)
dists = list(Distribution.discover(path=[path]))
if not dists:
return
print("Found", len(dists), "packages:", end=' ')
print(', '.join(dist.name for dist in dists))
def run():
for path in sys.path:
inspect(path)
if __name__ == '__main__':
run()

View File

@ -1,17 +1,18 @@
# from jaraco.path 3.5
# from jaraco.path 3.7
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
from typing import Dict, Protocol, Union
from typing import runtime_checkable
FilesSpec = Dict[str, Union[str, bytes, 'FilesSpec']] # type: ignore
class Symlink(str):
"""
A string indicating the target of a symlink.
"""
FilesSpec = Dict[str, Union[str, bytes, Symlink, 'FilesSpec']] # type: ignore
@runtime_checkable
@ -28,6 +29,9 @@ class TreeMaker(Protocol):
def write_bytes(self, content):
... # pragma: no cover
def symlink_to(self, target):
... # pragma: no cover
def _ensure_tree_maker(obj: Union[str, TreeMaker]) -> TreeMaker:
return obj if isinstance(obj, TreeMaker) else pathlib.Path(obj) # type: ignore
@ -51,12 +55,16 @@ def build(
... "__init__.py": "",
... },
... "baz.py": "# Some code",
... }
... "bar.py": Symlink("baz.py"),
... },
... "bing": Symlink("foo"),
... }
>>> target = getfixture('tmp_path')
>>> build(spec, target)
>>> target.joinpath('foo/baz.py').read_text(encoding='utf-8')
'# Some code'
>>> target.joinpath('bing/bar.py').read_text(encoding='utf-8')
'# Some code'
"""
for name, contents in spec.items():
create(contents, _ensure_tree_maker(prefix) / name)
@ -79,8 +87,8 @@ def _(content: str, path):
@create.register
def _(content: str, path):
path.write_text(content, encoding='utf-8')
def _(content: Symlink, path):
path.symlink_to(content)
class Recording:
@ -107,3 +115,6 @@ class Recording:
def mkdir(self, **kwargs):
return
def symlink_to(self, target):
pass

View File

@ -1,6 +1,7 @@
import os
import sys
import copy
import json
import shutil
import pathlib
import tempfile
@ -86,7 +87,15 @@ class OnSysPath(Fixtures):
self.fixtures.enter_context(self.add_sys_path(self.site_dir))
class DistInfoPkg(OnSysPath, SiteDir):
class SiteBuilder(SiteDir):
def setUp(self):
super().setUp()
for cls in self.__class__.mro():
with contextlib.suppress(AttributeError):
build_files(cls.files, prefix=self.site_dir)
class DistInfoPkg(OnSysPath, SiteBuilder):
files: FilesSpec = {
"distinfo_pkg-1.0.0.dist-info": {
"METADATA": """
@ -113,10 +122,6 @@ class DistInfoPkg(OnSysPath, SiteDir):
""",
}
def setUp(self):
super().setUp()
build_files(DistInfoPkg.files, self.site_dir)
def make_uppercase(self):
"""
Rewrite metadata with everything uppercase.
@ -128,7 +133,28 @@ class DistInfoPkg(OnSysPath, SiteDir):
build_files(files, self.site_dir)
class DistInfoPkgWithDot(OnSysPath, SiteDir):
class DistInfoPkgEditable(DistInfoPkg):
"""
Package with a PEP 660 direct_url.json.
"""
some_hash = '524127ce937f7cb65665130c695abd18ca386f60bb29687efb976faa1596fdcc'
files: FilesSpec = {
'distinfo_pkg-1.0.0.dist-info': {
'direct_url.json': json.dumps(
{
"archive_info": {
"hash": f"sha256={some_hash}",
"hashes": {"sha256": f"{some_hash}"},
},
"url": "file:///path/to/distinfo_pkg-1.0.0.editable-py3-none-any.whl",
}
)
},
}
class DistInfoPkgWithDot(OnSysPath, SiteBuilder):
files: FilesSpec = {
"pkg_dot-1.0.0.dist-info": {
"METADATA": """
@ -138,12 +164,8 @@ class DistInfoPkgWithDot(OnSysPath, SiteDir):
},
}
def setUp(self):
super().setUp()
build_files(DistInfoPkgWithDot.files, self.site_dir)
class DistInfoPkgWithDotLegacy(OnSysPath, SiteDir):
class DistInfoPkgWithDotLegacy(OnSysPath, SiteBuilder):
files: FilesSpec = {
"pkg.dot-1.0.0.dist-info": {
"METADATA": """
@ -159,18 +181,12 @@ class DistInfoPkgWithDotLegacy(OnSysPath, SiteDir):
},
}
def setUp(self):
super().setUp()
build_files(DistInfoPkgWithDotLegacy.files, self.site_dir)
class DistInfoPkgOffPath(SiteBuilder):
files = DistInfoPkg.files
class DistInfoPkgOffPath(SiteDir):
def setUp(self):
super().setUp()
build_files(DistInfoPkg.files, self.site_dir)
class EggInfoPkg(OnSysPath, SiteDir):
class EggInfoPkg(OnSysPath, SiteBuilder):
files: FilesSpec = {
"egginfo_pkg.egg-info": {
"PKG-INFO": """
@ -205,12 +221,8 @@ class EggInfoPkg(OnSysPath, SiteDir):
""",
}
def setUp(self):
super().setUp()
build_files(EggInfoPkg.files, prefix=self.site_dir)
class EggInfoPkgPipInstalledNoToplevel(OnSysPath, SiteDir):
class EggInfoPkgPipInstalledNoToplevel(OnSysPath, SiteBuilder):
files: FilesSpec = {
"egg_with_module_pkg.egg-info": {
"PKG-INFO": "Name: egg_with_module-pkg",
@ -240,12 +252,8 @@ class EggInfoPkgPipInstalledNoToplevel(OnSysPath, SiteDir):
""",
}
def setUp(self):
super().setUp()
build_files(EggInfoPkgPipInstalledNoToplevel.files, prefix=self.site_dir)
class EggInfoPkgPipInstalledNoModules(OnSysPath, SiteDir):
class EggInfoPkgPipInstalledNoModules(OnSysPath, SiteBuilder):
files: FilesSpec = {
"egg_with_no_modules_pkg.egg-info": {
"PKG-INFO": "Name: egg_with_no_modules-pkg",
@ -270,12 +278,8 @@ class EggInfoPkgPipInstalledNoModules(OnSysPath, SiteDir):
},
}
def setUp(self):
super().setUp()
build_files(EggInfoPkgPipInstalledNoModules.files, prefix=self.site_dir)
class EggInfoPkgSourcesFallback(OnSysPath, SiteDir):
class EggInfoPkgSourcesFallback(OnSysPath, SiteBuilder):
files: FilesSpec = {
"sources_fallback_pkg.egg-info": {
"PKG-INFO": "Name: sources_fallback-pkg",
@ -296,12 +300,8 @@ class EggInfoPkgSourcesFallback(OnSysPath, SiteDir):
""",
}
def setUp(self):
super().setUp()
build_files(EggInfoPkgSourcesFallback.files, prefix=self.site_dir)
class EggInfoFile(OnSysPath, SiteDir):
class EggInfoFile(OnSysPath, SiteBuilder):
files: FilesSpec = {
"egginfo_file.egg-info": """
Metadata-Version: 1.0
@ -317,10 +317,6 @@ class EggInfoFile(OnSysPath, SiteDir):
""",
}
def setUp(self):
super().setUp()
build_files(EggInfoFile.files, prefix=self.site_dir)
# dedent all text strings before writing
orig = _path.create.registry[str]

View File

@ -12,6 +12,7 @@ except ImportError:
from . import fixtures
from ._context import suppress
from ._path import Symlink
from importlib.metadata import (
Distribution,
EntryPoint,
@ -68,7 +69,7 @@ class BasicTests(fixtures.DistInfoPkg, unittest.TestCase):
dict(name=''),
)
def test_invalid_inputs_to_from_name(self, name):
with self.assertRaises(ValueError):
with self.assertRaises(Exception):
Distribution.from_name(name)
@ -207,6 +208,20 @@ class DiscoveryTests(
with self.assertRaises(ValueError):
list(distributions(context='something', name='else'))
def test_interleaved_discovery(self):
"""
Ensure interleaved searches are safe.
When the search is cached, it is possible for searches to be
interleaved, so make sure those use-cases are safe.
Ref #293
"""
dists = distributions()
next(dists)
version('egginfo-pkg')
next(dists)
class DirectoryTest(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
def test_egg_info(self):
@ -388,6 +403,27 @@ class PackagesDistributionsTest(
assert not any(name.endswith('.dist-info') for name in distributions)
def test_packages_distributions_symlinked_top_level(self) -> None:
"""
Distribution is resolvable from a simple top-level symlink in RECORD.
See #452.
"""
files: fixtures.FilesSpec = {
"symlinked_pkg-1.0.0.dist-info": {
"METADATA": """
Name: symlinked-pkg
Version: 1.0.0
""",
"RECORD": "symlinked,,\n",
},
".symlink.target": {},
"symlinked": Symlink(".symlink.target"),
}
fixtures.build_files(files, self.site_dir)
assert packages_distributions()['symlinked'] == ['symlinked-pkg']
class PackagesDistributionsEggTest(
fixtures.EggInfoPkg,
@ -424,3 +460,10 @@ class PackagesDistributionsEggTest(
# 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'}
class EditableDistributionTest(fixtures.DistInfoPkgEditable, unittest.TestCase):
def test_origin(self):
dist = Distribution.from_name('distinfo-pkg')
assert dist.origin.url.endswith('.whl')
assert dist.origin.archive_info.hashes.sha256

View File

@ -0,0 +1,5 @@
Sync with importlib_metadata 7.0, including improved type annotations, fixed
issue with symlinked packages in ``package_distributions``, added
``EntryPoints.__repr__``, introduced the ``diagnose`` script, added
``Distribution.origin`` property, and removed deprecated ``EntryPoint``
access by numeric index (tuple behavior).