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)
type Listener = Callable[[T], None] | Callable[[T], Awaitable[None]]
class Signal(typing.Generic[T]):
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.

def connect(self, listener: 'Listener[T]') -> Callable[[], None]:
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.

def disconnect_all(self) -> None:
54    def disconnect_all(self) -> None:
55        """Drop every listener. Useful in teardown."""
56        self._listeners.clear()

Drop every listener. Useful in teardown.

listener_count: int
58    @property
59    def listener_count(self) -> int:
60        return len(self._listeners)
async def emit(self, payload: 'T') -> None:
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().

async def emit_concurrent(self, payload: 'T') -> None:
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.