vention-state-machine 0.2.1__tar.gz → 0.4__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.2.1 → vention_state_machine-0.4}/PKG-INFO +44 -29
- {vention_state_machine-0.2.1 → vention_state_machine-0.4}/README.md +42 -26
- {vention_state_machine-0.2.1 → vention_state_machine-0.4}/pyproject.toml +3 -8
- {vention_state_machine-0.2.1 → vention_state_machine-0.4}/src/state_machine/utils.py +27 -1
- vention_state_machine-0.4/src/state_machine/vention_communication.py +170 -0
- vention_state_machine-0.2.1/src/state_machine/router.py +0 -181
- {vention_state_machine-0.2.1 → vention_state_machine-0.4}/src/state_machine/__init__.py +0 -0
- {vention_state_machine-0.2.1 → vention_state_machine-0.4}/src/state_machine/core.py +0 -0
- {vention_state_machine-0.2.1 → vention_state_machine-0.4}/src/state_machine/decorator_manager.py +0 -0
- {vention_state_machine-0.2.1 → vention_state_machine-0.4}/src/state_machine/decorator_protocols.py +0 -0
- {vention_state_machine-0.2.1 → vention_state_machine-0.4}/src/state_machine/decorators.py +0 -0
- {vention_state_machine-0.2.1 → vention_state_machine-0.4}/src/state_machine/defs.py +0 -0
- {vention_state_machine-0.2.1 → vention_state_machine-0.4}/src/state_machine/machine_protocols.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: vention-state-machine
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4
|
|
4
4
|
Summary: Declarative state machine framework for machine apps
|
|
5
5
|
License: Proprietary
|
|
6
6
|
Author: VentionCo
|
|
@@ -10,11 +10,10 @@ Classifier: Programming Language :: Python :: 3
|
|
|
10
10
|
Classifier: Programming Language :: Python :: 3.10
|
|
11
11
|
Requires-Dist: asyncio (>=3.4.3,<4.0.0)
|
|
12
12
|
Requires-Dist: coverage (>=7.10.1,<8.0.0)
|
|
13
|
-
Requires-Dist: fastapi (==0.121.1)
|
|
14
13
|
Requires-Dist: graphviz (>=0.21,<0.22)
|
|
15
|
-
Requires-Dist: httpx (>=0.28.1,<0.29.0)
|
|
16
14
|
Requires-Dist: transitions (>=0.9.3,<0.10.0)
|
|
17
15
|
Requires-Dist: uvicorn (>=0.35.0,<0.36.0)
|
|
16
|
+
Requires-Dist: vention-communication (>=0.2.2,<0.3.0)
|
|
18
17
|
Description-Content-Type: text/markdown
|
|
19
18
|
|
|
20
19
|
# vention-state-machine
|
|
@@ -41,7 +40,7 @@ A lightweight wrapper around `transitions` for building async-safe, recoverable
|
|
|
41
40
|
- Transition history recording with timestamps + durations
|
|
42
41
|
- Guard conditions for blocking transitions
|
|
43
42
|
- Global state change callbacks for logging/MQTT
|
|
44
|
-
- Optional
|
|
43
|
+
- Optional RPC bundle for exposing state machine via Connect RPCs
|
|
45
44
|
|
|
46
45
|
## 🧠 Concepts & Overview
|
|
47
46
|
|
|
@@ -94,20 +93,20 @@ pip install vention-state-machine
|
|
|
94
93
|
|
|
95
94
|
**Optional dependencies:**
|
|
96
95
|
- Graphviz (required for diagram generation)
|
|
97
|
-
-
|
|
96
|
+
- vention-communication (for RPC bundle integration)
|
|
98
97
|
|
|
99
98
|
**Install optional tools:**
|
|
100
99
|
|
|
101
100
|
MacOS:
|
|
102
101
|
```bash
|
|
103
102
|
brew install graphviz
|
|
104
|
-
pip install
|
|
103
|
+
pip install vention-communication
|
|
105
104
|
```
|
|
106
105
|
|
|
107
106
|
Linux (Debian/Ubuntu)
|
|
108
107
|
```bash
|
|
109
108
|
sudo apt-get install graphviz
|
|
110
|
-
pip install
|
|
109
|
+
pip install vention-communication
|
|
111
110
|
```
|
|
112
111
|
|
|
113
112
|
|
|
@@ -191,25 +190,37 @@ state_machine.start()
|
|
|
191
190
|
|
|
192
191
|
## 🛠 How-to Guides
|
|
193
192
|
|
|
194
|
-
### Expose Over
|
|
193
|
+
### Expose Over RPC with VentionApp
|
|
195
194
|
|
|
196
195
|
```python
|
|
197
|
-
from
|
|
198
|
-
from state_machine.
|
|
196
|
+
from communication.app import VentionApp
|
|
197
|
+
from state_machine.vention_communication import build_state_machine_bundle
|
|
199
198
|
from state_machine.core import StateMachine
|
|
200
199
|
|
|
201
200
|
state_machine = StateMachine(...)
|
|
202
201
|
state_machine.start()
|
|
203
202
|
|
|
204
|
-
app =
|
|
205
|
-
|
|
203
|
+
app = VentionApp(name="MyApp")
|
|
204
|
+
bundle = build_state_machine_bundle(state_machine)
|
|
205
|
+
app.register_rpc_plugin(bundle)
|
|
206
|
+
app.finalize()
|
|
206
207
|
```
|
|
207
208
|
|
|
208
|
-
**
|
|
209
|
-
- `
|
|
210
|
-
- `
|
|
211
|
-
- `
|
|
212
|
-
|
|
209
|
+
**RPC Actions:**
|
|
210
|
+
- `GetState` → Returns current state and last known state
|
|
211
|
+
- `GetHistory` → Returns transition history with timestamps
|
|
212
|
+
- `Trigger_<TriggerName>` → Triggers a state transition (e.g., `Trigger_Start`, `Trigger_Activate`)
|
|
213
|
+
|
|
214
|
+
**Options:**
|
|
215
|
+
```python
|
|
216
|
+
# Customize which actions are included
|
|
217
|
+
bundle = build_state_machine_bundle(
|
|
218
|
+
state_machine,
|
|
219
|
+
include_state_actions=True, # Include GetState
|
|
220
|
+
include_history_action=True, # Include GetHistory
|
|
221
|
+
triggers=["start", "activate"], # Only include specific triggers
|
|
222
|
+
)
|
|
223
|
+
```
|
|
213
224
|
|
|
214
225
|
### Timeout Example
|
|
215
226
|
|
|
@@ -369,24 +380,28 @@ Guard transition; blocks if function returns False.
|
|
|
369
380
|
**`@on_state_change`**
|
|
370
381
|
Global callback `(old_state, new_state, trigger)` fired after each transition.
|
|
371
382
|
|
|
372
|
-
###
|
|
383
|
+
### RPC Bundle
|
|
373
384
|
|
|
374
385
|
```python
|
|
375
|
-
def
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
386
|
+
def build_state_machine_bundle(
|
|
387
|
+
sm: StateMachine,
|
|
388
|
+
*,
|
|
389
|
+
include_state_actions: bool = True,
|
|
390
|
+
include_history_action: bool = True,
|
|
391
|
+
triggers: Optional[Sequence[str]] = None,
|
|
392
|
+
) -> RpcBundle
|
|
379
393
|
```
|
|
380
394
|
|
|
381
|
-
|
|
382
|
-
- `
|
|
383
|
-
- `
|
|
384
|
-
- `
|
|
385
|
-
|
|
395
|
+
Builds an RPC bundle exposing the state machine via Connect-style RPCs:
|
|
396
|
+
- `GetState` - Returns current and last known state
|
|
397
|
+
- `GetHistory` - Returns transition history
|
|
398
|
+
- `Trigger_<TriggerName>` - One RPC per trigger (PascalCase naming)
|
|
399
|
+
|
|
400
|
+
The bundle can be registered with a `VentionApp` using `app.register_rpc_plugin(bundle)`.
|
|
386
401
|
|
|
387
402
|
## 🔍 Troubleshooting & FAQ
|
|
388
403
|
|
|
389
|
-
- **Diagram endpoint returns 503** → Graphviz not installed.
|
|
390
404
|
- **Transitions blocked unexpectedly** → Check guard conditions.
|
|
391
405
|
- **Callbacks not firing** → Only successful transitions trigger them.
|
|
392
406
|
- **State not restored after restart** → Ensure `enable_last_state_recovery=True`.
|
|
407
|
+
- **RPC actions not available** → Ensure `app.finalize()` is called after registering bundles.
|
|
@@ -22,7 +22,7 @@ A lightweight wrapper around `transitions` for building async-safe, recoverable
|
|
|
22
22
|
- Transition history recording with timestamps + durations
|
|
23
23
|
- Guard conditions for blocking transitions
|
|
24
24
|
- Global state change callbacks for logging/MQTT
|
|
25
|
-
- Optional
|
|
25
|
+
- Optional RPC bundle for exposing state machine via Connect RPCs
|
|
26
26
|
|
|
27
27
|
## 🧠 Concepts & Overview
|
|
28
28
|
|
|
@@ -75,20 +75,20 @@ pip install vention-state-machine
|
|
|
75
75
|
|
|
76
76
|
**Optional dependencies:**
|
|
77
77
|
- Graphviz (required for diagram generation)
|
|
78
|
-
-
|
|
78
|
+
- vention-communication (for RPC bundle integration)
|
|
79
79
|
|
|
80
80
|
**Install optional tools:**
|
|
81
81
|
|
|
82
82
|
MacOS:
|
|
83
83
|
```bash
|
|
84
84
|
brew install graphviz
|
|
85
|
-
pip install
|
|
85
|
+
pip install vention-communication
|
|
86
86
|
```
|
|
87
87
|
|
|
88
88
|
Linux (Debian/Ubuntu)
|
|
89
89
|
```bash
|
|
90
90
|
sudo apt-get install graphviz
|
|
91
|
-
pip install
|
|
91
|
+
pip install vention-communication
|
|
92
92
|
```
|
|
93
93
|
|
|
94
94
|
|
|
@@ -172,25 +172,37 @@ state_machine.start()
|
|
|
172
172
|
|
|
173
173
|
## 🛠 How-to Guides
|
|
174
174
|
|
|
175
|
-
### Expose Over
|
|
175
|
+
### Expose Over RPC with VentionApp
|
|
176
176
|
|
|
177
177
|
```python
|
|
178
|
-
from
|
|
179
|
-
from state_machine.
|
|
178
|
+
from communication.app import VentionApp
|
|
179
|
+
from state_machine.vention_communication import build_state_machine_bundle
|
|
180
180
|
from state_machine.core import StateMachine
|
|
181
181
|
|
|
182
182
|
state_machine = StateMachine(...)
|
|
183
183
|
state_machine.start()
|
|
184
184
|
|
|
185
|
-
app =
|
|
186
|
-
|
|
185
|
+
app = VentionApp(name="MyApp")
|
|
186
|
+
bundle = build_state_machine_bundle(state_machine)
|
|
187
|
+
app.register_rpc_plugin(bundle)
|
|
188
|
+
app.finalize()
|
|
187
189
|
```
|
|
188
190
|
|
|
189
|
-
**
|
|
190
|
-
- `
|
|
191
|
-
- `
|
|
192
|
-
- `
|
|
193
|
-
|
|
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
|
+
```
|
|
194
206
|
|
|
195
207
|
### Timeout Example
|
|
196
208
|
|
|
@@ -350,24 +362,28 @@ Guard transition; blocks if function returns False.
|
|
|
350
362
|
**`@on_state_change`**
|
|
351
363
|
Global callback `(old_state, new_state, trigger)` fired after each transition.
|
|
352
364
|
|
|
353
|
-
###
|
|
365
|
+
### RPC Bundle
|
|
354
366
|
|
|
355
367
|
```python
|
|
356
|
-
def
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
|
360
375
|
```
|
|
361
376
|
|
|
362
|
-
|
|
363
|
-
- `
|
|
364
|
-
- `
|
|
365
|
-
- `
|
|
366
|
-
|
|
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)`.
|
|
367
383
|
|
|
368
384
|
## 🔍 Troubleshooting & FAQ
|
|
369
385
|
|
|
370
|
-
- **Diagram endpoint returns 503** → Graphviz not installed.
|
|
371
386
|
- **Transitions blocked unexpectedly** → Check guard conditions.
|
|
372
387
|
- **Callbacks not firing** → Only successful transitions trigger them.
|
|
373
|
-
- **State not restored after restart** → Ensure `enable_last_state_recovery=True`.
|
|
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.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "vention-state-machine"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4"
|
|
4
4
|
description = "Declarative state machine framework for machine apps"
|
|
5
5
|
authors = [ "VentionCo" ]
|
|
6
6
|
readme = "README.md"
|
|
@@ -10,17 +10,12 @@ packages = [{ include = "state_machine", from = "src" }]
|
|
|
10
10
|
[tool.poetry.dependencies]
|
|
11
11
|
asyncio = "^3.4.3"
|
|
12
12
|
python = ">=3.10,<3.11"
|
|
13
|
-
fastapi = "0.121.1"
|
|
14
13
|
uvicorn = "^0.35.0"
|
|
15
14
|
transitions = "^0.9.3"
|
|
16
15
|
graphviz = "^0.21"
|
|
17
16
|
coverage = "^7.10.1"
|
|
18
|
-
|
|
17
|
+
vention-communication = "^0.2.2"
|
|
19
18
|
|
|
20
19
|
[tool.poetry.group.dev.dependencies]
|
|
21
20
|
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"
|
|
21
|
+
ruff = "^0.8.0"
|
|
@@ -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
|
|
|
@@ -54,3 +56,27 @@ def is_state_container(state_source: object) -> bool:
|
|
|
54
56
|
)
|
|
55
57
|
except TypeError:
|
|
56
58
|
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Shared schema definitions for REST and RPC endpoints
|
|
62
|
+
# (camelCase aliases applied automatically by registry)
|
|
63
|
+
class StateResponse(BaseModel):
|
|
64
|
+
state: str
|
|
65
|
+
last_state: Optional[str] = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class HistoryEntry(BaseModel):
|
|
69
|
+
timestamp: datetime
|
|
70
|
+
state: str
|
|
71
|
+
duration_ms: Optional[int] = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class HistoryResponse(BaseModel):
|
|
75
|
+
history: list[HistoryEntry]
|
|
76
|
+
buffer_size: int
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TriggerResponse(BaseModel):
|
|
80
|
+
result: str
|
|
81
|
+
previous_state: str
|
|
82
|
+
new_state: str
|
|
@@ -0,0 +1,170 @@
|
|
|
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_bundle"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_state_machine_bundle(
|
|
22
|
+
sm: 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(sm, "get_last_state", lambda: None)()
|
|
48
|
+
return StateResponse(state=sm.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(sm, "history", [])
|
|
66
|
+
out = []
|
|
67
|
+
|
|
68
|
+
for item in raw:
|
|
69
|
+
if isinstance(item, dict):
|
|
70
|
+
# Handle dict items from history
|
|
71
|
+
state_str = str(item.get("state", ""))
|
|
72
|
+
duration_ms: Optional[int] = item.get("duration_ms")
|
|
73
|
+
if duration_ms is not None and not isinstance(duration_ms, int):
|
|
74
|
+
duration_ms = None
|
|
75
|
+
# Add timestamp if present, otherwise use current time
|
|
76
|
+
timestamp: datetime
|
|
77
|
+
if "timestamp" in item and isinstance(item["timestamp"], datetime):
|
|
78
|
+
timestamp = item["timestamp"]
|
|
79
|
+
else:
|
|
80
|
+
timestamp = datetime.now()
|
|
81
|
+
out.append(
|
|
82
|
+
HistoryEntry(
|
|
83
|
+
state=state_str,
|
|
84
|
+
timestamp=timestamp,
|
|
85
|
+
duration_ms=duration_ms,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
else:
|
|
89
|
+
# Handle simple state strings
|
|
90
|
+
out.append(HistoryEntry(state=str(item), timestamp=datetime.now()))
|
|
91
|
+
|
|
92
|
+
return HistoryResponse(history=out, buffer_size=len(raw))
|
|
93
|
+
|
|
94
|
+
bundle.actions.append(
|
|
95
|
+
ActionEntry(
|
|
96
|
+
name="GetHistory",
|
|
97
|
+
func=get_history,
|
|
98
|
+
input_type=None,
|
|
99
|
+
output_type=HistoryResponse,
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# ======================================================
|
|
104
|
+
# Triggers
|
|
105
|
+
# ======================================================
|
|
106
|
+
# Source of truth: transitions registered on SM
|
|
107
|
+
all_triggers = sorted(sm.events.keys())
|
|
108
|
+
selected = all_triggers if triggers is None else list(triggers)
|
|
109
|
+
|
|
110
|
+
# ---------- factory function (fixes closure bug) ----------
|
|
111
|
+
def make_trigger_handler(
|
|
112
|
+
trigger_name: str,
|
|
113
|
+
) -> Callable[[], Coroutine[Any, Any, TriggerResponse]]:
|
|
114
|
+
async def trigger_call() -> TriggerResponse:
|
|
115
|
+
current = sm.state
|
|
116
|
+
allowed = sm.get_triggers(current)
|
|
117
|
+
|
|
118
|
+
# Precondition error if invalid in current state
|
|
119
|
+
if trigger_name not in allowed:
|
|
120
|
+
raise ConnectError(
|
|
121
|
+
code="failed_precondition",
|
|
122
|
+
message=(
|
|
123
|
+
f"Trigger '{trigger_name}' cannot run from '{current}'. "
|
|
124
|
+
f"Allowed: {sorted(allowed)}"
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Fetch bound trigger method
|
|
129
|
+
try:
|
|
130
|
+
method = getattr(sm, trigger_name)
|
|
131
|
+
except AttributeError as e:
|
|
132
|
+
raise ConnectError(
|
|
133
|
+
code="internal",
|
|
134
|
+
message=f"StateMachine missing trigger '{trigger_name}'",
|
|
135
|
+
) from e
|
|
136
|
+
|
|
137
|
+
# Execute trigger (sync or async)
|
|
138
|
+
try:
|
|
139
|
+
result = method()
|
|
140
|
+
if inspect.isawaitable(result):
|
|
141
|
+
await result
|
|
142
|
+
except Exception as e:
|
|
143
|
+
raise ConnectError(
|
|
144
|
+
code="internal",
|
|
145
|
+
message=f"Trigger '{trigger_name}' failed: {e}",
|
|
146
|
+
) from e
|
|
147
|
+
|
|
148
|
+
return TriggerResponse(
|
|
149
|
+
result=trigger_name,
|
|
150
|
+
previous_state=current,
|
|
151
|
+
new_state=sm.state,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return trigger_call
|
|
155
|
+
|
|
156
|
+
# ---------- register each trigger ----------
|
|
157
|
+
for trig in selected:
|
|
158
|
+
rpc_name = f"Trigger_{trig[0].upper()}{trig[1:]}" # PascalCase RPC names
|
|
159
|
+
handler = make_trigger_handler(trig)
|
|
160
|
+
|
|
161
|
+
bundle.actions.append(
|
|
162
|
+
ActionEntry(
|
|
163
|
+
name=rpc_name,
|
|
164
|
+
func=handler,
|
|
165
|
+
input_type=None,
|
|
166
|
+
output_type=TriggerResponse,
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return bundle
|
|
@@ -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
|
|
File without changes
|
{vention_state_machine-0.2.1 → vention_state_machine-0.4}/src/state_machine/decorator_manager.py
RENAMED
|
File without changes
|
{vention_state_machine-0.2.1 → vention_state_machine-0.4}/src/state_machine/decorator_protocols.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{vention_state_machine-0.2.1 → vention_state_machine-0.4}/src/state_machine/machine_protocols.py
RENAMED
|
File without changes
|