Skip to content

Commit 3b5ef35

Browse files
authored
feat: class-level listener declarations with setup() protocol (#570)
* feat: class-level listener declarations with setup() protocol Allow listeners to be declared at class definition time via a `listeners` attribute on StateChart/StateMachine. The list accepts callables (classes, partial, lambdas) as per-instance factories and pre-built instances as shared listeners. - Metaclass collects `_class_listeners` from attrs and MRO - `listeners_inherit = False` to replace instead of extend parent listeners - `setup(sm, **kwargs)` protocol for runtime dependency injection - `active_listeners` public property to inspect attached listeners - Serialization correctly preserves all listeners through pickle/copy
1 parent 7faf7fa commit 3b5ef35

5 files changed

Lines changed: 741 additions & 3 deletions

File tree

docs/listeners.md

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,194 @@ Paulista Avenue after: red--(cycle)-->green
8787
```
8888

8989

90+
## Class-level listener declarations
91+
92+
```{versionadded} 3.0.0
93+
```
94+
95+
You can declare listeners at the class level so they are automatically attached to every
96+
instance of the state machine. This is useful for cross-cutting concerns like logging,
97+
persistence, or telemetry that should always be present.
98+
99+
The `listeners` class attribute accepts two forms:
100+
101+
- **Callable** (class, `functools.partial`, lambda): acts as a factory — called once per
102+
SM instance to produce a fresh listener. Use this for listeners that accumulate state.
103+
- **Instance** (pre-built object): shared across all SM instances. Use this for stateless
104+
listeners like a global logger.
105+
106+
```py
107+
>>> from statemachine import State, StateChart
108+
109+
>>> class AuditListener:
110+
... def __init__(self):
111+
... self.log = []
112+
...
113+
... def after_transition(self, event, source, target):
114+
... self.log.append(f"{event}: {source.id} -> {target.id}")
115+
116+
>>> class OrderMachine(StateChart):
117+
... listeners = [AuditListener]
118+
...
119+
... draft = State(initial=True)
120+
... confirmed = State(final=True)
121+
... confirm = draft.to(confirmed)
122+
123+
>>> sm = OrderMachine()
124+
>>> sm.send("confirm")
125+
>>> [type(l).__name__ for l in sm.active_listeners]
126+
['AuditListener']
127+
128+
>>> sm.active_listeners[0].log
129+
['confirm: draft -> confirmed']
130+
131+
```
132+
133+
### Listeners with configuration
134+
135+
Use `functools.partial` to pass configuration to listener factories:
136+
137+
```py
138+
>>> from functools import partial
139+
140+
>>> class HistoryListener:
141+
... def __init__(self, max_size=50):
142+
... self.max_size = max_size
143+
... self.entries = []
144+
...
145+
... def after_transition(self, event, source, target):
146+
... self.entries.append(f"{source.id} -> {target.id}")
147+
... if len(self.entries) > self.max_size:
148+
... self.entries.pop(0)
149+
150+
>>> class TrackedMachine(StateChart):
151+
... listeners = [partial(HistoryListener, max_size=10)]
152+
...
153+
... s1 = State(initial=True)
154+
... s2 = State(final=True)
155+
... go = s1.to(s2)
156+
157+
>>> sm = TrackedMachine()
158+
>>> sm.send("go")
159+
>>> sm.active_listeners[0].entries
160+
['s1 -> s2']
161+
162+
```
163+
164+
### Runtime listeners merge with class-level
165+
166+
Runtime listeners passed via the `listeners=` constructor parameter are appended after
167+
class-level listeners:
168+
169+
```py
170+
>>> runtime_listener = AuditListener()
171+
>>> sm = OrderMachine(listeners=[runtime_listener])
172+
>>> sm.send("confirm")
173+
>>> [type(l).__name__ for l in sm.active_listeners]
174+
['AuditListener', 'AuditListener']
175+
176+
>>> runtime_listener.log
177+
['confirm: draft -> confirmed']
178+
179+
```
180+
181+
### Inheritance
182+
183+
Child class listeners are appended after parent listeners. The full MRO chain is respected:
184+
185+
```py
186+
>>> class LogListener:
187+
... pass
188+
189+
>>> class BaseMachine(StateChart):
190+
... listeners = [LogListener]
191+
...
192+
... s1 = State(initial=True)
193+
... s2 = State(final=True)
194+
... go = s1.to(s2)
195+
196+
>>> class ChildMachine(BaseMachine):
197+
... listeners = [AuditListener]
198+
199+
>>> sm = ChildMachine()
200+
>>> [type(l).__name__ for l in sm.active_listeners]
201+
['LogListener', 'AuditListener']
202+
203+
```
204+
205+
To **replace** parent listeners instead of extending, set `listeners_inherit = False`:
206+
207+
```py
208+
>>> class ReplacedMachine(BaseMachine):
209+
... listeners_inherit = False
210+
... listeners = [AuditListener]
211+
212+
>>> sm = ReplacedMachine()
213+
>>> [type(l).__name__ for l in sm.active_listeners]
214+
['AuditListener']
215+
216+
```
217+
218+
### Listener `setup()` protocol
219+
220+
Listeners that need runtime dependencies (e.g., a database session, Redis client) can
221+
define a `setup()` method. It is called during SM `__init__` with the SM instance and
222+
any extra `**kwargs` passed to the constructor. The {ref}`dynamic-dispatch` mechanism
223+
ensures each listener receives only the kwargs it declares:
224+
225+
```py
226+
>>> class DBListener:
227+
... def __init__(self):
228+
... self.session = None
229+
...
230+
... def setup(self, sm, session=None, **kwargs):
231+
... self.session = session
232+
233+
>>> class PersistentMachine(StateChart):
234+
... listeners = [DBListener]
235+
...
236+
... s1 = State(initial=True)
237+
... s2 = State(final=True)
238+
... go = s1.to(s2)
239+
240+
>>> sm = PersistentMachine(session="my_db_session")
241+
>>> sm.active_listeners[0].session
242+
'my_db_session'
243+
244+
```
245+
246+
Multiple listeners with different dependencies compose naturally — each `setup()` picks
247+
only the kwargs it needs:
248+
249+
```py
250+
>>> class CacheListener:
251+
... def __init__(self):
252+
... self.redis = None
253+
...
254+
... def setup(self, sm, redis=None, **kwargs):
255+
... self.redis = redis
256+
257+
>>> class FullMachine(StateChart):
258+
... listeners = [DBListener, CacheListener]
259+
...
260+
... s1 = State(initial=True)
261+
... s2 = State(final=True)
262+
... go = s1.to(s2)
263+
264+
>>> sm = FullMachine(session="db_conn", redis="redis_conn")
265+
>>> sm.active_listeners[0].session
266+
'db_conn'
267+
>>> sm.active_listeners[1].redis
268+
'redis_conn'
269+
270+
```
271+
272+
```{note}
273+
The `setup()` method is only called on **factory-created** instances (callable entries).
274+
Shared instances (pre-built objects) do not receive `setup()` calls — they are assumed
275+
to be already configured by whoever created them.
276+
```
277+
90278
```{hint}
91279
The `StateChart` itself is registered as a listener, so by using `listeners` an
92280
external object can have the same level of functionalities provided to the built-in class.

docs/releases/3.0.0.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,21 @@ class GameCharacter(StateChart):
410410
See {ref}`weighted-transitions` for full documentation.
411411

412412

413+
### Class-level listener declarations
414+
415+
Listeners can now be declared at the class level using the `listeners` attribute, so they are
416+
automatically attached to every instance. The list accepts callables (classes, `partial`, lambdas)
417+
as factories that create a fresh listener per instance, or pre-built instances that are shared.
418+
419+
A `setup()` protocol allows factory-created listeners to receive runtime dependencies
420+
(DB sessions, Redis clients, etc.) via `**kwargs` forwarded from the SM constructor.
421+
422+
Inheritance is supported: child listeners are appended after parent listeners, unless
423+
`listeners_inherit = False` is set to replace them entirely.
424+
425+
See {ref}`observers` for full documentation.
426+
427+
413428
### Async concurrent event result routing
414429

415430
When multiple coroutines send events concurrently via `asyncio.gather`, each

statemachine/factory.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ def __init__(
6060
)
6161
cls.add_inherited(bases)
6262
cls.add_from_attributes(attrs)
63+
cls._collect_class_listeners(attrs, bases)
6364
cls._unpack_builders_callbacks()
6465
cls._update_event_references()
6566

@@ -233,6 +234,27 @@ def _setup(cls):
233234
"send",
234235
} | {s.id for s in cls.states}
235236

237+
def _collect_class_listeners(cls, attrs: Dict[str, Any], bases: Tuple[type]):
238+
"""Collect class-level listener declarations from attrs and MRO.
239+
240+
Listeners declared on parent classes are prepended (MRO order),
241+
unless the child sets ``listeners_inherit = False``.
242+
"""
243+
class_listeners: List[Any] = []
244+
if attrs.get("listeners_inherit", True):
245+
for base in reversed(bases):
246+
class_listeners.extend(getattr(base, "_class_listeners", []))
247+
for entry in attrs.get("listeners", []):
248+
if entry is None or isinstance(entry, (str, int, float, bool)):
249+
raise InvalidDefinition(
250+
_(
251+
"Invalid entry in 'listeners': {!r}. "
252+
"Expected a class, callable, or listener instance."
253+
).format(entry)
254+
)
255+
class_listeners.append(entry)
256+
cls._class_listeners: List[Any] = class_listeners
257+
236258
def add_inherited(cls, bases):
237259
for base in bases:
238260
for state in getattr(base, "states", []):

statemachine/statemachine.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from .graph import iterate_states_and_transitions
3030
from .i18n import _
3131
from .model import Model
32+
from .signature import SignatureAdapter
3233
from .utils import run_async_from_sync
3334

3435
if TYPE_CHECKING:
@@ -129,6 +130,7 @@ class StateChart(Generic[TModel], metaclass=StateMachineMetaclass):
129130
_events: "Dict[Event, None]"
130131
_protected_attrs: set
131132
_specs: CallbackSpecList
133+
_class_listeners: List[Any]
132134
prepare: SpecListGrouper
133135

134136
def __init__(
@@ -137,6 +139,7 @@ def __init__(
137139
state_field: str = "state",
138140
start_value: Any = None,
139141
listeners: "List[object] | None" = None,
142+
**kwargs: Any,
140143
):
141144
self.model: TModel = model if model is not None else Model() # type: ignore[assignment]
142145
self.history_values: Dict[
@@ -154,7 +157,9 @@ def __init__(
154157
if self._abstract:
155158
raise InvalidDefinition(_("There are no states or transitions."))
156159

157-
self._register_callbacks(listeners or [])
160+
class_listener_instances = self._resolve_class_listeners(**kwargs)
161+
all_listeners = class_listener_instances + (listeners or [])
162+
self._register_callbacks(all_listeners)
158163

159164
# Activate the initial state, this only works if the outer scope is sync code.
160165
# for async code, the user should manually call `await sm.activate_initial_state()`
@@ -168,6 +173,26 @@ def _get_engine(self):
168173

169174
return SyncEngine(self)
170175

176+
def _resolve_class_listeners(self, **kwargs: Any) -> List[object]:
177+
resolved: List[object] = []
178+
for entry in self._class_listeners:
179+
if callable(entry):
180+
instance = entry()
181+
setup = getattr(instance, "setup", None)
182+
if setup is not None:
183+
sig = SignatureAdapter.from_callable(setup)
184+
ba = sig.bind_expected(self, **kwargs)
185+
try:
186+
setup(*ba.args, **ba.kwargs)
187+
except TypeError as err:
188+
raise TypeError(
189+
f"Error calling setup() on listener {type(instance).__name__}: {err}"
190+
) from err
191+
else:
192+
instance = entry
193+
resolved.append(instance)
194+
return resolved
195+
171196
def activate_initial_state(self) -> Any:
172197
result = self._engine.activate_initial_state()
173198
if not isawaitable(result):
@@ -199,11 +224,13 @@ def __setstate__(self, state: Dict[str, Any]) -> None:
199224
self.__dict__.update(state) # type: ignore[attr-defined]
200225
self._callbacks = CallbacksRegistry()
201226
self._states_for_instance = {}
202-
203227
self._listeners = {}
204228

229+
# _listeners already contained both class-level and runtime listeners
230+
# when serialized, so just re-register them all.
205231
self._register_callbacks([])
206-
self.add_listener(*listeners.values())
232+
if listeners:
233+
self.add_listener(*listeners.values())
207234
self._engine = self._get_engine()
208235
self._engine.start()
209236

@@ -268,6 +295,15 @@ def _register_callbacks(self, listeners: List[object]):
268295

269296
self._callbacks.async_or_sync()
270297

298+
@property
299+
def active_listeners(self) -> List[object]:
300+
"""List of all active listeners attached to this instance.
301+
302+
Includes class-level listeners (resolved from the ``listeners`` class attribute),
303+
constructor ``listeners=`` parameter, and any added via :meth:`add_listener`.
304+
"""
305+
return list(self._listeners.values())
306+
271307
def add_listener(self, *listeners):
272308
"""Add a listener.
273309

0 commit comments

Comments
 (0)