Skip to content

Commit c18149e

Browse files
committed
feat: add error_ naming convention, statecharts docs, and error handling tests
Auto-register dot-notation aliases for event attributes starting with `error_` (e.g. `error_execution` matches both `error_execution` and `error.execution`), removing the need for verbose `id="error.execution"`. Add `docs/statecharts.md` documenting StateChart vs StateMachine class-level flags (allow_event_without_transition, enable_self_transition_entries, atomic_configuration_update, error_on_execution) and error.execution handling. Comprehensive test coverage for error handling with conditions, listeners, flow control, and SCXML spec compliance using LOTR-themed scenarios.
1 parent b44651f commit c18149e

File tree

6 files changed

+1408
-3
lines changed

6 files changed

+1408
-3
lines changed

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ mixins
1818
integrations
1919
diagram
2020
processing_model
21+
statecharts
2122
api
2223
auto_examples/index
2324
contributing

docs/releases/3.0.0.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,38 @@ but would not match events named `errors.my.custom`, `errorhandler.mistake`, `er
9494

9595
An event designator consisting solely of `*` can be used as a wildcard matching any sequence of tokens, and thus any event.
9696

97+
### Error handling with `error.execution`
98+
99+
When `error_on_execution` is enabled (default in `StateChart`), runtime exceptions during
100+
transitions are caught and result in an internal `error.execution` event. This follows
101+
the [SCXML error handling specification](https://www.w3.org/TR/scxml/#errorsAndEvents).
102+
103+
A naming convention makes this easy to use: any event attribute starting with `error_`
104+
automatically matches both the underscore and dot-notation forms:
105+
106+
```py
107+
>>> from statemachine import State, StateChart
108+
109+
>>> class MyChart(StateChart):
110+
... s1 = State("s1", initial=True)
111+
... error_state = State("error_state", final=True)
112+
...
113+
... go = s1.to(s1, on="bad_action")
114+
... error_execution = s1.to(error_state) # matches "error.execution" automatically
115+
...
116+
... def bad_action(self):
117+
... raise RuntimeError("something went wrong")
118+
119+
>>> sm = MyChart()
120+
>>> sm.send("go")
121+
>>> sm.configuration == {sm.error_state}
122+
True
123+
124+
```
125+
126+
The error object is available as `error` in handler kwargs. See {ref}`error-execution`
127+
for full details.
128+
97129
### Delayed events
98130

99131
Specify an event to run in the near future. The engine will keep track of the execution time
@@ -225,4 +257,3 @@ The `allow_event_without_transition` was previously configured as an init parame
225257
attribute.
226258

227259
Defaults to `False` in `StateMachine` class to preserve maximum backwards compatibility.
228-

docs/statecharts.md

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
(statecharts)=
2+
# Statecharts
3+
4+
Statecharts are a powerful extension to state machines that add hierarchy and concurrency.
5+
They extend the concept of state machines by introducing **compound states** (states with
6+
inner substates) and **parallel states** (states that can be active simultaneously).
7+
8+
This library's statechart support follows the
9+
[SCXML specification](https://www.w3.org/TR/scxml/), a W3C standard for statechart notation.
10+
11+
## StateChart vs StateMachine
12+
13+
The `StateChart` class is the new base class that follows the
14+
[SCXML specification](https://www.w3.org/TR/scxml/). The `StateMachine` class extends
15+
`StateChart` but overrides several defaults to preserve backward compatibility with
16+
existing code.
17+
18+
The behavioral differences between the two classes are controlled by class-level
19+
attributes. This design allows a gradual upgrade path: you can start from `StateMachine`
20+
and selectively enable spec-compliant behaviors one at a time, or start from `StateChart`
21+
and get full SCXML compliance out of the box.
22+
23+
```{tip}
24+
We **strongly recommend** that new projects use `StateChart` directly. Existing projects
25+
should consider migrating when possible, as the SCXML-compliant behavior is the standard
26+
and provides more predictable semantics.
27+
```
28+
29+
### Comparison table
30+
31+
| Attribute | `StateChart` | `StateMachine` | Description |
32+
|------------------------------------|---------------|----------------|--------------------------------------------------|
33+
| `allow_event_without_transition` | `True` | `False` | Tolerate events that don't match any transition |
34+
| `enable_self_transition_entries` | `True` | `False` | Execute entry/exit actions on self-transitions |
35+
| `atomic_configuration_update` | `False` | `True` | When to update configuration during a microstep |
36+
| `error_on_execution` | `True` | `False` | Catch runtime errors as `error.execution` events |
37+
38+
### `allow_event_without_transition`
39+
40+
When `True` (SCXML default), sending an event that does not match any enabled transition
41+
is silently ignored. When `False` (legacy default), a `TransitionNotAllowed` exception is
42+
raised, including for unknown event names.
43+
44+
The SCXML spec requires tolerance to unmatched events, as the event-driven model expects
45+
that not every event is relevant in every state.
46+
47+
### `enable_self_transition_entries`
48+
49+
When `True` (SCXML default), a self-transition (a transition where the source and target
50+
are the same state) will execute the state's exit and entry actions, just like any other
51+
transition. When `False` (legacy default), self-transitions skip entry/exit actions.
52+
53+
The SCXML spec treats self-transitions as regular transitions that happen to return to the
54+
same state, so entry/exit actions must fire.
55+
56+
### `atomic_configuration_update`
57+
58+
When `False` (SCXML default), a microstep follows the SCXML processing order: first exit
59+
all states in the exit set (running exit callbacks), then execute the transition content
60+
(`on` callbacks), then enter all states in the entry set (running entry callbacks). During
61+
the `on` callbacks, the configuration may be empty or partial.
62+
63+
When `True` (legacy default), the configuration is updated atomically after the `on`
64+
callbacks, so `sm.configuration` and `state.is_active` always reflect a consistent snapshot
65+
during the transition. This was the behavior of all previous versions.
66+
67+
```{note}
68+
When `atomic_configuration_update` is `False`, `on` callbacks can request
69+
`previous_configuration` and `new_configuration` keyword arguments to inspect which states
70+
were active before and after the microstep.
71+
```
72+
73+
### `error_on_execution`
74+
75+
When `True` (SCXML default), runtime exceptions in callbacks (guards, actions, entry/exit)
76+
are caught by the engine and result in an internal `error.execution` event. When `False`
77+
(legacy default), exceptions propagate normally to the caller.
78+
79+
See {ref}`error-execution` below for full details.
80+
81+
### Gradual migration
82+
83+
You can override any of these attributes individually. For example, to adopt SCXML error
84+
handling in an existing `StateMachine` without changing other behaviors:
85+
86+
```python
87+
class MyMachine(StateMachine):
88+
error_on_execution = True
89+
# ... everything else behaves as before ...
90+
```
91+
92+
Or to use `StateChart` but keep the legacy atomic configuration update:
93+
94+
```python
95+
class MyChart(StateChart):
96+
atomic_configuration_update = True
97+
# ... SCXML-compliant otherwise ...
98+
```
99+
100+
(error-execution)=
101+
## Error handling with `error.execution`
102+
103+
As described above, when `error_on_execution` is `True`, runtime exceptions during
104+
transitions are caught by the engine and result in an internal `error.execution` event
105+
being placed on the queue. This follows the
106+
[SCXML error handling specification](https://www.w3.org/TR/scxml/#errorsAndEvents).
107+
108+
You can define transitions for this event to gracefully handle errors within the state
109+
machine itself.
110+
111+
### The `error_` naming convention
112+
113+
Since Python identifiers cannot contain dots, the library provides a naming convention:
114+
any event attribute starting with `error_` automatically matches both the underscore form
115+
and the dot-notation form. For example, `error_execution` matches both `"error_execution"`
116+
and `"error.execution"`.
117+
118+
```py
119+
>>> from statemachine import State, StateChart
120+
121+
>>> class MyChart(StateChart):
122+
... s1 = State("s1", initial=True)
123+
... error_state = State("error_state", final=True)
124+
...
125+
... go = s1.to(s1, on="bad_action")
126+
... error_execution = s1.to(error_state)
127+
...
128+
... def bad_action(self):
129+
... raise RuntimeError("something went wrong")
130+
131+
>>> sm = MyChart()
132+
>>> sm.send("go")
133+
>>> sm.configuration == {sm.error_state}
134+
True
135+
136+
```
137+
138+
This is equivalent to the more verbose explicit form:
139+
140+
```python
141+
error_execution = Event(s1.to(error_state), id="error.execution")
142+
```
143+
144+
The convention works with both bare transitions and `Event` objects without an explicit `id`:
145+
146+
```py
147+
>>> from statemachine import Event, State, StateChart
148+
149+
>>> class ChartWithEvent(StateChart):
150+
... s1 = State("s1", initial=True)
151+
... error_state = State("error_state", final=True)
152+
...
153+
... go = s1.to(s1, on="bad_action")
154+
... error_execution = Event(s1.to(error_state))
155+
...
156+
... def bad_action(self):
157+
... raise RuntimeError("something went wrong")
158+
159+
>>> sm = ChartWithEvent()
160+
>>> sm.send("go")
161+
>>> sm.configuration == {sm.error_state}
162+
True
163+
164+
```
165+
166+
```{note}
167+
If you provide an explicit `id=` parameter, it takes precedence and the naming convention
168+
is not applied.
169+
```
170+
171+
### Accessing error data
172+
173+
The error object is passed as `error` in the keyword arguments to callbacks on the
174+
`error.execution` transition:
175+
176+
```py
177+
>>> from statemachine import State, StateChart
178+
179+
>>> class ErrorDataChart(StateChart):
180+
... s1 = State("s1", initial=True)
181+
... error_state = State("error_state", final=True)
182+
...
183+
... go = s1.to(s1, on="bad_action")
184+
... error_execution = s1.to(error_state, on="handle_error")
185+
...
186+
... def bad_action(self):
187+
... raise RuntimeError("specific error")
188+
...
189+
... def handle_error(self, error=None, **kwargs):
190+
... self.last_error = error
191+
192+
>>> sm = ErrorDataChart()
193+
>>> sm.send("go")
194+
>>> str(sm.last_error)
195+
'specific error'
196+
197+
```
198+
199+
### Enabling in StateMachine
200+
201+
By default, `StateMachine` propagates exceptions (`error_on_execution = False`). You can
202+
enable `error.execution` handling as described in {ref}`gradual migration <statecharts>`:
203+
204+
```python
205+
class MyMachine(StateMachine):
206+
error_on_execution = True
207+
# ... define states, transitions, error_execution handler ...
208+
```
209+
210+
### Error-in-error-handler behavior
211+
212+
If an error occurs while processing the `error.execution` event itself, the engine
213+
ignores the second error (logging a warning) to prevent infinite loops. The state machine
214+
remains in the configuration it was in before the failed error handler.

statemachine/factory.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,9 +245,17 @@ def add_from_attributes(cls, attrs): # noqa: C901
245245
if isinstance(value, State):
246246
cls.add_state(key, value)
247247
elif isinstance(value, (Transition, TransitionList)):
248-
cls.add_event(event=Event(transitions=value, id=key, name=key))
248+
event_id = key
249+
if key.startswith("error_"):
250+
event_id = f"{key} {key.replace('_', '.')}"
251+
cls.add_event(event=Event(transitions=value, id=event_id, name=key))
249252
elif isinstance(value, (Event,)):
250-
event_id = value.id if value._has_real_id else key
253+
if value._has_real_id:
254+
event_id = value.id
255+
elif key.startswith("error_"):
256+
event_id = f"{key} {key.replace('_', '.')}"
257+
else:
258+
event_id = key
251259
new_event = Event(
252260
transitions=value._transitions,
253261
id=event_id,

0 commit comments

Comments
 (0)