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]

Reply via email to