Skip to content

Commit 602ad69

Browse files
committed
feat: invoke manager hooks, deferred imports promotion, and session tracking
- Promote `asyncio`, `iterate_states`, and `InvokeManager` imports to top-level (no circular dependency for these). - Replace class-level attr hacks with `InvokeSession` dataclass and `_start` constructor parameter for child StateMachines. - Add engine hook methods on InvokeManager (on_state_entered, on_state_exiting, spawn_pending_sync/async, handle_external_event). - Wire engines to delegate invoke lifecycle to InvokeManager hooks.
1 parent 1ae397d commit 602ad69

10 files changed

Lines changed: 194 additions & 180 deletions

File tree

statemachine/engines/async_.py

Lines changed: 10 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -173,12 +173,8 @@ async def _exit_states( # type: ignore[override]
173173
for info in ordered_states:
174174
args, kwargs = await self._get_args_kwargs(info.transition, trigger_data)
175175

176-
# Cancel invocations for states being exited
177-
if info.state is not None and getattr(info.state, "invocations", None):
178-
self.invoke_manager.cancel_for_state(info.state)
179-
180-
# Remove from states_to_invoke if we exit before invoking
181-
self.states_to_invoke.discard(info.state)
176+
if self._invoke is not None:
177+
self._invoke.on_state_exiting(info.state)
182178

183179
if info.state is not None: # pragma: no branch
184180
await self.sm._callbacks.async_call(
@@ -250,8 +246,8 @@ async def _enter_states( # noqa: C901
250246
)
251247

252248
# Track states with invocations for post-macrostep spawning
253-
if getattr(target, "invocations", None):
254-
self.states_to_invoke.add(target)
249+
if self._invoke is not None:
250+
self._invoke.on_state_entered(target)
255251

256252
# Handle final states
257253
if target.final:
@@ -383,13 +379,8 @@ async def processing_loop( # noqa: C901
383379
await self._run_microstep(enabled_transitions, internal_event)
384380

385381
# Spawn invocations for states entered during this macrostep
386-
for state in sorted(
387-
self.states_to_invoke,
388-
key=lambda s: s.document_order,
389-
):
390-
for config in state.invocations:
391-
await self.invoke_manager.spawn_async(state, config, internal_event)
392-
self.states_to_invoke.clear()
382+
if self._invoke is not None:
383+
await self._invoke.spawn_pending_async(internal_event)
393384

394385
# Phase 2: remaining internal events
395386
while not self.internal_queue.is_empty(): # pragma: no cover
@@ -412,23 +403,13 @@ async def processing_loop( # noqa: C901
412403
# transitions can be processed while we wait.
413404
break
414405

415-
# Forward delayed cross-session events to their target
416-
if external_event.forward_target:
417-
self._forward_to_target(external_event)
418-
continue
406+
# Handle invoke-related event processing (forward, finalize, autoforward)
407+
if self._invoke is not None:
408+
if self._invoke.handle_external_event(external_event):
409+
continue
419410

420411
logger.debug("External event: %s", external_event.event)
421412

422-
# Handle invoke finalize and autoforward
423-
for state in self.sm.configuration:
424-
for inv in self.invoke_manager.active_for_state(state):
425-
if external_event.invokeid and inv.invokeid == external_event.invokeid:
426-
self.invoke_manager.apply_finalize(inv, external_event)
427-
if inv.config.autoforward and external_event.event:
428-
self.invoke_manager.forward_event(
429-
inv, str(external_event.event), external_event
430-
)
431-
432413
# Handle lazy initial state activation.
433414
# Break out of phase 3 so the outer loop restarts from phase 1
434415
# (eventless/internal), ensuring internal events queued during

statemachine/engines/base.py

Lines changed: 21 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,13 @@
2020
from ..event_data import TriggerData
2121
from ..exceptions import InvalidDefinition
2222
from ..exceptions import TransitionNotAllowed
23-
from ..invoke import InvokeManager
2423
from ..orderedset import OrderedSet
2524
from ..state import HistoryState
2625
from ..state import State
2726
from ..transition import Transition
2827

2928
if TYPE_CHECKING:
29+
from ..invoke import InvokeManager
3030
from ..statemachine import StateChart
3131

3232
logger = logging.getLogger(__name__)
@@ -95,8 +95,7 @@ def __init__(self, sm: "StateChart"):
9595
self.running = True
9696
self._processing = Lock()
9797
self._cache: Dict = {} # Cache for _get_args_kwargs results
98-
self.invoke_manager = InvokeManager(self)
99-
self.states_to_invoke: "OrderedSet[State]" = OrderedSet()
98+
self._invoke: "InvokeManager | None" = None # set by StateChart if needed
10099

101100
def empty(self): # pragma: no cover
102101
return self.external_queue.is_empty()
@@ -138,33 +137,6 @@ def cancel_event(self, send_id: str):
138137
"""Cancel the event with the given send_id."""
139138
self.external_queue.remove(send_id)
140139

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-
168140
def _on_error_handler(self) -> "Callable[[Exception], None] | None":
169141
"""Return a per-block error handler, or ``None``.
170142
@@ -528,12 +500,8 @@ def _exit_states(
528500
for info in ordered_states:
529501
args, kwargs = self._get_args_kwargs(info.transition, trigger_data)
530502

531-
# Cancel invocations for states being exited
532-
if info.state is not None and getattr(info.state, "invocations", None):
533-
self.invoke_manager.cancel_for_state(info.state)
534-
535-
# Remove from states_to_invoke if we exit before invoking
536-
self.states_to_invoke.discard(info.state)
503+
if self._invoke is not None:
504+
self._invoke.on_state_exiting(info.state)
537505

538506
# Execute `onexit` handlers — same per-block error isolation as onentry.
539507
if info.state is not None: # pragma: no branch
@@ -602,16 +570,16 @@ def _add_state_to_configuration(self, target: State):
602570
if not self.sm.atomic_configuration_update:
603571
self.sm.configuration |= {target}
604572

605-
def _terminate_machine(self):
606-
"""SCXML termination: exit all active states in exit order, firing onexit handlers.
573+
def _terminate_child_session(self):
574+
"""Exit all active states (firing onexit handlers) and stop the engine.
607575
608-
Per SCXML spec, when a top-level final state is reached, the machine
609-
terminates. All active states have their ``onexit`` handlers fired
610-
(in reverse document order), but the final configuration is preserved
611-
so that observers can see which final state was reached.
576+
Called when a child session (one with ``_invoke_session``) reaches a
577+
top-level final state. Per SCXML spec, all active states have their
578+
``onexit`` handlers fired (in reverse document order), but the final
579+
configuration is preserved so that observers can see which final state
580+
was reached.
612581
"""
613582
on_error = self._on_error_handler()
614-
# Sort active states by document_order (reverse) for exit order
615583
active_states = sorted(
616584
self.sm.configuration,
617585
key=lambda s: s.document_order,
@@ -621,24 +589,24 @@ def _terminate_machine(self):
621589
event_data = EventData(trigger_data=trigger_data, transition=None)
622590
args, kwargs = event_data.args, event_data.extended_kwargs
623591
for state in active_states:
624-
if getattr(state, "invocations", None):
625-
self.invoke_manager.cancel_for_state(state)
592+
if self._invoke is not None:
593+
self._invoke.on_state_exiting(state)
626594
self.sm._callbacks.call(state.exit.key, *args, on_error=on_error, **kwargs)
627-
# Note: we intentionally do NOT clear configuration — the final state
628-
# must remain visible for assertions and done.invoke reporting.
629-
self.invoke_manager.cancel_all()
595+
if self._invoke is not None:
596+
self._invoke.on_terminate()
630597
self.running = False
631598

632599
def _handle_final_state(self, target: State, on_entry_result: list):
633600
"""Handle final state entry: queue done events. No direct callback dispatch."""
634601
if target.parent is None:
635-
if getattr(self.sm, "_parent_sm", None) is not None:
602+
if getattr(self.sm, "_invoke_session", None) is not None:
636603
# Child session: fire onexit on all active states before terminating
637604
# so that #_parent sends in <onexit> of final states are delivered
638-
self._terminate_machine()
605+
self._terminate_child_session()
639606
else:
640607
self.running = False
641-
self.invoke_manager.cancel_all()
608+
if self._invoke is not None:
609+
self._invoke.on_terminate()
642610
else:
643611
parent = target.parent
644612
grandparent = parent.parent
@@ -729,8 +697,8 @@ def _enter_states( # noqa: C901
729697
)
730698

731699
# Track states with invocations for post-macrostep spawning
732-
if getattr(target, "invocations", None):
733-
self.states_to_invoke.add(target)
700+
if self._invoke is not None:
701+
self._invoke.on_state_entered(target)
734702

735703
# Handle final states
736704
if target.final:

statemachine/engines/sync.py

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -108,13 +108,8 @@ def processing_loop(self, caller_future=None): # noqa: C901
108108
self._run_microstep(enabled_transitions, internal_event)
109109

110110
# Spawn invocations for states entered during this macrostep
111-
for state in sorted(
112-
self.states_to_invoke,
113-
key=lambda s: s.document_order,
114-
):
115-
for config in state.invocations:
116-
self.invoke_manager.spawn_sync(state, config, internal_event)
117-
self.states_to_invoke.clear()
111+
if self._invoke is not None:
112+
self._invoke.spawn_pending_sync(internal_event)
118113

119114
# Process remaining internal events before external events.
120115
# Note: the macrostep loop above already drains the internal queue,
@@ -139,23 +134,13 @@ def processing_loop(self, caller_future=None): # noqa: C901
139134
# transitions can be processed while we wait.
140135
break
141136

142-
# Forward delayed cross-session events to their target
143-
if external_event.forward_target:
144-
self._forward_to_target(external_event)
145-
continue
137+
# Handle invoke-related event processing (forward, finalize, autoforward)
138+
if self._invoke is not None:
139+
if self._invoke.handle_external_event(external_event):
140+
continue
146141

147142
logger.debug("External event: %s", external_event.event)
148143

149-
# Handle invoke finalize and autoforward
150-
for state in self.sm.configuration:
151-
for inv in self.invoke_manager.active_for_state(state):
152-
if external_event.invokeid and inv.invokeid == external_event.invokeid:
153-
self.invoke_manager.apply_finalize(inv, external_event)
154-
if inv.config.autoforward and external_event.event:
155-
self.invoke_manager.forward_event(
156-
inv, str(external_event.event), external_event
157-
)
158-
159144
enabled_transitions = self.select_transitions(external_event)
160145
logger.debug("Enabled transitions: %s", enabled_transitions)
161146
if enabled_transitions:

statemachine/event.py

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -98,20 +98,20 @@ def is_same_event(self, *_args, event: "str | None" = None, **_kwargs) -> bool:
9898
if self == event:
9999
return True
100100
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
101+
return self._is_prefix_match(str(event))
113102
return False
114103

104+
def _is_prefix_match(self, event_str: str) -> bool:
105+
"""SCXML prefix matching with dot/underscore normalization.
106+
107+
``'done.invoke.x'`` matches ``'done.invoke.x.uuid'``.
108+
"""
109+
self_dot = str(self).replace("_", ".")
110+
event_dot = event_str.replace("_", ".")
111+
if self_dot == event_dot:
112+
return True
113+
return event_dot.startswith(self_dot + ".")
114+
115115
def _add_callback(self, callback, grouper: CallbackGroup, is_event=False, **kwargs):
116116
if self._transitions is None:
117117
raise InvalidDefinition(
@@ -141,7 +141,6 @@ def put(
141141
*args,
142142
send_id: "str | None" = None,
143143
invokeid: "str | None" = None,
144-
forward_target: "str | None" = None,
145144
**kwargs,
146145
):
147146
# The `__call__` is declared here to help IDEs knowing that an `Event`
@@ -154,7 +153,6 @@ def put(
154153
machine=self._sm,
155154
send_id=send_id,
156155
invokeid=invokeid,
157-
forward_target=forward_target,
158156
**kwargs,
159157
)
160158
self._sm._put_nonblocking(trigger_data, internal=self.internal)
@@ -166,7 +164,6 @@ def build_trigger(
166164
machine: "StateChart",
167165
send_id: "str | None" = None,
168166
invokeid: "str | None" = None,
169-
forward_target: "str | None" = None,
170167
**kwargs,
171168
):
172169
if machine is None:
@@ -178,7 +175,6 @@ def build_trigger(
178175
event=self,
179176
send_id=send_id,
180177
invokeid=invokeid,
181-
forward_target=forward_target,
182178
args=args,
183179
kwargs=kwargs,
184180
)

statemachine/event_data.py

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,6 @@ 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-
4333
model: Any = field(init=False, compare=False)
4434
"""A reference to the underlying model that holds the current :ref:`State`."""
4535

0 commit comments

Comments
 (0)