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.
- {python_statemachine-3.1.2.dist-info → python_statemachine-3.2.1.dist-info}/METADATA +16 -3
- python_statemachine-3.2.1.dist-info/RECORD +72 -0
- {python_statemachine-3.1.2.dist-info → python_statemachine-3.2.1.dist-info}/WHEEL +1 -1
- statemachine/__init__.py +1 -1
- statemachine/callbacks.py +5 -11
- statemachine/configuration.py +5 -6
- statemachine/contrib/diagram/extract.py +26 -27
- statemachine/contrib/diagram/formatter.py +5 -7
- statemachine/contrib/diagram/model.py +9 -11
- statemachine/contrib/diagram/renderers/dot.py +20 -26
- statemachine/contrib/diagram/renderers/mermaid.py +36 -40
- statemachine/contrib/diagram/renderers/table.py +7 -9
- statemachine/contrib/weighted.py +7 -11
- statemachine/dispatcher.py +18 -13
- statemachine/engines/async_.py +20 -9
- statemachine/engines/base.py +26 -17
- statemachine/event.py +1 -2
- statemachine/exceptions.py +2 -2
- statemachine/factory.py +11 -15
- statemachine/graph.py +2 -2
- statemachine/invoke.py +12 -11
- statemachine/io/__init__.py +52 -225
- statemachine/io/actions.py +627 -0
- statemachine/io/builder.py +195 -0
- statemachine/io/class_factory.py +236 -0
- statemachine/io/evaluators.py +303 -0
- statemachine/io/interpreter.py +128 -0
- statemachine/io/{scxml/invoke.py → invoke.py} +89 -49
- statemachine/io/json/__init__.py +1 -0
- statemachine/io/json/reader.py +27 -0
- statemachine/io/loader.py +175 -0
- statemachine/io/model.py +268 -0
- statemachine/io/native.py +402 -0
- statemachine/io/ports.py +83 -0
- statemachine/io/schemas/statechart.schema.json +258 -0
- statemachine/io/scxml/__init__.py +15 -0
- statemachine/io/scxml/processor.py +23 -253
- statemachine/io/scxml/{parser.py → reader.py} +88 -56
- statemachine/io/system_variables.py +199 -0
- statemachine/io/validation.py +44 -0
- statemachine/io/yaml/__init__.py +1 -0
- statemachine/io/yaml/reader.py +65 -0
- statemachine/locale/en/LC_MESSAGES/statemachine.po +19 -19
- statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po +19 -19
- statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po +19 -19
- statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po +19 -19
- statemachine/orderedset.py +15 -6
- statemachine/registry.py +1 -4
- statemachine/signature.py +2 -5
- statemachine/spec_parser.py +228 -45
- statemachine/state.py +7 -8
- statemachine/statemachine.py +18 -20
- statemachine/states.py +3 -5
- statemachine/transition.py +3 -4
- statemachine/transition_list.py +4 -5
- statemachine/transition_mixin.py +1 -1
- python_statemachine-3.1.2.dist-info/RECORD +0 -58
- statemachine/io/scxml/actions.py +0 -650
- statemachine/io/scxml/schema.py +0 -175
- {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
|