From bee1a4fb8d472835d5f9398671c179f3992c7da8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 19:05:24 +0000 Subject: [PATCH 1/7] Make mypy pass and enforce type checks in CI Closes #4. `mypy -p mode` reported 127 errors under a current mypy; the type checks had been commented out of scripts/lint.sh, so nothing caught the drift. This fixes every error and wires type checking back into CI. Fixes grouped by cause: - singledispatch fallbacks (`want_seconds`, `rate`, `level_number`, `level_name`) annotated their first parameter with only the fallback's own type, so registering `str`/`timedelta` was rejected and every caller passing `Seconds`/`Severity` failed. They now name the full union they dispatch over. - Proxy roles in `mode/locals.py` were written against older typeshed signatures: `Coroutine.throw`/`AsyncGenerator.athrow` are overloaded, `Mapping.items()`/`keys()` return views, and `MutableSet`/ `MutableSequence` in-place operators return `Self`. The two `throw` errors quoted in the issue are among these. - `mode/utils/tracebacks.py` read `gi_frame`/`cr_frame`/`ag_frame`/ `ag_await`/`gi_yieldfrom`/`cr_await` off the abstract protocols, which do not declare them; it now casts to the concrete `types.*Type`. Those attributes are `Optional`, so the frame getters say so. - `Heap` is bound to `SupportsRichComparison`: `heapq` orders elements by comparing them. - `FlowControlQueue`/`ThrowableQueue` used an unbound type variable in their signatures; they are now generic, so `ThrowableQueue[int]` means what it says. - `ServiceT.beacon` is abstract like every other property on the type. `ServiceBase._format_log` already requires it; `Service` and `ServiceProxy` both implement it. - `FileLogProxy` matches `TextIO`: `line_buffering` is a property, `read`/`readline`/`readlines`/`write` are `str`, not `AnyStr`. - Dropped the Python 3.6-era `asyncio.Task.all_tasks`/`current_task` fallbacks and 13 stale `# type: ignore` comments. - `Service._actually_start`/`itertimer` read `should_stop` into a local before each check. mypy folds repeated reads of a property into the first result and then calls the later checks dead code. Type checking now runs as its own CI job via scripts/typecheck.sh: mypy needs CPython 3.10+ and cannot run under PyPy, so it cannot go on every leg of the test matrix. The mypy floor moves to 2.0.0, the first release whose bundled typeshed the package checks clean against. Also silences a new RUF063 from a recent ruff by spelling the annotation lookup `vars(cls)`; that failure predates this change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017q5Vvxifyg39rFZ6x2YUA7 --- .github/workflows/tests.yml | 26 ++++++++++ mode/__init__.py | 2 +- mode/debug.py | 4 +- mode/locals.py | 75 ++++++++++++++------------- mode/loop/_gevent_loop.py | 5 +- mode/services.py | 31 ++++++++--- mode/signals.py | 8 ++- mode/types/services.py | 1 + mode/utils/aiter.py | 12 +++-- mode/utils/collections.py | 60 +++++++++++---------- mode/utils/futures.py | 14 +---- mode/utils/graphs/graph.py | 4 +- mode/utils/imports.py | 8 +-- mode/utils/logging.py | 38 ++++++++------ mode/utils/loops.py | 6 +-- mode/utils/mocks.py | 4 +- mode/utils/objects.py | 26 ++++++---- mode/utils/queues.py | 19 +++++-- mode/utils/times.py | 18 +++++-- mode/utils/tracebacks.py | 28 ++++++---- mode/worker.py | 2 +- requirements-tests.txt | 8 ++- scripts/lint.sh | 3 +- scripts/typecheck.sh | 10 ++++ tests/functional/utils/test_aiter.py | 5 ++ tests/functional/utils/test_queues.py | 11 ++++ tests/unit/test_services.py | 7 +++ tests/unit/utils/test_logging.py | 4 ++ 28 files changed, 285 insertions(+), 154 deletions(-) create mode 100755 scripts/typecheck.sh diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 083d332a..70cb0c27 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -56,11 +56,37 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} + typecheck: + name: "Type check with mypy" + runs-on: "ubuntu-latest" + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + # mypy needs CPython 3.10+ and cannot run under PyPy, so type checks + # run once here instead of on every leg of the test matrix. + - uses: "actions/setup-python@v4" + with: + python-version: "3.13" + cache: "pip" + cache-dependency-path: | + requirements-docs.txt + requirements-tests.txt + pyproject.toml + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Run type checks + run: scripts/typecheck.sh + check: # This job does nothing and is only used for the branch protection name: ✅ Ensure the required checks passing if: always() needs: - tests + - typecheck runs-on: ubuntu-latest steps: - name: Decide whether the needed jobs succeeded or failed diff --git a/mode/__init__.py b/mode/__init__.py index 9ef99bdc..eaca124a 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 c18e1fb5..5991fb2b 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 fce04c8b..2e86beb9 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, @@ -171,7 +173,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 +284,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 +425,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 +493,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 +602,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 +680,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 +724,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 +762,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,15 +816,6 @@ def popitem(self) -> tuple[KT, VT]: def setdefault(self, k: KT, *args: Any) -> VT: return self._get_mapping().setdefault(k, *args) - @overload - def update(self, __m: Mapping[KT, VT], **kwargs: VT) -> None: ... - - @overload - def update(self, __m: Iterable[tuple[KT, VT]], **kwargs: VT) -> None: ... - - @overload - def update(self, **kwargs: VT) -> None: ... - def update(self, *args: Any, **kwargs: Any) -> None: self._get_mapping().update(*args, **kwargs) diff --git a/mode/loop/_gevent_loop.py b/mode/loop/_gevent_loop.py index 30bf5b2a..ad47d3d8 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 e09376ab..6adf723e 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 96712ea1..dad01d63 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 f5fb964c..0e64c052 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 c2c5c9b0..e6379f2a 100644 --- a/mode/utils/aiter.py +++ b/mode/utils/aiter.py @@ -108,9 +108,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 +167,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 43174b6c..3775536d 100644 --- a/mode/utils/collections.py +++ b/mode/utils/collections.py @@ -65,23 +65,31 @@ 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 `<`/`>`. +_HT = TypeVar("_HT", bound="SupportsRichComparison") _Setlike = Union[Set[T], Iterable[T]] -class Heap(MutableSequence[T]): +class Heap(MutableSequence[_HT]): """Generic interface to `heapq`.""" - def __init__(self, data: Optional[Sequence[T]] = None) -> None: + def __init__(self, data: Optional[Sequence[_HT]] = None) -> None: self.data = list(data or []) heapify(self.data) - def pop(self, index: int = 0) -> T: + def pop(self, index: int = 0) -> _HT: """Pop the smallest item off the heap. Maintains the heap invariant. @@ -93,11 +101,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: _HT) -> None: """Push item onto heap, maintaining the heap invariant.""" heappush(self.data, item) - def pushpop(self, item: T) -> T: + def pushpop(self, item: _HT) -> _HT: """Push item on the heap, then pop and return from the heap. The combined action runs more efficiently than @@ -105,7 +113,7 @@ def pushpop(self, item: T) -> T: """ return heappushpop(self.data, item) - def replace(self, item: T) -> T: + def replace(self, item: _HT) -> _HT: """Pop and return the current smallest value, and add the new item. This is more efficient than :meth`pop` followed by `push`, @@ -122,21 +130,21 @@ 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[_HT]: """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[_HT]: """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: _HT) -> None: self.data.insert(index, value) def __str__(self) -> str: @@ -146,19 +154,19 @@ def __repr__(self) -> str: return repr(self.data) @overload - def __getitem__(self, s: int) -> T: ... + def __getitem__(self, s: int) -> _HT: ... @overload - def __getitem__(self, s: slice) -> MutableSequence[T]: ... + def __getitem__(self, s: slice) -> MutableSequence[_HT]: ... 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: _HT) -> None: ... @overload - def __setitem__(self, s: slice, o: Iterable[T]) -> None: ... + def __setitem__(self, s: slice, o: Iterable[_HT]) -> None: ... def __setitem__(self, s: Any, o: Any) -> None: self.data.__setitem__(s, o) @@ -264,7 +272,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 +302,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 +339,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 +347,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 +556,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 +641,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 70978b6b..aee6898e 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 5e787d38..31f49c11 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 36d0422f..fdedd7d5 100644 --- a/mode/utils/imports.py +++ b/mode/utils/imports.py @@ -142,7 +142,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 +285,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 @@ -383,7 +383,9 @@ def load_extension_class_names( ) # Python <3.10 else: - for ep in eps.get(namespace, []): + # `entry_points()` returned a mapping of group name to entry + # points back then; the modern `EntryPoints` has no `.get`. + for ep in cast(Any, eps).get(namespace, []): yield RawEntrypointExtension( ep.name, ":".join([ep.module, ep.attr]) ) diff --git a/mode/utils/logging.py b/mode/utils/logging.py index f343a067..26096c25 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 87a818f3..626c40d3 100644 --- a/mode/utils/loops.py +++ b/mode/utils/loops.py @@ -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 55eb4cfb..a2012504 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 5485d9bd..c6b23c70 100644 --- a/mode/utils/objects.py +++ b/mode/utils/objects.py @@ -38,7 +38,7 @@ def _eval_type(t, globalns, localns, recursive_guard=frozenset()): # type: igno return t -def _is_class_var(typ): +def _is_class_var(typ: Any) -> bool: # Works for typing.ClassVar and types.GenericAlias (Python 3.9+) origin = getattr(typ, "__origin__", None) return origin is ClassVar @@ -62,7 +62,11 @@ def _own_annotations( 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__", {}) + # `vars(cls)` is `cls.__dict__`; spelled this way because linters + # rewrite a literal `cls.__dict__["__annotations__"]` read into a + # `get_annotations()` call, which is the very function this + # backports. + ann = vars(cls).get("__annotations__", {}) if eval_str: ann = { k: (eval(v, globals, locals) if isinstance(v, str) else v) # noqa: S307 @@ -71,7 +75,7 @@ def _own_annotations( 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 +104,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 +114,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: @@ -271,6 +275,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 +289,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) @@ -499,7 +505,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 +542,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 +564,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 +588,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 c4de8b5a..2c37e42a 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 faed160d..ab61f82e 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 e7dab4c2..969c9e30 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 da2e7984..d367fa2d 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/requirements-tests.txt b/requirements-tests.txt index 4013de87..60f57b06 100644 --- a/requirements-tests.txt +++ b/requirements-tests.txt @@ -4,8 +4,12 @@ freezegun>=0.3.11 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" +# PyPy < 3.11; mypy does not support running under PyPy anyway. It also +# needs CPython 3.10+ to run, while mode itself still supports 3.9, so the +# typecheck job (scripts/typecheck.sh) runs on a newer interpreter. +# The 2.0 floor is the first release whose bundled typeshed `mode` checks +# clean against. +mypy>=2.0.0; platform_python_implementation != "PyPy" and python_version >= "3.10" pytest-aiofiles>=0.2.0 pytest-asyncio==0.21.1 pytest-base-url>=2.1.0 diff --git a/scripts/lint.sh b/scripts/lint.sh index 58b5dc05..6d6caeeb 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -3,6 +3,7 @@ set -e set -x -# mypy mode +# Type checking lives in scripts/typecheck.sh: mypy needs CPython 3.10+, +# so it cannot run on every leg of the test matrix. ruff check mode tests ruff format mode tests --check diff --git a/scripts/typecheck.sh b/scripts/typecheck.sh new file mode 100755 index 00000000..128aa6de --- /dev/null +++ b/scripts/typecheck.sh @@ -0,0 +1,10 @@ +#!/bin/sh + +export PREFIX="" +if [ -d 'venv' ] ; then + export PREFIX="venv/bin/" +fi + +set -ex + +${PREFIX}mypy -p mode diff --git a/tests/functional/utils/test_aiter.py b/tests/functional/utils/test_aiter.py index 85f07261..b6403b68 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 a8f7a7b0..9c7ee434 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 07f730be..0c65c993 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_logging.py b/tests/unit/utils/test_logging.py index 2cb721fe..dd4e61a5 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 From d75fb9021f7b2e39de9fc23d8614f88dfa08296b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 19:12:22 +0000 Subject: [PATCH 2/7] Restore MutableMappingRole.update overloads Dropping them cost call-site checking on `proxy.update(...)` for no good reason: the base's argument types live in `_typeshed`, which is not importable at runtime but is fine under `TYPE_CHECKING`. Three of typeshed's five overloads carry a `self: SupportsGetItem[str, _VT]` annotation restricting `**kwargs` to str-keyed mappings; no single implementation signature satisfies those, so the kwargs overload stays unrestricted, exactly as it was before. The other two now match the base instead of narrowing `SupportsKeysAndGetItem` to `Mapping`, which is what made them fail in the first place. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017q5Vvxifyg39rFZ6x2YUA7 --- mode/locals.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/mode/locals.py b/mode/locals.py index 2e86beb9..973d24c8 100644 --- a/mode/locals.py +++ b/mode/locals.py @@ -97,6 +97,7 @@ class XProxy(MutableMappingRole, AsyncContextManagerRole): from functools import wraps from types import GetSetDescriptorType, TracebackType from typing import ( + TYPE_CHECKING, Any, Callable, ClassVar, @@ -111,6 +112,9 @@ class XProxy(MutableMappingRole, AsyncContextManagerRole): from .utils.locals import LocalStack # XXX compat +if TYPE_CHECKING: + from _typeshed import SupportsKeysAndGetItem + __all__ = [ "AsyncContextManagerProxy", "AsyncContextManagerRole", @@ -816,6 +820,18 @@ 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: "SupportsKeysAndGetItem[KT, VT]", /) -> None: ... + + @overload + def update(self, m: Iterable[tuple[KT, VT]], /) -> None: ... + + @overload + def update(self, **kwargs: VT) -> None: ... + def update(self, *args: Any, **kwargs: Any) -> None: self._get_mapping().update(*args, **kwargs) From 53f11f5eeddbd098b2a4a388ec873ace2b17a531 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 19:14:25 +0000 Subject: [PATCH 3/7] Rename Heap's type variable to _ComparableT `_HT` said nothing about what it constrains. The new name states it: elements must support `<`/`>`, because heapq orders them by comparing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017q5Vvxifyg39rFZ6x2YUA7 --- mode/utils/collections.py | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/mode/utils/collections.py b/mode/utils/collections.py index 3775536d..c8f8bda5 100644 --- a/mode/utils/collections.py +++ b/mode/utils/collections.py @@ -76,20 +76,20 @@ class LazySettings: ... VT = TypeVar("VT") _S = TypeVar("_S") #: `heapq` orders elements by comparing them, so a heap can only hold -#: values that support `<`/`>`. -_HT = TypeVar("_HT", bound="SupportsRichComparison") +#: values that support `<`/`>` (`_typeshed.SupportsRichComparison`). +_ComparableT = TypeVar("_ComparableT", bound="SupportsRichComparison") _Setlike = Union[Set[T], Iterable[T]] -class Heap(MutableSequence[_HT]): +class Heap(MutableSequence[_ComparableT]): """Generic interface to `heapq`.""" - def __init__(self, data: Optional[Sequence[_HT]] = 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) -> _HT: + def pop(self, index: int = 0) -> _ComparableT: """Pop the smallest item off the heap. Maintains the heap invariant. @@ -101,11 +101,11 @@ def pop(self, index: int = 0) -> _HT: "Heap can only pop index 0, please use h.data.pop(index)" ) - def push(self, item: _HT) -> None: + def push(self, item: _ComparableT) -> None: """Push item onto heap, maintaining the heap invariant.""" heappush(self.data, item) - def pushpop(self, item: _HT) -> _HT: + 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 @@ -113,7 +113,7 @@ def pushpop(self, item: _HT) -> _HT: """ return heappushpop(self.data, item) - def replace(self, item: _HT) -> _HT: + 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`, @@ -130,21 +130,25 @@ def replace(self, item: _HT) -> _HT: """ return heapreplace(self.data, item) - def nlargest(self, n: int, key: Optional[Callable] = None) -> list[_HT]: + 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[_HT]: + 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: _HT) -> None: + def insert(self, index: int, value: _ComparableT) -> None: self.data.insert(index, value) def __str__(self) -> str: @@ -154,19 +158,19 @@ def __repr__(self) -> str: return repr(self.data) @overload - def __getitem__(self, s: int) -> _HT: ... + def __getitem__(self, s: int) -> _ComparableT: ... @overload - def __getitem__(self, s: slice) -> MutableSequence[_HT]: ... + 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: _HT) -> None: ... + def __setitem__(self, s: int, o: _ComparableT) -> None: ... @overload - def __setitem__(self, s: slice, o: Iterable[_HT]) -> None: ... + def __setitem__(self, s: slice, o: Iterable[_ComparableT]) -> None: ... def __setitem__(self, s: Any, o: Any) -> None: self.data.__setitem__(s, o) From d4d77dccc3bd685ceecd87a6f9a1e463238bc72c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 19:18:47 +0000 Subject: [PATCH 4/7] Document why mode ships aiter/anext, and Heap's ordering bound `mode.utils.aiter`'s module docstring still claimed aiter and anext were "missing" methods -- they have been builtins since Python 3.10. Replace it with what actually justifies keeping these: mode's `aiter` dispatches on synchronous iterables too, `anext` takes `*default`, and both shadow the builtins for the rest of the module. Same note on each function. `Heap` now states that its elements must be orderable and why, so the `_ComparableT` bound is explained where users meet it rather than only at the TypeVar. Both files are published via docs/references (mkdocstrings), so this lands on the docs site. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017q5Vvxifyg39rFZ6x2YUA7 --- mode/utils/aiter.py | 35 +++++++++++++++++++++++++++++++++-- mode/utils/collections.py | 10 +++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/mode/utils/aiter.py b/mode/utils/aiter.py index e6379f2a..efcdd9e9 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. diff --git a/mode/utils/collections.py b/mode/utils/collections.py index c8f8bda5..5c5aaf4b 100644 --- a/mode/utils/collections.py +++ b/mode/utils/collections.py @@ -83,7 +83,15 @@ class LazySettings: ... class Heap(MutableSequence[_ComparableT]): - """Generic interface to `heapq`.""" + """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[_ComparableT]] = None) -> None: self.data = list(data or []) From 487e2a09d8456a1382b5f6e66e3cb4a74dc14e33 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 19:30:44 +0000 Subject: [PATCH 5/7] Drop Python 3.9 support requires-python goes to >=3.10, along with the 3.9 classifier, ruff's target-version, the 3.9 and pypy3.9 CI legs, and the conditional importlib-metadata dependency. That makes three version branches dead, so they go too: - objects.py carried a backport of `inspect.get_annotations` for 3.9; it now imports the stdlib function directly. - `UNION_TYPES` no longer has to omit `types.UnionType`. - `load_extension_class_names` no longer probes for `.select`; `entry_points()` has had it since 3.10. The mypy requirement loses its `python_version >= "3.10"` marker, which only existed because mypy could not be installed on the 3.9 leg. The PyPy exclusion stays: mypy still cannot run under PyPy, and that alone is why type checking is a separate job. Raising ruff's target-version surfaces ~350 pyupgrade findings asking for `X | Y` / `X | None` annotations and `collections.abc` imports across the package. Those rules are ignored for now rather than answered here: it is a mechanical migration that deserves its own diff, and some of the aliases involved are evaluated at runtime. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017q5Vvxifyg39rFZ6x2YUA7 --- .github/workflows/tests.yml | 6 +-- mode/utils/imports.py | 24 ++---------- mode/utils/loops.py | 2 +- mode/utils/objects.py | 65 +++++++------------------------- pyproject.toml | 16 +++++--- requirements-tests.txt | 12 +++--- scripts/lint.sh | 4 +- tests/unit/utils/test_imports.py | 3 -- tests/unit/utils/test_objects.py | 5 +-- 9 files changed, 39 insertions(+), 98 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 70cb0c27..1cf94b2a 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" @@ -64,8 +62,8 @@ jobs: - uses: actions/checkout@v3 with: fetch-depth: 0 - # mypy needs CPython 3.10+ and cannot run under PyPy, so type checks - # run once here instead of on every leg of the test matrix. + # mypy cannot run under PyPy, so type checks run once here instead + # of on every leg of the test matrix. - uses: "actions/setup-python@v4" with: python-version: "3.13" diff --git a/mode/utils/imports.py b/mode/utils/imports.py index fdedd7d5..dd189513 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 @@ -374,21 +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: - # `entry_points()` returned a mapping of group name to entry - # points back then; the modern `EntryPoints` has no `.get`. - for ep in cast(Any, 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/loops.py b/mode/utils/loops.py index 626c40d3..c351af46 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` diff --git a/mode/utils/objects.py b/mode/utils/objects.py index c6b23c70..fb29253a 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, @@ -39,42 +41,11 @@ def _eval_type(t, globalns, localns, recursive_guard=frozenset()): # type: igno def _is_class_var(typ: Any) -> bool: - # Works for typing.ClassVar and types.GenericAlias (Python 3.9+) + # 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). - """ - # `vars(cls)` is `cls.__dict__`; spelled this way because linters - # rewrite a literal `cls.__dict__["__annotations__"]` read into a - # `get_annotations()` call, which is the very function this - # backports. - ann = vars(cls).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: type) -> dict[str, Any]: # Get the global namespace for a class module = sys.modules.get(cls.__module__) @@ -155,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): @@ -408,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), @@ -478,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) diff --git a/pyproject.toml b/pyproject.toml index e2f46326..8de4f1e4 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 60f57b06..183c95b7 100644 --- a/requirements-tests.txt +++ b/requirements-tests.txt @@ -3,13 +3,11 @@ 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. It also -# needs CPython 3.10+ to run, while mode itself still supports 3.9, so the -# typecheck job (scripts/typecheck.sh) runs on a newer interpreter. -# The 2.0 floor is the first release whose bundled typeshed `mode` checks -# clean against. -mypy>=2.0.0; platform_python_implementation != "PyPy" and python_version >= "3.10" +# mypy does not support running under PyPy, so it is left off those legs +# and type checking runs as its own job (scripts/typecheck.sh). The 2.0 +# floor is the first release whose bundled typeshed `mode` checks clean +# against. +mypy>=2.0.0; platform_python_implementation != "PyPy" pytest-aiofiles>=0.2.0 pytest-asyncio==0.21.1 pytest-base-url>=2.1.0 diff --git a/scripts/lint.sh b/scripts/lint.sh index 6d6caeeb..787058a1 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -3,7 +3,7 @@ set -e set -x -# Type checking lives in scripts/typecheck.sh: mypy needs CPython 3.10+, -# so it cannot run on every leg of the test matrix. +# Type checking lives in scripts/typecheck.sh: mypy cannot run under +# PyPy, so it cannot go on every leg of the test matrix. ruff check mode tests ruff format mode tests --check diff --git a/tests/unit/utils/test_imports.py b/tests/unit/utils/test_imports.py index dcc6df94..495c9b73 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_objects.py b/tests/unit/utils/test_objects.py index e74867cb..f9be0ea6 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) From 44aeca0f826840692c64d5cd6d4d7c51ca9281f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 19:36:05 +0000 Subject: [PATCH 6/7] Make mypy an optional dependency mypy hard-exits on PyPy -- `mypy/main.py` writes "Running mypy on PyPy is not supported yet" and calls sys.exit(2) before importing anything else -- so requiring it on every leg of the matrix bought nothing. It only ever runs in the typecheck job. It moves out of requirements-tests.txt into requirements-typecheck.txt, which requirements.txt does not include, and the typecheck job installs explicitly. The `platform_python_implementation != "PyPy"` marker goes away with it: the PyPy legs no longer see mypy at all, so there is also nothing to fail when pip tries to build mypy 2.x's ast-serialize extension, which ships no PyPy wheels. scripts/typecheck.sh now says how to install mypy instead of dying with "command not found", and CONTRIBUTING.md documents the extra step. It also claimed ./scripts/format.sh "uses ruff & mypy"; it has only run ruff since the type checks were commented out. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017q5Vvxifyg39rFZ6x2YUA7 --- .github/workflows/tests.yml | 8 +++++--- CONTRIBUTING.md | 17 ++++++++++++++++- requirements-tests.txt | 5 ----- requirements-typecheck.txt | 11 +++++++++++ scripts/lint.sh | 4 ++-- scripts/typecheck.sh | 7 +++++++ 6 files changed, 41 insertions(+), 11 deletions(-) create mode 100644 requirements-typecheck.txt diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1cf94b2a..65759fca 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -62,8 +62,9 @@ jobs: - uses: actions/checkout@v3 with: fetch-depth: 0 - # mypy cannot run under PyPy, so type checks run once here instead - # of on every leg of the test matrix. + # mypy refuses to run on PyPy, so type checks run once here instead + # of on every leg of the test matrix, and mypy stays an optional + # dependency the other legs never install. - uses: "actions/setup-python@v4" with: python-version: "3.13" @@ -71,10 +72,11 @@ jobs: cache-dependency-path: | requirements-docs.txt requirements-tests.txt + requirements-typecheck.txt pyproject.toml - name: Install dependencies - run: pip install -r requirements.txt + run: pip install -r requirements.txt -r requirements-typecheck.txt - name: Run type checks run: scripts/typecheck.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1abfedd4..1de6c503 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,7 +52,22 @@ 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 +``` + +It has to be run on CPython -- mypy exits with an error under PyPy -- which +is why it is a separate CI job rather than part of `./scripts/lint.sh`. ### Run tests diff --git a/requirements-tests.txt b/requirements-tests.txt index 183c95b7..d5424ffa 100644 --- a/requirements-tests.txt +++ b/requirements-tests.txt @@ -3,11 +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 does not support running under PyPy, so it is left off those legs -# and type checking runs as its own job (scripts/typecheck.sh). The 2.0 -# floor is the first release whose bundled typeshed `mode` checks clean -# against. -mypy>=2.0.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 00000000..efd83adf --- /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 787058a1..6275ec1a 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -3,7 +3,7 @@ set -e set -x -# Type checking lives in scripts/typecheck.sh: mypy cannot run under -# PyPy, so it cannot go on every leg of the test matrix. +# Type checking lives in scripts/typecheck.sh: mypy is an optional +# dependency and refuses to run on PyPy, so it cannot go here. ruff check mode tests ruff format mode tests --check diff --git a/scripts/typecheck.sh b/scripts/typecheck.sh index 128aa6de..7d052a46 100755 --- a/scripts/typecheck.sh +++ b/scripts/typecheck.sh @@ -5,6 +5,13 @@ 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 From 8ed09fb4d14a6cfd4babb408aaa9670b014ce2a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 19:40:25 +0000 Subject: [PATCH 7/7] Run mypy from the integration suite instead of a dedicated CI job tests/functional/test_typecheck.py shells out to `mypy -p mode` and fails with mypy's output. It skips when mypy is not importable, so the suite still runs for anyone who has not installed the optional requirements-typecheck.txt, and skips outright on PyPy -- mypy.main calls sys.exit(2) at import there, so it must not be imported in process. CI installs mypy on the non-PyPy legs, which turns that skip into a real run. The type checks now happen once per CPython version rather than against a single interpreter, which matters here: mypy resolves `sys.version_info` branches against the running interpreter, so 3.10 and 3.14 do not check the same code. That makes the separate typecheck job redundant, so it goes, and the branch-protection job needs only `tests` again. scripts/typecheck.sh stays for running the check directly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017q5Vvxifyg39rFZ6x2YUA7 --- .github/workflows/tests.yml | 37 +++++++-------------------- CONTRIBUTING.md | 7 +++-- scripts/lint.sh | 5 ++-- tests/functional/test_typecheck.py | 41 ++++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 32 deletions(-) create mode 100644 tests/functional/test_typecheck.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 65759fca..45844eee 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -38,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 @@ -54,39 +63,11 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} - typecheck: - name: "Type check with mypy" - runs-on: "ubuntu-latest" - - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - # mypy refuses to run on PyPy, so type checks run once here instead - # of on every leg of the test matrix, and mypy stays an optional - # dependency the other legs never install. - - uses: "actions/setup-python@v4" - with: - python-version: "3.13" - cache: "pip" - cache-dependency-path: | - requirements-docs.txt - requirements-tests.txt - requirements-typecheck.txt - pyproject.toml - - - name: Install dependencies - run: pip install -r requirements.txt -r requirements-typecheck.txt - - - name: Run type checks - run: scripts/typecheck.sh - check: # This job does nothing and is only used for the branch protection name: ✅ Ensure the required checks passing if: always() needs: - tests - - typecheck runs-on: ubuntu-latest steps: - name: Decide whether the needed jobs succeeded or failed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1de6c503..6e839d4b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,8 +66,11 @@ installed by `requirements.txt`: Success: no issues found in 45 source files ``` -It has to be run on CPython -- mypy exits with an error under PyPy -- which -is why it is a separate CI job rather than part of `./scripts/lint.sh`. +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/scripts/lint.sh b/scripts/lint.sh index 6275ec1a..75ad57b4 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -3,7 +3,8 @@ set -e set -x -# Type checking lives in scripts/typecheck.sh: mypy is an optional -# dependency and refuses to run on PyPy, so it cannot go here. +# 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/tests/functional/test_typecheck.py b/tests/functional/test_typecheck.py new file mode 100644 index 00000000..061703b3 --- /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}" + )