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.
- {python_statemachine-3.1.2.dist-info → python_statemachine-3.2.0.dist-info}/METADATA +16 -3
- python_statemachine-3.2.0.dist-info/RECORD +72 -0
- {python_statemachine-3.1.2.dist-info → python_statemachine-3.2.0.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 +23 -24
- 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 +13 -12
- statemachine/engines/async_.py +5 -6
- statemachine/engines/base.py +12 -14
- statemachine/event.py +1 -2
- statemachine/exceptions.py +1 -1
- statemachine/factory.py +11 -15
- statemachine/graph.py +2 -2
- statemachine/invoke.py +12 -11
- statemachine/io/__init__.py +45 -225
- statemachine/io/{scxml/actions.py → actions.py} +158 -288
- statemachine/io/builder.py +195 -0
- statemachine/io/class_factory.py +236 -0
- statemachine/io/evaluators.py +275 -0
- statemachine/io/interpreter.py +128 -0
- statemachine/io/{scxml/invoke.py → invoke.py} +77 -49
- statemachine/io/json/__init__.py +1 -0
- statemachine/io/json/reader.py +27 -0
- statemachine/io/loader.py +161 -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 +12 -0
- statemachine/io/scxml/processor.py +23 -253
- statemachine/io/scxml/{parser.py → reader.py} +64 -47
- statemachine/io/system_variables.py +184 -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 +3 -3
- statemachine/registry.py +1 -4
- statemachine/signature.py +2 -5
- statemachine/spec_parser.py +171 -42
- statemachine/state.py +5 -6
- 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/schema.py +0 -175
- {python_statemachine-3.1.2.dist-info → python_statemachine-3.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Evaluation strategies for statechart datamodel expressions and scripts.
|
|
2
|
+
|
|
3
|
+
An :class:`Evaluator` turns the raw expression/script strings carried by the
|
|
4
|
+
neutral IR (:mod:`statemachine.io.model`) into callables, deciding *how* they are
|
|
5
|
+
evaluated:
|
|
6
|
+
|
|
7
|
+
- :class:`RestrictedEvaluator` (secure default): compiles expressions with the
|
|
8
|
+
AST-whitelist evaluator (:func:`statemachine.spec_parser.parse_expr`), which
|
|
9
|
+
cannot reach builtins, dunder attributes, or arbitrary calls. ``<script>`` and
|
|
10
|
+
raw-Python evaluation are rejected because they are arbitrary code.
|
|
11
|
+
- :class:`PythonEvaluator` (opt-in, ``trusted=True``): preserves the legacy
|
|
12
|
+
behavior of evaluating expressions and scripts as arbitrary Python via
|
|
13
|
+
``eval``/``exec``.
|
|
14
|
+
|
|
15
|
+
The restricted evaluator validates the *structure* of an expression at compile
|
|
16
|
+
time (a violation raises :class:`~statemachine.exceptions.InvalidDefinition`),
|
|
17
|
+
but name resolution and value computation still happen at call time, so runtime
|
|
18
|
+
errors (``NameError``, ``TypeError``) keep flowing to ``error.execution`` as
|
|
19
|
+
before.
|
|
20
|
+
|
|
21
|
+
This module is format-neutral: it is shared by the SCXML, JSON and YAML readers.
|
|
22
|
+
See the :mod:`statemachine.io.scxml` package docstring and the GHSA-v4jc-pm6r-3vj8
|
|
23
|
+
advisory for the rationale.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import ast
|
|
27
|
+
import html
|
|
28
|
+
import re
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
from inspect import isawaitable
|
|
31
|
+
from typing import Any
|
|
32
|
+
from typing import Protocol
|
|
33
|
+
|
|
34
|
+
from ..dispatcher import callable_method
|
|
35
|
+
from ..event import _event_data_kwargs
|
|
36
|
+
from ..exceptions import InvalidDefinition
|
|
37
|
+
from ..spec_parser import InState
|
|
38
|
+
from ..spec_parser import parse_expr
|
|
39
|
+
|
|
40
|
+
#: Attributes that must never be exposed to or overwritten by datamodel
|
|
41
|
+
#: expressions (engine internals and SCXML system variables).
|
|
42
|
+
protected_attrs = _event_data_kwargs | {"_sessionid", "_ioprocessors", "_name", "_event"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_COND_REPLACEMENTS = {
|
|
46
|
+
"true": "True",
|
|
47
|
+
"false": "False",
|
|
48
|
+
"null": "None",
|
|
49
|
+
"===": "==",
|
|
50
|
+
"!==": "!=",
|
|
51
|
+
"&&": "and",
|
|
52
|
+
"||": "or",
|
|
53
|
+
}
|
|
54
|
+
_COND_PATTERN = re.compile(r"\b(?:true|false|null)\b|===|!==|&&|\|\|")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def normalize_cond(cond: str) -> str:
|
|
58
|
+
"""Normalize a JavaScript-like condition to Python syntax.
|
|
59
|
+
|
|
60
|
+
Decodes XML entities (e.g. ``<``) and maps ``true``/``false``/``null`` and
|
|
61
|
+
the ``===``/``!==``/``&&``/``||`` operators to their Python equivalents. This
|
|
62
|
+
is primarily an SCXML (ECMAScript datamodel) concern; for Python-style
|
|
63
|
+
expressions (JSON/YAML) it is effectively a no-op.
|
|
64
|
+
"""
|
|
65
|
+
cond = html.unescape(cond)
|
|
66
|
+
return _COND_PATTERN.sub(lambda match: _COND_REPLACEMENTS[match.group(0)], cond)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _resolvable_model_attr(name: str, model) -> bool:
|
|
70
|
+
"""The secure boundary for resolving a bare name off the model.
|
|
71
|
+
|
|
72
|
+
Engine-protected names (``machine``, ``event``, system variables, …) and any
|
|
73
|
+
private/dunder name (leading underscore) never resolve off the model, mirroring the
|
|
74
|
+
dotted-access guard in :func:`statemachine.spec_parser.build_attribute`. System
|
|
75
|
+
variables stay reachable because they are provided via the call kwargs, which are
|
|
76
|
+
consulted before this. This keeps a semi-trusted document from reading the model's
|
|
77
|
+
internals (``_secret``) or walking the object graph (``__class__``).
|
|
78
|
+
"""
|
|
79
|
+
return name not in protected_attrs and not name.startswith("_") and hasattr(model, name)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def variable_hook(name: str) -> Callable:
|
|
83
|
+
"""Resolve a datamodel name at call time.
|
|
84
|
+
|
|
85
|
+
Looks the name up in the call kwargs first (engine-provided variables such as
|
|
86
|
+
``_event``, ``_name``, ``machine``), then falls back to a (non-protected, non-private)
|
|
87
|
+
attribute of ``machine.model``. Mirrors the namespace built by :func:`_eval`.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def resolver(*args, **kwargs):
|
|
91
|
+
if name in kwargs:
|
|
92
|
+
return kwargs[name]
|
|
93
|
+
model = kwargs["machine"].model
|
|
94
|
+
if _resolvable_model_attr(name, model):
|
|
95
|
+
return getattr(model, name)
|
|
96
|
+
raise NameError(f"name '{name}' is not defined")
|
|
97
|
+
|
|
98
|
+
resolver.__name__ = name
|
|
99
|
+
return resolver
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def cond_variable_hook(name: str) -> Callable:
|
|
103
|
+
"""Resolve a name inside a guard, following the Python guard dialect.
|
|
104
|
+
|
|
105
|
+
Same lookup as :func:`variable_hook`, but a referenced **method** is *called*
|
|
106
|
+
(with dependency injection), while a property or plain attribute is *read*.
|
|
107
|
+
This keeps native ``cond``/``unless`` at parity with class-defined guards (see
|
|
108
|
+
``docs/guards.md``): a name can be a property, an attribute or a method.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def resolver(*args, **kwargs):
|
|
112
|
+
if name in kwargs:
|
|
113
|
+
return kwargs[name]
|
|
114
|
+
model = kwargs["machine"].model
|
|
115
|
+
if _resolvable_model_attr(name, model):
|
|
116
|
+
value = getattr(model, name)
|
|
117
|
+
if callable(value):
|
|
118
|
+
return callable_method(value)(*args, **kwargs)
|
|
119
|
+
return value
|
|
120
|
+
raise NameError(f"name '{name}' is not defined")
|
|
121
|
+
|
|
122
|
+
resolver.__name__ = name
|
|
123
|
+
return resolver
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _eval(expr: str, **kwargs) -> Any:
|
|
127
|
+
"""Evaluate an expression as arbitrary Python (``trusted=True`` path only).
|
|
128
|
+
|
|
129
|
+
.. warning::
|
|
130
|
+
|
|
131
|
+
Calls the built-in :func:`eval` with no sandboxing. Only reachable when
|
|
132
|
+
the document is loaded with ``trusted=True``.
|
|
133
|
+
"""
|
|
134
|
+
if "machine" in kwargs:
|
|
135
|
+
kwargs.update(
|
|
136
|
+
**{
|
|
137
|
+
k: v
|
|
138
|
+
for k, v in kwargs["machine"].model.__dict__.items()
|
|
139
|
+
if k not in protected_attrs
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
kwargs["In"] = InState(kwargs["machine"])
|
|
143
|
+
return eval(expr, {}, kwargs)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class Evaluator(Protocol):
|
|
147
|
+
"""Port: turns expression/script strings into callables.
|
|
148
|
+
|
|
149
|
+
This is the contract consumers (the processors, action compilers) depend on. They
|
|
150
|
+
never name a concrete implementation; wiring goes through :func:`evaluator_for`.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
def compile_value(self, expr: str) -> Callable: # pragma: no cover - structural Protocol
|
|
154
|
+
"""Compile a value expression (``<assign>``, ``<send>``, ``<data>``, ...)."""
|
|
155
|
+
...
|
|
156
|
+
|
|
157
|
+
def compile_bool(self, expr: str) -> Callable: # pragma: no cover - structural Protocol
|
|
158
|
+
"""Compile a boolean guard expression (``cond``)."""
|
|
159
|
+
...
|
|
160
|
+
|
|
161
|
+
def compile_script(self, content: str) -> Callable: # pragma: no cover - structural Protocol
|
|
162
|
+
"""Compile a ``<script>`` body into a callable side effect."""
|
|
163
|
+
...
|
|
164
|
+
|
|
165
|
+
def eval_literal(self, content: str) -> Any: # pragma: no cover - structural Protocol
|
|
166
|
+
"""Evaluate inline ``<content>`` as a literal, falling back to the raw string."""
|
|
167
|
+
...
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class RestrictedEvaluator:
|
|
171
|
+
"""Secure default: AST-whitelist evaluation, no ``eval``/``exec``.
|
|
172
|
+
|
|
173
|
+
``<script>`` and any expression outside the supported subset (method calls,
|
|
174
|
+
dunder attribute access, lambdas, comprehensions, ...) are rejected at
|
|
175
|
+
compile time with :class:`~statemachine.exceptions.InvalidDefinition`.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
def compile_value(self, expr: str) -> Callable:
|
|
179
|
+
return self._compile(expr, variable_hook)
|
|
180
|
+
|
|
181
|
+
def compile_bool(self, expr: str) -> Callable:
|
|
182
|
+
# Guards resolve names with the Python dialect (methods are called); the
|
|
183
|
+
# result is coerced to bool, awaiting it first when a coroutine (an async
|
|
184
|
+
# guard method) flows through, so the async engine works too.
|
|
185
|
+
value_fn = self._compile(normalize_cond(expr), cond_variable_hook)
|
|
186
|
+
|
|
187
|
+
def cond(*args, **kwargs):
|
|
188
|
+
result = value_fn(*args, **kwargs)
|
|
189
|
+
if isawaitable(result):
|
|
190
|
+
|
|
191
|
+
async def _coerce():
|
|
192
|
+
return bool(await result)
|
|
193
|
+
|
|
194
|
+
return _coerce()
|
|
195
|
+
return bool(result)
|
|
196
|
+
|
|
197
|
+
return cond
|
|
198
|
+
|
|
199
|
+
def compile_script(self, content: str) -> Callable:
|
|
200
|
+
raise InvalidDefinition(
|
|
201
|
+
"<script> executes arbitrary code and is disabled by default. Pass "
|
|
202
|
+
"trusted=True to enable it, and only for trusted sources."
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def eval_literal(self, content: str) -> Any:
|
|
206
|
+
try:
|
|
207
|
+
return ast.literal_eval(content)
|
|
208
|
+
except (ValueError, SyntaxError):
|
|
209
|
+
return content
|
|
210
|
+
|
|
211
|
+
@staticmethod
|
|
212
|
+
def _compile(expr: str, hook: Callable) -> Callable:
|
|
213
|
+
try:
|
|
214
|
+
return parse_expr(expr, hook)
|
|
215
|
+
except (ValueError, SyntaxError) as exc:
|
|
216
|
+
raise InvalidDefinition(
|
|
217
|
+
f"Expression {expr!r} is not allowed by the restricted "
|
|
218
|
+
f"datamodel ({exc}). Pass trusted=True to evaluate it as "
|
|
219
|
+
f"Python, and only for trusted sources."
|
|
220
|
+
) from exc
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class PythonEvaluator:
|
|
224
|
+
"""Opt-in (``trusted=True``): evaluate expressions and scripts as Python.
|
|
225
|
+
|
|
226
|
+
Preserves the legacy ``eval``/``exec`` behavior, including error timing
|
|
227
|
+
(syntax/name errors surface at call time and become ``error.execution``).
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
def compile_value(self, expr: str) -> Callable:
|
|
231
|
+
def value(*args, **kwargs):
|
|
232
|
+
return _eval(expr, **kwargs)
|
|
233
|
+
|
|
234
|
+
return value
|
|
235
|
+
|
|
236
|
+
def compile_bool(self, expr: str) -> Callable:
|
|
237
|
+
# Intentionally uses ``eval`` (not ``parse_expr``): the whole point of the
|
|
238
|
+
# trusted evaluator is to accept guards the restricted AST whitelist rejects —
|
|
239
|
+
# method/function calls, builtins, etc. (e.g. SCXML conformance conds like
|
|
240
|
+
# ``_event.data.get('x') == 1`` or ``hasattr(_event, 'name')``). Routing this
|
|
241
|
+
# through ``parse_expr`` would make trusted mode no more capable than the
|
|
242
|
+
# restricted one for guards and would break those documents.
|
|
243
|
+
normalized = normalize_cond(expr)
|
|
244
|
+
|
|
245
|
+
def cond(*args, **kwargs):
|
|
246
|
+
return _eval(normalized, **kwargs)
|
|
247
|
+
|
|
248
|
+
return cond
|
|
249
|
+
|
|
250
|
+
def compile_script(self, content: str) -> Callable:
|
|
251
|
+
def script(*args, **kwargs):
|
|
252
|
+
machine = kwargs["machine"]
|
|
253
|
+
local_vars = {**machine.model.__dict__}
|
|
254
|
+
exec(content, {}, local_vars)
|
|
255
|
+
for var_name, value in local_vars.items():
|
|
256
|
+
setattr(machine.model, var_name, value)
|
|
257
|
+
|
|
258
|
+
return script
|
|
259
|
+
|
|
260
|
+
def eval_literal(self, content: str) -> Any:
|
|
261
|
+
try:
|
|
262
|
+
return eval(content, {}, {})
|
|
263
|
+
except (NameError, SyntaxError, TypeError):
|
|
264
|
+
return content
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def evaluator_for(trusted: bool = False) -> Evaluator:
|
|
268
|
+
"""Return the evaluation strategy for the given trust level (the wiring point).
|
|
269
|
+
|
|
270
|
+
This is the single place that maps the public ``trusted`` flag to a concrete
|
|
271
|
+
adapter, so no other module needs to import :class:`RestrictedEvaluator` or
|
|
272
|
+
:class:`PythonEvaluator`. The default (``trusted=False``) is the secure restricted
|
|
273
|
+
evaluator, so callers that omit the argument get the safe behaviour.
|
|
274
|
+
"""
|
|
275
|
+
return PythonEvaluator() if trusted else RestrictedEvaluator()
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Format-neutral runtime that turns statechart definitions into running machines.
|
|
2
|
+
|
|
3
|
+
The :class:`Interpreter` is the execution-model runtime, independent of input syntax. It
|
|
4
|
+
owns a :class:`~statemachine.io.builder.DefinitionBuilder` (compilation), keeps the
|
|
5
|
+
registry of compiled classes (``scs``, needed for ``<invoke>``), manages per-machine
|
|
6
|
+
sessions, injects the system variables (``_event``/``_sessionid``/``_name``/
|
|
7
|
+
``_ioprocessors``) on every event, coordinates invoke, and instantiates the root machine.
|
|
8
|
+
|
|
9
|
+
It is parameterized by two ports: a :class:`~statemachine.io.ports.FormatReader` (so invoke
|
|
10
|
+
can compile children in the same format) and an
|
|
11
|
+
:class:`~statemachine.io.evaluators.Evaluator` (secure by default). SCXML is just one
|
|
12
|
+
reader; YAML/JSON get the very same runtime behavior.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from ..exceptions import InvalidDefinition
|
|
19
|
+
from ..statemachine import StateChart
|
|
20
|
+
from .builder import DefinitionBuilder
|
|
21
|
+
from .class_factory import create_machine_class_from_definition
|
|
22
|
+
from .evaluators import Evaluator
|
|
23
|
+
from .invoke import Invoker
|
|
24
|
+
from .model import InvokeDefinition
|
|
25
|
+
from .model import StateMachineDefinition
|
|
26
|
+
from .system_variables import IOProcessor
|
|
27
|
+
from .system_variables import SessionData
|
|
28
|
+
from .system_variables import build_system_variables
|
|
29
|
+
from .system_variables import create_invoke_init_callable
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Interpreter:
|
|
33
|
+
"""Runtime that compiles and hosts statecharts from any format.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
reader: the format reader, used to compile invoked children in the same format.
|
|
37
|
+
evaluator: the evaluation strategy for guards and executable content.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, *, reader, evaluator: Evaluator):
|
|
41
|
+
self.reader = reader
|
|
42
|
+
self._evaluator = evaluator
|
|
43
|
+
self._builder = DefinitionBuilder(evaluator=evaluator, hooks=self)
|
|
44
|
+
self.scs: "dict[str, type[StateChart]]" = {}
|
|
45
|
+
self.sessions: dict[str, SessionData] = {}
|
|
46
|
+
|
|
47
|
+
def process_definition(
|
|
48
|
+
self, definition: StateMachineDefinition, location: str, is_invoked: bool = False
|
|
49
|
+
):
|
|
50
|
+
kwargs = self._builder.build_class_kwargs(definition, is_invoked=is_invoked)
|
|
51
|
+
self._add(location, kwargs)
|
|
52
|
+
|
|
53
|
+
def start(self, **kwargs):
|
|
54
|
+
self.root_cls = next(iter(self.scs.values()))
|
|
55
|
+
self.root = self.root_cls(**kwargs)
|
|
56
|
+
return self.root
|
|
57
|
+
|
|
58
|
+
# -- RuntimeHooks (called back by the DefinitionBuilder) -----------------------
|
|
59
|
+
|
|
60
|
+
def initial_enter_prefix(self, is_invoked: bool) -> list:
|
|
61
|
+
# Invoked children store _invoke_session/_invoke_params before any other callback.
|
|
62
|
+
if is_invoked:
|
|
63
|
+
return [create_invoke_init_callable()]
|
|
64
|
+
return []
|
|
65
|
+
|
|
66
|
+
def definition_kwargs(self, definition) -> "dict[str, Any]":
|
|
67
|
+
"""Extra keyword arguments passed to ``create_machine_class_from_definition``.
|
|
68
|
+
|
|
69
|
+
The three structural ``validate_*`` checks are turned **off** for loaded
|
|
70
|
+
statecharts: declarative documents legitimately express configurations these
|
|
71
|
+
checks would reject (states reached only through parallel regions or eventless
|
|
72
|
+
paths, intentional trap/error states). The trade-off is that genuine structural
|
|
73
|
+
inconsistencies are not caught at load time; see the validations reference and
|
|
74
|
+
``docs/io``. ``prepare_event`` injects the system variables for every format.
|
|
75
|
+
"""
|
|
76
|
+
return {
|
|
77
|
+
"validate_disconnected_states": False,
|
|
78
|
+
"validate_trap_states": False,
|
|
79
|
+
"validate_final_reachability": False,
|
|
80
|
+
"start_configuration_values": list(definition.initial_states),
|
|
81
|
+
"prepare_event": self._prepare_event,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
def make_invoker(self, invoke_def: InvokeDefinition) -> Invoker:
|
|
85
|
+
return Invoker(
|
|
86
|
+
definition=invoke_def,
|
|
87
|
+
base_dir=os.getcwd(),
|
|
88
|
+
register_child=self._register_child,
|
|
89
|
+
evaluator=self._evaluator,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# -- Runtime services ----------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def _prepare_event(self, *args, event, **kwargs):
|
|
95
|
+
machine = kwargs["machine"]
|
|
96
|
+
session_data = self._get_session(machine)
|
|
97
|
+
return build_system_variables(machine, session_data, event, kwargs["event_data"])
|
|
98
|
+
|
|
99
|
+
def _get_session(self, machine) -> SessionData:
|
|
100
|
+
if machine.name not in self.sessions:
|
|
101
|
+
self.sessions[machine.name] = SessionData(
|
|
102
|
+
processor=IOProcessor(self, machine=machine), machine=machine
|
|
103
|
+
)
|
|
104
|
+
return self.sessions[machine.name]
|
|
105
|
+
|
|
106
|
+
def _register_child(self, content, child_name: str) -> type:
|
|
107
|
+
"""Compile and register a child statechart.
|
|
108
|
+
|
|
109
|
+
``content`` is either source text (parsed via this interpreter's reader, so the
|
|
110
|
+
child is in the same format) or an already-parsed
|
|
111
|
+
:class:`~statemachine.io.model.StateMachineDefinition` (native inline child).
|
|
112
|
+
"""
|
|
113
|
+
if isinstance(content, StateMachineDefinition):
|
|
114
|
+
definition = content
|
|
115
|
+
else:
|
|
116
|
+
definition = self.reader.read(content)
|
|
117
|
+
self.process_definition(definition, location=child_name, is_invoked=True)
|
|
118
|
+
return self.scs[child_name]
|
|
119
|
+
|
|
120
|
+
def _add(self, location: str, definition: dict[str, Any]):
|
|
121
|
+
try:
|
|
122
|
+
sc_class = create_machine_class_from_definition(location, **definition)
|
|
123
|
+
self.scs[location] = sc_class
|
|
124
|
+
return sc_class
|
|
125
|
+
except Exception as e: # pragma: no cover
|
|
126
|
+
raise InvalidDefinition(
|
|
127
|
+
f"Failed to create state machine class: {e} from definition: {definition}"
|
|
128
|
+
) from e
|
|
@@ -1,27 +1,34 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Format-neutral invoke handler.
|
|
2
2
|
|
|
3
|
-
Implements the IInvoke protocol
|
|
4
|
-
|
|
5
|
-
the child machine lifecycle including ``#_parent`` routing,
|
|
6
|
-
finalize.
|
|
3
|
+
Implements the engine's ``IInvoke`` protocol: resolves a child statechart (inline
|
|
4
|
+
content, ``src`` file, or ``srcexpr``), evaluates ``params``/``namelist`` in the parent
|
|
5
|
+
context, and manages the child machine lifecycle including ``#_parent`` routing,
|
|
6
|
+
autoforward and finalize.
|
|
7
|
+
|
|
8
|
+
The child is compiled via the ``register_child`` callback (which the interpreter wires to
|
|
9
|
+
its own format reader), so invoke is not tied to SCXML: a child may be authored in any
|
|
10
|
+
format the interpreter understands.
|
|
7
11
|
"""
|
|
8
12
|
|
|
9
13
|
import asyncio
|
|
10
14
|
import logging
|
|
15
|
+
from collections.abc import Callable
|
|
11
16
|
from inspect import isawaitable
|
|
12
17
|
from pathlib import Path
|
|
13
18
|
from typing import Any
|
|
14
|
-
from typing import Callable
|
|
15
19
|
|
|
16
|
-
from
|
|
17
|
-
from
|
|
20
|
+
from ..invoke import IInvoke
|
|
21
|
+
from ..invoke import InvokeContext
|
|
18
22
|
from .actions import ExecuteBlock
|
|
19
|
-
from .
|
|
20
|
-
from .
|
|
23
|
+
from .evaluators import Evaluator
|
|
24
|
+
from .model import InvokeDefinition
|
|
25
|
+
from .model import StateMachineDefinition
|
|
26
|
+
from .system_variables import EventDataWrapper
|
|
21
27
|
|
|
22
28
|
logger = logging.getLogger(__name__)
|
|
23
29
|
|
|
24
|
-
|
|
30
|
+
#: Invoke ``type`` values understood by the runtime (SCXML processor URLs + ``None``).
|
|
31
|
+
VALID_INVOKE_TYPES = {
|
|
25
32
|
None,
|
|
26
33
|
"scxml",
|
|
27
34
|
"http://www.w3.org/TR/scxml",
|
|
@@ -30,10 +37,10 @@ _VALID_INVOKE_TYPES = {
|
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
|
|
33
|
-
class
|
|
34
|
-
"""
|
|
40
|
+
class Invoker:
|
|
41
|
+
"""Invoke handler implementing the IInvoke protocol.
|
|
35
42
|
|
|
36
|
-
Resolves the child
|
|
43
|
+
Resolves the child statechart from inline content, a ``src`` file or ``srcexpr``,
|
|
37
44
|
evaluates params/namelist, and manages the child machine lifecycle.
|
|
38
45
|
"""
|
|
39
46
|
|
|
@@ -41,12 +48,14 @@ class SCXMLInvoker:
|
|
|
41
48
|
self,
|
|
42
49
|
definition: InvokeDefinition,
|
|
43
50
|
base_dir: str,
|
|
44
|
-
register_child: "Callable[[str, str], type]",
|
|
51
|
+
register_child: "Callable[[StateMachineDefinition | str, str], type]",
|
|
52
|
+
evaluator: Evaluator,
|
|
45
53
|
):
|
|
46
54
|
self._definition = definition
|
|
47
55
|
self._register_child = register_child
|
|
48
56
|
self._child: Any = None
|
|
49
57
|
self._base_dir: str = base_dir
|
|
58
|
+
self._evaluator: Evaluator = evaluator
|
|
50
59
|
|
|
51
60
|
# Duck-typed attributes for InvokeManager
|
|
52
61
|
self.invoke_id: "str | None" = definition.id
|
|
@@ -56,7 +65,10 @@ class SCXMLInvoker:
|
|
|
56
65
|
# Pre-compile finalize block
|
|
57
66
|
self._finalize_block: "ExecuteBlock | None" = None
|
|
58
67
|
if definition.finalize and not definition.finalize.is_empty:
|
|
59
|
-
self._finalize_block = ExecuteBlock(definition.finalize)
|
|
68
|
+
self._finalize_block = ExecuteBlock(definition.finalize, self._evaluator)
|
|
69
|
+
|
|
70
|
+
def _eval(self, expr: str, machine) -> Any:
|
|
71
|
+
return self._evaluator.compile_value(expr)(machine=machine)
|
|
60
72
|
|
|
61
73
|
def run(self, ctx: InvokeContext) -> Any:
|
|
62
74
|
"""Create and run the child state machine."""
|
|
@@ -69,27 +81,27 @@ class SCXMLInvoker:
|
|
|
69
81
|
# Resolve invoke type
|
|
70
82
|
invoke_type = self._definition.type
|
|
71
83
|
if self._definition.typeexpr:
|
|
72
|
-
invoke_type = _eval(self._definition.typeexpr, machine=machine)
|
|
84
|
+
invoke_type = self._eval(self._definition.typeexpr, machine=machine)
|
|
73
85
|
|
|
74
|
-
if invoke_type not in
|
|
86
|
+
if invoke_type not in VALID_INVOKE_TYPES:
|
|
75
87
|
raise ValueError(
|
|
76
|
-
f"Unsupported invoke type: {invoke_type}. Supported types: {
|
|
88
|
+
f"Unsupported invoke type: {invoke_type}. Supported types: {VALID_INVOKE_TYPES}"
|
|
77
89
|
)
|
|
78
90
|
|
|
79
|
-
# Resolve child
|
|
80
|
-
|
|
81
|
-
if
|
|
91
|
+
# Resolve child statechart content
|
|
92
|
+
child_content = self._resolve_content(machine)
|
|
93
|
+
if child_content is None:
|
|
82
94
|
raise ValueError("No content resolved for <invoke>")
|
|
83
95
|
|
|
84
96
|
# Evaluate params and namelist
|
|
85
97
|
invoke_params = self._evaluate_params(machine)
|
|
86
98
|
|
|
87
99
|
# Parse and create the child machine
|
|
88
|
-
child_cls = self._create_child_class(
|
|
100
|
+
child_cls = self._create_child_class(child_content, ctx.invokeid)
|
|
89
101
|
|
|
90
102
|
# _invoke_session and _invoke_params are passed as kwargs so that the
|
|
91
103
|
# invoke_init callback (inserted at position 0 in the initial state's onentry
|
|
92
|
-
# by the
|
|
104
|
+
# by the interpreter) can pop them and store them on the machine instance.
|
|
93
105
|
#
|
|
94
106
|
# The _ChildRefSetter listener captures ``self._child`` during the first
|
|
95
107
|
# state entry, before the processing loop blocks. This is necessary
|
|
@@ -108,7 +120,7 @@ class SCXMLInvoker:
|
|
|
108
120
|
|
|
109
121
|
def on_cancel(self):
|
|
110
122
|
"""Cancel the child machine and all its invocations."""
|
|
111
|
-
from
|
|
123
|
+
from ..invoke import _stop_child_machine
|
|
112
124
|
|
|
113
125
|
_stop_child_machine(self._child)
|
|
114
126
|
self._child = None
|
|
@@ -129,32 +141,36 @@ class SCXMLInvoker:
|
|
|
129
141
|
"machine": machine,
|
|
130
142
|
"model": machine.model,
|
|
131
143
|
}
|
|
132
|
-
# Inject SCXML context variables
|
|
133
|
-
from .actions import EventDataWrapper
|
|
134
|
-
|
|
135
144
|
kwargs.update(
|
|
136
145
|
{k: v for k, v in machine.model.__dict__.items() if not k.startswith("_")}
|
|
137
146
|
)
|
|
138
|
-
# Build EventDataWrapper from trigger_data's kwargs
|
|
139
147
|
kwargs["_event"] = EventDataWrapper.from_trigger_data(trigger_data)
|
|
140
148
|
self._finalize_block(**kwargs)
|
|
141
149
|
|
|
142
|
-
def _resolve_content(self, machine)
|
|
143
|
-
"""Resolve the child
|
|
150
|
+
def _resolve_content(self, machine):
|
|
151
|
+
"""Resolve the child statechart content from content/src/srcexpr.
|
|
152
|
+
|
|
153
|
+
Returns either a source string (file text / inline document text) that the reader
|
|
154
|
+
will parse, or an already-parsed definition (native inline child), which the
|
|
155
|
+
interpreter registers directly.
|
|
156
|
+
"""
|
|
144
157
|
defn = self._definition
|
|
145
158
|
|
|
146
|
-
if defn.content:
|
|
147
|
-
|
|
148
|
-
if
|
|
149
|
-
|
|
159
|
+
if defn.content is not None:
|
|
160
|
+
content = defn.content
|
|
161
|
+
if not isinstance(content, str):
|
|
162
|
+
# Native inline child: an already-parsed StateMachineDefinition.
|
|
163
|
+
return content
|
|
164
|
+
if self._is_inline_document(content):
|
|
165
|
+
return content
|
|
150
166
|
# It's an expression — evaluate it
|
|
151
|
-
result = _eval(
|
|
167
|
+
result = self._eval(content, machine=machine)
|
|
152
168
|
if isinstance(result, str):
|
|
153
169
|
return result
|
|
154
170
|
return str(result)
|
|
155
171
|
|
|
156
172
|
if defn.srcexpr:
|
|
157
|
-
src = _eval(defn.srcexpr, machine=machine)
|
|
173
|
+
src = self._eval(defn.srcexpr, machine=machine)
|
|
158
174
|
elif defn.src:
|
|
159
175
|
src = defn.src
|
|
160
176
|
else:
|
|
@@ -166,12 +182,20 @@ class SCXMLInvoker:
|
|
|
166
182
|
else:
|
|
167
183
|
path = Path(src)
|
|
168
184
|
|
|
169
|
-
# Resolve relative to the base directory of the parent
|
|
185
|
+
# Resolve relative to the base directory of the parent document
|
|
170
186
|
if not path.is_absolute():
|
|
171
187
|
path = Path(self._base_dir) / path
|
|
172
188
|
|
|
173
189
|
return path.read_text()
|
|
174
190
|
|
|
191
|
+
@staticmethod
|
|
192
|
+
def _is_inline_document(content: str) -> bool:
|
|
193
|
+
"""Whether ``content`` is an inline document (vs an expression to evaluate).
|
|
194
|
+
|
|
195
|
+
The default heuristic recognizes inline XML (SCXML). Other formats may override.
|
|
196
|
+
"""
|
|
197
|
+
return content.lstrip().startswith("<")
|
|
198
|
+
|
|
175
199
|
def _evaluate_params(self, machine) -> dict:
|
|
176
200
|
"""Evaluate params and namelist into a dict of values."""
|
|
177
201
|
defn = self._definition
|
|
@@ -186,28 +210,28 @@ class SCXMLInvoker:
|
|
|
186
210
|
# Evaluate param elements
|
|
187
211
|
for param in defn.params:
|
|
188
212
|
if param.expr is not None:
|
|
189
|
-
result[param.name] = _eval(param.expr, machine=machine)
|
|
213
|
+
result[param.name] = self._eval(param.expr, machine=machine)
|
|
190
214
|
elif param.location is not None:
|
|
191
|
-
result[param.name] = _eval(param.location, machine=machine)
|
|
215
|
+
result[param.name] = self._eval(param.location, machine=machine)
|
|
192
216
|
|
|
193
217
|
return result
|
|
194
218
|
|
|
195
|
-
def _create_child_class(self,
|
|
196
|
-
"""
|
|
219
|
+
def _create_child_class(self, content: "StateMachineDefinition | str", invokeid: str):
|
|
220
|
+
"""Compile the child statechart and create a machine class."""
|
|
197
221
|
child_name = f"invoke_{invokeid}"
|
|
198
|
-
return self._register_child(
|
|
222
|
+
return self._register_child(content, child_name)
|
|
199
223
|
|
|
200
224
|
|
|
201
225
|
class _ChildRefSetter:
|
|
202
226
|
"""Listener that captures the child machine reference during initialization.
|
|
203
227
|
|
|
204
228
|
The child's ``__init__`` blocks inside the processing loop (e.g. when there
|
|
205
|
-
are delayed events). By using this listener, ``
|
|
206
|
-
|
|
207
|
-
|
|
229
|
+
are delayed events). By using this listener, ``Invoker._child`` is set during
|
|
230
|
+
the first state entry — *before* the processing loop starts spinning — so that
|
|
231
|
+
``on_event()`` can forward events to the child immediately.
|
|
208
232
|
"""
|
|
209
233
|
|
|
210
|
-
def __init__(self, invoker: "
|
|
234
|
+
def __init__(self, invoker: "Invoker"):
|
|
211
235
|
self._invoker = invoker
|
|
212
236
|
|
|
213
237
|
def on_enter_state(self, machine=None, **kwargs):
|
|
@@ -221,13 +245,17 @@ class _InvokeSession:
|
|
|
221
245
|
def __init__(self, parent, invokeid: str):
|
|
222
246
|
self.parent = parent
|
|
223
247
|
self.invokeid = invokeid
|
|
248
|
+
self._pending_tasks: "set[asyncio.Future]" = set()
|
|
249
|
+
"""Strong refs to scheduled sends; the loop only holds weak refs to tasks."""
|
|
224
250
|
|
|
225
251
|
def send_to_parent(self, event: str, **data):
|
|
226
252
|
"""Send an event to the parent machine's external queue."""
|
|
227
253
|
result = self.parent.send(event, _invokeid=self.invokeid, **data)
|
|
228
254
|
if isawaitable(result):
|
|
229
|
-
asyncio.ensure_future(result)
|
|
255
|
+
task = asyncio.ensure_future(result)
|
|
256
|
+
self._pending_tasks.add(task)
|
|
257
|
+
task.add_done_callback(self._pending_tasks.discard)
|
|
230
258
|
|
|
231
259
|
|
|
232
260
|
# Verify protocol compliance at import time
|
|
233
|
-
assert isinstance(
|
|
261
|
+
assert isinstance(Invoker.__new__(Invoker), IInvoke)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""JSON statechart format adapter."""
|