From 57f31d77e18fef11dfadfd44775f253971c36920 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Thu, 27 Jun 2024 12:53:04 -0400 Subject: [PATCH 1/5] Allow metadata to return None when there is no metadata present. --- importlib_metadata/__init__.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 157b2c6f..4717f3d7 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -511,7 +511,7 @@ def _discover_resolvers(): return filter(None, declared) @property - def metadata(self) -> _meta.PackageMetadata: + def metadata(self) -> _meta.PackageMetadata | None: """Return the parsed metadata for this Distribution. The returned object will have keys that name the various bits of @@ -521,10 +521,8 @@ def metadata(self) -> _meta.PackageMetadata: Custom providers may provide the METADATA file or override this property. """ - # deferred for performance (python/cpython#109829) - from . import _adapters - opt_text = ( + text = ( self.read_text('METADATA') or self.read_text('PKG-INFO') # This last clause is here to support old egg-info files. Its @@ -532,7 +530,14 @@ def metadata(self) -> _meta.PackageMetadata: # (which points to the egg-info file) attribute unchanged. or self.read_text('') ) - text = cast(str, opt_text) + return self._assemble_message(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)) @property From 22bb567692d8e7bd216f864a9d8dee1272ee8674 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sun, 27 Apr 2025 10:32:46 -0400 Subject: [PATCH 2/5] Fix type errors where metadata could be None. --- importlib_metadata/__init__.py | 8 ++++---- importlib_metadata/compat/py39.py | 8 ++++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 4717f3d7..ded27e13 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -543,7 +543,7 @@ def _assemble_message(text: str) -> _meta.PackageMetadata: @property def name(self) -> str: """Return the 'Name' metadata for the distribution package.""" - return self.metadata['Name'] + return cast(PackageMetadata, self.metadata)['Name'] @property def _normalized_name(self): @@ -553,7 +553,7 @@ def _normalized_name(self): @property def version(self) -> str: """Return the 'Version' metadata for the distribution package.""" - return self.metadata['Version'] + return cast(PackageMetadata, self.metadata)['Version'] @property def entry_points(self) -> EntryPoints: @@ -1050,7 +1050,7 @@ def distributions(**kwargs) -> Iterable[Distribution]: return Distribution.discover(**kwargs) -def metadata(distribution_name: str) -> _meta.PackageMetadata: +def metadata(distribution_name: str) -> _meta.PackageMetadata | None: """Get the metadata for the named package. :param distribution_name: The name of the distribution package to query. @@ -1125,7 +1125,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(dist.metadata['Name']) + pkg_to_dist[pkg].append(cast(PackageMetadata, dist.metadata)['Name']) return dict(pkg_to_dist) diff --git a/importlib_metadata/compat/py39.py b/importlib_metadata/compat/py39.py index 2592436d..1fbcbf7b 100644 --- a/importlib_metadata/compat/py39.py +++ b/importlib_metadata/compat/py39.py @@ -4,7 +4,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast if TYPE_CHECKING: # pragma: no cover # Prevent circular imports on runtime. @@ -12,6 +12,8 @@ else: Distribution = EntryPoint = Any +from .._meta import PackageMetadata + def normalized_name(dist: Distribution) -> str | None: """ @@ -22,7 +24,9 @@ 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 dist.metadata['Name']) + return Prepared.normalize( + getattr(dist, "name", None) or cast(PackageMetadata, dist.metadata)['Name'] + ) def ep_matches(ep: EntryPoint, **params) -> bool: From 0830c39b8a23e48024365120c0e97a6f7c36c5ec Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sun, 27 Apr 2025 10:34:40 -0400 Subject: [PATCH 3/5] Add news fragment. --- newsfragments/493.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 newsfragments/493.feature.rst diff --git a/newsfragments/493.feature.rst b/newsfragments/493.feature.rst new file mode 100644 index 00000000..e75e0e3e --- /dev/null +++ b/newsfragments/493.feature.rst @@ -0,0 +1 @@ +``.metadata()`` (and ``Distribution.metadata``) can now return ``None`` if the metadata directory exists but not metadata file is present. From 5a657051f7386de6f0560c200d78e941be2c8058 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sun, 27 Apr 2025 10:47:08 -0400 Subject: [PATCH 4/5] Refactor the casting into a wrapper for brevity and to document its purpose. --- importlib_metadata/__init__.py | 9 +++++---- importlib_metadata/_typing.py | 15 +++++++++++++++ importlib_metadata/compat/py39.py | 6 +++--- 3 files changed, 23 insertions(+), 7 deletions(-) create mode 100644 importlib_metadata/_typing.py diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index ded27e13..cdfc1f62 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -27,7 +27,7 @@ from importlib import import_module from importlib.abc import MetaPathFinder from itertools import starmap -from typing import Any, cast +from typing import Any from . import _meta from ._collections import FreezableDefaultDict, Pair @@ -38,6 +38,7 @@ from ._functools import method_cache, pass_none from ._itertools import always_iterable, bucket, unique_everseen from ._meta import PackageMetadata, SimplePath +from ._typing import md_none from .compat import py39, py311 __all__ = [ @@ -543,7 +544,7 @@ def _assemble_message(text: str) -> _meta.PackageMetadata: @property def name(self) -> str: """Return the 'Name' metadata for the distribution package.""" - return cast(PackageMetadata, self.metadata)['Name'] + return md_none(self.metadata)['Name'] @property def _normalized_name(self): @@ -553,7 +554,7 @@ def _normalized_name(self): @property def version(self) -> str: """Return the 'Version' metadata for the distribution package.""" - return cast(PackageMetadata, self.metadata)['Version'] + return md_none(self.metadata)['Version'] @property def entry_points(self) -> EntryPoints: @@ -1125,7 +1126,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(cast(PackageMetadata, dist.metadata)['Name']) + pkg_to_dist[pkg].append(md_none(dist.metadata)['Name']) return dict(pkg_to_dist) diff --git a/importlib_metadata/_typing.py b/importlib_metadata/_typing.py new file mode 100644 index 00000000..32b1d2b9 --- /dev/null +++ b/importlib_metadata/_typing.py @@ -0,0 +1,15 @@ +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 1fbcbf7b..3eb9c01e 100644 --- a/importlib_metadata/compat/py39.py +++ b/importlib_metadata/compat/py39.py @@ -4,7 +4,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover # Prevent circular imports on runtime. @@ -12,7 +12,7 @@ else: Distribution = EntryPoint = Any -from .._meta import PackageMetadata +from .._typing import md_none def normalized_name(dist: Distribution) -> str | None: @@ -25,7 +25,7 @@ def normalized_name(dist: Distribution) -> str | None: from .. import Prepared # -> delay to prevent circular imports. return Prepared.normalize( - getattr(dist, "name", None) or cast(PackageMetadata, dist.metadata)['Name'] + getattr(dist, "name", None) or md_none(dist.metadata)['Name'] ) From e4351c226765f53a40316fa6aab50488aee8a90f Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sun, 27 Apr 2025 10:51:40 -0400 Subject: [PATCH 5/5] Add a new test capturing the new expectation. --- tests/test_main.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_main.py b/tests/test_main.py index 7c9851fc..5ed08c89 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -155,6 +155,16 @@ def test_valid_dists_preferred(self): dist = Distribution.from_name('foo') assert dist.version == "1.0" + def test_missing_metadata(self): + """ + Dists with a missing metadata file should return None. + + Ref python/importlib_metadata#493. + """ + 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 + class NonASCIITests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase): @staticmethod