Skip to content

Commit 0435699

Browse files
committed
refactor: callback-based invoke + move SCXML concerns out of core
Introduce a pythonic callback-based invoke interface where users can define `on_invoke_<state>()` methods, pass `State(invoke="method")` or `State(invoke=callable)` to trigger background work when a state is entered. The callback system resolves these into CallbackInvokeAdapters that the InvokeManager spawns uniformly alongside existing InvokeConfig handlers. SCXML-specific fields (autoforward, namelist, params, finalize) moved from InvokeConfig to SCXMLInvoker, keeping the core invoke module decoupled from SCXML concerns. InvokeManager.handle_external_event now delegates to invoker protocol methods (on_finalize, on_event) via duck-typing.
1 parent 602ad69 commit 0435699

8 files changed

Lines changed: 450 additions & 238 deletions

File tree

INVOKE_PLAN.md

Lines changed: 0 additions & 109 deletions
This file was deleted.

statemachine/callbacks.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ class CallbackGroup(IntEnum):
4646
PREPARE = auto()
4747
ENTER = auto()
4848
EXIT = auto()
49+
INVOKE = auto()
4950
VALIDATOR = auto()
5051
BEFORE = auto()
5152
ON = auto()

statemachine/invoke.py

Lines changed: 80 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -111,10 +111,6 @@ class InvokeConfig:
111111

112112
id: "str | None" = None
113113
idlocation: "str | None" = None
114-
autoforward: bool = False
115-
namelist: "str | None" = None
116-
params: "List[Any]" = field(default_factory=list)
117-
finalize: Any = None
118114

119115

120116
@dataclass
@@ -199,6 +195,52 @@ def _apply_params(child_sm: "StateChart", params: "dict[str, Any]"):
199195
setattr(child_sm.model, name, value)
200196

201197

198+
# ---------------------------------------------------------------------------
199+
# Callback-based invoke adapters
200+
# ---------------------------------------------------------------------------
201+
202+
203+
class CallbackInvokeAdapter:
204+
"""Wraps a resolved CallbackWrapper as an Invoker for InvokeManager.
205+
206+
When the state machine defines ``on_invoke_<state>()`` or passes a
207+
string/callable via ``State(invoke="method")``, the callback system
208+
resolves it into a ``CallbackWrapper``. This adapter bridges it to the
209+
invoke lifecycle so InvokeManager can spawn, track, and cancel it uniformly.
210+
"""
211+
212+
def __init__(self, wrapper):
213+
self._wrapper = wrapper
214+
self._cancelled: "threading.Event | None" = None
215+
216+
def run(self, ctx: InvokeContext) -> Any:
217+
self._cancelled = threading.Event()
218+
return self._wrapper.call(
219+
machine=ctx._parent_sm,
220+
model=ctx._parent_sm.model,
221+
cancelled=self._cancelled,
222+
)
223+
224+
def on_cancel(self):
225+
if self._cancelled is not None:
226+
self._cancelled.set()
227+
228+
229+
class AsyncCallbackInvokeAdapter(CallbackInvokeAdapter):
230+
"""Async variant of :class:`CallbackInvokeAdapter`."""
231+
232+
async def run(self, ctx: InvokeContext) -> Any:
233+
self._cancelled = threading.Event()
234+
result = self._wrapper._callback(
235+
machine=ctx._parent_sm,
236+
model=ctx._parent_sm.model,
237+
cancelled=self._cancelled,
238+
)
239+
if inspect.isawaitable(result):
240+
result = await result
241+
return result
242+
243+
202244
class _ParentBridge:
203245
"""Listener attached to a child SM. Placeholder for future extensions."""
204246

@@ -211,16 +253,6 @@ def __init__(self, ctx: InvokeContext):
211253
# ---------------------------------------------------------------------------
212254

213255

214-
class _TriggerDataAdapter:
215-
"""Adapts a TriggerData to look like EventData for EventDataWrapper."""
216-
217-
def __init__(self, trigger_data: Any):
218-
self.trigger_data = trigger_data
219-
220-
def __getattr__(self, name: str) -> Any:
221-
return getattr(self.trigger_data, name)
222-
223-
224256
class InvokeManager:
225257
"""Manages active invocations for an engine instance.
226258
@@ -238,38 +270,51 @@ def __init__(self, engine: "BaseEngine"):
238270
def sm(self) -> "StateChart":
239271
return self._engine.sm
240272

273+
# --- Config resolution ---
274+
275+
def _get_all_configs(self, state: "State") -> "List[InvokeConfig]":
276+
"""Combine InvokeConfig sources: static ``state.invocations`` and callback-based."""
277+
configs = list(getattr(state, "invocations", None) or [])
278+
callback_configs = getattr(self.sm, "_callback_invocations", {})
279+
configs.extend(callback_configs.get(state.id, []))
280+
return configs
281+
282+
def _has_invocations(self, state: "State") -> bool:
283+
return bool(self._get_all_configs(state))
284+
241285
# --- Engine hooks ---
242286

243287
def on_state_entered(self, state: "State") -> None:
244288
"""Track a state with invocations for post-macrostep spawning."""
245-
if getattr(state, "invocations", None):
289+
if self._has_invocations(state):
246290
self._states_to_invoke.add(state)
247291

248292
def on_state_exiting(self, state: "State") -> None:
249293
"""Cancel invocations and remove from pending spawn set."""
250-
if getattr(state, "invocations", None):
294+
if self._has_invocations(state):
251295
self.cancel_for_state(state)
252296
self._states_to_invoke.discard(state)
253297

254298
def spawn_pending_sync(self, trigger_data: Any) -> None:
255299
"""Spawn invocations for states entered during this macrostep (sync)."""
256300
for state in sorted(self._states_to_invoke, key=lambda s: s.document_order):
257-
for config in state.invocations:
301+
for config in self._get_all_configs(state):
258302
self.spawn_sync(state, config, trigger_data)
259303
self._states_to_invoke.clear()
260304

261305
async def spawn_pending_async(self, trigger_data: Any) -> None:
262306
"""Spawn invocations for states entered during this macrostep (async)."""
263307
for state in sorted(self._states_to_invoke, key=lambda s: s.document_order):
264-
for config in state.invocations:
308+
for config in self._get_all_configs(state):
265309
await self.spawn_async(state, config, trigger_data)
266310
self._states_to_invoke.clear()
267311

268312
def handle_external_event(self, trigger_data: Any) -> bool:
269313
"""Process invoke-related aspects of an external event.
270314
271315
Handles forward_target (returns ``True`` if the event was consumed),
272-
and applies finalize/autoforward as side-effects.
316+
and delegates finalize/autoforward to the invoker via optional
317+
protocol methods (``on_finalize``, ``on_event``).
273318
274319
Returns:
275320
``True`` if the event was forwarded to another session and should
@@ -280,13 +325,23 @@ def handle_external_event(self, trigger_data: Any) -> bool:
280325
self._forward_to_target(trigger_data)
281326
return True
282327

283-
# Handle invoke finalize and autoforward
328+
# Delegate to invokers
284329
for state in self.sm.configuration:
285330
for inv in self.active_for_state(state):
331+
handler = inv.handler
332+
333+
# Finalize: invoker handles it
286334
if trigger_data.invokeid and inv.invokeid == trigger_data.invokeid:
287-
self.apply_finalize(inv, trigger_data)
288-
if inv.config.autoforward and trigger_data.event:
289-
self.forward_event(inv, str(trigger_data.event), trigger_data)
335+
if hasattr(handler, "on_finalize"):
336+
handler.on_finalize(trigger_data)
337+
338+
# Autoforward: invoker handles it
339+
if hasattr(handler, "on_event") and trigger_data.event:
340+
if getattr(handler, "autoforward", False):
341+
try:
342+
handler.on_event(str(trigger_data.event), **trigger_data.kwargs)
343+
except Exception:
344+
logger.exception("Error forwarding event to %s", inv.invokeid)
290345

291346
return False
292347

@@ -330,37 +385,6 @@ def _set_idlocation(self, config: InvokeConfig, invokeid: str):
330385
if config.idlocation:
331386
setattr(self.sm.model, config.idlocation, invokeid)
332387

333-
# --- Param evaluation ---
334-
335-
def _evaluate_params(self, config: InvokeConfig, trigger_data: Any) -> "dict[str, Any]":
336-
"""Evaluate namelist and param expressions in the parent's context."""
337-
params: dict[str, Any] = {}
338-
339-
if config.namelist:
340-
for name in config.namelist.strip().split():
341-
if not hasattr(self.sm.model, name):
342-
raise NameError(f"Namelist variable '{name}' not found on parent model")
343-
params[name] = getattr(self.sm.model, name)
344-
345-
for param in config.params:
346-
if param.expr is not None:
347-
from .io.scxml.actions import _eval
348-
349-
try:
350-
kwargs = {"machine": self.sm, "model": self.sm.model}
351-
kwargs.update(
352-
{
353-
k: v
354-
for k, v in self.sm.model.__dict__.items()
355-
if k not in {"_sessionid", "_ioprocessors", "_name", "_event"}
356-
}
357-
)
358-
params[param.name] = _eval(param.expr, **kwargs)
359-
except Exception:
360-
logger.exception("Error evaluating param %s", param.name)
361-
362-
return params
363-
364388
# --- Handler resolution ---
365389

366390
def _resolve_handler(self, handler: Any) -> Any:
@@ -385,16 +409,9 @@ def spawn_sync(self, state: "State", config: InvokeConfig, trigger_data: Any):
385409
invokeid = self._generate_invokeid(state, config)
386410
self._set_idlocation(config, invokeid)
387411

388-
try:
389-
params = self._evaluate_params(config, trigger_data)
390-
except Exception as e:
391-
# SCXML spec: error in arg evaluation cancels the invocation
392-
logger.debug("Error evaluating invoke params for %s: %s", invokeid, e)
393-
self.sm.send("error.execution", error=e, internal=True)
394-
return
395412
ctx = InvokeContext(
396413
invokeid=invokeid,
397-
params=params,
414+
params={},
398415
send=self._make_parent_send(invokeid),
399416
_parent_sm=self.sm,
400417
)
@@ -470,15 +487,9 @@ async def spawn_async(self, state: "State", config: InvokeConfig, trigger_data:
470487
invokeid = self._generate_invokeid(state, config)
471488
self._set_idlocation(config, invokeid)
472489

473-
try:
474-
params = self._evaluate_params(config, trigger_data)
475-
except Exception as e:
476-
logger.debug("Error evaluating invoke params for %s: %s", invokeid, e)
477-
self.sm.send("error.execution", error=e, internal=True)
478-
return
479490
ctx = InvokeContext(
480491
invokeid=invokeid,
481-
params=params,
492+
params={},
482493
send=self._make_parent_send(invokeid),
483494
_parent_sm=self.sm,
484495
)
@@ -624,36 +635,6 @@ def get_invocation_for_child(self, child_sm: "StateChart") -> "Invocation | None
624635
return inv
625636
return None
626637

627-
# --- Finalize & Autoforward ---
628-
629-
def apply_finalize(self, invocation: Invocation, trigger_data: Any):
630-
"""Execute the finalize block for an invocation before the event is processed."""
631-
config = invocation.config
632-
if config.finalize is None:
633-
return
634-
try:
635-
from .io.scxml.actions import EventDataWrapper
636-
637-
_event = EventDataWrapper(_TriggerDataAdapter(trigger_data))
638-
config.finalize(
639-
machine=self.sm, model=self.sm.model, event_data=trigger_data, _event=_event
640-
)
641-
except Exception:
642-
logger.exception("Error in finalize for %s", invocation.invokeid)
643-
644-
def forward_event(self, invocation: Invocation, event_name: str, trigger_data: Any):
645-
"""Forward an event to a child session (autoforward)."""
646-
handler = invocation.handler
647-
if handler is not None and hasattr(handler, "on_event"):
648-
try:
649-
handler.on_event(event_name, **trigger_data.kwargs)
650-
except Exception:
651-
logger.exception("Error forwarding event to %s", invocation.invokeid)
652-
else:
653-
child_sm = invocation.child_sm or getattr(handler, "_child_sm", None)
654-
if child_sm is not None and not invocation.terminated:
655-
child_sm.send(event_name, **trigger_data.kwargs)
656-
657638
# --- Cross-session sends ---
658639

659640
def send_to_child(self, event_name: str, **kwargs):

0 commit comments

Comments
 (0)