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.
Files changed (60) hide show
  1. {python_statemachine-3.1.2.dist-info → python_statemachine-3.2.1.dist-info}/METADATA +16 -3
  2. python_statemachine-3.2.1.dist-info/RECORD +72 -0
  3. {python_statemachine-3.1.2.dist-info → python_statemachine-3.2.1.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 +26 -27
  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 +18 -13
  15. statemachine/engines/async_.py +20 -9
  16. statemachine/engines/base.py +26 -17
  17. statemachine/event.py +1 -2
  18. statemachine/exceptions.py +2 -2
  19. statemachine/factory.py +11 -15
  20. statemachine/graph.py +2 -2
  21. statemachine/invoke.py +12 -11
  22. statemachine/io/__init__.py +52 -225
  23. statemachine/io/actions.py +627 -0
  24. statemachine/io/builder.py +195 -0
  25. statemachine/io/class_factory.py +236 -0
  26. statemachine/io/evaluators.py +303 -0
  27. statemachine/io/interpreter.py +128 -0
  28. statemachine/io/{scxml/invoke.py → invoke.py} +89 -49
  29. statemachine/io/json/__init__.py +1 -0
  30. statemachine/io/json/reader.py +27 -0
  31. statemachine/io/loader.py +175 -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 +15 -0
  37. statemachine/io/scxml/processor.py +23 -253
  38. statemachine/io/scxml/{parser.py → reader.py} +88 -56
  39. statemachine/io/system_variables.py +199 -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 +15 -6
  48. statemachine/registry.py +1 -4
  49. statemachine/signature.py +2 -5
  50. statemachine/spec_parser.py +228 -45
  51. statemachine/state.py +7 -8
  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/actions.py +0 -650
  59. statemachine/io/scxml/schema.py +0 -175
  60. {python_statemachine-3.1.2.dist-info → python_statemachine-3.2.1.dist-info}/licenses/LICENSE +0 -0
statemachine/factory.py CHANGED
@@ -1,9 +1,5 @@
1
1
  import re
2
2
  from typing import Any
3
- from typing import Dict
4
- from typing import List
5
- from typing import Optional
6
- from typing import Tuple
7
3
 
8
4
  from . import registry
9
5
  from .callbacks import CallbackGroup
@@ -39,8 +35,8 @@ class StateMachineMetaclass(type):
39
35
  def __init__(
40
36
  cls,
41
37
  name: str,
42
- bases: Tuple[type],
43
- attrs: Dict[str, Any],
38
+ bases: tuple[type],
39
+ attrs: dict[str, Any],
44
40
  ) -> None:
45
41
  super().__init__(name, bases, attrs)
46
42
  registry.register(cls)
@@ -49,13 +45,13 @@ class StateMachineMetaclass(type):
49
45
  # TODO: Experiment with the IDEA of a root state
50
46
  # cls.root = State(id=cls.id, name=cls.name)
51
47
  cls.states: States = States()
52
- cls.states_map: Dict[Any, State] = {}
48
+ cls.states_map: dict[Any, State] = {}
53
49
  """Map of ``state.value`` to the corresponding :ref:`state`."""
54
50
 
55
51
  cls._abstract = True
56
- cls._events: Dict[Event, None] = {} # used Dict to preserve order and avoid duplicates
52
+ cls._events: dict[Event, None] = {} # used Dict to preserve order and avoid duplicates
57
53
  cls._protected_attrs: set = set()
58
- cls._events_to_update: Dict[Event, Optional[Event]] = {}
54
+ cls._events_to_update: dict[Event, Event | None] = {}
59
55
  cls._specs = CallbackSpecList()
60
56
  cls.prepare = cls._specs.grouper(CallbackGroup.PREPARE).add(
61
57
  "prepare_event", priority=CallbackPriority.GENERIC, is_convention=True
@@ -88,7 +84,7 @@ class StateMachineMetaclass(type):
88
84
  else: # pragma: no cover
89
85
  cls.initial_state = None
90
86
 
91
- cls.final_states: List[State] = [state for state in cls.states if state.final]
87
+ cls.final_states: list[State] = [state for state in cls.states if state.final]
92
88
 
93
89
  cls._check()
94
90
  cls._setup()
@@ -130,10 +126,10 @@ class StateMachineMetaclass(type):
130
126
  return formatter.render(cls, fmt) # type: ignore[arg-type]
131
127
 
132
128
  def _initials_by_document_order( # noqa: C901
133
- cls, states: List[State], parent: "State | None" = None, order: int = 1
129
+ cls, states: list[State], parent: "State | None" = None, order: int = 1
134
130
  ):
135
131
  """Set initial state by document order if no explicit initial state is set"""
136
- initials: List[State] = []
132
+ initials: list[State] = []
137
133
  for s in states:
138
134
  s.document_order = order
139
135
  order += 1
@@ -272,13 +268,13 @@ class StateMachineMetaclass(type):
272
268
  "send",
273
269
  } | {s.id for s in cls.states}
274
270
 
275
- def _collect_class_listeners(cls, attrs: Dict[str, Any], bases: Tuple[type]):
271
+ def _collect_class_listeners(cls, attrs: dict[str, Any], bases: tuple[type]):
276
272
  """Collect class-level listener declarations from attrs and MRO.
277
273
 
278
274
  Listeners declared on parent classes are prepended (MRO order),
279
275
  unless the child sets ``listeners_inherit = False``.
280
276
  """
281
- class_listeners: List[Any] = []
277
+ class_listeners: list[Any] = []
282
278
  if attrs.get("listeners_inherit", True):
283
279
  for base in reversed(bases):
284
280
  class_listeners.extend(getattr(base, "_class_listeners", []))
@@ -291,7 +287,7 @@ class StateMachineMetaclass(type):
291
287
  ).format(entry)
292
288
  )
293
289
  class_listeners.append(entry)
294
- cls._class_listeners: List[Any] = class_listeners
290
+ cls._class_listeners: list[Any] = class_listeners
295
291
 
296
292
  def add_inherited(cls, bases):
297
293
  for base in bases:
statemachine/graph.py CHANGED
@@ -1,7 +1,7 @@
1
1
  from collections import deque
2
+ from collections.abc import Iterable
3
+ from collections.abc import MutableSet
2
4
  from typing import TYPE_CHECKING
3
- from typing import Iterable
4
- from typing import MutableSet
5
5
 
6
6
  if TYPE_CHECKING:
7
7
  from .state import State
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,52 @@
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
+ A statechart is an *executable document*, not inert data, so loading one is closer to importing
15
+ code than to parsing JSON. Because a document may come from a semi-trusted source, loading is
16
+ **secure by default** (``trusted=False``): expressions are evaluated by a **restricted
17
+ AST-allowlist evaluator** that cannot reach builtins, dunder attributes or arbitrary calls;
18
+ ``<script>`` / ``script`` (arbitrary code) is rejected; external resource references
19
+ (``<data src>`` / ``<invoke src>``, which read local files) are rejected; write targets are
20
+ confined to public model attributes; and ``**``/``*`` are magnitude-capped.
21
+
22
+ Passing ``trusted=True`` restores full ``eval``/``exec`` evaluation, enables ``script`` and
23
+ allows external ``src`` references. In that mode a document is equivalent to executable Python
24
+ (much like :mod:`pickle`), so **only load ``trusted`` documents from sources you control**
25
+ (hand-authored documents, the output of your own tooling, the W3C conformance suite).
26
+
27
+ The intended use of ``trusted=False`` is your own dynamic definitions (a no-code editor, your
28
+ tooling, config you control); it is *hardening*, not a sandbox. It guarantees confidentiality
29
+ and integrity, not availability: a *running* machine can still exhaust resources (eventless
30
+ loops, large ``<foreach>``, recursive ``<invoke>``), so run genuinely untrusted documents under
31
+ your own timeout/resource limits or OS-level isolation. See the GHSA-v4jc-pm6r-3vj8,
32
+ GHSA-fj3w-533r-fvf6, GHSA-v3qq-3xvg-m77g, GHSA-4857-ggqc-p3jc and GHSA-r8gj-366q-cgvj advisories,
33
+ and ``docs/io/security.md``, for details.
34
+ """
35
+
36
+ from .class_factory import ActionProtocol
37
+ from .class_factory import HistoryDefinition
38
+ from .class_factory import StateDefinition
39
+ from .class_factory import TransitionDict
40
+ from .class_factory import create_machine_class_from_definition
41
+ from .loader import build_processor
42
+ from .loader import load
43
+
44
+ __all__ = [
45
+ "ActionProtocol",
46
+ "TransitionDict",
47
+ "StateDefinition",
48
+ "HistoryDefinition",
49
+ "create_machine_class_from_definition",
50
+ "load",
51
+ "build_processor",
52
+ ]