diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 083d332..45844ee 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,9 +19,7 @@ jobs: fail-fast: false matrix: python-version: - - "pypy3.9" - "pypy3.10" - - "3.9" - "3.10" - "3.11" - "3.12" @@ -40,11 +38,20 @@ jobs: cache-dependency-path: | requirements-docs.txt requirements-tests.txt + requirements-typecheck.txt pyproject.toml - name: Install dependencies run: pip install -r requirements.txt + # mypy is optional and refuses to run on PyPy. Installing it here + # is what makes tests/functional/test_typecheck.py run instead of + # skip, so the type checks are exercised once per CPython version + # rather than against a single interpreter. + - name: Install type checker + if: "!startsWith(matrix.python-version, 'pypy')" + run: pip install -r requirements-typecheck.txt + - name: Run linting checks run: scripts/lint.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1abfedd..6e839d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,7 +52,25 @@ You can run the format script to make your change compliant: + ruff check mode tests --fix ``` -_The script uses [ruff](https://github.com/astral-sh/ruff) & [mypy](https://mypy-lang.org/)._ +_The script uses [ruff](https://github.com/astral-sh/ruff)._ + +### Type check the code + +[mypy](https://mypy-lang.org/) is an optional dependency, so it is not +installed by `requirements.txt`: + +```sh +(venv) $ pip install -r requirements-typecheck.txt +(venv) $ ./scripts/typecheck.sh ++ mypy -p mode +Success: no issues found in 45 source files +``` + +The suite also runs it, as `tests/functional/test_typecheck.py`. That test +skips when mypy is not installed, so you only need it if you want the type +checks locally -- CI installs it on the CPython legs of the matrix, and the +check runs there once per Python version. It has to be CPython: mypy exits +with an error under PyPy. ### Run tests diff --git a/mode/__init__.py b/mode/__init__.py index 9ef99bd..eaca124 100644 --- a/mode/__init__.py +++ b/mode/__init__.py @@ -125,7 +125,7 @@ def __dir__(self) -> Sequence[str]: new_module.__dict__.update( { "__file__": __file__, - "__path__": __path__, # type: ignore + "__path__": __path__, "__doc__": __doc__, "__all__": tuple(object_origins), "__version__": __version__, diff --git a/mode/debug.py b/mode/debug.py index c18e1fb..5991fb2 100644 --- a/mode/debug.py +++ b/mode/debug.py @@ -4,7 +4,7 @@ import signal import traceback from types import FrameType -from typing import Any +from typing import Any, Optional from .services import Service from .utils.logging import get_logger @@ -85,7 +85,7 @@ def _clear_signal(self) -> None: def _arm(self, timeout: float) -> None: arm_alarm(timeout) - def _on_alarm(self, signum: int, frame: FrameType) -> None: + def _on_alarm(self, signum: int, frame: Optional[FrameType]) -> None: msg = f"Blocking detected (timeout={self.timeout})" stack = "".join(traceback.format_stack(frame)) self.log.warning( diff --git a/mode/locals.py b/mode/locals.py index fce04c8..973d24c 100644 --- a/mode/locals.py +++ b/mode/locals.py @@ -81,8 +81,10 @@ class XProxy(MutableMappingRole, AsyncContextManagerRole): Awaitable, Coroutine, Generator, + ItemsView, Iterable, Iterator, + KeysView, Mapping, MutableMapping, MutableSequence, @@ -95,6 +97,7 @@ class XProxy(MutableMappingRole, AsyncContextManagerRole): from functools import wraps from types import GetSetDescriptorType, TracebackType from typing import ( + TYPE_CHECKING, Any, Callable, ClassVar, @@ -109,6 +112,9 @@ class XProxy(MutableMappingRole, AsyncContextManagerRole): from .utils.locals import LocalStack # XXX compat +if TYPE_CHECKING: + from _typeshed import SupportsKeysAndGetItem + __all__ = [ "AsyncContextManagerProxy", "AsyncContextManagerRole", @@ -171,7 +177,7 @@ def __new__(cls: type, getter: Callable) -> Any: instance.__getter = getter # type: ignore return instance - def __get__(self: type, obj: Any, cls: Optional[type] = None) -> Any: + def __get__(self: Any, obj: Any, cls: Optional[type] = None) -> Any: return self.__getter(obj) if obj is not None else self return type(name, (type_,), {"__new__": __new__, "__get__": __get__}) @@ -282,7 +288,7 @@ def __class__(self) -> Any: return self._get_class() @__class__.setter - def __class__(self, t: type[T]) -> None: + def __class__(self, t: type) -> None: raise NotImplementedError() def _get_current_object(self) -> T: @@ -423,11 +429,15 @@ def send(self, value: T_contra) -> T_co: def throw( self, - typ: type[BaseException], - val: Optional[BaseException] = None, + typ: Union[type[BaseException], BaseException], + val: object = None, tb: Optional[TracebackType] = None, ) -> T_co: - return self._get_coroutine().throw(typ, val, tb) + # `Coroutine.throw` is overloaded (exception type plus optional + # value, or a bare exception instance), and a proxy that forwards + # whatever it is given matches neither overload exactly. + throw = cast(Callable[..., T_co], self._get_coroutine().throw) + return throw(typ, val, tb) def close(self) -> None: return self._get_coroutine().close() @@ -487,17 +497,25 @@ def asend(self, value: T_contra) -> Coroutine[Any, Any, T_co]: def athrow( self, - typ: type[BaseException], - val: Optional[BaseException] = None, + typ: Union[type[BaseException], BaseException], + val: object = None, tb: Optional[TracebackType] = None, ) -> Coroutine[Any, Any, T_co]: - return self._get_generator().athrow(typ, val, tb) + # See `CoroutineRole.throw`: `AsyncGenerator.athrow` is overloaded + # the same way. + athrow = cast( + Callable[..., Coroutine[Any, Any, T_co]], + self._get_generator().athrow, + ) + return athrow(typ, val, tb) def aclose(self) -> Coroutine[Any, Any, None]: return self._get_generator().aclose() def __aiter__(self) -> AsyncGenerator[T_co, T_contra]: - return self._get_generator().__aiter__() + return cast( + AsyncGenerator[T_co, T_contra], self._get_generator().__aiter__() + ) class AsyncGeneratorProxy( @@ -588,13 +606,11 @@ def pop(self, *args: Any) -> Any: def remove(self, object: T) -> None: self._get_sequence().remove(object) - def __iadd__(self, x: Iterable[T]) -> MutableSequence[T]: - return self._get_sequence().__iadd__(x) + def __iadd__(self, x: Iterable[T]) -> "MutableSequenceRole[T]": + return cast("MutableSequenceRole[T]", self._get_sequence().__iadd__(x)) -class MutableSequenceProxy( - Proxy[MutableSequence[T_co]], MutableSequenceRole[T_co] -): +class MutableSequenceProxy(Proxy[MutableSequence[T]], MutableSequenceRole[T]): """Proxy to `typing.MutableSequence` object.""" @@ -668,20 +684,22 @@ def pop(self) -> T: def remove(self, element: T) -> None: self._get_set().remove(element) - def __ior__(self, s: Set[S]) -> MutableSet[Union[T, S]]: - return self._get_set().__ior__(s) + def __ior__(self, s: Set[S]) -> "MutableSetRole[Union[T, S]]": + data = cast(MutableSet[Union[T, S]], self._get_set()) + return cast("MutableSetRole[Union[T, S]]", data.__ior__(s)) - def __iand__(self, s: Set[Any]) -> MutableSet[T]: - return self._get_set().__iand__(s) + def __iand__(self, s: Set[Any]) -> "MutableSetRole[T]": + return cast("MutableSetRole[T]", self._get_set().__iand__(s)) - def __ixor__(self, s: Set[S]) -> MutableSet[Union[T, S]]: - return self._get_set().__ixor__(s) + def __ixor__(self, s: Set[S]) -> "MutableSetRole[Union[T, S]]": + data = cast(MutableSet[Union[T, S]], self._get_set()) + return cast("MutableSetRole[Union[T, S]]", data.__ixor__(s)) - def __isub__(self, s: Set[Any]) -> MutableSet[T]: - return self._get_set().__isub__(s) + def __isub__(self, s: Set[Any]) -> "MutableSetRole[T]": + return cast("MutableSetRole[T]", self._get_set().__isub__(s)) -class MutableSetProxy(Proxy[MutableSet[T_co]], MutableSetRole[T_co]): +class MutableSetProxy(Proxy[MutableSet[T]], MutableSetRole[T]): """Proxy to `typing.MutableSet` object.""" @@ -710,7 +728,7 @@ class AsyncContextManagerRole(AbstractAsyncContextManager[T_co]): def __aenter__(self) -> Coroutine[Any, Any, T_co]: obj = self._get_current_object() # type: ignore - return obj.__aenter__() + return cast(Coroutine[Any, Any, T_co], obj.__aenter__()) def __aexit__( self, @@ -748,10 +766,10 @@ def get(self, k: KT, default: Union[VT_co, T]) -> Union[VT_co, T]: ... def get(self, *args: Any, **kwargs: Any) -> Any: return self._get_mapping().get(*args, **kwargs) - def items(self) -> Set[tuple[KT, VT_co]]: + def items(self) -> ItemsView[KT, VT_co]: return self._get_mapping().items() - def keys(self) -> Set[KT]: + def keys(self) -> KeysView[KT]: return self._get_mapping().keys() def values(self) -> ValuesView[VT_co]: @@ -802,11 +820,14 @@ def popitem(self) -> tuple[KT, VT]: def setdefault(self, k: KT, *args: Any) -> VT: return self._get_mapping().setdefault(k, *args) + # Mirrors `MutableMapping.update` in typeshed, minus the overloads + # whose `self:` annotation restricts `**kwargs` to str-keyed mappings + # -- an overload implementation cannot satisfy those. @overload - def update(self, __m: Mapping[KT, VT], **kwargs: VT) -> None: ... + def update(self, m: "SupportsKeysAndGetItem[KT, VT]", /) -> None: ... @overload - def update(self, __m: Iterable[tuple[KT, VT]], **kwargs: VT) -> None: ... + def update(self, m: Iterable[tuple[KT, VT]], /) -> None: ... @overload def update(self, **kwargs: VT) -> None: ... diff --git a/mode/loop/_gevent_loop.py b/mode/loop/_gevent_loop.py index 30bf5b2..ad47d3d 100644 --- a/mode/loop/_gevent_loop.py +++ b/mode/loop/_gevent_loop.py @@ -1,6 +1,7 @@ """Gevent loop customizations.""" -from typing import Any +import asyncio +from typing import Any, Optional import gevent.core @@ -10,7 +11,7 @@ class Loop(gevent.core.loop): # type: ignore """Gevent core event loop modifed to support `asyncio`.""" - _aioloop_loop = None + _aioloop_loop: Optional[asyncio.AbstractEventLoop] = None def run_callback(self, *args: Any, **kwargs: Any) -> None: if self._aioloop_loop is None: diff --git a/mode/services.py b/mode/services.py index e09376a..6adf723 100644 --- a/mode/services.py +++ b/mode/services.py @@ -142,7 +142,7 @@ def loop(self) -> asyncio.AbstractEventLoop: return self._loop @loop.setter - def loop(self, loop: asyncio.AbstractEventLoop) -> None: + def loop(self, loop: Optional[asyncio.AbstractEventLoop]) -> None: self._loop = loop @@ -835,28 +835,36 @@ async def _default_start(self) -> None: async def _actually_start(self) -> None: # noqa: C901 """Start the service.""" + # NOTE: ``should_stop`` is re-read into a local before every check: + # the flag flips while the awaits below are suspended, and mypy + # otherwise folds repeated reads of the property into the result of + # the first one and reports the later checks as dead code. for _ in [1]: # to use break if not self.restart_count: for dep in self.on_init_dependencies(): self.add_dependency(dep) await self.on_first_start() - if self.should_stop: + should_stop = self.should_stop + if should_stop: break self.exit_stack.__enter__() await self.async_exit_stack.__aenter__() - if self.should_stop: + should_stop = self.should_stop + if should_stop: break try: self._log_mundane("Starting...") await self.on_start() - if self.should_stop: + should_stop = self.should_stop + if should_stop: break for task in self._get_tasks(): self.add_future(task.fun(self)) for child in self._children: if child is not None: await child.maybe_start() - if self.should_stop: + should_stop = self.should_stop + if should_stop: break self.log.debug("Started.") await self.on_started() @@ -1079,7 +1087,12 @@ async def itertimer( ``` """ sleepfun = sleep or self.sleep - if self.should_stop: + # NOTE: ``should_stop`` is re-read into a local before every check: + # the flag flips while this generator is suspended, and mypy + # otherwise folds repeated reads of the property into the result of + # the first one and reports the later checks as dead code. + should_stop = self.should_stop + if should_stop: return try: async for sleep_time in Timer( @@ -1089,10 +1102,12 @@ async def itertimer( clock=clock, sleep=sleepfun, ): - if self.should_stop: + should_stop = self.should_stop + if should_stop: break yield sleep_time - if self.should_stop: + should_stop = self.should_stop + if should_stop: break finally: # this is required to collect the async_generator_athrow() diff --git a/mode/signals.py b/mode/signals.py index 96712ea..dad01d6 100644 --- a/mode/signals.py +++ b/mode/signals.py @@ -173,13 +173,17 @@ def _is_alive( if isinstance(ref, ReferenceType): value = ref() return value is not None, value - return True, ref() + # Receivers connected with ``weak=False`` are stored as a + # zero-argument callable returning the handler (see ``_connect``), + # and are always alive. + deref = cast(Callable[[], SignalHandlerT], ref) + return True, deref() def _create_ref(self, fun: SignalHandlerT) -> SignalHandlerRefT: if hasattr(fun, "__func__") and hasattr(fun, "__self__"): return cast(SignalHandlerRefT, WeakMethod(cast(MethodType, fun))) else: - return ref(fun) + return cast(SignalHandlerRefT, ref(fun)) def _create_id(self, sender: Any) -> int: try: diff --git a/mode/types/services.py b/mode/types/services.py index f5fb964..0e64c05 100644 --- a/mode/types/services.py +++ b/mode/types/services.py @@ -135,6 +135,7 @@ def label(self) -> str: ... def shortlabel(self) -> str: ... @property + @abc.abstractmethod def beacon(self) -> NodeT: ... @beacon.setter diff --git a/mode/utils/aiter.py b/mode/utils/aiter.py index c2c5c9b..efcdd9e 100644 --- a/mode/utils/aiter.py +++ b/mode/utils/aiter.py @@ -1,4 +1,18 @@ -"""Async iterator lost and found missing methods: aiter, anext, etc.""" +"""Async iterator utilities: `aiter`, `anext`, etc. + +Python gained `aiter()` and `anext()` as builtins in 3.10, long after this +module was written. The versions here are kept because they are not the +same functions: + +- `aiter` also accepts a *synchronous* iterable, wrapping it so that it + can be driven with `async for`. The builtin raises `TypeError` for + anything that does not implement `__aiter__`. +- `anext` takes its default as `*default` rather than as a single + positional argument. + +Both names shadow the builtins for the rest of this module, so `aiter` +below always means the dispatcher defined here. +""" import collections.abc import sys @@ -50,7 +64,20 @@ def __repr__(self) -> str: @singledispatch def aiter(it: Any) -> AsyncIterator[object]: - """Create iterator from iterable. + """Create an async iterator from an async *or* synchronous iterable. + + Unlike the `aiter` builtin added in Python 3.10, a synchronous + iterable is accepted as well: it is wrapped in `AsyncIterWrapper` so + that it can be consumed with `async for`. + + ```sh + >>> [x async for x in aiter([1, 2, 3])] + [1, 2, 3] + ``` + + Raises: + TypeError: if the argument is neither an `AsyncIterable` nor an + `Iterable`. Notes: If the object is already an iterator, the iterator @@ -74,6 +101,10 @@ def _aiter_iter(it: Iterable[T]) -> AsyncIterator[T]: async def anext(it: AsyncIterator[T], *default: Optional[T]) -> T: """Get next value from async iterator, or `default` if empty. + Differs from the `anext` builtin added in Python 3.10: the default is + taken as `*default`, so passing no default and passing one are both + handled by this single signature. + Raises: :exc:`StopAsyncIteration`: if default is not defined and the async iterator is fully consumed. @@ -108,9 +139,11 @@ def __init__( self, *slice_args: Optional[int], **slice_kwargs: Optional[int] ) -> None: s = slice(*slice_args, **slice_kwargs) - self.start = s.start or 0 - self.stop = s.stop - self.step = s.step or 1 + if s.stop is None: + raise TypeError("arange() requires a stop argument") + self.start: int = s.start or 0 + self.stop: int = s.stop + self.step: int = s.step or 1 self._range = range(self.start, self.stop, self.step) def count(self, n: int) -> int: @@ -165,6 +198,8 @@ async def chunks(it: AsyncIterable[T], n: int) -> AsyncIterable[list[T]]: [[0, 1, 2], [3, 4, 5], [6, 7, 8], [9, 10]] ``` """ - ait = aiter(it) + # `aiter` is a singledispatch function, so its return type cannot be + # tied to the type of its argument. + ait = cast(AsyncIterator[T], aiter(it)) async for item in ait: yield [item] + [x async for x in aslice(ait, n - 1)] diff --git a/mode/utils/collections.py b/mode/utils/collections.py index 43174b6..5c5aaf4 100644 --- a/mode/utils/collections.py +++ b/mode/utils/collections.py @@ -65,23 +65,39 @@ class LazySettings: ... "force_mapping", ] +if typing.TYPE_CHECKING: + from _typeshed import SupportsRichComparison +else: + SupportsRichComparison = Any + T = TypeVar("T") T_co = TypeVar("T_co", covariant=True) KT = TypeVar("KT") VT = TypeVar("VT") _S = TypeVar("_S") +#: `heapq` orders elements by comparing them, so a heap can only hold +#: values that support `<`/`>` (`_typeshed.SupportsRichComparison`). +_ComparableT = TypeVar("_ComparableT", bound="SupportsRichComparison") _Setlike = Union[Set[T], Iterable[T]] -class Heap(MutableSequence[T]): - """Generic interface to `heapq`.""" +class Heap(MutableSequence[_ComparableT]): + """Generic interface to `heapq`. + + Elements have to be orderable: `heapq` maintains the heap invariant by + comparing them, so the element type is bound to + `_typeshed.SupportsRichComparison` -- anything defining `__lt__` or + `__gt__`. `Heap[int]` and `Heap[str]` are fine, while an element type + with no ordering is rejected by the type checker instead of failing at + the first `push`. + """ - def __init__(self, data: Optional[Sequence[T]] = None) -> None: + def __init__(self, data: Optional[Sequence[_ComparableT]] = None) -> None: self.data = list(data or []) heapify(self.data) - def pop(self, index: int = 0) -> T: + def pop(self, index: int = 0) -> _ComparableT: """Pop the smallest item off the heap. Maintains the heap invariant. @@ -93,11 +109,11 @@ def pop(self, index: int = 0) -> T: "Heap can only pop index 0, please use h.data.pop(index)" ) - def push(self, item: T) -> None: + def push(self, item: _ComparableT) -> None: """Push item onto heap, maintaining the heap invariant.""" heappush(self.data, item) - def pushpop(self, item: T) -> T: + def pushpop(self, item: _ComparableT) -> _ComparableT: """Push item on the heap, then pop and return from the heap. The combined action runs more efficiently than @@ -105,7 +121,7 @@ def pushpop(self, item: T) -> T: """ return heappushpop(self.data, item) - def replace(self, item: T) -> T: + def replace(self, item: _ComparableT) -> _ComparableT: """Pop and return the current smallest value, and add the new item. This is more efficient than :meth`pop` followed by `push`, @@ -122,21 +138,25 @@ def replace(self, item: T) -> T: """ return heapreplace(self.data, item) - def nlargest(self, n: int, key: Optional[Callable] = None) -> list[T]: + def nlargest( + self, n: int, key: Optional[Callable] = None + ) -> list[_ComparableT]: """Find the n largest elements in the dataset.""" if key is not None: return nlargest(n, self.data, key=key) else: return nlargest(n, self.data) - def nsmallest(self, n: int, key: Optional[Callable] = None) -> list[T]: + def nsmallest( + self, n: int, key: Optional[Callable] = None + ) -> list[_ComparableT]: """Find the n smallest elements in the dataset.""" if key is not None: return nsmallest(n, self.data, key=key) else: return nsmallest(n, self.data) - def insert(self, index: int, value: T) -> None: + def insert(self, index: int, value: _ComparableT) -> None: self.data.insert(index, value) def __str__(self) -> str: @@ -146,19 +166,19 @@ def __repr__(self) -> str: return repr(self.data) @overload - def __getitem__(self, s: int) -> T: ... + def __getitem__(self, s: int) -> _ComparableT: ... @overload - def __getitem__(self, s: slice) -> MutableSequence[T]: ... + def __getitem__(self, s: slice) -> MutableSequence[_ComparableT]: ... def __getitem__(self, s: Any) -> Any: return self.data.__getitem__(s) @overload - def __setitem__(self, s: int, o: T) -> None: ... + def __setitem__(self, s: int, o: _ComparableT) -> None: ... @overload - def __setitem__(self, s: slice, o: Iterable[T]) -> None: ... + def __setitem__(self, s: slice, o: Iterable[_ComparableT]) -> None: ... def __setitem__(self, s: Any, o: Any) -> None: self.data.__setitem__(s, o) @@ -264,7 +284,7 @@ def __le__(self, other: Set[T]) -> bool: def __len__(self) -> int: return len(self.data) - def __or__(self, other: Set) -> Set[Union[T, object]]: + def __or__(self, other: Set[_S]) -> Set[Union[T, _S]]: return self.data.__or__(other) def __rand__(self, other: Set[T]) -> MutableSet[T]: @@ -294,8 +314,8 @@ def __sizeof__(self) -> int: def __str__(self) -> str: return str(self.data) - def __sub__(self, other: Set[Any]) -> MutableSet[object]: - return cast(MutableSet, self.data.__sub__(other)) + def __sub__(self, other: Set[Any]) -> MutableSet[T]: + return cast(MutableSet[T], self.data.__sub__(other)) def __xor__(self, other: Set) -> MutableSet[T]: return cast(MutableSet, self.data.__xor__(other)) @@ -331,7 +351,7 @@ def __iand__(self, other: Set[Any]) -> "FastUserSet": return self def __ior__(self, other: Set[_S]) -> "FastUserSet": - self.data.__ior__(other) + cast(MutableSet[Union[T, _S]], self.data).__ior__(other) return self def __isub__(self, other: Set[Any]) -> "FastUserSet[T]": @@ -339,7 +359,7 @@ def __isub__(self, other: Set[Any]) -> "FastUserSet[T]": return self def __ixor__(self, other: Set[_S]) -> "FastUserSet": - self.data.__ixor__(other) + cast(MutableSet[Union[T, _S]], self.data).__ixor__(other) return self def add(self, value: T) -> None: @@ -548,29 +568,29 @@ def pop(self) -> T: def raw_update(self, *args: Any, **kwargs: Any) -> None: self.data.update(*args, **kwargs) # type: ignore - def __iand__(self, other: Set[Any]) -> "FastUserSet": - self.on_change(added=set(), removed=cast(Set, self).difference(other)) + def __iand__(self, other: Set[Any]) -> "ManagedUserSet[T]": + self.on_change(added=set(), removed=cast(set, self).difference(other)) self.data.__iand__(other) return self - def __ior__(self, other: Set[_S]) -> "FastUserSet": + def __ior__(self, other: Set[_S]) -> "ManagedUserSet[T]": self.on_change(added=cast(set, other).difference(self), removed=set()) - self.data.__ior__(other) + cast(MutableSet[Union[T, _S]], self.data).__ior__(other) return self - def __isub__(self, other: Set[Any]) -> "FastUserSet": + def __isub__(self, other: Set[Any]) -> "ManagedUserSet[T]": self.on_change( added=set(), removed=cast(set, self.data).intersection(other) ) self.data.__isub__(other) return self - def __ixor__(self, other: Set[_S]) -> "FastUserSet": + def __ixor__(self, other: Set[_S]) -> "ManagedUserSet[T]": self.on_change( added=cast(set, other).difference(self.data), removed=cast(set, self.data).intersection(other), ) - self.data.__ixor__(other) + cast(MutableSet[Union[T, _S]], self.data).__ixor__(other) return self def difference_update(self, other: _Setlike[T]) -> None: @@ -633,7 +653,7 @@ def update(self, *args: Any, **kwargs: Any) -> None: for key, value in d.items(): self.on_key_set(key, value) for key, value in kwargs.items(): - self.on_key_set(key, value) + self.on_key_set(cast(KT, key), value) self.data.update(*args, **kwargs) def clear(self) -> None: diff --git a/mode/utils/futures.py b/mode/utils/futures.py index 70978b6..aee6898 100644 --- a/mode/utils/futures.py +++ b/mode/utils/futures.py @@ -1,6 +1,7 @@ """Async I/O Future utilities.""" import asyncio +from asyncio import all_tasks, current_task from inspect import isawaitable from typing import Any, Callable, NoReturn, Optional, Union @@ -9,19 +10,6 @@ # These used to be here, now moved to .queues from .queues import FlowControlEvent, FlowControlQueue # noqa: F401 -try: # pragma: no cover - from asyncio import all_tasks # type: ignore -except ImportError: # pragma: no cover - - def all_tasks(loop: asyncio.AbstractEventLoop) -> set[asyncio.Task]: - return asyncio.Task.all_tasks(loop=loop) - - -try: # pragma: no cover - from asyncio import current_task # type: ignore -except ImportError: # pragma: no cover - current_task = asyncio.Task.current_task - __all__ = [ "all_tasks", "current_task", diff --git a/mode/utils/graphs/graph.py b/mode/utils/graphs/graph.py index 5e787d3..31f49c1 100644 --- a/mode/utils/graphs/graph.py +++ b/mode/utils/graphs/graph.py @@ -9,7 +9,7 @@ Sequence, ) from functools import partial -from typing import IO, Any, Callable, Optional, cast +from typing import IO, Any, Callable, Optional from mode.utils.types.graphs import _T, DependencyGraphT, GraphFormatterT @@ -205,7 +205,7 @@ def __contains__(self, obj: object) -> bool: return obj in self.adjacent def items(self) -> ItemsView: - return cast(ItemsView, self.adjacent.items()) + return self.adjacent.items() def __repr__(self) -> str: return "\n".join(self._repr_node(N) for N in self) diff --git a/mode/utils/imports.py b/mode/utils/imports.py index 36d0422..dd18951 100644 --- a/mode/utils/imports.py +++ b/mode/utils/imports.py @@ -13,6 +13,7 @@ MutableMapping, ) from contextlib import contextmanager, suppress +from importlib.metadata import entry_points from types import ModuleType from typing import ( Any, @@ -25,12 +26,6 @@ cast, ) -try: - from importlib.metadata import entry_points # Python >= 3.10 -except ImportError: - from importlib_metadata import entry_points # type: ignore # Python < 3.10 - - from .collections import FastUserDict from .objects import cached_property from .text import didyoumean @@ -142,7 +137,7 @@ def _finalize(self) -> None: self.aliases.update(dict(load_extension_class_names(namespace))) @cached_property - def data(self) -> MutableMapping: # type: ignore + def data(self) -> MutableMapping: return self.aliases @@ -285,7 +280,7 @@ def symbol_by_name( try: try: - module = imp( # type: ignore + module = imp( module_name or "", package=package, # kwargs can be used to extend symbol_by_name when a custom @@ -374,19 +369,8 @@ def load_extension_class_names( [('msgpack', 'faust_msgpack:msgpack')] ``` """ - eps = entry_points() - # Python 3.10+ - if hasattr(eps, "select"): - for ep in eps.select(group=namespace): - yield RawEntrypointExtension( - ep.name, ":".join([ep.module, ep.attr]) - ) - # Python <3.10 - else: - for ep in eps.get(namespace, []): - yield RawEntrypointExtension( - ep.name, ":".join([ep.module, ep.attr]) - ) + for ep in entry_points().select(group=namespace): + yield RawEntrypointExtension(ep.name, ":".join([ep.module, ep.attr])) @contextmanager diff --git a/mode/utils/logging.py b/mode/utils/logging.py index f343a06..26096c2 100644 --- a/mode/utils/logging.py +++ b/mode/utils/logging.py @@ -19,7 +19,6 @@ from typing import ( IO, Any, - AnyStr, BinaryIO, Callable, ClassVar, @@ -320,7 +319,7 @@ class DefaultFormatter(logging.Formatter): """Default formatter adds support for extra data.""" def format(self, record: logging.LogRecord) -> str: - record.extra = _format_extra(record) # type: ignore + record.extra = _format_extra(record) return super().format(record) @@ -335,7 +334,7 @@ def __init__(self, stream: Optional[IO] = None, **kwargs: Any) -> None: def format(self, record: logging.LogRecord) -> str: self._format_args(record) - record.extra = _format_extra(record) # type: ignore + record.extra = _format_extra(record) return cast(str, super().format(record)) # type: ignore def _format_args(self, record: logging.LogRecord) -> None: @@ -348,9 +347,7 @@ def _format_args(self, record: logging.LogRecord) -> None: else: if not isinstance(record.args, tuple): # logger.log(severity, "msg %s", foo) - # mypy thinks this is unreachable as record is - # always Tuple - record.args = (record.args,) # type: ignore + record.args = (record.args,) # logger.log(severity, "msg %s", ('foo',)) record.args = tuple(format_arg(arg, record) for arg in record.args) @@ -374,9 +371,9 @@ def _format_arg2(self, arg: Any, record: logging.LogRecord) -> Any: @singledispatch -def level_name(loglevel: int) -> str: +def level_name(loglevel: Severity) -> str: """Convert log level to number.""" - return cast(str, logging.getLevelName(loglevel)) + return logging.getLevelName(loglevel) @level_name.register(str) @@ -385,14 +382,21 @@ def _when_str(loglevel: str) -> str: @singledispatch -def level_number(loglevel: int) -> int: +def level_number(loglevel: Optional[Severity]) -> int: """Convert log level number to name.""" - return loglevel + # `str` has a registered implementation below, so the fallback only + # ever sees numbers (or `None`, which is passed through unchanged). + # The parameter is annotated with the full union because + # `functools.singledispatch` requires the registered types to be + # subtypes of the fallback implementation's first argument. + return cast(int, loglevel) @level_number.register(str) def _(loglevel: str) -> int: - return logging.getLevelName(loglevel.upper()) # type: ignore + # `getLevelName` is typed as returning `str`, but returns the level + # number when given a level name. + return cast(int, logging.getLevelName(loglevel.upper())) def setup_logging( @@ -554,6 +558,7 @@ def cry( sep2 = sep2 * seplen if len(sep2) == 1 else sep2 sep3 = sep3 * seplen if len(sep3) == 1 else sep3 + loop: Optional[asyncio.AbstractEventLoop] for tid, frame in sys._current_frames().items(): thread = tmap.get(tid) if thread: @@ -868,7 +873,7 @@ def handleError(self, record: logging.LogRecord) -> None: handler.handleError = WithSafeHandleError().handleError # type: ignore - def write(self, s: AnyStr) -> int: + def write(self, s: str) -> int: if not getattr(self._threadlocal, "recurse_protection", False): data = s.strip() if data and not self.closed: @@ -895,6 +900,7 @@ def encoding(self) -> str: def errors(self) -> Optional[str]: return None + @property def line_buffering(self) -> bool: return False @@ -925,16 +931,16 @@ def fileno(self) -> int: def isatty(self) -> bool: return False - def read(self, n: int = -1) -> AnyStr: + def read(self, n: int = -1) -> str: raise NotImplementedError() def readable(self) -> bool: return False - def readline(self, limit: int = -1) -> AnyStr: + def readline(self, limit: int = -1) -> str: raise NotImplementedError() - def readlines(self, hint: int = -1) -> list[AnyStr]: + def readlines(self, hint: int = -1) -> list[str]: raise NotImplementedError() def seek(self, offset: int, whence: int = 0) -> int: @@ -966,7 +972,7 @@ def __exit__( exc_type: Optional[type[BaseException]] = None, exc_val: Optional[BaseException] = None, exc_tb: Optional[TracebackType] = None, - ) -> Optional[bool]: ... + ) -> None: ... @contextmanager diff --git a/mode/utils/loops.py b/mode/utils/loops.py index 87a818f..c351af4 100644 --- a/mode/utils/loops.py +++ b/mode/utils/loops.py @@ -28,7 +28,7 @@ def get_event_loop() -> asyncio.AbstractEventLoop: Mode accesses ``Service.loop`` (and other helpers) outside of a running loop -- e.g. at import time, when agents/services are declared at module level -- so it needs the historical "get or create" semantics. This - restores them in a way that works across Python 3.9-3.14. + restores them in a way that works across Python 3.10-3.14. Whether a loop is currently *running* can change on every call (that's the whole point of an event loop), so :func:`asyncio.get_running_loop` @@ -103,9 +103,7 @@ def call_asap( if _is_unix_loop(loop): return _call_asap(loop, callback, *args, context=context) if context is not None: - return loop.call_soon_threadsafe( # type: ignore - callback, *args, context=context - ) + return loop.call_soon_threadsafe(callback, *args, context=context) return loop.call_soon_threadsafe(callback, *args) @@ -117,7 +115,7 @@ def _call_asap( loop._check_callback(callback, "call_soon_threadsafe") loop._call_soon(callback, args, context) if context is not None: - handle = asyncio.Handle(callback, list(args), loop, context) # type: ignore + handle = asyncio.Handle(callback, list(args), loop, context) else: handle = asyncio.Handle(callback, list(args), loop) if handle._source_traceback: # type: ignore diff --git a/mode/utils/mocks.py b/mode/utils/mocks.py index 55eb4cf..a201250 100644 --- a/mode/utils/mocks.py +++ b/mode/utils/mocks.py @@ -7,7 +7,7 @@ from collections.abc import Iterator from contextlib import contextmanager from types import ModuleType -from typing import Any, cast +from typing import Any from unittest.mock import MagicMock __all__ = ["IN", "call", "mask_module", "patch_module"] @@ -95,7 +95,7 @@ def myimp(name: str, *args: Any, **kwargs: Any) -> ModuleType: if name in modnames: raise ImportError(f"No module named {name}") else: - return cast(ModuleType, realimport(name, *args, **kwargs)) + return realimport(name, *args, **kwargs) builtins.__import__ = myimp try: diff --git a/mode/utils/objects.py b/mode/utils/objects.py index 5485d9b..fb29253 100644 --- a/mode/utils/objects.py +++ b/mode/utils/objects.py @@ -3,6 +3,7 @@ import abc import collections.abc import sys +import types import typing from collections.abc import ( Iterable, @@ -16,6 +17,7 @@ from contextlib import suppress from decimal import Decimal from functools import total_ordering +from inspect import get_annotations from pathlib import Path from typing import ( Any, @@ -38,40 +40,13 @@ def _eval_type(t, globalns, localns, recursive_guard=frozenset()): # type: igno return t -def _is_class_var(typ): - # Works for typing.ClassVar and types.GenericAlias (Python 3.9+) +def _is_class_var(typ: Any) -> bool: + # Works for typing.ClassVar and types.GenericAlias origin = getattr(typ, "__origin__", None) return origin is ClassVar -if sys.version_info >= (3, 10): - from inspect import get_annotations as _own_annotations -else: - - def _own_annotations( - cls: type, - *, - globals: Optional[dict] = None, - locals: Optional[dict] = None, - eval_str: bool = False, - ) -> dict: - """Backport of :func:`inspect.get_annotations` for Python 3.9. - - Returns only the annotations declared directly in ``cls.__dict__``, - never inherited ones -- matching the 3.10+ stdlib function this - shadows, which is what makes it safe to use per-class inside a - bounded MRO walk (see ``local_annotations`` below). - """ - ann = cls.__dict__.get("__annotations__", {}) - if eval_str: - ann = { - k: (eval(v, globals, locals) if isinstance(v, str) else v) # noqa: S307 - for k, v in ann.items() - } - return dict(ann) - - -def _get_globalns(cls): +def _get_globalns(cls: type) -> dict[str, Any]: # Get the global namespace for a class module = sys.modules.get(cls.__module__) return module.__dict__ if module else {} @@ -100,7 +75,7 @@ def _get_globalns(cls): # Workaround for https://bugs.python.org/issue29581 try: - @typing.no_type_check # type: ignore + @typing.no_type_check class _InitSubclassCheck(metaclass=abc.ABCMeta): ident: int @@ -110,7 +85,7 @@ def __init_subclass__( self.ident = ident super().__init__(*args, **kwargs) - @typing.no_type_check # type: ignore + @typing.no_type_check class _UsingKwargsInNew(_InitSubclassCheck, ident=909): ... except TypeError: @@ -151,12 +126,7 @@ class _UsingKwargsInNew(_InitSubclassCheck, ident=909): ... TUPLE_TYPES: tuple[type, ...] = cast(tuple[type, ...], (tuple,)) -if sys.version_info >= (3, 10): - import types - - UNION_TYPES = (typing.Union, types.UnionType) -else: - UNION_TYPES = (typing.Union,) +UNION_TYPES = (typing.Union, types.UnionType) class InvalidAnnotation(Exception): @@ -271,6 +241,8 @@ def _detect_main_name() -> str: # pragma: no cover except (AttributeError, KeyError): # ipython/REPL return "__main__" else: + if filename is None: + return "__main__" path = Path(filename).absolute() node = path.parent seen = [] @@ -283,7 +255,7 @@ def _detect_main_name() -> str: # pragma: no cover return ".".join([*seen, path.stem]) -def _normalize_forwardref(t): +def _normalize_forwardref(t: Any) -> Any: if isinstance(t, str): return t origin = getattr(t, "__origin__", None) @@ -402,18 +374,14 @@ def local_annotations( # field of every subclass. # # Plain `cls.__annotations__` attribute access is *not* safe for this - # either: on Python < 3.10 it falls back to an inherited base's - # `__annotations__` via normal MRO lookup when `cls` itself has none of - # its own (re-introducing the same kind of leak), and on Python 3.14+ - # (PEP 649, deferred evaluation of annotations) direct `__annotations__` - # access on a class can behave unreliably -- the stdlib explicitly - # recommends `inspect.get_annotations()` instead, which reads only - # `cls.__dict__["__annotations__"]` (own annotations, no MRO fallback) - # and is written to handle PEP 649 correctly. `_own_annotations` here is - # that function, with a same-behavior backport for 3.9. String/ForwardRef - # annotations are still resolved below, per value, via - # `_resolve_refs`/`eval_type`. - d = _own_annotations(cls) + # either: on Python 3.14+ (PEP 649, deferred evaluation of annotations) + # direct `__annotations__` access on a class can behave unreliably -- + # the stdlib explicitly recommends `inspect.get_annotations()` instead, + # which reads only `cls.__dict__["__annotations__"]` (own annotations, + # no MRO fallback) and is written to handle PEP 649 correctly. + # String/ForwardRef annotations are still resolved below, per value, + # via `_resolve_refs`/`eval_type`. + d = get_annotations(cls) return _resolve_refs( d, globalns if globalns is not None else _get_globalns(cls), @@ -472,7 +440,8 @@ def eval_type( # __forward_value__ attributes as a workaround for Python 3.6/3.7; those # attributes are not part of any stable API and are no longer present at # all on Python 3.14's ForwardRef, which raised AttributeError. mode's - # floor is Python 3.9, well past the versions that workaround targeted). + # floor is Python 3.10, well past the versions that workaround + # targeted). if isinstance(typ, ForwardRef): try: typ = _eval_type(typ, globalns, localns) @@ -499,7 +468,7 @@ def eval_type( typ = _eval_type(typ, globalns, localns) if typ in invalid_types: raise InvalidAnnotation(typ) - return alias_types.get(typ, typ) + return cast(type, alias_types.get(typ, typ)) def iter_mro_reversed(cls: type, stop: type) -> Iterable[type]: @@ -536,7 +505,7 @@ def iter_mro_reversed(cls: type, stop: type) -> Iterable[type]: wanted = False for subcls in reversed(cls.__mro__): if wanted: - yield cast(type, subcls) + yield subcls else: wanted = subcls == stop @@ -558,7 +527,7 @@ def is_optional(typ: type) -> bool: return False -def _remove_optional(typ: type, *, find_origin: bool = False) -> Any: +def _remove_optional(typ: Any, *, find_origin: bool = False) -> Any: origin = get_origin(typ) args = get_args(typ) if origin in UNION_TYPES: @@ -582,7 +551,7 @@ def _remove_optional(typ: type, *, find_origin: bool = False) -> Any: def _py36_maybe_unwrap_GenericMeta(typ: type) -> type: if typ.__class__.__name__ == "GenericMeta": # Py3.6 - orig_bases = typ.__orig_bases__ + orig_bases = getattr(typ, "__orig_bases__", None) if orig_bases and orig_bases[0] in (list, tuple, dict, set): return cast(type, orig_bases[0]) return cast(type, getattr(typ, "__origin__", typ)) diff --git a/mode/utils/queues.py b/mode/utils/queues.py index c4de8b5..2c37e42 100644 --- a/mode/utils/queues.py +++ b/mode/utils/queues.py @@ -4,7 +4,15 @@ import math import typing from collections import deque -from typing import Any, Callable, Optional, TypeVar, cast, no_type_check +from typing import ( + Any, + Callable, + Generic, + Optional, + TypeVar, + cast, + no_type_check, +) from weakref import WeakSet from .locks import Event @@ -61,7 +69,8 @@ class FlowControlEvent: if typing.TYPE_CHECKING: _queues: WeakSet["FlowControlQueue"] - _queues = None + else: + _queues = None def __init__( self, @@ -104,7 +113,7 @@ async def acquire(self) -> None: await self._resume.wait() -class FlowControlQueue(asyncio.Queue): +class FlowControlQueue(asyncio.Queue, Generic[_T]): """`asyncio.Queue` managed by `FlowControlEvent`. See Also: @@ -211,7 +220,7 @@ def pressure_drop_size(self) -> int: return math.floor(self.maxsize * self.pressure_drop_ratio) -class ThrowableQueue(FlowControlQueue): +class ThrowableQueue(FlowControlQueue[_T]): """Queue that can be notified of errors.""" def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -238,7 +247,7 @@ def clear(self) -> None: def get_nowait(self) -> _T: if self._errors: raise self._errors.popleft() - return cast(_T, super().get_nowait()) + return super().get_nowait() async def throw(self, exc: BaseException) -> None: self._throw(exc) diff --git a/mode/utils/times.py b/mode/utils/times.py index faed160..ab61f82 100644 --- a/mode/utils/times.py +++ b/mode/utils/times.py @@ -9,7 +9,7 @@ from datetime import timedelta from functools import singledispatch from types import TracebackType -from typing import Callable, NamedTuple, Optional, Union +from typing import Callable, NamedTuple, Optional, Union, cast from .text import pluralize @@ -201,9 +201,13 @@ def tokens(self) -> float: @singledispatch -def rate(r: float) -> float: +def rate(r: Optional[Union[float, str]]) -> float: """Convert rate string (`"100/m"`, `"2/h"` or `"0.5/s"`) to seconds.""" - return r + # The parameter is annotated with every type this dispatches on, as + # `functools.singledispatch` requires the registered types to be + # subtypes of the fallback implementation's first argument. + # Anything reaching the fallback is already a rate in seconds. + return cast(float, r) @rate.register(str) @@ -235,9 +239,13 @@ def rate_limit( @singledispatch -def want_seconds(s: float) -> float: +def want_seconds(s: Seconds) -> float: """Convert `Seconds` to float.""" - return s + # `str` and `timedelta` have registered implementations below, so the + # fallback only ever sees numbers. The parameter is annotated with the + # full `Seconds` union because `functools.singledispatch` requires the + # registered types to be subtypes of it. + return cast(float, s) @want_seconds.register(str) diff --git a/mode/utils/tracebacks.py b/mode/utils/tracebacks.py index e7dab4c..969c9e3 100644 --- a/mode/utils/tracebacks.py +++ b/mode/utils/tracebacks.py @@ -6,7 +6,13 @@ import sys from collections.abc import AsyncGenerator, Coroutine, Generator, Mapping from traceback import StackSummary, print_list, walk_tb -from types import FrameType, TracebackType +from types import ( + AsyncGeneratorType, + CoroutineType, + FrameType, + GeneratorType, + TracebackType, +) from typing import IO, Any, Optional, Union, cast __all__ = ["Traceback", "format_task_stack", "print_task_stack"] @@ -213,7 +219,7 @@ def from_coroutine( # noqa: C901 break frames.append(current_frame) num_frames += 1 - current_frame = current_frame.f_back # type: ignore + current_frame = current_frame.f_back frames.reverse() prev: Optional[_BaseTraceback] = None root: Optional[_BaseTraceback] = None @@ -242,7 +248,7 @@ def from_coroutine( # noqa: C901 return root @classmethod - def _detect_frame(cls, obj: Any) -> FrameType: + def _detect_frame(cls, obj: Any) -> Optional[FrameType]: if inspect.isasyncgen(obj): return cls._get_agen_frame(obj) return cls._get_coroutine_frame(obj) @@ -250,14 +256,14 @@ def _detect_frame(cls, obj: Any) -> FrameType: @classmethod def _get_coroutine_frame( cls, coro: Union[Coroutine, Generator] - ) -> FrameType: + ) -> Optional[FrameType]: try: if inspect.isgenerator(coro): # is a @asyncio.coroutine wrapped generator - return cast(Generator, coro).gi_frame + return cast(GeneratorType, coro).gi_frame else: # is an async def function - return cast(Coroutine, coro).cr_frame + return cast(CoroutineType, coro).cr_frame except AttributeError as exc: raise cls._what_is_this(coro) from exc @@ -268,7 +274,9 @@ def _what_is_this(cls, obj: Any) -> AttributeError: ) @classmethod - def _get_agen_frame(cls, agen: AsyncGenerator) -> FrameType: + def _get_agen_frame( + cls, agen: AsyncGeneratorType[Any, Any] + ) -> Optional[FrameType]: try: return agen.ag_frame except AttributeError as exc: @@ -280,10 +288,10 @@ def _get_coroutine_next( ) -> Any: if inspect.isasyncgen(coro): # is a async def async-generator - return cast(AsyncGenerator, coro).ag_await + return cast(AsyncGeneratorType, coro).ag_await elif inspect.isgenerator(coro): # is a @asyncio.coroutine wrapped generator - return cast(Generator, coro).gi_yieldfrom + return cast(GeneratorType, coro).gi_yieldfrom else: # is an async def function - return cast(Coroutine, coro).cr_await + return cast(CoroutineType, coro).cr_await diff --git a/mode/worker.py b/mode/worker.py index da2e798..d367fa2 100644 --- a/mode/worker.py +++ b/mode/worker.py @@ -332,7 +332,7 @@ async def on_started(self) -> None: async def _add_monitor(self) -> Any: try: - import aiomonitor # type: ignore + import aiomonitor except ImportError: self.log.warning( "Cannot start console: aiomonitor is not installed" diff --git a/pyproject.toml b/pyproject.toml index e2f4632..8de4f1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ dynamic = [ ] description = "AsyncIO Service-based programming" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" keywords = ["asyncio", "service", "bootsteps", "graph", "coroutine"] authors = [ { name = "Ask Solem Hoel", email= "ask@robinhood.com" }, @@ -35,7 +35,6 @@ classifiers = [ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: BSD License", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -54,7 +53,6 @@ classifiers = [ dependencies = [ "colorlog>=6.0.0,<7.0.0", "croniter>=2.0.0,<6.0.0", - "importlib-metadata; python_version < '3.10'", "mypy_extensions", ] @@ -151,7 +149,7 @@ warn_unused_configs = true warn_unused_ignores = true [tool.ruff] -target-version = "py39" +target-version = "py310" line-length = 79 exclude = [ ".bzr", @@ -209,7 +207,15 @@ ignore = [ "PT006", # Allow pytest.mark.asyncio without the parentheses - "PT023" + "PT023", + + # Raising target-version to py310 makes pyupgrade want `X | Y` / + # `X | None` annotations and `collections.abc` imports throughout + # (~350 findings). That migration is worth doing, but as its own + # mechanical change -- some of these aliases are evaluated at runtime. + "UP007", + "UP035", + "UP045" ] [tool.ruff.lint.per-file-ignores] diff --git a/requirements-tests.txt b/requirements-tests.txt index 4013de8..d5424ff 100644 --- a/requirements-tests.txt +++ b/requirements-tests.txt @@ -3,9 +3,6 @@ freezegun>=0.3.11 # PyPy < 3.11, and ships no PyPy wheels; keep PyPy on the pure-Python line. hypothesis>=3.31; platform_python_implementation != "PyPy" hypothesis>=3.31,<6.156; platform_python_implementation == "PyPy" -# mypy 2.x depends on ast-serialize, a Rust extension that does not build on -# PyPy < 3.11; mypy does not support running under PyPy anyway. -mypy>=1.8.0; platform_python_implementation != "PyPy" pytest-aiofiles>=0.2.0 pytest-asyncio==0.21.1 pytest-base-url>=2.1.0 diff --git a/requirements-typecheck.txt b/requirements-typecheck.txt new file mode 100644 index 0000000..efd83ad --- /dev/null +++ b/requirements-typecheck.txt @@ -0,0 +1,11 @@ +# Optional: only needed to run scripts/typecheck.sh. +# +# Deliberately not pulled in by requirements.txt. mypy refuses to run on +# PyPy -- `mypy/main.py` exits immediately with "Running mypy on PyPy is +# not supported yet" -- and mypy 2.x depends on ast-serialize, a Rust +# extension with no PyPy wheels, so requiring it would break installing +# the test dependencies on the PyPy legs of the matrix. +# +# The 2.0 floor is the first release whose bundled typeshed `mode` checks +# clean against. +mypy>=2.0.0 diff --git a/scripts/lint.sh b/scripts/lint.sh index 58b5dc0..75ad57b 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -3,6 +3,8 @@ set -e set -x -# mypy mode +# Type checking is not here: mypy is an optional dependency that refuses +# to run on PyPy. It runs from tests/functional/test_typecheck.py (and +# scripts/typecheck.sh, for a direct run). ruff check mode tests ruff format mode tests --check diff --git a/scripts/typecheck.sh b/scripts/typecheck.sh new file mode 100755 index 0000000..7d052a4 --- /dev/null +++ b/scripts/typecheck.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +export PREFIX="" +if [ -d 'venv' ] ; then + export PREFIX="venv/bin/" +fi + +if ! command -v "${PREFIX}mypy" > /dev/null 2>&1 ; then + echo "mypy is not installed -- it is an optional dependency." >&2 + echo "Install it with: pip install -r requirements-typecheck.txt" >&2 + echo "(mypy refuses to run on PyPy; use CPython to type-check.)" >&2 + exit 1 +fi + +set -ex + +${PREFIX}mypy -p mode diff --git a/tests/functional/test_typecheck.py b/tests/functional/test_typecheck.py new file mode 100644 index 0000000..061703b --- /dev/null +++ b/tests/functional/test_typecheck.py @@ -0,0 +1,41 @@ +"""Type checking as an integration test. + +mypy is an optional dependency (`requirements-typecheck.txt`), so this +skips rather than fails when it is not installed -- contributors do not +have to install it to run the suite. CI installs it on the CPython legs +of the matrix, which is where the check actually runs. +""" + +import importlib.util +import platform +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).parent.parent.parent + +# `mypy.main` calls sys.exit(2) at import time under PyPy, so mypy must not +# even be imported there -- run it out of process and skip the leg entirely. +pytestmark = pytest.mark.skipif( + platform.python_implementation() == "PyPy", + reason="mypy refuses to run under PyPy", +) + + +def test_mypy_reports_no_errors(): + if importlib.util.find_spec("mypy") is None: + pytest.skip( + "mypy is not installed: pip install -r requirements-typecheck.txt" + ) + completed = subprocess.run( + [sys.executable, "-m", "mypy", "-p", "mode"], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + assert completed.returncode == 0, ( + f"mypy -p mode failed:\n{completed.stdout}{completed.stderr}" + ) diff --git a/tests/functional/utils/test_aiter.py b/tests/functional/utils/test_aiter.py index 85f0726..b6403b6 100644 --- a/tests/functional/utils/test_aiter.py +++ b/tests/functional/utils/test_aiter.py @@ -47,6 +47,11 @@ async def test_arange(): assert arange(10).index(3) == 3 +def test_arange__requires_stop(): + with pytest.raises(TypeError): + arange(None) + + @pytest.mark.asyncio async def test_aslice(): assert await alist(aslice(arange(100), 10)) == list(range(10)) diff --git a/tests/functional/utils/test_queues.py b/tests/functional/utils/test_queues.py index a8f7a7b..9c7ee43 100644 --- a/tests/functional/utils/test_queues.py +++ b/tests/functional/utils/test_queues.py @@ -164,3 +164,14 @@ async def test_get_nowait_empty(self): with pytest.raises(asyncio.QueueEmpty): queue.get_nowait() + + +@pytest.mark.parametrize("queue_type", [FlowControlQueue, ThrowableQueue]) +def test_queues_are_generic(queue_type): + # The queues declare an item type, so they must be subscriptable both + # for annotations and for instantiation. + assert queue_type[int] is not None + flow_control = FlowControlEvent(initially_suspended=False) + queue = queue_type[int](flow_control=flow_control) + queue.put_nowait(1) + assert queue.get_nowait() == 1 diff --git a/tests/unit/test_services.py b/tests/unit/test_services.py index 07f730b..0c65c99 100644 --- a/tests/unit/test_services.py +++ b/tests/unit/test_services.py @@ -8,6 +8,7 @@ from mode import Service from mode.services import Diag, ServiceTask, WaitResult +from mode.types import ServiceT from mode.utils.logging import get_logger @@ -72,6 +73,12 @@ async def test_start_stop(): assert s.state == "stopping" +def test_ServiceT_requires_beacon(): + # `beacon` is part of the ServiceT contract: ServiceBase reads it when + # formatting log messages, so implementations must provide it. + assert "beacon" in ServiceT.__abstractmethods__ + + def test_state_stopped(): s = S() s._started.set() diff --git a/tests/unit/utils/test_imports.py b/tests/unit/utils/test_imports.py index dcc6df9..495c9b7 100644 --- a/tests/unit/utils/test_imports.py +++ b/tests/unit/utils/test_imports.py @@ -197,11 +197,8 @@ def patch_importlib_metadata_entry_points(): ep2.name = "ep2" ep2.module = "bar" ep2.attr = "c" - # For Python >=3.10 mock_entry_points = Mock() mock_entry_points.select.return_value = [ep1, ep2] - # For Python <3.10 - mock_entry_points.get.return_value = [ep1, ep2] importlib_metadata_entry_points.return_value = mock_entry_points yield diff --git a/tests/unit/utils/test_logging.py b/tests/unit/utils/test_logging.py index 2cb721f..dd4e61a 100644 --- a/tests/unit/utils/test_logging.py +++ b/tests/unit/utils/test_logging.py @@ -558,6 +558,10 @@ def test_flush(self): def test_isatty(self): assert not FileLogProxy(get_logger("foo")).isatty() + def test_line_buffering(self): + # `TextIO.line_buffering` is an attribute, not a method. + assert FileLogProxy(get_logger("foo")).line_buffering is False + def test_redirect_stdouts(): prev_stdout = sys.stdout diff --git a/tests/unit/utils/test_objects.py b/tests/unit/utils/test_objects.py index e74867c..f9be0ea 100644 --- a/tests/unit/utils/test_objects.py +++ b/tests/unit/utils/test_objects.py @@ -1,7 +1,6 @@ import abc import collections.abc import pickle -import sys import typing from collections.abc import Mapping, MutableMapping, MutableSet, Sequence, Set from typing import ClassVar, Optional, Union @@ -419,10 +418,8 @@ def test_label_pass(): (int, False), (Union[int, bytes], True), (Optional[str], True), + (int | None, True), ] -if sys.version_info >= (3, 10): - # X | Y syntax is only usable at runtime on Python 3.10+ - IS_UNION_CASES.append((int | None, True)) @pytest.mark.parametrize("input,expected", IS_UNION_CASES)