truthound-dashboard 1.3.0__py3-none-any.whl → 1.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.
Files changed (169) hide show
  1. truthound_dashboard/api/alerts.py +258 -0
  2. truthound_dashboard/api/anomaly.py +1302 -0
  3. truthound_dashboard/api/cross_alerts.py +352 -0
  4. truthound_dashboard/api/deps.py +143 -0
  5. truthound_dashboard/api/drift_monitor.py +540 -0
  6. truthound_dashboard/api/lineage.py +1151 -0
  7. truthound_dashboard/api/maintenance.py +363 -0
  8. truthound_dashboard/api/middleware.py +373 -1
  9. truthound_dashboard/api/model_monitoring.py +805 -0
  10. truthound_dashboard/api/notifications_advanced.py +2452 -0
  11. truthound_dashboard/api/plugins.py +2096 -0
  12. truthound_dashboard/api/profile.py +211 -14
  13. truthound_dashboard/api/reports.py +853 -0
  14. truthound_dashboard/api/router.py +147 -0
  15. truthound_dashboard/api/rule_suggestions.py +310 -0
  16. truthound_dashboard/api/schema_evolution.py +231 -0
  17. truthound_dashboard/api/sources.py +47 -3
  18. truthound_dashboard/api/triggers.py +190 -0
  19. truthound_dashboard/api/validations.py +13 -0
  20. truthound_dashboard/api/validators.py +333 -4
  21. truthound_dashboard/api/versioning.py +309 -0
  22. truthound_dashboard/api/websocket.py +301 -0
  23. truthound_dashboard/core/__init__.py +27 -0
  24. truthound_dashboard/core/anomaly.py +1395 -0
  25. truthound_dashboard/core/anomaly_explainer.py +633 -0
  26. truthound_dashboard/core/cache.py +206 -0
  27. truthound_dashboard/core/cached_services.py +422 -0
  28. truthound_dashboard/core/charts.py +352 -0
  29. truthound_dashboard/core/connections.py +1069 -42
  30. truthound_dashboard/core/cross_alerts.py +837 -0
  31. truthound_dashboard/core/drift_monitor.py +1477 -0
  32. truthound_dashboard/core/drift_sampling.py +669 -0
  33. truthound_dashboard/core/i18n/__init__.py +42 -0
  34. truthound_dashboard/core/i18n/detector.py +173 -0
  35. truthound_dashboard/core/i18n/messages.py +564 -0
  36. truthound_dashboard/core/lineage.py +971 -0
  37. truthound_dashboard/core/maintenance.py +443 -5
  38. truthound_dashboard/core/model_monitoring.py +1043 -0
  39. truthound_dashboard/core/notifications/channels.py +1020 -1
  40. truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
  41. truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
  42. truthound_dashboard/core/notifications/deduplication/service.py +400 -0
  43. truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
  44. truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
  45. truthound_dashboard/core/notifications/dispatcher.py +43 -0
  46. truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
  47. truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
  48. truthound_dashboard/core/notifications/escalation/engine.py +429 -0
  49. truthound_dashboard/core/notifications/escalation/models.py +336 -0
  50. truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
  51. truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
  52. truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
  53. truthound_dashboard/core/notifications/events.py +49 -0
  54. truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
  55. truthound_dashboard/core/notifications/metrics/base.py +528 -0
  56. truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
  57. truthound_dashboard/core/notifications/routing/__init__.py +169 -0
  58. truthound_dashboard/core/notifications/routing/combinators.py +184 -0
  59. truthound_dashboard/core/notifications/routing/config.py +375 -0
  60. truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
  61. truthound_dashboard/core/notifications/routing/engine.py +382 -0
  62. truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
  63. truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
  64. truthound_dashboard/core/notifications/routing/rules.py +625 -0
  65. truthound_dashboard/core/notifications/routing/validator.py +678 -0
  66. truthound_dashboard/core/notifications/service.py +2 -0
  67. truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
  68. truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
  69. truthound_dashboard/core/notifications/throttling/builder.py +311 -0
  70. truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
  71. truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
  72. truthound_dashboard/core/openlineage.py +1028 -0
  73. truthound_dashboard/core/plugins/__init__.py +39 -0
  74. truthound_dashboard/core/plugins/docs/__init__.py +39 -0
  75. truthound_dashboard/core/plugins/docs/extractor.py +703 -0
  76. truthound_dashboard/core/plugins/docs/renderers.py +804 -0
  77. truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
  78. truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
  79. truthound_dashboard/core/plugins/hooks/manager.py +403 -0
  80. truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
  81. truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
  82. truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
  83. truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
  84. truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
  85. truthound_dashboard/core/plugins/loader.py +504 -0
  86. truthound_dashboard/core/plugins/registry.py +810 -0
  87. truthound_dashboard/core/plugins/reporter_executor.py +588 -0
  88. truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
  89. truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
  90. truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
  91. truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
  92. truthound_dashboard/core/plugins/sandbox.py +617 -0
  93. truthound_dashboard/core/plugins/security/__init__.py +68 -0
  94. truthound_dashboard/core/plugins/security/analyzer.py +535 -0
  95. truthound_dashboard/core/plugins/security/policies.py +311 -0
  96. truthound_dashboard/core/plugins/security/protocols.py +296 -0
  97. truthound_dashboard/core/plugins/security/signing.py +842 -0
  98. truthound_dashboard/core/plugins/security.py +446 -0
  99. truthound_dashboard/core/plugins/validator_executor.py +401 -0
  100. truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
  101. truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
  102. truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
  103. truthound_dashboard/core/plugins/versioning/semver.py +266 -0
  104. truthound_dashboard/core/profile_comparison.py +601 -0
  105. truthound_dashboard/core/report_history.py +570 -0
  106. truthound_dashboard/core/reporters/__init__.py +57 -0
  107. truthound_dashboard/core/reporters/base.py +296 -0
  108. truthound_dashboard/core/reporters/csv_reporter.py +155 -0
  109. truthound_dashboard/core/reporters/html_reporter.py +598 -0
  110. truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
  111. truthound_dashboard/core/reporters/i18n/base.py +494 -0
  112. truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
  113. truthound_dashboard/core/reporters/json_reporter.py +160 -0
  114. truthound_dashboard/core/reporters/junit_reporter.py +233 -0
  115. truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
  116. truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
  117. truthound_dashboard/core/reporters/registry.py +272 -0
  118. truthound_dashboard/core/rule_generator.py +2088 -0
  119. truthound_dashboard/core/scheduler.py +822 -12
  120. truthound_dashboard/core/schema_evolution.py +858 -0
  121. truthound_dashboard/core/services.py +152 -9
  122. truthound_dashboard/core/statistics.py +718 -0
  123. truthound_dashboard/core/streaming_anomaly.py +883 -0
  124. truthound_dashboard/core/triggers/__init__.py +45 -0
  125. truthound_dashboard/core/triggers/base.py +226 -0
  126. truthound_dashboard/core/triggers/evaluators.py +609 -0
  127. truthound_dashboard/core/triggers/factory.py +363 -0
  128. truthound_dashboard/core/unified_alerts.py +870 -0
  129. truthound_dashboard/core/validation_limits.py +509 -0
  130. truthound_dashboard/core/versioning.py +709 -0
  131. truthound_dashboard/core/websocket/__init__.py +59 -0
  132. truthound_dashboard/core/websocket/manager.py +512 -0
  133. truthound_dashboard/core/websocket/messages.py +130 -0
  134. truthound_dashboard/db/__init__.py +30 -0
  135. truthound_dashboard/db/models.py +3375 -3
  136. truthound_dashboard/main.py +22 -0
  137. truthound_dashboard/schemas/__init__.py +396 -1
  138. truthound_dashboard/schemas/anomaly.py +1258 -0
  139. truthound_dashboard/schemas/base.py +4 -0
  140. truthound_dashboard/schemas/cross_alerts.py +334 -0
  141. truthound_dashboard/schemas/drift_monitor.py +890 -0
  142. truthound_dashboard/schemas/lineage.py +428 -0
  143. truthound_dashboard/schemas/maintenance.py +154 -0
  144. truthound_dashboard/schemas/model_monitoring.py +374 -0
  145. truthound_dashboard/schemas/notifications_advanced.py +1363 -0
  146. truthound_dashboard/schemas/openlineage.py +704 -0
  147. truthound_dashboard/schemas/plugins.py +1293 -0
  148. truthound_dashboard/schemas/profile.py +420 -34
  149. truthound_dashboard/schemas/profile_comparison.py +242 -0
  150. truthound_dashboard/schemas/reports.py +285 -0
  151. truthound_dashboard/schemas/rule_suggestion.py +434 -0
  152. truthound_dashboard/schemas/schema_evolution.py +164 -0
  153. truthound_dashboard/schemas/source.py +117 -2
  154. truthound_dashboard/schemas/triggers.py +511 -0
  155. truthound_dashboard/schemas/unified_alerts.py +223 -0
  156. truthound_dashboard/schemas/validation.py +25 -1
  157. truthound_dashboard/schemas/validators/__init__.py +11 -0
  158. truthound_dashboard/schemas/validators/base.py +151 -0
  159. truthound_dashboard/schemas/versioning.py +152 -0
  160. truthound_dashboard/static/index.html +2 -2
  161. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/METADATA +142 -18
  162. truthound_dashboard-1.4.0.dist-info/RECORD +239 -0
  163. truthound_dashboard/static/assets/index-BCA8H1hO.js +0 -574
  164. truthound_dashboard/static/assets/index-BNsSQ2fN.css +0 -1
  165. truthound_dashboard/static/assets/unmerged_dictionaries-CsJWCRx9.js +0 -1
  166. truthound_dashboard-1.3.0.dist-info/RECORD +0 -110
  167. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/WHEEL +0 -0
  168. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/entry_points.txt +0 -0
  169. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,419 @@
1
+ """Plugin State Machine.
2
+
3
+ This module provides the state machine implementation
4
+ for plugin lifecycle management.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ import time
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime
14
+ from typing import Any, Callable, Awaitable
15
+
16
+ from .states import (
17
+ PluginState,
18
+ StateTransition,
19
+ StateTransitionError,
20
+ is_valid_transition,
21
+ get_valid_transitions,
22
+ )
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ # Callback types
28
+ SyncCallback = Callable[["PluginStateMachine", StateTransition], None]
29
+ AsyncCallback = Callable[["PluginStateMachine", StateTransition], Awaitable[None]]
30
+ TransitionCallback = SyncCallback | AsyncCallback
31
+
32
+
33
+ @dataclass
34
+ class PluginContext:
35
+ """Context for plugin state machine.
36
+
37
+ Attributes:
38
+ plugin_id: Plugin identifier.
39
+ version: Plugin version.
40
+ path: Plugin installation path.
41
+ config: Plugin configuration.
42
+ state_data: State-specific data.
43
+ error: Last error message.
44
+ last_error_at: When last error occurred.
45
+ retry_count: Number of retry attempts.
46
+ """
47
+
48
+ plugin_id: str
49
+ version: str = ""
50
+ path: str = ""
51
+ config: dict[str, Any] = field(default_factory=dict)
52
+ state_data: dict[str, Any] = field(default_factory=dict)
53
+ error: str | None = None
54
+ last_error_at: datetime | None = None
55
+ retry_count: int = 0
56
+
57
+
58
+ class PluginStateMachine:
59
+ """State machine for managing plugin lifecycle.
60
+
61
+ This class provides:
62
+ - State transition management
63
+ - Transition callbacks (before/after)
64
+ - State history tracking
65
+ - Error recovery and rollback
66
+ """
67
+
68
+ def __init__(
69
+ self,
70
+ plugin_id: str,
71
+ initial_state: PluginState = PluginState.DISCOVERED,
72
+ context: PluginContext | None = None,
73
+ ) -> None:
74
+ """Initialize the state machine.
75
+
76
+ Args:
77
+ plugin_id: Plugin identifier.
78
+ initial_state: Initial state.
79
+ context: Optional plugin context.
80
+ """
81
+ self.plugin_id = plugin_id
82
+ self._state = initial_state
83
+ self._previous_state: PluginState | None = None
84
+ self._state_entered_at = datetime.utcnow()
85
+ self._history: list[StateTransition] = []
86
+ self._context = context or PluginContext(plugin_id=plugin_id)
87
+
88
+ # Callbacks
89
+ self._before_callbacks: dict[PluginState, list[TransitionCallback]] = {}
90
+ self._after_callbacks: dict[PluginState, list[TransitionCallback]] = {}
91
+ self._on_any_transition: list[TransitionCallback] = []
92
+
93
+ # Locking
94
+ self._lock = asyncio.Lock()
95
+
96
+ @property
97
+ def state(self) -> PluginState:
98
+ """Get current state."""
99
+ return self._state
100
+
101
+ @property
102
+ def previous_state(self) -> PluginState | None:
103
+ """Get previous state."""
104
+ return self._previous_state
105
+
106
+ @property
107
+ def context(self) -> PluginContext:
108
+ """Get plugin context."""
109
+ return self._context
110
+
111
+ @property
112
+ def history(self) -> list[StateTransition]:
113
+ """Get state transition history."""
114
+ return self._history.copy()
115
+
116
+ def can_transition_to(self, target_state: PluginState) -> bool:
117
+ """Check if transition to target state is possible.
118
+
119
+ Args:
120
+ target_state: Target state.
121
+
122
+ Returns:
123
+ True if transition is valid.
124
+ """
125
+ return is_valid_transition(self._state, target_state)
126
+
127
+ def get_available_transitions(self) -> list[PluginState]:
128
+ """Get states that can be transitioned to from current state.
129
+
130
+ Returns:
131
+ List of valid target states.
132
+ """
133
+ return get_valid_transitions(self._state)
134
+
135
+ def transition(
136
+ self,
137
+ target_state: PluginState,
138
+ trigger: str = "",
139
+ metadata: dict[str, Any] | None = None,
140
+ ) -> StateTransition:
141
+ """Transition to a new state synchronously.
142
+
143
+ Args:
144
+ target_state: Target state.
145
+ trigger: What triggered the transition.
146
+ metadata: Additional metadata.
147
+
148
+ Returns:
149
+ StateTransition record.
150
+
151
+ Raises:
152
+ StateTransitionError: If transition is invalid.
153
+ """
154
+ if not is_valid_transition(self._state, target_state):
155
+ raise StateTransitionError(self._state, target_state)
156
+
157
+ # Calculate time in previous state
158
+ now = datetime.utcnow()
159
+ duration_ms = (now - self._state_entered_at).total_seconds() * 1000
160
+
161
+ # Create transition record
162
+ transition = StateTransition(
163
+ from_state=self._state,
164
+ to_state=target_state,
165
+ timestamp=now,
166
+ trigger=trigger,
167
+ duration_ms=duration_ms,
168
+ metadata=metadata or {},
169
+ )
170
+
171
+ # Execute before callbacks
172
+ self._execute_before_callbacks(transition)
173
+
174
+ # Perform transition
175
+ self._previous_state = self._state
176
+ self._state = target_state
177
+ self._state_entered_at = now
178
+ self._history.append(transition)
179
+
180
+ # Execute after callbacks
181
+ self._execute_after_callbacks(transition)
182
+
183
+ logger.info(
184
+ f"Plugin {self.plugin_id}: {transition.from_state.value} -> "
185
+ f"{transition.to_state.value} (trigger: {trigger})"
186
+ )
187
+
188
+ return transition
189
+
190
+ async def transition_async(
191
+ self,
192
+ target_state: PluginState,
193
+ trigger: str = "",
194
+ metadata: dict[str, Any] | None = None,
195
+ ) -> StateTransition:
196
+ """Transition to a new state asynchronously.
197
+
198
+ Args:
199
+ target_state: Target state.
200
+ trigger: What triggered the transition.
201
+ metadata: Additional metadata.
202
+
203
+ Returns:
204
+ StateTransition record.
205
+
206
+ Raises:
207
+ StateTransitionError: If transition is invalid.
208
+ """
209
+ async with self._lock:
210
+ if not is_valid_transition(self._state, target_state):
211
+ raise StateTransitionError(self._state, target_state)
212
+
213
+ # Calculate time in previous state
214
+ now = datetime.utcnow()
215
+ duration_ms = (now - self._state_entered_at).total_seconds() * 1000
216
+
217
+ # Create transition record
218
+ transition = StateTransition(
219
+ from_state=self._state,
220
+ to_state=target_state,
221
+ timestamp=now,
222
+ trigger=trigger,
223
+ duration_ms=duration_ms,
224
+ metadata=metadata or {},
225
+ )
226
+
227
+ # Execute before callbacks
228
+ await self._execute_before_callbacks_async(transition)
229
+
230
+ # Perform transition
231
+ self._previous_state = self._state
232
+ self._state = target_state
233
+ self._state_entered_at = now
234
+ self._history.append(transition)
235
+
236
+ # Execute after callbacks
237
+ await self._execute_after_callbacks_async(transition)
238
+
239
+ logger.info(
240
+ f"Plugin {self.plugin_id}: {transition.from_state.value} -> "
241
+ f"{transition.to_state.value} (trigger: {trigger})"
242
+ )
243
+
244
+ return transition
245
+
246
+ def fail(self, error: str, trigger: str = "error") -> StateTransition:
247
+ """Transition to FAILED state.
248
+
249
+ Args:
250
+ error: Error message.
251
+ trigger: What triggered the failure.
252
+
253
+ Returns:
254
+ StateTransition record.
255
+ """
256
+ self._context.error = error
257
+ self._context.last_error_at = datetime.utcnow()
258
+
259
+ return self.transition(
260
+ PluginState.FAILED,
261
+ trigger=trigger,
262
+ metadata={"error": error},
263
+ )
264
+
265
+ def rollback(self, trigger: str = "rollback") -> StateTransition | None:
266
+ """Rollback to previous state if possible.
267
+
268
+ Args:
269
+ trigger: What triggered the rollback.
270
+
271
+ Returns:
272
+ StateTransition record, or None if rollback not possible.
273
+ """
274
+ if self._previous_state is None:
275
+ return None
276
+
277
+ if not is_valid_transition(self._state, self._previous_state):
278
+ return None
279
+
280
+ return self.transition(
281
+ self._previous_state,
282
+ trigger=trigger,
283
+ metadata={"rollback": True},
284
+ )
285
+
286
+ def on_before(
287
+ self,
288
+ state: PluginState,
289
+ callback: TransitionCallback,
290
+ ) -> None:
291
+ """Register a callback to run before entering a state.
292
+
293
+ Args:
294
+ state: Target state.
295
+ callback: Callback function.
296
+ """
297
+ if state not in self._before_callbacks:
298
+ self._before_callbacks[state] = []
299
+ self._before_callbacks[state].append(callback)
300
+
301
+ def on_after(
302
+ self,
303
+ state: PluginState,
304
+ callback: TransitionCallback,
305
+ ) -> None:
306
+ """Register a callback to run after entering a state.
307
+
308
+ Args:
309
+ state: Target state.
310
+ callback: Callback function.
311
+ """
312
+ if state not in self._after_callbacks:
313
+ self._after_callbacks[state] = []
314
+ self._after_callbacks[state].append(callback)
315
+
316
+ def on_transition(self, callback: TransitionCallback) -> None:
317
+ """Register a callback for any transition.
318
+
319
+ Args:
320
+ callback: Callback function.
321
+ """
322
+ self._on_any_transition.append(callback)
323
+
324
+ def _execute_before_callbacks(self, transition: StateTransition) -> None:
325
+ """Execute before callbacks synchronously."""
326
+ callbacks = self._before_callbacks.get(transition.to_state, [])
327
+ for callback in callbacks:
328
+ try:
329
+ result = callback(self, transition)
330
+ if asyncio.iscoroutine(result):
331
+ # Run async callback synchronously
332
+ asyncio.get_event_loop().run_until_complete(result)
333
+ except Exception as e:
334
+ logger.error(f"Before callback error: {e}")
335
+
336
+ def _execute_after_callbacks(self, transition: StateTransition) -> None:
337
+ """Execute after callbacks synchronously."""
338
+ callbacks = self._after_callbacks.get(transition.to_state, [])
339
+ callbacks.extend(self._on_any_transition)
340
+ for callback in callbacks:
341
+ try:
342
+ result = callback(self, transition)
343
+ if asyncio.iscoroutine(result):
344
+ asyncio.get_event_loop().run_until_complete(result)
345
+ except Exception as e:
346
+ logger.error(f"After callback error: {e}")
347
+
348
+ async def _execute_before_callbacks_async(
349
+ self, transition: StateTransition
350
+ ) -> None:
351
+ """Execute before callbacks asynchronously."""
352
+ callbacks = self._before_callbacks.get(transition.to_state, [])
353
+ for callback in callbacks:
354
+ try:
355
+ result = callback(self, transition)
356
+ if asyncio.iscoroutine(result):
357
+ await result
358
+ except Exception as e:
359
+ logger.error(f"Before callback error: {e}")
360
+
361
+ async def _execute_after_callbacks_async(
362
+ self, transition: StateTransition
363
+ ) -> None:
364
+ """Execute after callbacks asynchronously."""
365
+ callbacks = self._after_callbacks.get(transition.to_state, [])
366
+ callbacks.extend(self._on_any_transition)
367
+ for callback in callbacks:
368
+ try:
369
+ result = callback(self, transition)
370
+ if asyncio.iscoroutine(result):
371
+ await result
372
+ except Exception as e:
373
+ logger.error(f"After callback error: {e}")
374
+
375
+ def get_state_info(self) -> dict[str, Any]:
376
+ """Get information about current state.
377
+
378
+ Returns:
379
+ State information dictionary.
380
+ """
381
+ return {
382
+ "plugin_id": self.plugin_id,
383
+ "state": self._state.value,
384
+ "previous_state": self._previous_state.value if self._previous_state else None,
385
+ "state_entered_at": self._state_entered_at.isoformat(),
386
+ "available_transitions": [s.value for s in self.get_available_transitions()],
387
+ "history_length": len(self._history),
388
+ "context": {
389
+ "version": self._context.version,
390
+ "error": self._context.error,
391
+ "retry_count": self._context.retry_count,
392
+ },
393
+ }
394
+
395
+
396
+ def create_state_machine(
397
+ plugin_id: str,
398
+ version: str = "",
399
+ initial_state: PluginState = PluginState.DISCOVERED,
400
+ ) -> PluginStateMachine:
401
+ """Create a new plugin state machine.
402
+
403
+ Args:
404
+ plugin_id: Plugin identifier.
405
+ version: Plugin version.
406
+ initial_state: Initial state.
407
+
408
+ Returns:
409
+ Configured PluginStateMachine.
410
+ """
411
+ context = PluginContext(
412
+ plugin_id=plugin_id,
413
+ version=version,
414
+ )
415
+ return PluginStateMachine(
416
+ plugin_id=plugin_id,
417
+ initial_state=initial_state,
418
+ context=context,
419
+ )
@@ -0,0 +1,266 @@
1
+ """Plugin Lifecycle States.
2
+
3
+ This module defines the state machine states and transitions
4
+ for plugin lifecycle management.
5
+
6
+ State Diagram:
7
+ DISCOVERED --> LOADING --> LOADED --> ACTIVATING --> ACTIVE
8
+ | | | | |
9
+ v v v v v
10
+ FAILED FAILED FAILED FAILED DEACTIVATING
11
+ |
12
+ v
13
+ UNLOADING --> UNLOADED
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+ from datetime import datetime
20
+ from enum import Enum
21
+ from typing import Any
22
+
23
+
24
+ class PluginState(str, Enum):
25
+ """Plugin lifecycle states."""
26
+
27
+ # Initial state when plugin is found
28
+ DISCOVERED = "discovered"
29
+
30
+ # Plugin is being loaded (reading files, parsing)
31
+ LOADING = "loading"
32
+
33
+ # Plugin code is loaded but not active
34
+ LOADED = "loaded"
35
+
36
+ # Plugin is being activated (initializing)
37
+ ACTIVATING = "activating"
38
+
39
+ # Plugin is active and running
40
+ ACTIVE = "active"
41
+
42
+ # Plugin is being deactivated
43
+ DEACTIVATING = "deactivating"
44
+
45
+ # Plugin is being unloaded
46
+ UNLOADING = "unloading"
47
+
48
+ # Plugin has been unloaded
49
+ UNLOADED = "unloaded"
50
+
51
+ # Plugin failed to load or activate
52
+ FAILED = "failed"
53
+
54
+ # Plugin is being reloaded (hot reload)
55
+ RELOADING = "reloading"
56
+
57
+ # Plugin is being upgraded to new version
58
+ UPGRADING = "upgrading"
59
+
60
+
61
+ class StateTransitionError(Exception):
62
+ """Raised when an invalid state transition is attempted."""
63
+
64
+ def __init__(
65
+ self,
66
+ current_state: PluginState,
67
+ target_state: PluginState,
68
+ message: str | None = None,
69
+ ) -> None:
70
+ """Initialize the error.
71
+
72
+ Args:
73
+ current_state: Current plugin state.
74
+ target_state: Attempted target state.
75
+ message: Optional error message.
76
+ """
77
+ self.current_state = current_state
78
+ self.target_state = target_state
79
+ msg = message or f"Cannot transition from {current_state.value} to {target_state.value}"
80
+ super().__init__(msg)
81
+
82
+
83
+ @dataclass
84
+ class StateTransition:
85
+ """Represents a state transition event.
86
+
87
+ Attributes:
88
+ from_state: Previous state.
89
+ to_state: New state.
90
+ timestamp: When the transition occurred.
91
+ trigger: What triggered the transition.
92
+ duration_ms: Time spent in previous state.
93
+ metadata: Additional transition metadata.
94
+ error: Error message if transition failed.
95
+ """
96
+
97
+ from_state: PluginState
98
+ to_state: PluginState
99
+ timestamp: datetime = field(default_factory=datetime.utcnow)
100
+ trigger: str = ""
101
+ duration_ms: float = 0
102
+ metadata: dict[str, Any] = field(default_factory=dict)
103
+ error: str | None = None
104
+
105
+ def to_dict(self) -> dict[str, Any]:
106
+ """Convert to dictionary."""
107
+ return {
108
+ "from_state": self.from_state.value,
109
+ "to_state": self.to_state.value,
110
+ "timestamp": self.timestamp.isoformat(),
111
+ "trigger": self.trigger,
112
+ "duration_ms": self.duration_ms,
113
+ "metadata": self.metadata,
114
+ "error": self.error,
115
+ }
116
+
117
+
118
+ # Valid state transitions
119
+ VALID_TRANSITIONS: dict[PluginState, set[PluginState]] = {
120
+ PluginState.DISCOVERED: {
121
+ PluginState.LOADING,
122
+ PluginState.FAILED,
123
+ PluginState.UNLOADED,
124
+ },
125
+ PluginState.LOADING: {
126
+ PluginState.LOADED,
127
+ PluginState.FAILED,
128
+ },
129
+ PluginState.LOADED: {
130
+ PluginState.ACTIVATING,
131
+ PluginState.UNLOADING,
132
+ PluginState.FAILED,
133
+ },
134
+ PluginState.ACTIVATING: {
135
+ PluginState.ACTIVE,
136
+ PluginState.FAILED,
137
+ PluginState.LOADED, # Rollback on failure
138
+ },
139
+ PluginState.ACTIVE: {
140
+ PluginState.DEACTIVATING,
141
+ PluginState.RELOADING,
142
+ PluginState.UPGRADING,
143
+ PluginState.FAILED,
144
+ },
145
+ PluginState.DEACTIVATING: {
146
+ PluginState.LOADED,
147
+ PluginState.UNLOADING,
148
+ PluginState.FAILED,
149
+ },
150
+ PluginState.UNLOADING: {
151
+ PluginState.UNLOADED,
152
+ PluginState.FAILED,
153
+ },
154
+ PluginState.UNLOADED: {
155
+ PluginState.DISCOVERED, # Re-install
156
+ PluginState.LOADING, # Re-load
157
+ },
158
+ PluginState.FAILED: {
159
+ PluginState.DISCOVERED, # Retry discovery
160
+ PluginState.LOADING, # Retry load
161
+ PluginState.UNLOADING, # Unload failed plugin
162
+ },
163
+ PluginState.RELOADING: {
164
+ PluginState.ACTIVE,
165
+ PluginState.FAILED,
166
+ PluginState.LOADED, # Rollback
167
+ },
168
+ PluginState.UPGRADING: {
169
+ PluginState.ACTIVE,
170
+ PluginState.FAILED,
171
+ PluginState.LOADED, # Rollback
172
+ },
173
+ }
174
+
175
+
176
+ def is_valid_transition(from_state: PluginState, to_state: PluginState) -> bool:
177
+ """Check if a state transition is valid.
178
+
179
+ Args:
180
+ from_state: Current state.
181
+ to_state: Target state.
182
+
183
+ Returns:
184
+ True if transition is valid.
185
+ """
186
+ valid_targets = VALID_TRANSITIONS.get(from_state, set())
187
+ return to_state in valid_targets
188
+
189
+
190
+ def get_valid_transitions(state: PluginState) -> list[PluginState]:
191
+ """Get valid target states from a given state.
192
+
193
+ Args:
194
+ state: Current state.
195
+
196
+ Returns:
197
+ List of valid target states.
198
+ """
199
+ return list(VALID_TRANSITIONS.get(state, []))
200
+
201
+
202
+ def can_activate(state: PluginState) -> bool:
203
+ """Check if plugin can be activated from current state.
204
+
205
+ Args:
206
+ state: Current state.
207
+
208
+ Returns:
209
+ True if activation is possible.
210
+ """
211
+ return state in {PluginState.LOADED, PluginState.FAILED}
212
+
213
+
214
+ def can_deactivate(state: PluginState) -> bool:
215
+ """Check if plugin can be deactivated from current state.
216
+
217
+ Args:
218
+ state: Current state.
219
+
220
+ Returns:
221
+ True if deactivation is possible.
222
+ """
223
+ return state == PluginState.ACTIVE
224
+
225
+
226
+ def is_running(state: PluginState) -> bool:
227
+ """Check if plugin is in a running state.
228
+
229
+ Args:
230
+ state: Current state.
231
+
232
+ Returns:
233
+ True if plugin is running.
234
+ """
235
+ return state == PluginState.ACTIVE
236
+
237
+
238
+ def is_transitioning(state: PluginState) -> bool:
239
+ """Check if plugin is in a transitional state.
240
+
241
+ Args:
242
+ state: Current state.
243
+
244
+ Returns:
245
+ True if plugin is transitioning.
246
+ """
247
+ return state in {
248
+ PluginState.LOADING,
249
+ PluginState.ACTIVATING,
250
+ PluginState.DEACTIVATING,
251
+ PluginState.UNLOADING,
252
+ PluginState.RELOADING,
253
+ PluginState.UPGRADING,
254
+ }
255
+
256
+
257
+ def is_terminal(state: PluginState) -> bool:
258
+ """Check if plugin is in a terminal state.
259
+
260
+ Args:
261
+ state: Current state.
262
+
263
+ Returns:
264
+ True if plugin is in terminal state.
265
+ """
266
+ return state in {PluginState.UNLOADED, PluginState.FAILED}