Skip to content

Commit 6d19238

Browse files
committed
docs(listeners): rewrite with progressive narrative
Reorganize listeners.md for a natural learning flow: - Intro with architectural insight (StateChart/models are listeners) - Defining a listener (any object with matching methods) - Class-level declarations first (declarative, most common) - Constructor attachment second, runtime add_listener third - setup() protocol promoted to its own section - All examples self-contained (no external test imports)
1 parent 16b4f27 commit 6d19238

1 file changed

Lines changed: 130 additions & 147 deletions

File tree

docs/listeners.md

Lines changed: 130 additions & 147 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,40 @@
1-
21
(observers)=
32
(listeners)=
3+
44
# Listeners
55

6-
Listeners are a way to generically add behavior to a state machine without
7-
changing its internal implementation.
6+
A **listener** is an external object that observes a state machine's lifecycle
7+
without modifying its class definition. Listeners receive the same
8+
{ref}`generic callbacks <actions>` as the state machine itself —
9+
`on_enter_state()`, `after_transition()`, `on_exit_state()`, and so on —
10+
making them ideal for cross-cutting concerns like logging, persistence,
11+
telemetry, or UI updates.
12+
13+
Under the hood, the `StateChart` class itself is registered as a listener —
14+
this is how naming-convention callbacks like `on_enter_idle()` are
15+
discovered. {ref}`Domain models <models>` are also registered as listeners.
16+
This means that an external listener has the **same level of access** to
17+
callbacks as methods defined directly on the state machine class.
18+
19+
```{tip}
20+
Why use a listener instead of defining callbacks directly on the class?
21+
Listeners keep concerns **separate and reusable** — the same logging
22+
listener can observe any state machine, and you can attach multiple
23+
independent listeners without them interfering with each other.
24+
```
825

9-
One possible use case is to add a listener that prints a log message when the SM runs a
10-
transition or enters a new state.
1126

12-
Giving the {ref}`sphx_glr_auto_examples_traffic_light_machine.py` as example:
27+
## Defining a listener
1328

29+
A listener is any object with methods that match the
30+
{ref}`callback naming conventions <actions>`. The library inspects the
31+
method signatures and calls them with {ref}`dependency injection <dependency-injection>`,
32+
so each listener receives only the parameters it declares:
1433

1534
```py
16-
>>> from tests.examples.traffic_light_machine import TrafficLightMachine
35+
>>> from statemachine import State, StateChart
1736

18-
>>> class LogListener(object):
37+
>>> class LogListener:
1938
... def __init__(self, name):
2039
... self.name = name
2140
...
@@ -25,88 +44,19 @@ Giving the {ref}`sphx_glr_auto_examples_traffic_light_machine.py` as example:
2544
... def on_enter_state(self, target, event):
2645
... print(f"{self.name} enter: {target.id} from {event}")
2746

28-
29-
>>> sm = TrafficLightMachine(listeners=[LogListener("Paulista Avenue")])
30-
Paulista Avenue enter: green from __initial__
31-
32-
>>> sm.cycle()
33-
Running cycle from green to yellow
34-
Paulista Avenue enter: yellow from cycle
35-
Paulista Avenue after: green--(cycle)-->yellow
36-
3747
```
3848

39-
## Adding listeners to an instance
49+
No base class or interface is required — any object with matching method
50+
names works.
4051

41-
Attach listeners to an already running state machine instance using `add_listener`.
4252

43-
Exploring our example, imagine that you can implement the LED panel as a listener, that
44-
reacts to state changes and turn on/off automatically.
53+
## Class-level declarations
4554

46-
47-
``` py
48-
>>> class LedPanel:
49-
...
50-
... def __init__(self, color: str):
51-
... self.color = color
52-
...
53-
... def on_enter_state(self, target: State):
54-
... if target.id == self.color:
55-
... print(f"{self.color} turning on")
56-
...
57-
... def on_exit_state(self, source: State):
58-
... if source.id == self.color:
59-
... print(f"{self.color} turning off")
60-
61-
```
62-
63-
Adding a listener for each traffic light indicator
64-
65-
```
66-
>>> sm.add_listener(LedPanel("green"), LedPanel("yellow"), LedPanel("red")) # doctest: +ELLIPSIS
67-
TrafficLightMachine...
68-
69-
```
70-
71-
Now each "LED panel" reacts to changes in state from the state machine:
55+
The most common way to attach listeners is at the class level, using the
56+
`listeners` class attribute. This ensures listeners are automatically
57+
created for every instance:
7258

7359
```py
74-
>>> sm.cycle()
75-
Running cycle from yellow to red
76-
yellow turning off
77-
Paulista Avenue enter: red from cycle
78-
red turning on
79-
Paulista Avenue after: yellow--(cycle)-->red
80-
81-
>>> sm.cycle()
82-
Running cycle from red to green
83-
red turning off
84-
Paulista Avenue enter: green from cycle
85-
green turning on
86-
Paulista Avenue after: red--(cycle)-->green
87-
88-
```
89-
90-
91-
## Class-level listener declarations
92-
93-
```{versionadded} 3.0.0
94-
```
95-
96-
You can declare listeners at the class level so they are automatically attached to every
97-
instance of the state machine. This is useful for cross-cutting concerns like logging,
98-
persistence, or telemetry that should always be present.
99-
100-
The `listeners` class attribute accepts two forms:
101-
102-
- **Callable** (class, `functools.partial`, lambda): acts as a factory — called once per
103-
SM instance to produce a fresh listener. Use this for listeners that accumulate state.
104-
- **Instance** (pre-built object): shared across all SM instances. Use this for stateless
105-
listeners like a global logger.
106-
107-
```py
108-
>>> from statemachine import State, StateChart
109-
11060
>>> class AuditListener:
11161
... def __init__(self):
11262
... self.log = []
@@ -123,15 +73,21 @@ The `listeners` class attribute accepts two forms:
12373

12474
>>> sm = OrderMachine()
12575
>>> sm.send("confirm")
126-
>>> [type(l).__name__ for l in sm.active_listeners]
127-
['AuditListener']
128-
12976
>>> sm.active_listeners[0].log
13077
['confirm: draft -> confirmed']
13178

13279
```
13380

134-
### Listeners with configuration
81+
The `listeners` attribute accepts two forms:
82+
83+
- **Callable** (class, `functools.partial`, lambda): acts as a **factory**
84+
called once per instance to produce a fresh listener. Use this for
85+
listeners that accumulate state.
86+
- **Instance** (pre-built object): **shared** across all instances. Use
87+
this for stateless listeners like a global logger.
88+
89+
90+
### Configuration with `functools.partial`
13591

13692
Use `functools.partial` to pass configuration to listener factories:
13793

@@ -162,33 +118,19 @@ Use `functools.partial` to pass configuration to listener factories:
162118

163119
```
164120

165-
### Runtime listeners merge with class-level
166-
167-
Runtime listeners passed via the `listeners=` constructor parameter are appended after
168-
class-level listeners:
169-
170-
```py
171-
>>> runtime_listener = AuditListener()
172-
>>> sm = OrderMachine(listeners=[runtime_listener])
173-
>>> sm.send("confirm")
174-
>>> [type(l).__name__ for l in sm.active_listeners]
175-
['AuditListener', 'AuditListener']
176-
177-
>>> runtime_listener.log
178-
['confirm: draft -> confirmed']
179-
180-
```
181121

182122
### Inheritance
183123

184-
Child class listeners are appended after parent listeners. The full MRO chain is respected:
124+
Child class listeners are appended after parent listeners. The full MRO
125+
chain is respected:
185126

186127
```py
187-
>>> class LogListener:
188-
... pass
128+
>>> class SimpleLogListener:
129+
... def after_transition(self, event, source, target):
130+
... pass
189131

190132
>>> class BaseMachine(StateChart):
191-
... listeners = [LogListener]
133+
... listeners = [SimpleLogListener]
192134
...
193135
... s1 = State(initial=True)
194136
... s2 = State(final=True)
@@ -199,11 +141,12 @@ Child class listeners are appended after parent listeners. The full MRO chain is
199141

200142
>>> sm = ChildMachine()
201143
>>> [type(l).__name__ for l in sm.active_listeners]
202-
['LogListener', 'AuditListener']
144+
['SimpleLogListener', 'AuditListener']
203145

204146
```
205147

206-
To **replace** parent listeners instead of extending, set `listeners_inherit = False`:
148+
To **replace** parent listeners instead of extending, set
149+
`listeners_inherit = False`:
207150

208151
```py
209152
>>> class ReplacedMachine(BaseMachine):
@@ -216,79 +159,119 @@ To **replace** parent listeners instead of extending, set `listeners_inherit = F
216159

217160
```
218161

219-
### Listener `setup()` protocol
220162

221-
Listeners that need runtime dependencies (e.g., a database session, Redis client) can
222-
define a `setup()` method. It is called during SM `__init__` with the SM instance and
223-
any extra `**kwargs` passed to the constructor. The {ref}`dynamic-dispatch` mechanism
224-
ensures each listener receives only the kwargs it declares:
163+
## Attaching at construction
164+
165+
Pass listeners to the constructor for instance-specific observers.
166+
Runtime listeners are appended **after** class-level listeners:
225167

226168
```py
227-
>>> class DBListener:
228-
... def __init__(self):
229-
... self.session = None
169+
>>> runtime_listener = AuditListener()
170+
>>> sm = OrderMachine(listeners=[runtime_listener])
171+
>>> sm.send("confirm")
172+
>>> [type(l).__name__ for l in sm.active_listeners]
173+
['AuditListener', 'AuditListener']
174+
175+
>>> runtime_listener.log
176+
['confirm: draft -> confirmed']
177+
178+
```
179+
180+
181+
## Attaching at runtime
182+
183+
Use `add_listener()` to attach listeners to an already running instance.
184+
This is useful when the listener depends on runtime context or when you
185+
want to start observing after initialization:
186+
187+
```py
188+
>>> class LedPanel:
189+
... def __init__(self, color):
190+
... self.color = color
191+
... self.is_on = False
230192
...
231-
... def setup(self, sm, session=None, **kwargs):
232-
... self.session = session
193+
... def on_enter_state(self, target, **kwargs):
194+
... if target.id == self.color:
195+
... self.is_on = True
196+
...
197+
... def on_exit_state(self, source, **kwargs):
198+
... if source.id == self.color:
199+
... self.is_on = False
233200

234-
>>> class PersistentMachine(StateChart):
235-
... listeners = [DBListener]
201+
>>> class TrafficLight(StateChart):
202+
... green = State(initial=True)
203+
... yellow = State()
204+
... red = State()
236205
...
237-
... s1 = State(initial=True)
238-
... s2 = State(final=True)
239-
... go = s1.to(s2)
206+
... cycle = green.to(yellow) | yellow.to(red) | red.to(green)
240207

241-
>>> sm = PersistentMachine(session="my_db_session")
242-
>>> sm.active_listeners[0].session
243-
'my_db_session'
208+
>>> sm = TrafficLight()
209+
>>> green_led = LedPanel("green")
210+
>>> yellow_led = LedPanel("yellow")
211+
>>> sm.add_listener(green_led, yellow_led) # doctest: +ELLIPSIS
212+
TrafficLight...
213+
214+
>>> green_led.is_on, yellow_led.is_on
215+
(False, False)
216+
217+
>>> sm.send("cycle")
218+
>>> green_led.is_on, yellow_led.is_on
219+
(False, True)
244220

245221
```
246222

247-
Multiple listeners with different dependencies compose naturally — each `setup()` picks
248-
only the kwargs it needs:
223+
224+
## The `setup()` protocol
225+
226+
Listeners that need runtime dependencies (e.g., a database session, a
227+
Redis client) can define a `setup()` method. It is called during the
228+
state machine's `__init__` with the instance and any extra `**kwargs`
229+
passed to the constructor. {ref}`Dependency injection <dependency-injection>`
230+
ensures each listener receives only the kwargs it declares:
249231

250232
```py
233+
>>> class DBListener:
234+
... def __init__(self):
235+
... self.session = None
236+
...
237+
... def setup(self, sm, session=None, **kwargs):
238+
... self.session = session
239+
251240
>>> class CacheListener:
252241
... def __init__(self):
253242
... self.redis = None
254243
...
255244
... def setup(self, sm, redis=None, **kwargs):
256245
... self.redis = redis
257246

258-
>>> class FullMachine(StateChart):
247+
>>> class PersistentMachine(StateChart):
259248
... listeners = [DBListener, CacheListener]
260249
...
261250
... s1 = State(initial=True)
262251
... s2 = State(final=True)
263252
... go = s1.to(s2)
264253

265-
>>> sm = FullMachine(session="db_conn", redis="redis_conn")
254+
>>> sm = PersistentMachine(session="db_conn", redis="redis_conn")
266255
>>> sm.active_listeners[0].session
267256
'db_conn'
268257
>>> sm.active_listeners[1].redis
269258
'redis_conn'
270259

271260
```
272261

273-
```{note}
274-
The `setup()` method is only called on **factory-created** instances (callable entries).
275-
Shared instances (pre-built objects) do not receive `setup()` calls — they are assumed
276-
to be already configured by whoever created them.
277-
```
278-
279-
```{hint}
280-
The `StateChart` itself is registered as a listener, so by using `listeners` an
281-
external object can have the same level of functionalities provided to the built-in class.
282-
```
262+
Multiple listeners with different dependencies compose naturally — each
263+
`setup()` picks only the kwargs it needs.
283264

284-
```{tip}
285-
{ref}`domain models` are also registered as a listener.
265+
```{note}
266+
The `setup()` method is only called on **factory-created** instances
267+
(callable entries in the `listeners` list). Shared instances (pre-built
268+
objects) do not receive `setup()` calls — they are assumed to be already
269+
configured.
286270
```
287271

288272

289273
```{seealso}
290-
See {ref}`actions`, {ref}`validators and guards` for a list of possible callbacks.
291-
292-
And also {ref}`dynamic-dispatch` to know more about how the lib calls methods to match
293-
their signature.
274+
See {ref}`actions` for the full list of callback groups and
275+
{ref}`dependency injection <dependency-injection>` for how method
276+
signatures are matched.
294277
```

0 commit comments

Comments
 (0)