From d9fc620fd5d00b439397dc15f1acfdd6f583b770 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sun, 27 Apr 2025 09:56:23 -0400 Subject: [PATCH 1/8] =?UTF-8?q?=F0=9F=91=B9=20Feed=20the=20hobgoblins=20(d?= =?UTF-8?q?elint).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- importlib_metadata/__init__.py | 36 ++++++++++++++++++---------------- importlib_metadata/_meta.py | 20 ++++++++----------- tests/_path.py | 3 ++- 3 files changed, 29 insertions(+), 30 deletions(-) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 46a14e64..87c9eb51 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -22,11 +22,13 @@ import sys import textwrap import types +from collections.abc import Iterable, Mapping from contextlib import suppress from importlib import import_module from importlib.abc import MetaPathFinder from itertools import starmap -from typing import Any, Iterable, List, Mapping, Match, Optional, Set, cast +from re import Match +from typing import Any, List, Optional, Set, cast from . import _meta from ._collections import FreezableDefaultDict, Pair @@ -175,7 +177,7 @@ class EntryPoint: value: str group: str - dist: Optional[Distribution] = None + dist: Distribution | None = None def __init__(self, name: str, value: str, group: str) -> None: vars(self).update(name=name, value=value, group=group) @@ -203,7 +205,7 @@ def attr(self) -> str: return match.group('attr') @property - def extras(self) -> List[str]: + def extras(self) -> list[str]: match = self.pattern.match(self.value) assert match is not None return re.findall(r'\w+', match.group('extras') or '') @@ -305,14 +307,14 @@ def select(self, **params) -> EntryPoints: return EntryPoints(ep for ep in self if py39.ep_matches(ep, **params)) @property - def names(self) -> Set[str]: + def names(self) -> set[str]: """ Return the set of all names of all entry points. """ return {ep.name for ep in self} @property - def groups(self) -> Set[str]: + def groups(self) -> set[str]: """ Return the set of all groups of all entry points. """ @@ -333,7 +335,7 @@ def _from_text(text): class PackagePath(pathlib.PurePosixPath): """A reference to a path in a package""" - hash: Optional[FileHash] + hash: FileHash | None size: int dist: Distribution @@ -368,7 +370,7 @@ class Distribution(metaclass=abc.ABCMeta): """ @abc.abstractmethod - def read_text(self, filename) -> Optional[str]: + def read_text(self, filename) -> str | None: """Attempt to load metadata file given by the name. Python distribution metadata is organized by blobs of text @@ -428,7 +430,7 @@ def from_name(cls, name: str) -> Distribution: @classmethod def discover( - cls, *, context: Optional[DistributionFinder.Context] = None, **kwargs + cls, *, context: DistributionFinder.Context | None = None, **kwargs ) -> Iterable[Distribution]: """Return an iterable of Distribution objects for all packages. @@ -524,7 +526,7 @@ def entry_points(self) -> EntryPoints: return EntryPoints._from_text_for(self.read_text('entry_points.txt'), self) @property - def files(self) -> Optional[List[PackagePath]]: + def files(self) -> list[PackagePath] | None: """Files in this distribution. :return: List of PackagePath for this distribution or None @@ -616,7 +618,7 @@ def _read_files_egginfo_sources(self): return text and map('"{}"'.format, text.splitlines()) @property - def requires(self) -> Optional[List[str]]: + def requires(self) -> list[str] | None: """Generated requirements specified for this Distribution""" reqs = self._read_dist_info_reqs() or self._read_egg_info_reqs() return reqs and list(reqs) @@ -722,7 +724,7 @@ def __init__(self, **kwargs): vars(self).update(kwargs) @property - def path(self) -> List[str]: + def path(self) -> list[str]: """ The sequence of directory path that a distribution finder should search. @@ -874,7 +876,7 @@ class Prepared: normalized = None legacy_normalized = None - def __init__(self, name: Optional[str]): + def __init__(self, name: str | None): self.name = name if name is None: return @@ -944,7 +946,7 @@ def __init__(self, path: SimplePath) -> None: """ self._path = path - def read_text(self, filename: str | os.PathLike[str]) -> Optional[str]: + def read_text(self, filename: str | os.PathLike[str]) -> str | None: with suppress( FileNotFoundError, IsADirectoryError, @@ -1051,7 +1053,7 @@ def entry_points(**params) -> EntryPoints: return EntryPoints(eps).select(**params) -def files(distribution_name: str) -> Optional[List[PackagePath]]: +def files(distribution_name: str) -> list[PackagePath] | None: """Return a list of files for the named package. :param distribution_name: The name of the distribution package to query. @@ -1060,7 +1062,7 @@ def files(distribution_name: str) -> Optional[List[PackagePath]]: return distribution(distribution_name).files -def requires(distribution_name: str) -> Optional[List[str]]: +def requires(distribution_name: str) -> list[str] | None: """ Return a list of requirements for the named package. @@ -1070,7 +1072,7 @@ def requires(distribution_name: str) -> Optional[List[str]]: return distribution(distribution_name).requires -def packages_distributions() -> Mapping[str, List[str]]: +def packages_distributions() -> Mapping[str, list[str]]: """ Return a mapping of top-level packages to their distributions. @@ -1091,7 +1093,7 @@ def _top_level_declared(dist): return (dist.read_text('top_level.txt') or '').split() -def _topmost(name: PackagePath) -> Optional[str]: +def _topmost(name: PackagePath) -> str | None: """ Return the top-most parent as long as there is a parent. """ diff --git a/importlib_metadata/_meta.py b/importlib_metadata/_meta.py index 0942bbd9..0c20eff3 100644 --- a/importlib_metadata/_meta.py +++ b/importlib_metadata/_meta.py @@ -1,15 +1,11 @@ from __future__ import annotations import os +from collections.abc import Iterator from typing import ( Any, - Dict, - Iterator, - List, - Optional, Protocol, TypeVar, - Union, overload, ) @@ -28,25 +24,25 @@ def __iter__(self) -> Iterator[str]: ... # pragma: no cover @overload def get( self, name: str, failobj: None = None - ) -> Optional[str]: ... # pragma: no cover + ) -> str | None: ... # pragma: no cover @overload - def get(self, name: str, failobj: _T) -> Union[str, _T]: ... # pragma: no cover + def get(self, name: str, failobj: _T) -> str | _T: ... # pragma: no cover # overload per python/importlib_metadata#435 @overload def get_all( self, name: str, failobj: None = None - ) -> Optional[List[Any]]: ... # pragma: no cover + ) -> list[Any] | None: ... # pragma: no cover @overload - def get_all(self, name: str, failobj: _T) -> Union[List[Any], _T]: + def get_all(self, name: str, failobj: _T) -> list[Any] | _T: """ Return all values associated with a possibly multi-valued key. """ @property - def json(self) -> Dict[str, Union[str, List[str]]]: + def json(self) -> dict[str, str | list[str]]: """ A JSON-compatible form of the metadata. """ @@ -58,11 +54,11 @@ class SimplePath(Protocol): """ def joinpath( - self, other: Union[str, os.PathLike[str]] + self, other: str | os.PathLike[str] ) -> SimplePath: ... # pragma: no cover def __truediv__( - self, other: Union[str, os.PathLike[str]] + self, other: str | os.PathLike[str] ) -> SimplePath: ... # pragma: no cover @property diff --git a/tests/_path.py b/tests/_path.py index c66cf5f8..e63d889f 100644 --- a/tests/_path.py +++ b/tests/_path.py @@ -4,7 +4,8 @@ import functools import pathlib -from typing import TYPE_CHECKING, Mapping, Protocol, Union, runtime_checkable +from collections.abc import Mapping +from typing import TYPE_CHECKING, Protocol, Union, runtime_checkable if TYPE_CHECKING: from typing_extensions import Self From 75670d283f379bbe7072cf5ec8fe1f6c7703f9ea Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sun, 27 Apr 2025 09:58:01 -0400 Subject: [PATCH 2/8] Remove unused imports. --- importlib_metadata/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 87c9eb51..275c7106 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -28,7 +28,7 @@ from importlib.abc import MetaPathFinder from itertools import starmap from re import Match -from typing import Any, List, Optional, Set, cast +from typing import Any, cast from . import _meta from ._collections import FreezableDefaultDict, Pair From 2bfbaf3bed463fc85646d5d57c04d257876844b5 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sun, 27 Apr 2025 10:01:40 -0400 Subject: [PATCH 3/8] Prefer typing.NamedTuple --- importlib_metadata/_collections.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/importlib_metadata/_collections.py b/importlib_metadata/_collections.py index cf0954e1..fc5045d3 100644 --- a/importlib_metadata/_collections.py +++ b/importlib_metadata/_collections.py @@ -1,4 +1,5 @@ import collections +import typing # from jaraco.collections 3.3 @@ -24,7 +25,10 @@ def freeze(self): self._frozen = lambda key: self.default_factory() -class Pair(collections.namedtuple('Pair', 'name value')): +class Pair(typing.NamedTuple): + name: str + value: str + @classmethod def parse(cls, text): return cls(*map(str.strip, text.split("=", 1))) From c10bdf30dafb55ec471a289e751089255e7f281d Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sun, 27 Apr 2025 10:02:50 -0400 Subject: [PATCH 4/8] =?UTF-8?q?=F0=9F=91=B9=20Feed=20the=20hobgoblins=20(d?= =?UTF-8?q?elint).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- importlib_metadata/compat/py39.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/importlib_metadata/compat/py39.py b/importlib_metadata/compat/py39.py index 1f15bd97..2592436d 100644 --- a/importlib_metadata/compat/py39.py +++ b/importlib_metadata/compat/py39.py @@ -2,7 +2,9 @@ Compatibility layer with Python 3.8/3.9 """ -from typing import TYPE_CHECKING, Any, Optional +from __future__ import annotations + +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover # Prevent circular imports on runtime. @@ -11,7 +13,7 @@ Distribution = EntryPoint = Any -def normalized_name(dist: Distribution) -> Optional[str]: +def normalized_name(dist: Distribution) -> str | None: """ Honor name normalization for distributions that don't provide ``_normalized_name``. """ From 55c6070ad7f337a423962698d3e02c62a8e1b10e Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sun, 27 Apr 2025 09:38:56 -0400 Subject: [PATCH 5/8] Refactored parsing and handling of EntryPoint.value. --- importlib_metadata/__init__.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 275c7106..849ce068 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -27,7 +27,6 @@ from importlib import import_module from importlib.abc import MetaPathFinder from itertools import starmap -from re import Match from typing import Any, cast from . import _meta @@ -135,6 +134,12 @@ def valid(line: str): return line and not line.startswith('#') +class _EntryPointMatch(types.SimpleNamespace): + module: str + attr: str + extras: str + + class EntryPoint: """An entry point as defined by Python packaging conventions. @@ -187,28 +192,27 @@ def load(self) -> Any: is indicated by the value, return that module. Otherwise, return the named object. """ - match = cast(Match, self.pattern.match(self.value)) - module = import_module(match.group('module')) - attrs = filter(None, (match.group('attr') or '').split('.')) + module = import_module(self.module) + attrs = filter(None, (self.attr or '').split('.')) return functools.reduce(getattr, attrs, module) @property def module(self) -> str: - match = self.pattern.match(self.value) - assert match is not None - return match.group('module') + return self._match.module @property def attr(self) -> str: - match = self.pattern.match(self.value) - assert match is not None - return match.group('attr') + return self._match.attr @property def extras(self) -> list[str]: + return re.findall(r'\w+', self._match.extras or '') + + @property + def _match(self) -> _EntryPointMatch: match = self.pattern.match(self.value) assert match is not None - return re.findall(r'\w+', match.group('extras') or '') + return _EntryPointMatch(**match.groupdict()) def _for(self, dist): vars(self).update(dist=dist) From eae6a754d004e8ea72d5d07b7dc3733a6be71f1b Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sun, 27 Apr 2025 09:41:26 -0400 Subject: [PATCH 6/8] Raise a ValueError if no match. Closes #488 --- importlib_metadata/__init__.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 849ce068..d527e403 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -155,6 +155,22 @@ class EntryPoint: 'attr' >>> ep.extras ['extra1', 'extra2'] + + If the value package or module are not valid identifiers, a + ValueError is raised on access. + + >>> EntryPoint(name=None, group=None, value='invalid-name').module + Traceback (most recent call last): + ... + ValueError: ('Invalid object reference...invalid-name... + >>> EntryPoint(name=None, group=None, value='invalid-name').attr + Traceback (most recent call last): + ... + ValueError: ('Invalid object reference...invalid-name... + >>> EntryPoint(name=None, group=None, value='invalid-name').extras + Traceback (most recent call last): + ... + ValueError: ('Invalid object reference...invalid-name... """ pattern = re.compile( @@ -211,7 +227,13 @@ def extras(self) -> list[str]: @property def _match(self) -> _EntryPointMatch: match = self.pattern.match(self.value) - assert match is not None + if not match: + raise ValueError( + 'Invalid object reference. ' + 'See https://packaging.python.org' + '/en/latest/specifications/entry-points/#data-model', + self.value, + ) return _EntryPointMatch(**match.groupdict()) def _for(self, dist): From f179e28888b2c6caf12baaf5449ff1cd82513dfe Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sun, 27 Apr 2025 09:45:56 -0400 Subject: [PATCH 7/8] Also raise ValueError on construction if the value is invalid. --- importlib_metadata/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index d527e403..ff3c2a44 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -171,6 +171,14 @@ class EntryPoint: Traceback (most recent call last): ... ValueError: ('Invalid object reference...invalid-name... + + The same thing happens on construction. + + >>> EntryPoint(name=None, group=None, value='invalid-name') + Traceback (most recent call last): + ... + ValueError: ('Invalid object reference...invalid-name... + """ pattern = re.compile( @@ -202,6 +210,7 @@ class EntryPoint: def __init__(self, name: str, value: str, group: str) -> None: vars(self).update(name=name, value=value, group=group) + self.module def load(self) -> Any: """Load the entry point from its definition. If only a module From 9f8af013635833cf3ac348413c9ac63b37caa3dd Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sun, 27 Apr 2025 09:50:19 -0400 Subject: [PATCH 8/8] Prefer a cached property, as the property is likely to be retrieved at least 3 times (on construction and for module:attr access). --- importlib_metadata/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index ff3c2a44..157b2c6f 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -233,7 +233,7 @@ def attr(self) -> str: def extras(self) -> list[str]: return re.findall(r'\w+', self._match.extras or '') - @property + @functools.cached_property def _match(self) -> _EntryPointMatch: match = self.pattern.match(self.value) if not match: