python-statemachine 3.1.2__py3-none-any.whl → 3.2.1__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 (60) hide show
  1. {python_statemachine-3.1.2.dist-info → python_statemachine-3.2.1.dist-info}/METADATA +16 -3
  2. python_statemachine-3.2.1.dist-info/RECORD +72 -0
  3. {python_statemachine-3.1.2.dist-info → python_statemachine-3.2.1.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 +26 -27
  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 +18 -13
  15. statemachine/engines/async_.py +20 -9
  16. statemachine/engines/base.py +26 -17
  17. statemachine/event.py +1 -2
  18. statemachine/exceptions.py +2 -2
  19. statemachine/factory.py +11 -15
  20. statemachine/graph.py +2 -2
  21. statemachine/invoke.py +12 -11
  22. statemachine/io/__init__.py +52 -225
  23. statemachine/io/actions.py +627 -0
  24. statemachine/io/builder.py +195 -0
  25. statemachine/io/class_factory.py +236 -0
  26. statemachine/io/evaluators.py +303 -0
  27. statemachine/io/interpreter.py +128 -0
  28. statemachine/io/{scxml/invoke.py → invoke.py} +89 -49
  29. statemachine/io/json/__init__.py +1 -0
  30. statemachine/io/json/reader.py +27 -0
  31. statemachine/io/loader.py +175 -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 +15 -0
  37. statemachine/io/scxml/processor.py +23 -253
  38. statemachine/io/scxml/{parser.py → reader.py} +88 -56
  39. statemachine/io/system_variables.py +199 -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 +15 -6
  48. statemachine/registry.py +1 -4
  49. statemachine/signature.py +2 -5
  50. statemachine/spec_parser.py +228 -45
  51. statemachine/state.py +7 -8
  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/actions.py +0 -650
  59. statemachine/io/scxml/schema.py +0 -175
  60. {python_statemachine-3.1.2.dist-info → python_statemachine-3.2.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,627 @@
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
+
9
+ import logging
10
+ import re
11
+ import types
12
+ from collections.abc import Callable
13
+ from itertools import chain
14
+ from typing import Any
15
+ from urllib.parse import urlparse
16
+ from uuid import uuid4
17
+
18
+ from ..event import BoundEvent
19
+ from ..event import Event
20
+ from ..statemachine import StateChart
21
+ from .evaluators import Evaluator
22
+ from .evaluators import protected_attrs
23
+ from .model import Action
24
+ from .model import AssignAction
25
+ from .model import CancelAction
26
+ from .model import DataItem
27
+ from .model import DataModel
28
+ from .model import DoneData
29
+ from .model import ExecutableContent
30
+ from .model import ForeachAction
31
+ from .model import IfAction
32
+ from .model import LogAction
33
+ from .model import Param
34
+ from .model import RaiseAction
35
+ from .model import ScriptAction
36
+ from .model import SendAction
37
+
38
+ logger = logging.getLogger(__name__)
39
+ _debug = logger.debug if logger.isEnabledFor(logging.DEBUG) else lambda *a, **k: None
40
+
41
+
42
+ def _ensure_writable_target(name: str, descriptor: str) -> None:
43
+ """Reject a document-controlled write target that isn't a plain public attribute.
44
+
45
+ Executable content chooses the *name* it writes to: ``<assign location>`` (each dotted
46
+ segment), ``<foreach item>``/``index``, ``<data id>`` and ``<send>``/``<invoke>``
47
+ ``idlocation``. A private, dunder or engine-protected name lets an untrusted document
48
+ reach internals or, by traversing ``__class__``, corrupt the shared model class
49
+ process-wide (GHSA-v3qq-3xvg-m77g, GHSA-4857-ggqc-p3jc). Only a valid, public (no
50
+ leading ``_``), non-protected identifier may be written. Raised as ``ValueError`` so the
51
+ engine routes it to ``error.execution`` like any other executable-content failure.
52
+ """
53
+ if not name.isidentifier() or name.startswith("_") or name in protected_attrs:
54
+ raise ValueError(
55
+ f"{descriptor} cannot write to a private, dunder or protected name: {name!r}"
56
+ )
57
+
58
+
59
+ def _reject_type_or_module(obj: Any, descriptor: str) -> None:
60
+ """Reject traversing through or writing onto a class or module.
61
+
62
+ A class (``isinstance(obj, type)``) or module is shared process-wide, so a ``setattr``
63
+ on it (or a hop through it) corrupts every user of that class/module, not just this
64
+ machine. Even if an untrusted expression manages to obtain such an object as a value
65
+ (e.g. via a leaked engine capability), the write target guard blocks the mutation
66
+ (GHSA-v3qq-3xvg-m77g). Plain data instances are unaffected. Raised as ``ValueError`` so
67
+ the engine routes it to ``error.execution``.
68
+ """
69
+ if isinstance(obj, type) or isinstance(obj, types.ModuleType):
70
+ raise ValueError(f"{descriptor} cannot traverse or write onto a class or module: {obj!r}")
71
+
72
+
73
+ def _reject_engine_capability(obj: Any, descriptor: str) -> None:
74
+ """Reject traversing through or writing onto a live engine-capability object.
75
+
76
+ Beyond classes and modules (:func:`_reject_type_or_module`), the engine's own runtime
77
+ objects are shared, mutable capabilities: the machine
78
+ (:class:`~statemachine.statemachine.StateChart`, and thus ``StateMachine``), the io
79
+ :class:`~statemachine.io.interpreter.Interpreter`, the core
80
+ :class:`~statemachine.state.State` / :class:`~statemachine.transition.Transition` /
81
+ :class:`~statemachine.event.Event`, the system-variable facades
82
+ (:class:`~statemachine.io.system_variables.EventDataWrapper` /
83
+ :class:`~statemachine.io.system_variables.IOProcessor`) and the event carriers
84
+ (:class:`~statemachine.event_data.EventData` / :class:`~statemachine.event_data.TriggerData`).
85
+ A ``setattr`` on any of them (or a hop through one) mutates state shared across sibling
86
+ machine instances of the same loaded class. Even if some public alias ever leaks such
87
+ an object as a value, this blocks pivoting the write onto a shared object
88
+ (GHSA-v3qq-3xvg-m77g). The datamodel container (:class:`~statemachine.model.Model`, the
89
+ legitimate ``machine.model`` root) and plain data (dict/list/str/int/ordinary app model
90
+ instances) are unaffected. Lazy imports keep this off the module import cycle. Raised as
91
+ ``ValueError`` so the engine routes it to ``error.execution``.
92
+ """
93
+ from ..event_data import EventData
94
+ from ..event_data import TriggerData
95
+ from ..state import State
96
+ from ..transition import Transition
97
+ from .interpreter import Interpreter
98
+ from .system_variables import EventDataWrapper
99
+ from .system_variables import IOProcessor
100
+
101
+ engine_types = (
102
+ StateChart,
103
+ Interpreter,
104
+ State,
105
+ Transition,
106
+ Event,
107
+ EventDataWrapper,
108
+ IOProcessor,
109
+ EventData,
110
+ TriggerData,
111
+ )
112
+ if isinstance(obj, engine_types):
113
+ raise ValueError(f"{descriptor} cannot traverse or write onto an engine object: {obj!r}")
114
+
115
+
116
+ class ParseTime:
117
+ pattern = re.compile(r"(\d+(?:\.\d+)?|\.\d+)(s|ms)")
118
+ """CSS2 time literal: a number (``5``, ``1.5``, ``.5``) followed by ``s`` or ``ms``."""
119
+
120
+ @classmethod
121
+ def replace(cls, expr: str) -> str:
122
+ def rep(match):
123
+ return str(cls.time_in_ms(match.group(0)))
124
+
125
+ return cls.pattern.sub(rep, expr)
126
+
127
+ @classmethod
128
+ def time_in_ms(cls, expr: str) -> float:
129
+ """
130
+ Convert a CSS2 time expression to milliseconds.
131
+
132
+ Args:
133
+ time (str): A string representing the time, e.g., '1.5s' or '150ms'.
134
+
135
+ Returns:
136
+ float: The time in milliseconds.
137
+
138
+ Raises:
139
+ ValueError: If the input is not a valid CSS2 time expression.
140
+ """
141
+ if expr.endswith("ms"):
142
+ try:
143
+ return float(expr[:-2])
144
+ except ValueError as e:
145
+ raise ValueError(f"Invalid time value: {expr}") from e
146
+ elif expr.endswith("s"):
147
+ try:
148
+ return float(expr[:-1]) * 1000
149
+ except ValueError as e:
150
+ raise ValueError(f"Invalid time value: {expr}") from e
151
+ else:
152
+ try:
153
+ return float(expr)
154
+ except ValueError as e:
155
+ raise ValueError(f"Invalid time unit in: {expr}") from e
156
+
157
+
158
+ class CallableAction:
159
+ action: Any
160
+
161
+ def __init__(self):
162
+ self.__qualname__ = f"{self.__class__.__module__}.{self.__class__.__name__}"
163
+
164
+ def __call__(self, *args, **kwargs):
165
+ raise NotImplementedError
166
+
167
+ def __str__(self):
168
+ return f"{self.action}"
169
+
170
+ def __repr__(self):
171
+ return f"{self.__class__.__name__}({self.action!r})"
172
+
173
+ @property
174
+ def __name__(self):
175
+ return str(self)
176
+
177
+ @property
178
+ def __code__(self):
179
+ return self.__call__.__code__
180
+
181
+
182
+ class Cond(CallableAction):
183
+ """Evaluates a condition like a predicate and returns True or False."""
184
+
185
+ @classmethod
186
+ def create(cls, cond: "str | None", evaluator: Evaluator, processor=None):
187
+ if cond is None:
188
+ return None
189
+ return cls(cond, evaluator, processor)
190
+
191
+ def __init__(self, cond: str, evaluator: Evaluator, processor=None):
192
+ super().__init__()
193
+ self.action = cond
194
+ self.processor = processor
195
+ self._cond = evaluator.compile_bool(cond)
196
+
197
+ def __call__(self, *args, **kwargs):
198
+ result = self._cond(*args, **kwargs)
199
+ _debug("Cond %s -> %s", self.action, result)
200
+ return result
201
+
202
+
203
+ def create_action_callable(action: Action, evaluator: Evaluator) -> Callable:
204
+ match action:
205
+ case RaiseAction():
206
+ return create_raise_action_callable(action)
207
+ case AssignAction():
208
+ return Assign(action, evaluator)
209
+ case LogAction():
210
+ return Log(action, evaluator)
211
+ case IfAction():
212
+ return create_if_action_callable(action, evaluator)
213
+ case ForeachAction():
214
+ return create_foreach_action_callable(action, evaluator)
215
+ case SendAction():
216
+ return create_send_action_callable(action, evaluator)
217
+ case CancelAction():
218
+ return create_cancel_action_callable(action, evaluator)
219
+ case ScriptAction():
220
+ return create_script_action_callable(action, evaluator)
221
+ case _:
222
+ raise ValueError(f"Unknown action type: {type(action)}")
223
+
224
+
225
+ class Assign(CallableAction):
226
+ def __init__(self, action: AssignAction, evaluator: Evaluator):
227
+ super().__init__()
228
+ self.action = action
229
+ self._expr = (
230
+ evaluator.compile_value(action.expr)
231
+ if action.child_xml is None and action.expr is not None
232
+ else None
233
+ )
234
+
235
+ def __call__(self, *args, **kwargs):
236
+ machine: StateChart = kwargs["machine"]
237
+ if self.action.child_xml is not None:
238
+ value = self.action.child_xml
239
+ else:
240
+ value = self._expr(*args, **kwargs) # type: ignore[misc]
241
+
242
+ *path, attr = self.action.location.split(".")
243
+ descriptor = f"<assign> 'location' ({self.action.location!r})"
244
+ obj = machine.model
245
+ # Validate every hop (initial, intermediate and final): traversing a private/dunder
246
+ # object (e.g. ``__class__``) or writing a protected name is how an untrusted document
247
+ # escapes the datamodel, validating only the final segment left that open. A class or
248
+ # module (``_reject_type_or_module``) and a live engine-capability instance
249
+ # (``_reject_engine_capability``: machine/interpreter/State/Transition/Event and the
250
+ # system-variable facades) are additionally rejected as a hop or write target, so even
251
+ # a value that leaked such an object cannot be pivoted onto shared state
252
+ # (GHSA-v3qq-3xvg-m77g).
253
+ for p in path:
254
+ _ensure_writable_target(p, descriptor)
255
+ _reject_type_or_module(obj, descriptor)
256
+ _reject_engine_capability(obj, descriptor)
257
+ obj = getattr(obj, p)
258
+
259
+ _ensure_writable_target(attr, descriptor)
260
+ _reject_type_or_module(obj, descriptor)
261
+ _reject_engine_capability(obj, descriptor)
262
+ if not (hasattr(obj, attr) or attr in kwargs):
263
+ raise ValueError(
264
+ f"<assign> 'location' must be declared before assignment, "
265
+ f"got: {self.action.location}"
266
+ )
267
+ setattr(obj, attr, value)
268
+ _debug("Assign: %s = %r", self.action.location, value)
269
+
270
+
271
+ class Log(CallableAction):
272
+ def __init__(self, action: LogAction, evaluator: Evaluator):
273
+ super().__init__()
274
+ self.action = action
275
+ self._expr = evaluator.compile_value(action.expr) if action.expr else None
276
+
277
+ def __call__(self, *args, **kwargs):
278
+ value = self._expr(*args, **kwargs) if self._expr else None
279
+
280
+ if self.action.label and self.action.expr is not None:
281
+ msg = f"{self.action.label}: {value!r}"
282
+ elif self.action.label:
283
+ msg = f"{self.action.label}"
284
+ else:
285
+ msg = f"{value!r}"
286
+ print(msg)
287
+
288
+
289
+ def create_if_action_callable(action: IfAction, evaluator: Evaluator) -> Callable:
290
+ branches = [
291
+ (
292
+ Cond.create(branch.cond, evaluator),
293
+ [create_action_callable(action, evaluator) for action in branch.actions],
294
+ )
295
+ for branch in action.branches
296
+ ]
297
+
298
+ def if_action(*args, **kwargs):
299
+ machine: StateChart = kwargs["machine"]
300
+ for cond, actions in branches:
301
+ try:
302
+ cond_result = not cond or cond(*args, **kwargs)
303
+ except Exception as e:
304
+ # SCXML spec: condition error → treat as false, queue error.execution.
305
+ if machine.catch_errors_as_events:
306
+ machine.send("error.execution", error=e, internal=True)
307
+ cond_result = False
308
+ else:
309
+ raise
310
+ if cond_result:
311
+ for action in actions:
312
+ action(*args, **kwargs)
313
+ return
314
+
315
+ if_action.action = action # type: ignore[attr-defined]
316
+ return if_action
317
+
318
+
319
+ def create_foreach_action_callable(action: ForeachAction, evaluator: Evaluator) -> Callable:
320
+ child_actions = [create_action_callable(act, evaluator) for act in action.content.actions]
321
+ array_expr = evaluator.compile_value(action.array)
322
+
323
+ def foreach_action(*args, **kwargs):
324
+ machine: StateChart = kwargs["machine"]
325
+ try:
326
+ # Evaluate the array expression to get the iterable
327
+ array = array_expr(*args, **kwargs)
328
+ except Exception as e:
329
+ raise ValueError(f"Error evaluating <foreach> 'array' expression: {e}") from e
330
+
331
+ _ensure_writable_target(action.item, "<foreach> 'item'")
332
+ if action.index:
333
+ _ensure_writable_target(action.index, "<foreach> 'index'")
334
+ for index, item in enumerate(array):
335
+ # Assign the item and optionally the index
336
+ setattr(machine.model, action.item, item)
337
+ if action.index:
338
+ setattr(machine.model, action.index, index)
339
+
340
+ # Execute child actions
341
+ for act in child_actions:
342
+ act(*args, **kwargs)
343
+
344
+ foreach_action.action = action # type: ignore[attr-defined]
345
+ return foreach_action
346
+
347
+
348
+ def create_raise_action_callable(action: RaiseAction) -> Callable:
349
+ def raise_action(*args, **kwargs):
350
+ machine: StateChart = kwargs["machine"]
351
+
352
+ Event(id=action.event, internal=True, _sm=machine).put()
353
+
354
+ raise_action.action = action # type: ignore[attr-defined]
355
+ return raise_action
356
+
357
+
358
+ def _resolve_event_and_params(action: SendAction, evaluator: Evaluator, **kwargs):
359
+ """Evaluate the event name, namelist and params for a <send> at call time."""
360
+ machine = kwargs["machine"]
361
+ event = action.event or evaluator.compile_value(action.eventexpr)(**kwargs) # type: ignore[arg-type]
362
+ names = []
363
+ for name in (action.namelist or "").strip().split():
364
+ if not hasattr(machine.model, name):
365
+ raise NameError(f"Namelist variable '{name}' not found on model")
366
+ names.append(Param(name=name, expr=name))
367
+ params_values = {}
368
+ for param in chain(names, action.params):
369
+ if param.expr is None:
370
+ continue
371
+ params_values[param.name] = evaluator.compile_value(param.expr)(**kwargs)
372
+ return event, params_values
373
+
374
+
375
+ def _send_to_parent(action: SendAction, evaluator: Evaluator, **kwargs):
376
+ """Route a <send target="#_parent"> to the parent machine via _invoke_session."""
377
+ machine = kwargs["machine"]
378
+ session = getattr(machine, "_invoke_session", None)
379
+ if session is None:
380
+ logger.warning(
381
+ "<send target='#_parent'> ignored: machine %r has no _invoke_session",
382
+ machine.name,
383
+ )
384
+ return
385
+ event, params_values = _resolve_event_and_params(action, evaluator, **kwargs)
386
+ session.send_to_parent(event, **params_values)
387
+
388
+
389
+ def _send_to_invoke(action: SendAction, invokeid: str, evaluator: Evaluator, **kwargs):
390
+ """Route a <send target="#_<invokeid>"> to the invoked child session."""
391
+ machine: StateChart = kwargs["machine"]
392
+ event, params_values = _resolve_event_and_params(action, evaluator, **kwargs)
393
+ if not machine._engine._invoke_manager.send_to_child(invokeid, event, **params_values):
394
+ # Per SCXML spec: if target is not reachable → error.communication
395
+ BoundEvent("error.communication", internal=True, _sm=machine).put()
396
+
397
+
398
+ def create_send_action_callable( # noqa: C901
399
+ action: SendAction, evaluator: Evaluator
400
+ ) -> Callable:
401
+ content: Any = ()
402
+ _valid_targets = (None, "#_internal", "internal", "#_parent", "parent")
403
+ if action.content:
404
+ content = (evaluator.eval_literal(action.content),)
405
+ delay_expr = (
406
+ evaluator.compile_value(ParseTime.replace(action.delayexpr)) if action.delayexpr else None
407
+ )
408
+
409
+ def send_action(*args, **kwargs): # noqa: C901
410
+ machine: StateChart = kwargs["machine"]
411
+ target = action.target if action.target else None
412
+
413
+ if action.type and action.type != "http://www.w3.org/TR/scxml/#SCXMLEventProcessor":
414
+ # Per SCXML spec 6.2.3, unsupported type raises error.execution
415
+ raise ValueError(
416
+ f"Unsupported send type: {action.type}. "
417
+ "Only 'http://www.w3.org/TR/scxml/#SCXMLEventProcessor' is supported"
418
+ )
419
+ if target not in _valid_targets:
420
+ if target and target.startswith("#_scxml_"):
421
+ # Valid SCXML session reference but undispatchable → error.communication
422
+ BoundEvent("error.communication", internal=True, _sm=machine).put()
423
+ elif target and target.startswith("#_"):
424
+ # #_<invokeid> → route to invoked child session
425
+ _send_to_invoke(action, target[2:], evaluator, **kwargs)
426
+ else:
427
+ # Invalid target expression → error.execution (raised as exception)
428
+ raise ValueError(f"Invalid target: {target}. Must be one of {_valid_targets}")
429
+ return
430
+
431
+ # Handle #_parent target — route to parent via _invoke_session
432
+ if target == "#_parent":
433
+ _send_to_parent(action, evaluator, **kwargs)
434
+ return
435
+
436
+ internal = target in ("#_internal", "internal")
437
+
438
+ send_id = None
439
+ if action.id:
440
+ send_id = action.id
441
+ elif action.idlocation:
442
+ _ensure_writable_target(action.idlocation, "<send> 'idlocation'")
443
+ send_id = uuid4().hex
444
+ setattr(machine.model, action.idlocation, send_id)
445
+
446
+ delay = 0.0
447
+ if action.delay:
448
+ delay = ParseTime.time_in_ms(action.delay)
449
+ elif delay_expr is not None:
450
+ delay = ParseTime.time_in_ms(delay_expr(**kwargs))
451
+
452
+ # Per SCXML spec, if namelist evaluation causes an error (e.g., variable not found),
453
+ # the send MUST NOT be dispatched and error.execution is raised.
454
+ event, params_values = _resolve_event_and_params(action, evaluator, **kwargs)
455
+
456
+ Event(id=event, delay=delay, internal=internal, _sm=machine).put(
457
+ *content,
458
+ send_id=send_id,
459
+ **params_values,
460
+ )
461
+
462
+ send_action.action = action # type: ignore[attr-defined]
463
+ return send_action
464
+
465
+
466
+ def create_cancel_action_callable(action: CancelAction, evaluator: Evaluator) -> Callable:
467
+ sendidexpr = evaluator.compile_value(action.sendidexpr) if action.sendidexpr else None
468
+
469
+ def cancel_action(*args, **kwargs):
470
+ machine: StateChart = kwargs["machine"]
471
+ if action.sendid:
472
+ send_id = action.sendid
473
+ elif sendidexpr is not None:
474
+ send_id = sendidexpr(*args, **kwargs)
475
+ else:
476
+ raise ValueError("CancelAction must have either 'sendid' or 'sendidexpr'")
477
+ # Implement cancel logic if necessary
478
+ # For now, we can just print that the event is canceled
479
+ machine.cancel_event(send_id)
480
+
481
+ cancel_action.action = action # type: ignore[attr-defined]
482
+ return cancel_action
483
+
484
+
485
+ def create_script_action_callable(action: ScriptAction, evaluator: Evaluator) -> Callable:
486
+ # In the restricted (default) evaluator this raises InvalidDefinition at parse
487
+ # time; <script> only runs under trusted=True.
488
+ script = evaluator.compile_script(action.content)
489
+
490
+ def script_action(*args, **kwargs):
491
+ script(*args, **kwargs)
492
+
493
+ script_action.action = action # type: ignore[attr-defined]
494
+ return script_action
495
+
496
+
497
+ def _resolve_data_src(action: DataItem, evaluator: Evaluator) -> "str | None":
498
+ """Read a ``<data src="file:…">`` reference at compile time, gated on trust.
499
+
500
+ Only the ``file`` scheme is resolved (mirroring the SCXML reader's historical
501
+ behaviour). Reading a local file named by an untrusted document is a confidentiality
502
+ risk, so the restricted evaluator rejects it; ``trusted=True`` reads it as before.
503
+ Runs inside the document's base directory (``loader._build`` ``_chdir``), so relative
504
+ paths resolve against the document.
505
+ """
506
+ if action.src is None:
507
+ return None
508
+ src_parsed = urlparse(action.src)
509
+ if src_parsed.scheme != "file":
510
+ return None
511
+ evaluator.ensure_external_src_allowed(f"<data id={action.id!r} src=...>")
512
+ with open(src_parsed.path) as f:
513
+ return f.read()
514
+
515
+
516
+ def _create_dataitem_callable(action: DataItem, evaluator: Evaluator) -> Callable:
517
+ content = action.content if action.content else _resolve_data_src(action, evaluator)
518
+ expr_fn = evaluator.compile_value(action.expr) if action.expr else None
519
+ content_fn = evaluator.compile_value(content) if content else None
520
+
521
+ def data_initializer(**kwargs):
522
+ machine: StateChart = kwargs["machine"]
523
+ _ensure_writable_target(action.id, "<data> 'id'")
524
+
525
+ # Check for invoke param overrides — params from parent override child defaults
526
+ invoke_params = getattr(machine, "_invoke_params", None)
527
+ if invoke_params and action.id in invoke_params:
528
+ setattr(machine.model, action.id, invoke_params[action.id])
529
+ return
530
+
531
+ if expr_fn is not None:
532
+ try:
533
+ value = expr_fn(**kwargs)
534
+ except Exception:
535
+ setattr(machine.model, action.id, None)
536
+ raise
537
+
538
+ elif content_fn is not None:
539
+ try:
540
+ value = content_fn(**kwargs)
541
+ except Exception:
542
+ value = content
543
+ else:
544
+ value = None
545
+
546
+ setattr(machine.model, action.id, value)
547
+
548
+ return data_initializer
549
+
550
+
551
+ def create_datamodel_action_callable(action: DataModel, evaluator: Evaluator) -> "Callable | None":
552
+ data_elements = [_create_dataitem_callable(item, evaluator) for item in action.data]
553
+ data_elements.extend(
554
+ [create_script_action_callable(script, evaluator) for script in action.scripts]
555
+ )
556
+
557
+ if not data_elements:
558
+ return None
559
+
560
+ initialized = False
561
+
562
+ def datamodel(*args, **kwargs):
563
+ nonlocal initialized
564
+ if initialized:
565
+ return
566
+ initialized = True
567
+
568
+ for act in data_elements:
569
+ act(**kwargs)
570
+
571
+ return datamodel
572
+
573
+
574
+ class ExecuteBlock(CallableAction):
575
+ """Parses the children as <executable> content XML into a callable."""
576
+
577
+ def __init__(self, content: ExecutableContent, evaluator: Evaluator):
578
+ super().__init__()
579
+ self.action = content
580
+ self.action_callables = [
581
+ create_action_callable(action, evaluator) for action in content.actions
582
+ ]
583
+
584
+ def __call__(self, *args, **kwargs):
585
+ for action in self.action_callables:
586
+ action(*args, **kwargs)
587
+
588
+
589
+ class DoneDataCallable(CallableAction):
590
+ """Evaluates <donedata> params/content and returns the data for done events."""
591
+
592
+ def __init__(self, donedata: DoneData, evaluator: Evaluator):
593
+ super().__init__()
594
+ self.action = donedata
595
+ self.donedata = donedata
596
+ self._content_expr = (
597
+ evaluator.compile_value(donedata.content_expr)
598
+ if donedata.content_expr is not None
599
+ else None
600
+ )
601
+ self._params = [
602
+ (
603
+ param.name,
604
+ evaluator.compile_value(param.expr)
605
+ if param.expr is not None
606
+ else evaluator.compile_value(param.location.strip()), # type: ignore[union-attr]
607
+ param.location,
608
+ )
609
+ for param in donedata.params
610
+ ]
611
+
612
+ def __call__(self, *args, **kwargs):
613
+ if self._content_expr is not None:
614
+ return self._content_expr(*args, **kwargs)
615
+
616
+ result = {}
617
+ for name, fn, location in self._params:
618
+ if location is None:
619
+ result[name] = fn(*args, **kwargs)
620
+ else:
621
+ try:
622
+ result[name] = fn(*args, **kwargs)
623
+ except Exception as e:
624
+ raise ValueError(
625
+ f"<param> location '{location.strip()}' does not resolve to a valid value"
626
+ ) from e
627
+ return result