Skip to content

Commit eec118a

Browse files
committed
feat: Support for History pseudo state
1 parent b91306e commit eec118a

20 files changed

+419
-378
lines changed

docs/releases/3.0.0.md

+4
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ To verify the standard adoption, now the automated tests suite includes several
2020
While these are exiting news for the library and our community, it also introduces several backwards incompatible changes. Due to the major version release, the new behaviour is assumed by default, but we put
2121
a lot of effort to minimize the changes needed in your codebase, and also introduced a few configuration options that you can enable to restore the old behaviour when possible. The following sections navigate to the new features and includes a migration guide.
2222

23+
### History pseudo-states
24+
25+
The **History pseudo-state** is a special state that is used to record the configuration of the state machine when leaving a compound state. When the state machine transitions into a history state, it will automatically transition to the state that was previously recorded. This allows the state machine to remember the configuration of its child states.
26+
2327

2428
### Create state machine class from a dict definition
2529

statemachine/contrib/diagram.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,10 @@ def _state_as_node(self, state):
140140
fontsize=self.state_font_size,
141141
peripheries=2 if state.final else 1,
142142
)
143-
if state == self.machine.current_state:
143+
if (
144+
isinstance(self.machine, StateMachine)
145+
and state.value in self.machine.configuration_values
146+
):
144147
node.set_penwidth(self.state_active_penwidth)
145148
node.set_fillcolor(self.state_active_fillcolor)
146149
else:

statemachine/engines/async_.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@ async def processing_loop(self):
6868
async def _trigger(self, trigger_data: TriggerData):
6969
executed = False
7070
if trigger_data.event == "__initial__":
71-
transition = self._initial_transition(trigger_data)
71+
transitions = self._initial_transitions(trigger_data)
72+
# TODO: Async does not support multiple initial state activation yet
73+
transition = transitions[0]
7274
await self._activate(trigger_data, transition)
7375
return self._sentinel
7476

statemachine/engines/base.py

+155-81
Large diffs are not rendered by default.

statemachine/engines/sync.py

+6-4
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,10 @@ def activate_initial_state(self):
3535
"""
3636
if self.sm.current_state_value is None:
3737
trigger_data = BoundEvent("__initial__", _sm=self.sm).build_trigger(machine=self.sm)
38-
transition = self._initial_transition(trigger_data)
38+
transitions = self._initial_transitions(trigger_data)
3939
self._processing.acquire(blocking=False)
4040
try:
41-
self._enter_states([transition], trigger_data, OrderedSet(), OrderedSet())
41+
self._enter_states(transitions, trigger_data, OrderedSet(), OrderedSet())
4242
finally:
4343
self._processing.release()
4444
return self.processing_loop()
@@ -75,6 +75,8 @@ def processing_loop(self): # noqa: C901
7575

7676
# handles eventless transitions and internal events
7777
while not macrostep_done:
78+
logger.debug("Macrostep: eventless/internal queue")
79+
7880
self.clear_cache()
7981
internal_event = TriggerData(
8082
self.sm, event=None
@@ -85,10 +87,9 @@ def processing_loop(self): # noqa: C901
8587
macrostep_done = True
8688
else:
8789
internal_event = self.internal_queue.pop()
88-
8990
enabled_transitions = self.select_transitions(internal_event)
9091
if enabled_transitions:
91-
logger.debug("Eventless/internal queue: %s", enabled_transitions)
92+
logger.debug("Enabled transitions: %s", enabled_transitions)
9293
took_events = True
9394
self.microstep(list(enabled_transitions), internal_event)
9495

@@ -106,6 +107,7 @@ def processing_loop(self): # noqa: C901
106107
self.microstep(list(enabled_transitions), internal_event)
107108

108109
# Process external events
110+
logger.debug("Macrostep: external queue")
109111
while not self.external_queue.is_empty():
110112
self.clear_cache()
111113
took_events = True

statemachine/factory.py

+20-16
Original file line numberDiff line numberDiff line change
@@ -91,34 +91,38 @@ def __init__(
9191

9292
def __getattr__(self, attribute: str) -> Any: ...
9393

94-
def _initials_by_document_order(
94+
def _initials_by_document_order( # noqa: C901
9595
cls, states: List[State], parent: "State | None" = None, order: int = 1
9696
):
9797
"""Set initial state by document order if no explicit initial state is set"""
98-
initial: "State | None" = None
98+
initials: List[State] = []
9999
for s in states:
100100
s.document_order = order
101101
order += 1
102102
if s.states:
103103
cls._initials_by_document_order(s.states, s, order)
104104
if s.initial:
105-
initial = s
106-
if not initial and states:
105+
initials.append(s)
106+
107+
if not initials and states:
107108
initial = states[0]
108109
initial._initial = True
110+
initials.append(initial)
111+
112+
if not parent:
113+
return
114+
115+
for initial in initials:
116+
if not any(t for t in parent.transitions if t.initial and t.target == initial):
117+
parent.to(initial, initial=True)
118+
119+
if not parent.parallel:
120+
return
109121

110-
if (
111-
parent
112-
and initial
113-
and not any(t for t in parent.transitions if t.initial and t.target == initial)
114-
):
115-
parent.to(initial, initial=True)
116-
117-
if parent and parent.parallel:
118-
for state in states:
119-
state._initial = True
120-
if not any(t for t in parent.transitions if t.initial and t.target == state):
121-
parent.to(state, initial=True)
122+
for state in states:
123+
state._initial = True
124+
if not any(t for t in parent.transitions if t.initial and t.target == state):
125+
parent.to(state, initial=True)
122126

123127
def _unpack_builders_callbacks(cls):
124128
callbacks = {}

statemachine/graph.py

+4
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,17 @@ def iterate_states_and_transitions(states: Iterable["State"]):
3131
yield from state.transitions
3232
if state.states:
3333
yield from iterate_states_and_transitions(state.states)
34+
if state.history:
35+
yield from iterate_states_and_transitions(state.history)
3436

3537

3638
def iterate_states(states: Iterable["State"]):
3739
for state in states:
3840
yield state
3941
if state.states:
4042
yield from iterate_states(state.states)
43+
if state.history:
44+
yield from iterate_states(state.history)
4145

4246

4347
def states_without_path_to_final_states(states: Iterable["State"]):

statemachine/io/__init__.py

+72-13
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from typing import cast
1010

1111
from ..factory import StateMachineMetaclass
12+
from ..state import HistoryState
1213
from ..state import State
1314
from ..statemachine import StateMachine
1415
from ..transition_list import TransitionList
@@ -32,6 +33,7 @@ class TransitionDict(TypedDict, total=False):
3233

3334

3435
TransitionsDict = Dict["str | None", List[TransitionDict]]
36+
TransitionsList = List[TransitionDict]
3537

3638

3739
class BaseStateKwargs(TypedDict, total=False):
@@ -46,11 +48,46 @@ class BaseStateKwargs(TypedDict, total=False):
4648

4749
class StateKwargs(BaseStateKwargs, total=False):
4850
states: List[State]
51+
history: List[HistoryState]
52+
53+
54+
class HistoryKwargs(TypedDict, total=False):
55+
name: str
56+
value: Any
57+
deep: bool
58+
59+
60+
class HistoryDefinition(HistoryKwargs, total=False):
61+
on: TransitionsDict
62+
transitions: TransitionsList
4963

5064

5165
class StateDefinition(BaseStateKwargs, total=False):
5266
states: Dict[str, "StateDefinition"]
67+
history: Dict[str, "HistoryDefinition"]
5368
on: TransitionsDict
69+
transitions: TransitionsList
70+
71+
72+
def _parse_history(
73+
states: Mapping[str, "HistoryKwargs |HistoryDefinition"],
74+
) -> Tuple[Dict[str, HistoryState], Dict[str, dict]]:
75+
states_instances: Dict[str, HistoryState] = {}
76+
events_definitions: Dict[str, dict] = {}
77+
for state_id, state_definition in states.items():
78+
state_definition = cast(HistoryDefinition, state_definition)
79+
transition_defs = state_definition.pop("on", {})
80+
transition_list = state_definition.pop("transitions", [])
81+
if transition_list:
82+
transition_defs[None] = transition_list
83+
84+
if transition_defs:
85+
events_definitions[state_id] = transition_defs
86+
87+
state_definition = cast(HistoryKwargs, state_definition)
88+
states_instances[state_id] = HistoryState(**state_definition)
89+
90+
return (states_instances, events_definitions)
5491

5592

5693
def _parse_states(
@@ -59,27 +96,47 @@ def _parse_states(
5996
states_instances: Dict[str, State] = {}
6097
events_definitions: Dict[str, dict] = {}
6198

62-
for state_id, state_kwargs in states.items():
99+
for state_id, state_definition in states.items():
63100
# Process nested states. Replaces `states` as a definition by a list of `State` instances.
64-
inner_states_definitions: Dict[str, StateDefinition] = cast(
65-
StateDefinition, state_kwargs
66-
).pop("states", {})
67-
if inner_states_definitions:
68-
inner_states, inner_events = _parse_states(inner_states_definitions)
101+
state_definition = cast(StateDefinition, state_definition)
102+
103+
# pop the nested states, history and transitions definitions
104+
inner_states_defs: Dict[str, StateDefinition] = state_definition.pop("states", {})
105+
inner_history_defs: Dict[str, HistoryDefinition] = state_definition.pop("history", {})
106+
transition_defs = state_definition.pop("on", {})
107+
transition_list = state_definition.pop("transitions", [])
108+
if transition_list:
109+
transition_defs[None] = transition_list
110+
111+
if inner_states_defs:
112+
inner_states, inner_events = _parse_states(inner_states_defs)
69113

70114
top_level_states = [
71115
state._set_id(state_id)
72116
for state_id, state in inner_states.items()
73117
if not state.parent
74118
]
75-
state_kwargs["states"] = top_level_states # type: ignore
119+
state_definition["states"] = top_level_states # type: ignore
76120
states_instances.update(inner_states)
77121
events_definitions.update(inner_events)
78-
transition_definitions = cast(StateDefinition, state_kwargs).pop("on", {})
79-
if transition_definitions:
80-
events_definitions[state_id] = transition_definitions
81122

82-
states_instances[state_id] = State(**state_kwargs)
123+
if inner_history_defs:
124+
inner_history, inner_events = _parse_history(inner_history_defs)
125+
126+
top_level_history = [
127+
state._set_id(state_id)
128+
for state_id, state in inner_history.items()
129+
if not state.parent
130+
]
131+
state_definition["history"] = top_level_history # type: ignore
132+
states_instances.update(inner_history)
133+
events_definitions.update(inner_events)
134+
135+
if transition_defs:
136+
events_definitions[state_id] = transition_defs
137+
138+
state_definition = cast(BaseStateKwargs, state_definition)
139+
states_instances[state_id] = State(**state_definition)
83140

84141
return (states_instances, events_definitions)
85142

@@ -114,11 +171,13 @@ def create_machine_class_from_definition(
114171

115172
target_state_id = transition_data["target"]
116173
target = states_instances[target_state_id] if target_state_id else None
174+
transition_event_name = transition_data.get("event")
175+
if event_name is not None:
176+
transition_event_name = f"{event_name} {transition_event_name}".strip()
117177

118-
# TODO: Join `trantion_data.event` with `event_name`
119178
transition = source.to(
120179
target,
121-
event=event_name,
180+
event=transition_event_name,
122181
internal=transition_data.get("internal"),
123182
initial=transition_data.get("initial"),
124183
cond=transition_data.get("cond"),

statemachine/io/scxml/actions.py

+6
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,13 @@ def create_datamodel_action_callable(action: DataModel) -> "Callable | None":
472472
if not data_elements:
473473
return None
474474

475+
initialized = False
476+
475477
def datamodel(*args, **kwargs):
478+
nonlocal initialized
479+
if initialized:
480+
return
481+
initialized = True
476482
machine: StateMachine = kwargs["machine"]
477483
for act in data_elements:
478484
try:

0 commit comments

Comments
 (0)