diff --git a/NEWS.rst b/NEWS.rst index 1a92cd19..83944755 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,3 +1,14 @@ +v8.8.0 +====== + +Features +-------- + +- Added ``MetadataNotFound`` (subclass of ``FileNotFoundError``) and updated + ``Distribution.metadata``/``metadata()`` to raise it when the metadata files + are missing instead of returning ``None`` (python/cpython#143387). + + v8.7.1 ====== diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 508b02e4..4e945775 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -35,10 +35,10 @@ NullFinder, install, ) +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 -from ._typing import md_none from .compat import py39, py311 __all__ = [ @@ -46,6 +46,7 @@ 'DistributionFinder', 'PackageMetadata', 'PackageNotFoundError', + 'MetadataNotFound', 'SimplePath', 'distribution', 'distributions', @@ -70,6 +71,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 @@ -491,7 +496,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 @@ -512,7 +522,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 @@ -521,6 +531,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 = ( @@ -531,20 +543,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): @@ -554,7 +571,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: @@ -1067,11 +1084,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 @@ -1142,7 +1160,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/importlib_metadata/_context.py b/importlib_metadata/_context.py new file mode 100644 index 00000000..2635b164 --- /dev/null +++ b/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 + + + >>> 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/importlib_metadata/_typing.py b/importlib_metadata/_typing.py deleted file mode 100644 index 32b1d2b9..00000000 --- a/importlib_metadata/_typing.py +++ /dev/null @@ -1,15 +0,0 @@ -import functools -import typing - -from ._meta import PackageMetadata - -md_none = functools.partial(typing.cast, PackageMetadata) -""" -Suppress type errors for optional metadata. - -Although Distribution.metadata can return None when metadata is corrupt -and thus None, allow callers to assume it's not None and crash if -that's the case. - -# python/importlib_metadata#493 -""" diff --git a/importlib_metadata/compat/py39.py b/importlib_metadata/compat/py39.py index 3eb9c01e..2592436d 100644 --- a/importlib_metadata/compat/py39.py +++ b/importlib_metadata/compat/py39.py @@ -12,8 +12,6 @@ else: Distribution = EntryPoint = Any -from .._typing import md_none - def normalized_name(dist: Distribution) -> str | None: """ @@ -24,9 +22,7 @@ def normalized_name(dist: Distribution) -> str | None: except AttributeError: from .. import Prepared # -> delay to prevent circular imports. - return Prepared.normalize( - getattr(dist, "name", None) or md_none(dist.metadata)['Name'] - ) + return Prepared.normalize(getattr(dist, "name", None) or dist.metadata['Name']) def ep_matches(ep: EntryPoint, **params) -> bool: diff --git a/newsfragments/+.removal.rst b/newsfragments/+.removal.rst new file mode 100644 index 00000000..cbbb2158 --- /dev/null +++ b/newsfragments/+.removal.rst @@ -0,0 +1,2 @@ +- Removed the internal ``md_none`` typing helper since ``Distribution.metadata`` now always returns ``PackageMetadata`` and raises ``MetadataNotFound`` when absent (python/cpython#143387). +- Vendored ``ExceptionTrap`` from ``jaraco.context`` (as ``_context``) and now rely on its ``passes`` helper when checking for missing metadata, keeping behavior aligned without adding dependencies. diff --git a/tests/test_main.py b/tests/test_main.py index 5ed08c89..92084df1 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -9,6 +9,7 @@ from importlib_metadata import ( Distribution, EntryPoint, + MetadataNotFound, PackageNotFoundError, _unique, distributions, @@ -157,13 +158,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):