]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-143387: Raise an exception instead of returning None when metadata file is missing...
authorJason R. Coombs <jaraco@jaraco.com>
Mon, 23 Mar 2026 13:12:36 +0000 (09:12 -0400)
committerGitHub <noreply@github.com>
Mon, 23 Mar 2026 13:12:36 +0000 (09:12 -0400)
Lib/importlib/metadata/__init__.py
Lib/importlib/metadata/_context.py [new file with mode: 0644]
Lib/test/test_importlib/metadata/test_main.py
Misc/NEWS.d/next/Library/2026-03-20-16-17-31.gh-issue-143387.9Waopa.rst [new file with mode: 0644]

index cde697e3dc7ab061b645302e9b219393e611c603..32f4b7d2d6e08be1395ee9dd4d37592a5d3070dc 100644 (file)
@@ -31,6 +31,7 @@ from typing import Any
 
 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 @@ __all__ = [
     'PackageMetadata',
     'PackageNotFoundError',
     'PackagePath',
+    'MetadataNotFound',
     'SimplePath',
     'distribution',
     'distributions',
@@ -66,6 +68,10 @@ class PackageNotFoundError(ModuleNotFoundError):
         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 @@ class Distribution(metaclass=abc.ABCMeta):
 
         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 @@ class Distribution(metaclass=abc.ABCMeta):
         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 @@ class Distribution(metaclass=abc.ABCMeta):
 
         Custom providers may provide the METADATA file or override this
         property.
+
+        :raises MetadataNotFound: If no metadata file is present.
         """
 
         text = (
@@ -527,20 +540,25 @@ class Distribution(metaclass=abc.ABCMeta):
             # (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 @@ class Distribution(metaclass=abc.ABCMeta):
     @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 (file)
index 0000000..2635b16
--- /dev/null
@@ -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_)
index f6c4ab2e78fe4733472aeef9b160918d9a404edd..aae052160d9763e5130c0bad0c68e0282267cc5f 100644 (file)
@@ -12,6 +12,7 @@ from test.support import os_helper
 from importlib.metadata import (
     Distribution,
     EntryPoint,
+    MetadataNotFound,
     PackageNotFoundError,
     _unique,
     distributions,
@@ -159,13 +160,15 @@ class InvalidMetadataTests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCa
 
     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 (file)
index 0000000..16bab04
--- /dev/null
@@ -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.