jmri_core.events
Lightweight async-friendly signal/slot event bus.
Replaces JMRI's PropertyChangeSupport / PropertyChangeListener
machinery. A Signal is a fan-out of zero or more listeners; emitting
delivers the payload to every listener synchronously, awaiting any that
return a coroutine.
Design choices:
- No global event bus — every emitter owns its signals as instance attributes. This keeps coupling explicit and lets tests construct beans in isolation.
- Listeners are weakly not held — callers are responsible for the lifetime
of their callbacks (return value of
Signal.connect()is a disconnect function). Keeping a strong reference avoids the surprise behaviour where bound-method listeners disappear because nothing else holds the receiver. - Errors in one listener don't suppress delivery to the rest; the first exception is collected and re-raised after every listener has run, mirroring asyncio.gather semantics.
1"""Lightweight async-friendly signal/slot event bus. 2 3Replaces JMRI's ``PropertyChangeSupport`` / ``PropertyChangeListener`` 4machinery. A :class:`Signal` is a fan-out of zero or more listeners; emitting 5delivers the payload to every listener synchronously, awaiting any that 6return a coroutine. 7 8Design choices: 9 10* No global event bus — every emitter owns its signals as instance attributes. 11 This keeps coupling explicit and lets tests construct beans in isolation. 12* Listeners are weakly *not* held — callers are responsible for the lifetime 13 of their callbacks (return value of :meth:`Signal.connect` is a disconnect 14 function). Keeping a strong reference avoids the surprise behaviour where 15 bound-method listeners disappear because nothing else holds the receiver. 16* Errors in one listener don't suppress delivery to the rest; the first 17 exception is collected and re-raised after every listener has run, mirroring 18 asyncio.gather semantics. 19""" 20from __future__ import annotations 21 22import asyncio 23import contextlib 24import inspect 25from collections.abc import Awaitable, Callable 26 27type Listener[T] = Callable[[T], None] | Callable[[T], Awaitable[None]] 28 29 30class Signal[T]: 31 """An emitter with zero or more listeners. See module docstring.""" 32 33 __slots__ = ("_listeners",) 34 35 def __init__(self) -> None: 36 self._listeners: list[Listener[T]] = [] 37 38 def connect(self, listener: Listener[T]) -> Callable[[], None]: 39 """Register *listener*. Returns a callable that disconnects it. 40 41 ``listener`` may be a sync or async callable taking one argument. 42 Returns a zero-arg disconnect function. 43 """ 44 self._listeners.append(listener) 45 46 def disconnect() -> None: 47 # Idempotent: removing twice is a no-op. 48 with contextlib.suppress(ValueError): 49 self._listeners.remove(listener) 50 51 return disconnect 52 53 def disconnect_all(self) -> None: 54 """Drop every listener. Useful in teardown.""" 55 self._listeners.clear() 56 57 @property 58 def listener_count(self) -> int: 59 return len(self._listeners) 60 61 async def emit(self, payload: T) -> None: 62 """Deliver *payload* to every connected listener. 63 64 Sync listeners run inline. Async listeners are awaited sequentially in 65 registration order — emission preserves ordering, which beans rely on 66 for state-change semantics (commanded-state notifications must arrive 67 before known-state notifications). If you need concurrency, use 68 :meth:`emit_concurrent`. 69 """ 70 first_error: BaseException | None = None 71 # Snapshot the list so listeners that disconnect during dispatch don't 72 # mutate the iteration. 73 for listener in list(self._listeners): 74 try: 75 result = listener(payload) 76 if inspect.iscoroutine(result): 77 await result 78 except BaseException as e: # noqa: BLE001 — re-raised below 79 if first_error is None: 80 first_error = e 81 if first_error is not None: 82 raise first_error 83 84 async def emit_concurrent(self, payload: T) -> None: 85 """Fan out to every async listener concurrently via ``asyncio.gather``. 86 87 Sync listeners still run inline before dispatching the async batch. 88 Use only when listeners are explicitly independent. 89 """ 90 async_results: list[Awaitable[None]] = [] 91 for listener in list(self._listeners): 92 result = listener(payload) 93 if inspect.iscoroutine(result): 94 async_results.append(result) 95 if async_results: 96 await asyncio.gather(*async_results)
31class Signal[T]: 32 """An emitter with zero or more listeners. See module docstring.""" 33 34 __slots__ = ("_listeners",) 35 36 def __init__(self) -> None: 37 self._listeners: list[Listener[T]] = [] 38 39 def connect(self, listener: Listener[T]) -> Callable[[], None]: 40 """Register *listener*. Returns a callable that disconnects it. 41 42 ``listener`` may be a sync or async callable taking one argument. 43 Returns a zero-arg disconnect function. 44 """ 45 self._listeners.append(listener) 46 47 def disconnect() -> None: 48 # Idempotent: removing twice is a no-op. 49 with contextlib.suppress(ValueError): 50 self._listeners.remove(listener) 51 52 return disconnect 53 54 def disconnect_all(self) -> None: 55 """Drop every listener. Useful in teardown.""" 56 self._listeners.clear() 57 58 @property 59 def listener_count(self) -> int: 60 return len(self._listeners) 61 62 async def emit(self, payload: T) -> None: 63 """Deliver *payload* to every connected listener. 64 65 Sync listeners run inline. Async listeners are awaited sequentially in 66 registration order — emission preserves ordering, which beans rely on 67 for state-change semantics (commanded-state notifications must arrive 68 before known-state notifications). If you need concurrency, use 69 :meth:`emit_concurrent`. 70 """ 71 first_error: BaseException | None = None 72 # Snapshot the list so listeners that disconnect during dispatch don't 73 # mutate the iteration. 74 for listener in list(self._listeners): 75 try: 76 result = listener(payload) 77 if inspect.iscoroutine(result): 78 await result 79 except BaseException as e: # noqa: BLE001 — re-raised below 80 if first_error is None: 81 first_error = e 82 if first_error is not None: 83 raise first_error 84 85 async def emit_concurrent(self, payload: T) -> None: 86 """Fan out to every async listener concurrently via ``asyncio.gather``. 87 88 Sync listeners still run inline before dispatching the async batch. 89 Use only when listeners are explicitly independent. 90 """ 91 async_results: list[Awaitable[None]] = [] 92 for listener in list(self._listeners): 93 result = listener(payload) 94 if inspect.iscoroutine(result): 95 async_results.append(result) 96 if async_results: 97 await asyncio.gather(*async_results)
An emitter with zero or more listeners. See module docstring.
39 def connect(self, listener: Listener[T]) -> Callable[[], None]: 40 """Register *listener*. Returns a callable that disconnects it. 41 42 ``listener`` may be a sync or async callable taking one argument. 43 Returns a zero-arg disconnect function. 44 """ 45 self._listeners.append(listener) 46 47 def disconnect() -> None: 48 # Idempotent: removing twice is a no-op. 49 with contextlib.suppress(ValueError): 50 self._listeners.remove(listener) 51 52 return disconnect
Register listener. Returns a callable that disconnects it.
listener may be a sync or async callable taking one argument.
Returns a zero-arg disconnect function.
54 def disconnect_all(self) -> None: 55 """Drop every listener. Useful in teardown.""" 56 self._listeners.clear()
Drop every listener. Useful in teardown.
62 async def emit(self, payload: T) -> None: 63 """Deliver *payload* to every connected listener. 64 65 Sync listeners run inline. Async listeners are awaited sequentially in 66 registration order — emission preserves ordering, which beans rely on 67 for state-change semantics (commanded-state notifications must arrive 68 before known-state notifications). If you need concurrency, use 69 :meth:`emit_concurrent`. 70 """ 71 first_error: BaseException | None = None 72 # Snapshot the list so listeners that disconnect during dispatch don't 73 # mutate the iteration. 74 for listener in list(self._listeners): 75 try: 76 result = listener(payload) 77 if inspect.iscoroutine(result): 78 await result 79 except BaseException as e: # noqa: BLE001 — re-raised below 80 if first_error is None: 81 first_error = e 82 if first_error is not None: 83 raise first_error
Deliver payload to every connected listener.
Sync listeners run inline. Async listeners are awaited sequentially in
registration order — emission preserves ordering, which beans rely on
for state-change semantics (commanded-state notifications must arrive
before known-state notifications). If you need concurrency, use
emit_concurrent().
85 async def emit_concurrent(self, payload: T) -> None: 86 """Fan out to every async listener concurrently via ``asyncio.gather``. 87 88 Sync listeners still run inline before dispatching the async batch. 89 Use only when listeners are explicitly independent. 90 """ 91 async_results: list[Awaitable[None]] = [] 92 for listener in list(self._listeners): 93 result = listener(payload) 94 if inspect.iscoroutine(result): 95 async_results.append(result) 96 if async_results: 97 await asyncio.gather(*async_results)
Fan out to every async listener concurrently via asyncio.gather.
Sync listeners still run inline before dispatching the async batch. Use only when listeners are explicitly independent.