vention-state-machine 0.1.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.
File without changes
state_machine/core.py ADDED
@@ -0,0 +1,303 @@
1
+ # mypy: disable-error-code=misc
2
+
3
+ import asyncio
4
+ from collections import deque
5
+ from collections.abc import Coroutine
6
+ from datetime import datetime, timezone
7
+ from typing import Any, Callable, Optional, Union, List
8
+ from transitions.extensions import HierarchicalGraphMachine
9
+ from transitions.core import State, Condition
10
+ from state_machine.defs import StateGroup
11
+ from enum import Enum
12
+ from state_machine.decorator_manager import DecoratorManager
13
+ from state_machine.utils import is_state_container
14
+
15
+
16
+ class BaseStates(str, Enum):
17
+ """Base states that all state machines include."""
18
+
19
+ READY = "ready"
20
+ FAULT = "fault"
21
+
22
+
23
+ class BaseTriggers(str, Enum):
24
+ """Base triggers that all state machines include."""
25
+
26
+ START = "start"
27
+ RESET = "reset"
28
+ TO_FAULT = "to_fault"
29
+
30
+
31
+ StateDict = dict[str, object]
32
+
33
+
34
+ class StateMachine(HierarchicalGraphMachine):
35
+ """
36
+ State Machine wrapping `HierarchicalGraphMachine` with:
37
+ - Default states: 'ready', 'fault'
38
+ - Global transitions: 'to_fault', 'reset'
39
+ - Async task tracking (spawn/cancel)
40
+ - Auto-timeout support via decorator
41
+ - Recoverable last-state functionality
42
+ - Transition history recording
43
+ - Decorator-based entry/exit hooks
44
+ - Guard conditions for transitions
45
+ - Global state change callbacks
46
+ """
47
+
48
+ DEFAULT_HISTORY_SIZE: int = 1000
49
+
50
+ def __init__(
51
+ self,
52
+ states: Union[object, list[StateDict], None] = None,
53
+ *,
54
+ transitions: Optional[list[dict[str, str]]] = None,
55
+ history_size: Optional[int] = None,
56
+ enable_last_state_recovery: bool = True,
57
+ **kw: Any,
58
+ ) -> None:
59
+ # Normalize state definitions
60
+ if is_state_container(states):
61
+ state_groups = [
62
+ value
63
+ for value in vars(states).values()
64
+ if isinstance(value, StateGroup)
65
+ ]
66
+ resolved_states = [group.to_state_list()[0] for group in state_groups]
67
+ elif isinstance(states, list):
68
+ resolved_states = states
69
+ else:
70
+ raise TypeError(
71
+ f"`states` must be either a StateGroup container or a list of state dicts. "
72
+ f"Got: {type(states).__name__}"
73
+ )
74
+
75
+ self._declared_states: list[dict[str, Any]] = resolved_states
76
+
77
+ self._decorator_manager = DecoratorManager(self)
78
+ self._decorator_manager.discover_decorated_handlers(self.__class__)
79
+
80
+ # Initialize base machine
81
+ combined_states = self._declared_states + [
82
+ BaseStates.READY.value,
83
+ BaseStates.FAULT.value,
84
+ ]
85
+ super().__init__(
86
+ states=combined_states,
87
+ transitions=transitions or [],
88
+ initial="ready",
89
+ send_event=True,
90
+ **kw,
91
+ )
92
+
93
+ # Internal tracking
94
+ self._tasks: set[asyncio.Task[Any]] = set()
95
+ self._timeouts: dict[str, asyncio.Task[Any]] = {}
96
+ self._last_state: Optional[str] = None
97
+ self._enable_recovery: bool = enable_last_state_recovery
98
+ self._history: deque[dict[str, Any]] = deque(
99
+ maxlen=history_size or self.DEFAULT_HISTORY_SIZE
100
+ )
101
+ self._current_start: Optional[datetime] = None
102
+ self._guard_conditions: dict[str, List[Callable[[], bool]]] = {}
103
+ self._state_change_callbacks: List[Callable[[str, str, str], None]] = []
104
+
105
+ self._decorator_manager.bind_decorated_handlers(self)
106
+ self._add_recovery_transitions()
107
+ self.add_transition(
108
+ BaseTriggers.TO_FAULT.value,
109
+ "*",
110
+ BaseStates.FAULT.value,
111
+ before="cancel_tasks",
112
+ )
113
+ self.add_transition(
114
+ BaseTriggers.RESET.value, BaseStates.FAULT.value, BaseStates.READY.value
115
+ )
116
+ self._attach_after_hooks()
117
+ self._add_guard_conditions_to_transitions()
118
+
119
+ def spawn(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task[Any]:
120
+ """
121
+ Start an async background task and track it for potential cancellation.
122
+ """
123
+ task: asyncio.Task[Any] = asyncio.create_task(coro)
124
+ self._tasks.add(task)
125
+ task.add_done_callback(lambda _: self._tasks.discard(task))
126
+ return task
127
+
128
+ async def cancel_tasks(self, *_: Any) -> None:
129
+ """
130
+ Cancel all tracked background tasks and clear any timeouts.
131
+ """
132
+ self._clear_all_timeouts()
133
+ for task in list(self._tasks):
134
+ task.cancel()
135
+ try:
136
+ await task
137
+ except asyncio.CancelledError:
138
+ pass
139
+
140
+ def set_timeout(
141
+ self, state_name: str, seconds: float, trigger_fn: Callable[[], str]
142
+ ) -> None:
143
+ """
144
+ Schedule a timeout: if `state_name` remains active after `seconds`, fire `trigger_fn()`.
145
+ """
146
+ self._clear_timeout(state_name)
147
+
148
+ async def _timeout_task() -> None:
149
+ await asyncio.sleep(seconds)
150
+ if self.state == state_name:
151
+ self.trigger(trigger_fn())
152
+
153
+ self._timeouts[state_name] = self.spawn(_timeout_task())
154
+
155
+ def _clear_timeout(self, state_name: str) -> None:
156
+ """Cancel a pending timeout for the given state."""
157
+ task = self._timeouts.pop(state_name, None)
158
+ if task:
159
+ task.cancel()
160
+
161
+ def _clear_all_timeouts(self) -> None:
162
+ """Cancel all pending state timeouts."""
163
+ for timeout in self._timeouts.values():
164
+ timeout.cancel()
165
+ self._timeouts.clear()
166
+
167
+ def record_last_state(self) -> None:
168
+ """Manually record the current state for later recovery."""
169
+ self._last_state = self.state
170
+
171
+ def get_last_state(self) -> Optional[str]:
172
+ """Get the most recently recorded recoverable state."""
173
+ return self._last_state
174
+
175
+ def start(self) -> None:
176
+ """
177
+ Enter the machine: if recovery is enabled and a state was recorded, fire the
178
+ corresponding recover__ transition; otherwise fire 'start'.
179
+ """
180
+ if self._enable_recovery and self._last_state:
181
+ self.trigger(f"recover__{self._last_state}")
182
+ else:
183
+ self.trigger(BaseTriggers.START.value)
184
+
185
+ @property
186
+ def history(self) -> list[dict[str, Any]]:
187
+ """Get the full transition history with timestamps and durations."""
188
+ return list(self._history)
189
+
190
+ def get_last_history_entries(self, n: int = 10) -> list[dict[str, Any]]:
191
+ """Get the last `n` history entries."""
192
+ return list(self._history)[-n:] if n > 0 else []
193
+
194
+ def add_transition_condition(
195
+ self, trigger_name: str, condition_fn: Callable[[], bool]
196
+ ) -> None:
197
+ """
198
+ Add a guard condition to a transition trigger.
199
+ Multiple conditions can be added for the same trigger - ALL must pass for the transition to be allowed.
200
+ """
201
+ if trigger_name not in self._guard_conditions:
202
+ self._guard_conditions[trigger_name] = []
203
+ self._guard_conditions[trigger_name].append(condition_fn)
204
+
205
+ def add_state_change_callback(
206
+ self, callback: Callable[[str, str, str], None]
207
+ ) -> None:
208
+ """
209
+ Add a callback that fires on any state change.
210
+
211
+ Args:
212
+ callback: Function that takes (old_state, new_state, trigger_name) as arguments
213
+ """
214
+ self._state_change_callbacks.append(callback)
215
+
216
+ # -- Private helpers --
217
+ def _run_enter_callbacks(self, state_name: str) -> None:
218
+ state_obj = self.get_state(state_name)
219
+ for callback in getattr(state_obj, "enter", []):
220
+ callback(None)
221
+
222
+ def on_enter_ready(self, _: Any) -> None:
223
+ if not self._enable_recovery:
224
+ self._last_state = None
225
+
226
+ def _is_leaf(self, state: Union[State, str]) -> bool:
227
+ state_data = self.get_state(state) if isinstance(state, str) else state
228
+ return not getattr(state_data, "children", [])
229
+
230
+ def _record_history_event(self, event: Any) -> None:
231
+ now = datetime.now(timezone.utc)
232
+ dest = event.transition.dest
233
+ if self._history and self._current_start is not None:
234
+ elapsed = (now - self._current_start).total_seconds() * 1000
235
+ self._history[-1]["duration_ms"] = int(elapsed)
236
+ self._history.append({"timestamp": now, "state": dest})
237
+ self._current_start = now
238
+ if self._is_leaf(dest) and dest not in {
239
+ BaseStates.READY.value,
240
+ BaseStates.FAULT.value,
241
+ }:
242
+ self._last_state = dest
243
+
244
+ def _attach_after_hooks(self) -> None:
245
+ # Attach after hooks to all transitions in all events
246
+ for _, event in self.events.items():
247
+ # Loop over all transitions for this event
248
+ for transitions_for_source in event.transitions.values():
249
+ # Add our custom callbacks to each transition
250
+ for transition in transitions_for_source:
251
+ transition.add_callback("after", self._record_history_event)
252
+ transition.add_callback("after", self._attach_exit_timeout_clear)
253
+ transition.add_callback("after", self._notify_state_change_callback)
254
+
255
+ def _attach_exit_timeout_clear(self, event: Any) -> None:
256
+ self._clear_timeout(event.transition.source)
257
+
258
+ def _add_recovery_transitions(self) -> None:
259
+ for state_spec in self._declared_states:
260
+ parent_name = state_spec["name"]
261
+ for child_spec in state_spec.get("children", []):
262
+ full_state_name = f"{parent_name}_{child_spec['name']}"
263
+ self.add_transition(
264
+ f"recover__{full_state_name}",
265
+ BaseStates.READY.value,
266
+ full_state_name,
267
+ )
268
+
269
+ def _add_guard_conditions_to_transitions(self) -> None:
270
+ """Add guard conditions to transitions using the transitions library's condition system."""
271
+ for trigger_name, guard_fns in self._guard_conditions.items():
272
+ if trigger_name not in self.events:
273
+ continue
274
+
275
+ event = self.events[trigger_name]
276
+ condition = self._create_guard_condition(guard_fns)
277
+
278
+ for transitions_for_source in event.transitions.values():
279
+ for transition in transitions_for_source:
280
+ transition.conditions.append(condition)
281
+
282
+ def _create_guard_condition(self, guard_fns: List[Callable[[], bool]]) -> Condition:
283
+ """Create a condition function for the transitions library."""
284
+
285
+ def condition(_: Any) -> bool:
286
+ for guard_fn in guard_fns:
287
+ if not guard_fn():
288
+ return False
289
+ return True
290
+
291
+ return Condition(condition)
292
+
293
+ def _notify_state_change_callback(self, event: Any) -> None:
294
+ """Notify state change callbacks after successful transition."""
295
+ trigger_name = event.event.name
296
+ old_state = event.transition.source
297
+ new_state = event.transition.dest
298
+
299
+ for callback in self._state_change_callbacks:
300
+ try:
301
+ callback(old_state, new_state, trigger_name)
302
+ except Exception as e:
303
+ print(f"Error in state change callback: {e}")
@@ -0,0 +1,102 @@
1
+ from typing import Any, Callable, cast
2
+ from state_machine.utils import wrap_with_timeout
3
+ from state_machine.machine_protocols import (
4
+ StateMachineProtocol,
5
+ )
6
+ from typing_extensions import TypeAlias
7
+
8
+ StateCallbackBinding: TypeAlias = tuple[str, str, Any]
9
+ GuardBinding: TypeAlias = tuple[str, Callable[[], bool]]
10
+ StateChangeCallback: TypeAlias = Callable[[str, str, str], None]
11
+
12
+
13
+ class DecoratorManager:
14
+ """
15
+ Manages discovery and binding of decorator-based lifecycle hooks
16
+ (`on_enter_state`, `on_exit_state`, `auto_timeout`, `guard`, and `on_state_change`) to a state machine instance.
17
+ """
18
+
19
+ def __init__(self, machine: StateMachineProtocol) -> None:
20
+ self.machine = machine
21
+ self._decorator_bindings: list[StateCallbackBinding] = []
22
+ self._exit_decorator_bindings: list[StateCallbackBinding] = []
23
+ self._guard_bindings: list[GuardBinding] = []
24
+ self._state_change_callbacks: list[StateChangeCallback] = []
25
+
26
+ def discover_decorated_handlers(self, target_class: type) -> None:
27
+ """
28
+ Collect decorated methods from a class definition.
29
+ You must call this on the class object before instantiating.
30
+ """
31
+ for attr in dir(target_class):
32
+ callback_fn = getattr(target_class, attr, None)
33
+ if callable(callback_fn):
34
+ self._discover_state_callbacks(callback_fn)
35
+ self._discover_guard_conditions(callback_fn)
36
+ self._discover_state_change_callbacks(callback_fn)
37
+
38
+ def _discover_state_callbacks(self, callback_fn: Any) -> None:
39
+ """Discover on_enter_state and on_exit_state decorators."""
40
+ if hasattr(callback_fn, "_on_enter_state"):
41
+ self._decorator_bindings.append(
42
+ (callback_fn._on_enter_state, "enter", callback_fn)
43
+ )
44
+
45
+ if hasattr(callback_fn, "_on_exit_state"):
46
+ self._exit_decorator_bindings.append(
47
+ (callback_fn._on_exit_state, "exit", callback_fn)
48
+ )
49
+
50
+ def _discover_guard_conditions(self, callback_fn: Any) -> None:
51
+ """Discover guard decorators."""
52
+ if hasattr(callback_fn, "_guard_conditions"):
53
+ for trigger_name, guard_fn in callback_fn._guard_conditions.items():
54
+ self._guard_bindings.append((trigger_name, guard_fn))
55
+
56
+ def _discover_state_change_callbacks(self, callback_fn: Any) -> None:
57
+ """Discover on_state_change decorators."""
58
+ if hasattr(callback_fn, "_state_change_callback"):
59
+ state_change_callback = cast(StateChangeCallback, callback_fn)
60
+ self._state_change_callbacks.append(state_change_callback)
61
+
62
+ def bind_decorated_handlers(self, instance: Any) -> None:
63
+ """
64
+ After instantiating your class, call this with the instance.
65
+ Hooks will be registered onto the state machine, and timeouts applied if needed.
66
+ """
67
+ self._bind_state_callbacks(instance)
68
+ self._bind_guard_conditions(instance)
69
+ self._bind_state_change_callbacks(instance)
70
+
71
+ def _bind_state_callbacks(self, instance: Any) -> None:
72
+ """Bind state entry/exit callbacks."""
73
+ for state_name, hook_type, callback_fn in (
74
+ self._decorator_bindings + self._exit_decorator_bindings
75
+ ):
76
+ bound_fn = callback_fn.__get__(instance)
77
+
78
+ if hook_type == "enter" and hasattr(callback_fn, "_timeout_config"):
79
+ handler = wrap_with_timeout(
80
+ bound_fn,
81
+ state_name,
82
+ callback_fn._timeout_config,
83
+ self.machine.set_timeout,
84
+ )
85
+ else:
86
+ handler = bound_fn
87
+
88
+ state_obj = self.machine.get_state(state_name)
89
+ if state_obj:
90
+ state_obj.add_callback(hook_type, handler)
91
+
92
+ def _bind_guard_conditions(self, instance: Any) -> None:
93
+ """Bind guard conditions."""
94
+ for trigger_name, guard_fn in self._guard_bindings:
95
+ bound_guard = guard_fn.__get__(instance)
96
+ self.machine.add_transition_condition(trigger_name, bound_guard)
97
+
98
+ def _bind_state_change_callbacks(self, instance: Any) -> None:
99
+ """Bind state change callbacks."""
100
+ for callback_fn in self._state_change_callbacks:
101
+ bound_callback = callback_fn.__get__(instance)
102
+ self.machine.add_state_change_callback(bound_callback)
@@ -0,0 +1,23 @@
1
+ from typing import Protocol, Callable, Any, Union
2
+
3
+ CallableType = Callable[..., Any]
4
+
5
+
6
+ class SupportsGuardConditions(Protocol):
7
+ _guard_conditions: dict[str, CallableType]
8
+
9
+
10
+ class SupportsStateChangeCallback(Protocol):
11
+ _state_change_callback: bool
12
+
13
+
14
+ class SupportsEnterState(Protocol):
15
+ _on_enter_state: str
16
+
17
+
18
+ class SupportsExitState(Protocol):
19
+ _on_exit_state: str
20
+
21
+
22
+ class SupportsTimeoutConfig(Protocol):
23
+ _timeout_config: tuple[float, Union[str, Callable[[], str]]]
@@ -0,0 +1,116 @@
1
+ from typing import Union, Callable, cast
2
+ from .defs import State, Trigger
3
+ from .decorator_protocols import (
4
+ SupportsGuardConditions,
5
+ SupportsStateChangeCallback,
6
+ SupportsEnterState,
7
+ SupportsExitState,
8
+ SupportsTimeoutConfig,
9
+ CallableType,
10
+ )
11
+
12
+
13
+ def on_enter_state(
14
+ state_node: Union[str, State],
15
+ ) -> Callable[[CallableType], CallableType]:
16
+ """
17
+ Decorator to bind a function to the on-enter hook for a state.
18
+ Accepts a str or a State descriptor.
19
+ """
20
+ if hasattr(state_node, "name"):
21
+ state_name = state_node.name
22
+ elif isinstance(state_node, str):
23
+ state_name = state_node
24
+ else:
25
+ raise TypeError(f"Expected a State or str, got {type(state_node)}")
26
+
27
+ def decorator(fn: CallableType) -> CallableType:
28
+ enter_fn: SupportsEnterState = cast(SupportsEnterState, fn)
29
+ enter_fn._on_enter_state = state_name
30
+ return fn
31
+
32
+ return decorator
33
+
34
+
35
+ def on_exit_state(
36
+ state_node: Union[str, State],
37
+ ) -> Callable[[CallableType], CallableType]:
38
+ """
39
+ Decorator to bind a function to the on-exit hook for a state.
40
+ Accepts a str or a State descriptor.
41
+ """
42
+ if hasattr(state_node, "name"):
43
+ state_name = state_node.name
44
+ elif isinstance(state_node, str):
45
+ state_name = state_node
46
+ else:
47
+ raise TypeError(f"Expected a State or str, got {type(state_node)}")
48
+
49
+ def decorator(fn: CallableType) -> CallableType:
50
+ exit_fn: SupportsExitState = cast(SupportsExitState, fn)
51
+ exit_fn._on_exit_state = state_name
52
+ return fn
53
+
54
+ return decorator
55
+
56
+
57
+ def auto_timeout(
58
+ seconds: float, trigger: Union[str, Callable[[], str]] = "to_fault"
59
+ ) -> Callable[[CallableType], CallableType]:
60
+ """
61
+ Decorator that applies an auto-timeout configuration to a state entry handler.
62
+ """
63
+
64
+ def decorator(fn: CallableType) -> CallableType:
65
+ timeout_fn: SupportsTimeoutConfig = cast(SupportsTimeoutConfig, fn)
66
+ timeout_fn._timeout_config = (seconds, trigger)
67
+ return fn
68
+
69
+ return decorator
70
+
71
+
72
+ def guard(
73
+ *triggers: Union[str, Trigger],
74
+ ) -> Callable[[CallableType], CallableType]:
75
+ """
76
+ Decorator to add a guard condition to one or more transition triggers.
77
+ The decorated function should return a boolean indicating whether the transition is allowed.
78
+ Accepts multiple str or Trigger descriptors.
79
+
80
+ Examples:
81
+ @guard(Triggers.reset) # Single trigger
82
+ @guard(Triggers.reset, Triggers.start) # Multiple triggers
83
+ @guard("reset", "start") # Multiple string triggers
84
+ """
85
+
86
+ def decorator(fn: CallableType) -> CallableType:
87
+ guard_fn: SupportsGuardConditions = cast(SupportsGuardConditions, fn)
88
+
89
+ if not hasattr(guard_fn, "_guard_conditions"):
90
+ guard_fn._guard_conditions = {}
91
+
92
+ for trigger in triggers:
93
+ trigger_name = (
94
+ getattr(trigger, "name", trigger)
95
+ if hasattr(trigger, "name")
96
+ else trigger
97
+ )
98
+ if not isinstance(trigger_name, str):
99
+ raise TypeError(f"Expected a Trigger or str, got {type(trigger)}")
100
+
101
+ guard_fn._guard_conditions[trigger_name] = fn
102
+
103
+ return fn
104
+
105
+ return decorator
106
+
107
+
108
+ def on_state_change(fn: CallableType) -> CallableType:
109
+ """
110
+ Decorator to register a global state change callback.
111
+ The decorated function will be called whenever any state transition occurs.
112
+ The callback receives (old_state, new_state, trigger_name) as arguments.
113
+ """
114
+ callback_fn: SupportsStateChangeCallback = cast(SupportsStateChangeCallback, fn)
115
+ callback_fn._state_change_callback = True
116
+ return fn
state_machine/defs.py ADDED
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Dict, List, Union
3
+
4
+
5
+ class State:
6
+ """
7
+ Descriptor for a single leaf state in a hierarchical state machine.
8
+
9
+ On class definition it captures its fully-qualified name, as well as
10
+ the owning group and member names separately.
11
+ """
12
+
13
+ __slots__ = ("name", "group_name", "member_name")
14
+
15
+ def __init__(self) -> None:
16
+ self.name: str = ""
17
+ self.group_name: str = ""
18
+ self.member_name: str = ""
19
+
20
+ def __set_name__(self, owner: type, attr_name: str) -> None:
21
+ self.group_name = owner.__name__
22
+ self.member_name = attr_name
23
+ self.name = f"{self.group_name}_{self.member_name}"
24
+
25
+ def __str__(self) -> str:
26
+ return self.name
27
+
28
+ def __repr__(self) -> str:
29
+ return f"{self.__class__.__name__}({self.name!r})"
30
+
31
+
32
+ class StateGroup:
33
+ """
34
+ Base class for grouping related State descriptors.
35
+
36
+ Subclass this, declare State fields, and `to_state_list()`
37
+ will emit exactly the structure transitions needs.
38
+ """
39
+
40
+ def to_state_list(self) -> List[Dict[str, Any]]:
41
+ """
42
+ Build a singleโ€element list of a dict:
43
+
44
+ {
45
+ "name": "<GroupName>",
46
+ "children": [{"name":"member"}...],
47
+ "initial": "<member>"
48
+ }
49
+
50
+ transitions will register:
51
+ <GroupName>_<member>
52
+ as the leaf states.
53
+ """
54
+ children: List[Dict[str, str]] = []
55
+ for _, descriptor in vars(self.__class__).items():
56
+ if isinstance(descriptor, State):
57
+ children.append({"name": descriptor.member_name})
58
+
59
+ if not children:
60
+ raise ValueError(f"{self.__class__.__name__} has no State members")
61
+
62
+ initial = children[0]["name"]
63
+ return [
64
+ {
65
+ "name": self.__class__.__name__,
66
+ "children": children,
67
+ "initial": initial,
68
+ }
69
+ ]
70
+
71
+
72
+ class Trigger:
73
+ """
74
+ Represents a state machine trigger/event.
75
+
76
+ You give it exactly one name; `.transition(...)` then builds
77
+ the dict transitions wants.
78
+ """
79
+
80
+ __slots__ = ("name",)
81
+
82
+ def __init__(self, name: str) -> None:
83
+ self.name = name
84
+
85
+ def __repr__(self) -> str:
86
+ return f"{self.__class__.__name__}({self.name!r})"
87
+
88
+ def __call__(self) -> str:
89
+ return self.name
90
+
91
+ def transition(
92
+ self, source: Union[str, State], dest: Union[str, State]
93
+ ) -> Dict[str, str]:
94
+ """
95
+ Build {"trigger": self.name, "source": <src>, "dest": <dst>}.
96
+ `source` / `dest` may be raw strings or StateKey instances.
97
+ """
98
+ src = source if isinstance(source, str) else str(source)
99
+ dst = dest if isinstance(dest, str) else str(dest)
100
+ return {"trigger": self.name, "source": src, "dest": dst}
@@ -0,0 +1,33 @@
1
+ from typing import Protocol, Callable, Any
2
+
3
+
4
+ class SupportsTimeout(Protocol):
5
+ def set_timeout(
6
+ self, state_name: str, seconds: float, trigger_fn: Callable[[], str]
7
+ ) -> None: ...
8
+
9
+
10
+ class SupportsStateCallbacks(Protocol):
11
+ def get_state(self, state_name: str) -> Any: ...
12
+
13
+
14
+ class SupportsGuardConditions(Protocol):
15
+ def add_transition_condition(
16
+ self, trigger_name: str, condition_fn: Callable[[], bool]
17
+ ) -> None: ...
18
+
19
+
20
+ class SupportsStateChangeCallbacks(Protocol):
21
+ def add_state_change_callback(
22
+ self, callback: Callable[[str, str, str], None]
23
+ ) -> None: ...
24
+
25
+
26
+ class StateMachineProtocol(
27
+ SupportsTimeout,
28
+ SupportsStateCallbacks,
29
+ SupportsGuardConditions,
30
+ SupportsStateChangeCallbacks,
31
+ Protocol,
32
+ ):
33
+ """Combined protocol for state machine interface."""
@@ -0,0 +1,181 @@
1
+ import asyncio
2
+ from datetime import datetime
3
+ from typing import Optional, Sequence, Union, cast
4
+ from fastapi import APIRouter, HTTPException, Response, status
5
+ from typing_extensions import TypedDict, NotRequired
6
+ from state_machine.core import StateMachine
7
+ from state_machine.defs import Trigger
8
+
9
+
10
+ # ----------- TypedDict response models -----------
11
+
12
+
13
+ class StateResponse(TypedDict):
14
+ state: str
15
+ last_state: Optional[str]
16
+
17
+
18
+ class HistoryEntry(TypedDict):
19
+ timestamp: datetime
20
+ state: str
21
+ duration_ms: NotRequired[int]
22
+
23
+
24
+ class HistoryResponse(TypedDict):
25
+ history: list[HistoryEntry]
26
+ buffer_size: int
27
+
28
+
29
+ class TriggerResponse(TypedDict):
30
+ result: str
31
+ previous_state: str
32
+ new_state: str
33
+
34
+
35
+ # ----------- Router setup -----------
36
+
37
+
38
+ def build_router(
39
+ state_machine: StateMachine,
40
+ triggers: Optional[Sequence[Union[str, Trigger]]] = None,
41
+ ) -> APIRouter:
42
+ """
43
+ Create an APIRouter for a given state_machine instance.
44
+
45
+ Routes:
46
+ - GET /state โ†’ current state and last known state
47
+ - GET /history โ†’ transition history
48
+ - POST /<trigger> โ†’ trigger transition (e.g., /start)
49
+ """
50
+ router = APIRouter()
51
+
52
+ _register_basic_routes(router, state_machine)
53
+ resolved_triggers = _resolve_triggers(state_machine, triggers)
54
+ for trigger in resolved_triggers:
55
+ _add_trigger_route(router, state_machine, trigger)
56
+
57
+ return router
58
+
59
+
60
+ # ----------- Basic routes -----------
61
+
62
+
63
+ def _register_basic_routes(router: APIRouter, state_machine: StateMachine) -> None:
64
+ @router.get("/state", response_model=StateResponse)
65
+ def get_state() -> StateResponse:
66
+ """Return current and last known state."""
67
+ return {
68
+ "state": state_machine.state,
69
+ "last_state": state_machine.get_last_state(),
70
+ }
71
+
72
+ @router.get("/history", response_model=HistoryResponse)
73
+ def get_history(last_n_entries: Optional[int] = None) -> HistoryResponse:
74
+ """
75
+ Return transition history. If `last_n_entries` is provided and non-negative,
76
+ returns only the most recent N entries. Otherwise returns the full buffer.
77
+ """
78
+ if last_n_entries is not None and last_n_entries < 0:
79
+ data = []
80
+ else:
81
+ data = (
82
+ state_machine.get_last_history_entries(last_n_entries)
83
+ if last_n_entries is not None
84
+ else state_machine.history
85
+ )
86
+ return {
87
+ "history": cast(list[HistoryEntry], data),
88
+ "buffer_size": len(state_machine.history),
89
+ }
90
+
91
+ @router.get("/diagram.svg", response_class=Response)
92
+ def get_svg() -> Response:
93
+ try:
94
+ graph = state_machine.get_graph()
95
+ svg_bytes = graph.pipe(format="svg")
96
+ return Response(content=svg_bytes, media_type="image/svg+xml")
97
+ except Exception as e:
98
+ msg = str(e).lower()
99
+ if "executable" in msg or "dot not found" in msg or "graphviz" in msg:
100
+ raise HTTPException(
101
+ status_code=503,
102
+ detail=(
103
+ "Graphviz is required to render the diagram. "
104
+ "Install the system package via brew or apt) "
105
+ ),
106
+ )
107
+ raise
108
+
109
+
110
+ # ----------- Trigger resolution -----------
111
+
112
+
113
+ def _resolve_triggers(
114
+ state_machine: StateMachine,
115
+ triggers: Optional[Sequence[Union[str, Trigger]]],
116
+ ) -> list[str]:
117
+ """Resolve and validate trigger names."""
118
+ if triggers is None:
119
+ return sorted(state_machine.events.keys())
120
+
121
+ resolved = []
122
+ for trigger in triggers:
123
+ trigger_name = trigger.name if isinstance(trigger, Trigger) else trigger
124
+ if trigger_name not in state_machine.events:
125
+ raise ValueError(f"Unknown trigger: '{trigger_name}'")
126
+ resolved.append(trigger_name)
127
+
128
+ return resolved
129
+
130
+
131
+ # ----------- Trigger route generation -----------
132
+
133
+
134
+ def _add_trigger_route(
135
+ router: APIRouter,
136
+ state_machine: StateMachine,
137
+ trigger: str,
138
+ ) -> None:
139
+ """Add a single trigger route to the router."""
140
+
141
+ async def trigger_handler() -> TriggerResponse:
142
+ previous_state = state_machine.state
143
+
144
+ available_triggers = state_machine.get_triggers(previous_state)
145
+ if trigger not in available_triggers:
146
+ raise HTTPException(
147
+ status_code=status.HTTP_409_CONFLICT,
148
+ detail=f"Trigger '{trigger}' not allowed from state '{previous_state}'. "
149
+ f"Available triggers: {sorted(available_triggers)}",
150
+ )
151
+
152
+ try:
153
+ method = getattr(state_machine, trigger)
154
+ result = method()
155
+ if asyncio.iscoroutine(result):
156
+ await result
157
+
158
+ return {
159
+ "result": trigger,
160
+ "previous_state": previous_state,
161
+ "new_state": state_machine.state,
162
+ }
163
+
164
+ except AttributeError:
165
+ raise HTTPException(
166
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
167
+ detail=f"Trigger method '{trigger}' not found on state machine",
168
+ )
169
+ except Exception as e:
170
+ raise HTTPException(
171
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
172
+ detail=f"Error executing trigger '{trigger}': {str(e)}",
173
+ )
174
+
175
+ router.add_api_route(
176
+ path=f"/{trigger}",
177
+ endpoint=trigger_handler,
178
+ methods=["POST"],
179
+ name=f"{trigger}_trigger",
180
+ response_model=TriggerResponse,
181
+ )
state_machine/utils.py ADDED
@@ -0,0 +1,56 @@
1
+ from typing import Any, Callable, Union
2
+ from state_machine.defs import StateGroup
3
+
4
+
5
+ def wrap_with_timeout(
6
+ handler: Callable[[Any], Any],
7
+ state_name: str,
8
+ timeout: tuple[float, Union[str, Callable[[], str]]],
9
+ set_timeout_fn: Callable[[str, float, Callable[[], str]], None],
10
+ ) -> Callable[[Any], Any]:
11
+ """
12
+ Wraps a state entry handler with timeout behavior.
13
+
14
+ Args:
15
+ handler: The original state entry function to wrap.
16
+ state_name: The name of the state for which the timeout applies.
17
+ timeout: A tuple containing (seconds, trigger), where:
18
+ - seconds: Timeout duration in seconds.
19
+ - trigger: A string (trigger name) or a function returning a trigger name.
20
+ set_timeout_fn: A function used to register the timeout with the FSM. Signature:
21
+ (state_name: str, seconds: float, trigger_fn: () -> str) -> None
22
+
23
+ Returns:
24
+ A new function that sets the timeout and then invokes the original handler.
25
+ """
26
+ seconds, trig = timeout
27
+
28
+ if isinstance(trig, str):
29
+
30
+ def trigger_fn() -> str:
31
+ return trig
32
+ elif callable(trig):
33
+ trigger_fn = trig
34
+ else:
35
+ raise TypeError(f"Unsupported trigger type: {type(trig).__name__}")
36
+
37
+ def wrapped(event: Any) -> Any:
38
+ set_timeout_fn(state_name, seconds, trigger_fn)
39
+ return handler(event)
40
+
41
+ return wrapped
42
+
43
+
44
+ def is_state_container(state_source: object) -> bool:
45
+ """
46
+ Return True if `obj` is an instance or class that contains StateGroup(s).
47
+ """
48
+ try:
49
+ return any(
50
+ isinstance(value, StateGroup) for value in vars(state_source).values()
51
+ ) or any(
52
+ isinstance(value, StateGroup)
53
+ for value in vars(state_source.__class__).values()
54
+ )
55
+ except TypeError:
56
+ return False
@@ -0,0 +1,295 @@
1
+ Metadata-Version: 2.1
2
+ Name: vention-state-machine
3
+ Version: 0.1.0
4
+ Summary: Declarative state machine framework for machine apps
5
+ License: Proprietary
6
+ Author: VentionCo
7
+ Requires-Python: >=3.9,<3.11
8
+ Classifier: License :: Other/Proprietary License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.9
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Requires-Dist: asyncio (>=3.4.3,<4.0.0)
13
+ Requires-Dist: coverage (>=7.10.1,<8.0.0)
14
+ Requires-Dist: fastapi (>=0.116.1,<0.117.0)
15
+ Requires-Dist: graphviz (>=0.21,<0.22)
16
+ Requires-Dist: httpx (>=0.28.1,<0.29.0)
17
+ Requires-Dist: transitions (>=0.9.3,<0.10.0)
18
+ Requires-Dist: uvicorn (>=0.35.0,<0.36.0)
19
+ Description-Content-Type: text/markdown
20
+
21
+ # vention-state-machine
22
+
23
+ A lightweight wrapper around `transitions` for building async-safe, recoverable hierarchical state machines with minimal boilerplate.
24
+
25
+ ## โœจ Features
26
+
27
+ - Built-in `ready` / `fault` states
28
+ - Global transitions: `to_fault`, `reset`
29
+ - Optional state recovery (`recover__state`)
30
+ - Async task spawning and cancellation
31
+ - Timeouts and auto-fault handling
32
+ - Transition history recording with timestamps + durations
33
+ - Guard conditions for blocking transitions
34
+ - Global state change callbacks for logging/MQTT
35
+
36
+ ## ๐Ÿง  Domain-Specific Language
37
+
38
+ This library uses a **declarative domain-specific language (DSL)** to define state machines in a readable and structured way. The key building blocks are:
39
+
40
+ - **`State`**: Represents a single leaf node in the state machine. Declared as a class attribute.
41
+
42
+ - **`StateGroup`**: A container for related states. It generates a hierarchical namespace for its child states (e.g. `MyGroup.my_state` becomes `"MyGroup_my_state"`).
43
+
44
+ - **`Trigger`**: Defines a named event that can cause state transitions. It is both callable (returns its name as a string) and composable (can generate transition dictionaries via `.transition(...)`).
45
+
46
+ This structure allows you to define states and transitions with strong typing and full IDE support โ€” without strings scattered across your codebase.
47
+
48
+ For Example:
49
+ ```python
50
+ class MyStates(StateGroup):
51
+ idle: State = State()
52
+ working: State = State()
53
+
54
+ class Triggers:
55
+ begin = Trigger("begin")
56
+ finish = Trigger("finish")
57
+ ```
58
+ You can then define transitions declaratively:
59
+ ```python
60
+ TRANSITIONS = [
61
+ Triggers.finish.transition(MyStates.working, MyStates.idle),
62
+ ]
63
+ ```
64
+
65
+ ## ๐Ÿงฑ Base States and Triggers
66
+
67
+ Every machine comes with built-in:
68
+
69
+ - **States**:
70
+ - `ready`: initial state
71
+ - `fault`: global error state
72
+ - **Triggers**:
73
+ - `start`: transition into the first defined state
74
+ - `to_fault`: jump to fault from any state
75
+ - `reset`: recover from fault back to ready
76
+
77
+ You can reference these via:
78
+
79
+ ```python
80
+ from state_machine.core import BaseStates, BaseTriggers
81
+
82
+ state_machine.trigger(BaseTriggers.RESET.value)
83
+ assert state_machine.state == BaseStates.READY.value
84
+ ```
85
+
86
+ ## ๐Ÿš€ Quick Start
87
+ ### 1. Define Your States and Triggers
88
+
89
+ ```python
90
+ from state_machine.defs import StateGroup, State, Trigger
91
+
92
+ class Running(StateGroup):
93
+ picking: State = State()
94
+ placing: State = State()
95
+ homing: State = State()
96
+
97
+ class States:
98
+ running = Running()
99
+
100
+ class Triggers:
101
+ start = Trigger("start")
102
+ finished_picking = Trigger("finished_picking")
103
+ finished_placing = Trigger("finished_placing")
104
+ finished_homing = Trigger("finished_homing")
105
+ to_fault = Trigger("to_fault")
106
+ reset = Trigger("reset")
107
+ ```
108
+
109
+ ### 2. Define Transitions
110
+ ```python
111
+ TRANSITIONS = [
112
+ Triggers.start.transition("ready", States.running.picking),
113
+ Triggers.finished_picking.transition(States.running.picking, States.running.placing),
114
+ Triggers.finished_placing.transition(States.running.placing, States.running.homing),
115
+ Triggers.finished_homing.transition(States.running.homing, States.running.picking)
116
+ ]
117
+ ```
118
+
119
+ ### 3. Implement Your State Machine
120
+
121
+ ```python
122
+ from state_machine.core import StateMachine
123
+
124
+ from state_machine.decorators import on_enter_state, auto_timeout, guard, on_state_change
125
+
126
+ class CustomMachine(StateMachine):
127
+
128
+ def __init__(self):
129
+ super().__init__(states=States, transitions=TRANSITIONS)
130
+
131
+ # Automatically trigger to_fault after 5s if no progress
132
+ @on_enter_state(States.running.picking)
133
+ @auto_timeout(5.0, Triggers.to_fault)
134
+ def enter_picking(self, _):
135
+ print("๐Ÿ”น Entering picking")
136
+
137
+ @on_enter_state(States.running.placing)
138
+ def enter_placing(self, _):
139
+ print("๐Ÿ”ธ Entering placing")
140
+
141
+ @on_enter_state(States.running.homing)
142
+ def enter_homing(self, _):
143
+ print("๐Ÿ”บ Entering homing")
144
+
145
+ # Guard condition - only allow reset when safety conditions are met
146
+ @guard(Triggers.reset)
147
+ def check_safety_conditions(self) -> bool:
148
+ """Only allow reset when estop is not pressed."""
149
+ return not self.estop_pressed
150
+
151
+ # Global state change callback for MQTT publishing
152
+ @on_state_change
153
+ def publish_state_to_mqtt(self, old_state: str, new_state: str, trigger: str):
154
+ """Publish state changes to MQTT."""
155
+ mqtt_client.publish("machine/state", {
156
+ "old_state": old_state,
157
+ "new_state": new_state,
158
+ "trigger": trigger
159
+ })
160
+ ```
161
+
162
+ ### 4. Start It
163
+ ```python
164
+ state_machine = StateMachine()
165
+ state_machine.start() # Enters last recorded state (if recovery enabled), else first state
166
+ ```
167
+
168
+ ## ๐ŸŒ Optional FastAPI Router
169
+ This library provides a FastAPI-compatible router that automatically exposes your state machine over HTTP. This is useful for:
170
+
171
+ - Triggering transitions via HTTP POST
172
+ - Inspecting current state and state history
173
+ #### Example
174
+ ```python
175
+ from fastapi import FastAPI
176
+ from state_machine.router import build_router
177
+ from state_machine.core import StateMachine
178
+
179
+ state_machine = StateMachine(...)
180
+ state_machine.start()
181
+
182
+ app = FastAPI()
183
+ app.include_router(build_router(state_machine))
184
+ ```
185
+
186
+ ### Available Routes
187
+ * `GET /state`: Returns current and last known state
188
+ * `GET /history`: Returns list of recent state transitions
189
+ * `POST /<trigger_name>`: Triggers a transition by name
190
+
191
+ You can expose only a subset of triggers by passing them explicitly:
192
+ ```python
193
+ from state_machine.defs import Trigger
194
+ # Only create endpoints for 'start' and 'reset'
195
+ router = build_router(state_machine, triggers=[Trigger("start"), Trigger("reset")])
196
+ ```
197
+
198
+ ## Diagram Visualization
199
+
200
+ - `GET /diagram.svg`: Returns a Graphviz-generated SVG of the state machine.
201
+ - Current state is highlighted in red.
202
+ - Previous state and the transition taken are highlighted in blue.
203
+ - Requires Graphviz installed on the system. If Graphviz is missing, the endpoint returns 503 Service Unavailable.
204
+
205
+ Example usage:
206
+ ```bash
207
+ curl http://localhost:8000/diagram.svg > machine.svg
208
+ open machine.svg
209
+ ```
210
+
211
+ ## ๐Ÿงช Testing & History
212
+ - `state_machine.history`: List of all transitions with timestamps and durations
213
+ - `state_machine.last(n)`: Last `n` transitions
214
+ - `state_machine.record_last_state()`: Manually record current state for later recovery
215
+ - `state_machine.get_last_state()`: Retrieve recorded state
216
+
217
+ ## โฒ Timeout Example
218
+ Any `on_enter_state` method can be wrapped with `@auto_timeout(seconds, trigger_fn)`, e.g.:
219
+
220
+ ```python
221
+ @auto_timeout(5.0, Triggers.to_fault)
222
+ ```
223
+ This automatically triggers `to_fault()` if the state remains active after 5 seconds.
224
+
225
+ ## ๐Ÿ” Recovery Example
226
+ Enable `enable_last_state_recovery=True` and use:
227
+ ```python
228
+ state_machine.start()
229
+ ```
230
+ If a last state was recorded, it will trigger `recover__{last_state}` instead of `start`.
231
+
232
+ ## ๐Ÿงฉ How Decorators Work
233
+
234
+ Decorators attach metadata to your methods:
235
+
236
+ - `@on_enter_state(state)` binds to the state's entry callback
237
+ - `@on_exit_state(state)` binds to the state's exit callback
238
+ - `@auto_timeout(seconds, trigger)` schedules a timeout once the state is entered
239
+ - `@guard(trigger)` adds a condition that must be true for the transition to proceed
240
+ - `@on_state_change` registers a global callback that fires on every state transition
241
+
242
+ The library automatically discovers and wires these up when your machine is initialized.
243
+
244
+ ## ๐Ÿ›ก๏ธ Guard Conditions
245
+
246
+ Guard conditions allow you to block transitions based on runtime conditions. They can be applied to single or multiple triggers:
247
+
248
+ ```python
249
+ # Single trigger
250
+ @guard(Triggers.reset)
251
+ def check_safety_conditions(self) -> bool:
252
+ """Only allow reset when estop is not pressed."""
253
+ return not self.estop_pressed
254
+
255
+ # Multiple triggers - same guard applies to both
256
+ @guard(Triggers.reset, Triggers.start)
257
+ def check_safety_conditions(self) -> bool:
258
+ """Check safety conditions for both reset and start."""
259
+ return not self.estop_pressed and self.safety_system_ok
260
+
261
+ # Multiple guard functions for the same trigger - ALL must pass
262
+ @guard(Triggers.reset)
263
+ def check_estop(self) -> bool:
264
+ return not self.estop_pressed
265
+
266
+ @guard(Triggers.reset)
267
+ def check_safety_system(self) -> bool:
268
+ return self.safety_system_ok
269
+ ```
270
+
271
+ If any guard function returns `False`, the transition is blocked and the state machine remains in its current state. When multiple guard functions are applied to the same trigger, **ALL conditions must pass** for the transition to be allowed.
272
+
273
+ ## ๐Ÿ“ก State Change Callbacks
274
+
275
+ Global state change callbacks are perfect for logging, MQTT publishing, or other side effects. They fire after every successful state transition:
276
+
277
+ ```python
278
+ @on_state_change
279
+ def publish_to_mqtt(self, old_state: str, new_state: str, trigger: str) -> None:
280
+ """Publish state changes to MQTT."""
281
+ mqtt_client.publish("machine/state", {
282
+ "old_state": old_state,
283
+ "new_state": new_state,
284
+ "trigger": trigger,
285
+ "timestamp": datetime.now().isoformat()
286
+ })
287
+
288
+ @on_state_change
289
+ def log_transitions(self, old_state: str, new_state: str, trigger: str) -> None:
290
+ """Log all state transitions."""
291
+ print(f"State change: {old_state} -> {new_state} (trigger: {trigger})")
292
+ ```
293
+
294
+ Multiple state change callbacks can be registered and they will all be called in the order they were defined. Callbacks only fire on **successful transitions** - blocked transitions (due to guard conditions) do not trigger callbacks.
295
+ ```
@@ -0,0 +1,12 @@
1
+ state_machine/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ state_machine/core.py,sha256=lAiGGPtWCiYQPCh1iz3P26YFrz8VvveIGlL5k2AFvKs,11201
3
+ state_machine/decorator_manager.py,sha256=uHhbtR1vS7idcn4kJsaNtJ1Z_7kiHf9YVa_cxkb-WNk,4420
4
+ state_machine/decorator_protocols.py,sha256=E6_xflTPxxTqDBa4Dj4p3OdPvR_EW0jA3PAjHTDlG8c,485
5
+ state_machine/decorators.py,sha256=V-KBJ6fSvINi7JHN_VmrQLTnOxGPy5iRV5QzXLKwWDw,3668
6
+ state_machine/defs.py,sha256=eYtkTqRMm89ZHFKs3p7H3HyGNZbrOnoCzAJbSeMxSDg,2836
7
+ state_machine/machine_protocols.py,sha256=Yxs4aw2-NJ1RluygbthsquVKn35-xMYae7PB7xlqQmE,826
8
+ state_machine/router.py,sha256=tRJyigrGpQo99_dWGdZ-ZirN50s0CbQF_v4kjAXlpWk,5560
9
+ state_machine/utils.py,sha256=z58CBQCsFWwJzPn8ZhoB2y1K2BH8UP7OZ8XaUx9etN8,1833
10
+ vention_state_machine-0.1.0.dist-info/METADATA,sha256=S4sUnTNdkmoY9CmOu4CinhzcTa8Pw6UuELzcO1Sr-oY,9972
11
+ vention_state_machine-0.1.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
12
+ vention_state_machine-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 1.9.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any