python-statemachine 3.1.2__py3-none-any.whl → 3.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_statemachine-3.1.2.dist-info → python_statemachine-3.2.1.dist-info}/METADATA +16 -3
- python_statemachine-3.2.1.dist-info/RECORD +72 -0
- {python_statemachine-3.1.2.dist-info → python_statemachine-3.2.1.dist-info}/WHEEL +1 -1
- statemachine/__init__.py +1 -1
- statemachine/callbacks.py +5 -11
- statemachine/configuration.py +5 -6
- statemachine/contrib/diagram/extract.py +26 -27
- statemachine/contrib/diagram/formatter.py +5 -7
- statemachine/contrib/diagram/model.py +9 -11
- statemachine/contrib/diagram/renderers/dot.py +20 -26
- statemachine/contrib/diagram/renderers/mermaid.py +36 -40
- statemachine/contrib/diagram/renderers/table.py +7 -9
- statemachine/contrib/weighted.py +7 -11
- statemachine/dispatcher.py +18 -13
- statemachine/engines/async_.py +20 -9
- statemachine/engines/base.py +26 -17
- statemachine/event.py +1 -2
- statemachine/exceptions.py +2 -2
- statemachine/factory.py +11 -15
- statemachine/graph.py +2 -2
- statemachine/invoke.py +12 -11
- statemachine/io/__init__.py +52 -225
- statemachine/io/actions.py +627 -0
- statemachine/io/builder.py +195 -0
- statemachine/io/class_factory.py +236 -0
- statemachine/io/evaluators.py +303 -0
- statemachine/io/interpreter.py +128 -0
- statemachine/io/{scxml/invoke.py → invoke.py} +89 -49
- statemachine/io/json/__init__.py +1 -0
- statemachine/io/json/reader.py +27 -0
- statemachine/io/loader.py +175 -0
- statemachine/io/model.py +268 -0
- statemachine/io/native.py +402 -0
- statemachine/io/ports.py +83 -0
- statemachine/io/schemas/statechart.schema.json +258 -0
- statemachine/io/scxml/__init__.py +15 -0
- statemachine/io/scxml/processor.py +23 -253
- statemachine/io/scxml/{parser.py → reader.py} +88 -56
- statemachine/io/system_variables.py +199 -0
- statemachine/io/validation.py +44 -0
- statemachine/io/yaml/__init__.py +1 -0
- statemachine/io/yaml/reader.py +65 -0
- statemachine/locale/en/LC_MESSAGES/statemachine.po +19 -19
- statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po +19 -19
- statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po +19 -19
- statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po +19 -19
- statemachine/orderedset.py +15 -6
- statemachine/registry.py +1 -4
- statemachine/signature.py +2 -5
- statemachine/spec_parser.py +228 -45
- statemachine/state.py +7 -8
- statemachine/statemachine.py +18 -20
- statemachine/states.py +3 -5
- statemachine/transition.py +3 -4
- statemachine/transition_list.py +4 -5
- statemachine/transition_mixin.py +1 -1
- python_statemachine-3.1.2.dist-info/RECORD +0 -58
- statemachine/io/scxml/actions.py +0 -650
- statemachine/io/scxml/schema.py +0 -175
- {python_statemachine-3.1.2.dist-info → python_statemachine-3.2.1.dist-info}/licenses/LICENSE +0 -0
statemachine/io/model.py
ADDED
|
@@ -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
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""Translate the native declarative dict syntax into the neutral IR.
|
|
2
|
+
|
|
3
|
+
This is the shared core of the JSON and YAML readers: both parse their text into a
|
|
4
|
+
plain Python ``dict`` and hand it to :func:`native_dict_to_definition`, which builds
|
|
5
|
+
a :class:`~statemachine.io.model.StateMachineDefinition`.
|
|
6
|
+
|
|
7
|
+
The surface syntax uses the library's own vocabulary (``states`` as a mapping keyed
|
|
8
|
+
by id, ``transitions`` as a single list with optional ``event``, ``enter``/``exit``/
|
|
9
|
+
``on`` actions, ``cond``/``unless`` guards) plus a structured action vocabulary with
|
|
10
|
+
SCXML parity (``assign``/``raise``/``log``/``if``/``foreach``/``send``/``cancel``/
|
|
11
|
+
``script``). A bare string in an action position is treated as a callback reference
|
|
12
|
+
(a method on the bound model). Guards and expressions are kept as strings; the
|
|
13
|
+
processor compiles them with the (secure-by-default) evaluator.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from ..exceptions import InvalidDefinition
|
|
22
|
+
from .model import Action
|
|
23
|
+
from .model import AssignAction
|
|
24
|
+
from .model import CancelAction
|
|
25
|
+
from .model import DataItem
|
|
26
|
+
from .model import DataModel
|
|
27
|
+
from .model import DoneData
|
|
28
|
+
from .model import ExecutableContent
|
|
29
|
+
from .model import ForeachAction
|
|
30
|
+
from .model import HistoryState
|
|
31
|
+
from .model import IfAction
|
|
32
|
+
from .model import IfBranch
|
|
33
|
+
from .model import InvokeDefinition
|
|
34
|
+
from .model import LogAction
|
|
35
|
+
from .model import Param
|
|
36
|
+
from .model import RaiseAction
|
|
37
|
+
from .model import ScriptAction
|
|
38
|
+
from .model import SendAction
|
|
39
|
+
from .model import State
|
|
40
|
+
from .model import StateMachineDefinition
|
|
41
|
+
from .model import Transition
|
|
42
|
+
|
|
43
|
+
_ACTION_KEYS = {"assign", "raise", "log", "if", "foreach", "send", "cancel", "script"}
|
|
44
|
+
|
|
45
|
+
# Allowed keys per container node. These are the single source of truth for the parser's
|
|
46
|
+
# accepted vocabulary and are asserted equal to the JSON Schema in tests/io/test_validation.py,
|
|
47
|
+
# so the schema and the parser can never silently drift apart.
|
|
48
|
+
_DOCUMENT_KEYS = frozenset({"name", "description", "datamodel", "states"})
|
|
49
|
+
_STATE_KEYS = frozenset(
|
|
50
|
+
{
|
|
51
|
+
"initial",
|
|
52
|
+
"final",
|
|
53
|
+
"parallel",
|
|
54
|
+
"enter",
|
|
55
|
+
"exit",
|
|
56
|
+
"transitions",
|
|
57
|
+
"invoke",
|
|
58
|
+
"states",
|
|
59
|
+
"history",
|
|
60
|
+
"donedata",
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
_TRANSITION_KEYS = frozenset(
|
|
64
|
+
{"event", "target", "cond", "unless", "internal", "initial", "on", "before", "after"}
|
|
65
|
+
)
|
|
66
|
+
_INVOKE_KEYS = frozenset(
|
|
67
|
+
{
|
|
68
|
+
"type",
|
|
69
|
+
"typeexpr",
|
|
70
|
+
"src",
|
|
71
|
+
"srcexpr",
|
|
72
|
+
"id",
|
|
73
|
+
"idlocation",
|
|
74
|
+
"autoforward",
|
|
75
|
+
"namelist",
|
|
76
|
+
"params",
|
|
77
|
+
"content",
|
|
78
|
+
"finalize",
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
_TRUE_STRINGS = {"true", "yes", "on", "1"}
|
|
83
|
+
_FALSE_STRINGS = {"false", "no", "off", "0", ""}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _flag(value) -> bool:
|
|
87
|
+
"""Coerce a flag value to bool, accepting bool or common string spellings."""
|
|
88
|
+
if isinstance(value, str):
|
|
89
|
+
token = value.strip().lower()
|
|
90
|
+
if token in _TRUE_STRINGS:
|
|
91
|
+
return True
|
|
92
|
+
if token in _FALSE_STRINGS:
|
|
93
|
+
return False
|
|
94
|
+
raise InvalidDefinition(f"Expected a boolean flag, got {value!r}.")
|
|
95
|
+
return bool(value)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _check_keys(node: Mapping, allowed: "frozenset[str]", kind: str) -> None:
|
|
99
|
+
"""Reject keys outside the declared vocabulary, mirroring the schema's strictness."""
|
|
100
|
+
unknown = sorted(set(node) - allowed)
|
|
101
|
+
if unknown:
|
|
102
|
+
raise InvalidDefinition(
|
|
103
|
+
f"Unknown {kind} key(s) {unknown}; allowed keys are {sorted(allowed)}."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def native_dict_to_definition(
|
|
108
|
+
doc: Mapping, *, source_name: "str | None" = None
|
|
109
|
+
) -> StateMachineDefinition:
|
|
110
|
+
"""Build a :class:`StateMachineDefinition` from the native dict syntax."""
|
|
111
|
+
if not isinstance(doc, Mapping):
|
|
112
|
+
raise InvalidDefinition(
|
|
113
|
+
f"Statechart definition must be a mapping, got {type(doc).__name__}."
|
|
114
|
+
)
|
|
115
|
+
_check_keys(doc, _DOCUMENT_KEYS, "document")
|
|
116
|
+
states_doc = doc.get("states")
|
|
117
|
+
if not isinstance(states_doc, Mapping) or not states_doc:
|
|
118
|
+
raise InvalidDefinition("Statechart definition must have a non-empty 'states' mapping.")
|
|
119
|
+
|
|
120
|
+
states: dict[str, State] = {}
|
|
121
|
+
initial_states: list[str] = []
|
|
122
|
+
for state_id, state_def in states_doc.items():
|
|
123
|
+
state = _parse_state(state_id, state_def or {})
|
|
124
|
+
states[state_id] = state
|
|
125
|
+
if state.initial:
|
|
126
|
+
initial_states.append(state_id)
|
|
127
|
+
|
|
128
|
+
# If no initial state was declared, pick the first one (mirrors the SCXML reader).
|
|
129
|
+
if not initial_states and states:
|
|
130
|
+
first = next(iter(states))
|
|
131
|
+
states[first].initial = True
|
|
132
|
+
initial_states.append(first)
|
|
133
|
+
|
|
134
|
+
return StateMachineDefinition(
|
|
135
|
+
name=doc.get("name") or source_name,
|
|
136
|
+
states=states,
|
|
137
|
+
initial_states=initial_states,
|
|
138
|
+
datamodel=_parse_datamodel(doc.get("datamodel")),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _parse_state(state_id: str, sdef: Mapping) -> State:
|
|
143
|
+
if not isinstance(sdef, Mapping):
|
|
144
|
+
raise InvalidDefinition(f"State {state_id!r} definition must be a mapping.")
|
|
145
|
+
_check_keys(sdef, _STATE_KEYS, f"state {state_id!r}")
|
|
146
|
+
|
|
147
|
+
state = State(
|
|
148
|
+
id=state_id,
|
|
149
|
+
initial=_flag(sdef.get("initial")),
|
|
150
|
+
final=_flag(sdef.get("final")),
|
|
151
|
+
parallel=_flag(sdef.get("parallel")),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
enter_content, state.enter_refs = _parse_actions(sdef.get("enter"))
|
|
155
|
+
if not enter_content.is_empty:
|
|
156
|
+
state.onentry.append(enter_content)
|
|
157
|
+
exit_content, state.exit_refs = _parse_actions(sdef.get("exit"))
|
|
158
|
+
if not exit_content.is_empty:
|
|
159
|
+
state.onexit.append(exit_content)
|
|
160
|
+
|
|
161
|
+
for item in sdef.get("transitions", []) or []:
|
|
162
|
+
state.transitions.append(_parse_transition(item))
|
|
163
|
+
|
|
164
|
+
for child_id, child_def in (sdef.get("states") or {}).items():
|
|
165
|
+
state.states[child_id] = _parse_state(child_id, child_def or {})
|
|
166
|
+
|
|
167
|
+
for hist_id, hist_def in (sdef.get("history") or {}).items():
|
|
168
|
+
state.history[hist_id] = _parse_history(hist_id, hist_def or {})
|
|
169
|
+
|
|
170
|
+
invoke_value = sdef.get("invoke")
|
|
171
|
+
if invoke_value:
|
|
172
|
+
items = [invoke_value] if isinstance(invoke_value, Mapping) else invoke_value
|
|
173
|
+
for item in items:
|
|
174
|
+
state.invocations.append(_parse_invoke(item))
|
|
175
|
+
|
|
176
|
+
if state.final and "donedata" in sdef:
|
|
177
|
+
state.donedata = _parse_donedata(sdef["donedata"])
|
|
178
|
+
|
|
179
|
+
return state
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _parse_invoke(item: Mapping) -> InvokeDefinition:
|
|
183
|
+
"""Parse a native ``invoke`` entry.
|
|
184
|
+
|
|
185
|
+
``content`` may be an inline child statechart (a mapping, parsed eagerly into a neutral
|
|
186
|
+
definition) or an expression string; ``src``/``srcexpr`` reference a child document in
|
|
187
|
+
the same format. ``finalize`` is structured executable content.
|
|
188
|
+
"""
|
|
189
|
+
if not isinstance(item, Mapping):
|
|
190
|
+
raise InvalidDefinition(f"Invoke entry must be a mapping, got {type(item).__name__}.")
|
|
191
|
+
_check_keys(item, _INVOKE_KEYS, "invoke")
|
|
192
|
+
|
|
193
|
+
content = item.get("content")
|
|
194
|
+
if isinstance(content, Mapping):
|
|
195
|
+
content = native_dict_to_definition(content)
|
|
196
|
+
|
|
197
|
+
params = [
|
|
198
|
+
Param(name=p["name"], expr=p.get("expr"), location=p.get("location"))
|
|
199
|
+
for p in (item.get("params") or [])
|
|
200
|
+
]
|
|
201
|
+
finalize_actions = _parse_action_list(item.get("finalize"))
|
|
202
|
+
|
|
203
|
+
return InvokeDefinition(
|
|
204
|
+
type=item.get("type"),
|
|
205
|
+
typeexpr=item.get("typeexpr"),
|
|
206
|
+
src=item.get("src"),
|
|
207
|
+
srcexpr=item.get("srcexpr"),
|
|
208
|
+
id=item.get("id"),
|
|
209
|
+
idlocation=item.get("idlocation"),
|
|
210
|
+
autoforward=_flag(item.get("autoforward")),
|
|
211
|
+
namelist=item.get("namelist"),
|
|
212
|
+
params=params,
|
|
213
|
+
content=content,
|
|
214
|
+
finalize=ExecutableContent(actions=finalize_actions) if finalize_actions else None,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _parse_history(hist_id: str, hdef: Mapping) -> HistoryState:
|
|
219
|
+
history = HistoryState(id=hist_id, type=hdef.get("type", "shallow"))
|
|
220
|
+
for item in hdef.get("transitions", []) or []:
|
|
221
|
+
history.transitions.append(_parse_transition(item))
|
|
222
|
+
return history
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _parse_transition(item: Mapping) -> Transition:
|
|
226
|
+
if not isinstance(item, Mapping):
|
|
227
|
+
raise InvalidDefinition(f"Transition must be a mapping, got {type(item).__name__}.")
|
|
228
|
+
_check_keys(item, _TRANSITION_KEYS, "transition")
|
|
229
|
+
transition = Transition(
|
|
230
|
+
target=item.get("target"),
|
|
231
|
+
event=item.get("event"),
|
|
232
|
+
internal=_flag(item.get("internal")),
|
|
233
|
+
initial=_flag(item.get("initial")),
|
|
234
|
+
cond=item.get("cond"),
|
|
235
|
+
unless=item.get("unless"),
|
|
236
|
+
)
|
|
237
|
+
on_content, transition.on_refs = _parse_actions(item.get("on"))
|
|
238
|
+
if not on_content.is_empty:
|
|
239
|
+
transition.on = on_content
|
|
240
|
+
before_content, transition.before_refs = _parse_actions(item.get("before"))
|
|
241
|
+
if not before_content.is_empty:
|
|
242
|
+
transition.before = before_content
|
|
243
|
+
after_content, transition.after_refs = _parse_actions(item.get("after"))
|
|
244
|
+
if not after_content.is_empty:
|
|
245
|
+
transition.after = after_content
|
|
246
|
+
return transition
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _parse_datamodel(value) -> "DataModel | None":
|
|
250
|
+
if not value:
|
|
251
|
+
return None
|
|
252
|
+
data_model = DataModel()
|
|
253
|
+
# Accept either a list of {id, expr/content} or a mapping {id: expr}.
|
|
254
|
+
if isinstance(value, Mapping):
|
|
255
|
+
items = [{"id": k, "expr": v} for k, v in value.items()]
|
|
256
|
+
else:
|
|
257
|
+
items = list(value)
|
|
258
|
+
for item in items:
|
|
259
|
+
data_model.data.append(
|
|
260
|
+
DataItem(
|
|
261
|
+
id=item["id"],
|
|
262
|
+
src=item.get("src"),
|
|
263
|
+
expr=item.get("expr"),
|
|
264
|
+
content=item.get("content"),
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
return data_model if data_model.data else None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _parse_donedata(value: Mapping) -> DoneData:
|
|
271
|
+
params = [
|
|
272
|
+
Param(name=p["name"], expr=p.get("expr"), location=p.get("location"))
|
|
273
|
+
for p in (value.get("params") or [])
|
|
274
|
+
]
|
|
275
|
+
return DoneData(params=params, content_expr=value.get("content"))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _as_list(value) -> list:
|
|
279
|
+
"""Normalize a single action or a list of actions into a list."""
|
|
280
|
+
if value is None:
|
|
281
|
+
return []
|
|
282
|
+
if isinstance(value, (str, Mapping)):
|
|
283
|
+
return [value]
|
|
284
|
+
if isinstance(value, Sequence):
|
|
285
|
+
return list(value)
|
|
286
|
+
raise InvalidDefinition(
|
|
287
|
+
f"Expected an action, a list, or a string, got {type(value).__name__}."
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _parse_actions(value) -> "tuple[ExecutableContent, list]":
|
|
292
|
+
"""Split an action value into executable content and bare callback references."""
|
|
293
|
+
actions = []
|
|
294
|
+
refs: list = []
|
|
295
|
+
for item in _as_list(value):
|
|
296
|
+
if isinstance(item, str):
|
|
297
|
+
refs.append(item)
|
|
298
|
+
elif isinstance(item, Mapping):
|
|
299
|
+
actions.append(_parse_action_node(item))
|
|
300
|
+
else:
|
|
301
|
+
raise InvalidDefinition(
|
|
302
|
+
f"Action must be a string or a mapping, got {type(item).__name__}."
|
|
303
|
+
)
|
|
304
|
+
return ExecutableContent(actions=actions), refs
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _parse_action_list(value) -> list:
|
|
308
|
+
"""Parse nested actions (inside ``if``/``foreach``); callback refs are not allowed here."""
|
|
309
|
+
content, refs = _parse_actions(value)
|
|
310
|
+
if refs:
|
|
311
|
+
raise InvalidDefinition(
|
|
312
|
+
f"Callback references {refs!r} are not allowed inside if/foreach branches; "
|
|
313
|
+
"use a structured action instead."
|
|
314
|
+
)
|
|
315
|
+
return content.actions
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _parse_action_node(node: Mapping):
|
|
319
|
+
keys = _ACTION_KEYS & set(node)
|
|
320
|
+
if len(keys) != 1:
|
|
321
|
+
raise InvalidDefinition(
|
|
322
|
+
f"Each action must have exactly one of {sorted(_ACTION_KEYS)} as its key, "
|
|
323
|
+
f"got keys {sorted(node)}."
|
|
324
|
+
)
|
|
325
|
+
kind = keys.pop()
|
|
326
|
+
body = node[kind]
|
|
327
|
+
return _ACTION_BUILDERS[kind](body)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _build_assign(body: Mapping) -> AssignAction:
|
|
331
|
+
return AssignAction(location=body["location"], expr=body.get("expr"))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _build_raise(body) -> RaiseAction:
|
|
335
|
+
event = body if isinstance(body, str) else body["event"]
|
|
336
|
+
return RaiseAction(event=event)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _build_log(body) -> LogAction:
|
|
340
|
+
if isinstance(body, str):
|
|
341
|
+
return LogAction(label=None, expr=body)
|
|
342
|
+
return LogAction(label=body.get("label"), expr=body.get("expr"))
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _build_script(body: str) -> ScriptAction:
|
|
346
|
+
return ScriptAction(content=body)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _build_cancel(body: Mapping) -> CancelAction:
|
|
350
|
+
return CancelAction(sendid=body.get("sendid"), sendidexpr=body.get("sendidexpr"))
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _build_foreach(body: Mapping) -> ForeachAction:
|
|
354
|
+
return ForeachAction(
|
|
355
|
+
array=body["array"],
|
|
356
|
+
item=body["item"],
|
|
357
|
+
index=body.get("index"),
|
|
358
|
+
content=ExecutableContent(actions=_parse_action_list(body.get("do"))),
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _build_send(body: Mapping) -> SendAction:
|
|
363
|
+
params = [
|
|
364
|
+
Param(name=p["name"], expr=p.get("expr"), location=p.get("location"))
|
|
365
|
+
for p in (body.get("params") or [])
|
|
366
|
+
]
|
|
367
|
+
return SendAction(
|
|
368
|
+
event=body.get("event"),
|
|
369
|
+
eventexpr=body.get("eventexpr"),
|
|
370
|
+
target=body.get("target"),
|
|
371
|
+
type=body.get("type"),
|
|
372
|
+
id=body.get("id"),
|
|
373
|
+
idlocation=body.get("idlocation"),
|
|
374
|
+
delay=body.get("delay"),
|
|
375
|
+
delayexpr=body.get("delayexpr"),
|
|
376
|
+
namelist=body.get("namelist"),
|
|
377
|
+
params=params,
|
|
378
|
+
content=body.get("content"),
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _build_if(body: Mapping) -> IfAction:
|
|
383
|
+
branches = [IfBranch(cond=body["cond"], actions=_parse_action_list(body.get("then")))]
|
|
384
|
+
for elif_branch in body.get("elif", []) or []:
|
|
385
|
+
branches.append(
|
|
386
|
+
IfBranch(cond=elif_branch["cond"], actions=_parse_action_list(elif_branch.get("then")))
|
|
387
|
+
)
|
|
388
|
+
if "else" in body:
|
|
389
|
+
branches.append(IfBranch(cond=None, actions=_parse_action_list(body["else"])))
|
|
390
|
+
return IfAction(branches=branches)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
_ACTION_BUILDERS: "dict[str, Callable[[Any], Action]]" = {
|
|
394
|
+
"assign": _build_assign,
|
|
395
|
+
"raise": _build_raise,
|
|
396
|
+
"log": _build_log,
|
|
397
|
+
"script": _build_script,
|
|
398
|
+
"cancel": _build_cancel,
|
|
399
|
+
"foreach": _build_foreach,
|
|
400
|
+
"send": _build_send,
|
|
401
|
+
"if": _build_if,
|
|
402
|
+
}
|