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,195 @@
1
+ """Compile the neutral IR into StateChart class keyword arguments.
2
+
3
+ :class:`DefinitionBuilder` translates a :class:`~statemachine.io.model.StateMachineDefinition`
4
+ into the ``kwargs`` accepted by :func:`~statemachine.io.create_machine_class_from_definition`,
5
+ compiling guards and executable content through an
6
+ :class:`~statemachine.io.evaluators.Evaluator` (secure by default).
7
+
8
+ It is format-neutral and runtime-agnostic: anything that is a runtime concern
9
+ (system-variable injection, the invoke bootstrap, building invokers) is requested from a
10
+ :class:`RuntimeHooks` collaborator, so the builder never imports the concrete runtime and
11
+ there is no import cycle.
12
+ """
13
+
14
+ from typing import Any
15
+ from typing import Protocol
16
+
17
+ from .actions import Cond
18
+ from .actions import DoneDataCallable
19
+ from .actions import ExecuteBlock
20
+ from .actions import create_datamodel_action_callable
21
+ from .class_factory import HistoryDefinition
22
+ from .class_factory import StateDefinition
23
+ from .class_factory import TransitionDict
24
+ from .class_factory import TransitionsList
25
+ from .evaluators import Evaluator
26
+ from .model import HistoryState
27
+ from .model import InvokeDefinition
28
+ from .model import State
29
+ from .model import StateMachineDefinition
30
+ from .model import Transition
31
+
32
+
33
+ class RuntimeHooks(Protocol):
34
+ """Runtime concerns the builder delegates back to the interpreter."""
35
+
36
+ def initial_enter_prefix(
37
+ self, is_invoked: bool
38
+ ) -> list: # pragma: no cover - structural Protocol
39
+ """Callbacks to insert at the front of the initial state's ``enter`` list."""
40
+ ...
41
+
42
+ def definition_kwargs(self, definition) -> dict: # pragma: no cover - structural Protocol
43
+ """Extra kwargs for ``create_machine_class_from_definition`` (e.g. ``prepare_event``)."""
44
+ ...
45
+
46
+ def make_invoker(
47
+ self, invoke_def: InvokeDefinition
48
+ ) -> Any: # pragma: no cover - structural Protocol
49
+ """Build the invoke handler for an ``<invoke>`` definition."""
50
+ ...
51
+
52
+
53
+ class DefinitionBuilder:
54
+ """Translates the neutral IR into ``create_machine_class_from_definition`` kwargs."""
55
+
56
+ def __init__(self, *, evaluator: Evaluator, hooks: RuntimeHooks):
57
+ self._evaluator = evaluator
58
+ self._hooks = hooks
59
+
60
+ def build_class_kwargs(self, definition: StateMachineDefinition, *, is_invoked: bool) -> dict:
61
+ states_dict = self._process_states(definition.states)
62
+
63
+ # Find the initial state for inserting init callbacks
64
+ try:
65
+ initial_state = next(s for s in iter(states_dict.values()) if s.get("initial"))
66
+ except StopIteration:
67
+ initial_state = next(iter(states_dict.values()))
68
+
69
+ if "enter" not in initial_state:
70
+ initial_state["enter"] = []
71
+
72
+ insert_pos = 0
73
+ # Runtime callbacks that must run before the datamodel (e.g. invoke init).
74
+ for callback in self._hooks.initial_enter_prefix(is_invoked):
75
+ initial_state["enter"].insert(insert_pos, callback) # type: ignore[union-attr]
76
+ insert_pos += 1
77
+
78
+ # Process datamodel (initial variables)
79
+ if definition.datamodel:
80
+ datamodel = create_datamodel_action_callable(definition.datamodel, self._evaluator)
81
+ if datamodel: # pragma: no branch – parse guarantees non-empty
82
+ if isinstance( # pragma: no branch – always a list from lines above
83
+ initial_state["enter"], list
84
+ ):
85
+ initial_state["enter"].insert(insert_pos, datamodel) # type: ignore[arg-type]
86
+
87
+ return {"states": states_dict, **self._hooks.definition_kwargs(definition)}
88
+
89
+ def _process_history(self, history: dict[str, HistoryState]) -> dict[str, HistoryDefinition]:
90
+ states_dict: dict[str, HistoryDefinition] = {}
91
+ for state_id, state in history.items():
92
+ state_dict = HistoryDefinition()
93
+ state_dict["type"] = state.type
94
+ if state.transitions:
95
+ state_dict["transitions"] = self._process_transitions(state.transitions)
96
+ states_dict[state_id] = state_dict
97
+ return states_dict
98
+
99
+ def _process_states(self, states: dict[str, State]) -> dict[str, StateDefinition]:
100
+ return {state_id: self._process_state(state) for state_id, state in states.items()}
101
+
102
+ def _process_state(self, state: State) -> StateDefinition: # noqa: C901
103
+ state_dict = StateDefinition()
104
+ if state.initial:
105
+ state_dict["initial"] = True
106
+ if state.final:
107
+ state_dict["final"] = True
108
+ if state.parallel:
109
+ state_dict["parallel"] = True
110
+
111
+ # Process enter actions (executable content first, then callback refs)
112
+ enter_callables: list = [
113
+ ExecuteBlock(content, self._evaluator)
114
+ for content in state.onentry
115
+ if not content.is_empty
116
+ ]
117
+ enter_callables.extend(state.enter_refs)
118
+ if enter_callables:
119
+ state_dict["enter"] = enter_callables
120
+ if state.final and state.donedata:
121
+ state_dict["donedata"] = DoneDataCallable(state.donedata, self._evaluator)
122
+
123
+ # Process exit actions (executable content first, then callback refs)
124
+ exit_callables: list = [
125
+ ExecuteBlock(content, self._evaluator)
126
+ for content in state.onexit
127
+ if not content.is_empty
128
+ ]
129
+ exit_callables.extend(state.exit_refs)
130
+ if exit_callables:
131
+ state_dict["exit"] = exit_callables
132
+
133
+ # Process transitions
134
+ if state.transitions:
135
+ state_dict["transitions"] = self._process_transitions(state.transitions)
136
+
137
+ # Process invoke elements (delegated to the runtime)
138
+ if state.invocations:
139
+ invokers = [self._hooks.make_invoker(inv) for inv in state.invocations]
140
+ state_dict["invoke"] = invokers # type: ignore[typeddict-unknown-key]
141
+
142
+ if state.states:
143
+ state_dict["states"] = self._process_states(state.states)
144
+
145
+ if state.history:
146
+ state_dict["history"] = self._process_history(state.history)
147
+
148
+ return state_dict
149
+
150
+ def _process_transitions(self, transitions: list[Transition]):
151
+ result: TransitionsList = []
152
+ for transition in transitions:
153
+ event = transition.event or None
154
+ transition_dict: TransitionDict = {
155
+ "event": event,
156
+ "target": transition.target,
157
+ "internal": transition.internal,
158
+ "initial": transition.initial,
159
+ }
160
+
161
+ # Process guards (cond / unless) as compiled expressions
162
+ if transition.cond:
163
+ transition_dict["cond"] = self._compile_guard(transition.cond)
164
+ if transition.unless:
165
+ transition_dict["unless"] = self._compile_guard(transition.unless)
166
+
167
+ # Each callback slot is executable content (compiled first) followed by the
168
+ # callback references. `on` mirrors SCXML; `before`/`after` are native-only
169
+ # library lifecycle slots that accept the same vocabulary.
170
+ on = self._lifecycle_callables(transition.on, transition.on_refs)
171
+ if on:
172
+ transition_dict["on"] = on if len(on) > 1 else on[0]
173
+ before = self._lifecycle_callables(transition.before, transition.before_refs)
174
+ if before:
175
+ transition_dict["before"] = before if len(before) > 1 else before[0]
176
+ after = self._lifecycle_callables(transition.after, transition.after_refs)
177
+ if after:
178
+ transition_dict["after"] = after if len(after) > 1 else after[0]
179
+
180
+ result.append(transition_dict)
181
+ return result
182
+
183
+ def _lifecycle_callables(self, content, refs: list) -> list:
184
+ """Compose a transition callback slot: executable content (if any) then named refs."""
185
+ callables: list = []
186
+ if content and not content.is_empty:
187
+ callables.append(ExecuteBlock(content, self._evaluator))
188
+ callables.extend(refs)
189
+ return callables
190
+
191
+ def _compile_guard(self, expr) -> Any:
192
+ """Compile a guard expression (or list of them) into Cond callables."""
193
+ if isinstance(expr, (list, tuple)):
194
+ return [Cond.create(e, self._evaluator) for e in expr]
195
+ return Cond.create(expr, self._evaluator)
@@ -0,0 +1,236 @@
1
+ """Build a :class:`~statemachine.statemachine.StateChart` class from a dict definition.
2
+
3
+ This is the lowest layer of the IO stack: it turns a plain mapping (the ``TransitionDict`` /
4
+ ``StateDefinition`` schema declared here) into a concrete ``StateChart`` subclass via the
5
+ :class:`~statemachine.factory.StateMachineMetaclass`. The higher layers (the format readers,
6
+ the :class:`~statemachine.io.builder.DefinitionBuilder`, the
7
+ :class:`~statemachine.io.interpreter.Interpreter`) compile their neutral IR down to this
8
+ schema and call :func:`create_machine_class_from_definition`.
9
+
10
+ It depends only on the core (``factory``/``state``/``statemachine``/``transition``), never on
11
+ the loader stack, so importing it never triggers an import cycle.
12
+ """
13
+
14
+ from collections.abc import Mapping
15
+ from collections.abc import Sequence
16
+ from typing import Any
17
+ from typing import Protocol
18
+ from typing import TypedDict
19
+ from typing import cast
20
+
21
+ from ..factory import StateMachineMetaclass
22
+ from ..state import HistoryState
23
+ from ..state import State
24
+ from ..statemachine import StateChart
25
+ from ..transition import Transition
26
+ from ..transition_list import TransitionList
27
+
28
+
29
+ class ActionProtocol(Protocol): # pragma: no cover
30
+ def __call__(self, *args, **kwargs) -> Any: ...
31
+
32
+
33
+ class TransitionDict(TypedDict, total=False):
34
+ target: "str | None"
35
+ event: "str | None"
36
+ internal: bool
37
+ initial: bool
38
+ validators: "str | ActionProtocol | Sequence[str] | Sequence[ActionProtocol]"
39
+ cond: "str | ActionProtocol | Sequence[str] | Sequence[ActionProtocol]"
40
+ unless: "str | ActionProtocol | Sequence[str] | Sequence[ActionProtocol]"
41
+ on: "str | ActionProtocol | Sequence[str] | Sequence[ActionProtocol]"
42
+ before: "str | ActionProtocol | Sequence[str] | Sequence[ActionProtocol]"
43
+ after: "str | ActionProtocol | Sequence[str] | Sequence[ActionProtocol]"
44
+
45
+
46
+ TransitionsDict = dict["str | None", list[TransitionDict]]
47
+ TransitionsList = list[TransitionDict]
48
+
49
+
50
+ class BaseStateKwargs(TypedDict, total=False):
51
+ name: str
52
+ value: Any
53
+ initial: bool
54
+ final: bool
55
+ parallel: bool
56
+ enter: "str | ActionProtocol | Sequence[str] | Sequence[ActionProtocol]"
57
+ exit: "str | ActionProtocol | Sequence[str] | Sequence[ActionProtocol]"
58
+ donedata: "ActionProtocol | None"
59
+
60
+
61
+ class StateKwargs(BaseStateKwargs, total=False):
62
+ states: list[State]
63
+ history: list[HistoryState]
64
+
65
+
66
+ class HistoryKwargs(TypedDict, total=False):
67
+ name: str
68
+ value: Any
69
+ type: str
70
+
71
+
72
+ class HistoryDefinition(HistoryKwargs, total=False):
73
+ on: TransitionsDict
74
+ transitions: TransitionsList
75
+
76
+
77
+ class StateDefinition(BaseStateKwargs, total=False):
78
+ states: dict[str, "StateDefinition"]
79
+ history: dict[str, "HistoryDefinition"]
80
+ on: TransitionsDict
81
+ transitions: TransitionsList
82
+
83
+
84
+ def _parse_history(
85
+ states: Mapping[str, "HistoryKwargs |HistoryDefinition"],
86
+ ) -> tuple[dict[str, HistoryState], dict[str, dict]]:
87
+ states_instances: dict[str, HistoryState] = {}
88
+ events_definitions: dict[str, dict] = {}
89
+ for state_id, state_definition in states.items():
90
+ state_definition = cast(HistoryDefinition, state_definition)
91
+ transition_defs = state_definition.pop("on", {})
92
+ transition_list = state_definition.pop("transitions", [])
93
+ if transition_list:
94
+ transition_defs[None] = transition_list
95
+
96
+ if transition_defs:
97
+ events_definitions[state_id] = transition_defs
98
+
99
+ state_definition = cast(HistoryKwargs, state_definition)
100
+ states_instances[state_id] = HistoryState(**state_definition)
101
+
102
+ return (states_instances, events_definitions)
103
+
104
+
105
+ def _parse_states(
106
+ states: Mapping[str, "BaseStateKwargs | StateDefinition"],
107
+ ) -> tuple[dict[str, State], dict[str, dict]]:
108
+ states_instances: dict[str, State] = {}
109
+ events_definitions: dict[str, dict] = {}
110
+
111
+ for state_id, state_definition in states.items():
112
+ # Process nested states. Replaces `states` as a definition by a list of `State` instances.
113
+ state_definition = cast(StateDefinition, state_definition)
114
+
115
+ # pop the nested states, history and transitions definitions
116
+ inner_states_defs: dict[str, StateDefinition] = state_definition.pop("states", {})
117
+ inner_history_defs: dict[str, HistoryDefinition] = state_definition.pop("history", {})
118
+ transition_defs = state_definition.pop("on", {})
119
+ transition_list = state_definition.pop("transitions", [])
120
+ if transition_list:
121
+ transition_defs[None] = transition_list
122
+
123
+ if inner_states_defs:
124
+ inner_states, inner_events = _parse_states(inner_states_defs)
125
+
126
+ top_level_states = [
127
+ state._set_id(state_id)
128
+ for state_id, state in inner_states.items()
129
+ if not state.parent
130
+ ]
131
+ state_definition["states"] = top_level_states # type: ignore
132
+ states_instances.update(inner_states)
133
+ events_definitions.update(inner_events)
134
+
135
+ if inner_history_defs:
136
+ inner_history, inner_events = _parse_history(inner_history_defs)
137
+
138
+ top_level_history = [
139
+ state._set_id(state_id)
140
+ for state_id, state in inner_history.items()
141
+ if not state.parent
142
+ ]
143
+ state_definition["history"] = top_level_history # type: ignore
144
+ states_instances.update(inner_history)
145
+ events_definitions.update(inner_events)
146
+
147
+ if transition_defs:
148
+ events_definitions[state_id] = transition_defs
149
+
150
+ state_definition = cast(BaseStateKwargs, state_definition)
151
+ states_instances[state_id] = State(**state_definition)
152
+
153
+ return (states_instances, events_definitions)
154
+
155
+
156
+ def create_machine_class_from_definition(
157
+ name: str, states: Mapping[str, "StateKwargs | StateDefinition"], **definition
158
+ ) -> "type[StateChart]": # noqa: C901
159
+ """Create a StateChart class dynamically from a dictionary definition.
160
+
161
+ Args:
162
+ name: The class name for the generated state machine.
163
+ states: A mapping of state IDs to state definitions. Each state definition
164
+ can include ``initial``, ``final``, ``parallel``, ``name``, ``value``,
165
+ ``enter``/``exit`` callbacks, ``donedata``, nested ``states``,
166
+ ``history``, and transitions via ``on`` (event-triggered) or
167
+ ``transitions`` (eventless).
168
+ **definition: Additional keyword arguments passed to the metaclass
169
+ (e.g., ``validate_final_reachability=False``).
170
+
171
+ Returns:
172
+ A new StateChart subclass configured with the given states and transitions.
173
+
174
+ Example:
175
+
176
+ >>> machine = create_machine_class_from_definition(
177
+ ... "TrafficLightMachine",
178
+ ... **{
179
+ ... "states": {
180
+ ... "green": {"initial": True, "on": {"change": [{"target": "yellow"}]}},
181
+ ... "yellow": {"on": {"change": [{"target": "red"}]}},
182
+ ... "red": {"on": {"change": [{"target": "green"}]}},
183
+ ... },
184
+ ... }
185
+ ... )
186
+
187
+ """
188
+ states_instances, events_definitions = _parse_states(states)
189
+
190
+ events: dict[str, TransitionList] = {}
191
+ for state_id, state_events in events_definitions.items():
192
+ for event_name, transitions_data in state_events.items():
193
+ for transition_data in transitions_data:
194
+ source = states_instances[state_id]
195
+
196
+ target_state_id = transition_data["target"]
197
+ transition_event_name = transition_data.get("event")
198
+ if event_name is not None and transition_event_name is not None:
199
+ transition_event_name = f"{event_name} {transition_event_name}"
200
+ elif event_name is not None:
201
+ transition_event_name = event_name
202
+
203
+ transition_kwargs = {
204
+ "event": transition_event_name,
205
+ "internal": transition_data.get("internal"),
206
+ "initial": transition_data.get("initial"),
207
+ "validators": transition_data.get("validators"),
208
+ "cond": transition_data.get("cond"),
209
+ "unless": transition_data.get("unless"),
210
+ "on": transition_data.get("on"),
211
+ "before": transition_data.get("before"),
212
+ "after": transition_data.get("after"),
213
+ }
214
+
215
+ # Handle multi-target transitions (space-separated target IDs)
216
+ if target_state_id and isinstance(target_state_id, str) and " " in target_state_id:
217
+ target_ids = target_state_id.split()
218
+ targets = [states_instances[tid] for tid in target_ids]
219
+ t = Transition(source, target=targets, **transition_kwargs)
220
+ source.transitions.add_transitions(t)
221
+ transition = TransitionList([t])
222
+ else:
223
+ target = states_instances[target_state_id] if target_state_id else None
224
+ transition = source.to(target, **transition_kwargs)
225
+
226
+ if event_name in events:
227
+ events[event_name] |= transition
228
+ elif event_name is not None:
229
+ events[event_name] = transition
230
+
231
+ top_level_states = {
232
+ state_id: state for state_id, state in states_instances.items() if not state.parent
233
+ }
234
+
235
+ attrs_mapper = {**definition, **top_level_states, **events}
236
+ return StateMachineMetaclass(name, (StateChart,), attrs_mapper) # type: ignore[return-value]