Skip to content

Commit 63f93fc

Browse files
committed
feat(scxml): add <invoke> parsing and runtime support
Parse SCXML <invoke> elements and wire them through the existing Python-level invoke infrastructure (IInvoke protocol, InvokeManager). Key changes: - New SCXMLInvoker handler (io/scxml/invoke.py) implementing IInvoke - Parser support for <invoke>, <content>, <param>, <finalize>, namelist - Processor wiring: InvokeDefinition → SCXMLInvoker → State(invoke=) - Engine hooks: handle_external_event for finalize/autoforward routing - Fix async processing loop early exit resolving caller_future - Fix finalize running even after invocation terminates (race condition) - 19 W3C invoke tests now passing (removed from xfail sets)
1 parent a6e57a6 commit 63f93fc

12 files changed

Lines changed: 519 additions & 75 deletions

File tree

statemachine/engines/async_.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,7 @@ async def processing_loop( # noqa: C901
346346
first_result = self._sentinel
347347
try:
348348
took_events = True
349-
while took_events:
349+
while took_events and self.running:
350350
self.clear_cache()
351351
took_events = False
352352
macrostep_done = False
@@ -406,6 +406,9 @@ async def processing_loop( # noqa: C901
406406
)
407407
break
408408

409+
# Finalize + autoforward for active invocations
410+
self._invoke_manager.handle_external_event(external_event)
411+
409412
event_future = external_event.future
410413
try:
411414
enabled_transitions = await self.select_transitions(external_event)
@@ -451,6 +454,8 @@ async def processing_loop( # noqa: C901
451454
result = first_result if first_result is not self._sentinel else None
452455
# If the caller has a future, await it (already resolved by now).
453456
if caller_future is not None:
457+
# Resolve the future if it wasn't processed (e.g. machine terminated).
458+
self._resolve_future(caller_future, result)
454459
return await caller_future
455460
return result
456461

statemachine/engines/sync.py

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ def processing_loop(self, caller_future=None): # noqa: C901
8181
first_result = self._sentinel
8282
try:
8383
took_events = True
84-
while took_events:
84+
while took_events and self.running:
8585
self.clear_cache()
8686
took_events = False
8787
# Execute the triggers in the queue in FIFO order until the queue is empty
@@ -136,18 +136,9 @@ def processing_loop(self, caller_future=None): # noqa: C901
136136
break
137137

138138
logger.debug("External event: %s", external_event.event)
139-
# # TODO: Handle cancel event
140-
# if self.is_cancel_event(external_event):
141-
# self.running = False
142-
# return
143-
144-
# TODO: Invoke states
145-
# for state in self.configuration:
146-
# for inv in state.invoke:
147-
# if inv.invokeid == external_event.invokeid:
148-
# self.apply_finalize(inv, external_event)
149-
# if inv.autoforward:
150-
# self.send(inv.id, external_event)
139+
140+
# Finalize + autoforward for active invocations
141+
self._invoke_manager.handle_external_event(external_event)
151142

152143
enabled_transitions = self.select_transitions(external_event)
153144
logger.debug("Enabled transitions: %s", enabled_transitions)

statemachine/invoke.py

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ def cancel_for_state(self, state: "State"):
290290
for inv_id, inv in list(self._active.items()):
291291
if inv.state_id == state.id and not inv.terminated:
292292
self._cancel(inv_id)
293-
self._pending = [(s, kw) for s, kw in self._pending if s is not state]
293+
self._pending = [(s, kw) for s, kw in self._pending if s.id != state.id]
294294

295295
def cancel_all(self):
296296
"""Cancel all active invocations."""
@@ -314,12 +314,13 @@ def spawn_pending_sync(self):
314314
def _spawn_one_sync(self, callback: "CallbackWrapper", **kwargs):
315315
state: "State" = kwargs["state"]
316316
event_kwargs: dict = kwargs.get("event_kwargs", {})
317-
ctx = self._make_context(state, event_kwargs)
318-
invocation = Invocation(invokeid=ctx.invokeid, state_id=state.id, ctx=ctx)
319317

320318
# Use meta.func to find the original (unwrapped) handler; the callback
321319
# system wraps everything in a signature_adapter closure.
322320
handler = self._resolve_handler(callback.meta.func)
321+
ctx = self._make_context(state, event_kwargs, handler=handler)
322+
invocation = Invocation(invokeid=ctx.invokeid, state_id=state.id, ctx=ctx)
323+
323324
invocation._handler = handler
324325
self._active[ctx.invokeid] = invocation
325326

@@ -347,11 +348,10 @@ def _run_sync_handler(
347348
self.sm.send(
348349
f"done.invoke.{ctx.invokeid}",
349350
data=result,
350-
internal=True,
351351
)
352352
except Exception as e:
353353
if not ctx.cancelled.is_set():
354-
self.sm.send("error.execution", error=e, internal=True)
354+
self.sm.send("error.execution", error=e)
355355
finally:
356356
invocation.terminated = True
357357

@@ -372,10 +372,11 @@ async def spawn_pending_async(self):
372372
def _spawn_one_async(self, callback: "CallbackWrapper", **kwargs):
373373
state: "State" = kwargs["state"]
374374
event_kwargs: dict = kwargs.get("event_kwargs", {})
375-
ctx = self._make_context(state, event_kwargs)
376-
invocation = Invocation(invokeid=ctx.invokeid, state_id=state.id, ctx=ctx)
377375

378376
handler = self._resolve_handler(callback.meta.func)
377+
ctx = self._make_context(state, event_kwargs, handler=handler)
378+
invocation = Invocation(invokeid=ctx.invokeid, state_id=state.id, ctx=ctx)
379+
379380
invocation._handler = handler
380381
self._active[ctx.invokeid] = invocation
381382

@@ -404,15 +405,14 @@ async def _run_async_handler(
404405
self.sm.send(
405406
f"done.invoke.{ctx.invokeid}",
406407
data=result,
407-
internal=True,
408408
)
409409
except asyncio.CancelledError:
410410
# Intentionally swallowed: the owning state was exited, so this
411411
# invocation was cancelled — there is nothing to propagate.
412412
return
413413
except Exception as e:
414414
if not ctx.cancelled.is_set():
415-
self.sm.send("error.execution", error=e, internal=True)
415+
self.sm.send("error.execution", error=e)
416416
finally:
417417
invocation.terminated = True
418418

@@ -434,8 +434,55 @@ def _cancel(self, invokeid: str):
434434

435435
# --- Helpers ---
436436

437-
def _make_context(self, state: "State", event_kwargs: "dict | None" = None) -> InvokeContext:
438-
invokeid = f"{state.id}.{uuid.uuid4().hex[:8]}"
437+
def handle_external_event(self, trigger_data) -> None:
438+
"""Run finalize blocks and autoforward for active invocations.
439+
440+
Called by the engine before processing each external event.
441+
For each active invocation whose handler has ``on_finalize`` or
442+
``on_event`` (autoforward), delegate accordingly.
443+
"""
444+
event_name = str(trigger_data.event) if trigger_data.event else None
445+
if event_name is None:
446+
return
447+
448+
# Tag done.invoke events with the invokeid
449+
if event_name.startswith("done.invoke."):
450+
invokeid = event_name[len("done.invoke.") :]
451+
trigger_data.kwargs.setdefault("_invokeid", invokeid)
452+
453+
for inv in list(self._active.values()):
454+
handler = inv._handler
455+
if handler is None:
456+
continue
457+
458+
# Check if event originates from this invocation
459+
is_from_child = trigger_data.kwargs.get(
460+
"_invokeid"
461+
) == inv.invokeid or event_name.startswith(f"done.invoke.{inv.invokeid}")
462+
463+
# Finalize: run the finalize block if the event came from this invocation.
464+
# Note: finalize must run even after the invocation terminates, because
465+
# child events may still be queued when the handler thread completes.
466+
if is_from_child and hasattr(handler, "on_finalize"):
467+
handler.on_finalize(trigger_data)
468+
469+
# Autoforward: forward parent events to child (not events from child itself).
470+
# Only forward if the invocation is still running.
471+
if (
472+
not inv.terminated
473+
and not is_from_child
474+
and hasattr(handler, "autoforward")
475+
and handler.autoforward
476+
and hasattr(handler, "on_event")
477+
):
478+
handler.on_event(event_name, **trigger_data.kwargs)
479+
480+
def _make_context(
481+
self, state: "State", event_kwargs: "dict | None" = None, handler: Any = None
482+
) -> InvokeContext:
483+
# Use static invoke_id from handler if available (SCXML id= attribute)
484+
static_id = getattr(handler, "invoke_id", None) if handler else None
485+
invokeid = static_id or f"{state.id}.{uuid.uuid4().hex[:8]}"
439486
return InvokeContext(
440487
invokeid=invokeid,
441488
state_id=state.id,
@@ -453,6 +500,11 @@ def _resolve_handler(underlying: Any) -> "Any | None":
453500
inner = underlying._invoke_handler
454501
if isinstance(inner, type) and issubclass(inner, StateChart):
455502
return StateChartInvoker(inner)
503+
# Return the inner handler directly if it's an IInvoke instance
504+
# (e.g., SCXMLInvoker) so duck-typed attributes like invoke_id are accessible.
505+
# Exclude classes — @runtime_checkable matches classes that define run().
506+
if not isinstance(inner, type) and isinstance(inner, IInvoke):
507+
return inner
456508
return underlying
457509
if isinstance(underlying, IInvoke):
458510
return underlying

statemachine/io/scxml/actions.py

Lines changed: 80 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -111,36 +111,69 @@ class EventDataWrapper:
111111

112112
def __init__(self, event_data):
113113
self.event_data = event_data
114-
self.sendid = event_data.trigger_data.send_id
115-
if event_data.trigger_data.event is None or event_data.trigger_data.event.internal:
116-
if "error.execution" == event_data.trigger_data.event:
114+
self.trigger_data = event_data.trigger_data
115+
td = self.trigger_data
116+
self.sendid = td.send_id
117+
self.invokeid = td.kwargs.get("_invokeid", "")
118+
if td.event is None or td.event.internal:
119+
if "error.execution" == td.event:
117120
self.type = "platform"
118121
else:
119122
self.type = "internal"
120123
self.origintype = ""
121124
else:
122125
self.type = "external"
123126

127+
@classmethod
128+
def from_trigger_data(cls, trigger_data):
129+
"""Create an EventDataWrapper directly from a TriggerData (no EventData needed)."""
130+
obj = cls.__new__(cls)
131+
obj.event_data = None
132+
obj.sendid = trigger_data.send_id
133+
obj.trigger_data = trigger_data
134+
obj.invokeid = trigger_data.kwargs.get("_invokeid", "")
135+
event = trigger_data.event
136+
if event is None or event.internal:
137+
if "error.execution" == event:
138+
obj.type = "platform"
139+
else:
140+
obj.type = "internal"
141+
obj.origintype = ""
142+
else:
143+
obj.type = "external"
144+
return obj
145+
124146
def __getattr__(self, name):
125-
return getattr(self.event_data, name)
147+
if self.event_data is not None:
148+
return getattr(self.event_data, name)
149+
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
126150

127151
def __eq__(self, value):
128152
"This makes SCXML test 329 pass. It assumes that the event is the same instance"
129153
return isinstance(value, EventDataWrapper)
130154

155+
@property
156+
def _trigger_data(self):
157+
if self.event_data is not None:
158+
return self.event_data.trigger_data
159+
return self.trigger_data
160+
131161
@property
132162
def name(self):
133-
return self.event_data.event
163+
if self.event_data is not None:
164+
return self.event_data.event
165+
return str(self._trigger_data.event) if self._trigger_data.event else None
134166

135167
@property
136168
def data(self):
137169
"Property used by the SCXML namespace"
138-
if self.trigger_data.kwargs:
139-
return _Data(self.trigger_data.kwargs)
140-
elif self.trigger_data.args and len(self.trigger_data.args) == 1:
141-
return self.trigger_data.args[0]
142-
elif self.trigger_data.args:
143-
return self.trigger_data.args
170+
td = self._trigger_data
171+
if td.kwargs:
172+
return _Data(td.kwargs)
173+
elif td.args and len(td.args) == 1:
174+
return td.args[0]
175+
elif td.args:
176+
return td.args
144177
else:
145178
return None
146179

@@ -257,7 +290,10 @@ def __init__(self, action: AssignAction):
257290

258291
def __call__(self, *args, **kwargs):
259292
machine: StateChart = kwargs["machine"]
260-
value = _eval(self.action.expr, **kwargs)
293+
if self.action.child_xml is not None:
294+
value = self.action.child_xml
295+
else:
296+
value = _eval(self.action.expr, **kwargs)
261297

262298
*path, attr = self.action.location.split(".")
263299
obj = machine.model
@@ -364,6 +400,26 @@ def raise_action(*args, **kwargs):
364400
return raise_action
365401

366402

403+
def _send_to_parent(action: SendAction, **kwargs):
404+
"""Route a <send target="#_parent"> to the parent machine via _invoke_session."""
405+
machine = kwargs["machine"]
406+
session = getattr(machine, "_invoke_session", None)
407+
if session is None:
408+
return
409+
event = action.event or _eval(action.eventexpr, **kwargs) # type: ignore[arg-type]
410+
names = []
411+
for name in (action.namelist or "").strip().split():
412+
if not hasattr(machine.model, name):
413+
raise NameError(f"Namelist variable '{name}' not found on model")
414+
names.append(Param(name=name, expr=name))
415+
params_values = {}
416+
for param in chain(names, action.params):
417+
if param.expr is None:
418+
continue
419+
params_values[param.name] = _eval(param.expr, **kwargs)
420+
session.send_to_parent(event, **params_values)
421+
422+
367423
def create_send_action_callable(action: SendAction) -> Callable: # noqa: C901
368424
content: Any = ()
369425
_valid_targets = (None, "#_internal", "internal", "#_parent", "parent")
@@ -373,7 +429,7 @@ def create_send_action_callable(action: SendAction) -> Callable: # noqa: C901
373429
except (NameError, SyntaxError, TypeError):
374430
content = (action.content,)
375431

376-
def send_action(*args, **kwargs):
432+
def send_action(*args, **kwargs): # noqa: C901
377433
machine: StateChart = kwargs["machine"]
378434
event = action.event or _eval(action.eventexpr, **kwargs) # type: ignore[arg-type]
379435
target = action.target if action.target else None
@@ -393,6 +449,11 @@ def send_action(*args, **kwargs):
393449
raise ValueError(f"Invalid target: {target}. Must be one of {_valid_targets}")
394450
return
395451

452+
# Handle #_parent target — route to parent via _invoke_session
453+
if target == "#_parent":
454+
_send_to_parent(action, **kwargs)
455+
return
456+
396457
internal = target in ("#_internal", "internal")
397458

398459
send_id = None
@@ -464,6 +525,12 @@ def _create_dataitem_callable(action: DataItem) -> Callable:
464525
def data_initializer(**kwargs):
465526
machine: StateChart = kwargs["machine"]
466527

528+
# Check for invoke param overrides — params from parent override child defaults
529+
invoke_params = getattr(machine, "_invoke_params", None)
530+
if invoke_params and action.id in invoke_params:
531+
setattr(machine.model, action.id, invoke_params[action.id])
532+
return
533+
467534
if action.expr:
468535
try:
469536
value = _eval(action.expr, **kwargs)

0 commit comments

Comments
 (0)