Skip to content

Commit d7b5414

Browse files
committed
feat: Support for SCXML foreach tag
1 parent 52a104d commit d7b5414

16 files changed

Lines changed: 325 additions & 62 deletions

statemachine/engines/sync.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def processing_loop(self):
7676
self._processing.release()
7777
return first_result if first_result is not self._sentinel else None
7878

79-
def _trigger(self, trigger_data: TriggerData):
79+
def _trigger(self, trigger_data: TriggerData): # noqa: C901
8080
executed = False
8181
if trigger_data.event == "__initial__":
8282
transition = self._initial_transition(trigger_data)
@@ -92,6 +92,8 @@ def _trigger(self, trigger_data: TriggerData):
9292
if not executed:
9393
continue
9494

95+
if self.sm.current_state.transitions.has_eventless_transition:
96+
self.put(TriggerData(self.sm, event=None))
9597
break
9698
else:
9799
if not self.sm.allow_event_without_transition:

statemachine/event_data.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
class TriggerData:
1515
machine: "StateMachine"
1616

17-
event: "Event"
17+
event: "Event | None"
1818
"""The Event that was triggered."""
1919

2020
model: Any = field(init=False)

statemachine/events.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,14 @@ def add(self, events):
3232
return self
3333

3434
def match(self, event: str):
35-
return any(e == event for e in self)
35+
if event is None and self.is_empty:
36+
return True
37+
return any(e == event or e == "*" for e in self)
3638

3739
def _replace(self, old, new):
3840
self._items.remove(old)
3941
self._items.append(new)
42+
43+
@property
44+
def is_empty(self):
45+
return len(self._items) == 0

statemachine/exceptions.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@ class AttrNotFound(InvalidDefinition):
3232
class TransitionNotAllowed(StateMachineError):
3333
"Raised when there's no transition that can run from the current :ref:`state`."
3434

35-
def __init__(self, event: "Event", state: "State"):
35+
def __init__(self, event: "Event | None", state: "State"):
3636
self.event = event
3737
self.state = state
38-
msg = _("Can't {} when in {}.").format(self.event.name, self.state.name)
38+
msg = _("Can't {} when in {}.").format(
39+
self.event and self.event.name or "transition", self.state.name
40+
)
3941
super().__init__(msg)

statemachine/io/__init__.py

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

88

9-
def create_machine_class_from_definition(name: str, definition: dict) -> StateMachine:
9+
def create_machine_class_from_definition(name: str, definition: dict) -> StateMachine: # noqa: C901
1010
"""
1111
Creates a StateMachine class from a dictionary definition, using the StateMachineMetaclass.
1212
@@ -16,32 +16,30 @@ def create_machine_class_from_definition(name: str, definition: dict) -> StateMa
1616
... "TrafficLightMachine",
1717
... {
1818
... "states": {
19-
... "green": {"initial": True},
20-
... "yellow": {},
21-
... "red": {},
22-
... },
23-
... "events": {
24-
... "change": [
25-
... {"from": "green", "to": "yellow"},
26-
... {"from": "yellow", "to": "red"},
27-
... {"from": "red", "to": "green"},
28-
... ]
19+
... "green": {"initial": True, "on": {"change": {"target": "yellow"}}},
20+
... "yellow": {"on": {"change": {"target": "red"}}},
21+
... "red": {"on": {"change": {"target": "green"}}},
2922
... },
3023
... }
3124
... )
3225
3326
"""
27+
states_instances: Dict[str, State] = {}
28+
events_definitions: Dict[str, dict] = {}
29+
30+
for state_id, state_kwargs in definition.pop("states").items():
31+
on_events = state_kwargs.pop("on", {})
32+
if on_events:
33+
events_definitions[state_id] = on_events
3434

35-
states_instances = {
36-
state_id: State(**state_kwargs)
37-
for state_id, state_kwargs in definition.pop("states").items()
38-
}
35+
states_instances[state_id] = State(**state_kwargs)
3936

4037
events: Dict[str, TransitionList] = {}
41-
for event_name, transitions in definition.pop("events").items():
42-
for transition_data in transitions:
43-
source = states_instances[transition_data["from"]]
44-
target = states_instances[transition_data["to"]]
38+
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"]]
4543

4644
transition = source.to(
4745
target,

statemachine/io/scxml.py

Lines changed: 87 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,17 @@
1212
from statemachine.statemachine import StateMachine
1313

1414

15-
def send_event(machine: StateMachine, event_to_send: str) -> None:
16-
machine.send(event_to_send)
17-
18-
1915
def parse_onentry(element):
2016
"""Parses the <onentry> XML into a callable."""
2117
actions = [parse_element(child) for child in element]
2218

2319
def execute_block(*args, **kwargs):
24-
for action in actions:
25-
action(*args, **kwargs)
20+
machine = kwargs["machine"]
21+
try:
22+
for action in actions:
23+
action(*args, **kwargs)
24+
except Exception:
25+
machine.send("error.execution")
2626

2727
return execute_block
2828

@@ -34,6 +34,8 @@ def parse_element(element):
3434
return parse_raise(element)
3535
elif tag == "assign":
3636
return parse_assign(element)
37+
elif tag == "foreach":
38+
return parse_foreach(element)
3739
elif tag == "log":
3840
return parse_log(element)
3941
elif tag == "if":
@@ -80,6 +82,58 @@ def assign_action(*args, **kwargs):
8082
return assign_action
8183

8284

85+
def parse_foreach(element): # noqa: C901
86+
"""
87+
Parses the <foreach> element into a callable.
88+
89+
- `array`: The iterable collection (required).
90+
- `item`: The variable name for the current item (required).
91+
- `index`: The variable name for the current index (optional).
92+
- Child elements are executed for each iteration.
93+
"""
94+
array_expr = element.attrib.get("array")
95+
if not array_expr:
96+
raise ValueError("<foreach> must have an 'array' attribute")
97+
98+
item_var = element.attrib.get("item")
99+
if not item_var:
100+
raise ValueError("<foreach> must have an 'item' attribute")
101+
102+
index_var = element.attrib.get("index")
103+
child_actions = [parse_element(child) for child in element]
104+
105+
def foreach_action(*args, **kwargs): # noqa: C901
106+
machine = kwargs["machine"]
107+
context = {**machine.model.__dict__} # Shallow copy of the model's attributes
108+
109+
try:
110+
# Evaluate the array expression to get the iterable
111+
array = eval(array_expr, {}, context)
112+
if not hasattr(array, "__iter__"):
113+
raise ValueError(
114+
f"<foreach> 'array' must evaluate to an iterable, got: {type(array).__name__}"
115+
)
116+
except Exception as e:
117+
raise ValueError(f"Error evaluating <foreach> 'array' expression: {e}") from e
118+
119+
if not item_var.isidentifier():
120+
raise ValueError(
121+
f"<foreach> 'item' must be a valid Python attribute name, got: {item_var}"
122+
)
123+
# Iterate over the array
124+
for index, item in enumerate(array):
125+
# Assign the item and optionally the index
126+
setattr(machine.model, item_var, item)
127+
if index_var:
128+
setattr(machine.model, index_var, index)
129+
130+
# Execute child actions
131+
for action in child_actions:
132+
action(*args, **kwargs)
133+
134+
return foreach_action
135+
136+
83137
def _normalize_cond(cond: "str | None") -> "str | None":
84138
"""
85139
Normalizes a JavaScript-like condition string to be compatible with Python's eval.
@@ -108,6 +162,19 @@ def _normalize_cond(cond: "str | None") -> "str | None":
108162
return pattern.sub(lambda match: replacements[match.group(0)], cond)
109163

110164

165+
def parse_cond(cond):
166+
"""Parses the <cond> element into a callable."""
167+
cond = _normalize_cond(cond)
168+
if cond is None:
169+
return None
170+
171+
def cond_action(*args, **kwargs):
172+
machine = kwargs["machine"]
173+
return eval(cond, {}, {"machine": machine, **machine.model.__dict__})
174+
175+
return cond_action
176+
177+
111178
def parse_if(element): # noqa: C901
112179
"""Parses the <if> element into a callable."""
113180
branches = []
@@ -198,6 +265,8 @@ def parse_data(element):
198265
"""
199266
data_id = element.attrib["id"]
200267
expr = element.attrib.get("expr")
268+
if not expr:
269+
expr = element.text and element.text.strip()
201270

202271
def data_initializer(model):
203272
# Evaluate the expression if provided, or set to None
@@ -230,19 +299,8 @@ def parse_scxml(scxml_content: str) -> Dict[str, Any]: # noqa: C901
230299
Parse SCXML content and return a dictionary definition compatible with
231300
create_machine_class_from_definition.
232301
233-
The returned dictionary has the format:
234-
{
235-
"states": {
236-
"state_id": {"initial": True},
237-
...
238-
},
239-
"events": {
240-
"event_name": [
241-
{"from": "source_state", "to": "target_state"},
242-
...
243-
]
244-
}
245-
}
302+
The returned dictionary has the format compatible with
303+
:ref:`create_machine_class_from_definition`.
246304
"""
247305
# Parse XML content
248306
root = ET.fromstring(scxml_content)
@@ -258,7 +316,6 @@ def parse_scxml(scxml_content: str) -> Dict[str, Any]: # noqa: C901
258316

259317
# Build states dictionary
260318
states = {}
261-
events: Dict[str, List[Dict[str, str]]] = {}
262319

263320
def _parse_state(state_elem, final=False): # noqa: C901
264321
state_id = state_elem.get("id")
@@ -272,21 +329,21 @@ def _parse_state(state_elem, final=False): # noqa: C901
272329
for trans_elem in state_elem.findall("transition"):
273330
event = trans_elem.get("event") or None
274331
target = trans_elem.get("target")
332+
cond = parse_cond(trans_elem.get("cond"))
275333

276334
if target:
277-
if event not in events:
278-
events[event] = []
335+
state = states[state_id]
336+
if "on" not in state:
337+
state["on"] = {}
338+
339+
if event not in state["on"]:
340+
state["on"][event] = {"target": target}
341+
if cond:
342+
state["on"][event]["cond"] = cond
279343

280344
if target not in states:
281345
states[target] = {}
282346

283-
events[event].append(
284-
{
285-
"from": state_id,
286-
"to": target,
287-
}
288-
)
289-
290347
for onentry_elem in state_elem.findall("onentry"):
291348
entry_action = parse_onentry(onentry_elem)
292349
state = states[state_id]
@@ -316,4 +373,4 @@ def _parse_state(state_elem, final=False): # noqa: C901
316373
first_state = next(iter(states))
317374
states[first_state]["initial"] = True
318375

319-
return {"states": states, "events": events, **extra_data}
376+
return {"states": states, **extra_data}

statemachine/statemachine.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def __init__(
8282
self._callbacks = CallbacksRegistry()
8383
self._states_for_instance: Dict[State, State] = {}
8484

85-
self._listeners: Dict[Any, Any] = {}
85+
self._listeners: Dict[int, Any] = {}
8686
"""Listeners that provides attributes to be used as callbacks."""
8787

8888
if self._abstract:
@@ -182,7 +182,7 @@ def _add_listener(self, listeners: "Listeners", allowed_references: SpecReferenc
182182
return self
183183

184184
def _register_callbacks(self, listeners: List[object]):
185-
self._listeners.update({listener: None for listener in listeners})
185+
self._listeners.update({id(listener): listener for listener in listeners})
186186
self._add_listener(
187187
Listeners.from_listeners(
188188
(
@@ -223,9 +223,9 @@ def add_listener(self, *listeners):
223223
224224
:ref:`listeners`.
225225
"""
226-
self._listeners.update({o: None for o in listeners})
226+
self._listeners.update({id(listener): listener for listener in listeners})
227227
return self._add_listener(
228-
Listeners.from_listeners(Listener.from_obj(o) for o in listeners),
228+
Listeners.from_listeners(Listener.from_obj(listener) for listener in listeners),
229229
allowed_references=SPECS_SAFE,
230230
)
231231

statemachine/transition.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,7 @@ def _copy_with_args(self, **kwargs):
145145
new_transition._specs.add(new_spec, new_spec.group)
146146

147147
return new_transition
148+
149+
@property
150+
def is_eventless(self):
151+
return self._events.is_empty

statemachine/transition_list.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,7 @@ def unique_events(self) -> List["Event"]:
122122
tmp_ordered_unique_events_as_keys_on_dict[event] = True
123123

124124
return list(tmp_ordered_unique_events_as_keys_on_dict.keys())
125+
126+
@property
127+
def has_eventless_transition(self):
128+
return any(transition.is_eventless for transition in self.transitions)

tests/w3c_tests/test_testcases.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1+
from dataclasses import dataclass
2+
from dataclasses import field
3+
4+
from statemachine import State
15
from statemachine import StateMachine
6+
from statemachine.event import Event
27

38
"""
49
Test cases as defined by W3C SCXML Test Suite
@@ -11,12 +16,21 @@
1116
""" # noqa: E501
1217

1318

19+
@dataclass(frozen=True, unsafe_hash=True)
20+
class DebugListener:
21+
events: list = field(default_factory=list)
22+
23+
def on_transition(self, event: Event, source: State, target: State):
24+
self.events.append(f"{source and source.id} --({event and event.id})--> {target.id}")
25+
26+
1427
def test_usecase(testcase_path, sm_class):
1528
# from statemachine.contrib.diagram import DotGraphMachine
1629

1730
# DotGraphMachine(sm_class).get_graph().write_png(
1831
# testcase_path.parent / f"{testcase_path.stem}.png"
1932
# )
20-
sm = sm_class()
33+
debug = DebugListener()
34+
sm = sm_class(listeners=[debug])
2135
assert isinstance(sm, StateMachine)
22-
assert sm.current_state.id == "pass"
36+
assert sm.current_state.id == "pass", debug

0 commit comments

Comments
 (0)