Skip to content

Commit 68a59a8

Browse files
committed
feat: migrate examples to StateChart, fix delayed events, document is_terminated
Migrate all 14 gallery examples from StateMachine to StateChart, setting behavioral flags where needed to preserve existing semantics (allow_event_without_transition, enable_self_transition_entries, error_on_execution). Add 4 new v3 feature examples: - statechart_eventless_machine: eventless transitions (Ring Corruption) - statechart_delayed_machine: compound/parallel states, delayed events, internal events, cancel_event (Beacons of Gondor) - statechart_error_handling_machine: error.execution handling - statechart_in_condition_machine: In() guard with parallel states Fix bugs in delayed event handling: - Rename send()/raise_() parameter from event_id to send_id to match BoundEvent.put() and cancel_event() signatures (cancel_event was silently broken because send_id was always None) - Change continue→break in sync/async engine Phase 3 loop when encountering delayed events, allowing internal events and eventless transitions to be processed while waiting Document is_terminated property: - Add docstring to the property itself - Add "Checking if the machine has terminated" section in docs/states.md - Add migration entry in upgrade guide for current_state.final → is_terminated - Update send_id references in docs (statecharts.md, release notes, upgrade guide)
1 parent 4d029e7 commit 68a59a8

25 files changed

Lines changed: 645 additions & 65 deletions

docs/releases/3.0.0.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -289,19 +289,17 @@ sm.send("light_beacons", delay=500) # fires after 500ms
289289
light = Event(dark.to(lit), delay=100)
290290

291291
# Cancel a delayed event before it fires
292-
sm.send("light_beacons", delay=5000, event_id="beacon_signal")
292+
sm.send("light_beacons", delay=5000, send_id="beacon_signal")
293293
sm.cancel_event("beacon_signal") # event is removed from the queue
294294
```
295295

296-
Also, delayed events can be revoked by their `send_id` using `sm.cancel_event(send_id)`.
297-
298296

299297
### New `send()` parameters
300298

301299
The `send()` method now accepts additional optional parameters:
302300

303301
- `delay` (float): Time in milliseconds before the event is processed.
304-
- `event_id` (str): Identifier for the event, useful for cancelling delayed events.
302+
- `send_id` (str): Identifier for the event, useful for cancelling delayed events.
305303
- `internal` (bool): If `True`, the event is placed in the internal queue and processed in the
306304
current macrostep.
307305

@@ -321,10 +319,10 @@ sm.raise_("error_event") # processed in the current macrostep
321319

322320
### `cancel_event()` method
323321

324-
Cancel delayed events by their `event_id`:
322+
Cancel delayed events by their `send_id`:
325323

326324
```python
327-
sm.send("timeout", delay=5000, event_id="my_timer")
325+
sm.send("timeout", delay=5000, send_id="my_timer")
328326
sm.cancel_event("my_timer") # event is removed from the queue
329327
```
330328

docs/releases/upgrade_2x_to_3.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,33 @@ single value and works as before. But we strongly recommend using `configuration
130130
```
131131

132132

133+
## Replace `current_state.final` with `is_terminated`
134+
135+
If you checked whether the machine had reached a final state via `current_state.final`, use the
136+
new `is_terminated` property instead. It works correctly for all topologies (flat, compound, and
137+
parallel).
138+
139+
**Before (2.x):**
140+
141+
```python
142+
if sm.current_state.final:
143+
print("done")
144+
145+
while not sm.current_state.final:
146+
sm.send("next")
147+
```
148+
149+
**After (3.0):**
150+
151+
```python
152+
if sm.is_terminated:
153+
print("done")
154+
155+
while not sm.is_terminated:
156+
sm.send("next")
157+
```
158+
159+
133160
## Replace `add_observer()` with `add_listener()`
134161

135162
The method `add_observer` has been renamed to `add_listener`. The old name still works but emits
@@ -256,11 +283,11 @@ The `send()` method has new optional parameters for delayed events and internal
256283
sm.send("event_name", *args, **kwargs)
257284

258285
# 3.0 signature (fully backward compatible)
259-
sm.send("event_name", *args, delay=0, event_id=None, internal=False, **kwargs)
286+
sm.send("event_name", *args, delay=0, send_id=None, internal=False, **kwargs)
260287
```
261288

262289
- `delay`: Time in milliseconds before the event is processed.
263-
- `event_id`: Identifier for the event, used to cancel delayed events with `sm.cancel_event(event_id)`.
290+
- `send_id`: Identifier for the event, used to cancel delayed events with `sm.cancel_event(send_id)`.
264291
- `internal`: If `True`, the event is placed in the internal queue (processed in the current macrostep).
265292

266293
Existing code calling `sm.send("event")` works unchanged.

docs/statecharts.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -645,10 +645,10 @@ light = Event(dark.to(lit), delay=100)
645645
```
646646

647647
Delayed events remain in the queue until their execution time arrives. They can be
648-
cancelled before firing by providing an `event_id` and calling `cancel_event()`:
648+
cancelled before firing by providing a `send_id` and calling `cancel_event()`:
649649

650650
```python
651-
sm.send("light_beacons", delay=5000, event_id="beacon_signal")
651+
sm.send("light_beacons", delay=5000, send_id="beacon_signal")
652652
sm.cancel_event("beacon_signal") # removed from queue
653653
```
654654

docs/states.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,29 @@ False
149149

150150
```
151151

152+
### Checking if the machine has terminated
153+
154+
Use the `is_terminated` property to check whether the state machine has reached a final state
155+
and the engine is no longer running. This is the recommended way to check for completion,
156+
especially with compound and parallel states where multiple states can be active at once.
157+
158+
```py
159+
>>> machine.send("produce")
160+
>>> machine.is_terminated
161+
False
162+
163+
>>> machine.send("deliver")
164+
>>> machine.is_terminated
165+
True
166+
167+
```
168+
169+
```{tip}
170+
Prefer `sm.is_terminated` over patterns like `sm.current_state.final` or
171+
`any(s.final for s in sm.configuration)`. It works correctly for all state machine
172+
topologies -- flat, compound, and parallel.
173+
```
174+
152175
## States from Enum types
153176

154177
{ref}`States` can also be declared from standard `Enum` classes.

statemachine/engines/async_.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,9 @@ async def processing_loop(self): # noqa: C901
319319
if external_event.execution_time > current_time:
320320
self.put(external_event, _delayed=True)
321321
await asyncio.sleep(self.sm._loop_sleep_in_ms)
322-
continue
322+
# Break to Phase 1 so internal events and eventless
323+
# transitions can be processed while we wait.
324+
break
323325

324326
logger.debug("External event: %s", external_event.event)
325327

statemachine/engines/sync.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,9 @@ def processing_loop(self): # noqa: C901
132132
if external_event.execution_time > current_time:
133133
self.put(external_event, _delayed=True)
134134
sleep(self.sm._loop_sleep_in_ms)
135-
continue
135+
# Break to Phase 1 so internal events and eventless
136+
# transitions can be processed while we wait.
137+
break
136138

137139
logger.debug("External event: %s", external_event.event)
138140
# # TODO: Handle cancel event

statemachine/statemachine.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,7 @@ def send(
428428
event: str,
429429
*args,
430430
delay: float = 0,
431-
event_id: "str | None" = None,
431+
send_id: "str | None" = None,
432432
internal: bool = False,
433433
**kwargs,
434434
):
@@ -437,6 +437,8 @@ def send(
437437
:param event: The trigger for the state machine, specified as an event id string.
438438
:param args: Additional positional arguments to pass to the event.
439439
:param delay: A time delay in milliseconds to process the event. Default is 0.
440+
:param send_id: An identifier for the event, used with ``cancel_event()`` to cancel
441+
delayed events.
440442
:param kwargs: Additional keyword arguments to pass to the event.
441443
442444
.. seealso::
@@ -451,12 +453,12 @@ def send(
451453
event_instance = BoundEvent(
452454
id=event, name=event_name, delay=delay, internal=internal, _sm=self
453455
)
454-
result = event_instance(*args, event_id=event_id, **kwargs)
456+
result = event_instance(*args, send_id=send_id, **kwargs)
455457
if not isawaitable(result):
456458
return result
457459
return run_async_from_sync(result)
458460

459-
def raise_(self, event: str, *args, delay: float = 0, event_id: "str | None" = None, **kwargs):
461+
def raise_(self, event: str, *args, delay: float = 0, send_id: "str | None" = None, **kwargs):
460462
"""Send an :ref:`Event` to the state machine in the internal event queue.
461463
462464
Events on the internal queue are processed immediately on the current step of the
@@ -466,14 +468,20 @@ def raise_(self, event: str, *args, delay: float = 0, event_id: "str | None" = N
466468
467469
See: :ref:`triggering events`.
468470
"""
469-
return self.send(event, *args, delay=delay, event_id=event_id, internal=True, **kwargs)
471+
return self.send(event, *args, delay=delay, send_id=send_id, internal=True, **kwargs)
470472

471473
def cancel_event(self, send_id: str):
472474
"""Cancel all the delayed events with the given ``send_id``."""
473475
self._engine.cancel_event(send_id)
474476

475477
@property
476478
def is_terminated(self):
479+
"""Whether the state machine has reached a final state.
480+
481+
Returns ``True`` when a top-level final state has been entered and the
482+
engine is no longer running. This is the recommended way to check for
483+
completion -- it works for flat, compound, and parallel topologies.
484+
"""
477485
return not self._engine.running
478486

479487

tests/examples/air_conditioner_machine.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
Air Conditioner machine
33
=======================
44
5-
A StateMachine that exercises reading from a stream of events.
5+
A StateChart that exercises reading from a stream of events.
66
77
"""
88

@@ -11,7 +11,7 @@
1111
from statemachine.utils import run_async_from_sync
1212

1313
from statemachine import State
14-
from statemachine import StateMachine
14+
from statemachine import StateChart
1515

1616

1717
def sensor_temperature_reader(seed: int, lower: int = 15, higher: int = 35):
@@ -21,7 +21,7 @@ def sensor_temperature_reader(seed: int, lower: int = 15, higher: int = 35):
2121
yield random.randint(lower, higher)
2222

2323

24-
class AirConditioner(StateMachine):
24+
class AirConditioner(StateChart):
2525
off = State(initial=True)
2626
cooling = State()
2727
standby = State()

tests/examples/all_actions_machine.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,17 @@
22
All actions machine
33
===================
44
5-
A StateMachine that exercises all possible :ref:`Actions` and :ref:`Guards`.
5+
A StateChart that exercises all possible :ref:`Actions` and :ref:`Guards`.
66
77
"""
88

99
from unittest import mock
1010

1111
from statemachine import State
12-
from statemachine import StateMachine
12+
from statemachine import StateChart
1313

1414

15-
class AllActionsMachine(StateMachine):
15+
class AllActionsMachine(StateChart):
1616
initial = State(initial=True)
1717
final = State(final=True)
1818

tests/examples/async_guess_the_number_machine.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
Async guess the number machine
33
==============================
44
5-
An async example of StateMachine for the well know game.
5+
An async example of StateChart for the well known game.
66
77
In order to pay the game, run this script and type a number between 1 and 5.
88
The command line should include an extra param to run the script in interactive mode:
@@ -23,19 +23,21 @@
2323
import sys
2424

2525
from statemachine import State
26-
from statemachine import StateMachine
26+
from statemachine import StateChart
2727

2828

29-
class GuessTheNumberMachine(StateMachine):
29+
class GuessTheNumberMachine(StateChart):
3030
"""
3131
Guess the number machine.
3232
33-
This docstring exercises the SAME `GuessTheNumberMachine` in syncronous code.
33+
This docstring exercises the SAME `GuessTheNumberMachine`` in synchronous code.
3434
35+
>>> random.seed(103)
3536
>>> sm = GuessTheNumberMachine(print, seed=103)
37+
>>> sm.activate_initial_state() # doctest: +SKIP
3638
I'm thinking of a number between 1 and 5. Can you guess what it is? >>>
3739
38-
>>> while not sm.current_state.final:
40+
>>> while not sm.is_terminated: # doctest: +SKIP
3941
... sm.send("guess", random.randint(1, 5))
4042
Your guess is 2...
4143
Too low. Try again. >>>
@@ -147,7 +149,7 @@ async def main_async():
147149
lambda s: writer.write(b"\n" + s.encode("utf-8")), seed=random.randint(1, 1000)
148150
)
149151
await sm.activate_initial_state()
150-
while not sm.current_state.final:
152+
while not sm.is_terminated:
151153
res = await reader.read(100)
152154
if not res:
153155
break
@@ -159,7 +161,7 @@ async def main_async():
159161
def main_sync():
160162
sm = GuessTheNumberMachine(print, seed=random.randint(1, 1000))
161163
sm.activate_initial_state()
162-
while not sm.current_state.final:
164+
while not sm.is_terminated:
163165
res = sys.stdin.readline()
164166
if not res:
165167
break

0 commit comments

Comments
 (0)