""" This module provides signals, which are a simple dispatching system that allows any number of interested parties to subscribe to events ("signals"). This is similar to the Blinker library (https://pypi.org/project/blinker/), with the following changes: - provides only a small subset of Blinker's functionality - supports type hints - supports async receivers. """ from __future__ import annotations import asyncio import inspect import weakref from collections.abc import Awaitable from collections.abc import Callable from typing import Any from typing import cast from typing import Generic from typing import ParamSpec from typing import TypeVar P = ParamSpec("P") R = TypeVar("R") def make_weak_ref(obj: Any) -> weakref.ReferenceType: """ Like weakref.ref(), but using weakref.WeakMethod for bound methods. """ if hasattr(obj, "__self__"): return cast(weakref.ref, weakref.WeakMethod(obj)) else: return weakref.ref(obj) # We're running into https://github.com/python/mypy/issues/6073 here, # which is why the base class is a mixin and not a generic superclass. class _SignalMixin: def __init__(self) -> None: self.receivers: list[weakref.ref[Callable]] = [] def connect(self, receiver: Callable) -> None: """ Register a signal receiver. The signal will only hold a weak reference to the receiver function. """ receiver = make_weak_ref(receiver) self.receivers.append(receiver) def disconnect(self, receiver: Callable) -> None: self.receivers = [r for r in self.receivers if r() != receiver] def notify(self, *args, **kwargs): cleanup = False for ref in self.receivers: r = ref() if r is not None: yield r(*args, **kwargs) else: cleanup = True if cleanup: self.receivers = [r for r in self.receivers if r() is not None] class _SyncSignal(Generic[P], _SignalMixin): def connect(self, receiver: Callable[P, None]) -> None: assert not inspect.iscoroutinefunction(receiver) super().connect(receiver) def disconnect(self, receiver: Callable[P, None]) -> None: super().disconnect(receiver) def send(self, *args: P.args, **kwargs: P.kwargs) -> None: for ret in super().notify(*args, **kwargs): assert ret is None or not inspect.isawaitable(ret) class _AsyncSignal(Generic[P], _SignalMixin): def connect(self, receiver: Callable[P, Awaitable[None] | None]) -> None: super().connect(receiver) def disconnect(self, receiver: Callable[P, Awaitable[None] | None]) -> None: super().disconnect(receiver) async def send(self, *args: P.args, **kwargs: P.kwargs) -> None: await asyncio.gather( *[ aws for aws in super().notify(*args, **kwargs) if aws is not None and inspect.isawaitable(aws) ] ) # noinspection PyPep8Naming def SyncSignal(receiver_spec: Callable[P, None]) -> _SyncSignal[P]: """ Create a synchronous signal with the given function signature for receivers. Example: s = SyncSignal(lambda event: None) # all receivers must accept a single "event" argument. def receiver(event): print(event) s.connect(receiver) s.send("foo") # prints foo s.send(event="bar") # prints bar def receiver2(): ... s.connect(receiver2) # mypy complains about receiver2 not having the right signature s2 = SyncSignal(lambda: None) # this signal has no arguments s2.send() """ return cast(_SyncSignal[P], _SyncSignal()) # noinspection PyPep8Naming def AsyncSignal(receiver_spec: Callable[P, Awaitable[None] | None]) -> _AsyncSignal[P]: """ Create an signal that supports both regular and async receivers: Example: s = AsyncSignal(lambda event: None) async def receiver(event): print(event) s.connect(receiver) await s.send("foo") # prints foo """ return cast(_AsyncSignal[P], _AsyncSignal())