python-statemachine 3.1.1__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 (60) hide show
  1. {python_statemachine-3.1.1.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.1.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/__init__.py +15 -6
  8. statemachine/contrib/diagram/extract.py +23 -24
  9. statemachine/contrib/diagram/formatter.py +5 -7
  10. statemachine/contrib/diagram/model.py +9 -11
  11. statemachine/contrib/diagram/renderers/dot.py +20 -26
  12. statemachine/contrib/diagram/renderers/mermaid.py +36 -40
  13. statemachine/contrib/diagram/renderers/table.py +7 -9
  14. statemachine/contrib/weighted.py +7 -11
  15. statemachine/dispatcher.py +13 -12
  16. statemachine/engines/async_.py +5 -6
  17. statemachine/engines/base.py +12 -14
  18. statemachine/event.py +1 -2
  19. statemachine/exceptions.py +1 -1
  20. statemachine/factory.py +12 -16
  21. statemachine/graph.py +2 -2
  22. statemachine/invoke.py +12 -11
  23. statemachine/io/__init__.py +45 -225
  24. statemachine/io/{scxml/actions.py → actions.py} +158 -288
  25. statemachine/io/builder.py +195 -0
  26. statemachine/io/class_factory.py +236 -0
  27. statemachine/io/evaluators.py +275 -0
  28. statemachine/io/interpreter.py +128 -0
  29. statemachine/io/{scxml/invoke.py → invoke.py} +77 -49
  30. statemachine/io/json/__init__.py +1 -0
  31. statemachine/io/json/reader.py +27 -0
  32. statemachine/io/loader.py +161 -0
  33. statemachine/io/model.py +268 -0
  34. statemachine/io/native.py +402 -0
  35. statemachine/io/ports.py +83 -0
  36. statemachine/io/schemas/statechart.schema.json +258 -0
  37. statemachine/io/scxml/__init__.py +12 -0
  38. statemachine/io/scxml/processor.py +23 -253
  39. statemachine/io/scxml/{parser.py → reader.py} +64 -47
  40. statemachine/io/system_variables.py +184 -0
  41. statemachine/io/validation.py +44 -0
  42. statemachine/io/yaml/__init__.py +1 -0
  43. statemachine/io/yaml/reader.py +65 -0
  44. statemachine/locale/en/LC_MESSAGES/statemachine.po +19 -19
  45. statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po +19 -19
  46. statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po +19 -19
  47. statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po +20 -22
  48. statemachine/orderedset.py +3 -3
  49. statemachine/registry.py +1 -4
  50. statemachine/signature.py +2 -5
  51. statemachine/spec_parser.py +171 -42
  52. statemachine/state.py +5 -6
  53. statemachine/statemachine.py +18 -20
  54. statemachine/states.py +3 -5
  55. statemachine/transition.py +3 -4
  56. statemachine/transition_list.py +4 -5
  57. statemachine/transition_mixin.py +1 -1
  58. python_statemachine-3.1.1.dist-info/RECORD +0 -58
  59. statemachine/io/scxml/schema.py +0 -175
  60. {python_statemachine-3.1.1.dist-info → python_statemachine-3.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,27 @@
1
+ """JSON format adapter: parse a JSON statechart document into the neutral IR."""
2
+
3
+ import json
4
+
5
+ from ..model import StateMachineDefinition
6
+ from ..native import native_dict_to_definition
7
+ from ..ports import FormatSpec
8
+ from ..ports import register_format
9
+
10
+
11
+ class JSONReader:
12
+ """Format adapter that parses JSON documents (stdlib :mod:`json`) into the IR."""
13
+
14
+ def parse_document(self, text: str) -> dict:
15
+ return json.loads(text) # type: ignore[no-any-return]
16
+
17
+ def read(self, text: str, *, source_name: "str | None" = None) -> StateMachineDefinition:
18
+ return native_dict_to_definition(self.parse_document(text), source_name=source_name)
19
+
20
+
21
+ register_format(
22
+ FormatSpec(
23
+ name="json",
24
+ extensions=(".json",),
25
+ reader_factory=JSONReader,
26
+ )
27
+ )
@@ -0,0 +1,161 @@
1
+ """High-level, format-neutral facade for loading statecharts.
2
+
3
+ :func:`load` is the simple entry point: give it a file path (format detected by
4
+ extension) or inline content (with an explicit ``format=``) and it returns the
5
+ ready-to-instantiate :class:`~statemachine.statemachine.StateChart` class. It is
6
+ **secure by default** — expressions are evaluated by a restricted AST-whitelist
7
+ evaluator and ``<script>`` / arbitrary Python is rejected unless ``trusted=True``.
8
+
9
+ For advanced scenarios (a document declaring several statecharts, or SCXML files
10
+ that import/invoke others), use :func:`build_processor` to get the underlying
11
+ processor and reach ``.scs`` / ``.start()``.
12
+ """
13
+
14
+ import os
15
+ from contextlib import contextmanager
16
+ from pathlib import Path
17
+ from typing import cast
18
+
19
+ from ..statemachine import StateChart
20
+ from .evaluators import evaluator_for
21
+ from .interpreter import Interpreter
22
+ from .json import reader as _json_reader # noqa: F401
23
+ from .ports import detect_format
24
+ from .ports import get_format
25
+
26
+ # Importing the reader modules registers their formats (extensions). None of these
27
+ # imports pulls in PyYAML or jsonschema at module load time.
28
+ from .scxml import reader as _scxml_reader # noqa: F401
29
+ from .yaml import reader as _yaml_reader # noqa: F401
30
+
31
+
32
+ @contextmanager
33
+ def _chdir(new_dir: Path):
34
+ original = os.getcwd()
35
+ try:
36
+ os.chdir(new_dir)
37
+ yield
38
+ finally:
39
+ os.chdir(original)
40
+
41
+
42
+ def _resolve_source(source: "str | Path", format: "str | None"):
43
+ """Return ``(text, format_name, location_hint, base_dir)`` for a source.
44
+
45
+ A :class:`~pathlib.Path`, or a single-line string naming an existing file, is
46
+ read from disk (format detected by extension unless overridden). Any other
47
+ string is treated as inline content and requires an explicit ``format``.
48
+ """
49
+ if isinstance(source, Path):
50
+ path: "Path | None" = source
51
+ elif "\n" not in source and Path(source).is_file():
52
+ path = Path(source)
53
+ else:
54
+ path = None
55
+
56
+ if path is not None:
57
+ text = path.read_text()
58
+ fmt = detect_format(path, format)
59
+ return text, fmt, path.stem, path.parent
60
+
61
+ fmt = detect_format(Path("<inline>"), format)
62
+ return source, fmt, None, None
63
+
64
+
65
+ def _build(source, *, format, trusted, validate, name):
66
+ """Shared pipeline: read source -> IR -> interpreter (returns ``(interpreter, location)``)."""
67
+ text, fmt, location_hint, base_dir = _resolve_source(source, format)
68
+ spec = get_format(fmt)
69
+ reader = spec.reader_factory()
70
+
71
+ parse_document = getattr(reader, "parse_document", None)
72
+ if validate and parse_document is None:
73
+ raise ValueError(
74
+ f"validate=True is not supported for the {fmt!r} format; "
75
+ "validation applies to the native JSON/YAML schema."
76
+ )
77
+
78
+ def _read_and_build():
79
+ # Parsing and compilation both run here, inside the file's directory when loading
80
+ # from a file, so a reader that resolves external references (e.g. SCXML
81
+ # ``<data src="...">``) and invoke ``src`` resolve relative to the document.
82
+ if parse_document is not None:
83
+ doc = parse_document(text)
84
+ if validate:
85
+ from .validation import validate_document
86
+
87
+ validate_document(doc)
88
+ from .native import native_dict_to_definition
89
+
90
+ definition = native_dict_to_definition(doc, source_name=name or location_hint)
91
+ else:
92
+ definition = reader.read(text, source_name=name or location_hint)
93
+
94
+ location = name or definition.name or location_hint or "statechart"
95
+ # The runtime is the format-neutral Interpreter, wired with this format's reader
96
+ # (so invoked children compile in the same format) and the chosen evaluator.
97
+ interpreter = Interpreter(reader=reader, evaluator=evaluator_for(trusted))
98
+ interpreter.process_definition(definition, location=location)
99
+ return interpreter, location
100
+
101
+ if base_dir is not None:
102
+ with _chdir(base_dir):
103
+ return _read_and_build()
104
+ return _read_and_build()
105
+
106
+
107
+ def build_processor(
108
+ source: "str | Path",
109
+ *,
110
+ format: "str | None" = None,
111
+ trusted: bool = False,
112
+ validate: bool = False,
113
+ name: "str | None" = None,
114
+ ):
115
+ """Load a statechart and return the underlying interpreter (low-level API).
116
+
117
+ Returns the :class:`~statemachine.io.interpreter.Interpreter`. Use it when you need
118
+ access to ``interpreter.scs`` (all compiled classes) or ``interpreter.start(...)`` —
119
+ e.g. documents that invoke/import children. See :func:`load` for the argument semantics.
120
+ """
121
+ interpreter, _location = _build(
122
+ source, format=format, trusted=trusted, validate=validate, name=name
123
+ )
124
+ return interpreter
125
+
126
+
127
+ def load(
128
+ source: "str | Path",
129
+ *,
130
+ format: "str | None" = None,
131
+ trusted: bool = False,
132
+ validate: bool = False,
133
+ name: "str | None" = None,
134
+ ) -> "type[StateChart]":
135
+ """Load a statechart from a file or inline content and return its class.
136
+
137
+ Args:
138
+ source: a file path (``str``/:class:`~pathlib.Path`; format detected from
139
+ the extension) or inline document content (requires ``format``).
140
+ format: explicit format name (``"scxml"``, ``"json"``, ``"yaml"``),
141
+ overriding extension detection and required for inline content.
142
+ trusted: when ``False`` (default), expressions are evaluated by a restricted
143
+ AST-whitelist evaluator and ``<script>`` / arbitrary Python is rejected.
144
+ When ``True``, expressions and scripts are evaluated as arbitrary Python.
145
+ validate: when ``True`` (native JSON/YAML only), validate the document against
146
+ the published JSON Schema before building (requires the ``[validation]``
147
+ extra).
148
+ name: explicit name for the generated class (defaults to the document name
149
+ or the file stem).
150
+
151
+ Returns:
152
+ The :class:`~statemachine.statemachine.StateChart` subclass. Instantiate it
153
+ to run the machine (``load("m.yaml")()``).
154
+ """
155
+ interpreter, location = _build(
156
+ source, format=format, trusted=trusted, validate=validate, name=name
157
+ )
158
+ cls = interpreter.scs[location]
159
+ # Keep the interpreter reachable (and alive) from the returned class.
160
+ cls._io_processor = interpreter # pyright: ignore[reportAttributeAccessIssue]
161
+ return cast("type[StateChart]", cls)
@@ -0,0 +1,268 @@
1
+ """Format-neutral intermediate representation (IR) of a statechart.
2
+
3
+ These dataclasses are the common target every format reader (SCXML, JSON, YAML)
4
+ produces, and the single input the :class:`~statemachine.io.processor.GenericProcessor`
5
+ consumes to build a :class:`~statemachine.statemachine.StateChart` class. They carry
6
+ the *structure* of a statechart (states, transitions, executable content, datamodel)
7
+ as plain data, with expressions kept as un-evaluated strings; turning those strings
8
+ into callables is the evaluator's job, not the IR's.
9
+
10
+ The vocabulary mirrors the SCXML semantic model (the formalism this library
11
+ standardizes on) plus a few optional callback-reference fields used by the native
12
+ JSON/YAML format to integrate with methods on a bound model.
13
+ """
14
+
15
+ from dataclasses import dataclass
16
+ from dataclasses import field
17
+ from typing import Literal
18
+
19
+
20
+ @dataclass
21
+ class Action:
22
+ def __str__(self):
23
+ return f"{self.__class__.__name__}"
24
+
25
+
26
+ @dataclass
27
+ class ExecutableContent:
28
+ """An ordered block of actions (the body of an ``onentry``/``onexit``/transition).
29
+
30
+ A state may carry several blocks (SCXML allows multiple ``<onentry>`` elements),
31
+ which is why ``State.onentry``/``onexit`` are *lists* of ``ExecutableContent``.
32
+ """
33
+
34
+ actions: list[Action] = field(default_factory=list)
35
+
36
+ def __str__(self):
37
+ return ", ".join(str(action) for action in self.actions)
38
+
39
+ @property
40
+ def is_empty(self):
41
+ return not self.actions
42
+
43
+
44
+ @dataclass
45
+ class RaiseAction(Action):
46
+ event: str
47
+
48
+
49
+ @dataclass
50
+ class AssignAction(Action):
51
+ """Assign a value to a datamodel location (``location = expr``).
52
+
53
+ ``location`` is a dotted attribute path on the model (e.g. ``user.name``).
54
+ ``expr`` is the value expression; when it is ``None``, ``child_xml`` carries the
55
+ literal inline XML/text assigned instead (SCXML ``<assign>`` with element body).
56
+ """
57
+
58
+ location: str
59
+ expr: "str | None" = None
60
+ child_xml: "str | None" = None
61
+
62
+
63
+ @dataclass
64
+ class LogAction(Action):
65
+ label: "str | None"
66
+ expr: "str | None"
67
+
68
+
69
+ @dataclass
70
+ class IfBranch(Action):
71
+ """One branch of an :class:`IfAction`.
72
+
73
+ ``cond`` is the guard expression for this branch. A ``cond`` of ``None`` marks the
74
+ final ``else`` branch, which always matches.
75
+ """
76
+
77
+ cond: "str | None"
78
+ actions: list[Action] = field(default_factory=list)
79
+
80
+ def __str__(self):
81
+ return self.cond or "<empty cond>"
82
+
83
+ def append(self, action: Action):
84
+ self.actions.append(action)
85
+
86
+
87
+ @dataclass
88
+ class IfAction(Action):
89
+ """An ``if``/``elif``/``else`` chain.
90
+
91
+ ``branches`` is ordered and evaluated top to bottom; the first branch whose ``cond``
92
+ is truthy runs its actions and the rest are skipped. By convention the first branch
93
+ is the ``if``, intermediate branches with a ``cond`` are ``elif``, and a trailing
94
+ branch with ``cond=None`` is the ``else`` (see :class:`IfBranch`).
95
+ """
96
+
97
+ branches: list[IfBranch] = field(default_factory=list)
98
+
99
+
100
+ @dataclass
101
+ class ForeachAction(Action):
102
+ """Iterate ``item`` (and optional ``index``) over the iterable ``array`` evaluates to,
103
+ running ``content`` once per element."""
104
+
105
+ array: str
106
+ item: str
107
+ index: "str | None"
108
+ content: ExecutableContent
109
+
110
+
111
+ @dataclass
112
+ class Param:
113
+ name: str
114
+ expr: "str | None"
115
+ location: "str | None" = None
116
+
117
+
118
+ @dataclass
119
+ class SendAction(Action):
120
+ event: "str | None" = None
121
+ eventexpr: "str | None" = None
122
+ target: "str | None" = None
123
+ type: "str | None" = None
124
+ id: "str | None" = None
125
+ idlocation: "str | None" = None
126
+ delay: "str | None" = None
127
+ delayexpr: "str | None" = None
128
+ namelist: "str | None" = None
129
+ params: list[Param] = field(default_factory=list)
130
+ content: "str | None" = None
131
+
132
+
133
+ @dataclass
134
+ class CancelAction(Action):
135
+ sendid: "str | None" = None
136
+ sendidexpr: "str | None" = None
137
+
138
+
139
+ @dataclass
140
+ class ScriptAction(Action):
141
+ content: str
142
+
143
+
144
+ @dataclass
145
+ class Transition:
146
+ """A transition out of a state.
147
+
148
+ Two attributes carry non-obvious conventions:
149
+
150
+ - ``event=None`` makes the transition **eventless**: it fires automatically
151
+ whenever ``cond`` holds (the SCXML NULL transition), instead of on a named event.
152
+ - ``target=None`` makes it **targetless**: taking it runs ``on`` but does not change
153
+ the active configuration (a self-action with no state change).
154
+
155
+ ``cond``/``unless`` are guard expressions and may be a single string or a list (all
156
+ must hold). ``on`` is the executable content run when the transition is taken.
157
+ """
158
+
159
+ target: "str | None" = None
160
+ internal: bool = False
161
+ initial: bool = False
162
+ event: "str | None" = None
163
+ cond: "str | None | list" = None
164
+ on: "ExecutableContent | None" = None
165
+ unless: "str | None | list" = None
166
+ """Negated guard expression (or list); allowed only if falsy. Native format only."""
167
+ on_refs: list = field(default_factory=list)
168
+ """Extra ``on`` callbacks referenced by name. Native JSON/YAML format only (the SCXML
169
+ reader leaves it empty)."""
170
+ before: "ExecutableContent | None" = None
171
+ """``before`` executable content (the library lifecycle slot that runs once the guards
172
+ pass, before the state change). Native format only; SCXML has no equivalent slot."""
173
+ before_refs: list = field(default_factory=list)
174
+ """Extra ``before`` callbacks referenced by name. Native format only."""
175
+ after: "ExecutableContent | None" = None
176
+ """``after`` executable content (runs after the configuration has settled). Native
177
+ format only; SCXML has no equivalent slot."""
178
+ after_refs: list = field(default_factory=list)
179
+ """Extra ``after`` callbacks referenced by name. Native format only."""
180
+
181
+
182
+ @dataclass
183
+ class DoneData:
184
+ params: list[Param] = field(default_factory=list)
185
+ content_expr: "str | None" = None
186
+
187
+
188
+ @dataclass
189
+ class InvokeDefinition:
190
+ type: "str | None" = None
191
+ typeexpr: "str | None" = None
192
+ src: "str | None" = None
193
+ srcexpr: "str | None" = None
194
+ id: "str | None" = None
195
+ idlocation: "str | None" = None
196
+ autoforward: bool = False
197
+ namelist: "str | None" = None
198
+ params: list[Param] = field(default_factory=list)
199
+ content: "str | StateMachineDefinition | None" = None
200
+ """Inline child content. A string (inline SCXML, or an expression to evaluate) or, for
201
+ the native format, an already-parsed :class:`StateMachineDefinition`."""
202
+ finalize: "ExecutableContent | None" = None
203
+
204
+
205
+ @dataclass
206
+ class State:
207
+ """A state node.
208
+
209
+ ``parallel`` marks an orthogonal region (all child states are active at once);
210
+ ``final`` marks an accepting state (which may carry ``donedata``). ``states`` holds
211
+ nested children (compound state), keyed by id.
212
+ """
213
+
214
+ id: str
215
+ initial: bool = False
216
+ final: bool = False
217
+ parallel: bool = False
218
+ transitions: list[Transition] = field(default_factory=list)
219
+ onentry: list[ExecutableContent] = field(default_factory=list)
220
+ onexit: list[ExecutableContent] = field(default_factory=list)
221
+ states: dict[str, "State"] = field(default_factory=dict)
222
+ history: dict[str, "HistoryState"] = field(default_factory=dict)
223
+ donedata: "DoneData | None" = None
224
+ invocations: list[InvokeDefinition] = field(default_factory=list)
225
+ enter_refs: list = field(default_factory=list)
226
+ """``enter`` callbacks referenced by name, appended after the ``onentry`` content.
227
+ Native JSON/YAML format only (the SCXML reader leaves it empty)."""
228
+ exit_refs: list = field(default_factory=list)
229
+ """``exit`` callbacks referenced by name, appended after the ``onexit`` content.
230
+ Native format only."""
231
+
232
+
233
+ @dataclass
234
+ class HistoryState:
235
+ id: str
236
+ type: "Literal['shallow', 'deep']" = "shallow"
237
+ transitions: list[Transition] = field(default_factory=list)
238
+
239
+
240
+ @dataclass
241
+ class DataItem:
242
+ """A datamodel variable initializer (``id``), from ``expr``, inline ``content`` or
243
+ an external ``src`` location (kept as a raw string; URL parsing happens in the reader)."""
244
+
245
+ id: str
246
+ src: "str | None"
247
+ expr: "str | None"
248
+ content: "str | None"
249
+
250
+
251
+ @dataclass
252
+ class DataModel:
253
+ data: list[DataItem] = field(default_factory=list)
254
+ scripts: list[ScriptAction] = field(default_factory=list)
255
+
256
+
257
+ @dataclass
258
+ class StateMachineDefinition:
259
+ """Top-level, format-neutral statechart definition produced by every reader.
260
+
261
+ ``initial_states`` is a list because the initial configuration may name several
262
+ states at once (one per parallel region).
263
+ """
264
+
265
+ name: "str | None" = None
266
+ states: dict[str, State] = field(default_factory=dict)
267
+ initial_states: list[str] = field(default_factory=list)
268
+ datamodel: "DataModel | None" = None