Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@ jobs:
fail-fast: false
matrix:
python-version:
- "pypy3.9"
- "pypy3.10"
- "3.9"
- "3.10"
- "3.11"
- "3.12"
Expand All @@ -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

Expand Down
20 changes: 19 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion mode/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__,
Expand Down
4 changes: 2 additions & 2 deletions mode/debug.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
77 changes: 49 additions & 28 deletions mode/locals.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,10 @@ class XProxy(MutableMappingRole, AsyncContextManagerRole):
Awaitable,
Coroutine,
Generator,
ItemsView,
Iterable,
Iterator,
KeysView,
Mapping,
MutableMapping,
MutableSequence,
Expand All @@ -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,
Expand All @@ -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",
Expand Down Expand Up @@ -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__})
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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."""


Expand Down Expand Up @@ -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."""


Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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: ...
Expand Down
5 changes: 3 additions & 2 deletions mode/loop/_gevent_loop.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Gevent loop customizations."""

from typing import Any
import asyncio
from typing import Any, Optional

import gevent.core

Expand All @@ -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:
Expand Down
31 changes: 23 additions & 8 deletions mode/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand All @@ -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()
Expand Down
8 changes: 6 additions & 2 deletions mode/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions mode/types/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ def label(self) -> str: ...
def shortlabel(self) -> str: ...

@property
@abc.abstractmethod
def beacon(self) -> NodeT: ...

@beacon.setter
Expand Down
Loading
Loading