vention-state-machine 0.1.0__tar.gz

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.
@@ -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,275 @@
1
+ # vention-state-machine
2
+
3
+ A lightweight wrapper around `transitions` for building async-safe, recoverable hierarchical state machines with minimal boilerplate.
4
+
5
+ ## โœจ Features
6
+
7
+ - Built-in `ready` / `fault` states
8
+ - Global transitions: `to_fault`, `reset`
9
+ - Optional state recovery (`recover__state`)
10
+ - Async task spawning and cancellation
11
+ - Timeouts and auto-fault handling
12
+ - Transition history recording with timestamps + durations
13
+ - Guard conditions for blocking transitions
14
+ - Global state change callbacks for logging/MQTT
15
+
16
+ ## ๐Ÿง  Domain-Specific Language
17
+
18
+ This library uses a **declarative domain-specific language (DSL)** to define state machines in a readable and structured way. The key building blocks are:
19
+
20
+ - **`State`**: Represents a single leaf node in the state machine. Declared as a class attribute.
21
+
22
+ - **`StateGroup`**: A container for related states. It generates a hierarchical namespace for its child states (e.g. `MyGroup.my_state` becomes `"MyGroup_my_state"`).
23
+
24
+ - **`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(...)`).
25
+
26
+ This structure allows you to define states and transitions with strong typing and full IDE support โ€” without strings scattered across your codebase.
27
+
28
+ For Example:
29
+ ```python
30
+ class MyStates(StateGroup):
31
+ idle: State = State()
32
+ working: State = State()
33
+
34
+ class Triggers:
35
+ begin = Trigger("begin")
36
+ finish = Trigger("finish")
37
+ ```
38
+ You can then define transitions declaratively:
39
+ ```python
40
+ TRANSITIONS = [
41
+ Triggers.finish.transition(MyStates.working, MyStates.idle),
42
+ ]
43
+ ```
44
+
45
+ ## ๐Ÿงฑ Base States and Triggers
46
+
47
+ Every machine comes with built-in:
48
+
49
+ - **States**:
50
+ - `ready`: initial state
51
+ - `fault`: global error state
52
+ - **Triggers**:
53
+ - `start`: transition into the first defined state
54
+ - `to_fault`: jump to fault from any state
55
+ - `reset`: recover from fault back to ready
56
+
57
+ You can reference these via:
58
+
59
+ ```python
60
+ from state_machine.core import BaseStates, BaseTriggers
61
+
62
+ state_machine.trigger(BaseTriggers.RESET.value)
63
+ assert state_machine.state == BaseStates.READY.value
64
+ ```
65
+
66
+ ## ๐Ÿš€ Quick Start
67
+ ### 1. Define Your States and Triggers
68
+
69
+ ```python
70
+ from state_machine.defs import StateGroup, State, Trigger
71
+
72
+ class Running(StateGroup):
73
+ picking: State = State()
74
+ placing: State = State()
75
+ homing: State = State()
76
+
77
+ class States:
78
+ running = Running()
79
+
80
+ class Triggers:
81
+ start = Trigger("start")
82
+ finished_picking = Trigger("finished_picking")
83
+ finished_placing = Trigger("finished_placing")
84
+ finished_homing = Trigger("finished_homing")
85
+ to_fault = Trigger("to_fault")
86
+ reset = Trigger("reset")
87
+ ```
88
+
89
+ ### 2. Define Transitions
90
+ ```python
91
+ TRANSITIONS = [
92
+ Triggers.start.transition("ready", States.running.picking),
93
+ Triggers.finished_picking.transition(States.running.picking, States.running.placing),
94
+ Triggers.finished_placing.transition(States.running.placing, States.running.homing),
95
+ Triggers.finished_homing.transition(States.running.homing, States.running.picking)
96
+ ]
97
+ ```
98
+
99
+ ### 3. Implement Your State Machine
100
+
101
+ ```python
102
+ from state_machine.core import StateMachine
103
+
104
+ from state_machine.decorators import on_enter_state, auto_timeout, guard, on_state_change
105
+
106
+ class CustomMachine(StateMachine):
107
+
108
+ def __init__(self):
109
+ super().__init__(states=States, transitions=TRANSITIONS)
110
+
111
+ # Automatically trigger to_fault after 5s if no progress
112
+ @on_enter_state(States.running.picking)
113
+ @auto_timeout(5.0, Triggers.to_fault)
114
+ def enter_picking(self, _):
115
+ print("๐Ÿ”น Entering picking")
116
+
117
+ @on_enter_state(States.running.placing)
118
+ def enter_placing(self, _):
119
+ print("๐Ÿ”ธ Entering placing")
120
+
121
+ @on_enter_state(States.running.homing)
122
+ def enter_homing(self, _):
123
+ print("๐Ÿ”บ Entering homing")
124
+
125
+ # Guard condition - only allow reset when safety conditions are met
126
+ @guard(Triggers.reset)
127
+ def check_safety_conditions(self) -> bool:
128
+ """Only allow reset when estop is not pressed."""
129
+ return not self.estop_pressed
130
+
131
+ # Global state change callback for MQTT publishing
132
+ @on_state_change
133
+ def publish_state_to_mqtt(self, old_state: str, new_state: str, trigger: str):
134
+ """Publish state changes to MQTT."""
135
+ mqtt_client.publish("machine/state", {
136
+ "old_state": old_state,
137
+ "new_state": new_state,
138
+ "trigger": trigger
139
+ })
140
+ ```
141
+
142
+ ### 4. Start It
143
+ ```python
144
+ state_machine = StateMachine()
145
+ state_machine.start() # Enters last recorded state (if recovery enabled), else first state
146
+ ```
147
+
148
+ ## ๐ŸŒ Optional FastAPI Router
149
+ This library provides a FastAPI-compatible router that automatically exposes your state machine over HTTP. This is useful for:
150
+
151
+ - Triggering transitions via HTTP POST
152
+ - Inspecting current state and state history
153
+ #### Example
154
+ ```python
155
+ from fastapi import FastAPI
156
+ from state_machine.router import build_router
157
+ from state_machine.core import StateMachine
158
+
159
+ state_machine = StateMachine(...)
160
+ state_machine.start()
161
+
162
+ app = FastAPI()
163
+ app.include_router(build_router(state_machine))
164
+ ```
165
+
166
+ ### Available Routes
167
+ * `GET /state`: Returns current and last known state
168
+ * `GET /history`: Returns list of recent state transitions
169
+ * `POST /<trigger_name>`: Triggers a transition by name
170
+
171
+ You can expose only a subset of triggers by passing them explicitly:
172
+ ```python
173
+ from state_machine.defs import Trigger
174
+ # Only create endpoints for 'start' and 'reset'
175
+ router = build_router(state_machine, triggers=[Trigger("start"), Trigger("reset")])
176
+ ```
177
+
178
+ ## Diagram Visualization
179
+
180
+ - `GET /diagram.svg`: Returns a Graphviz-generated SVG of the state machine.
181
+ - Current state is highlighted in red.
182
+ - Previous state and the transition taken are highlighted in blue.
183
+ - Requires Graphviz installed on the system. If Graphviz is missing, the endpoint returns 503 Service Unavailable.
184
+
185
+ Example usage:
186
+ ```bash
187
+ curl http://localhost:8000/diagram.svg > machine.svg
188
+ open machine.svg
189
+ ```
190
+
191
+ ## ๐Ÿงช Testing & History
192
+ - `state_machine.history`: List of all transitions with timestamps and durations
193
+ - `state_machine.last(n)`: Last `n` transitions
194
+ - `state_machine.record_last_state()`: Manually record current state for later recovery
195
+ - `state_machine.get_last_state()`: Retrieve recorded state
196
+
197
+ ## โฒ Timeout Example
198
+ Any `on_enter_state` method can be wrapped with `@auto_timeout(seconds, trigger_fn)`, e.g.:
199
+
200
+ ```python
201
+ @auto_timeout(5.0, Triggers.to_fault)
202
+ ```
203
+ This automatically triggers `to_fault()` if the state remains active after 5 seconds.
204
+
205
+ ## ๐Ÿ” Recovery Example
206
+ Enable `enable_last_state_recovery=True` and use:
207
+ ```python
208
+ state_machine.start()
209
+ ```
210
+ If a last state was recorded, it will trigger `recover__{last_state}` instead of `start`.
211
+
212
+ ## ๐Ÿงฉ How Decorators Work
213
+
214
+ Decorators attach metadata to your methods:
215
+
216
+ - `@on_enter_state(state)` binds to the state's entry callback
217
+ - `@on_exit_state(state)` binds to the state's exit callback
218
+ - `@auto_timeout(seconds, trigger)` schedules a timeout once the state is entered
219
+ - `@guard(trigger)` adds a condition that must be true for the transition to proceed
220
+ - `@on_state_change` registers a global callback that fires on every state transition
221
+
222
+ The library automatically discovers and wires these up when your machine is initialized.
223
+
224
+ ## ๐Ÿ›ก๏ธ Guard Conditions
225
+
226
+ Guard conditions allow you to block transitions based on runtime conditions. They can be applied to single or multiple triggers:
227
+
228
+ ```python
229
+ # Single trigger
230
+ @guard(Triggers.reset)
231
+ def check_safety_conditions(self) -> bool:
232
+ """Only allow reset when estop is not pressed."""
233
+ return not self.estop_pressed
234
+
235
+ # Multiple triggers - same guard applies to both
236
+ @guard(Triggers.reset, Triggers.start)
237
+ def check_safety_conditions(self) -> bool:
238
+ """Check safety conditions for both reset and start."""
239
+ return not self.estop_pressed and self.safety_system_ok
240
+
241
+ # Multiple guard functions for the same trigger - ALL must pass
242
+ @guard(Triggers.reset)
243
+ def check_estop(self) -> bool:
244
+ return not self.estop_pressed
245
+
246
+ @guard(Triggers.reset)
247
+ def check_safety_system(self) -> bool:
248
+ return self.safety_system_ok
249
+ ```
250
+
251
+ 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.
252
+
253
+ ## ๐Ÿ“ก State Change Callbacks
254
+
255
+ Global state change callbacks are perfect for logging, MQTT publishing, or other side effects. They fire after every successful state transition:
256
+
257
+ ```python
258
+ @on_state_change
259
+ def publish_to_mqtt(self, old_state: str, new_state: str, trigger: str) -> None:
260
+ """Publish state changes to MQTT."""
261
+ mqtt_client.publish("machine/state", {
262
+ "old_state": old_state,
263
+ "new_state": new_state,
264
+ "trigger": trigger,
265
+ "timestamp": datetime.now().isoformat()
266
+ })
267
+
268
+ @on_state_change
269
+ def log_transitions(self, old_state: str, new_state: str, trigger: str) -> None:
270
+ """Log all state transitions."""
271
+ print(f"State change: {old_state} -> {new_state} (trigger: {trigger})")
272
+ ```
273
+
274
+ 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.
275
+ ```
@@ -0,0 +1,26 @@
1
+ [tool.poetry]
2
+ name = "vention-state-machine"
3
+ version = "0.1.0"
4
+ description = "Declarative state machine framework for machine apps"
5
+ authors = [ "VentionCo" ]
6
+ readme = "README.md"
7
+ license = "Proprietary"
8
+ packages = [{ include = "state_machine", from = "src" }]
9
+
10
+ [tool.poetry.dependencies]
11
+ asyncio = "^3.4.3"
12
+ python = ">=3.9,<3.11"
13
+ fastapi = "^0.116.1"
14
+ uvicorn = "^0.35.0"
15
+ transitions = "^0.9.3"
16
+ graphviz = "^0.21"
17
+ coverage = "^7.10.1"
18
+ httpx = "^0.28.1"
19
+
20
+ [tool.poetry.group.dev.dependencies]
21
+ pytest = "8.3.4"
22
+ ruff = "^0.8.0"
23
+
24
+ [build-system]
25
+ requires = [ "poetry-core>=1.0.0,<2.0.0" ]
26
+ build-backend = "poetry.core.masonry.api"