Skip to content

Commit f5d47fc

Browse files
authored
gh-143387: Raise an exception instead of returning None when metadata file is missing. (#146234)
1 parent 1114d7f commit f5d47fc

File tree

4 files changed

+159
-12
lines changed

4 files changed

+159
-12
lines changed

Lib/importlib/metadata/__init__.py

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232
from . import _meta
3333
from ._collections import FreezableDefaultDict, Pair
34+
from ._context import ExceptionTrap
3435
from ._functools import method_cache, noop, pass_none, passthrough
3536
from ._itertools import always_iterable, bucket, unique_everseen
3637
from ._meta import PackageMetadata, SimplePath
@@ -42,6 +43,7 @@
4243
'PackageMetadata',
4344
'PackageNotFoundError',
4445
'PackagePath',
46+
'MetadataNotFound',
4547
'SimplePath',
4648
'distribution',
4749
'distributions',
@@ -66,6 +68,10 @@ def name(self) -> str: # type: ignore[override] # make readonly
6668
return name
6769

6870

71+
class MetadataNotFound(FileNotFoundError):
72+
"""No metadata file is present in the distribution."""
73+
74+
6975
class Sectioned:
7076
"""
7177
A simple entry point config parser for performance
@@ -487,7 +493,12 @@ def _prefer_valid(dists: Iterable[Distribution]) -> Iterable[Distribution]:
487493
488494
Ref python/importlib_resources#489.
489495
"""
490-
buckets = bucket(dists, lambda dist: bool(dist.metadata))
496+
497+
has_metadata = ExceptionTrap(MetadataNotFound).passes(
498+
operator.attrgetter('metadata')
499+
)
500+
501+
buckets = bucket(dists, has_metadata)
491502
return itertools.chain(buckets[True], buckets[False])
492503

493504
@staticmethod
@@ -508,7 +519,7 @@ def _discover_resolvers():
508519
return filter(None, declared)
509520

510521
@property
511-
def metadata(self) -> _meta.PackageMetadata | None:
522+
def metadata(self) -> _meta.PackageMetadata:
512523
"""Return the parsed metadata for this Distribution.
513524
514525
The returned object will have keys that name the various bits of
@@ -517,6 +528,8 @@ def metadata(self) -> _meta.PackageMetadata | None:
517528
518529
Custom providers may provide the METADATA file or override this
519530
property.
531+
532+
:raises MetadataNotFound: If no metadata file is present.
520533
"""
521534

522535
text = (
@@ -527,20 +540,25 @@ def metadata(self) -> _meta.PackageMetadata | None:
527540
# (which points to the egg-info file) attribute unchanged.
528541
or self.read_text('')
529542
)
530-
return self._assemble_message(text)
543+
return self._assemble_message(self._ensure_metadata_present(text))
531544

532545
@staticmethod
533-
@pass_none
534546
def _assemble_message(text: str) -> _meta.PackageMetadata:
535547
# deferred for performance (python/cpython#109829)
536548
from . import _adapters
537549

538550
return _adapters.Message(email.message_from_string(text))
539551

552+
def _ensure_metadata_present(self, text: str | None) -> str:
553+
if text is not None:
554+
return text
555+
556+
raise MetadataNotFound('No package metadata was found.')
557+
540558
@property
541559
def name(self) -> str:
542560
"""Return the 'Name' metadata for the distribution package."""
543-
return md_none(self.metadata)['Name']
561+
return self.metadata['Name']
544562

545563
@property
546564
def _normalized_name(self):
@@ -550,7 +568,7 @@ def _normalized_name(self):
550568
@property
551569
def version(self) -> str:
552570
"""Return the 'Version' metadata for the distribution package."""
553-
return md_none(self.metadata)['Version']
571+
return self.metadata['Version']
554572

555573
@property
556574
def entry_points(self) -> EntryPoints:
@@ -1063,11 +1081,12 @@ def distributions(**kwargs) -> Iterable[Distribution]:
10631081
return Distribution.discover(**kwargs)
10641082

10651083

1066-
def metadata(distribution_name: str) -> _meta.PackageMetadata | None:
1084+
def metadata(distribution_name: str) -> _meta.PackageMetadata:
10671085
"""Get the metadata for the named package.
10681086
10691087
:param distribution_name: The name of the distribution package to query.
10701088
:return: A PackageMetadata containing the parsed metadata.
1089+
:raises MetadataNotFound: If no metadata file is present in the distribution.
10711090
"""
10721091
return Distribution.from_name(distribution_name).metadata
10731092

@@ -1138,7 +1157,7 @@ def packages_distributions() -> Mapping[str, list[str]]:
11381157
pkg_to_dist = collections.defaultdict(list)
11391158
for dist in distributions():
11401159
for pkg in _top_level_declared(dist) or _top_level_inferred(dist):
1141-
pkg_to_dist[pkg].append(md_none(dist.metadata)['Name'])
1160+
pkg_to_dist[pkg].append(dist.metadata['Name'])
11421161
return dict(pkg_to_dist)
11431162

11441163

Lib/importlib/metadata/_context.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
from __future__ import annotations
2+
3+
import functools
4+
import operator
5+
6+
7+
# from jaraco.context 6.1
8+
class ExceptionTrap:
9+
"""
10+
A context manager that will catch certain exceptions and provide an
11+
indication they occurred.
12+
13+
>>> with ExceptionTrap() as trap:
14+
... raise Exception()
15+
>>> bool(trap)
16+
True
17+
18+
>>> with ExceptionTrap() as trap:
19+
... pass
20+
>>> bool(trap)
21+
False
22+
23+
>>> with ExceptionTrap(ValueError) as trap:
24+
... raise ValueError("1 + 1 is not 3")
25+
>>> bool(trap)
26+
True
27+
>>> trap.value
28+
ValueError('1 + 1 is not 3')
29+
>>> trap.tb
30+
<traceback object at ...>
31+
32+
>>> with ExceptionTrap(ValueError) as trap:
33+
... raise Exception()
34+
Traceback (most recent call last):
35+
...
36+
Exception
37+
38+
>>> bool(trap)
39+
False
40+
"""
41+
42+
exc_info = None, None, None
43+
44+
def __init__(self, exceptions=(Exception,)):
45+
self.exceptions = exceptions
46+
47+
def __enter__(self):
48+
return self
49+
50+
@property
51+
def type(self):
52+
return self.exc_info[0]
53+
54+
@property
55+
def value(self):
56+
return self.exc_info[1]
57+
58+
@property
59+
def tb(self):
60+
return self.exc_info[2]
61+
62+
def __exit__(self, *exc_info):
63+
type = exc_info[0]
64+
matches = type and issubclass(type, self.exceptions)
65+
if matches:
66+
self.exc_info = exc_info
67+
return matches
68+
69+
def __bool__(self):
70+
return bool(self.type)
71+
72+
def raises(self, func, *, _test=bool):
73+
"""
74+
Wrap func and replace the result with the truth
75+
value of the trap (True if an exception occurred).
76+
77+
First, give the decorator an alias to support Python 3.8
78+
Syntax.
79+
80+
>>> raises = ExceptionTrap(ValueError).raises
81+
82+
Now decorate a function that always fails.
83+
84+
>>> @raises
85+
... def fail():
86+
... raise ValueError('failed')
87+
>>> fail()
88+
True
89+
"""
90+
91+
@functools.wraps(func)
92+
def wrapper(*args, **kwargs):
93+
with ExceptionTrap(self.exceptions) as trap:
94+
func(*args, **kwargs)
95+
return _test(trap)
96+
97+
return wrapper
98+
99+
def passes(self, func):
100+
"""
101+
Wrap func and replace the result with the truth
102+
value of the trap (True if no exception).
103+
104+
First, give the decorator an alias to support Python 3.8
105+
Syntax.
106+
107+
>>> passes = ExceptionTrap(ValueError).passes
108+
109+
Now decorate a function that always fails.
110+
111+
>>> @passes
112+
... def fail():
113+
... raise ValueError('failed')
114+
115+
>>> fail()
116+
False
117+
"""
118+
return self.raises(func, _test=operator.not_)

Lib/test/test_importlib/metadata/test_main.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from importlib.metadata import (
1313
Distribution,
1414
EntryPoint,
15+
MetadataNotFound,
1516
PackageNotFoundError,
1617
_unique,
1718
distributions,
@@ -159,13 +160,15 @@ def test_valid_dists_preferred(self):
159160

160161
def test_missing_metadata(self):
161162
"""
162-
Dists with a missing metadata file should return None.
163+
Dists with a missing metadata file should raise ``MetadataNotFound``.
163164
164-
Ref python/importlib_metadata#493.
165+
Ref python/importlib_metadata#493 and python/cpython#143387.
165166
"""
166167
fixtures.build_files(self.make_pkg('foo-4.3', files={}), self.site_dir)
167-
assert Distribution.from_name('foo').metadata is None
168-
assert metadata('foo') is None
168+
with self.assertRaises(MetadataNotFound):
169+
Distribution.from_name('foo').metadata
170+
with self.assertRaises(MetadataNotFound):
171+
metadata('foo')
169172

170173

171174
class NonASCIITests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
In importlib.metadata, when a distribution file is corrupt and there is no
2+
metadata file, calls to ``Distribution.metadata()`` (including implicit
3+
calls from other properties like ``.name`` and ``.requires``) will now raise
4+
a ``MetadataNotFound`` Exception. This allows callers to distinguish between
5+
missing metadata and a degenerate (empty) metadata. Previously, if the file
6+
was missing, an empty ``PackageMetadata`` would be returned and would be
7+
indistinguishable from the presence of an empty file.

0 commit comments

Comments
 (0)