From 67909a0ef5313fec702bac1b1fbdbf6243ef0595 Mon Sep 17 00:00:00 2001 From: Anders Kaseorg Date: Wed, 25 Aug 2021 00:19:11 -0700 Subject: [PATCH 01/14] Linearize base classes of SelectableGroups Signed-off-by: Anders Kaseorg --- importlib_metadata/__init__.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 6c554558..1182f458 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -368,13 +368,12 @@ def _parse_groups(text): ) -class Deprecated: +class DeprecatedDict(dict): """ Compatibility add-in for mapping to indicate that mapping behavior is deprecated. >>> recwarn = getfixture('recwarn') - >>> class DeprecatedDict(Deprecated, dict): pass >>> dd = DeprecatedDict(foo='bar') >>> dd.get('baz', None) >>> dd['foo'] @@ -423,7 +422,7 @@ def values(self): return super().values() -class SelectableGroups(Deprecated, dict): +class SelectableGroups(DeprecatedDict): """ A backward- and forward-compatible result from entry_points that fully implements the dict interface. @@ -441,7 +440,7 @@ def _all(self): """ Reconstruct a list of all entrypoints from the groups. """ - groups = super(Deprecated, self).values() + groups = super(DeprecatedDict, self).values() return EntryPoints(itertools.chain.from_iterable(groups)) @property From 70f1f241455e1702081925fc99f49223969119b0 Mon Sep 17 00:00:00 2001 From: Anders Kaseorg Date: Wed, 25 Aug 2021 16:44:50 -0700 Subject: [PATCH 02/14] DeprecatedList: Only warn when comparing to other lists Signed-off-by: Anders Kaseorg --- 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 1182f458..ad1411bc 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -296,7 +296,7 @@ def sort(self, *args, **kwargs): return super().sort(*args, **kwargs) def __eq__(self, other): - if not isinstance(other, tuple): + if isinstance(other, list): self._warn() other = tuple(other) From cb0acdf6f74a447342a0b006f46ccc8e4ffa7680 Mon Sep 17 00:00:00 2001 From: Anders Kaseorg Date: Wed, 25 Aug 2021 16:48:58 -0700 Subject: [PATCH 03/14] EntryPoints: Improve __getitem__ compatibility Signed-off-by: Anders Kaseorg --- importlib_metadata/__init__.py | 3 ++- importlib_metadata/_compat.py | 15 +++++---------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index ad1411bc..744d58e5 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -19,6 +19,7 @@ from ._compat import ( NullFinder, PyPy_repr, + SupportsIndex, install, pypy_partial, ) @@ -314,7 +315,7 @@ def __getitem__(self, name): # -> EntryPoint: """ Get the EntryPoint in self matching name. """ - if isinstance(name, int): + if isinstance(name, (SupportsIndex, slice)): warnings.warn( "Accessing entry points by index is deprecated. " "Cast to tuple if needed.", diff --git a/importlib_metadata/_compat.py b/importlib_metadata/_compat.py index 1947d449..5ca36eef 100644 --- a/importlib_metadata/_compat.py +++ b/importlib_metadata/_compat.py @@ -2,18 +2,13 @@ import platform -__all__ = ['install', 'NullFinder', 'PyPy_repr', 'Protocol'] +__all__ = ['install', 'NullFinder', 'PyPy_repr', 'Protocol', 'SupportsIndex'] -try: - from typing import Protocol -except ImportError: # pragma: no cover - """ - pytest-mypy complains here because: - error: Incompatible import of "Protocol" (imported name has type - "typing_extensions._SpecialForm", local name has type "typing._SpecialForm") - """ - from typing_extensions import Protocol # type: ignore +if sys.version_info >= (3, 8): + from typing import Protocol, SupportsIndex +else: + from typing_extensions import Protocol, SupportsIndex def install(cls): From 0e960d4c6d44e4241ac7019447f28ec89098cd4c Mon Sep 17 00:00:00 2001 From: Anders Kaseorg Date: Wed, 25 Aug 2021 16:53:50 -0700 Subject: [PATCH 04/14] SimplePath: Add .read_text(encoding) and .read_bytes() This functionality is needed by PackagePath, which previously called .open(). Signed-off-by: Anders Kaseorg --- importlib_metadata/__init__.py | 6 ++---- importlib_metadata/_meta.py | 5 ++++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 744d58e5..625043a8 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -467,12 +467,10 @@ class PackagePath(pathlib.PurePosixPath): """A reference to a path in a package""" def read_text(self, encoding='utf-8'): - with self.locate().open(encoding=encoding) as stream: - return stream.read() + return self.locate().read_text(encoding=encoding) def read_binary(self): - with self.locate().open('rb') as stream: - return stream.read() + return self.locate().read_bytes() def locate(self): """Return a path-like object for this path""" diff --git a/importlib_metadata/_meta.py b/importlib_metadata/_meta.py index 37ee43e6..e2eca895 100644 --- a/importlib_metadata/_meta.py +++ b/importlib_metadata/_meta.py @@ -44,5 +44,8 @@ def __truediv__(self) -> 'SimplePath': def parent(self) -> 'SimplePath': ... # pragma: no cover - def read_text(self) -> str: + def read_text(self, encoding: str = ...) -> str: + ... # pragma: no cover + + def read_bytes(self) -> bytes: ... # pragma: no cover From 53e1a5354fb6590f2f1b8db6d9b5fc0cd8fdbcc3 Mon Sep 17 00:00:00 2001 From: Anders Kaseorg Date: Wed, 25 Aug 2021 17:07:24 -0700 Subject: [PATCH 05/14] FoldedCase: Improve split() compatibility Signed-off-by: Anders Kaseorg --- importlib_metadata/_text.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/importlib_metadata/_text.py b/importlib_metadata/_text.py index c88cfbb2..7371842c 100644 --- a/importlib_metadata/_text.py +++ b/importlib_metadata/_text.py @@ -26,6 +26,9 @@ class FoldedCase(str): >>> s.split('O') ['hell', ' w', 'rld'] + >>> s.split() + ['hello', 'world'] + >>> sorted(map(FoldedCase, ['GAMMA', 'alpha', 'Beta'])) ['alpha', 'Beta', 'GAMMA'] @@ -94,6 +97,8 @@ def lower(self): def index(self, sub): return self.lower().index(sub.lower()) - def split(self, splitter=' ', maxsplit=0): + def split(self, splitter=None, maxsplit=0): + if splitter is None: + return super().split() pattern = re.compile(re.escape(splitter), re.I) return pattern.split(self, maxsplit) From 1790ef235a11ce33f583d5238aaebf35de1ad1d2 Mon Sep 17 00:00:00 2001 From: Anders Kaseorg Date: Wed, 25 Aug 2021 18:14:23 -0700 Subject: [PATCH 06/14] tests: Correct self.skip to self.skipTest Signed-off-by: Anders Kaseorg --- tests/fixtures.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/fixtures.py b/tests/fixtures.py index c6e645f5..b561e294 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -282,7 +282,9 @@ def build_files(file_defs, prefix=pathlib.Path()): class FileBuilder: def unicode_filename(self): - return FS_NONASCII or self.skip("File system does not support non-ascii.") + if not FS_NONASCII: + self.skipTest("File system does not support non-ascii.") + return FS_NONASCII def DALS(str): From 3e474b2b618b17fbd555f786017d6fe08ce0b938 Mon Sep 17 00:00:00 2001 From: Anders Kaseorg Date: Thu, 26 Aug 2021 08:37:53 -0700 Subject: [PATCH 07/14] FoldedCase: Improve index() compatibility Signed-off-by: Anders Kaseorg --- importlib_metadata/_text.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/importlib_metadata/_text.py b/importlib_metadata/_text.py index 7371842c..7dd9c806 100644 --- a/importlib_metadata/_text.py +++ b/importlib_metadata/_text.py @@ -94,8 +94,8 @@ def in_(self, other): def lower(self): return super().lower() - def index(self, sub): - return self.lower().index(sub.lower()) + def index(self, sub, start=None, end=None): + return self.lower().index(sub.lower(), start, end) def split(self, splitter=None, maxsplit=0): if splitter is None: From f21fe55513e76f4aafdf1dde9807f5b56a5258d4 Mon Sep 17 00:00:00 2001 From: Anders Kaseorg Date: Thu, 26 Aug 2021 08:38:50 -0700 Subject: [PATCH 08/14] FoldedCase: Accept arbitrary objects in __eq__, __ne__, __contains__ Signed-off-by: Anders Kaseorg --- importlib_metadata/_text.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/importlib_metadata/_text.py b/importlib_metadata/_text.py index 7dd9c806..3c97b45b 100644 --- a/importlib_metadata/_text.py +++ b/importlib_metadata/_text.py @@ -74,16 +74,16 @@ def __gt__(self, other): return self.lower() > other.lower() def __eq__(self, other): - return self.lower() == other.lower() + return isinstance(other, str) and self.lower() == other.lower() def __ne__(self, other): - return self.lower() != other.lower() + return isinstance(other, str) and self.lower() != other.lower() def __hash__(self): return hash(self.lower()) def __contains__(self, other): - return super().lower().__contains__(other.lower()) + return isinstance(other, str) and super().lower().__contains__(other.lower()) def in_(self, other): "Does self appear in other?" From bb603e5fd3fa7adb3382cf8f1d3302efafe98b68 Mon Sep 17 00:00:00 2001 From: Anders Kaseorg Date: Thu, 26 Aug 2021 08:43:04 -0700 Subject: [PATCH 09/14] =?UTF-8?q?Replace=20always=5Fiterable=20with=20?= =?UTF-8?q?=E2=80=98or=20[]=E2=80=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit always_iterable cannot be correctly annotated in Python’s type system. Signed-off-by: Anders Kaseorg --- importlib_metadata/__init__.py | 4 +-- importlib_metadata/_itertools.py | 54 -------------------------------- 2 files changed, 2 insertions(+), 56 deletions(-) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 625043a8..252d4399 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -24,7 +24,7 @@ pypy_partial, ) from ._functools import method_cache -from ._itertools import always_iterable, unique_everseen +from ._itertools import unique_everseen from ._meta import PackageMetadata, SimplePath from contextlib import suppress @@ -1023,6 +1023,6 @@ def _top_level_declared(dist): def _top_level_inferred(dist): return { f.parts[0] if len(f.parts) > 1 else f.with_suffix('').name - for f in always_iterable(dist.files) + for f in dist.files or [] if f.suffix == ".py" } diff --git a/importlib_metadata/_itertools.py b/importlib_metadata/_itertools.py index d4ca9b91..dd45f2f0 100644 --- a/importlib_metadata/_itertools.py +++ b/importlib_metadata/_itertools.py @@ -17,57 +17,3 @@ def unique_everseen(iterable, key=None): if k not in seen: seen_add(k) yield element - - -# copied from more_itertools 8.8 -def always_iterable(obj, base_type=(str, bytes)): - """If *obj* is iterable, return an iterator over its items:: - - >>> obj = (1, 2, 3) - >>> list(always_iterable(obj)) - [1, 2, 3] - - If *obj* is not iterable, return a one-item iterable containing *obj*:: - - >>> obj = 1 - >>> list(always_iterable(obj)) - [1] - - If *obj* is ``None``, return an empty iterable: - - >>> obj = None - >>> list(always_iterable(None)) - [] - - By default, binary and text strings are not considered iterable:: - - >>> obj = 'foo' - >>> list(always_iterable(obj)) - ['foo'] - - If *base_type* is set, objects for which ``isinstance(obj, base_type)`` - returns ``True`` won't be considered iterable. - - >>> obj = {'a': 1} - >>> list(always_iterable(obj)) # Iterate over the dict's keys - ['a'] - >>> list(always_iterable(obj, base_type=dict)) # Treat dicts as a unit - [{'a': 1}] - - Set *base_type* to ``None`` to avoid any special handling and treat objects - Python considers iterable as iterable: - - >>> obj = 'foo' - >>> list(always_iterable(obj, base_type=None)) - ['f', 'o', 'o'] - """ - if obj is None: - return iter(()) - - if (base_type is not None) and isinstance(obj, base_type): - return iter((obj,)) - - try: - return iter(obj) - except TypeError: - return iter((obj,)) From f803c934070469ea940c67335130be98f0e3ae86 Mon Sep 17 00:00:00 2001 From: Anders Kaseorg Date: Thu, 26 Aug 2021 13:04:35 -0700 Subject: [PATCH 10/14] entry_points: Remove unnecessary functools.partial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There’s no using benefit to using functools.partial here since it’s only called once. Signed-off-by: Anders Kaseorg --- importlib_metadata/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 252d4399..a7de3b2e 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -973,9 +973,8 @@ def entry_points(**params) -> Union[EntryPoints, SelectableGroups]: :return: EntryPoints or SelectableGroups for all installed packages. """ norm_name = operator.attrgetter('_normalized_name') - unique = functools.partial(unique_everseen, key=norm_name) eps = itertools.chain.from_iterable( - dist.entry_points for dist in unique(distributions()) + dist.entry_points for dist in unique_everseen(distributions(), key=norm_name) ) return SelectableGroups.load(eps).select(**params) From 6e3903e5d84632d1982c0ea3e0928aa7b6731d44 Mon Sep 17 00:00:00 2001 From: Anders Kaseorg Date: Thu, 26 Aug 2021 13:18:13 -0700 Subject: [PATCH 11/14] EntryPoint: Remove None default for dist dist was only set to None temporarily during construction. This made the type annotation more complicated than necessary. Signed-off-by: Anders Kaseorg --- importlib_metadata/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index a7de3b2e..4b3aec10 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -31,7 +31,7 @@ from importlib import import_module from importlib.abc import MetaPathFinder from itertools import starmap -from typing import List, Mapping, Optional, Union +from typing import List, Mapping, Union __all__ = [ @@ -158,7 +158,7 @@ class EntryPoint( following the attr, and following any extras. """ - dist: Optional['Distribution'] = None + dist: 'Distribution' def load(self): """Load the entry point from its definition. If only a module From fb0adc7029b25d52ec8ee992b28c400a6e55fc4c Mon Sep 17 00:00:00 2001 From: Anders Kaseorg Date: Thu, 26 Aug 2021 13:54:58 -0700 Subject: [PATCH 12/14] tests: Replace __init__ with setUp. Signed-off-by: Anders Kaseorg --- tests/test_main.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_main.py b/tests/test_main.py index e73af818..4744c020 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -222,8 +222,7 @@ def test_discovery(self): class TestEntryPoints(unittest.TestCase): - def __init__(self, *args): - super().__init__(*args) + def setUp(self): self.ep = importlib_metadata.EntryPoint('name', 'value', 'group') def test_entry_point_pickleable(self): From b1b358baf61a05efacde9e1213d127d62e9c959c Mon Sep 17 00:00:00 2001 From: Anders Kaseorg Date: Wed, 25 Aug 2021 17:07:26 -0700 Subject: [PATCH 13/14] Complete type annotations Signed-off-by: Anders Kaseorg --- conftest.py | 4 +- exercises.py | 14 +- importlib_metadata/__init__.py | 455 ++++++++++++++++++-------- importlib_metadata/_adapters.py | 22 +- importlib_metadata/_collections.py | 26 +- importlib_metadata/_compat.py | 23 +- importlib_metadata/_functools.py | 23 +- importlib_metadata/_itertools.py | 31 +- importlib_metadata/_meta.py | 6 +- importlib_metadata/_text.py | 27 +- prepare/example/example/__init__.py | 2 +- prepare/example2/example2/__init__.py | 2 +- tests/fixtures.py | 95 +++--- tests/py39compat.py | 4 +- tests/test_api.py | 107 +++--- tests/test_integration.py | 18 +- tests/test_main.py | 81 ++--- tests/test_zip.py | 27 +- 18 files changed, 616 insertions(+), 351 deletions(-) diff --git a/conftest.py b/conftest.py index ab6c8cae..5f4d0ac0 100644 --- a/conftest.py +++ b/conftest.py @@ -7,11 +7,11 @@ ] -def pytest_configure(): +def pytest_configure() -> None: remove_importlib_metadata() -def remove_importlib_metadata(): +def remove_importlib_metadata() -> None: """ Because pytest imports importlib_metadata, the coverage reports are broken (#322). So work around the issue by diff --git a/exercises.py b/exercises.py index bc8a44e9..acff8e18 100644 --- a/exercises.py +++ b/exercises.py @@ -1,23 +1,23 @@ from pytest_perf.deco import extras -@extras('perf') -def discovery_perf(): +@extras('perf') # type: ignore[misc] +def discovery_perf() -> None: "discovery" import importlib_metadata # end warmup importlib_metadata.distribution('ipython') -def entry_points_perf(): +def entry_points_perf() -> None: "entry_points()" import importlib_metadata # end warmup importlib_metadata.entry_points() -@extras('perf') -def cached_distribution_perf(): +@extras('perf') # type: ignore[misc] +def cached_distribution_perf() -> None: "cached distribution" import importlib_metadata @@ -25,8 +25,8 @@ def cached_distribution_perf(): importlib_metadata.distribution('ipython') -@extras('perf') -def uncached_distribution_perf(): +@extras('perf') # type: ignore[misc] +def uncached_distribution_perf() -> None: "uncached distribution" import importlib import importlib_metadata diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 4b3aec10..c9b373b1 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -18,6 +18,7 @@ from ._collections import FreezableDefaultDict, Pair from ._compat import ( NullFinder, + Protocol, PyPy_repr, SupportsIndex, install, @@ -31,7 +32,26 @@ from importlib import import_module from importlib.abc import MetaPathFinder from itertools import starmap -from typing import List, Mapping, Union +from typing import ( + Any, + Callable, + Dict, + Iterable, + Iterator, + KeysView, + List, + Mapping, + Match, + NamedTuple, + Optional, + Set, + Tuple, + Type, + TypeVar, + Union, + ValuesView, + overload, +) __all__ = [ @@ -53,11 +73,13 @@ class PackageNotFoundError(ModuleNotFoundError): """The package was not found.""" - def __str__(self): + args: Tuple[str] + + def __str__(self) -> str: return f"No package metadata was found for {self.name}" @property - def name(self): + def name(self) -> str: # type: ignore[override] (name,) = self.args return name @@ -104,7 +126,7 @@ class Sectioned: ).lstrip() @classmethod - def section_pairs(cls, text): + def section_pairs(cls, text: str) -> Iterator[Pair]: return ( section._replace(value=Pair.parse(section.value)) for section in cls.read(text, filter_=cls.valid) @@ -112,7 +134,9 @@ def section_pairs(cls, text): ) @staticmethod - def read(text, filter_=None): + def read( + text: str, filter_: Optional[Callable[[str], bool]] = None + ) -> Iterator[Pair]: lines = filter(filter_, map(str.strip, text.splitlines())) name = None for value in lines: @@ -123,13 +147,17 @@ def read(text, filter_=None): yield Pair(name, value) @staticmethod - def valid(line): - return line and not line.startswith('#') + def valid(line: str) -> bool: + return line != '' and not line.startswith('#') + +class EntryPointBase(NamedTuple): + name: str + value: str + group: str -class EntryPoint( - PyPy_repr, collections.namedtuple('EntryPointBase', 'name value group') -): + +class EntryPoint(PyPy_repr, EntryPointBase): """An entry point as defined by Python packaging conventions. See `the packaging docs on entry points @@ -160,36 +188,40 @@ class EntryPoint( dist: 'Distribution' - def load(self): + def load(self) -> Any: """Load the entry point from its definition. If only a module is indicated by the value, return that module. Otherwise, return the named object. """ match = self.pattern.match(self.value) + assert match is not None module = import_module(match.group('module')) attrs = filter(None, (match.group('attr') or '').split('.')) return functools.reduce(getattr, attrs, module) @property - def module(self): + def module(self) -> str: match = self.pattern.match(self.value) + assert match is not None return match.group('module') @property - def attr(self): + def attr(self) -> str: match = self.pattern.match(self.value) + assert match is not None return match.group('attr') @property - def extras(self): + def extras(self) -> List[Match[str]]: match = self.pattern.match(self.value) + assert match is not None return list(re.finditer(r'\w+', match.group('extras') or '')) - def _for(self, dist): + def _for(self, dist: 'Distribution') -> 'EntryPoint': self.dist = dist return self - def __iter__(self): + def __iter__(self) -> Iterator[object]: # type: ignore[override] """ Supply iter so one may construct dicts of EntryPoints by name. """ @@ -200,18 +232,28 @@ def __iter__(self): warnings.warn(msg, DeprecationWarning) return iter((self.name, self)) - def __reduce__(self): + def __reduce__(self) -> Tuple[Type['EntryPoint'], Tuple[str, str, str]]: return ( self.__class__, (self.name, self.value, self.group), ) - def matches(self, **params): + def matches(self, **params: str) -> bool: attrs = (getattr(self, param) for param in params) return all(map(operator.eq, params.values(), attrs)) -class DeprecatedList(list): +class _SupportsLessThan(Protocol): + def __lt__(self, __other: Any) -> bool: + ... # pragma: no cover + + +_T = TypeVar('_T') +_DeprecatedListT = TypeVar('_DeprecatedListT', bound='DeprecatedList[Any]') +_SupportsLessThanT = TypeVar('_SupportsLessThanT', bound=_SupportsLessThan) + + +class DeprecatedList(List[_T]): """ Allow an otherwise immutable object to implement mutability for compatibility. @@ -250,53 +292,83 @@ class DeprecatedList(list): stacklevel=pypy_partial(2), ) - def __setitem__(self, *args, **kwargs): + @overload + def __setitem__(self, index: SupportsIndex, value: _T) -> None: + ... # pragma: no cover + + @overload + def __setitem__(self, index: slice, value: Iterable[_T]) -> None: + ... # pragma: no cover + + def __setitem__(self, index: Union[SupportsIndex, slice], value: Any) -> None: self._warn() - return super().__setitem__(*args, **kwargs) + super().__setitem__(index, value) - def __delitem__(self, *args, **kwargs): + def __delitem__(self, index: Union[SupportsIndex, slice]) -> None: self._warn() - return super().__delitem__(*args, **kwargs) + super().__delitem__(index) - def append(self, *args, **kwargs): + def append(self, value: _T) -> None: self._warn() - return super().append(*args, **kwargs) + super().append(value) - def reverse(self, *args, **kwargs): + def reverse(self) -> None: self._warn() - return super().reverse(*args, **kwargs) + super().reverse() - def extend(self, *args, **kwargs): + def extend(self, values: Iterable[_T]) -> None: self._warn() - return super().extend(*args, **kwargs) + super().extend(values) - def pop(self, *args, **kwargs): + def pop(self, index: int = -1) -> _T: self._warn() - return super().pop(*args, **kwargs) + return super().pop(index) - def remove(self, *args, **kwargs): + def remove(self, value: _T) -> None: self._warn() - return super().remove(*args, **kwargs) + super().remove(value) - def __iadd__(self, *args, **kwargs): + def __iadd__(self: _DeprecatedListT, values: Iterable[_T]) -> _DeprecatedListT: self._warn() - return super().__iadd__(*args, **kwargs) + return super().__iadd__(values) - def __add__(self, other): + def __add__( + self: _DeprecatedListT, other: Union[List[_T], Tuple[_T, ...]] + ) -> _DeprecatedListT: if not isinstance(other, tuple): self._warn() other = tuple(other) return self.__class__(tuple(self) + other) - def insert(self, *args, **kwargs): + def insert(self, index: int, value: _T) -> None: self._warn() - return super().insert(*args, **kwargs) - - def sort(self, *args, **kwargs): + super().insert(index, value) + + @overload + def sort( + self: 'DeprecatedList[_SupportsLessThanT]', + *, + key: None = ..., + reverse: bool = ..., + ) -> None: + ... # pragma: no cover + + @overload + def sort( + self, *, key: Callable[[_T], _SupportsLessThan], reverse: bool = ... + ) -> None: + ... # pragma: no cover + + def sort( + self, + *, + key: Optional[Callable[[_T], _SupportsLessThan]] = None, + reverse: bool = False, + ) -> None: self._warn() - return super().sort(*args, **kwargs) + super().sort(key=key, reverse=reverse) - def __eq__(self, other): + def __eq__(self, other: object) -> bool: if isinstance(other, list): self._warn() other = tuple(other) @@ -304,14 +376,28 @@ def __eq__(self, other): return tuple(self).__eq__(other) -class EntryPoints(DeprecatedList): +class EntryPoints(DeprecatedList[EntryPoint]): """ An immutable collection of selectable EntryPoint objects. """ __slots__ = () - def __getitem__(self, name): # -> EntryPoint: + @overload + def __getitem__(self, name: SupportsIndex) -> EntryPoint: + ... # pragma: no cover + + @overload + def __getitem__(self, name: slice) -> List[EntryPoint]: + ... # pragma: no cover + + @overload + def __getitem__(self, name: str) -> EntryPoint: + ... # pragma: no cover + + def __getitem__( + self, name: Union[SupportsIndex, slice, str] + ) -> Union[EntryPoint, List[EntryPoint]]: """ Get the EntryPoint in self matching name. """ @@ -328,7 +414,7 @@ def __getitem__(self, name): # -> EntryPoint: except StopIteration: raise KeyError(name) - def select(self, **params): + def select(self, **params: str) -> 'EntryPoints': """ Select entry points from self that match the given parameters (typically group and/or name). @@ -336,14 +422,14 @@ def select(self, **params): return EntryPoints(ep for ep in self if ep.matches(**params)) @property - def names(self): + 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): + def groups(self) -> Set[str]: """ Return the set of all groups of all entry points. @@ -354,22 +440,26 @@ def groups(self): return {ep.group for ep in self} @classmethod - def _from_text_for(cls, text, dist): + def _from_text_for(cls, text: str, dist: 'Distribution') -> 'EntryPoints': return cls(ep._for(dist) for ep in cls._from_text(text)) @classmethod - def _from_text(cls, text): + def _from_text(cls, text: str) -> Iterator['EntryPoint']: return itertools.starmap(EntryPoint, cls._parse_groups(text or '')) @staticmethod - def _parse_groups(text): + def _parse_groups(text: str) -> Iterator[Tuple[str, str, Optional[str]]]: return ( (item.value.name, item.value.value, item.name) for item in Sectioned.section_pairs(text) ) -class DeprecatedDict(dict): +_K = TypeVar('_K') +_V = TypeVar('_V') + + +class DeprecatedDict(Dict[_K, _V]): """ Compatibility add-in for mapping to indicate that mapping behavior is deprecated. @@ -398,46 +488,54 @@ class DeprecatedDict(dict): stacklevel=pypy_partial(2), ) - def __getitem__(self, name): + def __getitem__(self, name: _K) -> _V: self._warn() return super().__getitem__(name) - def get(self, name, default=None): + @overload + def get(self, name: _K) -> Optional[_V]: + ... # pragma: no cover + + @overload + def get(self, name: _K, default: _T) -> Union[_V, _T]: + ... # pragma: no cover + + def get(self, name: _K, default: Optional[_T] = None) -> Union[_V, _T, None]: self._warn() return super().get(name, default) - def __iter__(self): + def __iter__(self) -> Iterator[_K]: self._warn() return super().__iter__() - def __contains__(self, *args): + def __contains__(self, value: object) -> bool: self._warn() - return super().__contains__(*args) + return super().__contains__(value) - def keys(self): + def keys(self) -> KeysView[_K]: self._warn() return super().keys() - def values(self): + def values(self) -> ValuesView[_V]: self._warn() return super().values() -class SelectableGroups(DeprecatedDict): +class SelectableGroups(DeprecatedDict[str, EntryPoints]): """ A backward- and forward-compatible result from entry_points that fully implements the dict interface. """ @classmethod - def load(cls, eps): + def load(cls, eps: Iterable[EntryPoint]) -> 'SelectableGroups': by_group = operator.attrgetter('group') ordered = sorted(eps, key=by_group) grouped = itertools.groupby(ordered, by_group) return cls((group, EntryPoints(eps)) for group, eps in grouped) @property - def _all(self): + def _all(self) -> EntryPoints: """ Reconstruct a list of all entrypoints from the groups. """ @@ -445,11 +543,11 @@ def _all(self): return EntryPoints(itertools.chain.from_iterable(groups)) @property - def groups(self): + def groups(self) -> Set[str]: return self._all.groups @property - def names(self): + def names(self) -> Set[str]: """ for coverage: >>> SelectableGroups().names @@ -457,7 +555,31 @@ def names(self): """ return self._all.names - def select(self, **params): + @overload + def select(self) -> 'SelectableGroups': + ... # pragma: no cover + + @overload + def select(self, *, name: str, **params: str) -> EntryPoints: + ... # pragma: no cover + + @overload + def select(self, *, value: str, **params: str) -> EntryPoints: + ... # pragma: no cover + + @overload + def select(self, *, group: str, **params: str) -> EntryPoints: + ... # pragma: no cover + + @overload + def select(self, *, module: str, **params: str) -> EntryPoints: + ... # pragma: no cover + + @overload + def select(self, *, attr: str, **params: str) -> EntryPoints: + ... # pragma: no cover + + def select(self, **params: str) -> Union['SelectableGroups', EntryPoints]: if not params: return self return self._all.select(**params) @@ -466,22 +588,26 @@ def select(self, **params): class PackagePath(pathlib.PurePosixPath): """A reference to a path in a package""" - def read_text(self, encoding='utf-8'): + hash: Optional['FileHash'] + size: Optional[int] + dist: 'Distribution' + + def read_text(self, encoding: str = 'utf-8') -> str: return self.locate().read_text(encoding=encoding) - def read_binary(self): + def read_binary(self) -> bytes: return self.locate().read_bytes() - def locate(self): + def locate(self) -> SimplePath: """Return a path-like object for this path""" return self.dist.locate_file(self) class FileHash: - def __init__(self, spec): + def __init__(self, spec: str) -> None: self.mode, _, self.value = spec.partition('=') - def __repr__(self): + def __repr__(self) -> str: return f'' @@ -489,7 +615,7 @@ class Distribution: """A Python distribution package.""" @abc.abstractmethod - def read_text(self, filename): + def read_text(self, filename: Union[str, 'os.PathLike[str]']) -> Optional[str]: """Attempt to load metadata file given by the name. :param filename: The name of the file in the distribution info. @@ -497,14 +623,14 @@ def read_text(self, filename): """ @abc.abstractmethod - def locate_file(self, path): + def locate_file(self, path: Union[str, 'os.PathLike[str]']) -> SimplePath: """ Given a path to a file in this distribution, return a path to it. """ @classmethod - def from_name(cls, name): + def from_name(cls, name: str) -> 'Distribution': """Return the Distribution for the given package name. :param name: The name of the distribution package to search for. @@ -522,7 +648,7 @@ def from_name(cls, name): raise PackageNotFoundError(name) @classmethod - def discover(cls, **kwargs): + def discover(cls, **kwargs: Any) -> Iterator['PathDistribution']: """Return an iterable of Distribution objects for all packages. Pass a ``context`` or pass keyword arguments for constructing @@ -531,7 +657,7 @@ def discover(cls, **kwargs): :context: A ``DistributionFinder.Context`` object. :return: Iterable of Distribution objects for all packages. """ - context = kwargs.pop('context', None) + context: Optional[DistributionFinder.Context] = kwargs.pop('context', None) if context and kwargs: raise ValueError("cannot accept context and kwargs") context = context or DistributionFinder.Context(**kwargs) @@ -540,7 +666,7 @@ def discover(cls, **kwargs): ) @staticmethod - def at(path): + def at(path: Union[str, 'os.PathLike[str]']) -> 'PathDistribution': """Return a Distribution for the indicated metadata path :param path: a string or path-like object @@ -549,7 +675,9 @@ def at(path): return PathDistribution(pathlib.Path(path)) @staticmethod - def _discover_resolvers(): + def _discover_resolvers() -> Iterator[ + Callable[['DistributionFinder.Context'], Iterator['PathDistribution']] + ]: """Search the meta_path for resolvers.""" declared = ( getattr(finder, 'find_distributions', None) for finder in sys.meta_path @@ -557,7 +685,7 @@ def _discover_resolvers(): return filter(None, declared) @classmethod - def _local(cls, root='.'): + def _local(cls, root: str = '.') -> 'PathDistribution': from pep517 import build, meta system = build.compat_system(root) @@ -582,30 +710,33 @@ def metadata(self) -> _meta.PackageMetadata: # effect is to just end up using the PathDistribution's self._path # (which points to the egg-info file) attribute unchanged. or self.read_text('') + or '' ) return _adapters.Message(email.message_from_string(text)) @property - def name(self): + def name(self) -> str: """Return the 'Name' metadata for the distribution package.""" return self.metadata['Name'] @property - def _normalized_name(self): + def _normalized_name(self) -> str: """Return a normalized version of the name.""" return Prepared.normalize(self.name) @property - def version(self): + def version(self) -> str: """Return the 'Version' metadata for the distribution package.""" return self.metadata['Version'] @property - def entry_points(self): - return EntryPoints._from_text_for(self.read_text('entry_points.txt'), self) + def entry_points(self) -> EntryPoints: + return EntryPoints._from_text_for( + self.read_text('entry_points.txt') or '', self + ) @property - def files(self): + def files(self) -> Optional[List[PackagePath]]: """Files in this distribution. :return: List of PackagePath for this distribution or None @@ -617,23 +748,29 @@ def files(self): """ file_lines = self._read_files_distinfo() or self._read_files_egginfo() - def make_file(name, hash=None, size_str=None): + def make_file( + name: str, hash: Optional[str] = None, size_str: Optional[str] = None + ) -> PackagePath: result = PackagePath(name) result.hash = FileHash(hash) if hash else None result.size = int(size_str) if size_str else None result.dist = self return result - return file_lines and list(starmap(make_file, csv.reader(file_lines))) + return ( + None + if file_lines is None + else list(starmap(make_file, csv.reader(file_lines))) + ) - def _read_files_distinfo(self): + def _read_files_distinfo(self) -> Optional[Iterable[str]]: """ Read the lines of RECORD """ text = self.read_text('RECORD') return text and text.splitlines() - def _read_files_egginfo(self): + def _read_files_egginfo(self) -> Optional[Iterable[str]]: """ SOURCES.txt might contain literal commas, so wrap each line in quotes. @@ -642,24 +779,26 @@ def _read_files_egginfo(self): return text and map('"{}"'.format, text.splitlines()) @property - def requires(self): + def requires(self) -> Optional[List[str]]: """Generated requirements specified for this Distribution""" reqs = self._read_dist_info_reqs() or self._read_egg_info_reqs() - return reqs and list(reqs) + return None if reqs is None else list(reqs) - def _read_dist_info_reqs(self): + def _read_dist_info_reqs(self) -> List[str]: return self.metadata.get_all('Requires-Dist') - def _read_egg_info_reqs(self): + def _read_egg_info_reqs(self) -> Optional[Iterable[str]]: source = self.read_text('requires.txt') return source and self._deps_from_requires_text(source) @classmethod - def _deps_from_requires_text(cls, source): + def _deps_from_requires_text(cls, source: str) -> Iterator[str]: return cls._convert_egg_info_reqs_to_simple_reqs(Sectioned.read(source)) @staticmethod - def _convert_egg_info_reqs_to_simple_reqs(sections): + def _convert_egg_info_reqs_to_simple_reqs( + sections: Iterator[Pair], + ) -> Iterator[str]: """ Historically, setuptools would solicit and store 'extra' requirements, including those with environment markers, @@ -670,10 +809,10 @@ def _convert_egg_info_reqs_to_simple_reqs(sections): latter. See _test_deps_from_requires_text for an example. """ - def make_condition(name): + def make_condition(name: str) -> str: return name and f'extra == "{name}"' - def parse_condition(section): + def parse_condition(section: Optional[str]) -> str: section = section or '' extra, sep, markers = section.partition(':') if extra and markers: @@ -702,17 +841,17 @@ class Context: parameters defined below when appropriate. """ - name = None + name: Optional[str] = None """ Specific name for which a distribution finder should match. A name of ``None`` matches all distributions. """ - def __init__(self, **kwargs): + def __init__(self, **kwargs: object) -> None: vars(self).update(kwargs) @property - def path(self): + def path(self) -> str: """ The sequence of directory path that a distribution finder should search. @@ -723,7 +862,9 @@ def path(self): return vars(self).get('path', sys.path) @abc.abstractmethod - def find_distributions(self, context=Context()): + def find_distributions( + self, context: Context = Context() + ) -> Iterable['PathDistribution']: """ Find distributions. @@ -739,50 +880,51 @@ class FastPath: children. """ - @functools.lru_cache() # type: ignore - def __new__(cls, root): - return super().__new__(cls) + @functools.lru_cache() # type: ignore[misc] + def __new__(cls, root: Union[str, 'os.PathLike[str]']) -> 'FastPath': + return super().__new__(cls) # type: ignore[no-any-return] - def __init__(self, root): + def __init__(self, root: Union[str, 'os.PathLike[str]']) -> None: self.root = str(root) - def joinpath(self, child): + def joinpath(self, child: Union[str, 'os.PathLike[str]']) -> pathlib.Path: return pathlib.Path(self.root, child) - def children(self): + def children(self) -> Iterable[str]: with suppress(Exception): return os.listdir(self.root or '') with suppress(Exception): return self.zip_children() return [] - def zip_children(self): + def zip_children(self) -> Dict[str, None]: zip_path = zipp.Path(self.root) names = zip_path.root.namelist() - self.joinpath = zip_path.joinpath + self.joinpath = zip_path.joinpath # type: ignore[assignment] return dict.fromkeys(child.split(posixpath.sep, 1)[0] for child in names) - def search(self, name): + def search(self, name: 'Prepared') -> Iterator[pathlib.Path]: return self.lookup(self.mtime).search(name) @property - def mtime(self): + def mtime(self) -> Optional[float]: with suppress(OSError): return os.stat(self.root).st_mtime - self.lookup.cache_clear() + self.lookup.cache_clear() # type: ignore[attr-defined] + return None @method_cache - def lookup(self, mtime): + def lookup(self, mtime: Optional[float]) -> 'Lookup': return Lookup(self) class Lookup: - def __init__(self, path: FastPath): + def __init__(self, path: FastPath) -> None: base = os.path.basename(path.root).lower() base_is_egg = base.endswith(".egg") - self.infos = FreezableDefaultDict(list) - self.eggs = FreezableDefaultDict(list) + self.infos = FreezableDefaultDict[Optional[str], List[pathlib.Path]](list) + self.eggs = FreezableDefaultDict[Optional[str], List[pathlib.Path]](list) for child in path.children(): low = child.lower() @@ -799,7 +941,7 @@ def __init__(self, path: FastPath): self.infos.freeze() self.eggs.freeze() - def search(self, prepared): + def search(self, prepared: 'Prepared') -> Iterator[pathlib.Path]: infos = ( self.infos[prepared.normalized] if prepared @@ -818,10 +960,10 @@ class Prepared: A prepared search for metadata on a possibly-named package. """ - normalized = None - legacy_normalized = None + normalized: Optional[str] = None + legacy_normalized: Optional[str] = None - def __init__(self, name): + def __init__(self, name: Optional[str]) -> None: self.name = name if name is None: return @@ -829,21 +971,21 @@ def __init__(self, name): self.legacy_normalized = self.legacy_normalize(name) @staticmethod - def normalize(name): + def normalize(name: str) -> str: """ PEP 503 normalization plus dashes as underscores. """ return re.sub(r"[-_.]+", "-", name).lower().replace('-', '_') @staticmethod - def legacy_normalize(name): + def legacy_normalize(name: str) -> str: """ Normalize the package name as found in the convention in older packaging tools versions and specs. """ return name.lower().replace('-', '_') - def __bool__(self): + def __bool__(self) -> bool: return bool(self.name) @@ -855,7 +997,9 @@ class MetadataPathFinder(NullFinder, DistributionFinder): of Python that do not have a PathFinder find_distributions(). """ - def find_distributions(self, context=DistributionFinder.Context()): + def find_distributions( + self, context: DistributionFinder.Context = DistributionFinder.Context() + ) -> Iterator['PathDistribution']: """ Find distributions. @@ -868,26 +1012,28 @@ def find_distributions(self, context=DistributionFinder.Context()): return map(PathDistribution, found) @classmethod - def _search_paths(cls, name, paths): + def _search_paths( + cls, name: Optional[str], paths: Iterable[Union[str, 'os.PathLike[str]']] + ) -> Iterator[pathlib.Path]: """Find metadata directories in paths heuristically.""" prepared = Prepared(name) return itertools.chain.from_iterable( path.search(prepared) for path in map(FastPath, paths) ) - def invalidate_caches(cls): + def invalidate_caches(cls) -> None: FastPath.__new__.cache_clear() class PathDistribution(Distribution): - def __init__(self, path: SimplePath): + def __init__(self, path: SimplePath) -> None: """Construct a distribution. :param path: SimplePath indicating the metadata directory. """ self._path = path - def read_text(self, filename): + def read_text(self, filename: Union[str, 'os.PathLike[str]']) -> Optional[str]: with suppress( FileNotFoundError, IsADirectoryError, @@ -896,14 +1042,15 @@ def read_text(self, filename): PermissionError, ): return self._path.joinpath(filename).read_text(encoding='utf-8') + return None read_text.__doc__ = Distribution.read_text.__doc__ - def locate_file(self, path): + def locate_file(self, path: Union[str, 'os.PathLike[str]']) -> SimplePath: return self._path.parent / path @property - def _normalized_name(self): + def _normalized_name(self) -> str: """ Performance optimization: where possible, resolve the normalized name from the file system path. @@ -911,15 +1058,15 @@ def _normalized_name(self): stem = os.path.basename(str(self._path)) return self._name_from_stem(stem) or super()._normalized_name - def _name_from_stem(self, stem): + def _name_from_stem(self, stem: str) -> Optional[str]: name, ext = os.path.splitext(stem) if ext not in ('.dist-info', '.egg-info'): - return + return None name, sep, rest = stem.partition('-') return name -def distribution(distribution_name): +def distribution(distribution_name: str) -> Distribution: """Get the ``Distribution`` instance for the named package. :param distribution_name: The name of the distribution package as a string. @@ -928,7 +1075,7 @@ def distribution(distribution_name): return Distribution.from_name(distribution_name) -def distributions(**kwargs): +def distributions(**kwargs: Any) -> Iterator[PathDistribution]: """Get all ``Distribution`` instances in the current environment. :return: An iterable of ``Distribution`` instances. @@ -936,7 +1083,7 @@ def distributions(**kwargs): return Distribution.discover(**kwargs) -def metadata(distribution_name) -> _meta.PackageMetadata: +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. @@ -945,7 +1092,7 @@ def metadata(distribution_name) -> _meta.PackageMetadata: return Distribution.from_name(distribution_name).metadata -def version(distribution_name): +def version(distribution_name: str) -> str: """Get the version string for the named package. :param distribution_name: The name of the distribution package to query. @@ -955,7 +1102,37 @@ def version(distribution_name): return distribution(distribution_name).version -def entry_points(**params) -> Union[EntryPoints, SelectableGroups]: +@overload +def entry_points() -> SelectableGroups: + ... # pragma: no cover + + +@overload +def entry_points(*, name: str, **params: str) -> EntryPoints: + ... # pragma: no cover + + +@overload +def entry_points(*, value: str, **params: str) -> EntryPoints: + ... # pragma: no cover + + +@overload +def entry_points(*, group: str, **params: str) -> EntryPoints: + ... # pragma: no cover + + +@overload +def entry_points(*, module: str, **params: str) -> EntryPoints: + ... # pragma: no cover + + +@overload +def entry_points(*, attr: str, **params: str) -> EntryPoints: + ... # pragma: no cover + + +def entry_points(**params: str) -> Union[EntryPoints, SelectableGroups]: """Return EntryPoint objects for all installed packages. Pass selection parameters (group or name) to filter the @@ -979,7 +1156,7 @@ def entry_points(**params) -> Union[EntryPoints, SelectableGroups]: return SelectableGroups.load(eps).select(**params) -def files(distribution_name): +def files(distribution_name: str) -> Optional[List[PackagePath]]: """Return a list of files for the named package. :param distribution_name: The name of the distribution package to query. @@ -988,7 +1165,7 @@ def files(distribution_name): return distribution(distribution_name).files -def requires(distribution_name): +def requires(distribution_name: str) -> Optional[List[str]]: """ Return a list of requirements for the named package. @@ -1015,11 +1192,11 @@ def packages_distributions() -> Mapping[str, List[str]]: return dict(pkg_to_dist) -def _top_level_declared(dist): +def _top_level_declared(dist: Distribution) -> List[str]: return (dist.read_text('top_level.txt') or '').split() -def _top_level_inferred(dist): +def _top_level_inferred(dist: Distribution) -> Set[str]: return { f.parts[0] if len(f.parts) > 1 else f.with_suffix('').name for f in dist.files or [] diff --git a/importlib_metadata/_adapters.py b/importlib_metadata/_adapters.py index aa460d3e..0b8bf628 100644 --- a/importlib_metadata/_adapters.py +++ b/importlib_metadata/_adapters.py @@ -3,6 +3,7 @@ import email.message from ._text import FoldedCase +from typing import Dict, Iterator, List, Tuple, Union class Message(email.message.Message): @@ -27,38 +28,39 @@ class Message(email.message.Message): Keys that may be indicated multiple times per PEP 566. """ - def __new__(cls, orig: email.message.Message): - res = super().__new__(cls) + def __new__(cls, orig: email.message.Message) -> 'Message': + res: Message = super().__new__(cls) vars(res).update(vars(orig)) return res - def __init__(self, *args, **kwargs): + def __init__(self, orig: email.message.Message) -> None: self._headers = self._repair_headers() # suppress spurious error from mypy - def __iter__(self): - return super().__iter__() + # https://github.com/python/typeshed/pull/5960 + def __iter__(self) -> Iterator[str]: + return super().__iter__() # type: ignore[misc,no-any-return] - def _repair_headers(self): - def redent(value): + def _repair_headers(self) -> List[Tuple[str, str]]: + def redent(value: str) -> str: "Correct for RFC822 indentation" if not value or '\n' not in value: return value return textwrap.dedent(' ' * 8 + value) headers = [(key, redent(value)) for key, value in vars(self)['_headers']] - if self._payload: + if self._payload: # type: ignore[attr-defined] headers.append(('Description', self.get_payload())) return headers @property - def json(self): + def json(self) -> Dict[str, Union[str, List[str]]]: """ Convert PackageMetadata to a JSON-compatible format per PEP 0566. """ - def transform(key): + def transform(key: FoldedCase) -> Tuple[str, Union[str, List[str]]]: value = self.get_all(key) if key in self.multiple_use_keys else self[key] if key == 'Keywords': value = re.split(r'\s+', value) diff --git a/importlib_metadata/_collections.py b/importlib_metadata/_collections.py index cf0954e1..e802d84d 100644 --- a/importlib_metadata/_collections.py +++ b/importlib_metadata/_collections.py @@ -1,8 +1,12 @@ -import collections +from typing import Any, DefaultDict, NamedTuple, Optional, TypeVar # from jaraco.collections 3.3 -class FreezableDefaultDict(collections.defaultdict): +K = TypeVar('K') +V = TypeVar('V') + + +class FreezableDefaultDict(DefaultDict[K, V]): """ Often it is desirable to prevent the mutation of a default dict after its initial construction, such @@ -17,14 +21,20 @@ class FreezableDefaultDict(collections.defaultdict): 1 """ - def __missing__(self, key): - return getattr(self, '_frozen', super().__missing__)(key) + def __missing__(self, key: K) -> V: + return getattr( # type: ignore[no-any-return] + self, '_frozen', super().__missing__ + )(key) + + def freeze(self) -> None: + if self.default_factory is not None: + self._frozen = lambda key: self.default_factory() - def freeze(self): - self._frozen = lambda key: self.default_factory() +class Pair(NamedTuple): + name: Optional[str] + value: Any # Python 3.6 doesn't support generic NamedTuple -class Pair(collections.namedtuple('Pair', 'name value')): @classmethod - def parse(cls, text): + def parse(cls, text: str) -> 'Pair': return cls(*map(str.strip, text.split("=", 1))) diff --git a/importlib_metadata/_compat.py b/importlib_metadata/_compat.py index 5ca36eef..6fb0616e 100644 --- a/importlib_metadata/_compat.py +++ b/importlib_metadata/_compat.py @@ -1,6 +1,8 @@ import sys import platform +from typing import TypeVar + __all__ = ['install', 'NullFinder', 'PyPy_repr', 'Protocol', 'SupportsIndex'] @@ -11,7 +13,10 @@ from typing_extensions import Protocol, SupportsIndex -def install(cls): +TypeT = TypeVar('TypeT', bound=type) + + +def install(cls: TypeT) -> TypeT: """ Class decorator for installation on sys.meta_path. @@ -24,7 +29,7 @@ def install(cls): return cls -def disable_stdlib_finder(): +def disable_stdlib_finder() -> None: """ Give the backport primacy for discovering path-based distributions by monkey-patching the stdlib O_O. @@ -33,13 +38,13 @@ def disable_stdlib_finder(): behavior. """ - def matches(finder): + def matches(finder: object) -> bool: return getattr( finder, '__module__', None ) == '_frozen_importlib_external' and hasattr(finder, 'find_distributions') for finder in filter(matches, sys.meta_path): # pragma: nocover - del finder.find_distributions + del finder.find_distributions # type: ignore[attr-defined] class NullFinder: @@ -49,7 +54,7 @@ class NullFinder: """ @staticmethod - def find_spec(*args, **kwargs): + def find_spec(*args: object, **kwargs: object) -> None: return None # In Python 2, the import system requires finders @@ -69,12 +74,12 @@ class PyPy_repr: affected = hasattr(sys, 'pypy_version_info') - def __compat_repr__(self): # pragma: nocover - def make_param(name): + def __compat_repr__(self) -> str: # pragma: nocover + def make_param(name: str) -> str: value = getattr(self, name) return f'{name}={value!r}' - params = ', '.join(map(make_param, self._fields)) + params = ', '.join(map(make_param, self._fields)) # type: ignore[attr-defined] return f'EntryPoint({params})' if affected: # pragma: nocover @@ -82,7 +87,7 @@ def make_param(name): del affected -def pypy_partial(val): +def pypy_partial(val: int) -> int: """ Adjust for variable stacklevel on partial under PyPy. diff --git a/importlib_metadata/_functools.py b/importlib_metadata/_functools.py index 73f50d00..71794ada 100644 --- a/importlib_metadata/_functools.py +++ b/importlib_metadata/_functools.py @@ -1,9 +1,19 @@ import types import functools +from typing import Callable, TypeVar + + +CallableT = TypeVar("CallableT", bound=Callable[..., object]) + # from jaraco.functools 3.3 -def method_cache(method, cache_wrapper=None): +def method_cache( + method: CallableT, + cache_wrapper: Callable[ + [CallableT], CallableT + ] = functools.lru_cache(), # type: ignore[assignment] +) -> CallableT: """ Wrap lru_cache to support storing the cache data in the object instances. @@ -70,16 +80,17 @@ def method_cache(method, cache_wrapper=None): http://code.activestate.com/recipes/577452-a-memoize-decorator-for-instance-methods/ for another implementation and additional justification. """ - cache_wrapper = cache_wrapper or functools.lru_cache() - def wrapper(self, *args, **kwargs): + def wrapper(self: object, *args: object, **kwargs: object) -> object: # it's the first call, replace the method with a cached, bound method - bound_method = types.MethodType(method, self) + bound_method: CallableT = types.MethodType( # type: ignore[assignment] + method, self + ) cached_method = cache_wrapper(bound_method) setattr(self, method.__name__, cached_method) return cached_method(*args, **kwargs) # Support cache clear even before cache has been created. - wrapper.cache_clear = lambda: None + wrapper.cache_clear = lambda: None # type: ignore[attr-defined] - return wrapper + return wrapper # type: ignore[return-value] diff --git a/importlib_metadata/_itertools.py b/importlib_metadata/_itertools.py index dd45f2f0..847418df 100644 --- a/importlib_metadata/_itertools.py +++ b/importlib_metadata/_itertools.py @@ -1,11 +1,38 @@ from itertools import filterfalse +from typing import ( + Callable, + Hashable, + Iterable, + Iterator, + Optional, + Set, + TypeVar, + overload, +) +T = TypeVar('T') +HashableT = TypeVar('HashableT', bound=Hashable) -def unique_everseen(iterable, key=None): + +@overload +def unique_everseen( + iterable: Iterable[HashableT], key: None = ... +) -> Iterator[HashableT]: + ... # pragma: no cover + + +@overload +def unique_everseen(iterable: Iterable[T], key: Callable[[T], Hashable]) -> Iterator[T]: + ... # pragma: no cover + + +def unique_everseen( + iterable: Iterable[T], key: Optional[Callable[[T], Hashable]] = None +) -> Iterator[T]: "List unique elements, preserving order. Remember all elements ever seen." # unique_everseen('AAAABBBCCDAABBB') --> A B C D # unique_everseen('ABBCcAD', str.lower) --> A B C D - seen = set() + seen: Set[object] = set() seen_add = seen.add if key is None: for element in filterfalse(seen.__contains__, iterable): diff --git a/importlib_metadata/_meta.py b/importlib_metadata/_meta.py index e2eca895..7761d847 100644 --- a/importlib_metadata/_meta.py +++ b/importlib_metadata/_meta.py @@ -1,4 +1,5 @@ from ._compat import Protocol +from os import PathLike from typing import Any, Dict, Iterator, List, TypeVar, Union @@ -35,12 +36,13 @@ class SimplePath(Protocol): A minimal subset of pathlib.Path required by PathDistribution. """ - def joinpath(self) -> 'SimplePath': + def joinpath(self, *other: Union[str, 'PathLike[str]']) -> 'SimplePath': ... # pragma: no cover - def __truediv__(self) -> 'SimplePath': + def __truediv__(self, other: Union[str, 'PathLike[str]']) -> 'SimplePath': ... # pragma: no cover + @property def parent(self) -> 'SimplePath': ... # pragma: no cover diff --git a/importlib_metadata/_text.py b/importlib_metadata/_text.py index 3c97b45b..c6724a4e 100644 --- a/importlib_metadata/_text.py +++ b/importlib_metadata/_text.py @@ -1,6 +1,8 @@ import re +from ._compat import SupportsIndex from ._functools import method_cache +from typing import List, Optional # from jaraco.text 3.5 @@ -67,37 +69,42 @@ class FoldedCase(str): False """ - def __lt__(self, other): + def __lt__(self, other: str) -> bool: return self.lower() < other.lower() - def __gt__(self, other): + def __gt__(self, other: str) -> bool: return self.lower() > other.lower() - def __eq__(self, other): + def __eq__(self, other: object) -> bool: return isinstance(other, str) and self.lower() == other.lower() - def __ne__(self, other): + def __ne__(self, other: object) -> bool: return isinstance(other, str) and self.lower() != other.lower() - def __hash__(self): + def __hash__(self) -> int: return hash(self.lower()) - def __contains__(self, other): + def __contains__(self, other: object) -> bool: return isinstance(other, str) and super().lower().__contains__(other.lower()) - def in_(self, other): + def in_(self, other: str) -> bool: "Does self appear in other?" return self in FoldedCase(other) # cache lower since it's likely to be called frequently. @method_cache - def lower(self): + def lower(self) -> str: return super().lower() - def index(self, sub, start=None, end=None): + def index( + self, + sub: str, + start: Optional[SupportsIndex] = None, + end: Optional[SupportsIndex] = None, + ) -> int: return self.lower().index(sub.lower(), start, end) - def split(self, splitter=None, maxsplit=0): + def split(self, splitter: Optional[str] = None, maxsplit: int = 0) -> List[str]: if splitter is None: return super().split() pattern = re.compile(re.escape(splitter), re.I) diff --git a/prepare/example/example/__init__.py b/prepare/example/example/__init__.py index ba73b743..0ae69c2b 100644 --- a/prepare/example/example/__init__.py +++ b/prepare/example/example/__init__.py @@ -1,2 +1,2 @@ -def main(): +def main() -> str: return 'example' diff --git a/prepare/example2/example2/__init__.py b/prepare/example2/example2/__init__.py index de645c2e..45a56b04 100644 --- a/prepare/example2/example2/__init__.py +++ b/prepare/example2/example2/__init__.py @@ -1,2 +1,2 @@ -def main(): +def main() -> str: return "example" diff --git a/tests/fixtures.py b/tests/fixtures.py index b561e294..345d838c 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -5,22 +5,29 @@ import pathlib import tempfile import textwrap +import unittest import contextlib +from importlib.abc import MetaPathFinder from .py39compat import FS_NONASCII -from typing import Dict, Union +from typing import Iterator, Mapping, Optional, TypeVar, Union -try: +if sys.version_info >= (3, 9): from importlib import resources +else: + import importlib_resources as resources - getattr(resources, 'files') - getattr(resources, 'as_file') -except (ImportError, AttributeError): - import importlib_resources as resources # type: ignore + +T = TypeVar("T") + + +def not_none(value: Optional[T]) -> T: + assert value is not None + return value @contextlib.contextmanager -def tempdir(): +def tempdir() -> Iterator[pathlib.Path]: tmpdir = tempfile.mkdtemp() try: yield pathlib.Path(tmpdir) @@ -29,7 +36,7 @@ def tempdir(): @contextlib.contextmanager -def save_cwd(): +def save_cwd() -> Iterator[None]: orig = os.getcwd() try: yield @@ -38,7 +45,7 @@ def save_cwd(): @contextlib.contextmanager -def tempdir_as_cwd(): +def tempdir_as_cwd() -> Iterator[pathlib.Path]: with tempdir() as tmp: with save_cwd(): os.chdir(str(tmp)) @@ -46,7 +53,7 @@ def tempdir_as_cwd(): @contextlib.contextmanager -def install_finder(finder): +def install_finder(finder: MetaPathFinder) -> Iterator[None]: sys.meta_path.append(finder) try: yield @@ -54,36 +61,36 @@ def install_finder(finder): sys.meta_path.remove(finder) -class Fixtures: - def setUp(self): +class Fixtures(unittest.TestCase): + def setUp(self) -> None: self.fixtures = contextlib.ExitStack() self.addCleanup(self.fixtures.close) class SiteDir(Fixtures): - def setUp(self): + def setUp(self) -> None: super().setUp() self.site_dir = self.fixtures.enter_context(tempdir()) -class OnSysPath(Fixtures): +class OnSysPath(SiteDir): @staticmethod @contextlib.contextmanager - def add_sys_path(dir): + def add_sys_path(dir: pathlib.Path) -> Iterator[None]: sys.path[:0] = [str(dir)] try: yield finally: sys.path.remove(str(dir)) - def setUp(self): + def setUp(self) -> None: super().setUp() self.fixtures.enter_context(self.add_sys_path(self.site_dir)) # Except for python/mypy#731, prefer to define -# FilesDef = Dict[str, Union['FilesDef', str]] -FilesDef = Dict[str, Union[Dict[str, Union[Dict[str, str], str]], str]] +# FilesDef = Mapping[str, Union['FilesDef', str]] +FilesDef = Mapping[str, Union[Mapping[str, Union[Mapping[str, str], str]], str]] class DistInfoPkg(OnSysPath, SiteDir): @@ -113,17 +120,18 @@ def main(): """, } - def setUp(self): + def setUp(self) -> None: super().setUp() build_files(DistInfoPkg.files, self.site_dir) - def make_uppercase(self): + def make_uppercase(self) -> None: """ Rewrite metadata with everything uppercase. """ shutil.rmtree(self.site_dir / "distinfo_pkg-1.0.0.dist-info") files = copy.deepcopy(DistInfoPkg.files) info = files["distinfo_pkg-1.0.0.dist-info"] + assert isinstance(info, dict) and isinstance(info["METADATA"], str) info["METADATA"] = info["METADATA"].upper() build_files(files, self.site_dir) @@ -138,7 +146,7 @@ class DistInfoPkgWithDot(OnSysPath, SiteDir): }, } - def setUp(self): + def setUp(self) -> None: super().setUp() build_files(DistInfoPkgWithDot.files, self.site_dir) @@ -159,13 +167,13 @@ class DistInfoPkgWithDotLegacy(OnSysPath, SiteDir): }, } - def setUp(self): + def setUp(self) -> None: super().setUp() build_files(DistInfoPkgWithDotLegacy.files, self.site_dir) class DistInfoPkgOffPath(SiteDir): - def setUp(self): + def setUp(self) -> None: super().setUp() build_files(DistInfoPkg.files, self.site_dir) @@ -205,7 +213,7 @@ def main(): """, } - def setUp(self): + def setUp(self) -> None: super().setUp() build_files(EggInfoPkg.files, prefix=self.site_dir) @@ -226,12 +234,12 @@ class EggInfoFile(OnSysPath, SiteDir): """, } - def setUp(self): + def setUp(self) -> None: super().setUp() build_files(EggInfoFile.files, prefix=self.site_dir) -class LocalPackage: +class LocalPackage(unittest.TestCase): files: FilesDef = { "setup.py": """ import setuptools @@ -239,14 +247,14 @@ class LocalPackage: """, } - def setUp(self): + def setUp(self) -> None: self.fixtures = contextlib.ExitStack() self.addCleanup(self.fixtures.close) self.fixtures.enter_context(tempdir_as_cwd()) build_files(self.files) -def build_files(file_defs, prefix=pathlib.Path()): +def build_files(file_defs: FilesDef, prefix: pathlib.Path = pathlib.Path()) -> None: """Build a set of files/directories, as described by the file_defs dictionary. Each key/value pair in the dictionary is @@ -268,46 +276,45 @@ def build_files(file_defs, prefix=pathlib.Path()): """ for name, contents in file_defs.items(): full_name = prefix / name - if isinstance(contents, dict): + if isinstance(contents, bytes): + with full_name.open('wb') as f: + f.write(contents) + elif isinstance(contents, str): + with full_name.open('w', encoding='utf-8') as f: + f.write(DALS(contents)) + else: full_name.mkdir() build_files(contents, prefix=full_name) - else: - if isinstance(contents, bytes): - with full_name.open('wb') as f: - f.write(contents) - else: - with full_name.open('w', encoding='utf-8') as f: - f.write(DALS(contents)) -class FileBuilder: - def unicode_filename(self): +class FileBuilder(unittest.TestCase): + def unicode_filename(self) -> str: if not FS_NONASCII: self.skipTest("File system does not support non-ascii.") - return FS_NONASCII + return FS_NONASCII # type: ignore[no-any-return] -def DALS(str): +def DALS(str: str) -> str: "Dedent and left-strip" return textwrap.dedent(str).lstrip() class NullFinder: - def find_module(self, name): + def find_module(self, name: str) -> None: pass -class ZipFixtures: +class ZipFixtures(unittest.TestCase): root = 'tests.data' - def _fixture_on_path(self, filename): + def _fixture_on_path(self, filename: str) -> None: pkg_file = resources.files(self.root).joinpath(filename) file = self.resources.enter_context(resources.as_file(pkg_file)) assert file.name.startswith('example'), file.name sys.path.insert(0, str(file)) self.resources.callback(sys.path.pop, 0) - def setUp(self): + def setUp(self) -> None: # Add self.zip_name to the front of sys.path. self.resources = contextlib.ExitStack() self.addCleanup(self.resources.close) diff --git a/tests/py39compat.py b/tests/py39compat.py index 926dcad9..41a2e37b 100644 --- a/tests/py39compat.py +++ b/tests/py39compat.py @@ -1,4 +1,4 @@ try: - from test.support.os_helper import FS_NONASCII + from test.support.os_helper import FS_NONASCII as FS_NONASCII except ImportError: - from test.support import FS_NONASCII # noqa + from test.support import FS_NONASCII as FS_NONASCII # noqa diff --git a/tests/test_api.py b/tests/test_api.py index 75d4184d..c7fc4627 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,4 +1,5 @@ import re +import pathlib import textwrap import unittest import warnings @@ -6,9 +7,12 @@ import contextlib from . import fixtures +from .fixtures import not_none from importlib_metadata import ( Distribution, + EntryPoint, PackageNotFoundError, + PackagePath, distribution, entry_points, files, @@ -16,10 +20,11 @@ requires, version, ) +from typing import Dict, Iterator, List @contextlib.contextmanager -def suppress_known_deprecation(): +def suppress_known_deprecation() -> Iterator[List[warnings.WarningMessage]]: with warnings.catch_warnings(record=True) as ctx: warnings.simplefilter('default', category=DeprecationWarning) yield ctx @@ -35,45 +40,48 @@ class APITests( version_pattern = r'\d+\.\d+(\.\d)?' - def test_retrieves_version_of_self(self): + def test_retrieves_version_of_self(self) -> None: pkg_version = version('egginfo-pkg') assert isinstance(pkg_version, str) assert re.match(self.version_pattern, pkg_version) - def test_retrieves_version_of_distinfo_pkg(self): + def test_retrieves_version_of_distinfo_pkg(self) -> None: pkg_version = version('distinfo-pkg') assert isinstance(pkg_version, str) assert re.match(self.version_pattern, pkg_version) - def test_for_name_does_not_exist(self): + def test_for_name_does_not_exist(self) -> None: with self.assertRaises(PackageNotFoundError): distribution('does-not-exist') - def test_name_normalization(self): + def test_name_normalization(self) -> None: names = 'pkg.dot', 'pkg_dot', 'pkg-dot', 'pkg..dot', 'Pkg.Dot' for name in names: with self.subTest(name): assert distribution(name).metadata['Name'] == 'pkg.dot' - def test_prefix_not_matched(self): + def test_prefix_not_matched(self) -> None: prefixes = 'p', 'pkg', 'pkg.' for prefix in prefixes: with self.subTest(prefix): with self.assertRaises(PackageNotFoundError): distribution(prefix) - def test_for_top_level(self): + def test_for_top_level(self) -> None: self.assertEqual( - distribution('egginfo-pkg').read_text('top_level.txt').strip(), 'mod' + not_none(distribution('egginfo-pkg').read_text('top_level.txt')).strip(), + 'mod', ) - def test_read_text(self): + def test_read_text(self) -> None: top_level = [ - path for path in files('egginfo-pkg') if path.name == 'top_level.txt' + path + for path in not_none(files('egginfo-pkg')) + if path.name == 'top_level.txt' ][0] self.assertEqual(top_level.read_text(), 'mod\n') - def test_entry_points(self): + def test_entry_points(self) -> None: eps = entry_points() assert 'entries' in eps.groups entries = eps.select(group='entries') @@ -82,21 +90,21 @@ def test_entry_points(self): self.assertEqual(ep.value, 'mod:main') self.assertEqual(ep.extras, []) - def test_entry_points_distribution(self): + def test_entry_points_distribution(self) -> None: entries = entry_points(group='entries') for entry in ("main", "ns:sub"): ep = entries[entry] self.assertIn(ep.dist.name, ('distinfo-pkg', 'egginfo-pkg')) self.assertEqual(ep.dist.version, "1.0.0") - def test_entry_points_unique_packages(self): + def test_entry_points_unique_packages(self) -> None: """ Entry points should only be exposed for the first package on sys.path with a given name. """ alt_site_dir = self.fixtures.enter_context(fixtures.tempdir()) self.fixtures.enter_context(self.add_sys_path(alt_site_dir)) - alt_pkg = { + alt_pkg: fixtures.FilesDef = { "distinfo_pkg-1.1.0.dist-info": { "METADATA": """ Name: distinfo-pkg @@ -117,21 +125,23 @@ def test_entry_points_unique_packages(self): # ns:sub doesn't exist in alt_pkg assert 'ns:sub' not in entries.names - def test_entry_points_missing_name(self): + def test_entry_points_missing_name(self) -> None: with self.assertRaises(KeyError): entry_points(group='entries')['missing'] - def test_entry_points_missing_group(self): + def test_entry_points_missing_group(self) -> None: assert entry_points(group='missing') == () - def test_entry_points_dict_construction(self): + def test_entry_points_dict_construction(self) -> None: """ Prior versions of entry_points() returned simple lists and allowed casting those lists into maps by name using ``dict()``. Capture this now deprecated use-case. """ with suppress_known_deprecation() as caught: - eps = dict(entry_points(group='entries')) + eps: Dict[str, EntryPoint] = dict( + entry_points(group='entries') # type: ignore[arg-type] + ) assert 'main' in eps assert eps['main'] == entry_points(group='entries')['main'] @@ -141,7 +151,7 @@ def test_entry_points_dict_construction(self): assert expected.category is DeprecationWarning assert "Construction of dict of EntryPoints is deprecated" in str(expected) - def test_entry_points_by_index(self): + def test_entry_points_by_index(self) -> None: """ Prior versions of Distribution.entry_points would return a tuple that allowed access by index. @@ -157,7 +167,7 @@ def test_entry_points_by_index(self): assert expected.category is DeprecationWarning assert "Accessing entry points by index is deprecated" in str(expected) - def test_entry_points_groups_getitem(self): + def test_entry_points_groups_getitem(self) -> None: """ Prior versions of entry_points() returned a dict. Ensure that callers using '.__getitem__()' are supported but warned to @@ -169,7 +179,7 @@ def test_entry_points_groups_getitem(self): with self.assertRaises(KeyError): entry_points()['missing'] - def test_entry_points_groups_get(self): + def test_entry_points_groups_get(self) -> None: """ Prior versions of entry_points() returned a dict. Ensure that callers using '.get()' are supported but warned to @@ -180,7 +190,7 @@ def test_entry_points_groups_get(self): entry_points().get('entries', 'default') == entry_points()['entries'] entry_points().get('missing', ()) == () - def test_metadata_for_this_package(self): + def test_metadata_for_this_package(self) -> None: md = metadata('egginfo-pkg') assert md['author'] == 'Steven Ma' assert md['LICENSE'] == 'Unknown' @@ -188,53 +198,54 @@ def test_metadata_for_this_package(self): classifiers = md.get_all('Classifier') assert 'Topic :: Software Development :: Libraries' in classifiers - def test_importlib_metadata_version(self): + def test_importlib_metadata_version(self) -> None: resolved = version('importlib-metadata') assert re.match(self.version_pattern, resolved) @staticmethod - def _test_files(files): + def _test_files(files: List[PackagePath]) -> None: root = files[0].root for file in files: assert file.root == root assert not file.hash or file.hash.value assert not file.hash or file.hash.mode == 'sha256' assert not file.size or file.size >= 0 - assert file.locate().exists() + path = file.locate() + assert isinstance(path, pathlib.Path) and path.exists() assert isinstance(file.read_binary(), bytes) if file.name.endswith('.py'): file.read_text() - def test_file_hash_repr(self): - util = [p for p in files('distinfo-pkg') if p.name == 'mod.py'][0] + def test_file_hash_repr(self) -> None: + util = [p for p in not_none(files('distinfo-pkg')) if p.name == 'mod.py'][0] self.assertRegex(repr(util.hash), '') - def test_files_dist_info(self): - self._test_files(files('distinfo-pkg')) + def test_files_dist_info(self) -> None: + self._test_files(not_none(files('distinfo-pkg'))) - def test_files_egg_info(self): - self._test_files(files('egginfo-pkg')) + def test_files_egg_info(self) -> None: + self._test_files(not_none(files('egginfo-pkg'))) - def test_version_egg_info_file(self): + def test_version_egg_info_file(self) -> None: self.assertEqual(version('egginfo-file'), '0.1') - def test_requires_egg_info_file(self): + def test_requires_egg_info_file(self) -> None: requirements = requires('egginfo-file') self.assertIsNone(requirements) - def test_requires_egg_info(self): - deps = requires('egginfo-pkg') + def test_requires_egg_info(self) -> None: + deps = not_none(requires('egginfo-pkg')) assert len(deps) == 2 assert any(dep == 'wheel >= 1.0; python_version >= "2.7"' for dep in deps) - def test_requires_dist_info(self): - deps = requires('distinfo-pkg') + def test_requires_dist_info(self) -> None: + deps = not_none(requires('distinfo-pkg')) assert len(deps) == 2 assert all(deps) assert 'wheel >= 1.0' in deps assert "pytest; extra == 'test'" in deps - def test_more_complex_deps_requires_text(self): + def test_more_complex_deps_requires_text(self) -> None: requires = textwrap.dedent( """ dep1 @@ -264,23 +275,25 @@ def test_more_complex_deps_requires_text(self): assert deps == expected - def test_as_json(self): + def test_as_json(self) -> None: md = metadata('distinfo-pkg').json assert 'name' in md assert md['keywords'] == ['sample', 'package'] desc = md['description'] + assert isinstance(desc, str) assert desc.startswith('Once upon a time\nThere was') assert len(md['requires_dist']) == 2 - def test_as_json_egg_info(self): + def test_as_json_egg_info(self) -> None: md = metadata('egginfo-pkg').json assert 'name' in md assert md['keywords'] == ['sample', 'package'] desc = md['description'] + assert isinstance(desc, str) assert desc.startswith('Once upon a time\nThere was') assert len(md['classifier']) == 2 - def test_as_json_odd_case(self): + def test_as_json_odd_case(self) -> None: self.make_uppercase() md = metadata('distinfo-pkg').json assert 'name' in md @@ -289,13 +302,13 @@ def test_as_json_odd_case(self): class LegacyDots(fixtures.DistInfoPkgWithDotLegacy, unittest.TestCase): - def test_name_normalization(self): + def test_name_normalization(self) -> None: names = 'pkg.dot', 'pkg_dot', 'pkg-dot', 'pkg..dot', 'Pkg.Dot' for name in names: with self.subTest(name): assert distribution(name).metadata['Name'] == 'pkg.dot' - def test_name_normalization_versionless_egg_info(self): + def test_name_normalization_versionless_egg_info(self) -> None: names = 'pkg.lot', 'pkg_lot', 'pkg-lot', 'pkg..lot', 'Pkg.Lot' for name in names: with self.subTest(name): @@ -303,23 +316,23 @@ def test_name_normalization_versionless_egg_info(self): class OffSysPathTests(fixtures.DistInfoPkgOffPath, unittest.TestCase): - def test_find_distributions_specified_path(self): + def test_find_distributions_specified_path(self) -> None: dists = Distribution.discover(path=[str(self.site_dir)]) assert any(dist.metadata['Name'] == 'distinfo-pkg' for dist in dists) - def test_distribution_at_pathlib(self): + def test_distribution_at_pathlib(self) -> None: """Demonstrate how to load metadata direct from a directory.""" dist_info_path = self.site_dir / 'distinfo_pkg-1.0.0.dist-info' dist = Distribution.at(dist_info_path) assert dist.version == '1.0.0' - def test_distribution_at_str(self): + def test_distribution_at_str(self) -> None: dist_info_path = self.site_dir / 'distinfo_pkg-1.0.0.dist-info' dist = Distribution.at(str(dist_info_path)) assert dist.version == '1.0.0' class InvalidateCache(unittest.TestCase): - def test_invalidate_cache(self): + def test_invalidate_cache(self) -> None: # No externally observable behavior, but ensures test coverage... importlib.invalidate_caches() diff --git a/tests/test_integration.py b/tests/test_integration.py index 00e9021a..14d5b4c5 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -13,13 +13,13 @@ class IntegrationTests(fixtures.DistInfoPkg, unittest.TestCase): - def test_package_spec_installed(self): + def test_package_spec_installed(self) -> None: """ Illustrate the recommended procedure to determine if a specified version of a package is installed. """ - def is_installed(package_spec): + def is_installed(package_spec: str) -> bool: req = packaging.requirements.Requirement(package_spec) return version(req.name) in req.specifier @@ -29,30 +29,32 @@ def is_installed(package_spec): class FinderTests(fixtures.Fixtures, unittest.TestCase): - def test_finder_without_module(self): + def test_finder_without_module(self) -> None: class ModuleFreeFinder(fixtures.NullFinder): """ A finder without an __module__ attribute """ - def __getattribute__(self, name): + def __getattribute__(self, name: str) -> object: if name == '__module__': raise AttributeError(name) return super().__getattribute__(name) - self.fixtures.enter_context(fixtures.install_finder(ModuleFreeFinder())) + self.fixtures.enter_context( + fixtures.install_finder(ModuleFreeFinder()) # type: ignore[arg-type] + ) _compat.disable_stdlib_finder() class LocalProjectTests(fixtures.LocalPackage, unittest.TestCase): - def test_find_local(self): + def test_find_local(self) -> None: dist = Distribution._local() assert dist.metadata['Name'] == 'local-pkg' assert dist.version == '2.0.1' class DistSearch(unittest.TestCase): - def test_search_dist_dirs(self): + def test_search_dist_dirs(self) -> None: """ Pip needs the _search_paths interface to locate distribution metadata dirs. Protect it for PyPA @@ -61,7 +63,7 @@ def test_search_dist_dirs(self): res = MetadataPathFinder._search_paths('any-name', []) assert list(res) == [] - def test_interleaved_discovery(self): + def test_interleaved_discovery(self) -> None: """ When the search is cached, it is possible for searches to be interleaved, so make sure diff --git a/tests/test_main.py b/tests/test_main.py index 4744c020..f3348300 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,6 +1,7 @@ import re import json import pickle +import pathlib import textwrap import unittest import warnings @@ -25,16 +26,16 @@ class BasicTests(fixtures.DistInfoPkg, unittest.TestCase): version_pattern = r'\d+\.\d+(\.\d)?' - def test_retrieves_version_of_self(self): + def test_retrieves_version_of_self(self) -> None: dist = Distribution.from_name('distinfo-pkg') assert isinstance(dist.version, str) assert re.match(self.version_pattern, dist.version) - def test_for_name_does_not_exist(self): + def test_for_name_does_not_exist(self) -> None: with self.assertRaises(PackageNotFoundError): Distribution.from_name('does-not-exist') - def test_package_not_found_mentions_metadata(self): + def test_package_not_found_mentions_metadata(self) -> None: """ When a package is not found, that could indicate that the packgae is not installed or that it is installed without @@ -46,27 +47,27 @@ def test_package_not_found_mentions_metadata(self): assert "metadata" in str(ctx.exception) - def test_new_style_classes(self): + def test_new_style_classes(self) -> None: self.assertIsInstance(Distribution, type) self.assertIsInstance(MetadataPathFinder, type) class ImportTests(fixtures.DistInfoPkg, unittest.TestCase): - def test_import_nonexistent_module(self): + def test_import_nonexistent_module(self) -> None: # Ensure that the MetadataPathFinder does not crash an import of a # non-existent module. with self.assertRaises(ImportError): importlib.import_module('does_not_exist') - def test_resolve(self): + def test_resolve(self) -> None: ep = entry_points(group='entries')['main'] self.assertEqual(ep.load().__name__, "main") - def test_entrypoint_with_colon_in_name(self): + def test_entrypoint_with_colon_in_name(self) -> None: ep = entry_points(group='entries')['ns:sub'] self.assertEqual(ep.value, 'mod:main') - def test_resolve_without_attr(self): + def test_resolve_without_attr(self) -> None: ep = EntryPoint( name='ep', value='importlib_metadata', @@ -77,7 +78,7 @@ def test_resolve_without_attr(self): class NameNormalizationTests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase): @staticmethod - def pkg_with_dashes(site_dir): + def pkg_with_dashes(site_dir: pathlib.Path) -> str: """ Create minimal metadata for a package with dashes in the name (and thus underscores in the filename). @@ -89,7 +90,7 @@ def pkg_with_dashes(site_dir): strm.write('Version: 1.0\n') return 'my-pkg' - def test_dashes_in_dist_name_found_as_underscores(self): + def test_dashes_in_dist_name_found_as_underscores(self) -> None: """ For a package with a dash in the name, the dist-info metadata uses underscores in the name. Ensure the metadata loads. @@ -98,7 +99,7 @@ def test_dashes_in_dist_name_found_as_underscores(self): assert version(pkg_name) == '1.0' @staticmethod - def pkg_with_mixed_case(site_dir): + def pkg_with_mixed_case(site_dir: pathlib.Path) -> str: """ Create minimal metadata for a package with mixed case in the name. @@ -110,7 +111,7 @@ def pkg_with_mixed_case(site_dir): strm.write('Version: 1.0\n') return 'CherryPy' - def test_dist_name_found_as_any_case(self): + def test_dist_name_found_as_any_case(self) -> None: """ Ensure the metadata loads when queried with any case. """ @@ -122,7 +123,7 @@ def test_dist_name_found_as_any_case(self): class NonASCIITests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase): @staticmethod - def pkg_with_non_ascii_description(site_dir): + def pkg_with_non_ascii_description(site_dir: pathlib.Path) -> str: """ Create minimal metadata for a package with non-ASCII in the description. @@ -135,7 +136,7 @@ def pkg_with_non_ascii_description(site_dir): return 'portend' @staticmethod - def pkg_with_non_ascii_description_egg_info(site_dir): + def pkg_with_non_ascii_description_egg_info(site_dir: pathlib.Path) -> str: """ Create minimal metadata for an egg-info package with non-ASCII in the description. @@ -155,38 +156,38 @@ def pkg_with_non_ascii_description_egg_info(site_dir): ) return 'portend' - def test_metadata_loads(self): + def test_metadata_loads(self) -> None: pkg_name = self.pkg_with_non_ascii_description(self.site_dir) meta = metadata(pkg_name) assert meta['Description'] == 'pôrˈtend' - def test_metadata_loads_egg_info(self): + def test_metadata_loads_egg_info(self) -> None: pkg_name = self.pkg_with_non_ascii_description_egg_info(self.site_dir) meta = metadata(pkg_name) assert meta['Description'] == 'pôrˈtend' class DiscoveryTests(fixtures.EggInfoPkg, fixtures.DistInfoPkg, unittest.TestCase): - def test_package_discovery(self): + def test_package_discovery(self) -> None: dists = list(distributions()) assert all(isinstance(dist, Distribution) for dist in dists) assert any(dist.metadata['Name'] == 'egginfo-pkg' for dist in dists) assert any(dist.metadata['Name'] == 'distinfo-pkg' for dist in dists) - def test_invalid_usage(self): + def test_invalid_usage(self) -> None: with self.assertRaises(ValueError): list(distributions(context='something', name='else')) class DirectoryTest(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase): - def test_egg_info(self): + def test_egg_info(self) -> None: # make an `EGG-INFO` directory that's unrelated self.site_dir.joinpath('EGG-INFO').mkdir() # used to crash with `IsADirectoryError` with self.assertRaises(PackageNotFoundError): version('unknown-package') - def test_egg(self): + def test_egg(self) -> None: egg = self.site_dir.joinpath('foo-3.6.egg') egg.mkdir() with self.add_sys_path(egg): @@ -195,9 +196,9 @@ def test_egg(self): class MissingSysPath(fixtures.OnSysPath, unittest.TestCase): - site_dir = '/does-not-exist' + site_dir = pathlib.Path('/does-not-exist') - def test_discovery(self): + def test_discovery(self) -> None: """ Discovering distributions should succeed even if there is an invalid path on sys.path. @@ -205,15 +206,15 @@ def test_discovery(self): importlib_metadata.distributions() -class InaccessibleSysPath(fixtures.OnSysPath, ffs.TestCase): - site_dir = '/access-denied' +class InaccessibleSysPath(fixtures.OnSysPath, ffs.TestCase): # type: ignore[misc] + site_dir = pathlib.Path('/access-denied') - def setUp(self): + def setUp(self) -> None: super().setUp() self.setUpPyfakefs() self.fs.create_dir(self.site_dir, perm_bits=000) - def test_discovery(self): + def test_discovery(self) -> None: """ Discovering distributions should succeed even if there is an invalid path on sys.path. @@ -222,28 +223,28 @@ def test_discovery(self): class TestEntryPoints(unittest.TestCase): - def setUp(self): + def setUp(self) -> None: self.ep = importlib_metadata.EntryPoint('name', 'value', 'group') - def test_entry_point_pickleable(self): + def test_entry_point_pickleable(self) -> None: revived = pickle.loads(pickle.dumps(self.ep)) assert revived == self.ep - def test_immutable(self): + def test_immutable(self) -> None: """EntryPoints should be immutable""" with self.assertRaises(AttributeError): - self.ep.name = 'badactor' + self.ep.name = 'badactor' # type: ignore[misc] - def test_repr(self): + def test_repr(self) -> None: assert 'EntryPoint' in repr(self.ep) assert 'name=' in repr(self.ep) assert "'name'" in repr(self.ep) - def test_hashable(self): + def test_hashable(self) -> None: """EntryPoints should be hashable""" hash(self.ep) - def test_json_dump(self): + def test_json_dump(self) -> None: """ json should not expect to be able to dump an EntryPoint """ @@ -251,13 +252,13 @@ def test_json_dump(self): with warnings.catch_warnings(record=True): json.dumps(self.ep) - def test_module(self): + def test_module(self) -> None: assert self.ep.module == 'value' - def test_attr(self): + def test_attr(self) -> None: assert self.ep.attr is None - def test_sortable(self): + def test_sortable(self) -> None: """ EntryPoint objects are sortable, but result is undefined. """ @@ -272,7 +273,7 @@ def test_sortable(self): class FileSystem( fixtures.OnSysPath, fixtures.SiteDir, fixtures.FileBuilder, unittest.TestCase ): - def test_unicode_dir_on_sys_path(self): + def test_unicode_dir_on_sys_path(self) -> None: """ Ensure a Unicode subdirectory of a directory on sys.path does not crash. @@ -285,11 +286,11 @@ def test_unicode_dir_on_sys_path(self): class PackagesDistributionsPrebuiltTest(fixtures.ZipFixtures, unittest.TestCase): - def test_packages_distributions_example(self): + def test_packages_distributions_example(self) -> None: self._fixture_on_path('example-21.12-py3-none-any.whl') assert packages_distributions()['example'] == ['example'] - def test_packages_distributions_example2(self): + def test_packages_distributions_example2(self) -> None: """ Test packages_distributions on a wheel built by trampolim. @@ -301,7 +302,7 @@ def test_packages_distributions_example2(self): class PackagesDistributionsTest( fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase ): - def test_packages_distributions_neither_toplevel_nor_files(self): + def test_packages_distributions_neither_toplevel_nor_files(self) -> None: """ Test a package built without 'top-level.txt' or a file list. """ diff --git a/tests/test_zip.py b/tests/test_zip.py index 01aba6df..bb27b67a 100644 --- a/tests/test_zip.py +++ b/tests/test_zip.py @@ -2,6 +2,7 @@ import unittest from . import fixtures +from .fixtures import not_none from importlib_metadata import ( PackageNotFoundError, distribution, @@ -13,50 +14,50 @@ class TestZip(fixtures.ZipFixtures, unittest.TestCase): - def setUp(self): + def setUp(self) -> None: super().setUp() self._fixture_on_path('example-21.12-py3-none-any.whl') - def test_zip_version(self): + def test_zip_version(self) -> None: self.assertEqual(version('example'), '21.12') - def test_zip_version_does_not_match(self): + def test_zip_version_does_not_match(self) -> None: with self.assertRaises(PackageNotFoundError): version('definitely-not-installed') - def test_zip_entry_points(self): + def test_zip_entry_points(self) -> None: scripts = entry_points(group='console_scripts') entry_point = scripts['example'] self.assertEqual(entry_point.value, 'example:main') entry_point = scripts['Example'] self.assertEqual(entry_point.value, 'example:main') - def test_missing_metadata(self): + def test_missing_metadata(self) -> None: self.assertIsNone(distribution('example').read_text('does not exist')) - def test_case_insensitive(self): + def test_case_insensitive(self) -> None: self.assertEqual(version('Example'), '21.12') - def test_files(self): - for file in files('example'): + def test_files(self) -> None: + for file in not_none(files('example')): path = str(file.dist.locate_file(file)) assert '.whl/' in path, path - def test_one_distribution(self): + def test_one_distribution(self) -> None: dists = list(distributions(path=sys.path[:1])) assert len(dists) == 1 class TestEgg(TestZip): - def setUp(self): + def setUp(self) -> None: super().setUp() self._fixture_on_path('example-21.12-py3.6.egg') - def test_files(self): - for file in files('example'): + def test_files(self) -> None: + for file in not_none(files('example')): path = str(file.dist.locate_file(file)) assert '.egg/' in path, path - def test_normalized_name(self): + def test_normalized_name(self) -> None: dist = distribution('example') assert dist._normalized_name == 'example' From 324fd33564ad70672a43a7afcc2c27e7662ac214 Mon Sep 17 00:00:00 2001 From: Anders Kaseorg Date: Wed, 25 Aug 2021 17:07:51 -0700 Subject: [PATCH 14/14] mypy: Enable strict mode Signed-off-by: Anders Kaseorg --- mypy.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mypy.ini b/mypy.ini index 976ba029..ae04de1b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,2 +1,4 @@ [mypy] ignore_missing_imports = True +strict = True +disallow_untyped_calls = False pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy