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
13692Use ` 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