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,258 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://fgmacedo.github.io/python-statemachine/schemas/statechart/v1.json",
4
+ "title": "python-statemachine statechart",
5
+ "description": "Declarative statechart definition for python-statemachine (JSON/YAML native format).",
6
+ "type": "object",
7
+ "required": ["states"],
8
+ "properties": {
9
+ "name": { "type": "string" },
10
+ "description": { "type": "string" },
11
+ "datamodel": {
12
+ "oneOf": [
13
+ { "type": "object", "additionalProperties": true },
14
+ { "type": "array", "items": { "$ref": "#/$defs/dataItem" } }
15
+ ]
16
+ },
17
+ "states": {
18
+ "type": "object",
19
+ "minProperties": 1,
20
+ "additionalProperties": { "$ref": "#/$defs/state" }
21
+ }
22
+ },
23
+ "additionalProperties": false,
24
+ "$defs": {
25
+ "flag": {
26
+ "oneOf": [
27
+ { "type": "boolean" },
28
+ { "type": "string", "enum": ["true", "false", "yes", "no", "on", "off"] }
29
+ ]
30
+ },
31
+ "guard": {
32
+ "oneOf": [
33
+ { "type": "string" },
34
+ { "type": "array", "items": { "type": "string" } }
35
+ ]
36
+ },
37
+ "dataItem": {
38
+ "type": "object",
39
+ "required": ["id"],
40
+ "properties": {
41
+ "id": { "type": "string" },
42
+ "expr": { "type": "string" },
43
+ "content": {},
44
+ "src": { "type": "string" }
45
+ },
46
+ "additionalProperties": false
47
+ },
48
+ "param": {
49
+ "type": "object",
50
+ "required": ["name"],
51
+ "properties": {
52
+ "name": { "type": "string" },
53
+ "expr": { "type": "string" },
54
+ "location": { "type": "string" }
55
+ },
56
+ "additionalProperties": false
57
+ },
58
+ "structuredAction": {
59
+ "type": "object",
60
+ "minProperties": 1,
61
+ "maxProperties": 1,
62
+ "properties": {
63
+ "assign": {
64
+ "type": "object",
65
+ "required": ["location"],
66
+ "properties": {
67
+ "location": { "type": "string" },
68
+ "expr": { "type": "string" }
69
+ },
70
+ "additionalProperties": false
71
+ },
72
+ "raise": {
73
+ "oneOf": [
74
+ { "type": "string" },
75
+ {
76
+ "type": "object",
77
+ "required": ["event"],
78
+ "properties": { "event": { "type": "string" } },
79
+ "additionalProperties": false
80
+ }
81
+ ]
82
+ },
83
+ "log": {
84
+ "oneOf": [
85
+ { "type": "string" },
86
+ {
87
+ "type": "object",
88
+ "properties": {
89
+ "label": { "type": "string" },
90
+ "expr": { "type": "string" }
91
+ },
92
+ "additionalProperties": false
93
+ }
94
+ ]
95
+ },
96
+ "script": { "type": "string" },
97
+ "cancel": {
98
+ "type": "object",
99
+ "properties": {
100
+ "sendid": { "type": "string" },
101
+ "sendidexpr": { "type": "string" }
102
+ },
103
+ "additionalProperties": false
104
+ },
105
+ "send": {
106
+ "type": "object",
107
+ "properties": {
108
+ "event": { "type": "string" },
109
+ "eventexpr": { "type": "string" },
110
+ "target": { "type": "string" },
111
+ "type": { "type": "string" },
112
+ "id": { "type": "string" },
113
+ "idlocation": { "type": "string" },
114
+ "delay": { "type": "string" },
115
+ "delayexpr": { "type": "string" },
116
+ "namelist": { "type": "string" },
117
+ "params": { "type": "array", "items": { "$ref": "#/$defs/param" } },
118
+ "content": { "type": "string" }
119
+ },
120
+ "additionalProperties": false
121
+ },
122
+ "foreach": {
123
+ "type": "object",
124
+ "required": ["array", "item"],
125
+ "properties": {
126
+ "array": { "type": "string" },
127
+ "item": { "type": "string" },
128
+ "index": { "type": "string" },
129
+ "do": { "$ref": "#/$defs/structuredActions" }
130
+ },
131
+ "additionalProperties": false
132
+ },
133
+ "if": {
134
+ "type": "object",
135
+ "required": ["cond"],
136
+ "properties": {
137
+ "cond": { "type": "string" },
138
+ "then": { "$ref": "#/$defs/structuredActions" },
139
+ "elif": {
140
+ "type": "array",
141
+ "items": {
142
+ "type": "object",
143
+ "required": ["cond"],
144
+ "properties": {
145
+ "cond": { "type": "string" },
146
+ "then": { "$ref": "#/$defs/structuredActions" }
147
+ },
148
+ "additionalProperties": false
149
+ }
150
+ },
151
+ "else": { "$ref": "#/$defs/structuredActions" }
152
+ },
153
+ "additionalProperties": false
154
+ }
155
+ },
156
+ "additionalProperties": false
157
+ },
158
+ "action": {
159
+ "oneOf": [
160
+ { "type": "string" },
161
+ { "$ref": "#/$defs/structuredAction" }
162
+ ]
163
+ },
164
+ "actions": {
165
+ "oneOf": [
166
+ { "$ref": "#/$defs/action" },
167
+ { "type": "array", "items": { "$ref": "#/$defs/action" } }
168
+ ]
169
+ },
170
+ "structuredActions": {
171
+ "oneOf": [
172
+ { "$ref": "#/$defs/structuredAction" },
173
+ { "type": "array", "items": { "$ref": "#/$defs/structuredAction" } }
174
+ ]
175
+ },
176
+ "invoke": {
177
+ "type": "object",
178
+ "properties": {
179
+ "type": { "type": "string" },
180
+ "typeexpr": { "type": "string" },
181
+ "src": { "type": "string" },
182
+ "srcexpr": { "type": "string" },
183
+ "id": { "type": "string" },
184
+ "idlocation": { "type": "string" },
185
+ "autoforward": { "$ref": "#/$defs/flag" },
186
+ "namelist": { "type": "string" },
187
+ "params": { "type": "array", "items": { "$ref": "#/$defs/param" } },
188
+ "content": {
189
+ "oneOf": [
190
+ { "type": "string" },
191
+ { "$ref": "#" }
192
+ ]
193
+ },
194
+ "finalize": { "$ref": "#/$defs/structuredActions" }
195
+ },
196
+ "additionalProperties": false
197
+ },
198
+ "invokeList": {
199
+ "oneOf": [
200
+ { "$ref": "#/$defs/invoke" },
201
+ { "type": "array", "items": { "$ref": "#/$defs/invoke" } }
202
+ ]
203
+ },
204
+ "transition": {
205
+ "type": "object",
206
+ "properties": {
207
+ "event": { "type": "string" },
208
+ "target": { "type": "string" },
209
+ "cond": { "$ref": "#/$defs/guard" },
210
+ "unless": { "$ref": "#/$defs/guard" },
211
+ "internal": { "$ref": "#/$defs/flag" },
212
+ "initial": { "$ref": "#/$defs/flag" },
213
+ "on": { "$ref": "#/$defs/actions" },
214
+ "before": { "$ref": "#/$defs/actions" },
215
+ "after": { "$ref": "#/$defs/actions" }
216
+ },
217
+ "additionalProperties": false
218
+ },
219
+ "history": {
220
+ "type": "object",
221
+ "properties": {
222
+ "type": { "type": "string", "enum": ["shallow", "deep"] },
223
+ "transitions": { "type": "array", "items": { "$ref": "#/$defs/transition" } }
224
+ },
225
+ "additionalProperties": false
226
+ },
227
+ "donedata": {
228
+ "type": "object",
229
+ "properties": {
230
+ "params": { "type": "array", "items": { "$ref": "#/$defs/param" } },
231
+ "content": { "type": "string" }
232
+ },
233
+ "additionalProperties": false
234
+ },
235
+ "state": {
236
+ "type": "object",
237
+ "properties": {
238
+ "initial": { "$ref": "#/$defs/flag" },
239
+ "final": { "$ref": "#/$defs/flag" },
240
+ "parallel": { "$ref": "#/$defs/flag" },
241
+ "enter": { "$ref": "#/$defs/actions" },
242
+ "exit": { "$ref": "#/$defs/actions" },
243
+ "transitions": { "type": "array", "items": { "$ref": "#/$defs/transition" } },
244
+ "invoke": { "$ref": "#/$defs/invokeList" },
245
+ "states": {
246
+ "type": "object",
247
+ "additionalProperties": { "$ref": "#/$defs/state" }
248
+ },
249
+ "history": {
250
+ "type": "object",
251
+ "additionalProperties": { "$ref": "#/$defs/history" }
252
+ },
253
+ "donedata": { "$ref": "#/$defs/donedata" }
254
+ },
255
+ "additionalProperties": false
256
+ }
257
+ }
258
+ }
@@ -0,0 +1,12 @@
1
+ """SCXML ingestion support.
2
+
3
+ SCXML is, per the W3C specification, *executable content*: ``cond``/``expr`` attributes and
4
+ ``<script>`` elements are evaluated in the document's datamodel language. This implementation
5
+ provides a Python datamodel and loads SCXML through the same
6
+ :func:`~statemachine.io.load` facade as the native JSON/YAML formats.
7
+
8
+ Like every format, SCXML is loaded **secure by default** (``trusted=False`` rejects
9
+ ``<script>`` and evaluates expressions with the restricted AST-whitelist evaluator); pass
10
+ ``trusted=True`` only for documents you control. See the security note in
11
+ :mod:`statemachine.io` and the GHSA-v4jc-pm6r-3vj8 advisory.
12
+ """
@@ -1,263 +1,33 @@
1
- import os
2
- from contextlib import contextmanager
3
- from dataclasses import dataclass
4
- from pathlib import Path
5
- from typing import Any
6
- from typing import Dict
7
- from typing import List
1
+ """Back-compatible SCXML entry point: a preconfigured neutral Interpreter.
8
2
 
9
- from ...event import Event
10
- from ...exceptions import InvalidDefinition
11
- from ...statemachine import StateChart
12
- from .. import HistoryDefinition
13
- from .. import StateDefinition
14
- from .. import TransitionDict
15
- from .. import TransitionsList
16
- from .. import create_machine_class_from_definition
17
- from .actions import Cond
18
- from .actions import DoneDataCallable
19
- from .actions import EventDataWrapper
20
- from .actions import ExecuteBlock
21
- from .actions import create_datamodel_action_callable
22
- from .actions import create_invoke_init_callable
23
- from .invoke import SCXMLInvoker
24
- from .parser import parse_scxml
25
- from .schema import HistoryState
26
- from .schema import InvokeDefinition
27
- from .schema import State
28
- from .schema import Transition
3
+ The runtime is the format-neutral :class:`~statemachine.io.interpreter.Interpreter`.
4
+ ``SCXMLProcessor`` is simply that interpreter wired with the SCXML reader and the
5
+ trusted/restricted evaluator, plus the ``parse_scxml`` convenience for parsing a document
6
+ from a string. New code should prefer :func:`statemachine.io.load` (which also reads files
7
+ and detects the format from the extension).
8
+ """
29
9
 
10
+ from ..evaluators import evaluator_for
11
+ from ..interpreter import Interpreter
12
+ from .reader import SCXMLReader
13
+ from .reader import parse_scxml
30
14
 
31
- @contextmanager
32
- def temporary_directory(new_current_dir):
33
- original_dir = os.getcwd()
34
- try:
35
- os.chdir(new_current_dir)
36
- yield
37
- finally:
38
- os.chdir(original_dir)
39
15
 
16
+ class SCXMLProcessor(Interpreter):
17
+ """Parses SCXML documents into :class:`~statemachine.statemachine.StateChart` classes.
40
18
 
41
- class IOProcessor:
42
- def __init__(self, processor: "SCXMLProcessor", machine: StateChart):
43
- self.scxml_processor = processor
44
- self.machine = machine
19
+ Args:
20
+ trusted: when ``False`` (default), datamodel expressions are evaluated by
21
+ a restricted AST-whitelist evaluator and ``<script>`` is rejected, so
22
+ loading a document cannot execute arbitrary code. When ``True``,
23
+ expressions and ``<script>`` are evaluated as arbitrary Python via
24
+ ``eval``/``exec`` — only use it for SCXML you trust (see the package
25
+ docstring and GHSA-v4jc-pm6r-3vj8).
26
+ """
45
27
 
46
- def __getitem__(self, name: str):
47
- return self
48
-
49
- @property
50
- def location(self):
51
- return self.machine.name
52
-
53
- def get(self, name: str):
54
- return getattr(self, name)
55
-
56
-
57
- @dataclass
58
- class SessionData:
59
- machine: StateChart
60
- processor: IOProcessor
61
- first_event_raised: bool = False
62
-
63
- def __post_init__(self):
64
- self.session_id = f"{self.machine.name}:{id(self.machine)}"
65
-
66
-
67
- class SCXMLProcessor:
68
- def __init__(self):
69
- self.scs: "Dict[str, type[StateChart]]" = {}
70
- self.sessions: Dict[str, SessionData] = {}
71
- self._ioprocessors = {
72
- "http://www.w3.org/TR/scxml/#SCXMLEventProcessor": self,
73
- "scxml": self,
74
- }
75
-
76
- def parse_scxml_file(self, path: Path):
77
- scxml_content = path.read_text()
78
- with temporary_directory(path.parent):
79
- return self.parse_scxml(path.stem, scxml_content)
28
+ def __init__(self, trusted: bool = False):
29
+ super().__init__(reader=SCXMLReader(), evaluator=evaluator_for(trusted))
80
30
 
81
31
  def parse_scxml(self, sm_name: str, scxml_content: str):
82
32
  definition = parse_scxml(scxml_content)
83
33
  self.process_definition(definition, location=definition.name or sm_name)
84
-
85
- def process_definition(self, definition, location: str, is_invoked: bool = False):
86
- states_dict = self._process_states(definition.states)
87
-
88
- # Find the initial state for inserting init callbacks
89
- try:
90
- initial_state = next(s for s in iter(states_dict.values()) if s.get("initial"))
91
- except StopIteration:
92
- initial_state = next(iter(states_dict.values()))
93
-
94
- if "enter" not in initial_state:
95
- initial_state["enter"] = []
96
-
97
- insert_pos = 0
98
-
99
- # For invoked children, insert invoke_init to pop _invoke_session/_invoke_params
100
- # from kwargs and store them on the machine instance before any other callbacks.
101
- if is_invoked:
102
- initial_state["enter"].insert(0, create_invoke_init_callable()) # type: ignore[union-attr]
103
- insert_pos = 1
104
-
105
- # Process datamodel (initial variables)
106
- if definition.datamodel:
107
- datamodel = create_datamodel_action_callable(definition.datamodel)
108
- if datamodel: # pragma: no branch – parse_datamodel guarantees non-empty
109
- if isinstance( # pragma: no branch – always a list from lines above
110
- initial_state["enter"], list
111
- ):
112
- initial_state["enter"].insert(insert_pos, datamodel) # type: ignore[arg-type]
113
-
114
- self._add(
115
- location,
116
- {
117
- "states": states_dict,
118
- "prepare_event": self._prepare_event,
119
- "validate_disconnected_states": False,
120
- "validate_trap_states": False,
121
- "validate_final_reachability": False,
122
- "start_configuration_values": list(definition.initial_states),
123
- },
124
- )
125
-
126
- def _prepare_event(self, *args, event: Event, **kwargs):
127
- machine = kwargs["machine"]
128
- session_data = self._get_session(machine)
129
-
130
- if not session_data.first_event_raised and event and event != "__initial__":
131
- session_data.first_event_raised = True
132
-
133
- _event: "EventDataWrapper | None" = None
134
- if session_data.first_event_raised:
135
- _event = EventDataWrapper(kwargs["event_data"])
136
-
137
- return {
138
- "_name": machine.name,
139
- "_sessionid": session_data.session_id,
140
- "_ioprocessors": session_data.processor,
141
- "_event": _event,
142
- }
143
-
144
- def _get_session(self, machine: StateChart):
145
- if machine.name not in self.sessions:
146
- self.sessions[machine.name] = SessionData(
147
- processor=IOProcessor(self, machine=machine), machine=machine
148
- )
149
- return self.sessions[machine.name]
150
-
151
- def _process_history(self, history: Dict[str, HistoryState]) -> Dict[str, HistoryDefinition]:
152
- states_dict: Dict[str, HistoryDefinition] = {}
153
- for state_id, state in history.items():
154
- state_dict = HistoryDefinition()
155
-
156
- state_dict["type"] = state.type
157
-
158
- # Process transitions
159
- if state.transitions:
160
- state_dict["transitions"] = self._process_transitions(state.transitions)
161
-
162
- states_dict[state_id] = state_dict
163
-
164
- return states_dict
165
-
166
- def _process_states(self, states: Dict[str, State]) -> Dict[str, StateDefinition]:
167
- states_dict: Dict[str, StateDefinition] = {}
168
- for state_id, state in states.items():
169
- states_dict[state_id] = self._process_state(state)
170
- return states_dict
171
-
172
- def _process_state(self, state: State) -> StateDefinition: # noqa: C901
173
- state_dict = StateDefinition()
174
- if state.initial:
175
- state_dict["initial"] = True
176
- if state.final:
177
- state_dict["final"] = True
178
- if state.parallel:
179
- state_dict["parallel"] = True
180
-
181
- # Process enter actions
182
- enter_callables: list = [
183
- ExecuteBlock(content) for content in state.onentry if not content.is_empty
184
- ]
185
- if enter_callables:
186
- state_dict["enter"] = enter_callables
187
- if state.final and state.donedata:
188
- state_dict["donedata"] = DoneDataCallable(state.donedata)
189
-
190
- # Process exit actions
191
- if state.onexit:
192
- callables = [ExecuteBlock(content) for content in state.onexit if not content.is_empty]
193
- state_dict["exit"] = callables
194
-
195
- # Process transitions
196
- if state.transitions:
197
- state_dict["transitions"] = self._process_transitions(state.transitions)
198
-
199
- # Process invoke elements
200
- if state.invocations:
201
- invokers = [self._process_invocation(inv) for inv in state.invocations]
202
- state_dict["invoke"] = invokers # type: ignore[typeddict-unknown-key]
203
-
204
- if state.states:
205
- state_dict["states"] = self._process_states(state.states)
206
-
207
- if state.history:
208
- state_dict["history"] = self._process_history(state.history)
209
-
210
- return state_dict
211
-
212
- def _process_invocation(self, invoke_def: InvokeDefinition) -> SCXMLInvoker:
213
- """Convert an InvokeDefinition into an SCXMLInvoker."""
214
- return SCXMLInvoker(
215
- definition=invoke_def,
216
- base_dir=os.getcwd(),
217
- register_child=self._register_child,
218
- )
219
-
220
- def _register_child(self, scxml_content: str, child_name: str) -> type:
221
- """Parse SCXML content, register it as a child machine, and return its class."""
222
- definition = parse_scxml(scxml_content)
223
- self.process_definition(definition, location=child_name, is_invoked=True)
224
- return self.scs[child_name]
225
-
226
- def _process_transitions(self, transitions: List[Transition]):
227
- result: TransitionsList = []
228
- for transition in transitions:
229
- event = transition.event or None
230
- transition_dict: TransitionDict = {
231
- "event": event,
232
- "target": transition.target,
233
- "internal": transition.internal,
234
- "initial": transition.initial,
235
- }
236
-
237
- # Process cond
238
- if transition.cond:
239
- cond_callable = Cond.create(transition.cond, processor=self)
240
- if cond_callable is not None: # pragma: no branch – cond already truthy
241
- transition_dict["cond"] = cond_callable
242
-
243
- # Process actions
244
- if transition.on and not transition.on.is_empty:
245
- transition_dict["on"] = ExecuteBlock(transition.on)
246
-
247
- result.append(transition_dict)
248
- return result
249
-
250
- def _add(self, location: str, definition: Dict[str, Any]):
251
- try:
252
- sc_class = create_machine_class_from_definition(location, **definition)
253
- self.scs[location] = sc_class
254
- return sc_class
255
- except Exception as e: # pragma: no cover
256
- raise InvalidDefinition(
257
- f"Failed to create state machine class: {e} from definition: {definition}"
258
- ) from e
259
-
260
- def start(self, **kwargs):
261
- self.root_cls = next(iter(self.scs.values()))
262
- self.root = self.root_cls(**kwargs)
263
- return self.root