Skip to content
Open
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
35 changes: 27 additions & 8 deletions Lib/importlib/metadata/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -42,6 +43,7 @@
'PackageMetadata',
'PackageNotFoundError',
'PackagePath',
'MetadataNotFound',
'SimplePath',
'distribution',
'distributions',
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 = (
Expand All @@ -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):
Expand All @@ -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:
Expand Down Expand Up @@ -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

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


Expand Down
118 changes: 118 additions & 0 deletions Lib/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_)
11 changes: 7 additions & 4 deletions Lib/test/test_importlib/metadata/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from importlib.metadata import (
Distribution,
EntryPoint,
MetadataNotFound,
PackageNotFoundError,
_unique,
distributions,
Expand Down Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Loading