Source code for simvx.core.event_bus
"""Typed event bus keyed on dataclass event types.
Handlers subscribe against a *dataclass* event type. Calling
:meth:`EventBus.publish` with an instance of that dataclass synchronously
fans the event out to every live handler subscribed for the *exact* class.
There is no MRO walk: a handler for ``Parent`` does NOT receive publishes
of ``Child(Parent)``; subclasses must subscribe themselves. This keeps
dispatch predictable and O(1) per event class.
Vocabulary (subscribe/publish/unsubscribe) is intentionally distinct from
:class:`Signal`'s connect/emit/disconnect: an ``EventBus`` is a typed pub/sub
hub, while a Signal is a per-instance callback list. Different shapes, so
different verbs.
Each event class has its own :class:`Signal`, so a handler's lifetime follows
the signal rule:
* A bound method of a :class:`Node` is held weakly, and its subscription ends
when the node is destroyed (:meth:`Node.destroy`, or ``change_scene``, which
destroys the scene it replaces) or garbage-collected. Leaving the tree
without being destroyed (``remove_child``) does not end it.
* Every other callable (a lambda, a closure, a :class:`functools.partial`, a
free function, a method of a plain object) is held strongly and fires until
it is unsubscribed, even when it captures a node that was destroyed.
Two dispatch modes:
* :meth:`publish`: synchronous. Handlers run in subscription order on the
calling thread before ``publish`` returns.
* :meth:`publish_deferred`: queues the event. The queue is drained when
:meth:`flush_deferred` is called (typically once per frame).
Threading: the bus assumes a single-threaded engine. There are no locks.
Calling ``publish`` from a non-main thread is undefined behaviour.
"""
from __future__ import annotations
import dataclasses
from collections.abc import Callable
from typing import Any
from .signals import Signal
EventCls = type
Handler = Callable[[Any], None]
[docs]
class EventBus:
"""Typed publish/subscribe bus keyed on dataclass event types."""
__slots__ = ("_subs", "_deferred")
def __init__(self) -> None:
self._subs: dict[EventCls, Signal] = {}
self._deferred: list[Any] = []
# ------------------------------------------------------------------
# Subscription
# ------------------------------------------------------------------
[docs]
def subscribe(self, event_cls: EventCls, handler: Handler) -> None:
"""Register ``handler`` for events of exactly ``event_cls``.
A bound method of a :class:`Node` is held weakly and dropped when the
node is destroyed or collected; any other callable is held until
:meth:`unsubscribe`.
Subscribing a handler that is already subscribed does nothing.
"""
if not callable(handler):
raise TypeError(f"EventBus handler must be callable, got {type(handler).__name__!r}")
signal = self._subs.get(event_cls)
if signal is None:
signal = self._subs[event_cls] = Signal(event_cls)
elif signal._connection_for(handler) is not None:
return
signal.connect(handler)
[docs]
def unsubscribe(self, event_cls: EventCls, handler: Handler) -> None:
"""Remove ``handler`` from ``event_cls``. Idempotent.
``handler`` matches by equality, so a freshly bound method finds the
subscription made with an earlier binding of the same method.
"""
signal = self._subs.get(event_cls)
if signal is None:
return
signal.disconnect(handler)
if not signal._callbacks:
del self._subs[event_cls]
[docs]
def disconnect_from_module(self, module_name: str) -> int:
"""Drop every handler defined in ``module_name``; return how many.
Hot reload calls this before reloading a module, so the old version of
a handler stops firing and the old module can be collected.
"""
dropped = 0
for event_cls, signal in list(self._subs.items()):
dropped += signal.disconnect_from_module(module_name)
if not signal._callbacks:
del self._subs[event_cls]
return dropped
# ------------------------------------------------------------------
# Dispatch
# ------------------------------------------------------------------
[docs]
def publish(self, event: Any) -> None:
"""Synchronously fan ``event`` out to handlers of its exact type.
Raises :class:`TypeError` if ``event`` is not a dataclass instance.
Handlers run in subscription order. Subscriptions of collected nodes
are pruned during dispatch.
Unsubscribing takes effect immediately, as it does for a
:class:`Signal`: a handler removed by an earlier handler in this same
dispatch is not called. Unsubscribing mid-dispatch nearly always means
the subscriber is being torn down, so calling it anyway is wrong.
"""
if not dataclasses.is_dataclass(event) or isinstance(event, type):
raise TypeError(f"EventBus.publish requires a dataclass instance, got {type(event).__name__!r}")
event_cls = type(event)
signal = self._subs.get(event_cls)
if signal is None:
return
signal.emit(event)
if not signal._callbacks and self._subs.get(event_cls) is signal:
del self._subs[event_cls]
[docs]
def publish_deferred(self, event: Any) -> None:
"""Queue ``event`` for the next :meth:`flush_deferred` call."""
if not dataclasses.is_dataclass(event) or isinstance(event, type):
raise TypeError(f"EventBus.publish_deferred requires a dataclass instance, got {type(event).__name__!r}")
self._deferred.append(event)
[docs]
def flush_deferred(self) -> None:
"""Drain the deferred queue, dispatching each event via :meth:`publish`."""
if not self._deferred:
return
# Swap-and-clear so handlers that publish_deferred during flush land
# on the next flush, not the current one.
pending = self._deferred
self._deferred = []
for event in pending:
self.publish(event)