Skip to content

Commit 27e5310

Browse files
committed
feat: protocol-based invoke architecture with SCXML spec compliance
Refactor invoke mechanism to use Python Protocols, enabling any callable or class with a run() method to be used as State(invoke=handler). Key changes: - InvokeConfig/InvokeContext/Invoker Protocol in statemachine/invoke.py - StateChartInvoker adapter for child StateChart invocations - SCXMLInvoker in io/scxml/invoke.py for src/srcexpr/content resolution - done.invoke uses invokeid per SCXML spec (prefix matching in is_same_event) - One external event per macrostep iteration (SCXML spec compliance) - Skip unknown transition targets gracefully in create_machine_class_from_definition - Error handling for invoke param evaluation (sends error.execution) - Child SM lookup via handler._child_sm during execution - Move _assert_passed into _run_scxml_testcase for --upd-fail coverage - Add fail markers for optional SCXML tests and test276.async
1 parent 679d265 commit 27e5310

46 files changed

Lines changed: 23932 additions & 400 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

statemachine/engines/async_.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,19 @@ async def _run_microstep(self, enabled_transitions, trigger_data): # pragma: no
312312
except Exception as e:
313313
self._handle_error(e, trigger_data)
314314

315+
async def enter_initial_configuration(self):
316+
"""Enter the initial state configuration without starting the processing loop.
317+
318+
Async override of the base method for use by invoke's two-phase activation.
319+
"""
320+
if self.sm.current_state_value is not None:
321+
return
322+
from ..event import BoundEvent
323+
324+
trigger_data = BoundEvent("__initial__", _sm=self.sm).build_trigger(machine=self.sm)
325+
transitions = self._initial_transitions(trigger_data)
326+
await self._enter_states(transitions, trigger_data, OrderedSet(), OrderedSet())
327+
315328
async def activate_initial_state(self):
316329
"""Activate the initial state.
317330
@@ -399,6 +412,11 @@ async def processing_loop( # noqa: C901
399412
# transitions can be processed while we wait.
400413
break
401414

415+
# Forward delayed cross-session events to their target
416+
if external_event.forward_target:
417+
self._forward_to_target(external_event)
418+
continue
419+
402420
logger.debug("External event: %s", external_event.event)
403421

404422
# Handle invoke finalize and autoforward

statemachine/engines/base.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,33 @@ def cancel_event(self, send_id: str):
138138
"""Cancel the event with the given send_id."""
139139
self.external_queue.remove(send_id)
140140

141+
def _forward_to_target(self, trigger_data: TriggerData):
142+
"""Forward an event to a cross-session target instead of processing it.
143+
144+
Called by the processing loop when ``trigger_data.forward_target`` is set.
145+
This supports delayed cross-session sends: the event sits on the child's
146+
queue with a delay, and when it expires the processing loop forwards it
147+
to the named target.
148+
"""
149+
target = trigger_data.forward_target
150+
event_name = str(trigger_data.event)
151+
if target in ("#_parent", "parent"):
152+
parent_sm = getattr(self.sm, "_parent_sm", None)
153+
if parent_sm is not None:
154+
child_invokeid = getattr(self.sm, "_invokeid", None)
155+
parent_sm.send(
156+
event_name,
157+
*trigger_data.args,
158+
invokeid=child_invokeid,
159+
**trigger_data.kwargs,
160+
)
161+
else:
162+
self.sm.send("error.communication", internal=True)
163+
elif target == "#_child":
164+
self.invoke_manager.send_to_child(event_name, **trigger_data.kwargs)
165+
else:
166+
logger.warning("Unknown forward_target: %s", target)
167+
141168
def _on_error_handler(self) -> "Callable[[Exception], None] | None":
142169
"""Return a per-block error handler, or ``None``.
143170
@@ -188,6 +215,19 @@ def start(self):
188215

189216
BoundEvent("__initial__", _sm=self.sm).put()
190217

218+
def enter_initial_configuration(self):
219+
"""Enter the initial state configuration without starting the processing loop.
220+
221+
Used by invoke to split activation into two phases: enter initial states
222+
(which runs datamodel entry actions), then apply invoke params, then start
223+
the processing loop.
224+
"""
225+
if self.sm.current_state_value is not None:
226+
return
227+
trigger_data = BoundEvent("__initial__", _sm=self.sm).build_trigger(machine=self.sm)
228+
transitions = self._initial_transitions(trigger_data)
229+
self._enter_states(transitions, trigger_data, OrderedSet(), OrderedSet())
230+
191231
def _initial_transitions(self, trigger_data):
192232
empty_state = State()
193233
configuration = self.sm._get_initial_configuration()

statemachine/engines/sync.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ def processing_loop(self, caller_future=None): # noqa: C901
7979
first_result = self._sentinel
8080
try:
8181
took_events = True
82-
while took_events:
82+
while took_events and not self.sm.is_terminated:
8383
self.clear_cache()
8484
took_events = False
8585
# Execute the triggers in the queue in FIFO order until the queue is empty
@@ -139,6 +139,11 @@ def processing_loop(self, caller_future=None): # noqa: C901
139139
# transitions can be processed while we wait.
140140
break
141141

142+
# Forward delayed cross-session events to their target
143+
if external_event.forward_target:
144+
self._forward_to_target(external_event)
145+
continue
146+
142147
logger.debug("External event: %s", external_event.event)
143148

144149
# Handle invoke finalize and autoforward
@@ -165,10 +170,21 @@ def processing_loop(self, caller_future=None): # noqa: C901
165170
self.clear()
166171
raise
167172

173+
# Per SCXML spec: process ONE external event per macrostep,
174+
# then loop back to handle eventless transitions, internal
175+
# events, and invoke spawning before the next external event.
176+
break
177+
168178
else:
169179
if not self.sm.allow_event_without_transition:
170180
raise TransitionNotAllowed(external_event.event, self.sm.configuration)
171181

182+
# If no events were processed but there are pending events
183+
# on the external queue (e.g., delayed timeouts in SCXML tests),
184+
# keep the loop alive so child sessions can send events back.
185+
if not took_events and not self.external_queue.is_empty():
186+
took_events = True
187+
172188
finally:
173189
self._processing.release()
174190
return first_result if first_result is not self._sentinel else None

statemachine/event.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,22 @@ def __repr__(self):
9595
)
9696

9797
def is_same_event(self, *_args, event: "str | None" = None, **_kwargs) -> bool:
98-
return self == event
98+
if self == event:
99+
return True
100+
if event is not None:
101+
event_str = str(event)
102+
self_dot = str(self).replace("_", ".")
103+
event_dot = event_str.replace("_", ".")
104+
105+
# Exact match with dot/underscore normalization
106+
if self_dot == event_dot:
107+
return True
108+
109+
# SCXML prefix matching: descriptor "done.invoke.active" matches
110+
# actual event "done.invoke.active.abc123"
111+
if event_dot.startswith(self_dot + "."):
112+
return True
113+
return False
99114

100115
def _add_callback(self, callback, grouper: CallbackGroup, is_event=False, **kwargs):
101116
if self._transitions is None:
@@ -121,14 +136,26 @@ def __get__(self, instance, owner):
121136
return self
122137
return BoundEvent(id=self.id, name=self.name, delay=self.delay, _sm=instance)
123138

124-
def put(self, *args, send_id: "str | None" = None, invokeid: "str | None" = None, **kwargs):
139+
def put(
140+
self,
141+
*args,
142+
send_id: "str | None" = None,
143+
invokeid: "str | None" = None,
144+
forward_target: "str | None" = None,
145+
**kwargs,
146+
):
125147
# The `__call__` is declared here to help IDEs knowing that an `Event`
126148
# can be called as a method. But it is not meant to be called without
127149
# an SM instance. Such SM instance is provided by `__get__` method when
128150
# used as a property descriptor.
129151
assert self._sm is not None
130152
trigger_data = self.build_trigger(
131-
*args, machine=self._sm, send_id=send_id, invokeid=invokeid, **kwargs
153+
*args,
154+
machine=self._sm,
155+
send_id=send_id,
156+
invokeid=invokeid,
157+
forward_target=forward_target,
158+
**kwargs,
132159
)
133160
self._sm._put_nonblocking(trigger_data, internal=self.internal)
134161
return trigger_data
@@ -139,6 +166,7 @@ def build_trigger(
139166
machine: "StateChart",
140167
send_id: "str | None" = None,
141168
invokeid: "str | None" = None,
169+
forward_target: "str | None" = None,
142170
**kwargs,
143171
):
144172
if machine is None:
@@ -150,6 +178,7 @@ def build_trigger(
150178
event=self,
151179
send_id=send_id,
152180
invokeid=invokeid,
181+
forward_target=forward_target,
153182
args=args,
154183
kwargs=kwargs,
155184
)

statemachine/event_data.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,16 @@ class TriggerData:
3030
execution_time: float = field(default=0.0)
3131
"""The time at which the :ref:`Event` should run."""
3232

33+
forward_target: "str | None" = field(compare=False, default=None)
34+
"""When set, the processing loop forwards this event to the named target
35+
(e.g. ``"#_parent"``, ``"#_child"``) instead of processing it normally.
36+
37+
Used for delayed cross-session sends: the event sits on the child's queue
38+
with a delay, and when the delay expires it is forwarded to the target.
39+
If the child terminates first, the event is never processed — automatic
40+
cancellation per SCXML spec.
41+
"""
42+
3343
model: Any = field(init=False, compare=False)
3444
"""A reference to the underlying model that holds the current :ref:`State`."""
3545

@@ -57,7 +67,7 @@ class EventData:
5767
trigger_data: TriggerData
5868
"""The :ref:`TriggerData` of the :ref:`event`."""
5969

60-
transition: "Transition"
70+
transition: "Transition | None"
6171
"""The :ref:`Transition` instance that was activated by the :ref:`Event`."""
6272

6373
state: "State" = field(init=False)

0 commit comments

Comments
 (0)