https://github.com/python/cpython/commit/f5d47fceb0d8384eb4cd4bd294ae4bc09383d57c
commit: f5d47fceb0d8384eb4cd4bd294ae4bc09383d57c
branch: main
author: Jason R. Coombs <[email protected]>
committer: jaraco <[email protected]>
date: 2026-03-23T09:12:36-04:00
summary:
gh-143387: Raise an exception instead of returning None when metadata file is
missing. (#146234)
files:
A Lib/importlib/metadata/_context.py
A Misc/NEWS.d/next/Library/2026-03-20-16-17-31.gh-issue-143387.9Waopa.rst
M Lib/importlib/metadata/__init__.py
M Lib/test/test_importlib/metadata/test_main.py
diff --git a/Lib/importlib/metadata/__init__.py
b/Lib/importlib/metadata/__init__.py
index cde697e3dc7ab0..32f4b7d2d6e08b 100644
--- a/Lib/importlib/metadata/__init__.py
+++ b/Lib/importlib/metadata/__init__.py
@@ -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)
diff --git a/Lib/importlib/metadata/_context.py
b/Lib/importlib/metadata/_context.py
new file mode 100644
index 00000000000000..2635b164ce8923
--- /dev/null
+++ b/Lib/importlib/metadata/_context.py
@@ -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_)
diff --git a/Lib/test/test_importlib/metadata/test_main.py
b/Lib/test/test_importlib/metadata/test_main.py
index f6c4ab2e78fe47..aae052160d9763 100644
--- a/Lib/test/test_importlib/metadata/test_main.py
+++ b/Lib/test/test_importlib/metadata/test_main.py
@@ -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):
diff --git
a/Misc/NEWS.d/next/Library/2026-03-20-16-17-31.gh-issue-143387.9Waopa.rst
b/Misc/NEWS.d/next/Library/2026-03-20-16-17-31.gh-issue-143387.9Waopa.rst
new file mode 100644
index 00000000000000..16bab047424e50
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-03-20-16-17-31.gh-issue-143387.9Waopa.rst
@@ -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.
_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]