Skip to content

Commit 8c340c4

Browse files
committed
feat: Internal transitions
1 parent 44994ed commit 8c340c4

20 files changed

+158
-57
lines changed

statemachine/engines/async_.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,9 @@ async def _trigger(self, trigger_data: TriggerData):
7272
await self._activate(trigger_data, transition)
7373
return self._sentinel
7474

75-
state = self.sm.current_state
75+
# TODO: Fix async engine
76+
state = next(iter(self.sm.configuration))
77+
7678
for transition in state.transitions:
7779
if not transition.match(trigger_data.event):
7880
continue
@@ -83,7 +85,7 @@ async def _trigger(self, trigger_data: TriggerData):
8385
break
8486
else:
8587
if not self.sm.allow_event_without_transition:
86-
raise TransitionNotAllowed(trigger_data.event, state)
88+
raise TransitionNotAllowed(trigger_data.event, self.sm.configuration)
8789

8890
return result if executed else None
8991

statemachine/engines/base.py

Lines changed: 60 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1+
import logging
12
from dataclasses import dataclass
23
from itertools import chain
34
from queue import PriorityQueue
45
from queue import Queue
56
from threading import Lock
67
from typing import TYPE_CHECKING
8+
from typing import Any
79
from typing import Callable
10+
from typing import Dict
811
from typing import List
12+
from typing import cast
913
from weakref import proxy
1014

1115
from ..event import BoundEvent
@@ -20,6 +24,8 @@
2024
if TYPE_CHECKING:
2125
from ..statemachine import StateMachine
2226

27+
logger = logging.getLogger(__name__)
28+
2329

2430
@dataclass(frozen=True, unsafe_hash=True)
2531
class StateTransition:
@@ -76,7 +82,7 @@ def empty(self):
7682
def put(self, trigger_data: TriggerData, internal: bool = False):
7783
"""Put the trigger on the queue without blocking the caller."""
7884
if not self.running and not self.sm.allow_event_without_transition:
79-
raise TransitionNotAllowed(trigger_data.event, self.sm.current_state)
85+
raise TransitionNotAllowed(trigger_data.event, self.sm.configuration)
8086

8187
if internal:
8288
self.internal_queue.put(trigger_data)
@@ -117,7 +123,9 @@ def _conditions_match(self, transition: Transition, trigger_data: TriggerData):
117123
self.sm._callbacks.call(transition.validators.key, *args, **kwargs)
118124
return self.sm._callbacks.all(transition.cond.key, *args, **kwargs)
119125

120-
def _filter_conflicting_transitions(self, transitions: OrderedSet[Transition]):
126+
def _filter_conflicting_transitions(
127+
self, transitions: OrderedSet[Transition]
128+
) -> OrderedSet[Transition]:
121129
"""
122130
Remove transições conflitantes, priorizando aquelas com estados de origem descendentes
123131
ou que aparecem antes na ordem do documento.
@@ -128,18 +136,18 @@ def _filter_conflicting_transitions(self, transitions: OrderedSet[Transition]):
128136
Returns:
129137
OrderedSet[Transition]: Conjunto de transições sem conflitos.
130138
"""
131-
filtered_transitions = OrderedSet()
139+
filtered_transitions = OrderedSet[Transition]()
132140

133141
# Ordena as transições na ordem dos estados que as selecionaram
134142
for t1 in transitions:
135143
t1_preempted = False
136-
transitions_to_remove = OrderedSet()
144+
transitions_to_remove = OrderedSet[Transition]()
137145

138146
# Verifica conflitos com as transições já filtradas
139147
for t2 in filtered_transitions:
140148
# Calcula os conjuntos de saída (exit sets)
141-
t1_exit_set = self._compute_exit_set(t1)
142-
t2_exit_set = self._compute_exit_set(t2)
149+
t1_exit_set = self._compute_exit_set([t1])
150+
t2_exit_set = self._compute_exit_set([t2])
143151

144152
# Verifica interseção dos conjuntos de saída
145153
if t1_exit_set & t2_exit_set: # Há interseção
@@ -162,7 +170,7 @@ def _filter_conflicting_transitions(self, transitions: OrderedSet[Transition]):
162170
def _compute_exit_set(self, transitions: List[Transition]) -> OrderedSet[StateTransition]:
163171
"""Compute the exit set for a transition."""
164172

165-
states_to_exit = OrderedSet()
173+
states_to_exit = OrderedSet[StateTransition]()
166174

167175
for transition in transitions:
168176
if transition.target is None:
@@ -193,6 +201,8 @@ def get_transition_domain(self, transition: Transition) -> "State | None":
193201
and all(state.is_descendant(transition.source) for state in states)
194202
):
195203
return transition.source
204+
elif transition.internal and transition.is_self and transition.target.is_atomic:
205+
return transition.source
196206
else:
197207
return self.find_lcca([transition.source] + list(states))
198208

@@ -213,6 +223,7 @@ def find_lcca(states: List[State]) -> "State | None":
213223
ancestors = [anc for anc in head.ancestors() if anc.is_compound]
214224

215225
# Find the first ancestor that is also an ancestor of all other states in the list
226+
ancestor: State
216227
for ancestor in ancestors:
217228
if all(state.is_descendant(ancestor) for state in tail):
218229
return ancestor
@@ -233,13 +244,16 @@ def _select_transitions(
233244
self, trigger_data: TriggerData, predicate: Callable
234245
) -> OrderedSet[Transition]:
235246
"""Select the transitions that match the trigger data."""
236-
enabled_transitions = OrderedSet()
247+
enabled_transitions = OrderedSet[Transition]()
237248

238249
# Get atomic states, TODO: sorted by document order
239250
atomic_states = (state for state in self.sm.configuration if state.is_atomic)
240251

241-
def first_transition_that_matches(state: State, event: Event) -> "Transition | None":
252+
def first_transition_that_matches(
253+
state: State, event: "Event | None"
254+
) -> "Transition | None":
242255
for s in chain([state], state.ancestors()):
256+
transition: Transition
243257
for transition in s.transitions:
244258
if (
245259
not transition.initial
@@ -248,6 +262,8 @@ def first_transition_that_matches(state: State, event: Event) -> "Transition | N
248262
):
249263
return transition
250264

265+
return None
266+
251267
for state in atomic_states:
252268
transition = first_transition_that_matches(state, trigger_data.event)
253269
if transition is not None:
@@ -264,6 +280,7 @@ def microstep(self, transitions: List[Transition], trigger_data: TriggerData):
264280
)
265281

266282
states_to_exit = self._exit_states(transitions, trigger_data)
283+
logger.debug("States to exit: %s", states_to_exit)
267284
result += self._execute_transition_content(transitions, trigger_data, lambda t: t.on.key)
268285
self._enter_states(transitions, trigger_data, states_to_exit)
269286
self._execute_transition_content(
@@ -304,7 +321,7 @@ def _exit_states(self, enabled_transitions: List[Transition], trigger_data: Trig
304321
# self.history_values[history.id] = history_value
305322

306323
# Execute `onexit` handlers
307-
if info.source is not None and not info.transition.internal:
324+
if info.source is not None: # TODO: and not info.transition.internal:
308325
self.sm._callbacks.call(info.source.exit.key, *args, **kwargs)
309326

310327
# TODO: Cancel invocations
@@ -343,22 +360,29 @@ def _enter_states(
343360
"""Enter the states as determined by the given transitions."""
344361
states_to_enter = OrderedSet[StateTransition]()
345362
states_for_default_entry = OrderedSet[StateTransition]()
346-
default_history_content = {}
363+
default_history_content: Dict[str, Any] = {}
347364

348365
# Compute the set of states to enter
349366
self.compute_entry_set(
350367
enabled_transitions, states_to_enter, states_for_default_entry, default_history_content
351368
)
352369

353370
# We update the configuration atomically
354-
states_targets_to_enter = OrderedSet(info.target for info in states_to_enter)
371+
states_targets_to_enter = OrderedSet(
372+
info.target for info in states_to_enter if info.target
373+
)
374+
logger.debug("States to enter: %s", states_targets_to_enter)
375+
355376
configuration = self.sm.configuration
356-
self.sm.configuration = (configuration - states_to_exit) | states_targets_to_enter
377+
self.sm.configuration = cast(
378+
OrderedSet[State], (configuration - states_to_exit) | states_targets_to_enter
379+
)
357380

358381
# Sort states to enter in entry order
359382
# for state in sorted(states_to_enter, key=self.entry_order): # TODO: ordegin of states_to_enter # noqa: E501
360383
for info in states_to_enter:
361384
target = info.target
385+
assert target
362386
transition = info.transition
363387
event_data = EventData(trigger_data=trigger_data, transition=transition)
364388
event_data.state = target
@@ -376,8 +400,8 @@ def _enter_states(
376400
# state.is_first_entry = False
377401

378402
# Execute `onentry` handlers
379-
if not transition.internal:
380-
self.sm._callbacks.call(target.enter.key, *args, **kwargs)
403+
# TODO: if not transition.internal:
404+
self.sm._callbacks.call(target.enter.key, *args, **kwargs)
381405

382406
# Handle default initial states
383407
# TODO: Handle default initial states
@@ -396,11 +420,17 @@ def _enter_states(
396420
parent = target.parent
397421
grandparent = parent.parent
398422

399-
self.internal_queue.put(BoundEvent(f"done.state.{parent.id}", _sm=self.sm))
423+
self.internal_queue.put(
424+
BoundEvent(f"done.state.{parent.id}", _sm=self.sm).build_trigger(
425+
machine=self.sm
426+
)
427+
)
400428
if grandparent.parallel:
401429
if all(child.final for child in grandparent.states):
402430
self.internal_queue.put(
403-
BoundEvent(f"done.state.{parent.id}", _sm=self.sm)
431+
BoundEvent(f"done.state.{parent.id}", _sm=self.sm).build_trigger(
432+
machine=self.sm
433+
)
404434
)
405435

406436
def compute_entry_set(
@@ -476,8 +506,19 @@ def add_descendant_states_to_enter(
476506
# return
477507

478508
# Add the state to the entry set
479-
states_to_enter.add(info)
509+
if (
510+
not self.sm.enable_self_transition_entries
511+
and info.transition.internal
512+
and (
513+
info.transition.is_self
514+
or info.transition.target.is_descendant(info.transition.source)
515+
)
516+
):
517+
pass
518+
else:
519+
states_to_enter.add(info)
480520
state = info.target
521+
assert state
481522

482523
if state.is_compound:
483524
# Handle compound states
@@ -541,6 +582,7 @@ def add_ancestor_states_to_enter(
541582
default_history_content: A dictionary to hold temporary content for history states.
542583
"""
543584
state = info.target
585+
assert state
544586
for anc in state.ancestors(parent=ancestor):
545587
# Add the ancestor to the entry set
546588
info_to_add = StateTransition(

statemachine/engines/sync.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
from time import sleep
23
from time import time
34
from typing import TYPE_CHECKING
@@ -12,6 +13,8 @@
1213
if TYPE_CHECKING:
1314
from ..transition import Transition
1415

16+
logger = logging.getLogger(__name__)
17+
1518

1619
class SyncEngine(BaseEngine):
1720
def start(self):
@@ -58,6 +61,7 @@ def processing_loop(self): # noqa: C901
5861
# We will collect the first result as the processing result to keep backwards compatibility
5962
# so we need to use a sentinel object instead of `None` because the first result may
6063
# be also `None`, and on this case the `first_result` may be overridden by another result.
64+
logger.debug("Processing loop started: %s", self.sm.current_state_value)
6165
first_result = self._sentinel
6266
try:
6367
took_events = True
@@ -82,6 +86,7 @@ def processing_loop(self): # noqa: C901
8286

8387
enabled_transitions = self.select_transitions(internal_event)
8488
if enabled_transitions:
89+
logger.debug("Eventless/internal queue: %s", enabled_transitions)
8590
took_events = True
8691
self.microstep(list(enabled_transitions), internal_event)
8792

@@ -102,6 +107,7 @@ def processing_loop(self): # noqa: C901
102107
while not self.external_queue.is_empty():
103108
took_events = True
104109
external_event = self.external_queue.pop()
110+
logger.debug("External event: %s", external_event)
105111
current_time = time()
106112
if external_event.execution_time > current_time:
107113
self.put(external_event)
@@ -122,6 +128,7 @@ def processing_loop(self): # noqa: C901
122128
# self.send(inv.id, external_event)
123129

124130
enabled_transitions = self.select_transitions(external_event)
131+
logger.debug("Enabled transitions: %s", enabled_transitions)
125132
if enabled_transitions:
126133
try:
127134
result = self.microstep(list(enabled_transitions), external_event)
@@ -136,7 +143,7 @@ def processing_loop(self): # noqa: C901
136143

137144
else:
138145
if not self.sm.allow_event_without_transition:
139-
raise TransitionNotAllowed(external_event.event, self.sm.current_state)
146+
raise TransitionNotAllowed(external_event.event, self.sm.configuration)
140147

141148
finally:
142149
self._processing.release()

statemachine/exceptions.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from typing import TYPE_CHECKING
2+
from typing import MutableSet
23

34
from .i18n import _
45

@@ -30,12 +31,13 @@ class AttrNotFound(InvalidDefinition):
3031

3132

3233
class TransitionNotAllowed(StateMachineError):
33-
"Raised when there's no transition that can run from the current :ref:`state`."
34+
"Raised when there's no transition that can run from the current :ref:`configuration`."
3435

35-
def __init__(self, event: "Event | None", state: "State"):
36+
def __init__(self, event: "Event | None", configuration: MutableSet["State"]):
3637
self.event = event
37-
self.state = state
38+
self.configuration = configuration
39+
name = ", ".join([s.name for s in configuration])
3840
msg = _("Can't {} when in {}.").format(
39-
self.event and self.event.name or "transition", self.state.name
41+
self.event and self.event.name or "transition", name
4042
)
4143
super().__init__(msg)

statemachine/factory.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def __init__(
5252
if not cls.states:
5353
return
5454

55-
cls._initials_by_document_order(cls.states, parent=None)
55+
cls._initials_by_document_order(list(cls.states), parent=None)
5656

5757
initials = [s for s in cls.states if s.initial]
5858
parallels = [s.id for s in cls.states if s.parallel]
@@ -81,7 +81,7 @@ def __init__(
8181

8282
def __getattr__(self, attribute: str) -> Any: ...
8383

84-
def _initials_by_document_order(cls, states, parent: "State | None" = None):
84+
def _initials_by_document_order(cls, states: List[State], parent: "State | None" = None):
8585
"""Set initial state by document order if no explicit initial state is set"""
8686
initial: "State | None" = None
8787
for s in states:

statemachine/io/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ def create_machine_class_from_definition(
118118
transition = source.to(
119119
target,
120120
event=event_name,
121+
internal=transition_data.get("internal"),
121122
initial=transition_data.get("initial"),
122123
cond=transition_data.get("cond"),
123124
unless=transition_data.get("unless"),

statemachine/io/scxml/actions.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,10 @@ def __call__(self, *args, **kwargs):
166166
kwargs["_ioprocessors"] = self.processor.wrap(**kwargs)
167167

168168
try:
169-
return _eval(self.action, **kwargs)
169+
result = _eval(self.action, **kwargs)
170+
logger.debug("Cond %s -> %s", self.action, result)
171+
return result
172+
170173
except Exception as e:
171174
machine.send("error.execution", error=e, internal=True)
172175
return False
@@ -238,6 +241,7 @@ def __call__(self, *args, **kwargs):
238241
f"got: {self.action.location}"
239242
)
240243
setattr(obj, attr, value)
244+
logger.debug(f"Assign: {self.action.location} = {value!r}")
241245

242246

243247
class Log(CallableAction):

0 commit comments

Comments
 (0)