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
|
@@ -1,31 +1,32 @@
|
|
|
1
1
|
import re
|
|
2
2
|
import xml.etree.ElementTree as ET
|
|
3
|
-
from
|
|
3
|
+
from collections.abc import Callable
|
|
4
4
|
from typing import Literal
|
|
5
|
-
from typing import Set
|
|
6
5
|
from typing import cast
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
from
|
|
10
|
-
from
|
|
11
|
-
from
|
|
12
|
-
from
|
|
13
|
-
from
|
|
14
|
-
from
|
|
15
|
-
from
|
|
16
|
-
from
|
|
17
|
-
from
|
|
18
|
-
from
|
|
19
|
-
from
|
|
20
|
-
from
|
|
21
|
-
from
|
|
22
|
-
from
|
|
23
|
-
from
|
|
24
|
-
from
|
|
25
|
-
from
|
|
26
|
-
from
|
|
27
|
-
from
|
|
28
|
-
from
|
|
6
|
+
|
|
7
|
+
from ...exceptions import InvalidDefinition
|
|
8
|
+
from ..model import Action
|
|
9
|
+
from ..model import AssignAction
|
|
10
|
+
from ..model import CancelAction
|
|
11
|
+
from ..model import DataItem
|
|
12
|
+
from ..model import DataModel
|
|
13
|
+
from ..model import DoneData
|
|
14
|
+
from ..model import ExecutableContent
|
|
15
|
+
from ..model import ForeachAction
|
|
16
|
+
from ..model import HistoryState
|
|
17
|
+
from ..model import IfAction
|
|
18
|
+
from ..model import IfBranch
|
|
19
|
+
from ..model import InvokeDefinition
|
|
20
|
+
from ..model import LogAction
|
|
21
|
+
from ..model import Param
|
|
22
|
+
from ..model import RaiseAction
|
|
23
|
+
from ..model import ScriptAction
|
|
24
|
+
from ..model import SendAction
|
|
25
|
+
from ..model import State
|
|
26
|
+
from ..model import StateMachineDefinition
|
|
27
|
+
from ..model import Transition
|
|
28
|
+
from ..ports import FormatSpec
|
|
29
|
+
from ..ports import register_format
|
|
29
30
|
|
|
30
31
|
|
|
31
32
|
def strip_namespaces(tree: ET.Element):
|
|
@@ -40,14 +41,33 @@ def strip_namespaces(tree: ET.Element):
|
|
|
40
41
|
attrib[new_name] = attrib.pop(name)
|
|
41
42
|
|
|
42
43
|
|
|
43
|
-
def _parse_initial(initial_content: "str | None") ->
|
|
44
|
+
def _parse_initial(initial_content: "str | None") -> list[str]:
|
|
44
45
|
if initial_content is None:
|
|
45
46
|
return []
|
|
46
47
|
return initial_content.split()
|
|
47
48
|
|
|
48
49
|
|
|
50
|
+
class _NoDTDTreeBuilder(ET.TreeBuilder):
|
|
51
|
+
"""A tree builder that refuses documents carrying a ``<!DOCTYPE>``/DTD.
|
|
52
|
+
|
|
53
|
+
Internal entity definitions (the basis of billion-laughs / quadratic-blowup
|
|
54
|
+
expansion bombs) require a DTD, so rejecting the DOCTYPE neutralizes them at
|
|
55
|
+
parse time. Expat calls this ``doctype`` hook when it meets the declaration.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def doctype(self, name, pubid, system): # noqa: D102 - expat callback
|
|
59
|
+
raise InvalidDefinition(
|
|
60
|
+
"DOCTYPE/DTD is not allowed in SCXML documents (XML entity-expansion protection)."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _fromstring_no_dtd(scxml_content: str) -> ET.Element:
|
|
65
|
+
"""Parse SCXML text rejecting any DOCTYPE (see :class:`_NoDTDTreeBuilder`)."""
|
|
66
|
+
return ET.fromstring(scxml_content, parser=ET.XMLParser(target=_NoDTDTreeBuilder()))
|
|
67
|
+
|
|
68
|
+
|
|
49
69
|
def parse_scxml(scxml_content: str) -> StateMachineDefinition: # noqa: C901
|
|
50
|
-
root =
|
|
70
|
+
root = _fromstring_no_dtd(scxml_content)
|
|
51
71
|
strip_namespaces(root)
|
|
52
72
|
|
|
53
73
|
scxml = root if root.tag == "scxml" else root.find(".//scxml")
|
|
@@ -87,13 +107,13 @@ def parse_scxml(scxml_content: str) -> StateMachineDefinition: # noqa: C901
|
|
|
87
107
|
return definition
|
|
88
108
|
|
|
89
109
|
|
|
90
|
-
def _find_own_datamodel_elements(root: ET.Element) ->
|
|
110
|
+
def _find_own_datamodel_elements(root: ET.Element) -> list[ET.Element]:
|
|
91
111
|
"""Find <datamodel> elements that belong to this SCXML document, not to inline children.
|
|
92
112
|
|
|
93
113
|
Skips any <datamodel> nested inside <content> elements (which contain inline
|
|
94
114
|
child SCXML documents for <invoke>).
|
|
95
115
|
"""
|
|
96
|
-
result:
|
|
116
|
+
result: list[ET.Element] = []
|
|
97
117
|
|
|
98
118
|
def _walk(elem: ET.Element):
|
|
99
119
|
for child in elem:
|
|
@@ -113,16 +133,12 @@ def parse_datamodel(root: ET.Element) -> "DataModel | None":
|
|
|
113
133
|
for datamodel_elem in _find_own_datamodel_elements(root):
|
|
114
134
|
for data_elem in datamodel_elem.findall("data"):
|
|
115
135
|
content = data_elem.text and re.sub(r"\s+", " ", data_elem.text).strip() or None
|
|
116
|
-
src
|
|
117
|
-
|
|
118
|
-
if src_parsed and src_parsed.scheme == "file" and content is None:
|
|
119
|
-
with open(src_parsed.path) as f:
|
|
120
|
-
content = f.read()
|
|
121
|
-
|
|
136
|
+
# An external ``src`` is kept as a raw string; resolving it (reading the file)
|
|
137
|
+
# is deferred to datamodel compilation, where the trust policy can gate it.
|
|
122
138
|
data_model.data.append(
|
|
123
139
|
DataItem(
|
|
124
140
|
id=data_elem.attrib["id"],
|
|
125
|
-
src=
|
|
141
|
+
src=data_elem.attrib.get("src"),
|
|
126
142
|
expr=data_elem.attrib.get("expr"),
|
|
127
143
|
content=content,
|
|
128
144
|
)
|
|
@@ -157,7 +173,7 @@ def parse_history(state_elem: ET.Element) -> HistoryState:
|
|
|
157
173
|
|
|
158
174
|
def parse_state( # noqa: C901
|
|
159
175
|
state_elem: ET.Element,
|
|
160
|
-
initial_states:
|
|
176
|
+
initial_states: set[str],
|
|
161
177
|
is_final: bool = False,
|
|
162
178
|
is_parallel: bool = False,
|
|
163
179
|
) -> State:
|
|
@@ -279,25 +295,11 @@ def parse_executable_content(element: ET.Element) -> ExecutableContent:
|
|
|
279
295
|
|
|
280
296
|
|
|
281
297
|
def parse_element(element: ET.Element) -> Action:
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
elif tag == "log":
|
|
288
|
-
return parse_log(element)
|
|
289
|
-
elif tag == "if":
|
|
290
|
-
return parse_if(element)
|
|
291
|
-
elif tag == "send":
|
|
292
|
-
return parse_send(element)
|
|
293
|
-
elif tag == "script":
|
|
294
|
-
return parse_script(element)
|
|
295
|
-
elif tag == "foreach":
|
|
296
|
-
return parse_foreach(element)
|
|
297
|
-
elif tag == "cancel":
|
|
298
|
-
return parse_cancel(element)
|
|
299
|
-
|
|
300
|
-
raise ValueError(f"Unknown tag: {tag}")
|
|
298
|
+
try:
|
|
299
|
+
parser = _ELEMENT_PARSERS[element.tag]
|
|
300
|
+
except KeyError:
|
|
301
|
+
raise ValueError(f"Unknown tag: {element.tag}") from None
|
|
302
|
+
return parser(element)
|
|
301
303
|
|
|
302
304
|
|
|
303
305
|
def parse_raise(element: ET.Element) -> RaiseAction:
|
|
@@ -434,7 +436,7 @@ def parse_invoke(element: ET.Element) -> InvokeDefinition:
|
|
|
434
436
|
autoforward = element.attrib.get("autoforward", "false").lower() == "true"
|
|
435
437
|
namelist = element.attrib.get("namelist")
|
|
436
438
|
|
|
437
|
-
params:
|
|
439
|
+
params: list[Param] = []
|
|
438
440
|
content: "str | None" = None
|
|
439
441
|
finalize: "ExecutableContent | None" = None
|
|
440
442
|
|
|
@@ -471,3 +473,33 @@ def parse_invoke(element: ET.Element) -> InvokeDefinition:
|
|
|
471
473
|
content=content,
|
|
472
474
|
finalize=finalize,
|
|
473
475
|
)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
#: Dispatch table for executable-content elements (all parsers share the same
|
|
479
|
+
#: ``(element) -> Action`` signature, so a lookup is cleaner than a branch chain).
|
|
480
|
+
_ELEMENT_PARSERS: "dict[str, Callable[[ET.Element], Action]]" = {
|
|
481
|
+
"raise": parse_raise,
|
|
482
|
+
"assign": parse_assign,
|
|
483
|
+
"log": parse_log,
|
|
484
|
+
"if": parse_if,
|
|
485
|
+
"send": parse_send,
|
|
486
|
+
"script": parse_script,
|
|
487
|
+
"foreach": parse_foreach,
|
|
488
|
+
"cancel": parse_cancel,
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
class SCXMLReader:
|
|
493
|
+
"""Format adapter that parses SCXML (XML) documents into the neutral IR."""
|
|
494
|
+
|
|
495
|
+
def read(self, text: str, *, source_name: "str | None" = None) -> StateMachineDefinition:
|
|
496
|
+
return parse_scxml(text)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
register_format(
|
|
500
|
+
FormatSpec(
|
|
501
|
+
name="scxml",
|
|
502
|
+
extensions=(".scxml", ".xml"),
|
|
503
|
+
reader_factory=SCXMLReader,
|
|
504
|
+
)
|
|
505
|
+
)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Runtime system variables of the statechart execution model.
|
|
2
|
+
|
|
3
|
+
These are part of the (SCXML-derived) execution model the library implements, not of
|
|
4
|
+
any particular syntax — so they are format-neutral and available to statecharts loaded
|
|
5
|
+
from SCXML, JSON or YAML alike. The :class:`~statemachine.io.interpreter.Interpreter`
|
|
6
|
+
injects them on every event via :func:`build_system_variables`:
|
|
7
|
+
|
|
8
|
+
- ``_event``: the current event, wrapped as :class:`EventDataWrapper`
|
|
9
|
+
(``name``/``data``/``type``/``origintype``/``sendid``/``invokeid``).
|
|
10
|
+
- ``_sessionid``: a stable id for the running machine session.
|
|
11
|
+
- ``_name``: the machine name.
|
|
12
|
+
- ``_ioprocessors``: the session's :class:`IOProcessor`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class _Data:
|
|
21
|
+
"""The ``_event.data`` payload view.
|
|
22
|
+
|
|
23
|
+
The raw payload dict is stored under a private (``_``-prefixed) name so a restricted
|
|
24
|
+
expression cannot hand back the underlying dict via ``_event.data.kwargs``; only the
|
|
25
|
+
individual payload fields (``_event.data.<field>``) are reachable.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
_kwargs: dict
|
|
29
|
+
|
|
30
|
+
def __getattr__(self, name):
|
|
31
|
+
if name == "_kwargs":
|
|
32
|
+
# Guard against unbounded recursion before the field is set (e.g. copy/pickle).
|
|
33
|
+
raise AttributeError(name)
|
|
34
|
+
return self._kwargs.get(name, None)
|
|
35
|
+
|
|
36
|
+
def get(self, name, default=None):
|
|
37
|
+
return self._kwargs.get(name, default)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OriginTypeSCXML(str):
|
|
41
|
+
"""The origintype of an :ref:`Event` as specified by the SCXML namespace."""
|
|
42
|
+
|
|
43
|
+
def __eq__(self, other):
|
|
44
|
+
return other == "http://www.w3.org/TR/scxml/#SCXMLEventProcessor" or other == "scxml"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class EventDataWrapper:
|
|
48
|
+
"""The ``_event`` system variable: a read-only view of the current event.
|
|
49
|
+
|
|
50
|
+
Exposes ``name``/``data``/``type``/``origintype``/``sendid``/``invokeid`` following
|
|
51
|
+
the SCXML event model, which is the library's execution model regardless of the
|
|
52
|
+
source syntax.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
origin: str = ""
|
|
56
|
+
origintype: str = OriginTypeSCXML("scxml")
|
|
57
|
+
invokeid: str = ""
|
|
58
|
+
"""If this event is generated from an invoked child process, the Processor MUST set
|
|
59
|
+
this field to the invoke id of the invocation that triggered the child process.
|
|
60
|
+
Otherwise it MUST leave it blank.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, event_data=None, *, trigger_data=None):
|
|
64
|
+
# The engine carriers are stored under private (``_``-prefixed) names so a
|
|
65
|
+
# restricted expression cannot reach them (``_event.event_data`` /
|
|
66
|
+
# ``_event.trigger_data``) and pivot to the machine/interpreter/State. The
|
|
67
|
+
# restricted evaluator blocks attribute access to ``_``-prefixed names, and this
|
|
68
|
+
# class deliberately exposes NO public attribute or ``__getattr__`` forward that
|
|
69
|
+
# would hand them back (GHSA-v3qq-3xvg-m77g).
|
|
70
|
+
self._event_data = event_data
|
|
71
|
+
if trigger_data is not None:
|
|
72
|
+
self._trigger_data = trigger_data
|
|
73
|
+
elif event_data is not None:
|
|
74
|
+
self._trigger_data = event_data.trigger_data
|
|
75
|
+
else:
|
|
76
|
+
raise ValueError("Either event_data or trigger_data must be provided")
|
|
77
|
+
|
|
78
|
+
td = self._trigger_data
|
|
79
|
+
self.sendid = td.send_id
|
|
80
|
+
self.invokeid = td.kwargs.get("_invokeid", "")
|
|
81
|
+
if td.event is None or td.event.internal:
|
|
82
|
+
if "error.execution" == td.event:
|
|
83
|
+
self.type = "platform"
|
|
84
|
+
else:
|
|
85
|
+
self.type = "internal"
|
|
86
|
+
self.origintype = ""
|
|
87
|
+
else:
|
|
88
|
+
self.type = "external"
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_trigger_data(cls, trigger_data):
|
|
92
|
+
"""Create an EventDataWrapper directly from a TriggerData (no EventData needed)."""
|
|
93
|
+
return cls(trigger_data=trigger_data)
|
|
94
|
+
|
|
95
|
+
def __eq__(self, value):
|
|
96
|
+
"This makes SCXML test 329 pass. It assumes that the event is the same instance"
|
|
97
|
+
return isinstance(value, EventDataWrapper)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def name(self):
|
|
101
|
+
if self._event_data is not None:
|
|
102
|
+
return self._event_data.event
|
|
103
|
+
return str(self._trigger_data.event) if self._trigger_data.event else None
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def data(self):
|
|
107
|
+
"Property used to access the event payload (the SCXML ``_event.data``)."
|
|
108
|
+
td = self._trigger_data
|
|
109
|
+
if td.kwargs:
|
|
110
|
+
return _Data(td.kwargs)
|
|
111
|
+
elif td.args and len(td.args) == 1:
|
|
112
|
+
return td.args[0]
|
|
113
|
+
elif td.args:
|
|
114
|
+
return td.args
|
|
115
|
+
else:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class IOProcessor:
|
|
120
|
+
"""The ``_ioprocessors`` system variable for a session.
|
|
121
|
+
|
|
122
|
+
A minimal Event I/O Processor handle: indexing by any processor name returns itself,
|
|
123
|
+
and ``location`` is the machine name.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(self, interpreter, machine):
|
|
127
|
+
# Stored under private (``_``-prefixed) names so a restricted expression cannot
|
|
128
|
+
# reach the interpreter/machine via ``_ioprocessors.interpreter`` /
|
|
129
|
+
# ``_ioprocessors.machine`` and mutate the process-shared engine
|
|
130
|
+
# (GHSA-v3qq-3xvg-m77g). Only the SCXML-visible ``location`` stays public.
|
|
131
|
+
self._interpreter = interpreter
|
|
132
|
+
self._machine = machine
|
|
133
|
+
|
|
134
|
+
def __getitem__(self, name: str):
|
|
135
|
+
return self
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def location(self):
|
|
139
|
+
return self._machine.name
|
|
140
|
+
|
|
141
|
+
def get(self, name: str):
|
|
142
|
+
return getattr(self, name)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass
|
|
146
|
+
class SessionData:
|
|
147
|
+
"""Per-machine runtime session state held by the interpreter."""
|
|
148
|
+
|
|
149
|
+
machine: Any
|
|
150
|
+
processor: IOProcessor
|
|
151
|
+
first_event_raised: bool = False
|
|
152
|
+
|
|
153
|
+
def __post_init__(self):
|
|
154
|
+
self.session_id = f"{self.machine.name}:{id(self.machine)}"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def build_system_variables(machine, session_data: SessionData, event, event_data) -> dict:
|
|
158
|
+
"""Compute the system variables to inject into callbacks for the current event.
|
|
159
|
+
|
|
160
|
+
``_event`` is exposed only after the first real (non-``__initial__``) event, matching
|
|
161
|
+
the SCXML rule that ``_event`` is unbound during the initial macrostep.
|
|
162
|
+
"""
|
|
163
|
+
if not session_data.first_event_raised and event and event != "__initial__":
|
|
164
|
+
session_data.first_event_raised = True
|
|
165
|
+
|
|
166
|
+
_event: "EventDataWrapper | None" = None
|
|
167
|
+
if session_data.first_event_raised:
|
|
168
|
+
_event = EventDataWrapper(event_data)
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
"_name": machine.name,
|
|
172
|
+
"_sessionid": session_data.session_id,
|
|
173
|
+
"_ioprocessors": session_data.processor,
|
|
174
|
+
"_event": _event,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def create_invoke_init_callable():
|
|
179
|
+
"""Create a callback that extracts invoke-specific kwargs and stores them on the machine.
|
|
180
|
+
|
|
181
|
+
Inserted at position 0 in the initial state's onentry list for invoked children, so
|
|
182
|
+
``_invoke_session`` and ``_invoke_params`` are handled before any other callbacks run,
|
|
183
|
+
even for machines without a datamodel.
|
|
184
|
+
"""
|
|
185
|
+
initialized = False
|
|
186
|
+
|
|
187
|
+
def invoke_init(*args, **kwargs):
|
|
188
|
+
nonlocal initialized
|
|
189
|
+
if initialized:
|
|
190
|
+
return
|
|
191
|
+
initialized = True
|
|
192
|
+
machine = kwargs.get("machine")
|
|
193
|
+
if machine is not None:
|
|
194
|
+
# Use get() not pop(): each callback receives a copy of kwargs
|
|
195
|
+
# (via EventData.extended_kwargs), so pop would be misleading.
|
|
196
|
+
machine._invoke_params = kwargs.get("_invoke_params")
|
|
197
|
+
machine._invoke_session = kwargs.get("_invoke_session")
|
|
198
|
+
|
|
199
|
+
return invoke_init
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Optional JSON Schema validation for the native (JSON/YAML) statechart format.
|
|
2
|
+
|
|
3
|
+
Requires the optional ``[validation]`` extra (``jsonschema``). The schema itself is
|
|
4
|
+
shipped with the package at ``statemachine/io/schemas/statechart.schema.json`` and is
|
|
5
|
+
also published so editors/tools can reference it via ``$schema``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
from importlib.resources import files
|
|
11
|
+
|
|
12
|
+
from ..exceptions import InvalidDefinition
|
|
13
|
+
|
|
14
|
+
SCHEMA_RESOURCE = "statechart.schema.json"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@lru_cache(maxsize=1)
|
|
18
|
+
def load_schema() -> dict:
|
|
19
|
+
"""Load and cache the bundled statechart JSON Schema."""
|
|
20
|
+
resource = files("statemachine.io").joinpath("schemas").joinpath(SCHEMA_RESOURCE)
|
|
21
|
+
return json.loads(resource.read_text(encoding="utf-8")) # type: ignore[no-any-return]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate_document(doc) -> None:
|
|
25
|
+
"""Validate a parsed native document against the statechart JSON Schema.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
InvalidDefinition: if the document does not conform to the schema, or if the
|
|
29
|
+
optional ``jsonschema`` dependency is not installed.
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
import jsonschema # type: ignore[import-untyped]
|
|
33
|
+
except ModuleNotFoundError as exc: # pragma: no cover - exercised via import mock
|
|
34
|
+
raise InvalidDefinition(
|
|
35
|
+
"validate=True requires the 'jsonschema' package. Install it with: "
|
|
36
|
+
'pip install "python-statemachine[validation]"'
|
|
37
|
+
) from exc
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
jsonschema.validate(instance=doc, schema=load_schema())
|
|
41
|
+
except jsonschema.ValidationError as exc:
|
|
42
|
+
raise InvalidDefinition(
|
|
43
|
+
f"Statechart document failed schema validation: {exc.message}"
|
|
44
|
+
) from exc
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""YAML statechart format adapter (requires the optional ``[yaml]`` extra)."""
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""YAML format adapter: parse a YAML statechart document into the neutral IR.
|
|
2
|
+
|
|
3
|
+
Requires the optional ``[yaml]`` extra (PyYAML). YAML is always parsed with
|
|
4
|
+
``yaml.safe_load`` semantics so a document can never instantiate arbitrary Python
|
|
5
|
+
objects.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ..model import StateMachineDefinition
|
|
9
|
+
from ..native import native_dict_to_definition
|
|
10
|
+
from ..ports import FormatSpec
|
|
11
|
+
from ..ports import register_format
|
|
12
|
+
|
|
13
|
+
_LOADER = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _make_loader(yaml):
|
|
17
|
+
"""Build a SafeLoader that does NOT coerce ``on``/``off``/``yes``/``no`` to bool.
|
|
18
|
+
|
|
19
|
+
YAML 1.1 turns those tokens into booleans, which silently mangles state ids
|
|
20
|
+
like ``off``/``on`` into ``True``/``False``. We keep ``yaml.safe_load``'s safety
|
|
21
|
+
but restrict the implicit bool resolver to ``true``/``false`` only.
|
|
22
|
+
"""
|
|
23
|
+
global _LOADER
|
|
24
|
+
if _LOADER is not None:
|
|
25
|
+
return _LOADER
|
|
26
|
+
|
|
27
|
+
class _StatechartSafeLoader(yaml.SafeLoader):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
_StatechartSafeLoader.yaml_implicit_resolvers = {
|
|
31
|
+
first_char: [
|
|
32
|
+
(tag, regexp)
|
|
33
|
+
for (tag, regexp) in resolvers
|
|
34
|
+
if not (tag == "tag:yaml.org,2002:bool" and first_char not in "tTfF")
|
|
35
|
+
]
|
|
36
|
+
for first_char, resolvers in yaml.SafeLoader.yaml_implicit_resolvers.items()
|
|
37
|
+
}
|
|
38
|
+
_LOADER = _StatechartSafeLoader
|
|
39
|
+
return _LOADER
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class YAMLReader:
|
|
43
|
+
"""Format adapter that parses YAML documents (``yaml.safe_load``) into the IR."""
|
|
44
|
+
|
|
45
|
+
def parse_document(self, text: str) -> dict:
|
|
46
|
+
try:
|
|
47
|
+
import yaml # type: ignore[import-untyped]
|
|
48
|
+
except ModuleNotFoundError as exc: # pragma: no cover - exercised via import mock
|
|
49
|
+
raise ImportError(
|
|
50
|
+
"YAML support requires PyYAML. Install it with: "
|
|
51
|
+
'pip install "python-statemachine[yaml]"'
|
|
52
|
+
) from exc
|
|
53
|
+
return yaml.load(text, Loader=_make_loader(yaml)) # type: ignore[no-any-return]
|
|
54
|
+
|
|
55
|
+
def read(self, text: str, *, source_name: "str | None" = None) -> StateMachineDefinition:
|
|
56
|
+
return native_dict_to_definition(self.parse_document(text), source_name=source_name)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
register_format(
|
|
60
|
+
FormatSpec(
|
|
61
|
+
name="yaml",
|
|
62
|
+
extensions=(".yaml", ".yml"),
|
|
63
|
+
reader_factory=YAMLReader,
|
|
64
|
+
)
|
|
65
|
+
)
|
|
@@ -5,7 +5,7 @@ msgid ""
|
|
|
5
5
|
msgstr ""
|
|
6
6
|
"Project-Id-Version: 3.1.1\n"
|
|
7
7
|
"Report-Msgid-Bugs-To: fgmacedo@gmail.com\n"
|
|
8
|
-
"POT-Creation-Date: 2026-
|
|
8
|
+
"POT-Creation-Date: 2026-06-16 23:00-0300\n"
|
|
9
9
|
"PO-Revision-Date: 2026-02-24 14:31-0300\n"
|
|
10
10
|
"Last-Translator: Fernando Macedo <fgmacedo@gmail.com>\n"
|
|
11
11
|
"Language: en\n"
|
|
@@ -16,12 +16,12 @@ msgstr ""
|
|
|
16
16
|
"Content-Transfer-Encoding: 8bit\n"
|
|
17
17
|
"Generated-By: Babel 2.18.0\n"
|
|
18
18
|
|
|
19
|
-
#: statemachine/callbacks.py:
|
|
19
|
+
#: statemachine/callbacks.py:398 statemachine/callbacks.py:403
|
|
20
20
|
#, python-brace-format
|
|
21
21
|
msgid "Did not found name '{}' from model or statemachine"
|
|
22
22
|
msgstr "Did not found name '{}' from model or statemachine"
|
|
23
23
|
|
|
24
|
-
#: statemachine/configuration.py:
|
|
24
|
+
#: statemachine/configuration.py:128
|
|
25
25
|
msgid ""
|
|
26
26
|
"There's no current state set. In async code, did you activate the initial"
|
|
27
27
|
" state? (e.g., `await sm.activate_initial_state()`)"
|
|
@@ -29,12 +29,12 @@ msgstr ""
|
|
|
29
29
|
"There's no current state set. In async code, did you activate the initial"
|
|
30
30
|
" state? (e.g., `await sm.activate_initial_state()`)"
|
|
31
31
|
|
|
32
|
-
#: statemachine/dispatcher.py:
|
|
32
|
+
#: statemachine/dispatcher.py:123
|
|
33
33
|
#, python-brace-format
|
|
34
34
|
msgid "Failed to parse boolean expression '{}'"
|
|
35
35
|
msgstr "Failed to parse boolean expression '{}'"
|
|
36
36
|
|
|
37
|
-
#: statemachine/event.py:
|
|
37
|
+
#: statemachine/event.py:94
|
|
38
38
|
#, python-brace-format
|
|
39
39
|
msgid ""
|
|
40
40
|
"Event() received a non-string 'id' ({cls_name}). To combine multiple "
|
|
@@ -43,12 +43,12 @@ msgstr ""
|
|
|
43
43
|
"Event() received a non-string 'id' ({cls_name}). To combine multiple "
|
|
44
44
|
"transitions under one event, use the | operator: t1 | t2."
|
|
45
45
|
|
|
46
|
-
#: statemachine/event.py:
|
|
46
|
+
#: statemachine/event.py:130
|
|
47
47
|
#, python-brace-format
|
|
48
48
|
msgid "Cannot add callback '{}' to an event with no transitions."
|
|
49
49
|
msgstr "Cannot add callback '{}' to an event with no transitions."
|
|
50
50
|
|
|
51
|
-
#: statemachine/event.py:
|
|
51
|
+
#: statemachine/event.py:163
|
|
52
52
|
#, python-brace-format
|
|
53
53
|
msgid "Event {} cannot be called without a SM instance"
|
|
54
54
|
msgstr "Event {} cannot be called without a SM instance"
|
|
@@ -63,7 +63,7 @@ msgstr "{!r} is not a valid state value."
|
|
|
63
63
|
msgid "Can't {} when in {}."
|
|
64
64
|
msgstr "Can't {} when in {}."
|
|
65
65
|
|
|
66
|
-
#: statemachine/factory.py:
|
|
66
|
+
#: statemachine/factory.py:77
|
|
67
67
|
#, python-brace-format
|
|
68
68
|
msgid ""
|
|
69
69
|
"There should be one and only one initial state. Your currently have "
|
|
@@ -72,7 +72,7 @@ msgstr ""
|
|
|
72
72
|
"There should be one and only one initial state. Your currently have "
|
|
73
73
|
"these: {0}"
|
|
74
74
|
|
|
75
|
-
#: statemachine/factory.py:
|
|
75
|
+
#: statemachine/factory.py:194
|
|
76
76
|
#, python-brace-format
|
|
77
77
|
msgid ""
|
|
78
78
|
"There should be one and only one initial state. You currently have these:"
|
|
@@ -81,12 +81,12 @@ msgstr ""
|
|
|
81
81
|
"There should be one and only one initial state. You currently have these:"
|
|
82
82
|
" {!r}"
|
|
83
83
|
|
|
84
|
-
#: statemachine/factory.py:
|
|
84
|
+
#: statemachine/factory.py:209
|
|
85
85
|
#, python-brace-format
|
|
86
86
|
msgid "Cannot declare transitions from final state. Invalid state(s): {}"
|
|
87
87
|
msgstr "Cannot declare transitions from final state. Invalid state(s): {}"
|
|
88
88
|
|
|
89
|
-
#: statemachine/factory.py:
|
|
89
|
+
#: statemachine/factory.py:221
|
|
90
90
|
#, python-brace-format
|
|
91
91
|
msgid ""
|
|
92
92
|
"All non-final states should have at least one outgoing transition. These "
|
|
@@ -95,7 +95,7 @@ msgstr ""
|
|
|
95
95
|
"All non-final states should have at least one outgoing transition. These "
|
|
96
96
|
"states have no outgoing transition: {!r}"
|
|
97
97
|
|
|
98
|
-
#: statemachine/factory.py:
|
|
98
|
+
#: statemachine/factory.py:235
|
|
99
99
|
#, python-brace-format
|
|
100
100
|
msgid ""
|
|
101
101
|
"All non-final states should have at least one path to a final state. "
|
|
@@ -104,7 +104,7 @@ msgstr ""
|
|
|
104
104
|
"All non-final states should have at least one path to a final state. "
|
|
105
105
|
"These states have no path to a final state: {!r}"
|
|
106
106
|
|
|
107
|
-
#: statemachine/factory.py:
|
|
107
|
+
#: statemachine/factory.py:248
|
|
108
108
|
#, python-brace-format
|
|
109
109
|
msgid ""
|
|
110
110
|
"There are unreachable states. The statemachine graph should have a single"
|
|
@@ -113,7 +113,7 @@ msgstr ""
|
|
|
113
113
|
"There are unreachable states. The statemachine graph should have a single"
|
|
114
114
|
" component. Disconnected states: {}"
|
|
115
115
|
|
|
116
|
-
#: statemachine/factory.py:
|
|
116
|
+
#: statemachine/factory.py:285
|
|
117
117
|
#, python-brace-format
|
|
118
118
|
msgid ""
|
|
119
119
|
"Invalid entry in 'listeners': {!r}. Expected a class, callable, or "
|
|
@@ -122,7 +122,7 @@ msgstr ""
|
|
|
122
122
|
"Invalid entry in 'listeners': {!r}. Expected a class, callable, or "
|
|
123
123
|
"listener instance."
|
|
124
124
|
|
|
125
|
-
#: statemachine/factory.py:
|
|
125
|
+
#: statemachine/factory.py:384
|
|
126
126
|
#, python-brace-format
|
|
127
127
|
msgid "An event in the '{}' has no id."
|
|
128
128
|
msgstr "An event in the '{}' has no id."
|
|
@@ -132,20 +132,20 @@ msgstr "An event in the '{}' has no id."
|
|
|
132
132
|
msgid "{!r} is not a valid state machine name."
|
|
133
133
|
msgstr "{!r} is not a valid state machine name."
|
|
134
134
|
|
|
135
|
-
#: statemachine/state.py:
|
|
135
|
+
#: statemachine/state.py:249
|
|
136
136
|
msgid "'donedata' can only be specified on final states."
|
|
137
137
|
msgstr "'donedata' can only be specified on final states."
|
|
138
138
|
|
|
139
|
-
#: statemachine/statemachine.py:
|
|
139
|
+
#: statemachine/statemachine.py:167
|
|
140
140
|
msgid "There are no states or transitions."
|
|
141
141
|
msgstr "There are no states or transitions."
|
|
142
142
|
|
|
143
|
-
#: statemachine/statemachine.py:
|
|
143
|
+
#: statemachine/statemachine.py:237
|
|
144
144
|
#, python-brace-format
|
|
145
145
|
msgid "State overriding is not allowed. Trying to add '{}' to {}"
|
|
146
146
|
msgstr "State overriding is not allowed. Trying to add '{}' to {}"
|
|
147
147
|
|
|
148
|
-
#: statemachine/transition.py:
|
|
148
|
+
#: statemachine/transition.py:74
|
|
149
149
|
#, python-brace-format
|
|
150
150
|
msgid ""
|
|
151
151
|
"Not a valid internal transition from source {source!r}, target {target!r}"
|