Skip to content

Commit 52c44c7

Browse files
committed
fix: Trigger eventless transition at startup
1 parent d7b5414 commit 52c44c7

7 files changed

Lines changed: 101 additions & 46 deletions

File tree

statemachine/engines/sync.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ def _trigger(self, trigger_data: TriggerData): # noqa: C901
8181
if trigger_data.event == "__initial__":
8282
transition = self._initial_transition(trigger_data)
8383
self._activate(trigger_data, transition)
84+
if self.sm.current_state.transitions.has_eventless_transition:
85+
self.put(TriggerData(self.sm, event=None))
8486
return self._sentinel
8587

8688
state = self.sm.current_state

statemachine/factory.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,20 +61,12 @@ def __getattr__(self, attribute: str) -> Any: ...
6161

6262
def _check(cls):
6363
has_states = bool(cls.states)
64-
has_events = bool(cls._events)
65-
66-
cls._abstract = not has_states and not has_events
64+
cls._abstract = not has_states
6765

6866
# do not validate the base abstract classes
6967
if cls._abstract:
7068
return
7169

72-
if not has_states:
73-
raise InvalidDefinition(_("There are no states."))
74-
75-
if not has_events:
76-
raise InvalidDefinition(_("There are no events."))
77-
7870
cls._check_initial_state()
7971
cls._check_final_states()
8072
cls._check_disconnected_state()
@@ -90,6 +82,8 @@ def _check_initial_state(cls):
9082
"You currently have these: {!r}"
9183
).format([s.id for s in initials])
9284
)
85+
if not initials[0].transitions.transitions:
86+
raise InvalidDefinition(_("There are no transitions."))
9387

9488
def _check_final_states(cls):
9589
final_state_with_invalid_transitions = [

statemachine/io/__init__.py

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,19 @@
66
from ..transition_list import TransitionList
77

88

9-
def create_machine_class_from_definition(name: str, definition: dict) -> StateMachine: # noqa: C901
9+
def create_machine_class_from_definition(name: str, **definition) -> StateMachine: # noqa: C901
1010
"""
1111
Creates a StateMachine class from a dictionary definition, using the StateMachineMetaclass.
1212
1313
Example usage with a traffic light machine:
1414
1515
>>> machine = create_machine_class_from_definition(
1616
... "TrafficLightMachine",
17-
... {
17+
... **{
1818
... "states": {
19-
... "green": {"initial": True, "on": {"change": {"target": "yellow"}}},
20-
... "yellow": {"on": {"change": {"target": "red"}}},
21-
... "red": {"on": {"change": {"target": "green"}}},
19+
... "green": {"initial": True, "on": {"change": [{"target": "yellow"}]}},
20+
... "yellow": {"on": {"change": [{"target": "red"}]}},
21+
... "red": {"on": {"change": [{"target": "green"}]}},
2222
... },
2323
... }
2424
... )
@@ -36,25 +36,26 @@ def create_machine_class_from_definition(name: str, definition: dict) -> StateMa
3636

3737
events: Dict[str, TransitionList] = {}
3838
for state_id, state_events in events_definitions.items():
39-
for event_name, transition_data in state_events.items():
40-
source = states_instances[state_id]
41-
42-
target = states_instances[transition_data["target"]]
43-
44-
transition = source.to(
45-
target,
46-
event=event_name,
47-
cond=transition_data.get("cond"),
48-
unless=transition_data.get("unless"),
49-
on=transition_data.get("on"),
50-
before=transition_data.get("before"),
51-
after=transition_data.get("after"),
52-
)
53-
54-
if event_name in events:
55-
events[event_name] |= transition
56-
elif event_name is not None:
57-
events[event_name] = transition
39+
for event_name, transitions_data in state_events.items():
40+
for trantion_data in transitions_data:
41+
source = states_instances[state_id]
42+
43+
target = states_instances[trantion_data["target"]]
44+
45+
transition = source.to(
46+
target,
47+
event=event_name,
48+
cond=trantion_data.get("cond"),
49+
unless=trantion_data.get("unless"),
50+
on=trantion_data.get("on"),
51+
before=trantion_data.get("before"),
52+
after=trantion_data.get("after"),
53+
)
54+
55+
if event_name in events:
56+
events[event_name] |= transition
57+
elif event_name is not None:
58+
events[event_name] = transition
5859

5960
attrs_mapper = {**definition, **states_instances, **events}
6061
return StateMachineMetaclass(name, (StateMachine,), attrs_mapper) # type: ignore[return-value]

statemachine/io/scxml.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22
Simple SCXML parser that converts SCXML documents to state machine definitions.
33
"""
44

5+
import html
56
import re
67
import xml.etree.ElementTree as ET
78
from typing import Any
89
from typing import Dict
910
from typing import List
1011

11-
from statemachine.model import Model
12-
from statemachine.statemachine import StateMachine
12+
from ..model import Model
13+
from ..statemachine import StateMachine
1314

1415

1516
def parse_onentry(element):
@@ -147,6 +148,9 @@ def _normalize_cond(cond: "str | None") -> "str | None":
147148
if cond is None:
148149
return None
149150

151+
# Decode HTML entities, to allow XML syntax like `Var1<Var2`
152+
cond = html.unescape(cond)
153+
150154
replacements = {
151155
"true": "True",
152156
"false": "False",
@@ -172,6 +176,8 @@ def cond_action(*args, **kwargs):
172176
machine = kwargs["machine"]
173177
return eval(cond, {}, {"machine": machine, **machine.model.__dict__})
174178

179+
cond_action.cond = cond
180+
175181
return cond_action
176182

177183

@@ -180,7 +186,7 @@ def parse_if(element): # noqa: C901
180186
branches = []
181187
else_branch = []
182188

183-
current_cond = _normalize_cond(element.attrib.get("cond"))
189+
current_cond = parse_cond(element.attrib.get("cond"))
184190
current_actions = []
185191

186192
for child in element:
@@ -192,7 +198,7 @@ def parse_if(element): # noqa: C901
192198

193199
# Update for the new branch
194200
if tag == "elseif":
195-
current_cond = _normalize_cond(child.attrib.get("cond"))
201+
current_cond = parse_cond(child.attrib.get("cond"))
196202
current_actions = []
197203
elif tag == "else":
198204
current_cond = None
@@ -208,10 +214,9 @@ def parse_if(element): # noqa: C901
208214
else_branch = current_actions
209215

210216
def if_action(*args, **kwargs):
211-
machine = kwargs["machine"]
212217
# Evaluate each branch in order
213218
for cond, actions in branches:
214-
if eval(cond, {}, {"machine": machine, **machine.model.__dict__}):
219+
if cond(*args, **kwargs):
215220
for action in actions:
216221
action(*args, **kwargs)
217222
return
@@ -337,9 +342,15 @@ def _parse_state(state_elem, final=False): # noqa: C901
337342
state["on"] = {}
338343

339344
if event not in state["on"]:
340-
state["on"][event] = {"target": target}
341-
if cond:
342-
state["on"][event]["cond"] = cond
345+
state["on"][event] = []
346+
347+
transitions = state["on"][event]
348+
349+
transition = {"target": target}
350+
if cond:
351+
transition["cond"] = cond
352+
353+
transitions.append(transition)
343354

344355
if target not in states:
345356
states[target] = {}

tests/test_statemachine.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -349,10 +349,12 @@ class EmptyMachine(StateMachine):
349349

350350
def test_should_not_create_instance_of_machine_without_states():
351351
s1 = State()
352-
with pytest.raises(exceptions.InvalidDefinition):
353352

354-
class OnlyTransitionMachine(StateMachine):
355-
t1 = s1.to.itself()
353+
class OnlyTransitionMachine(StateMachine):
354+
t1 = s1.to.itself()
355+
356+
with pytest.raises(exceptions.InvalidDefinition):
357+
OnlyTransitionMachine()
356358

357359

358360
def test_should_not_create_instance_of_machine_without_transitions():

tests/w3c_tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def sm_class(testcase_path: Path):
2323

2424
# Create state machine class
2525
try:
26-
return create_machine_class_from_definition(testcase_path.stem, definition)
26+
return create_machine_class_from_definition(testcase_path.stem, **definition)
2727
except Exception as e:
2828
raise Exception(
2929
f"Failed to create state machine class: {e} from definition: {definition}"
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!-- test that foreach goes over the array in the right order. since the array contains 1 2 3, we
3+
compare the current
4+
value with the previous value, which is stored in var1. The current value should always be larger.
5+
If
6+
it ever isn't, set Var4 to 0, indicating failure -->
7+
<scxml xmlns="http://www.w3.org/2005/07/scxml" xmlns:conf="http://www.w3.org/2005/scxml-conformance"
8+
initial="s0" version="1.0" datamodel="ecmascript">
9+
<datamodel>
10+
<data id="Var1" expr="0" />
11+
<!-- contains the previous value -->
12+
<data id="Var2" />
13+
<!-- the item which will contain the current value -->
14+
<data id="Var3">
15+
[1,2,3]
16+
</data>
17+
<data id="Var4" expr="1" />
18+
<!-- 1 if success, 0 if failure -->
19+
</datamodel>
20+
<state id="s0">
21+
<onentry>
22+
<foreach item="Var2" array="Var3">
23+
<if cond="Var1&lt;Var2">
24+
<assign location="Var1" expr="Var2" />
25+
<else />
26+
<!-- values are out of order, record failure -->
27+
<assign location="Var4" expr="0" />
28+
</if>
29+
</foreach>
30+
</onentry>
31+
<!-- check that var1 has its original value -->
32+
<transition cond="Var4==0" target="fail" />
33+
<transition target="pass" />
34+
</state>
35+
<final id="pass">
36+
<onentry>
37+
<log label="Outcome" expr="'pass'" />
38+
</onentry>
39+
</final>
40+
<final id="fail">
41+
<onentry>
42+
<log label="Outcome" expr="'fail'" />
43+
</onentry>
44+
</final>
45+
</scxml>

0 commit comments

Comments
 (0)