Skip to content

Commit b44651f

Browse files
committed
feat: implement error.execution event handling per SCXML spec
Add `error_on_execution` flag (True on StateChart, False on StateMachine for backward compat) that catches runtime exceptions in callbacks and queues an `error.execution` internal event instead of propagating. Error handling is layered by responsibility: - callbacks.call(on_error=...): per-block isolation for onentry/onexit — errors in one block don't affect other blocks - callbacks.all(on_error=...): condition errors treated as False - Engine._conditions_match: delegates to callbacks.all with on_error - Engine.microstep: catches errors in transition actions with rollback - SCXML if_action: condition errors treated as false (SCXML translation) - InvalidDefinition always propagates regardless of error_on_execution Also fixes: - Event with custom id in factory (preserve SCXML event ids like "error.execution" while keeping Python attribute accessible) - SCXML _event system variable initialized to None before first event - Infinite loop guard for error.execution in transition handlers
1 parent a0bfa10 commit b44651f

9 files changed

Lines changed: 428 additions & 76 deletions

File tree

statemachine/callbacks.py

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -314,17 +314,33 @@ async def async_all(self, *args, **kwargs):
314314
return False
315315
return True
316316

317-
def call(self, *args, **kwargs):
318-
return [
319-
callback.call(*args, **kwargs)
320-
for callback in self
321-
if callback.condition(*args, **kwargs)
322-
]
323-
324-
def all(self, *args, **kwargs):
317+
def call(self, *args, on_error: "Callable[[Exception], None] | None" = None, **kwargs):
318+
if on_error is None:
319+
return [
320+
callback.call(*args, **kwargs)
321+
for callback in self
322+
if callback.condition(*args, **kwargs)
323+
]
324+
325+
results = []
326+
for callback in self:
327+
if callback.condition(*args, **kwargs):
328+
try:
329+
results.append(callback.call(*args, **kwargs))
330+
except Exception as e:
331+
on_error(e)
332+
return results
333+
334+
def all(self, *args, on_error: "Callable[[Exception], None] | None" = None, **kwargs):
325335
for condition in self:
326-
if not condition.call(*args, **kwargs):
327-
return False
336+
try:
337+
if not condition.call(*args, **kwargs):
338+
return False
339+
except Exception as e:
340+
if on_error is not None:
341+
on_error(e)
342+
return False
343+
raise
328344
return True
329345

330346

@@ -361,18 +377,30 @@ def async_or_sync(self):
361377
callback._iscoro for executor in self._registry.values() for callback in executor
362378
)
363379

364-
def call(self, key: str, *args, **kwargs):
380+
def call(
381+
self,
382+
key: str,
383+
*args,
384+
on_error: "Callable[[Exception], None] | None" = None,
385+
**kwargs,
386+
):
365387
if key not in self._registry:
366388
return []
367-
return self._registry[key].call(*args, **kwargs)
389+
return self._registry[key].call(*args, on_error=on_error, **kwargs)
368390

369391
def async_call(self, key: str, *args, **kwargs):
370392
return self._registry[key].async_call(*args, **kwargs)
371393

372-
def all(self, key: str, *args, **kwargs):
394+
def all(
395+
self,
396+
key: str,
397+
*args,
398+
on_error: "Callable[[Exception], None] | None" = None,
399+
**kwargs,
400+
):
373401
if key not in self._registry:
374402
return True
375-
return self._registry[key].all(*args, **kwargs)
403+
return self._registry[key].all(*args, on_error=on_error, **kwargs)
376404

377405
def async_all(self, key: str, *args, **kwargs):
378406
return self._registry[key].async_all(*args, **kwargs)

statemachine/engines/async_.py

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from ..event_data import EventData
66
from ..event_data import TriggerData
7+
from ..exceptions import InvalidDefinition
78
from ..exceptions import TransitionNotAllowed
89
from .base import BaseEngine
910

@@ -91,14 +92,27 @@ async def _trigger(self, trigger_data: TriggerData):
9192

9293
return result if executed else None
9394

94-
async def _activate(self, trigger_data: TriggerData, transition: "Transition"):
95-
event_data = EventData(trigger_data=trigger_data, transition=transition)
96-
args, kwargs = event_data.args, event_data.extended_kwargs
97-
98-
await self.sm._callbacks.async_call(transition.validators.key, *args, **kwargs)
99-
if not await self.sm._callbacks.async_all(transition.cond.key, *args, **kwargs):
100-
return False, None
95+
async def _async_conditions_match(
96+
self, transition: "Transition", trigger_data: TriggerData, args, kwargs
97+
):
98+
if self.sm.error_on_execution:
99+
try:
100+
await self.sm._callbacks.async_call(transition.validators.key, *args, **kwargs)
101+
return await self.sm._callbacks.async_all(transition.cond.key, *args, **kwargs)
102+
except InvalidDefinition:
103+
raise
104+
except Exception as e:
105+
self._send_error_execution(trigger_data, e)
106+
return False
107+
else:
108+
await self.sm._callbacks.async_call(transition.validators.key, *args, **kwargs)
109+
return await self.sm._callbacks.async_all(transition.cond.key, *args, **kwargs)
101110

111+
async def _async_execute_transition(
112+
self, transition: "Transition", event_data: EventData, trigger_data: TriggerData
113+
):
114+
"""Execute transition callbacks (before, exit, on, enter) with error handling."""
115+
args, kwargs = event_data.args, event_data.extended_kwargs
102116
source = transition.source
103117
target = transition.target
104118

@@ -116,7 +130,34 @@ async def _activate(self, trigger_data: TriggerData, transition: "Transition"):
116130

117131
if not transition.internal:
118132
await self.sm._callbacks.async_call(target.enter.key, *args, **kwargs)
119-
await self.sm._callbacks.async_call(transition.after.key, *args, **kwargs)
133+
return result
134+
135+
async def _activate(self, trigger_data: TriggerData, transition: "Transition"):
136+
event_data = EventData(trigger_data=trigger_data, transition=transition)
137+
args, kwargs = event_data.args, event_data.extended_kwargs
138+
139+
if not await self._async_conditions_match(transition, trigger_data, args, kwargs):
140+
return False, None
141+
142+
try:
143+
result = await self._async_execute_transition(transition, event_data, trigger_data)
144+
except InvalidDefinition:
145+
raise
146+
except Exception as e:
147+
if self.sm.error_on_execution:
148+
self._send_error_execution(trigger_data, e)
149+
return True, None
150+
raise
151+
152+
try:
153+
await self.sm._callbacks.async_call(transition.after.key, *args, **kwargs)
154+
except InvalidDefinition:
155+
raise
156+
except Exception as e:
157+
if self.sm.error_on_execution:
158+
self._send_error_execution(trigger_data, e)
159+
else:
160+
raise
120161

121162
if len(result) == 0:
122163
result = None

statemachine/engines/base.py

Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from ..event import Event
1919
from ..event_data import EventData
2020
from ..event_data import TriggerData
21+
from ..exceptions import InvalidDefinition
2122
from ..exceptions import TransitionNotAllowed
2223
from ..orderedset import OrderedSet
2324
from ..state import HistoryState
@@ -119,6 +120,34 @@ def cancel_event(self, send_id: str):
119120
"""Cancel the event with the given send_id."""
120121
self.external_queue.remove(send_id)
121122

123+
@property
124+
def _on_error_execution(self):
125+
"""Return an error handler for per-block error isolation, or None.
126+
127+
When ``error_on_execution`` is enabled, returns a handler that queues
128+
``error.execution`` on the internal queue. Otherwise returns ``None``
129+
so that exceptions propagate normally.
130+
"""
131+
if self.sm.error_on_execution:
132+
133+
def _handler(e: Exception):
134+
if isinstance(e, InvalidDefinition):
135+
raise
136+
self.sm.send("error.execution", error=e, internal=True)
137+
138+
return _handler
139+
return None
140+
141+
def _send_error_execution(self, trigger_data: TriggerData, error: Exception):
142+
"""Send error.execution to internal queue (SCXML spec).
143+
144+
If already processing an error.execution event, ignore to avoid infinite loops.
145+
"""
146+
if trigger_data.event and str(trigger_data.event) == "error.execution":
147+
logger.warning("Error while processing error.execution, ignoring: %s", error)
148+
return
149+
self.sm.send("error.execution", error=error, internal=True)
150+
122151
def start(self):
123152
if self.sm.current_state_value is not None:
124153
return
@@ -321,15 +350,30 @@ def microstep(self, transitions: List[Transition], trigger_data: TriggerData):
321350
result += self._enter_states(
322351
transitions, trigger_data, states_to_exit, previous_configuration
323352
)
324-
except Exception:
353+
except InvalidDefinition:
325354
self.sm.configuration = previous_configuration
326355
raise
327-
self._execute_transition_content(
328-
transitions,
329-
trigger_data,
330-
lambda t: t.after.key,
331-
set_target_as_state=True,
332-
)
356+
except Exception as e:
357+
self.sm.configuration = previous_configuration
358+
if self.sm.error_on_execution:
359+
self._send_error_execution(trigger_data, e)
360+
return None
361+
raise
362+
363+
try:
364+
self._execute_transition_content(
365+
transitions,
366+
trigger_data,
367+
lambda t: t.after.key,
368+
set_target_as_state=True,
369+
)
370+
except InvalidDefinition:
371+
raise
372+
except Exception as e:
373+
if self.sm.error_on_execution:
374+
self._send_error_execution(trigger_data, e)
375+
else:
376+
raise
333377

334378
if len(result) == 0:
335379
result = None
@@ -366,8 +410,12 @@ def _get_args_kwargs(
366410
def _conditions_match(self, transition: Transition, trigger_data: TriggerData):
367411
args, kwargs = self._get_args_kwargs(transition, trigger_data)
368412

369-
self.sm._callbacks.call(transition.validators.key, *args, **kwargs)
370-
return self.sm._callbacks.all(transition.cond.key, *args, **kwargs)
413+
self.sm._callbacks.call(
414+
transition.validators.key, *args, on_error=self._on_error_execution, **kwargs
415+
)
416+
return self.sm._callbacks.all(
417+
transition.cond.key, *args, on_error=self._on_error_execution, **kwargs
418+
)
371419

372420
def _exit_states(
373421
self, enabled_transitions: List[Transition], trigger_data: TriggerData
@@ -405,9 +453,11 @@ def _exit_states(
405453
for info in ordered_states:
406454
args, kwargs = self._get_args_kwargs(info.transition, trigger_data)
407455

408-
# Execute `onexit` handlers
456+
# Execute `onexit` handlers — same per-block error isolation as onentry.
409457
if info.state is not None: # TODO: and not info.transition.internal:
410-
self.sm._callbacks.call(info.state.exit.key, *args, **kwargs)
458+
self.sm._callbacks.call(
459+
info.state.exit.key, *args, on_error=self._on_error_execution, **kwargs
460+
)
411461

412462
# TODO: Cancel invocations
413463
# for invocation in state.invoke:
@@ -503,8 +553,11 @@ def _enter_states( # noqa: C901
503553
# self.initialize_data_model(state)
504554
# state.is_first_entry = False
505555

506-
# Execute `onentry` handlers
507-
on_entry_result = self.sm._callbacks.call(target.enter.key, *args, **kwargs)
556+
# Execute `onentry` handlers — each handler is a separate block per
557+
# SCXML spec: errors in one block MUST NOT affect other blocks.
558+
on_entry_result = self.sm._callbacks.call(
559+
target.enter.key, *args, on_error=self._on_error_execution, **kwargs
560+
)
508561

509562
# Handle default initial states
510563
if target.id in {t.state.id for t in states_for_default_entry if t.state}:

statemachine/engines/sync.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from statemachine.orderedset import OrderedSet
88

99
from ..event_data import TriggerData
10+
from ..exceptions import InvalidDefinition
1011
from ..exceptions import TransitionNotAllowed
1112
from .base import BaseEngine
1213

@@ -17,6 +18,18 @@
1718

1819

1920
class SyncEngine(BaseEngine):
21+
def _run_microstep(self, enabled_transitions, trigger_data):
22+
"""Run a microstep for internal/eventless transitions with error handling."""
23+
try:
24+
self.microstep(list(enabled_transitions), trigger_data)
25+
except InvalidDefinition:
26+
raise
27+
except Exception as e:
28+
if self.sm.error_on_execution:
29+
self._send_error_execution(trigger_data, e)
30+
else:
31+
raise
32+
2033
def start(self):
2134
if self.sm.current_state_value is not None:
2235
return
@@ -91,7 +104,7 @@ def processing_loop(self): # noqa: C901
91104
if enabled_transitions:
92105
logger.debug("Enabled transitions: %s", enabled_transitions)
93106
took_events = True
94-
self.microstep(list(enabled_transitions), internal_event)
107+
self._run_microstep(enabled_transitions, internal_event)
95108

96109
# TODO: Invoke platform-specific logic
97110
# for state in sorted(self.states_to_invoke, key=self.entry_order):
@@ -104,7 +117,7 @@ def processing_loop(self): # noqa: C901
104117
internal_event = self.internal_queue.pop()
105118
enabled_transitions = self.select_transitions(internal_event)
106119
if enabled_transitions:
107-
self.microstep(list(enabled_transitions), internal_event)
120+
self._run_microstep(enabled_transitions, internal_event)
108121

109122
# Process external events
110123
logger.debug("Macrostep: external queue")
@@ -141,7 +154,7 @@ def processing_loop(self): # noqa: C901
141154
first_result = result
142155

143156
except Exception:
144-
# Whe clear the queue as we don't have an expected behavior
157+
# We clear the queue as we don't have an expected behavior
145158
# and cannot keep processing
146159
self.clear()
147160
raise

statemachine/factory.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -247,14 +247,16 @@ def add_from_attributes(cls, attrs): # noqa: C901
247247
elif isinstance(value, (Transition, TransitionList)):
248248
cls.add_event(event=Event(transitions=value, id=key, name=key))
249249
elif isinstance(value, (Event,)):
250-
cls.add_event(
251-
event=Event(
252-
transitions=value._transitions,
253-
id=key,
254-
name=value.name,
255-
),
256-
old_event=value,
250+
event_id = value.id if value._has_real_id else key
251+
new_event = Event(
252+
transitions=value._transitions,
253+
id=event_id,
254+
name=value.name,
257255
)
256+
cls.add_event(event=new_event, old_event=value)
257+
# Ensure the event is accessible by the Python attribute name
258+
if event_id != key:
259+
setattr(cls, key, new_event)
258260
elif getattr(value, "attr_name", None):
259261
cls._add_unbounded_callback(key, value)
260262

0 commit comments

Comments
 (0)