python-statemachine 3.1.1__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 (60) hide show
  1. {python_statemachine-3.1.1.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.1.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/__init__.py +15 -6
  8. statemachine/contrib/diagram/extract.py +23 -24
  9. statemachine/contrib/diagram/formatter.py +5 -7
  10. statemachine/contrib/diagram/model.py +9 -11
  11. statemachine/contrib/diagram/renderers/dot.py +20 -26
  12. statemachine/contrib/diagram/renderers/mermaid.py +36 -40
  13. statemachine/contrib/diagram/renderers/table.py +7 -9
  14. statemachine/contrib/weighted.py +7 -11
  15. statemachine/dispatcher.py +13 -12
  16. statemachine/engines/async_.py +5 -6
  17. statemachine/engines/base.py +12 -14
  18. statemachine/event.py +1 -2
  19. statemachine/exceptions.py +1 -1
  20. statemachine/factory.py +12 -16
  21. statemachine/graph.py +2 -2
  22. statemachine/invoke.py +12 -11
  23. statemachine/io/__init__.py +45 -225
  24. statemachine/io/{scxml/actions.py → actions.py} +158 -288
  25. statemachine/io/builder.py +195 -0
  26. statemachine/io/class_factory.py +236 -0
  27. statemachine/io/evaluators.py +275 -0
  28. statemachine/io/interpreter.py +128 -0
  29. statemachine/io/{scxml/invoke.py → invoke.py} +77 -49
  30. statemachine/io/json/__init__.py +1 -0
  31. statemachine/io/json/reader.py +27 -0
  32. statemachine/io/loader.py +161 -0
  33. statemachine/io/model.py +268 -0
  34. statemachine/io/native.py +402 -0
  35. statemachine/io/ports.py +83 -0
  36. statemachine/io/schemas/statechart.schema.json +258 -0
  37. statemachine/io/scxml/__init__.py +12 -0
  38. statemachine/io/scxml/processor.py +23 -253
  39. statemachine/io/scxml/{parser.py → reader.py} +64 -47
  40. statemachine/io/system_variables.py +184 -0
  41. statemachine/io/validation.py +44 -0
  42. statemachine/io/yaml/__init__.py +1 -0
  43. statemachine/io/yaml/reader.py +65 -0
  44. statemachine/locale/en/LC_MESSAGES/statemachine.po +19 -19
  45. statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po +19 -19
  46. statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po +19 -19
  47. statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po +20 -22
  48. statemachine/orderedset.py +3 -3
  49. statemachine/registry.py +1 -4
  50. statemachine/signature.py +2 -5
  51. statemachine/spec_parser.py +171 -42
  52. statemachine/state.py +5 -6
  53. statemachine/statemachine.py +18 -20
  54. statemachine/states.py +3 -5
  55. statemachine/transition.py +3 -4
  56. statemachine/transition_list.py +4 -5
  57. statemachine/transition_mixin.py +1 -1
  58. python_statemachine-3.1.1.dist-info/RECORD +0 -58
  59. statemachine/io/scxml/schema.py +0 -175
  60. {python_statemachine-3.1.1.dist-info → python_statemachine-3.2.0.dist-info}/licenses/LICENSE +0 -0
statemachine/invoke.py CHANGED
@@ -9,6 +9,7 @@ decorators (``@state.invoke``), and inline callables all work out of the box.
9
9
  import asyncio
10
10
  import threading
11
11
  import uuid
12
+ from collections.abc import Callable
12
13
  from concurrent.futures import Future
13
14
  from concurrent.futures import ThreadPoolExecutor
14
15
  from dataclasses import dataclass
@@ -16,10 +17,6 @@ from dataclasses import field
16
17
  from inspect import iscoroutinefunction
17
18
  from typing import TYPE_CHECKING
18
19
  from typing import Any
19
- from typing import Callable
20
- from typing import Dict
21
- from typing import List
22
- from typing import Tuple
23
20
  from typing import runtime_checkable
24
21
 
25
22
  try:
@@ -216,13 +213,13 @@ class InvokeGroup:
216
213
  the exception propagates (which causes an ``error.execution`` event).
217
214
  """
218
215
 
219
- def __init__(self, callables: "List[Callable[..., Any]]"):
216
+ def __init__(self, callables: "list[Callable[..., Any]]"):
220
217
  self._callables = list(callables)
221
- self._futures: "List[Future[Any]]" = []
218
+ self._futures: "list[Future[Any]]" = []
222
219
  self._executor: "ThreadPoolExecutor | None" = None
223
220
 
224
- def run(self, ctx: "InvokeContext") -> "List[Any]":
225
- results: "List[Any]" = [None] * len(self._callables)
221
+ def run(self, ctx: "InvokeContext") -> "list[Any]":
222
+ results: "list[Any]" = [None] * len(self._callables)
226
223
  self._executor = ThreadPoolExecutor(max_workers=len(self._callables))
227
224
  try:
228
225
  self._futures = [self._executor.submit(fn) for fn in self._callables]
@@ -282,8 +279,8 @@ class InvokeManager:
282
279
 
283
280
  def __init__(self, engine: "BaseEngine"):
284
281
  self._engine = engine
285
- self._active: Dict[str, Invocation] = {}
286
- self._pending: "List[Tuple[State, dict]]" = []
282
+ self._active: dict[str, Invocation] = {}
283
+ self._pending: "list[tuple[State, dict]]" = []
287
284
 
288
285
  @property
289
286
  def _debug(self):
@@ -529,7 +526,11 @@ class InvokeManager:
529
526
  self._debug("%s Error in on_cancel for %s", self._log_id, invokeid, exc_info=True)
530
527
 
531
528
  # 3) Cancel the async task (raises CancelledError at next await).
532
- if invocation.task is not None and not invocation.task.done():
529
+ if (
530
+ invocation.task is not None
531
+ and invocation.task is not asyncio.current_task()
532
+ and not invocation.task.done()
533
+ ):
533
534
  invocation.task.cancel()
534
535
 
535
536
  # 4) Wait for the sync thread to actually finish (skip if we ARE
@@ -1,225 +1,45 @@
1
- from typing import Any
2
- from typing import Dict
3
- from typing import List
4
- from typing import Mapping
5
- from typing import Protocol
6
- from typing import Sequence
7
- from typing import Tuple
8
- from typing import TypedDict
9
- from typing import cast
10
-
11
- from ..factory import StateMachineMetaclass
12
- from ..state import HistoryState
13
- from ..state import State
14
- from ..statemachine import StateChart
15
- from ..transition import Transition
16
- from ..transition_list import TransitionList
17
-
18
-
19
- class ActionProtocol(Protocol): # pragma: no cover
20
- def __call__(self, *args, **kwargs) -> Any: ...
21
-
22
-
23
- class TransitionDict(TypedDict, total=False):
24
- target: "str | None"
25
- event: "str | None"
26
- internal: bool
27
- initial: bool
28
- validators: bool
29
- cond: "str | ActionProtocol | Sequence[str] | Sequence[ActionProtocol]"
30
- unless: "str | ActionProtocol | Sequence[str] | Sequence[ActionProtocol]"
31
- on: "str | ActionProtocol | Sequence[str] | Sequence[ActionProtocol]"
32
- before: "str | ActionProtocol | Sequence[str] | Sequence[ActionProtocol]"
33
- after: "str | ActionProtocol | Sequence[str] | Sequence[ActionProtocol]"
34
-
35
-
36
- TransitionsDict = Dict["str | None", List[TransitionDict]]
37
- TransitionsList = List[TransitionDict]
38
-
39
-
40
- class BaseStateKwargs(TypedDict, total=False):
41
- name: str
42
- value: Any
43
- initial: bool
44
- final: bool
45
- parallel: bool
46
- enter: "str | ActionProtocol | Sequence[str] | Sequence[ActionProtocol]"
47
- exit: "str | ActionProtocol | Sequence[str] | Sequence[ActionProtocol]"
48
- donedata: "ActionProtocol | None"
49
-
50
-
51
- class StateKwargs(BaseStateKwargs, total=False):
52
- states: List[State]
53
- history: List[HistoryState]
54
-
55
-
56
- class HistoryKwargs(TypedDict, total=False):
57
- name: str
58
- value: Any
59
- type: str
60
-
61
-
62
- class HistoryDefinition(HistoryKwargs, total=False):
63
- on: TransitionsDict
64
- transitions: TransitionsList
65
-
66
-
67
- class StateDefinition(BaseStateKwargs, total=False):
68
- states: Dict[str, "StateDefinition"]
69
- history: Dict[str, "HistoryDefinition"]
70
- on: TransitionsDict
71
- transitions: TransitionsList
72
-
73
-
74
- def _parse_history(
75
- states: Mapping[str, "HistoryKwargs |HistoryDefinition"],
76
- ) -> Tuple[Dict[str, HistoryState], Dict[str, dict]]:
77
- states_instances: Dict[str, HistoryState] = {}
78
- events_definitions: Dict[str, dict] = {}
79
- for state_id, state_definition in states.items():
80
- state_definition = cast(HistoryDefinition, state_definition)
81
- transition_defs = state_definition.pop("on", {})
82
- transition_list = state_definition.pop("transitions", [])
83
- if transition_list:
84
- transition_defs[None] = transition_list
85
-
86
- if transition_defs:
87
- events_definitions[state_id] = transition_defs
88
-
89
- state_definition = cast(HistoryKwargs, state_definition)
90
- states_instances[state_id] = HistoryState(**state_definition)
91
-
92
- return (states_instances, events_definitions)
93
-
94
-
95
- def _parse_states(
96
- states: Mapping[str, "BaseStateKwargs | StateDefinition"],
97
- ) -> Tuple[Dict[str, State], Dict[str, dict]]:
98
- states_instances: Dict[str, State] = {}
99
- events_definitions: Dict[str, dict] = {}
100
-
101
- for state_id, state_definition in states.items():
102
- # Process nested states. Replaces `states` as a definition by a list of `State` instances.
103
- state_definition = cast(StateDefinition, state_definition)
104
-
105
- # pop the nested states, history and transitions definitions
106
- inner_states_defs: Dict[str, StateDefinition] = state_definition.pop("states", {})
107
- inner_history_defs: Dict[str, HistoryDefinition] = state_definition.pop("history", {})
108
- transition_defs = state_definition.pop("on", {})
109
- transition_list = state_definition.pop("transitions", [])
110
- if transition_list:
111
- transition_defs[None] = transition_list
112
-
113
- if inner_states_defs:
114
- inner_states, inner_events = _parse_states(inner_states_defs)
115
-
116
- top_level_states = [
117
- state._set_id(state_id)
118
- for state_id, state in inner_states.items()
119
- if not state.parent
120
- ]
121
- state_definition["states"] = top_level_states # type: ignore
122
- states_instances.update(inner_states)
123
- events_definitions.update(inner_events)
124
-
125
- if inner_history_defs:
126
- inner_history, inner_events = _parse_history(inner_history_defs)
127
-
128
- top_level_history = [
129
- state._set_id(state_id)
130
- for state_id, state in inner_history.items()
131
- if not state.parent
132
- ]
133
- state_definition["history"] = top_level_history # type: ignore
134
- states_instances.update(inner_history)
135
- events_definitions.update(inner_events)
136
-
137
- if transition_defs:
138
- events_definitions[state_id] = transition_defs
139
-
140
- state_definition = cast(BaseStateKwargs, state_definition)
141
- states_instances[state_id] = State(**state_definition)
142
-
143
- return (states_instances, events_definitions)
144
-
145
-
146
- def create_machine_class_from_definition(
147
- name: str, states: Mapping[str, "StateKwargs | StateDefinition"], **definition
148
- ) -> "type[StateChart]": # noqa: C901
149
- """Create a StateChart class dynamically from a dictionary definition.
150
-
151
- Args:
152
- name: The class name for the generated state machine.
153
- states: A mapping of state IDs to state definitions. Each state definition
154
- can include ``initial``, ``final``, ``parallel``, ``name``, ``value``,
155
- ``enter``/``exit`` callbacks, ``donedata``, nested ``states``,
156
- ``history``, and transitions via ``on`` (event-triggered) or
157
- ``transitions`` (eventless).
158
- **definition: Additional keyword arguments passed to the metaclass
159
- (e.g., ``validate_final_reachability=False``).
160
-
161
- Returns:
162
- A new StateChart subclass configured with the given states and transitions.
163
-
164
- Example:
165
-
166
- >>> machine = create_machine_class_from_definition(
167
- ... "TrafficLightMachine",
168
- ... **{
169
- ... "states": {
170
- ... "green": {"initial": True, "on": {"change": [{"target": "yellow"}]}},
171
- ... "yellow": {"on": {"change": [{"target": "red"}]}},
172
- ... "red": {"on": {"change": [{"target": "green"}]}},
173
- ... },
174
- ... }
175
- ... )
176
-
177
- """
178
- states_instances, events_definitions = _parse_states(states)
179
-
180
- events: Dict[str, TransitionList] = {}
181
- for state_id, state_events in events_definitions.items():
182
- for event_name, transitions_data in state_events.items():
183
- for transition_data in transitions_data:
184
- source = states_instances[state_id]
185
-
186
- target_state_id = transition_data["target"]
187
- transition_event_name = transition_data.get("event")
188
- if event_name is not None and transition_event_name is not None:
189
- transition_event_name = f"{event_name} {transition_event_name}"
190
- elif event_name is not None:
191
- transition_event_name = event_name
192
-
193
- transition_kwargs = {
194
- "event": transition_event_name,
195
- "internal": transition_data.get("internal"),
196
- "initial": transition_data.get("initial"),
197
- "cond": transition_data.get("cond"),
198
- "unless": transition_data.get("unless"),
199
- "on": transition_data.get("on"),
200
- "before": transition_data.get("before"),
201
- "after": transition_data.get("after"),
202
- }
203
-
204
- # Handle multi-target transitions (space-separated target IDs)
205
- if target_state_id and isinstance(target_state_id, str) and " " in target_state_id:
206
- target_ids = target_state_id.split()
207
- targets = [states_instances[tid] for tid in target_ids]
208
- t = Transition(source, target=targets, **transition_kwargs)
209
- source.transitions.add_transitions(t)
210
- transition = TransitionList([t])
211
- else:
212
- target = states_instances[target_state_id] if target_state_id else None
213
- transition = source.to(target, **transition_kwargs)
214
-
215
- if event_name in events:
216
- events[event_name] |= transition
217
- elif event_name is not None:
218
- events[event_name] = transition
219
-
220
- top_level_states = {
221
- state_id: state for state_id, state in states_instances.items() if not state.parent
222
- }
223
-
224
- attrs_mapper = {**definition, **top_level_states, **events}
225
- return StateMachineMetaclass(name, (StateChart,), attrs_mapper) # type: ignore[return-value]
1
+ """Load a statechart from a declarative document (SCXML, JSON or YAML).
2
+
3
+ This package is the high-level facade for building a running state machine from a document
4
+ instead of a Python class. :func:`~statemachine.io.load` is the entry point: it detects the
5
+ format (by file extension, or via an explicit ``format=``), parses the text into a neutral
6
+ intermediate representation, compiles it with a secure-by-default evaluator and returns a
7
+ ready-to-instantiate :class:`~statemachine.statemachine.StateChart` class. Every format runs
8
+ under the same execution model, so a guard, an action or a nested machine behaves identically
9
+ whether it was authored in SCXML, JSON or YAML.
10
+
11
+ Security
12
+ --------
13
+
14
+ Loading a document compiles its guards, datamodel expressions and executable content into
15
+ callables. Because a document may come from a semi-trusted source, loading is **secure by
16
+ default** (``trusted=False``): expressions are evaluated by a **restricted AST-whitelist
17
+ evaluator** that cannot reach builtins, dunder attributes, or arbitrary calls, and
18
+ ``<script>`` / ``script`` (arbitrary code) is rejected. This mirrors ``yaml.safe_load`` and
19
+ keeps loading from turning into arbitrary code execution.
20
+
21
+ Passing ``trusted=True`` restores full ``eval``/``exec`` evaluation and enables ``script``.
22
+ In that mode a document is equivalent to executable Python (much like :mod:`pickle`), so
23
+ **only load ``trusted`` documents from sources you control** (hand-authored documents, the
24
+ output of your own tooling, the W3C conformance suite).
25
+
26
+ See the GHSA-v4jc-pm6r-3vj8 advisory for details.
27
+ """
28
+
29
+ from .class_factory import ActionProtocol
30
+ from .class_factory import HistoryDefinition
31
+ from .class_factory import StateDefinition
32
+ from .class_factory import TransitionDict
33
+ from .class_factory import create_machine_class_from_definition
34
+ from .loader import build_processor
35
+ from .loader import load
36
+
37
+ __all__ = [
38
+ "ActionProtocol",
39
+ "TransitionDict",
40
+ "StateDefinition",
41
+ "HistoryDefinition",
42
+ "create_machine_class_from_definition",
43
+ "load",
44
+ "build_processor",
45
+ ]