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.
@@ -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 FastAPI router for HTTP access and visualization
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
- - FastAPI (for HTTP exposure of state machine)
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 fastapi
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 fastapi
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 HTTP with FastAPI
209
+ ### Expose Over RPC with VentionApp
176
210
 
177
211
  ```python
178
- from fastapi import FastAPI
179
- from state_machine.router import build_router
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 = FastAPI()
186
- app.include_router(build_router(state_machine))
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
- **Endpoints:**
190
- - `GET /state` → Current state
191
- - `GET /history` → Transition history
192
- - `POST /<trigger>` → Trigger a transition
193
- - `GET /diagram.svg` → Graphviz diagram
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
- ### Router
399
+ ### RPC Bundle
354
400
 
355
401
  ```python
356
- def build_router(
357
- machine: StateMachine,
358
- triggers: Optional[list[Trigger]] = None
359
- ) -> fastapi.APIRouter
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
- Exposes endpoints:
363
- - `GET /state`
364
- - `GET /history`
365
- - `POST /<trigger>`
366
- - `GET /diagram.svg`
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 FastAPI router for HTTP access and visualization
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
- - FastAPI (for HTTP exposure of state machine)
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 fastapi
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 fastapi
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 HTTP with FastAPI
175
+ ### Expose Over RPC with VentionApp
195
176
 
196
177
  ```python
197
- from fastapi import FastAPI
198
- from state_machine.router import build_router
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 = FastAPI()
205
- app.include_router(build_router(state_machine))
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
- **Endpoints:**
209
- - `GET /state` → Current state
210
- - `GET /history` → Transition history
211
- - `POST /<trigger>` → Trigger a transition
212
- - `GET /diagram.svg` → Graphviz diagram
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
- ### Router
365
+ ### RPC Bundle
373
366
 
374
367
  ```python
375
- def build_router(
376
- machine: StateMachine,
377
- triggers: Optional[list[Trigger]] = None
378
- ) -> fastapi.APIRouter
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
- Exposes endpoints:
382
- - `GET /state`
383
- - `GET /history`
384
- - `POST /<trigger>`
385
- - `GET /diagram.svg`
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
 
@@ -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.
@@ -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 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
 
@@ -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
- )