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.
- state_machine/__init__.py +0 -0
- state_machine/core.py +303 -0
- state_machine/decorator_manager.py +102 -0
- state_machine/decorator_protocols.py +23 -0
- state_machine/decorators.py +116 -0
- state_machine/defs.py +100 -0
- state_machine/machine_protocols.py +33 -0
- state_machine/router.py +181 -0
- state_machine/utils.py +56 -0
- vention_state_machine-0.1.0.dist-info/METADATA +295 -0
- vention_state_machine-0.1.0.dist-info/RECORD +12 -0
- vention_state_machine-0.1.0.dist-info/WHEEL +4 -0
|
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."""
|
state_machine/router.py
ADDED
|
@@ -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,,
|