Requiring 3.13 meant the device needed an interpreter the distribution does not
have, which is what dragged uv in, and uv then had to be matched to the Pi's
32-bit userland by hand and to build Pillow from source because no armv7 wheel
exists for a 3.13 ABI. Dropping to 3.11 removes all of that: apt provides the
interpreter and piwheels has prebuilt armhf wheels for the native dependencies.
The 3.13-only syntax was shallow - PEP 695 throughout, which converts back
mechanically:
type X = Y -> X: TypeAlias = Y
type Handler[E: Event] -> E = TypeVar("E", bound=Event) plus a plain alias,
which is generic anyway because it carries a TypeVar
def f[T: Bound](...) -> a module-level TypeVar
Also drop the one @override (3.12, and static-only), and stop the lirc test
harness calling Server.close_clients(), which is 3.13: the scripted handler now
releases its connection when asked, which is what that call was there to force.
Verified on 3.11.14 - 485 passed, mypy strict clean - and still 496 passed on
the 3.14 dev venv, which additionally has the analysis extra.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
158 lines
5.4 KiB
Python
158 lines
5.4 KiB
Python
"""The event bus.
|
|
|
|
Everything in the process is serialised through one FIFO queue on one loop, which is
|
|
what makes "last event wins" a well-defined rule for LED zone arbitration and what
|
|
makes scenario tests deterministic.
|
|
|
|
Handlers may be sync or async; async handlers are awaited, so one event is fully
|
|
handled before the next is dispatched. A handler that raises is logged and does not
|
|
stop the others or the bus.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import inspect
|
|
import logging
|
|
from collections.abc import Callable, Coroutine
|
|
from typing import Any, TypeAlias, TypeVar
|
|
|
|
from musicmouse.events import Event
|
|
|
|
_log = logging.getLogger(__name__)
|
|
|
|
__all__ = ["EventBus", "Handler", "Unsubscribe"]
|
|
|
|
#: An alias carrying a TypeVar is generic on its own, so ``Handler[SomeEvent]`` still
|
|
#: parameterises it the way the PEP 695 form did.
|
|
E = TypeVar("E", bound=Event)
|
|
|
|
Handler: TypeAlias = Callable[[E], Coroutine[Any, Any, None] | None]
|
|
Unsubscribe: TypeAlias = Callable[[], None]
|
|
|
|
|
|
class EventBus:
|
|
def __init__(self) -> None:
|
|
self._handlers: dict[type[Event], list[Handler[Any]]] = {}
|
|
self._wildcard: list[Handler[Any]] = []
|
|
self._resolved: dict[type[Event], tuple[Handler[Any], ...]] = {}
|
|
self._queue: asyncio.Queue[Event] = asyncio.Queue()
|
|
self._loop: asyncio.AbstractEventLoop | None = None
|
|
self._dispatcher: asyncio.Task[None] | None = None
|
|
|
|
# ------------------------------------------------------------------ lifecycle
|
|
|
|
async def start(self) -> None:
|
|
if self._dispatcher is not None:
|
|
return
|
|
self._loop = asyncio.get_running_loop()
|
|
self._dispatcher = asyncio.create_task(self._run(), name="event-bus")
|
|
|
|
async def stop(self) -> None:
|
|
if self._dispatcher is None:
|
|
return
|
|
self._dispatcher.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await self._dispatcher
|
|
self._dispatcher = None
|
|
self._loop = None
|
|
|
|
async def __aenter__(self) -> EventBus:
|
|
await self.start()
|
|
return self
|
|
|
|
async def __aexit__(self, *exc_info: object) -> None:
|
|
await self.stop()
|
|
|
|
# --------------------------------------------------------------- subscription
|
|
|
|
def subscribe(self, event_type: type[E], handler: Handler[E]) -> Unsubscribe:
|
|
"""Register ``handler`` for ``event_type`` and any subclass of it."""
|
|
self._handlers.setdefault(event_type, []).append(handler)
|
|
self._resolved.clear()
|
|
|
|
def unsubscribe() -> None:
|
|
handlers = self._handlers.get(event_type)
|
|
if handlers and handler in handlers:
|
|
handlers.remove(handler)
|
|
self._resolved.clear()
|
|
|
|
return unsubscribe
|
|
|
|
def subscribe_all(self, handler: Handler[Event]) -> Unsubscribe:
|
|
"""Register ``handler`` for every event. Useful for logging and broadcasting."""
|
|
self._wildcard.append(handler)
|
|
|
|
def unsubscribe() -> None:
|
|
if handler in self._wildcard:
|
|
self._wildcard.remove(handler)
|
|
|
|
return unsubscribe
|
|
|
|
# ---------------------------------------------------------------- publication
|
|
|
|
def emit(self, event: Event) -> None:
|
|
"""Queue ``event`` for dispatch. Safe to call from any thread.
|
|
|
|
libVLC fires its callbacks on its own thread; this is where that crossing is
|
|
made safe instead of reaching the serial transport off-loop.
|
|
"""
|
|
loop = self._loop
|
|
if loop is None:
|
|
raise RuntimeError("EventBus.emit() before start()")
|
|
try:
|
|
running = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
running = None
|
|
if running is loop:
|
|
self._queue.put_nowait(event)
|
|
else:
|
|
loop.call_soon_threadsafe(self._queue.put_nowait, event)
|
|
|
|
async def drain(self) -> None:
|
|
"""Wait until every queued event, and everything they emitted, is handled."""
|
|
await self._queue.join()
|
|
|
|
async def emit_and_wait(self, event: Event) -> None:
|
|
self.emit(event)
|
|
await self.drain()
|
|
|
|
# -------------------------------------------------------------------- internals
|
|
|
|
async def _run(self) -> None:
|
|
while True:
|
|
event = await self._queue.get()
|
|
try:
|
|
await self._dispatch(event)
|
|
finally:
|
|
self._queue.task_done()
|
|
|
|
async def _dispatch(self, event: Event) -> None:
|
|
for handler in self._handlers_for(type(event)):
|
|
try:
|
|
result = handler(event)
|
|
if inspect.isawaitable(result):
|
|
await result
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception:
|
|
_log.exception("Handler %s failed on %r", _name(handler), event)
|
|
|
|
def _handlers_for(self, event_type: type[Event]) -> tuple[Handler[Any], ...]:
|
|
cached = self._resolved.get(event_type)
|
|
if cached is None:
|
|
matched: list[Handler[Any]] = []
|
|
for klass in event_type.__mro__:
|
|
if klass is object:
|
|
continue
|
|
matched.extend(self._handlers.get(klass, ()))
|
|
cached = tuple(matched)
|
|
self._resolved[event_type] = cached
|
|
# Wildcards are not cached: they are appended last and change rarely.
|
|
return cached + tuple(self._wildcard)
|
|
|
|
|
|
def _name(handler: Handler[Any]) -> str:
|
|
return getattr(handler, "__qualname__", repr(handler))
|