Skip to content

Commit 3ef1794

Browse files
committed
feat: implement SCXML donedata support for final states
Parse <donedata> elements (<param> and <content>) from <final> states, evaluate expressions at runtime, and pass the data correctly to done.state.* events. Error cases (invalid expr/location) raise error.execution per SCXML spec. Fixes W3C conformance tests: 294, 298, 343, 488, 527, 528, 529.
1 parent 84dd67a commit 3ef1794

12 files changed

Lines changed: 107 additions & 299 deletions

File tree

statemachine/engines/base.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -590,23 +590,27 @@ def _enter_states( # noqa: C901
590590
parent = target.parent
591591
grandparent = parent.parent
592592

593-
donedata = {}
593+
donedata_args: tuple = ()
594+
donedata_kwargs: dict = {}
594595
for item in on_entry_result:
595596
if not item:
596597
continue
597-
donedata.update(item)
598+
if isinstance(item, dict):
599+
donedata_kwargs.update(item)
600+
else:
601+
donedata_args = (item,)
598602

599603
BoundEvent(
600604
f"done.state.{parent.id}",
601605
_sm=self.sm,
602606
internal=True,
603-
).put(donedata=donedata)
607+
).put(*donedata_args, **donedata_kwargs)
604608

605609
if grandparent and grandparent.parallel:
606610
if all(self.is_in_final_state(child) for child in grandparent.states):
607611
BoundEvent(
608612
f"done.state.{grandparent.id}", _sm=self.sm, internal=True
609-
).put(donedata=donedata)
613+
).put(*donedata_args, **donedata_kwargs)
610614
return result
611615

612616
def compute_entry_set(

statemachine/io/scxml/actions.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from .schema import CancelAction
2121
from .schema import DataItem
2222
from .schema import DataModel
23+
from .schema import DoneData
2324
from .schema import ExecutableContent
2425
from .schema import ForeachAction
2526
from .schema import Param
@@ -138,8 +139,10 @@ def data(self):
138139
return _Data(self.trigger_data.kwargs)
139140
elif self.trigger_data.args and len(self.trigger_data.args) == 1:
140141
return self.trigger_data.args[0]
141-
else:
142+
elif self.trigger_data.args:
142143
return self.trigger_data.args
144+
else:
145+
return None
143146

144147

145148
def _eval(expr: str, **kwargs) -> Any:
@@ -502,3 +505,30 @@ def __call__(self, *args, **kwargs):
502505
action(*args, **kwargs)
503506

504507
machine._processing_loop()
508+
509+
510+
class DoneDataCallable(CallableAction):
511+
"""Evaluates <donedata> params/content and returns the data for done events."""
512+
513+
def __init__(self, donedata: DoneData):
514+
super().__init__()
515+
self.action = donedata
516+
self.donedata = donedata
517+
518+
def __call__(self, *args, **kwargs):
519+
if self.donedata.content_expr is not None:
520+
return _eval(self.donedata.content_expr, **kwargs)
521+
522+
result = {}
523+
for param in self.donedata.params:
524+
if param.expr is not None:
525+
result[param.name] = _eval(param.expr, **kwargs)
526+
elif param.location is not None:
527+
location = param.location.strip()
528+
try:
529+
result[param.name] = _eval(location, **kwargs)
530+
except Exception as e:
531+
raise ValueError(
532+
f"<param> location '{location}' does not resolve to a valid value"
533+
) from e
534+
return result

statemachine/io/scxml/parser.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from .schema import CancelAction
1010
from .schema import DataItem
1111
from .schema import DataModel
12+
from .schema import DoneData
1213
from .schema import ExecutableContent
1314
from .schema import ForeachAction
1415
from .schema import HistoryState
@@ -183,9 +184,32 @@ def parse_state( # noqa: C901
183184
child_history_state = parse_history(child_state_elem)
184185
state.history[child_history_state.id] = child_history_state
185186

187+
# Parse donedata (only valid on final states)
188+
if is_final:
189+
donedata_elem = state_elem.find("donedata")
190+
if donedata_elem is not None:
191+
state.donedata = parse_donedata(donedata_elem)
192+
186193
return state
187194

188195

196+
def parse_donedata(element: ET.Element) -> DoneData:
197+
"""Parse a <donedata> element containing <param> and/or <content> children."""
198+
params = []
199+
content_expr = None
200+
for child in element:
201+
if child.tag == "param":
202+
name = child.attrib["name"]
203+
expr = child.attrib.get("expr")
204+
location = child.attrib.get("location")
205+
params.append(Param(name=name, expr=expr, location=location))
206+
elif child.tag == "content":
207+
content_expr = child.attrib.get("expr")
208+
if content_expr is None and child.text:
209+
content_expr = re.sub(r"\s+", " ", child.text).strip()
210+
return DoneData(params=params, content_expr=content_expr)
211+
212+
189213
def parse_transition(trans_elem: ET.Element, initial: bool = False) -> Transition:
190214
target = trans_elem.get("target")
191215

statemachine/io/scxml/processor.py

Lines changed: 37 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from .. import TransitionsList
1616
from .. import create_machine_class_from_definition
1717
from .actions import Cond
18+
from .actions import DoneDataCallable
1819
from .actions import EventDataWrapper
1920
from .actions import ExecuteBlock
2021
from .actions import create_datamodel_action_callable
@@ -149,42 +150,44 @@ def _process_history(self, history: Dict[str, HistoryState]) -> Dict[str, Histor
149150
def _process_states(self, states: Dict[str, State]) -> Dict[str, StateDefinition]:
150151
states_dict: Dict[str, StateDefinition] = {}
151152
for state_id, state in states.items():
152-
state_dict = StateDefinition()
153-
if state.initial:
154-
state_dict["initial"] = True
155-
if state.final:
156-
state_dict["final"] = True
157-
if state.parallel:
158-
state_dict["parallel"] = True
159-
160-
# Process enter actions
161-
if state.onentry:
162-
callables = [
163-
ExecuteBlock(content) for content in state.onentry if not content.is_empty
164-
]
165-
state_dict["enter"] = callables
166-
167-
# Process exit actions
168-
if state.onexit:
169-
callables = [
170-
ExecuteBlock(content) for content in state.onexit if not content.is_empty
171-
]
172-
state_dict["exit"] = callables
173-
174-
# Process transitions
175-
if state.transitions:
176-
state_dict["transitions"] = self._process_transitions(state.transitions)
177-
178-
states_dict[state_id] = state_dict
179-
180-
if state.states:
181-
state_dict["states"] = self._process_states(state.states)
182-
183-
if state.history:
184-
state_dict["history"] = self._process_history(state.history)
185-
153+
states_dict[state_id] = self._process_state(state)
186154
return states_dict
187155

156+
def _process_state(self, state: State) -> StateDefinition:
157+
state_dict = StateDefinition()
158+
if state.initial:
159+
state_dict["initial"] = True
160+
if state.final:
161+
state_dict["final"] = True
162+
if state.parallel:
163+
state_dict["parallel"] = True
164+
165+
# Process enter actions + donedata
166+
enter_callables: list = [
167+
ExecuteBlock(content) for content in state.onentry if not content.is_empty
168+
]
169+
if state.final and state.donedata:
170+
enter_callables.append(DoneDataCallable(state.donedata))
171+
if enter_callables:
172+
state_dict["enter"] = enter_callables
173+
174+
# Process exit actions
175+
if state.onexit:
176+
callables = [ExecuteBlock(content) for content in state.onexit if not content.is_empty]
177+
state_dict["exit"] = callables
178+
179+
# Process transitions
180+
if state.transitions:
181+
state_dict["transitions"] = self._process_transitions(state.transitions)
182+
183+
if state.states:
184+
state_dict["states"] = self._process_states(state.states)
185+
186+
if state.history:
187+
state_dict["history"] = self._process_history(state.history)
188+
189+
return state_dict
190+
188191
def _process_transitions(self, transitions: List[Transition]):
189192
result: TransitionsList = []
190193
for transition in transitions:

statemachine/io/scxml/schema.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,12 @@ class Transition:
110110
on: "ExecutableContent | None" = None
111111

112112

113+
@dataclass
114+
class DoneData:
115+
params: List[Param] = field(default_factory=list)
116+
content_expr: "str | None" = None
117+
118+
113119
@dataclass
114120
class State:
115121
id: str
@@ -121,6 +127,7 @@ class State:
121127
onexit: List[ExecutableContent] = field(default_factory=list)
122128
states: Dict[str, "State"] = field(default_factory=dict)
123129
history: Dict[str, "HistoryState"] = field(default_factory=dict)
130+
donedata: "DoneData | None" = None
124131

125132

126133
@dataclass

tests/scxml/w3c/mandatory/test294.fail.md

Lines changed: 0 additions & 37 deletions
This file was deleted.

tests/scxml/w3c/mandatory/test298.fail.md

Lines changed: 0 additions & 36 deletions
This file was deleted.

tests/scxml/w3c/mandatory/test343.fail.md

Lines changed: 0 additions & 36 deletions
This file was deleted.

tests/scxml/w3c/mandatory/test488.fail.md

Lines changed: 0 additions & 41 deletions
This file was deleted.

0 commit comments

Comments
 (0)