Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions NEWS.rst
Original file line number Diff line number Diff line change
@@ -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
======

Expand Down
36 changes: 27 additions & 9 deletions importlib_metadata/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,18 @@
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__ = [
'Distribution',
'DistributionFinder',
'PackageMetadata',
'PackageNotFoundError',
'MetadataNotFound',
'SimplePath',
'distribution',
'distributions',
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 = (
Expand All @@ -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):
Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)


Expand Down
118 changes: 118 additions & 0 deletions importlib_metadata/_context.py
Original file line number Diff line number Diff line change
@@ -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_)
15 changes: 0 additions & 15 deletions importlib_metadata/_typing.py

This file was deleted.

6 changes: 1 addition & 5 deletions importlib_metadata/compat/py39.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@
else:
Distribution = EntryPoint = Any

from .._typing import md_none


def normalized_name(dist: Distribution) -> str | None:
"""
Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions newsfragments/+.removal.rst
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 7 additions & 4 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from importlib_metadata import (
Distribution,
EntryPoint,
MetadataNotFound,
PackageNotFoundError,
_unique,
distributions,
Expand Down Expand Up @@ -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):
Expand Down
Loading