Skip to content

Commit 3e0fec7

Browse files
authored
Sync with importlib_metadata 6.5 (GH-103584)
1 parent 5c00a62 commit 3e0fec7

File tree

10 files changed

+531
-72
lines changed

10 files changed

+531
-72
lines changed

Doc/library/importlib.metadata.rst

+4
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,10 @@ Python module or `Import Package <https://packaging.python.org/en/latest/glossar
308308
>>> packages_distributions()
309309
{'importlib_metadata': ['importlib-metadata'], 'yaml': ['PyYAML'], 'jaraco': ['jaraco.classes', 'jaraco.functools'], ...}
310310

311+
Some editable installs, `do not supply top-level names
312+
<https://github.com/pypa/packaging-problems/issues/609>`_, and thus this
313+
function is not reliable with such installs.
314+
311315
.. versionadded:: 3.10
312316

313317
.. _distributions:

Lib/importlib/metadata/__init__.py

+84-14
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
import functools
1313
import itertools
1414
import posixpath
15+
import contextlib
1516
import collections
17+
import inspect
1618

1719
from . import _adapters, _meta
1820
from ._collections import FreezableDefaultDict, Pair
@@ -24,7 +26,7 @@
2426
from importlib import import_module
2527
from importlib.abc import MetaPathFinder
2628
from itertools import starmap
27-
from typing import List, Mapping, Optional
29+
from typing import List, Mapping, Optional, cast
2830

2931

3032
__all__ = [
@@ -341,11 +343,30 @@ def __repr__(self):
341343
return f'<FileHash mode: {self.mode} value: {self.value}>'
342344

343345

344-
class Distribution:
346+
class DeprecatedNonAbstract:
347+
def __new__(cls, *args, **kwargs):
348+
all_names = {
349+
name for subclass in inspect.getmro(cls) for name in vars(subclass)
350+
}
351+
abstract = {
352+
name
353+
for name in all_names
354+
if getattr(getattr(cls, name), '__isabstractmethod__', False)
355+
}
356+
if abstract:
357+
warnings.warn(
358+
f"Unimplemented abstract methods {abstract}",
359+
DeprecationWarning,
360+
stacklevel=2,
361+
)
362+
return super().__new__(cls)
363+
364+
365+
class Distribution(DeprecatedNonAbstract):
345366
"""A Python distribution package."""
346367

347368
@abc.abstractmethod
348-
def read_text(self, filename):
369+
def read_text(self, filename) -> Optional[str]:
349370
"""Attempt to load metadata file given by the name.
350371
351372
:param filename: The name of the file in the distribution info.
@@ -419,14 +440,15 @@ def metadata(self) -> _meta.PackageMetadata:
419440
The returned object will have keys that name the various bits of
420441
metadata. See PEP 566 for details.
421442
"""
422-
text = (
443+
opt_text = (
423444
self.read_text('METADATA')
424445
or self.read_text('PKG-INFO')
425446
# This last clause is here to support old egg-info files. Its
426447
# effect is to just end up using the PathDistribution's self._path
427448
# (which points to the egg-info file) attribute unchanged.
428449
or self.read_text('')
429450
)
451+
text = cast(str, opt_text)
430452
return _adapters.Message(email.message_from_string(text))
431453

432454
@property
@@ -455,8 +477,8 @@ def files(self):
455477
:return: List of PackagePath for this distribution or None
456478
457479
Result is `None` if the metadata file that enumerates files
458-
(i.e. RECORD for dist-info or SOURCES.txt for egg-info) is
459-
missing.
480+
(i.e. RECORD for dist-info, or installed-files.txt or
481+
SOURCES.txt for egg-info) is missing.
460482
Result may be empty if the metadata exists but is empty.
461483
"""
462484

@@ -469,9 +491,19 @@ def make_file(name, hash=None, size_str=None):
469491

470492
@pass_none
471493
def make_files(lines):
472-
return list(starmap(make_file, csv.reader(lines)))
494+
return starmap(make_file, csv.reader(lines))
473495

474-
return make_files(self._read_files_distinfo() or self._read_files_egginfo())
496+
@pass_none
497+
def skip_missing_files(package_paths):
498+
return list(filter(lambda path: path.locate().exists(), package_paths))
499+
500+
return skip_missing_files(
501+
make_files(
502+
self._read_files_distinfo()
503+
or self._read_files_egginfo_installed()
504+
or self._read_files_egginfo_sources()
505+
)
506+
)
475507

476508
def _read_files_distinfo(self):
477509
"""
@@ -480,10 +512,43 @@ def _read_files_distinfo(self):
480512
text = self.read_text('RECORD')
481513
return text and text.splitlines()
482514

483-
def _read_files_egginfo(self):
515+
def _read_files_egginfo_installed(self):
516+
"""
517+
Read installed-files.txt and return lines in a similar
518+
CSV-parsable format as RECORD: each file must be placed
519+
relative to the site-packages directory, and must also be
520+
quoted (since file names can contain literal commas).
521+
522+
This file is written when the package is installed by pip,
523+
but it might not be written for other installation methods.
524+
Hence, even if we can assume that this file is accurate
525+
when it exists, we cannot assume that it always exists.
484526
"""
485-
SOURCES.txt might contain literal commas, so wrap each line
486-
in quotes.
527+
text = self.read_text('installed-files.txt')
528+
# We need to prepend the .egg-info/ subdir to the lines in this file.
529+
# But this subdir is only available in the PathDistribution's self._path
530+
# which is not easily accessible from this base class...
531+
subdir = getattr(self, '_path', None)
532+
if not text or not subdir:
533+
return
534+
with contextlib.suppress(Exception):
535+
ret = [
536+
str((subdir / line).resolve().relative_to(self.locate_file('')))
537+
for line in text.splitlines()
538+
]
539+
return map('"{}"'.format, ret)
540+
541+
def _read_files_egginfo_sources(self):
542+
"""
543+
Read SOURCES.txt and return lines in a similar CSV-parsable
544+
format as RECORD: each file name must be quoted (since it
545+
might contain literal commas).
546+
547+
Note that SOURCES.txt is not a reliable source for what
548+
files are installed by a package. This file is generated
549+
for a source archive, and the files that are present
550+
there (e.g. setup.py) may not correctly reflect the files
551+
that are present after the package has been installed.
487552
"""
488553
text = self.read_text('SOURCES.txt')
489554
return text and map('"{}"'.format, text.splitlines())
@@ -886,8 +951,13 @@ def _top_level_declared(dist):
886951

887952

888953
def _top_level_inferred(dist):
889-
return {
890-
f.parts[0] if len(f.parts) > 1 else f.with_suffix('').name
954+
opt_names = {
955+
f.parts[0] if len(f.parts) > 1 else inspect.getmodulename(f)
891956
for f in always_iterable(dist.files)
892-
if f.suffix == ".py"
893957
}
958+
959+
@pass_none
960+
def importable_name(name):
961+
return '.' not in name
962+
963+
return filter(importable_name, opt_names)

Lib/importlib/metadata/_adapters.py

+21
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
1+
import functools
2+
import warnings
13
import re
24
import textwrap
35
import email.message
46

57
from ._text import FoldedCase
68

79

10+
# Do not remove prior to 2024-01-01 or Python 3.14
11+
_warn = functools.partial(
12+
warnings.warn,
13+
"Implicit None on return values is deprecated and will raise KeyErrors.",
14+
DeprecationWarning,
15+
stacklevel=2,
16+
)
17+
18+
819
class Message(email.message.Message):
920
multiple_use_keys = set(
1021
map(
@@ -39,6 +50,16 @@ def __init__(self, *args, **kwargs):
3950
def __iter__(self):
4051
return super().__iter__()
4152

53+
def __getitem__(self, item):
54+
"""
55+
Warn users that a ``KeyError`` can be expected when a
56+
mising key is supplied. Ref python/importlib_metadata#371.
57+
"""
58+
res = super().__getitem__(item)
59+
if res is None:
60+
_warn()
61+
return res
62+
4263
def _repair_headers(self):
4364
def redent(value):
4465
"Correct for RFC822 indentation"

Lib/importlib/metadata/_meta.py

+22-6
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from typing import Any, Dict, Iterator, List, Protocol, TypeVar, Union
1+
from typing import Protocol
2+
from typing import Any, Dict, Iterator, List, Optional, TypeVar, Union, overload
23

34

45
_T = TypeVar("_T")
@@ -17,7 +18,21 @@ def __getitem__(self, key: str) -> str:
1718
def __iter__(self) -> Iterator[str]:
1819
... # pragma: no cover
1920

20-
def get_all(self, name: str, failobj: _T = ...) -> Union[List[Any], _T]:
21+
@overload
22+
def get(self, name: str, failobj: None = None) -> Optional[str]:
23+
... # pragma: no cover
24+
25+
@overload
26+
def get(self, name: str, failobj: _T) -> Union[str, _T]:
27+
... # pragma: no cover
28+
29+
# overload per python/importlib_metadata#435
30+
@overload
31+
def get_all(self, name: str, failobj: None = None) -> Optional[List[Any]]:
32+
... # pragma: no cover
33+
34+
@overload
35+
def get_all(self, name: str, failobj: _T) -> Union[List[Any], _T]:
2136
"""
2237
Return all values associated with a possibly multi-valued key.
2338
"""
@@ -29,18 +44,19 @@ def json(self) -> Dict[str, Union[str, List[str]]]:
2944
"""
3045

3146

32-
class SimplePath(Protocol):
47+
class SimplePath(Protocol[_T]):
3348
"""
3449
A minimal subset of pathlib.Path required by PathDistribution.
3550
"""
3651

37-
def joinpath(self) -> 'SimplePath':
52+
def joinpath(self) -> _T:
3853
... # pragma: no cover
3954

40-
def __truediv__(self) -> 'SimplePath':
55+
def __truediv__(self, other: Union[str, _T]) -> _T:
4156
... # pragma: no cover
4257

43-
def parent(self) -> 'SimplePath':
58+
@property
59+
def parent(self) -> _T:
4460
... # pragma: no cover
4561

4662
def read_text(self) -> str:

Lib/test/test_importlib/_context.py

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import contextlib
2+
3+
4+
# from jaraco.context 4.3
5+
class suppress(contextlib.suppress, contextlib.ContextDecorator):
6+
"""
7+
A version of contextlib.suppress with decorator support.
8+
9+
>>> @suppress(KeyError)
10+
... def key_error():
11+
... {}['']
12+
>>> key_error()
13+
"""

Lib/test/test_importlib/_path.py

+109
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# from jaraco.path 3.5
2+
3+
import functools
4+
import pathlib
5+
from typing import Dict, Union
6+
7+
try:
8+
from typing import Protocol, runtime_checkable
9+
except ImportError: # pragma: no cover
10+
# Python 3.7
11+
from typing_extensions import Protocol, runtime_checkable # type: ignore
12+
13+
14+
FilesSpec = Dict[str, Union[str, bytes, 'FilesSpec']] # type: ignore
15+
16+
17+
@runtime_checkable
18+
class TreeMaker(Protocol):
19+
def __truediv__(self, *args, **kwargs):
20+
... # pragma: no cover
21+
22+
def mkdir(self, **kwargs):
23+
... # pragma: no cover
24+
25+
def write_text(self, content, **kwargs):
26+
... # pragma: no cover
27+
28+
def write_bytes(self, content):
29+
... # pragma: no cover
30+
31+
32+
def _ensure_tree_maker(obj: Union[str, TreeMaker]) -> TreeMaker:
33+
return obj if isinstance(obj, TreeMaker) else pathlib.Path(obj) # type: ignore
34+
35+
36+
def build(
37+
spec: FilesSpec,
38+
prefix: Union[str, TreeMaker] = pathlib.Path(), # type: ignore
39+
):
40+
"""
41+
Build a set of files/directories, as described by the spec.
42+
43+
Each key represents a pathname, and the value represents
44+
the content. Content may be a nested directory.
45+
46+
>>> spec = {
47+
... 'README.txt': "A README file",
48+
... "foo": {
49+
... "__init__.py": "",
50+
... "bar": {
51+
... "__init__.py": "",
52+
... },
53+
... "baz.py": "# Some code",
54+
... }
55+
... }
56+
>>> target = getfixture('tmp_path')
57+
>>> build(spec, target)
58+
>>> target.joinpath('foo/baz.py').read_text(encoding='utf-8')
59+
'# Some code'
60+
"""
61+
for name, contents in spec.items():
62+
create(contents, _ensure_tree_maker(prefix) / name)
63+
64+
65+
@functools.singledispatch
66+
def create(content: Union[str, bytes, FilesSpec], path):
67+
path.mkdir(exist_ok=True)
68+
build(content, prefix=path) # type: ignore
69+
70+
71+
@create.register
72+
def _(content: bytes, path):
73+
path.write_bytes(content)
74+
75+
76+
@create.register
77+
def _(content: str, path):
78+
path.write_text(content, encoding='utf-8')
79+
80+
81+
@create.register
82+
def _(content: str, path):
83+
path.write_text(content, encoding='utf-8')
84+
85+
86+
class Recording:
87+
"""
88+
A TreeMaker object that records everything that would be written.
89+
90+
>>> r = Recording()
91+
>>> build({'foo': {'foo1.txt': 'yes'}, 'bar.txt': 'abc'}, r)
92+
>>> r.record
93+
['foo/foo1.txt', 'bar.txt']
94+
"""
95+
96+
def __init__(self, loc=pathlib.PurePosixPath(), record=None):
97+
self.loc = loc
98+
self.record = record if record is not None else []
99+
100+
def __truediv__(self, other):
101+
return Recording(self.loc / other, self.record)
102+
103+
def write_text(self, content, **kwargs):
104+
self.record.append(str(self.loc))
105+
106+
write_bytes = write_text
107+
108+
def mkdir(self, **kwargs):
109+
return

0 commit comments

Comments
 (0)