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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. {python_statemachine-3.1.2.dist-info → python_statemachine-3.2.1.dist-info}/METADATA +16 -3
  2. python_statemachine-3.2.1.dist-info/RECORD +72 -0
  3. {python_statemachine-3.1.2.dist-info → python_statemachine-3.2.1.dist-info}/WHEEL +1 -1
  4. statemachine/__init__.py +1 -1
  5. statemachine/callbacks.py +5 -11
  6. statemachine/configuration.py +5 -6
  7. statemachine/contrib/diagram/extract.py +26 -27
  8. statemachine/contrib/diagram/formatter.py +5 -7
  9. statemachine/contrib/diagram/model.py +9 -11
  10. statemachine/contrib/diagram/renderers/dot.py +20 -26
  11. statemachine/contrib/diagram/renderers/mermaid.py +36 -40
  12. statemachine/contrib/diagram/renderers/table.py +7 -9
  13. statemachine/contrib/weighted.py +7 -11
  14. statemachine/dispatcher.py +18 -13
  15. statemachine/engines/async_.py +20 -9
  16. statemachine/engines/base.py +26 -17
  17. statemachine/event.py +1 -2
  18. statemachine/exceptions.py +2 -2
  19. statemachine/factory.py +11 -15
  20. statemachine/graph.py +2 -2
  21. statemachine/invoke.py +12 -11
  22. statemachine/io/__init__.py +52 -225
  23. statemachine/io/actions.py +627 -0
  24. statemachine/io/builder.py +195 -0
  25. statemachine/io/class_factory.py +236 -0
  26. statemachine/io/evaluators.py +303 -0
  27. statemachine/io/interpreter.py +128 -0
  28. statemachine/io/{scxml/invoke.py → invoke.py} +89 -49
  29. statemachine/io/json/__init__.py +1 -0
  30. statemachine/io/json/reader.py +27 -0
  31. statemachine/io/loader.py +175 -0
  32. statemachine/io/model.py +268 -0
  33. statemachine/io/native.py +402 -0
  34. statemachine/io/ports.py +83 -0
  35. statemachine/io/schemas/statechart.schema.json +258 -0
  36. statemachine/io/scxml/__init__.py +15 -0
  37. statemachine/io/scxml/processor.py +23 -253
  38. statemachine/io/scxml/{parser.py → reader.py} +88 -56
  39. statemachine/io/system_variables.py +199 -0
  40. statemachine/io/validation.py +44 -0
  41. statemachine/io/yaml/__init__.py +1 -0
  42. statemachine/io/yaml/reader.py +65 -0
  43. statemachine/locale/en/LC_MESSAGES/statemachine.po +19 -19
  44. statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po +19 -19
  45. statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po +19 -19
  46. statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po +19 -19
  47. statemachine/orderedset.py +15 -6
  48. statemachine/registry.py +1 -4
  49. statemachine/signature.py +2 -5
  50. statemachine/spec_parser.py +228 -45
  51. statemachine/state.py +7 -8
  52. statemachine/statemachine.py +18 -20
  53. statemachine/states.py +3 -5
  54. statemachine/transition.py +3 -4
  55. statemachine/transition_list.py +4 -5
  56. statemachine/transition_mixin.py +1 -1
  57. python_statemachine-3.1.2.dist-info/RECORD +0 -58
  58. statemachine/io/scxml/actions.py +0 -650
  59. statemachine/io/scxml/schema.py +0 -175
  60. {python_statemachine-3.1.2.dist-info → python_statemachine-3.2.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,303 @@
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-allowlist 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
+ The evaluator is also the trust gate for external resource references: an external
22
+ ``<data src>`` / ``<invoke src>`` reads a local file, so the restricted evaluator rejects
23
+ it via :meth:`ensure_external_src_allowed` (the trusted one allows it).
24
+
25
+ This module is format-neutral: it is shared by the SCXML, JSON and YAML readers.
26
+ See the :mod:`statemachine.io.scxml` package docstring and the GHSA-v4jc-pm6r-3vj8
27
+ (code execution) and GHSA-fj3w-533r-fvf6 (file disclosure) advisories for the rationale.
28
+ """
29
+
30
+ import ast
31
+ import html
32
+ import re
33
+ from collections.abc import Callable
34
+ from inspect import isawaitable
35
+ from typing import Any
36
+ from typing import Protocol
37
+
38
+ from ..dispatcher import callable_method
39
+ from ..event import _event_data_kwargs
40
+ from ..exceptions import InvalidDefinition
41
+ from ..spec_parser import InState
42
+ from ..spec_parser import parse_expr
43
+
44
+ #: Attributes that must never be exposed to or overwritten by datamodel
45
+ #: expressions (engine internals and SCXML system variables).
46
+ protected_attrs = _event_data_kwargs | {"_sessionid", "_ioprocessors", "_name", "_event"}
47
+
48
+
49
+ _COND_REPLACEMENTS = {
50
+ "true": "True",
51
+ "false": "False",
52
+ "null": "None",
53
+ "===": "==",
54
+ "!==": "!=",
55
+ "&&": "and",
56
+ "||": "or",
57
+ }
58
+ _COND_PATTERN = re.compile(r"\b(?:true|false|null)\b|===|!==|&&|\|\|")
59
+
60
+
61
+ def normalize_cond(cond: str) -> str:
62
+ """Normalize a JavaScript-like condition to Python syntax.
63
+
64
+ Decodes XML entities (e.g. ``&lt;``) and maps ``true``/``false``/``null`` and
65
+ the ``===``/``!==``/``&&``/``||`` operators to their Python equivalents. This
66
+ is primarily an SCXML (ECMAScript datamodel) concern; for Python-style
67
+ expressions (JSON/YAML) it is effectively a no-op.
68
+ """
69
+ cond = html.unescape(cond)
70
+ return _COND_PATTERN.sub(lambda match: _COND_REPLACEMENTS[match.group(0)], cond)
71
+
72
+
73
+ def _resolvable_model_attr(name: str, model) -> bool:
74
+ """The secure boundary for resolving a bare name off the model.
75
+
76
+ Engine-protected names (``machine``, ``event``, system variables, …) and any
77
+ private/dunder name (leading underscore) never resolve off the model, mirroring the
78
+ dotted-access guard in :func:`statemachine.spec_parser.build_attribute`. System
79
+ variables stay reachable because they are provided via the call kwargs, which are
80
+ consulted before this. This keeps a semi-trusted document from reading the model's
81
+ internals (``_secret``) or walking the object graph (``__class__``).
82
+ """
83
+ return name not in protected_attrs and not name.startswith("_") and hasattr(model, name)
84
+
85
+
86
+ def variable_hook(name: str) -> Callable:
87
+ """Resolve a datamodel name at call time.
88
+
89
+ Looks the name up in the call kwargs first (SCXML system variables such as
90
+ ``_event``, ``_name``), then falls back to a (non-protected, non-private) attribute of
91
+ ``machine.model``. Names in :data:`~statemachine.event._event_data_kwargs` (``machine``,
92
+ ``model``, ``event``, ``transition``, ...) are the live engine-capability objects; they
93
+ are **withheld** from the kwargs lookup so an untrusted expression cannot obtain the
94
+ engine and pivot through it onto shared classes (GHSA-v3qq-3xvg-m77g). The ``_``-prefixed
95
+ system variables are not engine objects and stay resolvable.
96
+ """
97
+
98
+ def resolver(*args, **kwargs):
99
+ if name not in _event_data_kwargs and name in kwargs:
100
+ return kwargs[name]
101
+ model = kwargs["machine"].model
102
+ if _resolvable_model_attr(name, model):
103
+ return getattr(model, name)
104
+ raise NameError(f"name '{name}' is not defined")
105
+
106
+ resolver.__name__ = name
107
+ return resolver
108
+
109
+
110
+ def cond_variable_hook(name: str) -> Callable:
111
+ """Resolve a name inside a guard, following the Python guard dialect.
112
+
113
+ Same lookup as :func:`variable_hook`, but a referenced **method** is *called*
114
+ (with dependency injection), while a property or plain attribute is *read*.
115
+ This keeps native ``cond``/``unless`` at parity with class-defined guards (see
116
+ ``docs/guards.md``): a name can be a property, an attribute or a method.
117
+ """
118
+
119
+ def resolver(*args, **kwargs):
120
+ if name not in _event_data_kwargs and name in kwargs:
121
+ return kwargs[name]
122
+ model = kwargs["machine"].model
123
+ if _resolvable_model_attr(name, model):
124
+ value = getattr(model, name)
125
+ if callable(value):
126
+ return callable_method(value)(*args, **kwargs)
127
+ return value
128
+ raise NameError(f"name '{name}' is not defined")
129
+
130
+ resolver.__name__ = name
131
+ return resolver
132
+
133
+
134
+ def _eval(expr: str, **kwargs) -> Any:
135
+ """Evaluate an expression as arbitrary Python (``trusted=True`` path only).
136
+
137
+ .. warning::
138
+
139
+ Calls the built-in :func:`eval` with no sandboxing. Only reachable when
140
+ the document is loaded with ``trusted=True``.
141
+ """
142
+ if "machine" in kwargs:
143
+ kwargs.update(
144
+ **{
145
+ k: v
146
+ for k, v in kwargs["machine"].model.__dict__.items()
147
+ if k not in protected_attrs
148
+ }
149
+ )
150
+ kwargs["In"] = InState(kwargs["machine"])
151
+ return eval(expr, {}, kwargs)
152
+
153
+
154
+ class Evaluator(Protocol):
155
+ """Port: turns expression/script strings into callables.
156
+
157
+ This is the contract consumers (the processors, action compilers) depend on. They
158
+ never name a concrete implementation; wiring goes through :func:`evaluator_for`.
159
+ """
160
+
161
+ def compile_value(self, expr: str) -> Callable: # pragma: no cover - structural Protocol
162
+ """Compile a value expression (``<assign>``, ``<send>``, ``<data>``, ...)."""
163
+ ...
164
+
165
+ def compile_bool(self, expr: str) -> Callable: # pragma: no cover - structural Protocol
166
+ """Compile a boolean guard expression (``cond``)."""
167
+ ...
168
+
169
+ def compile_script(self, content: str) -> Callable: # pragma: no cover - structural Protocol
170
+ """Compile a ``<script>`` body into a callable side effect."""
171
+ ...
172
+
173
+ def eval_literal(self, content: str) -> Any: # pragma: no cover - structural Protocol
174
+ """Evaluate inline ``<content>`` as a literal, falling back to the raw string."""
175
+ ...
176
+
177
+ def ensure_external_src_allowed( # pragma: no cover - structural Protocol
178
+ self, descriptor: str
179
+ ) -> None:
180
+ """Guard external resource references (``<data src>`` / ``<invoke src>``).
181
+
182
+ Reading a local file named by an untrusted document is a confidentiality risk, so
183
+ it is gated on the trust level just like ``<script>``. ``descriptor`` names the
184
+ offending element for the error message.
185
+ """
186
+ ...
187
+
188
+
189
+ class RestrictedEvaluator:
190
+ """Secure default: AST-allowlist evaluation, no ``eval``/``exec``.
191
+
192
+ ``<script>`` and any expression outside the supported subset (method calls,
193
+ dunder attribute access, lambdas, comprehensions, ...) are rejected at
194
+ compile time with :class:`~statemachine.exceptions.InvalidDefinition`.
195
+ """
196
+
197
+ def compile_value(self, expr: str) -> Callable:
198
+ return self._compile(expr, variable_hook)
199
+
200
+ def compile_bool(self, expr: str) -> Callable:
201
+ # Guards resolve names with the Python dialect (methods are called); the
202
+ # result is coerced to bool, awaiting it first when a coroutine (an async
203
+ # guard method) flows through, so the async engine works too.
204
+ value_fn = self._compile(normalize_cond(expr), cond_variable_hook)
205
+
206
+ def cond(*args, **kwargs):
207
+ result = value_fn(*args, **kwargs)
208
+ if isawaitable(result):
209
+
210
+ async def _coerce():
211
+ return bool(await result)
212
+
213
+ return _coerce()
214
+ return bool(result)
215
+
216
+ return cond
217
+
218
+ def compile_script(self, content: str) -> Callable:
219
+ raise InvalidDefinition(
220
+ "<script> executes arbitrary code and is disabled by default. Pass "
221
+ "trusted=True to enable it, and only for trusted sources."
222
+ )
223
+
224
+ def eval_literal(self, content: str) -> Any:
225
+ try:
226
+ return ast.literal_eval(content)
227
+ except (ValueError, SyntaxError):
228
+ return content
229
+
230
+ def ensure_external_src_allowed(self, descriptor: str) -> None:
231
+ raise InvalidDefinition(
232
+ f"{descriptor} loads a local file and is disabled by default. Pass "
233
+ "trusted=True to enable it, and only for trusted sources."
234
+ )
235
+
236
+ @staticmethod
237
+ def _compile(expr: str, hook: Callable) -> Callable:
238
+ try:
239
+ return parse_expr(expr, hook)
240
+ except (ValueError, SyntaxError) as exc:
241
+ raise InvalidDefinition(
242
+ f"Expression {expr!r} is not allowed by the restricted "
243
+ f"datamodel ({exc}). Pass trusted=True to evaluate it as "
244
+ f"Python, and only for trusted sources."
245
+ ) from exc
246
+
247
+
248
+ class PythonEvaluator:
249
+ """Opt-in (``trusted=True``): evaluate expressions and scripts as Python.
250
+
251
+ Preserves the legacy ``eval``/``exec`` behavior, including error timing
252
+ (syntax/name errors surface at call time and become ``error.execution``).
253
+ """
254
+
255
+ def compile_value(self, expr: str) -> Callable:
256
+ def value(*args, **kwargs):
257
+ return _eval(expr, **kwargs)
258
+
259
+ return value
260
+
261
+ def compile_bool(self, expr: str) -> Callable:
262
+ # Intentionally uses ``eval`` (not ``parse_expr``): the whole point of the
263
+ # trusted evaluator is to accept guards the restricted AST allowlist rejects —
264
+ # method/function calls, builtins, etc. (e.g. SCXML conformance conds like
265
+ # ``_event.data.get('x') == 1`` or ``hasattr(_event, 'name')``). Routing this
266
+ # through ``parse_expr`` would make trusted mode no more capable than the
267
+ # restricted one for guards and would break those documents.
268
+ normalized = normalize_cond(expr)
269
+
270
+ def cond(*args, **kwargs):
271
+ return _eval(normalized, **kwargs)
272
+
273
+ return cond
274
+
275
+ def compile_script(self, content: str) -> Callable:
276
+ def script(*args, **kwargs):
277
+ machine = kwargs["machine"]
278
+ local_vars = {**machine.model.__dict__}
279
+ exec(content, {}, local_vars)
280
+ for var_name, value in local_vars.items():
281
+ setattr(machine.model, var_name, value)
282
+
283
+ return script
284
+
285
+ def eval_literal(self, content: str) -> Any:
286
+ try:
287
+ return eval(content, {}, {})
288
+ except (NameError, SyntaxError, TypeError):
289
+ return content
290
+
291
+ def ensure_external_src_allowed(self, descriptor: str) -> None:
292
+ return None
293
+
294
+
295
+ def evaluator_for(trusted: bool = False) -> Evaluator:
296
+ """Return the evaluation strategy for the given trust level (the wiring point).
297
+
298
+ This is the single place that maps the public ``trusted`` flag to a concrete
299
+ adapter, so no other module needs to import :class:`RestrictedEvaluator` or
300
+ :class:`PythonEvaluator`. The default (``trusted=False``) is the secure restricted
301
+ evaluator, so callers that omit the argument get the safe behaviour.
302
+ """
303
+ 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