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,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
+ }
@@ -0,0 +1,83 @@
1
+ """Format ports & adapters: the reader Protocol and the format registry.
2
+
3
+ A :class:`FormatReader` is the *port* every format adapter implements: it turns
4
+ raw text into the neutral IR (:class:`~statemachine.io.model.StateMachineDefinition`).
5
+ Each format also declares which file extensions it owns and which processor builds
6
+ its IR into a :class:`~statemachine.statemachine.StateChart` class.
7
+
8
+ Adapters register themselves with :func:`register_format` (see the ``scxml``,
9
+ ``json`` and ``yaml`` reader modules). The high-level :func:`statemachine.io.load`
10
+ facade uses :func:`detect_format` and :func:`get_format` to wire everything up.
11
+ """
12
+
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any
17
+ from typing import Protocol
18
+ from typing import runtime_checkable
19
+
20
+ from .model import StateMachineDefinition
21
+
22
+
23
+ @runtime_checkable
24
+ class FormatReader(Protocol):
25
+ """A format adapter: parses text of one format into the neutral IR."""
26
+
27
+ def read(
28
+ self, text: str, *, source_name: "str | None" = None
29
+ ) -> StateMachineDefinition: # pragma: no cover - structural Protocol
30
+ ...
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class FormatSpec:
35
+ """Describes a supported serialization format.
36
+
37
+ The runtime is the format-neutral :class:`~statemachine.io.interpreter.Interpreter`;
38
+ a format only needs to provide a reader (text -> neutral IR).
39
+
40
+ Args:
41
+ name: the canonical format name (e.g. ``"scxml"``, ``"json"``, ``"yaml"``).
42
+ extensions: file extensions (with leading dot) that map to this format.
43
+ reader_factory: builds a :class:`FormatReader` for this format.
44
+ """
45
+
46
+ name: str
47
+ extensions: "tuple[str, ...]"
48
+ reader_factory: "Callable[..., Any]"
49
+
50
+
51
+ _FORMATS: "dict[str, FormatSpec]" = {}
52
+ _EXTENSIONS: "dict[str, str]" = {}
53
+
54
+
55
+ def register_format(spec: FormatSpec) -> None:
56
+ """Register a format spec, indexing it by name and by each of its extensions."""
57
+ _FORMATS[spec.name] = spec
58
+ for ext in spec.extensions:
59
+ _EXTENSIONS[ext] = spec.name
60
+
61
+
62
+ def get_format(name: str) -> FormatSpec:
63
+ """Return the :class:`FormatSpec` registered under ``name``."""
64
+ try:
65
+ return _FORMATS[name]
66
+ except KeyError:
67
+ available = ", ".join(sorted(_FORMATS)) or "(none registered)"
68
+ raise ValueError(f"Unknown format: {name!r}. Available formats: {available}.") from None
69
+
70
+
71
+ def detect_format(path: Path, explicit: "str | None" = None) -> str:
72
+ """Resolve the format name from an explicit override or the file extension."""
73
+ if explicit is not None:
74
+ return explicit
75
+ ext = path.suffix.lower()
76
+ try:
77
+ return _EXTENSIONS[ext]
78
+ except KeyError:
79
+ known = ", ".join(sorted(_EXTENSIONS)) or "(none registered)"
80
+ raise ValueError(
81
+ f"Cannot detect format from extension {ext!r}. Known extensions: "
82
+ f"{known}. Pass an explicit format=... argument."
83
+ ) from None