python-statemachine 3.1.2__py3-none-any.whl → 3.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. {python_statemachine-3.1.2.dist-info → python_statemachine-3.2.0.dist-info}/METADATA +16 -3
  2. python_statemachine-3.2.0.dist-info/RECORD +72 -0
  3. {python_statemachine-3.1.2.dist-info → python_statemachine-3.2.0.dist-info}/WHEEL +1 -1
  4. statemachine/__init__.py +1 -1
  5. statemachine/callbacks.py +5 -11
  6. statemachine/configuration.py +5 -6
  7. statemachine/contrib/diagram/extract.py +23 -24
  8. statemachine/contrib/diagram/formatter.py +5 -7
  9. statemachine/contrib/diagram/model.py +9 -11
  10. statemachine/contrib/diagram/renderers/dot.py +20 -26
  11. statemachine/contrib/diagram/renderers/mermaid.py +36 -40
  12. statemachine/contrib/diagram/renderers/table.py +7 -9
  13. statemachine/contrib/weighted.py +7 -11
  14. statemachine/dispatcher.py +13 -12
  15. statemachine/engines/async_.py +5 -6
  16. statemachine/engines/base.py +12 -14
  17. statemachine/event.py +1 -2
  18. statemachine/exceptions.py +1 -1
  19. statemachine/factory.py +11 -15
  20. statemachine/graph.py +2 -2
  21. statemachine/invoke.py +12 -11
  22. statemachine/io/__init__.py +45 -225
  23. statemachine/io/{scxml/actions.py → actions.py} +158 -288
  24. statemachine/io/builder.py +195 -0
  25. statemachine/io/class_factory.py +236 -0
  26. statemachine/io/evaluators.py +275 -0
  27. statemachine/io/interpreter.py +128 -0
  28. statemachine/io/{scxml/invoke.py → invoke.py} +77 -49
  29. statemachine/io/json/__init__.py +1 -0
  30. statemachine/io/json/reader.py +27 -0
  31. statemachine/io/loader.py +161 -0
  32. statemachine/io/model.py +268 -0
  33. statemachine/io/native.py +402 -0
  34. statemachine/io/ports.py +83 -0
  35. statemachine/io/schemas/statechart.schema.json +258 -0
  36. statemachine/io/scxml/__init__.py +12 -0
  37. statemachine/io/scxml/processor.py +23 -253
  38. statemachine/io/scxml/{parser.py → reader.py} +64 -47
  39. statemachine/io/system_variables.py +184 -0
  40. statemachine/io/validation.py +44 -0
  41. statemachine/io/yaml/__init__.py +1 -0
  42. statemachine/io/yaml/reader.py +65 -0
  43. statemachine/locale/en/LC_MESSAGES/statemachine.po +19 -19
  44. statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po +19 -19
  45. statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po +19 -19
  46. statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po +19 -19
  47. statemachine/orderedset.py +3 -3
  48. statemachine/registry.py +1 -4
  49. statemachine/signature.py +2 -5
  50. statemachine/spec_parser.py +171 -42
  51. statemachine/state.py +5 -6
  52. statemachine/statemachine.py +18 -20
  53. statemachine/states.py +3 -5
  54. statemachine/transition.py +3 -4
  55. statemachine/transition_list.py +4 -5
  56. statemachine/transition_mixin.py +1 -1
  57. python_statemachine-3.1.2.dist-info/RECORD +0 -58
  58. statemachine/io/scxml/schema.py +0 -175
  59. {python_statemachine-3.1.2.dist-info → python_statemachine-3.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,49 +1,45 @@
1
- import html
1
+ """Turn neutral-IR :mod:`~statemachine.io.model` actions into executable callables.
2
+
3
+ Each :class:`~statemachine.io.model.Action` is compiled into a callable by
4
+ :func:`create_action_callable`, using an :class:`~statemachine.io.evaluators.Evaluator`
5
+ to evaluate the expression/script strings it carries (secure by default). This layer
6
+ is format-neutral: it is shared by the SCXML, JSON and YAML readers.
7
+ """
8
+
2
9
  import logging
3
10
  import re
4
- from dataclasses import dataclass
11
+ from collections.abc import Callable
5
12
  from itertools import chain
6
13
  from typing import Any
7
- from typing import Callable
8
14
  from uuid import uuid4
9
15
 
10
- from ...event import BoundEvent
11
- from ...event import Event
12
- from ...event import _event_data_kwargs
13
- from ...spec_parser import InState
14
- from ...statemachine import StateChart
15
- from .parser import Action
16
- from .parser import AssignAction
17
- from .parser import IfAction
18
- from .parser import LogAction
19
- from .parser import RaiseAction
20
- from .parser import SendAction
21
- from .schema import CancelAction
22
- from .schema import DataItem
23
- from .schema import DataModel
24
- from .schema import DoneData
25
- from .schema import ExecutableContent
26
- from .schema import ForeachAction
27
- from .schema import Param
28
- from .schema import ScriptAction
16
+ from ..event import BoundEvent
17
+ from ..event import Event
18
+ from ..statemachine import StateChart
19
+ from .evaluators import Evaluator
20
+ from .evaluators import protected_attrs
21
+ from .model import Action
22
+ from .model import AssignAction
23
+ from .model import CancelAction
24
+ from .model import DataItem
25
+ from .model import DataModel
26
+ from .model import DoneData
27
+ from .model import ExecutableContent
28
+ from .model import ForeachAction
29
+ from .model import IfAction
30
+ from .model import LogAction
31
+ from .model import Param
32
+ from .model import RaiseAction
33
+ from .model import ScriptAction
34
+ from .model import SendAction
29
35
 
30
36
  logger = logging.getLogger(__name__)
31
37
  _debug = logger.debug if logger.isEnabledFor(logging.DEBUG) else lambda *a, **k: None
32
- protected_attrs = _event_data_kwargs | {"_sessionid", "_ioprocessors", "_name", "_event"}
33
38
 
34
39
 
35
40
  class ParseTime:
36
- pattern = re.compile(r"(\d+)?(\.\d+)?(s|ms)")
37
-
38
- @classmethod
39
- def parse_delay(cls, delay: "str | None", delayexpr: "str | None", **kwargs):
40
- if delay:
41
- return cls.time_in_ms(delay)
42
- elif delayexpr:
43
- delay_expr_expanded = cls.replace(delayexpr)
44
- return cls.time_in_ms(_eval(delay_expr_expanded, **kwargs))
45
-
46
- return 0
41
+ pattern = re.compile(r"(\d+(?:\.\d+)?|\.\d+)(s|ms)")
42
+ """CSS2 time literal: a number (``5``, ``1.5``, ``.5``) followed by ``s`` or ``ms``."""
47
43
 
48
44
  @classmethod
49
45
  def replace(cls, expr: str) -> str:
@@ -83,102 +79,6 @@ class ParseTime:
83
79
  raise ValueError(f"Invalid time unit in: {expr}") from e
84
80
 
85
81
 
86
- @dataclass
87
- class _Data:
88
- kwargs: dict
89
-
90
- def __getattr__(self, name):
91
- return self.kwargs.get(name, None)
92
-
93
- def get(self, name, default=None):
94
- return self.kwargs.get(name, default)
95
-
96
-
97
- class OriginTypeSCXML(str):
98
- """The origintype of the :ref:`Event` as specified by the SCXML namespace."""
99
-
100
- def __eq__(self, other):
101
- return other == "http://www.w3.org/TR/scxml/#SCXMLEventProcessor" or other == "scxml"
102
-
103
-
104
- class EventDataWrapper:
105
- origin: str = ""
106
- origintype: str = OriginTypeSCXML("scxml")
107
- """The origintype of the :ref:`Event` as specified by the SCXML namespace."""
108
- invokeid: str = ""
109
- """If this event is generated from an invoked child process, the SCXML Processor MUST set
110
- this field to the invoke id of the invocation that triggered the child process.
111
- Otherwise it MUST leave it blank.
112
- """
113
-
114
- def __init__(self, event_data=None, *, trigger_data=None):
115
- self.event_data = event_data
116
- if trigger_data is not None:
117
- self.trigger_data = trigger_data
118
- elif event_data is not None:
119
- self.trigger_data = event_data.trigger_data
120
- else:
121
- raise ValueError("Either event_data or trigger_data must be provided")
122
-
123
- td = self.trigger_data
124
- self.sendid = td.send_id
125
- self.invokeid = td.kwargs.get("_invokeid", "")
126
- if td.event is None or td.event.internal:
127
- if "error.execution" == td.event:
128
- self.type = "platform"
129
- else:
130
- self.type = "internal"
131
- self.origintype = ""
132
- else:
133
- self.type = "external"
134
-
135
- @classmethod
136
- def from_trigger_data(cls, trigger_data):
137
- """Create an EventDataWrapper directly from a TriggerData (no EventData needed)."""
138
- return cls(trigger_data=trigger_data)
139
-
140
- def __getattr__(self, name):
141
- if self.event_data is not None:
142
- return getattr(self.event_data, name)
143
- raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
144
-
145
- def __eq__(self, value):
146
- "This makes SCXML test 329 pass. It assumes that the event is the same instance"
147
- return isinstance(value, EventDataWrapper)
148
-
149
- @property
150
- def name(self):
151
- if self.event_data is not None:
152
- return self.event_data.event
153
- return str(self.trigger_data.event) if self.trigger_data.event else None
154
-
155
- @property
156
- def data(self):
157
- "Property used by the SCXML namespace"
158
- td = self.trigger_data
159
- if td.kwargs:
160
- return _Data(td.kwargs)
161
- elif td.args and len(td.args) == 1:
162
- return td.args[0]
163
- elif td.args:
164
- return td.args
165
- else:
166
- return None
167
-
168
-
169
- def _eval(expr: str, **kwargs) -> Any:
170
- if "machine" in kwargs:
171
- kwargs.update(
172
- **{
173
- k: v
174
- for k, v in kwargs["machine"].model.__dict__.items()
175
- if k not in protected_attrs
176
- }
177
- )
178
- kwargs["In"] = InState(kwargs["machine"])
179
- return eval(expr, {}, kwargs)
180
-
181
-
182
82
  class CallableAction:
183
83
  action: Any
184
84
 
@@ -207,81 +107,61 @@ class Cond(CallableAction):
207
107
  """Evaluates a condition like a predicate and returns True or False."""
208
108
 
209
109
  @classmethod
210
- def create(cls, cond: "str | None", processor=None):
211
- cond = cls._normalize(cond)
110
+ def create(cls, cond: "str | None", evaluator: Evaluator, processor=None):
212
111
  if cond is None:
213
112
  return None
113
+ return cls(cond, evaluator, processor)
214
114
 
215
- return cls(cond, processor)
216
-
217
- def __init__(self, cond: str, processor=None):
115
+ def __init__(self, cond: str, evaluator: Evaluator, processor=None):
218
116
  super().__init__()
219
117
  self.action = cond
220
118
  self.processor = processor
119
+ self._cond = evaluator.compile_bool(cond)
221
120
 
222
121
  def __call__(self, *args, **kwargs):
223
- result = _eval(self.action, **kwargs)
122
+ result = self._cond(*args, **kwargs)
224
123
  _debug("Cond %s -> %s", self.action, result)
225
124
  return result
226
125
 
227
- @staticmethod
228
- def _normalize(cond: "str | None") -> "str | None":
229
- """
230
- Normalizes a JavaScript-like condition string to be compatible with Python's eval.
231
- """
232
- if cond is None:
233
- return None
234
126
 
235
- # Decode HTML entities, to allow XML syntax like `Var1<Var2`
236
- cond = html.unescape(cond)
237
-
238
- replacements = {
239
- "true": "True",
240
- "false": "False",
241
- "null": "None",
242
- "===": "==",
243
- "!==": "!=",
244
- "&&": "and",
245
- "||": "or",
246
- }
247
-
248
- # Use regex to replace each JavaScript-like token with its Python equivalent
249
- pattern = re.compile(r"\b(?:true|false|null)\b|===|!==|&&|\|\|")
250
- return pattern.sub(lambda match: replacements[match.group(0)], cond)
251
-
252
-
253
- def create_action_callable(action: Action) -> Callable:
254
- if isinstance(action, RaiseAction):
255
- return create_raise_action_callable(action)
256
- elif isinstance(action, AssignAction):
257
- return Assign(action)
258
- elif isinstance(action, LogAction):
259
- return Log(action)
260
- elif isinstance(action, IfAction):
261
- return create_if_action_callable(action)
262
- elif isinstance(action, ForeachAction):
263
- return create_foreach_action_callable(action)
264
- elif isinstance(action, SendAction):
265
- return create_send_action_callable(action)
266
- elif isinstance(action, CancelAction):
267
- return create_cancel_action_callable(action)
268
- elif isinstance(action, ScriptAction):
269
- return create_script_action_callable(action)
270
- else:
271
- raise ValueError(f"Unknown action type: {type(action)}")
127
+ def create_action_callable(action: Action, evaluator: Evaluator) -> Callable:
128
+ match action:
129
+ case RaiseAction():
130
+ return create_raise_action_callable(action)
131
+ case AssignAction():
132
+ return Assign(action, evaluator)
133
+ case LogAction():
134
+ return Log(action, evaluator)
135
+ case IfAction():
136
+ return create_if_action_callable(action, evaluator)
137
+ case ForeachAction():
138
+ return create_foreach_action_callable(action, evaluator)
139
+ case SendAction():
140
+ return create_send_action_callable(action, evaluator)
141
+ case CancelAction():
142
+ return create_cancel_action_callable(action, evaluator)
143
+ case ScriptAction():
144
+ return create_script_action_callable(action, evaluator)
145
+ case _:
146
+ raise ValueError(f"Unknown action type: {type(action)}")
272
147
 
273
148
 
274
149
  class Assign(CallableAction):
275
- def __init__(self, action: AssignAction):
150
+ def __init__(self, action: AssignAction, evaluator: Evaluator):
276
151
  super().__init__()
277
152
  self.action = action
153
+ self._expr = (
154
+ evaluator.compile_value(action.expr)
155
+ if action.child_xml is None and action.expr is not None
156
+ else None
157
+ )
278
158
 
279
159
  def __call__(self, *args, **kwargs):
280
160
  machine: StateChart = kwargs["machine"]
281
161
  if self.action.child_xml is not None:
282
162
  value = self.action.child_xml
283
163
  else:
284
- value = _eval(self.action.expr, **kwargs)
164
+ value = self._expr(*args, **kwargs) # type: ignore[misc]
285
165
 
286
166
  *path, attr = self.action.location.split(".")
287
167
  obj = machine.model
@@ -303,12 +183,13 @@ class Assign(CallableAction):
303
183
 
304
184
 
305
185
  class Log(CallableAction):
306
- def __init__(self, action: LogAction):
186
+ def __init__(self, action: LogAction, evaluator: Evaluator):
307
187
  super().__init__()
308
188
  self.action = action
189
+ self._expr = evaluator.compile_value(action.expr) if action.expr else None
309
190
 
310
191
  def __call__(self, *args, **kwargs):
311
- value = _eval(self.action.expr, **kwargs) if self.action.expr else None
192
+ value = self._expr(*args, **kwargs) if self._expr else None
312
193
 
313
194
  if self.action.label and self.action.expr is not None:
314
195
  msg = f"{self.action.label}: {value!r}"
@@ -319,11 +200,11 @@ class Log(CallableAction):
319
200
  print(msg)
320
201
 
321
202
 
322
- def create_if_action_callable(action: IfAction) -> Callable:
203
+ def create_if_action_callable(action: IfAction, evaluator: Evaluator) -> Callable:
323
204
  branches = [
324
205
  (
325
- Cond.create(branch.cond),
326
- [create_action_callable(action) for action in branch.actions],
206
+ Cond.create(branch.cond, evaluator),
207
+ [create_action_callable(action, evaluator) for action in branch.actions],
327
208
  )
328
209
  for branch in action.branches
329
210
  ]
@@ -349,14 +230,15 @@ def create_if_action_callable(action: IfAction) -> Callable:
349
230
  return if_action
350
231
 
351
232
 
352
- def create_foreach_action_callable(action: ForeachAction) -> Callable:
353
- child_actions = [create_action_callable(act) for act in action.content.actions]
233
+ def create_foreach_action_callable(action: ForeachAction, evaluator: Evaluator) -> Callable:
234
+ child_actions = [create_action_callable(act, evaluator) for act in action.content.actions]
235
+ array_expr = evaluator.compile_value(action.array)
354
236
 
355
237
  def foreach_action(*args, **kwargs):
356
238
  machine: StateChart = kwargs["machine"]
357
239
  try:
358
240
  # Evaluate the array expression to get the iterable
359
- array = _eval(action.array, **kwargs)
241
+ array = array_expr(*args, **kwargs)
360
242
  except Exception as e:
361
243
  raise ValueError(f"Error evaluating <foreach> 'array' expression: {e}") from e
362
244
 
@@ -388,17 +270,10 @@ def create_raise_action_callable(action: RaiseAction) -> Callable:
388
270
  return raise_action
389
271
 
390
272
 
391
- def _send_to_parent(action: SendAction, **kwargs):
392
- """Route a <send target="#_parent"> to the parent machine via _invoke_session."""
273
+ def _resolve_event_and_params(action: SendAction, evaluator: Evaluator, **kwargs):
274
+ """Evaluate the event name, namelist and params for a <send> at call time."""
393
275
  machine = kwargs["machine"]
394
- session = getattr(machine, "_invoke_session", None)
395
- if session is None:
396
- logger.warning(
397
- "<send target='#_parent'> ignored: machine %r has no _invoke_session",
398
- machine.name,
399
- )
400
- return
401
- event = action.event or _eval(action.eventexpr, **kwargs) # type: ignore[arg-type]
276
+ event = action.event or evaluator.compile_value(action.eventexpr)(**kwargs) # type: ignore[arg-type]
402
277
  names = []
403
278
  for name in (action.namelist or "").strip().split():
404
279
  if not hasattr(machine.model, name):
@@ -408,41 +283,46 @@ def _send_to_parent(action: SendAction, **kwargs):
408
283
  for param in chain(names, action.params):
409
284
  if param.expr is None:
410
285
  continue
411
- params_values[param.name] = _eval(param.expr, **kwargs)
286
+ params_values[param.name] = evaluator.compile_value(param.expr)(**kwargs)
287
+ return event, params_values
288
+
289
+
290
+ def _send_to_parent(action: SendAction, evaluator: Evaluator, **kwargs):
291
+ """Route a <send target="#_parent"> to the parent machine via _invoke_session."""
292
+ machine = kwargs["machine"]
293
+ session = getattr(machine, "_invoke_session", None)
294
+ if session is None:
295
+ logger.warning(
296
+ "<send target='#_parent'> ignored: machine %r has no _invoke_session",
297
+ machine.name,
298
+ )
299
+ return
300
+ event, params_values = _resolve_event_and_params(action, evaluator, **kwargs)
412
301
  session.send_to_parent(event, **params_values)
413
302
 
414
303
 
415
- def _send_to_invoke(action: SendAction, invokeid: str, **kwargs):
304
+ def _send_to_invoke(action: SendAction, invokeid: str, evaluator: Evaluator, **kwargs):
416
305
  """Route a <send target="#_<invokeid>"> to the invoked child session."""
417
306
  machine: StateChart = kwargs["machine"]
418
- event = action.event or _eval(action.eventexpr, **kwargs) # type: ignore[arg-type]
419
- names = []
420
- for name in (action.namelist or "").strip().split():
421
- if not hasattr(machine.model, name):
422
- raise NameError(f"Namelist variable '{name}' not found on model")
423
- names.append(Param(name=name, expr=name))
424
- params_values = {}
425
- for param in chain(names, action.params):
426
- if param.expr is None:
427
- continue
428
- params_values[param.name] = _eval(param.expr, **kwargs)
307
+ event, params_values = _resolve_event_and_params(action, evaluator, **kwargs)
429
308
  if not machine._engine._invoke_manager.send_to_child(invokeid, event, **params_values):
430
309
  # Per SCXML spec: if target is not reachable → error.communication
431
310
  BoundEvent("error.communication", internal=True, _sm=machine).put()
432
311
 
433
312
 
434
- def create_send_action_callable(action: SendAction) -> Callable: # noqa: C901
313
+ def create_send_action_callable( # noqa: C901
314
+ action: SendAction, evaluator: Evaluator
315
+ ) -> Callable:
435
316
  content: Any = ()
436
317
  _valid_targets = (None, "#_internal", "internal", "#_parent", "parent")
437
318
  if action.content:
438
- try:
439
- content = (eval(action.content, {}, {}),)
440
- except (NameError, SyntaxError, TypeError):
441
- content = (action.content,)
319
+ content = (evaluator.eval_literal(action.content),)
320
+ delay_expr = (
321
+ evaluator.compile_value(ParseTime.replace(action.delayexpr)) if action.delayexpr else None
322
+ )
442
323
 
443
324
  def send_action(*args, **kwargs): # noqa: C901
444
325
  machine: StateChart = kwargs["machine"]
445
- event = action.event or _eval(action.eventexpr, **kwargs) # type: ignore[arg-type]
446
326
  target = action.target if action.target else None
447
327
 
448
328
  if action.type and action.type != "http://www.w3.org/TR/scxml/#SCXMLEventProcessor":
@@ -457,7 +337,7 @@ def create_send_action_callable(action: SendAction) -> Callable: # noqa: C901
457
337
  BoundEvent("error.communication", internal=True, _sm=machine).put()
458
338
  elif target and target.startswith("#_"):
459
339
  # #_<invokeid> → route to invoked child session
460
- _send_to_invoke(action, target[2:], **kwargs)
340
+ _send_to_invoke(action, target[2:], evaluator, **kwargs)
461
341
  else:
462
342
  # Invalid target expression → error.execution (raised as exception)
463
343
  raise ValueError(f"Invalid target: {target}. Must be one of {_valid_targets}")
@@ -465,7 +345,7 @@ def create_send_action_callable(action: SendAction) -> Callable: # noqa: C901
465
345
 
466
346
  # Handle #_parent target — route to parent via _invoke_session
467
347
  if target == "#_parent":
468
- _send_to_parent(action, **kwargs)
348
+ _send_to_parent(action, evaluator, **kwargs)
469
349
  return
470
350
 
471
351
  internal = target in ("#_internal", "internal")
@@ -477,20 +357,15 @@ def create_send_action_callable(action: SendAction) -> Callable: # noqa: C901
477
357
  send_id = uuid4().hex
478
358
  setattr(machine.model, action.idlocation, send_id)
479
359
 
480
- delay = ParseTime.parse_delay(action.delay, action.delayexpr, **kwargs)
360
+ delay = 0.0
361
+ if action.delay:
362
+ delay = ParseTime.time_in_ms(action.delay)
363
+ elif delay_expr is not None:
364
+ delay = ParseTime.time_in_ms(delay_expr(**kwargs))
481
365
 
482
366
  # Per SCXML spec, if namelist evaluation causes an error (e.g., variable not found),
483
367
  # the send MUST NOT be dispatched and error.execution is raised.
484
- names = []
485
- for name in (action.namelist or "").strip().split():
486
- if not hasattr(machine.model, name):
487
- raise NameError(f"Namelist variable '{name}' not found on model")
488
- names.append(Param(name=name, expr=name))
489
- params_values = {}
490
- for param in chain(names, action.params):
491
- if param.expr is None:
492
- continue
493
- params_values[param.name] = _eval(param.expr, **kwargs)
368
+ event, params_values = _resolve_event_and_params(action, evaluator, **kwargs)
494
369
 
495
370
  Event(id=event, delay=delay, internal=internal, _sm=machine).put(
496
371
  *content,
@@ -502,13 +377,15 @@ def create_send_action_callable(action: SendAction) -> Callable: # noqa: C901
502
377
  return send_action
503
378
 
504
379
 
505
- def create_cancel_action_callable(action: CancelAction) -> Callable:
380
+ def create_cancel_action_callable(action: CancelAction, evaluator: Evaluator) -> Callable:
381
+ sendidexpr = evaluator.compile_value(action.sendidexpr) if action.sendidexpr else None
382
+
506
383
  def cancel_action(*args, **kwargs):
507
384
  machine: StateChart = kwargs["machine"]
508
385
  if action.sendid:
509
386
  send_id = action.sendid
510
- elif action.sendidexpr:
511
- send_id = _eval(action.sendidexpr, **kwargs)
387
+ elif sendidexpr is not None:
388
+ send_id = sendidexpr(*args, **kwargs)
512
389
  else:
513
390
  raise ValueError("CancelAction must have either 'sendid' or 'sendidexpr'")
514
391
  # Implement cancel logic if necessary
@@ -519,47 +396,22 @@ def create_cancel_action_callable(action: CancelAction) -> Callable:
519
396
  return cancel_action
520
397
 
521
398
 
522
- def create_script_action_callable(action: ScriptAction) -> Callable:
523
- def script_action(*args, **kwargs):
524
- machine: StateChart = kwargs["machine"]
525
- local_vars = {
526
- **machine.model.__dict__,
527
- }
528
- exec(action.content, {}, local_vars)
399
+ def create_script_action_callable(action: ScriptAction, evaluator: Evaluator) -> Callable:
400
+ # In the restricted (default) evaluator this raises InvalidDefinition at parse
401
+ # time; <script> only runs under trusted=True.
402
+ script = evaluator.compile_script(action.content)
529
403
 
530
- # Assign the resulting variables to the state machine's model
531
- for var_name, value in local_vars.items():
532
- setattr(machine.model, var_name, value)
404
+ def script_action(*args, **kwargs):
405
+ script(*args, **kwargs)
533
406
 
534
407
  script_action.action = action # type: ignore[attr-defined]
535
408
  return script_action
536
409
 
537
410
 
538
- def create_invoke_init_callable() -> Callable:
539
- """Create a callback that extracts invoke-specific kwargs and stores them on the machine.
540
-
541
- This is always inserted at position 0 in the initial state's onentry list by the
542
- SCXML processor, so that ``_invoke_session`` and ``_invoke_params`` are handled
543
- before any other callbacks run — even for SMs without a ``<datamodel>``.
544
- """
545
- initialized = False
546
-
547
- def invoke_init(*args, **kwargs):
548
- nonlocal initialized
549
- if initialized:
550
- return
551
- initialized = True
552
- machine = kwargs.get("machine")
553
- if machine is not None:
554
- # Use get() not pop(): each callback receives a copy of kwargs
555
- # (via EventData.extended_kwargs), so pop would be misleading.
556
- machine._invoke_params = kwargs.get("_invoke_params")
557
- machine._invoke_session = kwargs.get("_invoke_session")
558
-
559
- return invoke_init
560
-
411
+ def _create_dataitem_callable(action: DataItem, evaluator: Evaluator) -> Callable:
412
+ expr_fn = evaluator.compile_value(action.expr) if action.expr else None
413
+ content_fn = evaluator.compile_value(action.content) if action.content else None
561
414
 
562
- def _create_dataitem_callable(action: DataItem) -> Callable:
563
415
  def data_initializer(**kwargs):
564
416
  machine: StateChart = kwargs["machine"]
565
417
 
@@ -569,16 +421,16 @@ def _create_dataitem_callable(action: DataItem) -> Callable:
569
421
  setattr(machine.model, action.id, invoke_params[action.id])
570
422
  return
571
423
 
572
- if action.expr:
424
+ if expr_fn is not None:
573
425
  try:
574
- value = _eval(action.expr, **kwargs)
426
+ value = expr_fn(**kwargs)
575
427
  except Exception:
576
428
  setattr(machine.model, action.id, None)
577
429
  raise
578
430
 
579
- elif action.content:
431
+ elif content_fn is not None:
580
432
  try:
581
- value = _eval(action.content, **kwargs)
433
+ value = content_fn(**kwargs)
582
434
  except Exception:
583
435
  value = action.content
584
436
  else:
@@ -589,9 +441,11 @@ def _create_dataitem_callable(action: DataItem) -> Callable:
589
441
  return data_initializer
590
442
 
591
443
 
592
- def create_datamodel_action_callable(action: DataModel) -> "Callable | None":
593
- data_elements = [_create_dataitem_callable(item) for item in action.data]
594
- data_elements.extend([create_script_action_callable(script) for script in action.scripts])
444
+ def create_datamodel_action_callable(action: DataModel, evaluator: Evaluator) -> "Callable | None":
445
+ data_elements = [_create_dataitem_callable(item, evaluator) for item in action.data]
446
+ data_elements.extend(
447
+ [create_script_action_callable(script, evaluator) for script in action.scripts]
448
+ )
595
449
 
596
450
  if not data_elements:
597
451
  return None
@@ -613,10 +467,12 @@ def create_datamodel_action_callable(action: DataModel) -> "Callable | None":
613
467
  class ExecuteBlock(CallableAction):
614
468
  """Parses the children as <executable> content XML into a callable."""
615
469
 
616
- def __init__(self, content: ExecutableContent):
470
+ def __init__(self, content: ExecutableContent, evaluator: Evaluator):
617
471
  super().__init__()
618
472
  self.action = content
619
- self.action_callables = [create_action_callable(action) for action in content.actions]
473
+ self.action_callables = [
474
+ create_action_callable(action, evaluator) for action in content.actions
475
+ ]
620
476
 
621
477
  def __call__(self, *args, **kwargs):
622
478
  for action in self.action_callables:
@@ -626,25 +482,39 @@ class ExecuteBlock(CallableAction):
626
482
  class DoneDataCallable(CallableAction):
627
483
  """Evaluates <donedata> params/content and returns the data for done events."""
628
484
 
629
- def __init__(self, donedata: DoneData):
485
+ def __init__(self, donedata: DoneData, evaluator: Evaluator):
630
486
  super().__init__()
631
487
  self.action = donedata
632
488
  self.donedata = donedata
489
+ self._content_expr = (
490
+ evaluator.compile_value(donedata.content_expr)
491
+ if donedata.content_expr is not None
492
+ else None
493
+ )
494
+ self._params = [
495
+ (
496
+ param.name,
497
+ evaluator.compile_value(param.expr)
498
+ if param.expr is not None
499
+ else evaluator.compile_value(param.location.strip()), # type: ignore[union-attr]
500
+ param.location,
501
+ )
502
+ for param in donedata.params
503
+ ]
633
504
 
634
505
  def __call__(self, *args, **kwargs):
635
- if self.donedata.content_expr is not None:
636
- return _eval(self.donedata.content_expr, **kwargs)
506
+ if self._content_expr is not None:
507
+ return self._content_expr(*args, **kwargs)
637
508
 
638
509
  result = {}
639
- for param in self.donedata.params:
640
- if param.expr is not None:
641
- result[param.name] = _eval(param.expr, **kwargs)
642
- elif param.location is not None: # pragma: no branch
643
- location = param.location.strip()
510
+ for name, fn, location in self._params:
511
+ if location is None:
512
+ result[name] = fn(*args, **kwargs)
513
+ else:
644
514
  try:
645
- result[param.name] = _eval(location, **kwargs)
515
+ result[name] = fn(*args, **kwargs)
646
516
  except Exception as e:
647
517
  raise ValueError(
648
- f"<param> location '{location}' does not resolve to a valid value"
518
+ f"<param> location '{location.strip()}' does not resolve to a valid value"
649
519
  ) from e
650
520
  return result