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
@@ -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. ``&lt;``) 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
- """SCXML-specific invoke handler.
1
+ """Format-neutral invoke handler.
2
2
 
3
- Implements the IInvoke protocol by resolving child SCXML content (inline or
4
- via src/srcexpr), evaluating params/namelist in the parent context, and managing
5
- the child machine lifecycle including ``#_parent`` routing, autoforward, and
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 ...invoke import IInvoke
17
- from ...invoke import InvokeContext
20
+ from ..invoke import IInvoke
21
+ from ..invoke import InvokeContext
18
22
  from .actions import ExecuteBlock
19
- from .actions import _eval
20
- from .schema import InvokeDefinition
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
- _VALID_INVOKE_TYPES = {
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 SCXMLInvoker:
34
- """SCXML-specific invoke handler implementing the IInvoke protocol.
40
+ class Invoker:
41
+ """Invoke handler implementing the IInvoke protocol.
35
42
 
36
- Resolves the child SCXML from inline content, src file, or srcexpr,
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 _VALID_INVOKE_TYPES:
86
+ if invoke_type not in VALID_INVOKE_TYPES:
75
87
  raise ValueError(
76
- f"Unsupported invoke type: {invoke_type}. Supported types: {_VALID_INVOKE_TYPES}"
88
+ f"Unsupported invoke type: {invoke_type}. Supported types: {VALID_INVOKE_TYPES}"
77
89
  )
78
90
 
79
- # Resolve child SCXML content
80
- scxml_content = self._resolve_content(machine)
81
- if scxml_content is None:
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(scxml_content, ctx.invokeid)
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 processor) can pop them and store them on the machine instance.
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 ...invoke import _stop_child_machine
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) -> "str | None":
143
- """Resolve the child SCXML content from content/src/srcexpr."""
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
- # Content could be an expr to evaluate or inline SCXML
148
- if defn.content.lstrip().startswith("<"):
149
- return defn.content
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(defn.content, machine=machine)
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 SCXML file
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, scxml_content: str, invokeid: str):
196
- """Parse the child SCXML and create a machine class."""
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(scxml_content, child_name)
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, ``SCXMLInvoker._child`` is set
206
- during the first state entry — *before* the processing loop starts spinning —
207
- so that ``on_event()`` can forward events to the child immediately.
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: "SCXMLInvoker"):
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(SCXMLInvoker.__new__(SCXMLInvoker), IInvoke)
261
+ assert isinstance(Invoker.__new__(Invoker), IInvoke)
@@ -0,0 +1 @@
1
+ """JSON statechart format adapter."""