vention-state-machine 0.3.1__tar.gz → 0.4.2__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.
- vention_state_machine-0.3.1/README.md → vention_state_machine-0.4.2/PKG-INFO +76 -26
- vention_state_machine-0.3.1/PKG-INFO → vention_state_machine-0.4.2/README.md +41 -44
- vention_state_machine-0.4.2/pyproject.toml +127 -0
- {vention_state_machine-0.3.1 → vention_state_machine-0.4.2}/src/state_machine/core.py +7 -24
- {vention_state_machine-0.3.1 → vention_state_machine-0.4.2}/src/state_machine/decorator_manager.py +3 -9
- {vention_state_machine-0.3.1 → vention_state_machine-0.4.2}/src/state_machine/decorators.py +2 -8
- {vention_state_machine-0.3.1 → vention_state_machine-0.4.2}/src/state_machine/defs.py +1 -3
- {vention_state_machine-0.3.1 → vention_state_machine-0.4.2}/src/state_machine/machine_protocols.py +3 -9
- {vention_state_machine-0.3.1 → vention_state_machine-0.4.2}/src/state_machine/utils.py +29 -6
- vention_state_machine-0.4.2/src/state_machine/vention_communication.py +164 -0
- vention_state_machine-0.3.1/pyproject.toml +0 -26
- vention_state_machine-0.3.1/src/state_machine/router.py +0 -181
- {vention_state_machine-0.3.1 → vention_state_machine-0.4.2}/src/state_machine/__init__.py +0 -0
- {vention_state_machine-0.3.1 → vention_state_machine-0.4.2}/src/state_machine/decorator_protocols.py +0 -0
|
@@ -1,3 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vention-state-machine
|
|
3
|
+
Version: 0.4.2
|
|
4
|
+
Summary: Declarative state machine framework for machine apps
|
|
5
|
+
License: Proprietary
|
|
6
|
+
Author: VentionCo
|
|
7
|
+
Requires-Python: >=3.10,<3.11
|
|
8
|
+
Classifier: License :: Other/Proprietary License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Requires-Dist: annotated-doc (==0.0.4) ; python_version == "3.10"
|
|
12
|
+
Requires-Dist: annotated-types (==0.7.0) ; python_version == "3.10"
|
|
13
|
+
Requires-Dist: anyio (==4.11.0) ; python_version == "3.10"
|
|
14
|
+
Requires-Dist: asyncio (==3.4.3) ; python_version == "3.10"
|
|
15
|
+
Requires-Dist: click (==8.1.8) ; python_version == "3.10"
|
|
16
|
+
Requires-Dist: colorama (==0.4.6) ; python_version == "3.10" and platform_system == "Windows"
|
|
17
|
+
Requires-Dist: coverage (==7.10.7) ; python_version == "3.10"
|
|
18
|
+
Requires-Dist: exceptiongroup (==1.3.0) ; python_version == "3.10"
|
|
19
|
+
Requires-Dist: fastapi (==0.121.1) ; python_version == "3.10"
|
|
20
|
+
Requires-Dist: graphviz (==0.21) ; python_version == "3.10"
|
|
21
|
+
Requires-Dist: h11 (==0.16.0) ; python_version == "3.10"
|
|
22
|
+
Requires-Dist: idna (==3.11) ; python_version == "3.10"
|
|
23
|
+
Requires-Dist: pydantic (==2.12.3) ; python_version == "3.10"
|
|
24
|
+
Requires-Dist: pydantic-core (==2.41.4) ; python_version == "3.10"
|
|
25
|
+
Requires-Dist: six (==1.17.0) ; python_version == "3.10"
|
|
26
|
+
Requires-Dist: sniffio (==1.3.1) ; python_version == "3.10"
|
|
27
|
+
Requires-Dist: starlette (==0.48.0) ; python_version == "3.10"
|
|
28
|
+
Requires-Dist: transitions (==0.9.3) ; python_version == "3.10"
|
|
29
|
+
Requires-Dist: typing-extensions (==4.15.0) ; python_version == "3.10"
|
|
30
|
+
Requires-Dist: typing-inspection (==0.4.2) ; python_version == "3.10"
|
|
31
|
+
Requires-Dist: uvicorn (==0.35.0) ; python_version == "3.10"
|
|
32
|
+
Requires-Dist: vention-communication (==0.3.0) ; python_version == "3.10"
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
1
35
|
# vention-state-machine
|
|
2
36
|
|
|
3
37
|
A lightweight wrapper around `transitions` for building async-safe, recoverable hierarchical state machines with minimal boilerplate.
|
|
@@ -22,7 +56,7 @@ A lightweight wrapper around `transitions` for building async-safe, recoverable
|
|
|
22
56
|
- Transition history recording with timestamps + durations
|
|
23
57
|
- Guard conditions for blocking transitions
|
|
24
58
|
- Global state change callbacks for logging/MQTT
|
|
25
|
-
- Optional
|
|
59
|
+
- Optional RPC bundle for exposing state machine via Connect RPCs
|
|
26
60
|
|
|
27
61
|
## 🧠 Concepts & Overview
|
|
28
62
|
|
|
@@ -75,20 +109,20 @@ pip install vention-state-machine
|
|
|
75
109
|
|
|
76
110
|
**Optional dependencies:**
|
|
77
111
|
- Graphviz (required for diagram generation)
|
|
78
|
-
-
|
|
112
|
+
- vention-communication (for RPC bundle integration)
|
|
79
113
|
|
|
80
114
|
**Install optional tools:**
|
|
81
115
|
|
|
82
116
|
MacOS:
|
|
83
117
|
```bash
|
|
84
118
|
brew install graphviz
|
|
85
|
-
pip install
|
|
119
|
+
pip install vention-communication
|
|
86
120
|
```
|
|
87
121
|
|
|
88
122
|
Linux (Debian/Ubuntu)
|
|
89
123
|
```bash
|
|
90
124
|
sudo apt-get install graphviz
|
|
91
|
-
pip install
|
|
125
|
+
pip install vention-communication
|
|
92
126
|
```
|
|
93
127
|
|
|
94
128
|
|
|
@@ -172,25 +206,37 @@ state_machine.start()
|
|
|
172
206
|
|
|
173
207
|
## 🛠 How-to Guides
|
|
174
208
|
|
|
175
|
-
### Expose Over
|
|
209
|
+
### Expose Over RPC with VentionApp
|
|
176
210
|
|
|
177
211
|
```python
|
|
178
|
-
from
|
|
179
|
-
from state_machine.
|
|
212
|
+
from communication.app import VentionApp
|
|
213
|
+
from state_machine.vention_communication import build_state_machine_bundle
|
|
180
214
|
from state_machine.core import StateMachine
|
|
181
215
|
|
|
182
216
|
state_machine = StateMachine(...)
|
|
183
217
|
state_machine.start()
|
|
184
218
|
|
|
185
|
-
app =
|
|
186
|
-
|
|
219
|
+
app = VentionApp(name="MyApp")
|
|
220
|
+
bundle = build_state_machine_bundle(state_machine)
|
|
221
|
+
app.register_rpc_plugin(bundle)
|
|
222
|
+
app.finalize()
|
|
187
223
|
```
|
|
188
224
|
|
|
189
|
-
**
|
|
190
|
-
- `
|
|
191
|
-
- `
|
|
192
|
-
- `
|
|
193
|
-
|
|
225
|
+
**RPC Actions:**
|
|
226
|
+
- `GetState` → Returns current state and last known state
|
|
227
|
+
- `GetHistory` → Returns transition history with timestamps
|
|
228
|
+
- `Trigger_<TriggerName>` → Triggers a state transition (e.g., `Trigger_Start`, `Trigger_Activate`)
|
|
229
|
+
|
|
230
|
+
**Options:**
|
|
231
|
+
```python
|
|
232
|
+
# Customize which actions are included
|
|
233
|
+
bundle = build_state_machine_bundle(
|
|
234
|
+
state_machine,
|
|
235
|
+
include_state_actions=True, # Include GetState
|
|
236
|
+
include_history_action=True, # Include GetHistory
|
|
237
|
+
triggers=["start", "activate"], # Only include specific triggers
|
|
238
|
+
)
|
|
239
|
+
```
|
|
194
240
|
|
|
195
241
|
### Timeout Example
|
|
196
242
|
|
|
@@ -350,24 +396,28 @@ Guard transition; blocks if function returns False.
|
|
|
350
396
|
**`@on_state_change`**
|
|
351
397
|
Global callback `(old_state, new_state, trigger)` fired after each transition.
|
|
352
398
|
|
|
353
|
-
###
|
|
399
|
+
### RPC Bundle
|
|
354
400
|
|
|
355
401
|
```python
|
|
356
|
-
def
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
402
|
+
def build_state_machine_bundle(
|
|
403
|
+
sm: StateMachine,
|
|
404
|
+
*,
|
|
405
|
+
include_state_actions: bool = True,
|
|
406
|
+
include_history_action: bool = True,
|
|
407
|
+
triggers: Optional[Sequence[str]] = None,
|
|
408
|
+
) -> RpcBundle
|
|
360
409
|
```
|
|
361
410
|
|
|
362
|
-
|
|
363
|
-
- `
|
|
364
|
-
- `
|
|
365
|
-
- `
|
|
366
|
-
|
|
411
|
+
Builds an RPC bundle exposing the state machine via Connect-style RPCs:
|
|
412
|
+
- `GetState` - Returns current and last known state
|
|
413
|
+
- `GetHistory` - Returns transition history
|
|
414
|
+
- `Trigger_<TriggerName>` - One RPC per trigger (PascalCase naming)
|
|
415
|
+
|
|
416
|
+
The bundle can be registered with a `VentionApp` using `app.register_rpc_plugin(bundle)`.
|
|
367
417
|
|
|
368
418
|
## 🔍 Troubleshooting & FAQ
|
|
369
419
|
|
|
370
|
-
- **Diagram endpoint returns 503** → Graphviz not installed.
|
|
371
420
|
- **Transitions blocked unexpectedly** → Check guard conditions.
|
|
372
421
|
- **Callbacks not firing** → Only successful transitions trigger them.
|
|
373
|
-
- **State not restored after restart** → Ensure `enable_last_state_recovery=True`.
|
|
422
|
+
- **State not restored after restart** → Ensure `enable_last_state_recovery=True`.
|
|
423
|
+
- **RPC actions not available** → Ensure `app.finalize()` is called after registering bundles.
|
|
@@ -1,22 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.1
|
|
2
|
-
Name: vention-state-machine
|
|
3
|
-
Version: 0.3.1
|
|
4
|
-
Summary: Declarative state machine framework for machine apps
|
|
5
|
-
License: Proprietary
|
|
6
|
-
Author: VentionCo
|
|
7
|
-
Requires-Python: >=3.10,<3.11
|
|
8
|
-
Classifier: License :: Other/Proprietary License
|
|
9
|
-
Classifier: Programming Language :: Python :: 3
|
|
10
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
-
Requires-Dist: asyncio (>=3.4.3,<4.0.0)
|
|
12
|
-
Requires-Dist: coverage (>=7.10.1,<8.0.0)
|
|
13
|
-
Requires-Dist: fastapi (==0.121.1)
|
|
14
|
-
Requires-Dist: graphviz (>=0.21,<0.22)
|
|
15
|
-
Requires-Dist: httpx (>=0.28.1,<0.29.0)
|
|
16
|
-
Requires-Dist: transitions (>=0.9.3,<0.10.0)
|
|
17
|
-
Requires-Dist: uvicorn (>=0.35.0,<0.36.0)
|
|
18
|
-
Description-Content-Type: text/markdown
|
|
19
|
-
|
|
20
1
|
# vention-state-machine
|
|
21
2
|
|
|
22
3
|
A lightweight wrapper around `transitions` for building async-safe, recoverable hierarchical state machines with minimal boilerplate.
|
|
@@ -41,7 +22,7 @@ A lightweight wrapper around `transitions` for building async-safe, recoverable
|
|
|
41
22
|
- Transition history recording with timestamps + durations
|
|
42
23
|
- Guard conditions for blocking transitions
|
|
43
24
|
- Global state change callbacks for logging/MQTT
|
|
44
|
-
- Optional
|
|
25
|
+
- Optional RPC bundle for exposing state machine via Connect RPCs
|
|
45
26
|
|
|
46
27
|
## 🧠 Concepts & Overview
|
|
47
28
|
|
|
@@ -94,20 +75,20 @@ pip install vention-state-machine
|
|
|
94
75
|
|
|
95
76
|
**Optional dependencies:**
|
|
96
77
|
- Graphviz (required for diagram generation)
|
|
97
|
-
-
|
|
78
|
+
- vention-communication (for RPC bundle integration)
|
|
98
79
|
|
|
99
80
|
**Install optional tools:**
|
|
100
81
|
|
|
101
82
|
MacOS:
|
|
102
83
|
```bash
|
|
103
84
|
brew install graphviz
|
|
104
|
-
pip install
|
|
85
|
+
pip install vention-communication
|
|
105
86
|
```
|
|
106
87
|
|
|
107
88
|
Linux (Debian/Ubuntu)
|
|
108
89
|
```bash
|
|
109
90
|
sudo apt-get install graphviz
|
|
110
|
-
pip install
|
|
91
|
+
pip install vention-communication
|
|
111
92
|
```
|
|
112
93
|
|
|
113
94
|
|
|
@@ -191,25 +172,37 @@ state_machine.start()
|
|
|
191
172
|
|
|
192
173
|
## 🛠 How-to Guides
|
|
193
174
|
|
|
194
|
-
### Expose Over
|
|
175
|
+
### Expose Over RPC with VentionApp
|
|
195
176
|
|
|
196
177
|
```python
|
|
197
|
-
from
|
|
198
|
-
from state_machine.
|
|
178
|
+
from communication.app import VentionApp
|
|
179
|
+
from state_machine.vention_communication import build_state_machine_bundle
|
|
199
180
|
from state_machine.core import StateMachine
|
|
200
181
|
|
|
201
182
|
state_machine = StateMachine(...)
|
|
202
183
|
state_machine.start()
|
|
203
184
|
|
|
204
|
-
app =
|
|
205
|
-
|
|
185
|
+
app = VentionApp(name="MyApp")
|
|
186
|
+
bundle = build_state_machine_bundle(state_machine)
|
|
187
|
+
app.register_rpc_plugin(bundle)
|
|
188
|
+
app.finalize()
|
|
206
189
|
```
|
|
207
190
|
|
|
208
|
-
**
|
|
209
|
-
- `
|
|
210
|
-
- `
|
|
211
|
-
- `
|
|
212
|
-
|
|
191
|
+
**RPC Actions:**
|
|
192
|
+
- `GetState` → Returns current state and last known state
|
|
193
|
+
- `GetHistory` → Returns transition history with timestamps
|
|
194
|
+
- `Trigger_<TriggerName>` → Triggers a state transition (e.g., `Trigger_Start`, `Trigger_Activate`)
|
|
195
|
+
|
|
196
|
+
**Options:**
|
|
197
|
+
```python
|
|
198
|
+
# Customize which actions are included
|
|
199
|
+
bundle = build_state_machine_bundle(
|
|
200
|
+
state_machine,
|
|
201
|
+
include_state_actions=True, # Include GetState
|
|
202
|
+
include_history_action=True, # Include GetHistory
|
|
203
|
+
triggers=["start", "activate"], # Only include specific triggers
|
|
204
|
+
)
|
|
205
|
+
```
|
|
213
206
|
|
|
214
207
|
### Timeout Example
|
|
215
208
|
|
|
@@ -369,24 +362,28 @@ Guard transition; blocks if function returns False.
|
|
|
369
362
|
**`@on_state_change`**
|
|
370
363
|
Global callback `(old_state, new_state, trigger)` fired after each transition.
|
|
371
364
|
|
|
372
|
-
###
|
|
365
|
+
### RPC Bundle
|
|
373
366
|
|
|
374
367
|
```python
|
|
375
|
-
def
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
368
|
+
def build_state_machine_bundle(
|
|
369
|
+
sm: StateMachine,
|
|
370
|
+
*,
|
|
371
|
+
include_state_actions: bool = True,
|
|
372
|
+
include_history_action: bool = True,
|
|
373
|
+
triggers: Optional[Sequence[str]] = None,
|
|
374
|
+
) -> RpcBundle
|
|
379
375
|
```
|
|
380
376
|
|
|
381
|
-
|
|
382
|
-
- `
|
|
383
|
-
- `
|
|
384
|
-
- `
|
|
385
|
-
|
|
377
|
+
Builds an RPC bundle exposing the state machine via Connect-style RPCs:
|
|
378
|
+
- `GetState` - Returns current and last known state
|
|
379
|
+
- `GetHistory` - Returns transition history
|
|
380
|
+
- `Trigger_<TriggerName>` - One RPC per trigger (PascalCase naming)
|
|
381
|
+
|
|
382
|
+
The bundle can be registered with a `VentionApp` using `app.register_rpc_plugin(bundle)`.
|
|
386
383
|
|
|
387
384
|
## 🔍 Troubleshooting & FAQ
|
|
388
385
|
|
|
389
|
-
- **Diagram endpoint returns 503** → Graphviz not installed.
|
|
390
386
|
- **Transitions blocked unexpectedly** → Check guard conditions.
|
|
391
387
|
- **Callbacks not firing** → Only successful transitions trigger them.
|
|
392
388
|
- **State not restored after restart** → Ensure `enable_last_state_recovery=True`.
|
|
389
|
+
- **RPC actions not available** → Ensure `app.finalize()` is called after registering bundles.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "vention-state-machine"
|
|
3
|
+
version = "0.4.2"
|
|
4
|
+
description = "Declarative state machine framework for machine apps"
|
|
5
|
+
authors = [ "VentionCo" ]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "Proprietary"
|
|
8
|
+
|
|
9
|
+
[[tool.poetry.packages]]
|
|
10
|
+
include = "state_machine"
|
|
11
|
+
from = "src"
|
|
12
|
+
|
|
13
|
+
[tool.poetry.dependencies]
|
|
14
|
+
python = ">=3.10,<3.11"
|
|
15
|
+
|
|
16
|
+
[tool.poetry.dependencies.annotated-doc]
|
|
17
|
+
version = "0.0.4"
|
|
18
|
+
markers = 'python_version == "3.10"'
|
|
19
|
+
optional = false
|
|
20
|
+
|
|
21
|
+
[tool.poetry.dependencies.annotated-types]
|
|
22
|
+
version = "0.7.0"
|
|
23
|
+
markers = 'python_version == "3.10"'
|
|
24
|
+
optional = false
|
|
25
|
+
|
|
26
|
+
[tool.poetry.dependencies.anyio]
|
|
27
|
+
version = "4.11.0"
|
|
28
|
+
markers = 'python_version == "3.10"'
|
|
29
|
+
optional = false
|
|
30
|
+
|
|
31
|
+
[tool.poetry.dependencies.asyncio]
|
|
32
|
+
version = "3.4.3"
|
|
33
|
+
markers = 'python_version == "3.10"'
|
|
34
|
+
optional = false
|
|
35
|
+
|
|
36
|
+
[tool.poetry.dependencies.click]
|
|
37
|
+
version = "8.1.8"
|
|
38
|
+
markers = 'python_version == "3.10"'
|
|
39
|
+
optional = false
|
|
40
|
+
|
|
41
|
+
[tool.poetry.dependencies.colorama]
|
|
42
|
+
version = "0.4.6"
|
|
43
|
+
markers = 'python_version == "3.10" and platform_system == "Windows"'
|
|
44
|
+
optional = false
|
|
45
|
+
|
|
46
|
+
[tool.poetry.dependencies.coverage]
|
|
47
|
+
version = "7.10.7"
|
|
48
|
+
markers = 'python_version == "3.10"'
|
|
49
|
+
optional = false
|
|
50
|
+
|
|
51
|
+
[tool.poetry.dependencies.exceptiongroup]
|
|
52
|
+
version = "1.3.0"
|
|
53
|
+
markers = 'python_version == "3.10"'
|
|
54
|
+
optional = false
|
|
55
|
+
|
|
56
|
+
[tool.poetry.dependencies.fastapi]
|
|
57
|
+
version = "0.121.1"
|
|
58
|
+
markers = 'python_version == "3.10"'
|
|
59
|
+
optional = false
|
|
60
|
+
|
|
61
|
+
[tool.poetry.dependencies.graphviz]
|
|
62
|
+
version = "0.21"
|
|
63
|
+
markers = 'python_version == "3.10"'
|
|
64
|
+
optional = false
|
|
65
|
+
|
|
66
|
+
[tool.poetry.dependencies.h11]
|
|
67
|
+
version = "0.16.0"
|
|
68
|
+
markers = 'python_version == "3.10"'
|
|
69
|
+
optional = false
|
|
70
|
+
|
|
71
|
+
[tool.poetry.dependencies.idna]
|
|
72
|
+
version = "3.11"
|
|
73
|
+
markers = 'python_version == "3.10"'
|
|
74
|
+
optional = false
|
|
75
|
+
|
|
76
|
+
[tool.poetry.dependencies.pydantic-core]
|
|
77
|
+
version = "2.41.4"
|
|
78
|
+
markers = 'python_version == "3.10"'
|
|
79
|
+
optional = false
|
|
80
|
+
|
|
81
|
+
[tool.poetry.dependencies.pydantic]
|
|
82
|
+
version = "2.12.3"
|
|
83
|
+
markers = 'python_version == "3.10"'
|
|
84
|
+
optional = false
|
|
85
|
+
|
|
86
|
+
[tool.poetry.dependencies.six]
|
|
87
|
+
version = "1.17.0"
|
|
88
|
+
markers = 'python_version == "3.10"'
|
|
89
|
+
optional = false
|
|
90
|
+
|
|
91
|
+
[tool.poetry.dependencies.sniffio]
|
|
92
|
+
version = "1.3.1"
|
|
93
|
+
markers = 'python_version == "3.10"'
|
|
94
|
+
optional = false
|
|
95
|
+
|
|
96
|
+
[tool.poetry.dependencies.starlette]
|
|
97
|
+
version = "0.48.0"
|
|
98
|
+
markers = 'python_version == "3.10"'
|
|
99
|
+
optional = false
|
|
100
|
+
|
|
101
|
+
[tool.poetry.dependencies.transitions]
|
|
102
|
+
version = "0.9.3"
|
|
103
|
+
markers = 'python_version == "3.10"'
|
|
104
|
+
optional = false
|
|
105
|
+
|
|
106
|
+
[tool.poetry.dependencies.typing-extensions]
|
|
107
|
+
version = "4.15.0"
|
|
108
|
+
markers = 'python_version == "3.10"'
|
|
109
|
+
optional = false
|
|
110
|
+
|
|
111
|
+
[tool.poetry.dependencies.typing-inspection]
|
|
112
|
+
version = "0.4.2"
|
|
113
|
+
markers = 'python_version == "3.10"'
|
|
114
|
+
optional = false
|
|
115
|
+
|
|
116
|
+
[tool.poetry.dependencies.uvicorn]
|
|
117
|
+
version = "0.35.0"
|
|
118
|
+
markers = 'python_version == "3.10"'
|
|
119
|
+
optional = false
|
|
120
|
+
|
|
121
|
+
[tool.poetry.dependencies.vention-communication]
|
|
122
|
+
version = "0.3.0"
|
|
123
|
+
markers = 'python_version == "3.10"'
|
|
124
|
+
optional = false
|
|
125
|
+
|
|
126
|
+
[tool.poetry.group.dev]
|
|
127
|
+
dependencies = { }
|
|
@@ -58,19 +58,12 @@ class StateMachine(HierarchicalGraphMachine):
|
|
|
58
58
|
) -> None:
|
|
59
59
|
# Normalize state definitions
|
|
60
60
|
if is_state_container(states):
|
|
61
|
-
state_groups = [
|
|
62
|
-
value
|
|
63
|
-
for value in vars(states).values()
|
|
64
|
-
if isinstance(value, StateGroup)
|
|
65
|
-
]
|
|
61
|
+
state_groups = [value for value in vars(states).values() if isinstance(value, StateGroup)]
|
|
66
62
|
resolved_states = [group.to_state_list()[0] for group in state_groups]
|
|
67
63
|
elif isinstance(states, list):
|
|
68
64
|
resolved_states = states
|
|
69
65
|
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
|
-
)
|
|
66
|
+
raise TypeError(f"`states` must be either a StateGroup container or a list of state dicts. " f"Got: {type(states).__name__}")
|
|
74
67
|
|
|
75
68
|
self._declared_states: list[dict[str, Any]] = resolved_states
|
|
76
69
|
|
|
@@ -95,9 +88,7 @@ class StateMachine(HierarchicalGraphMachine):
|
|
|
95
88
|
self._timeouts: dict[str, asyncio.Task[Any]] = {}
|
|
96
89
|
self._last_state: Optional[str] = None
|
|
97
90
|
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
|
-
)
|
|
91
|
+
self._history: deque[dict[str, Any]] = deque(maxlen=history_size or self.DEFAULT_HISTORY_SIZE)
|
|
101
92
|
self._current_start: Optional[datetime] = None
|
|
102
93
|
self._guard_conditions: dict[str, List[Callable[[], bool]]] = {}
|
|
103
94
|
self._state_change_callbacks: List[Callable[[str, str, str], None]] = []
|
|
@@ -110,9 +101,7 @@ class StateMachine(HierarchicalGraphMachine):
|
|
|
110
101
|
BaseStates.FAULT.value,
|
|
111
102
|
before="cancel_tasks",
|
|
112
103
|
)
|
|
113
|
-
self.add_transition(
|
|
114
|
-
BaseTriggers.RESET.value, BaseStates.FAULT.value, BaseStates.READY.value
|
|
115
|
-
)
|
|
104
|
+
self.add_transition(BaseTriggers.RESET.value, BaseStates.FAULT.value, BaseStates.READY.value)
|
|
116
105
|
self._attach_after_hooks()
|
|
117
106
|
self._add_guard_conditions_to_transitions()
|
|
118
107
|
|
|
@@ -137,9 +126,7 @@ class StateMachine(HierarchicalGraphMachine):
|
|
|
137
126
|
except asyncio.CancelledError:
|
|
138
127
|
pass
|
|
139
128
|
|
|
140
|
-
def set_timeout(
|
|
141
|
-
self, state_name: str, seconds: float, trigger_fn: Callable[[], str]
|
|
142
|
-
) -> None:
|
|
129
|
+
def set_timeout(self, state_name: str, seconds: float, trigger_fn: Callable[[], str]) -> None:
|
|
143
130
|
"""
|
|
144
131
|
Schedule a timeout: if `state_name` remains active after `seconds`, fire `trigger_fn()`.
|
|
145
132
|
"""
|
|
@@ -191,9 +178,7 @@ class StateMachine(HierarchicalGraphMachine):
|
|
|
191
178
|
"""Get the last `n` history entries."""
|
|
192
179
|
return list(self._history)[-n:] if n > 0 else []
|
|
193
180
|
|
|
194
|
-
def add_transition_condition(
|
|
195
|
-
self, trigger_name: str, condition_fn: Callable[[], bool]
|
|
196
|
-
) -> None:
|
|
181
|
+
def add_transition_condition(self, trigger_name: str, condition_fn: Callable[[], bool]) -> None:
|
|
197
182
|
"""
|
|
198
183
|
Add a guard condition to a transition trigger.
|
|
199
184
|
Multiple conditions can be added for the same trigger - ALL must pass for the transition to be allowed.
|
|
@@ -202,9 +187,7 @@ class StateMachine(HierarchicalGraphMachine):
|
|
|
202
187
|
self._guard_conditions[trigger_name] = []
|
|
203
188
|
self._guard_conditions[trigger_name].append(condition_fn)
|
|
204
189
|
|
|
205
|
-
def add_state_change_callback(
|
|
206
|
-
self, callback: Callable[[str, str, str], None]
|
|
207
|
-
) -> None:
|
|
190
|
+
def add_state_change_callback(self, callback: Callable[[str, str, str], None]) -> None:
|
|
208
191
|
"""
|
|
209
192
|
Add a callback that fires on any state change.
|
|
210
193
|
|
{vention_state_machine-0.3.1 → vention_state_machine-0.4.2}/src/state_machine/decorator_manager.py
RENAMED
|
@@ -38,14 +38,10 @@ class DecoratorManager:
|
|
|
38
38
|
def _discover_state_callbacks(self, callback_fn: Any) -> None:
|
|
39
39
|
"""Discover on_enter_state and on_exit_state decorators."""
|
|
40
40
|
if hasattr(callback_fn, "_on_enter_state"):
|
|
41
|
-
self._decorator_bindings.append(
|
|
42
|
-
(callback_fn._on_enter_state, "enter", callback_fn)
|
|
43
|
-
)
|
|
41
|
+
self._decorator_bindings.append((callback_fn._on_enter_state, "enter", callback_fn))
|
|
44
42
|
|
|
45
43
|
if hasattr(callback_fn, "_on_exit_state"):
|
|
46
|
-
self._exit_decorator_bindings.append(
|
|
47
|
-
(callback_fn._on_exit_state, "exit", callback_fn)
|
|
48
|
-
)
|
|
44
|
+
self._exit_decorator_bindings.append((callback_fn._on_exit_state, "exit", callback_fn))
|
|
49
45
|
|
|
50
46
|
def _discover_guard_conditions(self, callback_fn: Any) -> None:
|
|
51
47
|
"""Discover guard decorators."""
|
|
@@ -70,9 +66,7 @@ class DecoratorManager:
|
|
|
70
66
|
|
|
71
67
|
def _bind_state_callbacks(self, instance: Any) -> None:
|
|
72
68
|
"""Bind state entry/exit callbacks."""
|
|
73
|
-
for state_name, hook_type, callback_fn in
|
|
74
|
-
self._decorator_bindings + self._exit_decorator_bindings
|
|
75
|
-
):
|
|
69
|
+
for state_name, hook_type, callback_fn in self._decorator_bindings + self._exit_decorator_bindings:
|
|
76
70
|
bound_fn = callback_fn.__get__(instance)
|
|
77
71
|
|
|
78
72
|
if hook_type == "enter" and hasattr(callback_fn, "_timeout_config"):
|
|
@@ -54,9 +54,7 @@ def on_exit_state(
|
|
|
54
54
|
return decorator
|
|
55
55
|
|
|
56
56
|
|
|
57
|
-
def auto_timeout(
|
|
58
|
-
seconds: float, trigger: Union[str, Callable[[], str]] = "to_fault"
|
|
59
|
-
) -> Callable[[CallableType], CallableType]:
|
|
57
|
+
def auto_timeout(seconds: float, trigger: Union[str, Callable[[], str]] = "to_fault") -> Callable[[CallableType], CallableType]:
|
|
60
58
|
"""
|
|
61
59
|
Decorator that applies an auto-timeout configuration to a state entry handler.
|
|
62
60
|
"""
|
|
@@ -90,11 +88,7 @@ def guard(
|
|
|
90
88
|
guard_fn._guard_conditions = {}
|
|
91
89
|
|
|
92
90
|
for trigger in triggers:
|
|
93
|
-
trigger_name = (
|
|
94
|
-
getattr(trigger, "name", trigger)
|
|
95
|
-
if hasattr(trigger, "name")
|
|
96
|
-
else trigger
|
|
97
|
-
)
|
|
91
|
+
trigger_name = getattr(trigger, "name", trigger) if hasattr(trigger, "name") else trigger
|
|
98
92
|
if not isinstance(trigger_name, str):
|
|
99
93
|
raise TypeError(f"Expected a Trigger or str, got {type(trigger)}")
|
|
100
94
|
|
|
@@ -88,9 +88,7 @@ class Trigger:
|
|
|
88
88
|
def __call__(self) -> str:
|
|
89
89
|
return self.name
|
|
90
90
|
|
|
91
|
-
def transition(
|
|
92
|
-
self, source: Union[str, State], dest: Union[str, State]
|
|
93
|
-
) -> Dict[str, str]:
|
|
91
|
+
def transition(self, source: Union[str, State], dest: Union[str, State]) -> Dict[str, str]:
|
|
94
92
|
"""
|
|
95
93
|
Build {"trigger": self.name, "source": <src>, "dest": <dst>}.
|
|
96
94
|
`source` / `dest` may be raw strings or StateKey instances.
|
{vention_state_machine-0.3.1 → vention_state_machine-0.4.2}/src/state_machine/machine_protocols.py
RENAMED
|
@@ -2,9 +2,7 @@ from typing import Protocol, Callable, Any
|
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
class SupportsTimeout(Protocol):
|
|
5
|
-
def set_timeout(
|
|
6
|
-
self, state_name: str, seconds: float, trigger_fn: Callable[[], str]
|
|
7
|
-
) -> None: ...
|
|
5
|
+
def set_timeout(self, state_name: str, seconds: float, trigger_fn: Callable[[], str]) -> None: ...
|
|
8
6
|
|
|
9
7
|
|
|
10
8
|
class SupportsStateCallbacks(Protocol):
|
|
@@ -12,15 +10,11 @@ class SupportsStateCallbacks(Protocol):
|
|
|
12
10
|
|
|
13
11
|
|
|
14
12
|
class SupportsGuardConditions(Protocol):
|
|
15
|
-
def add_transition_condition(
|
|
16
|
-
self, trigger_name: str, condition_fn: Callable[[], bool]
|
|
17
|
-
) -> None: ...
|
|
13
|
+
def add_transition_condition(self, trigger_name: str, condition_fn: Callable[[], bool]) -> None: ...
|
|
18
14
|
|
|
19
15
|
|
|
20
16
|
class SupportsStateChangeCallbacks(Protocol):
|
|
21
|
-
def add_state_change_callback(
|
|
22
|
-
self, callback: Callable[[str, str, str], None]
|
|
23
|
-
) -> None: ...
|
|
17
|
+
def add_state_change_callback(self, callback: Callable[[str, str, str], None]) -> None: ...
|
|
24
18
|
|
|
25
19
|
|
|
26
20
|
class StateMachineProtocol(
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
from
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Any, Callable, Optional, Union
|
|
3
|
+
from pydantic import BaseModel
|
|
2
4
|
from state_machine.defs import StateGroup
|
|
3
5
|
|
|
4
6
|
|
|
@@ -46,11 +48,32 @@ def is_state_container(state_source: object) -> bool:
|
|
|
46
48
|
Return True if `obj` is an instance or class that contains StateGroup(s).
|
|
47
49
|
"""
|
|
48
50
|
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()
|
|
51
|
+
return any(isinstance(value, StateGroup) for value in vars(state_source).values()) or any(
|
|
52
|
+
isinstance(value, StateGroup) for value in vars(state_source.__class__).values()
|
|
54
53
|
)
|
|
55
54
|
except TypeError:
|
|
56
55
|
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Shared schema definitions for REST and RPC endpoints
|
|
59
|
+
# (camelCase aliases applied automatically by registry)
|
|
60
|
+
class StateResponse(BaseModel):
|
|
61
|
+
state: str
|
|
62
|
+
last_state: Optional[str] = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class HistoryEntry(BaseModel):
|
|
66
|
+
timestamp: datetime
|
|
67
|
+
state: str
|
|
68
|
+
duration_ms: Optional[int] = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class HistoryResponse(BaseModel):
|
|
72
|
+
history: list[HistoryEntry]
|
|
73
|
+
buffer_size: int
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TriggerResponse(BaseModel):
|
|
77
|
+
result: str
|
|
78
|
+
previous_state: str
|
|
79
|
+
new_state: str
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any, Callable, Coroutine, Optional, Sequence
|
|
6
|
+
|
|
7
|
+
from communication.entries import RpcBundle, ActionEntry
|
|
8
|
+
from communication.errors import ConnectError
|
|
9
|
+
from state_machine.core import StateMachine
|
|
10
|
+
from state_machine.utils import (
|
|
11
|
+
StateResponse,
|
|
12
|
+
HistoryResponse,
|
|
13
|
+
HistoryEntry,
|
|
14
|
+
TriggerResponse,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
__all__ = ["build_state_machine_rpc_bundle"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_state_machine_rpc_bundle(
|
|
22
|
+
state_machine: StateMachine,
|
|
23
|
+
*,
|
|
24
|
+
include_state_actions: bool = True,
|
|
25
|
+
include_history_action: bool = True,
|
|
26
|
+
triggers: Optional[Sequence[str]] = None,
|
|
27
|
+
) -> RpcBundle:
|
|
28
|
+
"""
|
|
29
|
+
Build and return an RpcBundle exposing a StateMachine instance.
|
|
30
|
+
|
|
31
|
+
Provides unary RPCs:
|
|
32
|
+
|
|
33
|
+
- GetState
|
|
34
|
+
- GetHistory
|
|
35
|
+
- Trigger_<TriggerName> (one per trigger)
|
|
36
|
+
|
|
37
|
+
For integration via app.extend_bundle(bundle).
|
|
38
|
+
"""
|
|
39
|
+
bundle = RpcBundle()
|
|
40
|
+
|
|
41
|
+
# ======================================================
|
|
42
|
+
# GetState
|
|
43
|
+
# ======================================================
|
|
44
|
+
if include_state_actions:
|
|
45
|
+
|
|
46
|
+
async def get_state() -> StateResponse:
|
|
47
|
+
last = getattr(state_machine, "get_last_state", lambda: None)()
|
|
48
|
+
return StateResponse(state=state_machine.state, last_state=last)
|
|
49
|
+
|
|
50
|
+
bundle.actions.append(
|
|
51
|
+
ActionEntry(
|
|
52
|
+
name="GetState",
|
|
53
|
+
func=get_state,
|
|
54
|
+
input_type=None,
|
|
55
|
+
output_type=StateResponse,
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# ======================================================
|
|
60
|
+
# GetHistory
|
|
61
|
+
# ======================================================
|
|
62
|
+
if include_history_action:
|
|
63
|
+
|
|
64
|
+
async def get_history() -> HistoryResponse:
|
|
65
|
+
raw = getattr(state_machine, "history", [])
|
|
66
|
+
out = []
|
|
67
|
+
|
|
68
|
+
for item in raw:
|
|
69
|
+
if isinstance(item, dict):
|
|
70
|
+
state_str = str(item.get("state", ""))
|
|
71
|
+
duration_ms: Optional[int] = item.get("duration_ms")
|
|
72
|
+
if duration_ms is not None and not isinstance(duration_ms, int):
|
|
73
|
+
duration_ms = None
|
|
74
|
+
timestamp: datetime
|
|
75
|
+
if "timestamp" in item and isinstance(item["timestamp"], datetime):
|
|
76
|
+
timestamp = item["timestamp"]
|
|
77
|
+
else:
|
|
78
|
+
timestamp = datetime.now()
|
|
79
|
+
out.append(
|
|
80
|
+
HistoryEntry(
|
|
81
|
+
state=state_str,
|
|
82
|
+
timestamp=timestamp,
|
|
83
|
+
duration_ms=duration_ms,
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
else:
|
|
87
|
+
out.append(HistoryEntry(state=str(item), timestamp=datetime.now()))
|
|
88
|
+
|
|
89
|
+
return HistoryResponse(history=out, buffer_size=len(raw))
|
|
90
|
+
|
|
91
|
+
bundle.actions.append(
|
|
92
|
+
ActionEntry(
|
|
93
|
+
name="GetHistory",
|
|
94
|
+
func=get_history,
|
|
95
|
+
input_type=None,
|
|
96
|
+
output_type=HistoryResponse,
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# ======================================================
|
|
101
|
+
# Triggers
|
|
102
|
+
# ======================================================
|
|
103
|
+
# Source of truth: transitions registered on state machine
|
|
104
|
+
all_triggers = sorted(state_machine.events.keys())
|
|
105
|
+
selected = all_triggers if triggers is None else list(triggers)
|
|
106
|
+
|
|
107
|
+
# ---------- factory function (fixes closure bug) ----------
|
|
108
|
+
def make_trigger_handler(
|
|
109
|
+
trigger_name: str,
|
|
110
|
+
) -> Callable[[], Coroutine[Any, Any, TriggerResponse]]:
|
|
111
|
+
async def trigger_call() -> TriggerResponse:
|
|
112
|
+
current = state_machine.state
|
|
113
|
+
allowed = state_machine.get_triggers(current)
|
|
114
|
+
|
|
115
|
+
# Precondition error if invalid in current state
|
|
116
|
+
if trigger_name not in allowed:
|
|
117
|
+
raise ConnectError(
|
|
118
|
+
code="failed_precondition",
|
|
119
|
+
message=(f"Trigger '{trigger_name}' cannot run from '{current}'. " f"Allowed: {sorted(allowed)}"),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Fetch bound trigger method
|
|
123
|
+
try:
|
|
124
|
+
method = getattr(state_machine, trigger_name)
|
|
125
|
+
except AttributeError as e:
|
|
126
|
+
raise ConnectError(
|
|
127
|
+
code="internal",
|
|
128
|
+
message=f"StateMachine missing trigger '{trigger_name}'",
|
|
129
|
+
) from e
|
|
130
|
+
|
|
131
|
+
# Execute trigger (sync or async)
|
|
132
|
+
try:
|
|
133
|
+
result = method()
|
|
134
|
+
if inspect.isawaitable(result):
|
|
135
|
+
await result
|
|
136
|
+
except Exception as e:
|
|
137
|
+
raise ConnectError(
|
|
138
|
+
code="internal",
|
|
139
|
+
message=f"Trigger '{trigger_name}' failed: {e}",
|
|
140
|
+
) from e
|
|
141
|
+
|
|
142
|
+
return TriggerResponse(
|
|
143
|
+
result=trigger_name,
|
|
144
|
+
previous_state=current,
|
|
145
|
+
new_state=state_machine.state,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return trigger_call
|
|
149
|
+
|
|
150
|
+
# ---------- register each trigger ----------
|
|
151
|
+
for trig in selected:
|
|
152
|
+
rpc_name = f"Trigger_{trig[0].upper()}{trig[1:]}" # PascalCase RPC names
|
|
153
|
+
handler = make_trigger_handler(trig)
|
|
154
|
+
|
|
155
|
+
bundle.actions.append(
|
|
156
|
+
ActionEntry(
|
|
157
|
+
name=rpc_name,
|
|
158
|
+
func=handler,
|
|
159
|
+
input_type=None,
|
|
160
|
+
output_type=TriggerResponse,
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
return bundle
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
[tool.poetry]
|
|
2
|
-
name = "vention-state-machine"
|
|
3
|
-
version = "0.3.1"
|
|
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.10,<3.11"
|
|
13
|
-
fastapi = "0.121.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"
|
|
@@ -1,181 +0,0 @@
|
|
|
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
|
-
)
|
|
File without changes
|
{vention_state_machine-0.3.1 → vention_state_machine-0.4.2}/src/state_machine/decorator_protocols.py
RENAMED
|
File without changes
|