vention-state-machine 0.3.1__py3-none-any.whl → 0.4.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/utils.py CHANGED
@@ -1,4 +1,6 @@
1
- from typing import Any, Callable, Union
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,167 @@
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=(
120
+ f"Trigger '{trigger_name}' cannot run from '{current}'. "
121
+ f"Allowed: {sorted(allowed)}"
122
+ ),
123
+ )
124
+
125
+ # Fetch bound trigger method
126
+ try:
127
+ method = getattr(state_machine, trigger_name)
128
+ except AttributeError as e:
129
+ raise ConnectError(
130
+ code="internal",
131
+ message=f"StateMachine missing trigger '{trigger_name}'",
132
+ ) from e
133
+
134
+ # Execute trigger (sync or async)
135
+ try:
136
+ result = method()
137
+ if inspect.isawaitable(result):
138
+ await result
139
+ except Exception as e:
140
+ raise ConnectError(
141
+ code="internal",
142
+ message=f"Trigger '{trigger_name}' failed: {e}",
143
+ ) from e
144
+
145
+ return TriggerResponse(
146
+ result=trigger_name,
147
+ previous_state=current,
148
+ new_state=state_machine.state,
149
+ )
150
+
151
+ return trigger_call
152
+
153
+ # ---------- register each trigger ----------
154
+ for trig in selected:
155
+ rpc_name = f"Trigger_{trig[0].upper()}{trig[1:]}" # PascalCase RPC names
156
+ handler = make_trigger_handler(trig)
157
+
158
+ bundle.actions.append(
159
+ ActionEntry(
160
+ name=rpc_name,
161
+ func=handler,
162
+ input_type=None,
163
+ output_type=TriggerResponse,
164
+ )
165
+ )
166
+
167
+ return bundle
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: vention-state-machine
3
- Version: 0.3.1
3
+ Version: 0.4.0
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 FastAPI router for HTTP access and visualization
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
- - FastAPI (for HTTP exposure of state machine)
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 fastapi
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 fastapi
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 HTTP with FastAPI
193
+ ### Expose Over RPC with VentionApp
195
194
 
196
195
  ```python
197
- from fastapi import FastAPI
198
- from state_machine.router import build_router
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 = FastAPI()
205
- app.include_router(build_router(state_machine))
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
- **Endpoints:**
209
- - `GET /state` → Current state
210
- - `GET /history` → Transition history
211
- - `POST /<trigger>` → Trigger a transition
212
- - `GET /diagram.svg` → Graphviz diagram
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
- ### Router
383
+ ### RPC Bundle
373
384
 
374
385
  ```python
375
- def build_router(
376
- machine: StateMachine,
377
- triggers: Optional[list[Trigger]] = None
378
- ) -> fastapi.APIRouter
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
- Exposes endpoints:
382
- - `GET /state`
383
- - `GET /history`
384
- - `POST /<trigger>`
385
- - `GET /diagram.svg`
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.
@@ -5,8 +5,8 @@ state_machine/decorator_protocols.py,sha256=E6_xflTPxxTqDBa4Dj4p3OdPvR_EW0jA3PAj
5
5
  state_machine/decorators.py,sha256=V-KBJ6fSvINi7JHN_VmrQLTnOxGPy5iRV5QzXLKwWDw,3668
6
6
  state_machine/defs.py,sha256=eYtkTqRMm89ZHFKs3p7H3HyGNZbrOnoCzAJbSeMxSDg,2836
7
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.3.1.dist-info/METADATA,sha256=EVMmxDmwb67RVbY6N_Ohig5hDLQCCSnOL2M7tSf12DU,10812
11
- vention_state_machine-0.3.1.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
12
- vention_state_machine-0.3.1.dist-info/RECORD,,
8
+ state_machine/utils.py,sha256=3eWL63_RAk_Jijh0ZIArBsUF3A7f0vQmwTjDRwRvpIs,2395
9
+ state_machine/vention_communication.py,sha256=BZjHC1SsXFqLiSpX0ChCEGfhkEeitpXmLClZCQIgOrA,5530
10
+ vention_state_machine-0.4.0.dist-info/METADATA,sha256=UlROQZDnqaO5j0AbfJJ2KofpSrrr_woWR8xPWbJY35c,11671
11
+ vention_state_machine-0.4.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
12
+ vention_state_machine-0.4.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 1.9.1
2
+ Generator: poetry-core 2.2.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
state_machine/router.py DELETED
@@ -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
- )