gh-143387: Raise an exception instead of returning None when metadata file is missing. (#146234)

This commit is contained in:
Jason R. Coombs 2026-03-23 09:12:36 -04:00 committed by GitHub
parent 1114d7f7f8
commit f5d47fceb0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 159 additions and 12 deletions

View file

@ -31,6 +31,7 @@
from . import _meta
from ._collections import FreezableDefaultDict, Pair
from ._context import ExceptionTrap
from ._functools import method_cache, noop, pass_none, passthrough
from ._itertools import always_iterable, bucket, unique_everseen
from ._meta import PackageMetadata, SimplePath
@ -42,6 +43,7 @@
'PackageMetadata',
'PackageNotFoundError',
'PackagePath',
'MetadataNotFound',
'SimplePath',
'distribution',
'distributions',
@ -66,6 +68,10 @@ def name(self) -> str: # type: ignore[override] # make readonly
return name
class MetadataNotFound(FileNotFoundError):
"""No metadata file is present in the distribution."""
class Sectioned:
"""
A simple entry point config parser for performance
@ -487,7 +493,12 @@ def _prefer_valid(dists: Iterable[Distribution]) -> Iterable[Distribution]:
Ref python/importlib_resources#489.
"""
buckets = bucket(dists, lambda dist: bool(dist.metadata))
has_metadata = ExceptionTrap(MetadataNotFound).passes(
operator.attrgetter('metadata')
)
buckets = bucket(dists, has_metadata)
return itertools.chain(buckets[True], buckets[False])
@staticmethod
@ -508,7 +519,7 @@ def _discover_resolvers():
return filter(None, declared)
@property
def metadata(self) -> _meta.PackageMetadata | None:
def metadata(self) -> _meta.PackageMetadata:
"""Return the parsed metadata for this Distribution.
The returned object will have keys that name the various bits of
@ -517,6 +528,8 @@ def metadata(self) -> _meta.PackageMetadata | None:
Custom providers may provide the METADATA file or override this
property.
:raises MetadataNotFound: If no metadata file is present.
"""
text = (
@ -527,20 +540,25 @@ def metadata(self) -> _meta.PackageMetadata | None:
# (which points to the egg-info file) attribute unchanged.
or self.read_text('')
)
return self._assemble_message(text)
return self._assemble_message(self._ensure_metadata_present(text))
@staticmethod
@pass_none
def _assemble_message(text: str) -> _meta.PackageMetadata:
# deferred for performance (python/cpython#109829)
from . import _adapters
return _adapters.Message(email.message_from_string(text))
def _ensure_metadata_present(self, text: str | None) -> str:
if text is not None:
return text
raise MetadataNotFound('No package metadata was found.')
@property
def name(self) -> str:
"""Return the 'Name' metadata for the distribution package."""
return md_none(self.metadata)['Name']
return self.metadata['Name']
@property
def _normalized_name(self):
@ -550,7 +568,7 @@ def _normalized_name(self):
@property
def version(self) -> str:
"""Return the 'Version' metadata for the distribution package."""
return md_none(self.metadata)['Version']
return self.metadata['Version']
@property
def entry_points(self) -> EntryPoints:
@ -1063,11 +1081,12 @@ def distributions(**kwargs) -> Iterable[Distribution]:
return Distribution.discover(**kwargs)
def metadata(distribution_name: str) -> _meta.PackageMetadata | None:
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.
:return: A PackageMetadata containing the parsed metadata.
:raises MetadataNotFound: If no metadata file is present in the distribution.
"""
return Distribution.from_name(distribution_name).metadata
@ -1138,7 +1157,7 @@ def packages_distributions() -> Mapping[str, list[str]]:
pkg_to_dist = collections.defaultdict(list)
for dist in distributions():
for pkg in _top_level_declared(dist) or _top_level_inferred(dist):
pkg_to_dist[pkg].append(md_none(dist.metadata)['Name'])
pkg_to_dist[pkg].append(dist.metadata['Name'])
return dict(pkg_to_dist)

View file

@ -0,0 +1,118 @@
from __future__ import annotations
import functools
import operator
# from jaraco.context 6.1
class ExceptionTrap:
"""
A context manager that will catch certain exceptions and provide an
indication they occurred.
>>> with ExceptionTrap() as trap:
... raise Exception()
>>> bool(trap)
True
>>> with ExceptionTrap() as trap:
... pass
>>> bool(trap)
False
>>> with ExceptionTrap(ValueError) as trap:
... raise ValueError("1 + 1 is not 3")
>>> bool(trap)
True
>>> trap.value
ValueError('1 + 1 is not 3')
>>> trap.tb
<traceback object at ...>
>>> with ExceptionTrap(ValueError) as trap:
... raise Exception()
Traceback (most recent call last):
...
Exception
>>> bool(trap)
False
"""
exc_info = None, None, None
def __init__(self, exceptions=(Exception,)):
self.exceptions = exceptions
def __enter__(self):
return self
@property
def type(self):
return self.exc_info[0]
@property
def value(self):
return self.exc_info[1]
@property
def tb(self):
return self.exc_info[2]
def __exit__(self, *exc_info):
type = exc_info[0]
matches = type and issubclass(type, self.exceptions)
if matches:
self.exc_info = exc_info
return matches
def __bool__(self):
return bool(self.type)
def raises(self, func, *, _test=bool):
"""
Wrap func and replace the result with the truth
value of the trap (True if an exception occurred).
First, give the decorator an alias to support Python 3.8
Syntax.
>>> raises = ExceptionTrap(ValueError).raises
Now decorate a function that always fails.
>>> @raises
... def fail():
... raise ValueError('failed')
>>> fail()
True
"""
@functools.wraps(func)
def wrapper(*args, **kwargs):
with ExceptionTrap(self.exceptions) as trap:
func(*args, **kwargs)
return _test(trap)
return wrapper
def passes(self, func):
"""
Wrap func and replace the result with the truth
value of the trap (True if no exception).
First, give the decorator an alias to support Python 3.8
Syntax.
>>> passes = ExceptionTrap(ValueError).passes
Now decorate a function that always fails.
>>> @passes
... def fail():
... raise ValueError('failed')
>>> fail()
False
"""
return self.raises(func, _test=operator.not_)

View file

@ -12,6 +12,7 @@
from importlib.metadata import (
Distribution,
EntryPoint,
MetadataNotFound,
PackageNotFoundError,
_unique,
distributions,
@ -159,13 +160,15 @@ def test_valid_dists_preferred(self):
def test_missing_metadata(self):
"""
Dists with a missing metadata file should return None.
Dists with a missing metadata file should raise ``MetadataNotFound``.
Ref python/importlib_metadata#493.
Ref python/importlib_metadata#493 and python/cpython#143387.
"""
fixtures.build_files(self.make_pkg('foo-4.3', files={}), self.site_dir)
assert Distribution.from_name('foo').metadata is None
assert metadata('foo') is None
with self.assertRaises(MetadataNotFound):
Distribution.from_name('foo').metadata
with self.assertRaises(MetadataNotFound):
metadata('foo')
class NonASCIITests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):

View file

@ -0,0 +1,7 @@
In importlib.metadata, when a distribution file is corrupt and there is no
metadata file, calls to ``Distribution.metadata()`` (including implicit
calls from other properties like ``.name`` and ``.requires``) will now raise
a ``MetadataNotFound`` Exception. This allows callers to distinguish between
missing metadata and a degenerate (empty) metadata. Previously, if the file
was missing, an empty ``PackageMetadata`` would be returned and would be
indistinguishable from the presence of an empty file.