truthound-dashboard 1.3.1__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.1.dist-info → truthound_dashboard-1.4.0.dist-info}/METADATA +142 -22
  162. truthound_dashboard-1.4.0.dist-info/RECORD +239 -0
  163. truthound_dashboard/static/assets/index-BZG20KuF.js +0 -586
  164. truthound_dashboard/static/assets/index-D_HyZ3pb.css +0 -1
  165. truthound_dashboard/static/assets/unmerged_dictionaries-CtpqQBm0.js +0 -1
  166. truthound_dashboard-1.3.1.dist-info/RECORD +0 -110
  167. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/WHEEL +0 -0
  168. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/entry_points.txt +0 -0
  169. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,584 @@
1
+ """Hot Reload Support.
2
+
3
+ This module provides hot reload capability for plugins,
4
+ including file watching and graceful reload with rollback.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import hashlib
11
+ import logging
12
+ import os
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime
16
+ from enum import Enum
17
+ from pathlib import Path
18
+ from typing import Any, Callable, Awaitable
19
+
20
+ from .states import PluginState
21
+ from .machine import PluginStateMachine
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class ReloadStrategy(str, Enum):
27
+ """Strategy for handling hot reloads."""
28
+
29
+ IMMEDIATE = "immediate" # Reload immediately on change
30
+ DEBOUNCED = "debounced" # Wait for changes to settle
31
+ MANUAL = "manual" # Only reload on manual trigger
32
+ SCHEDULED = "scheduled" # Reload at scheduled intervals
33
+
34
+
35
+ @dataclass
36
+ class ReloadResult:
37
+ """Result of a reload operation.
38
+
39
+ Attributes:
40
+ success: Whether reload succeeded.
41
+ plugin_id: Plugin that was reloaded.
42
+ old_version: Previous version.
43
+ new_version: New version after reload.
44
+ duration_ms: Time taken for reload.
45
+ error: Error message if failed.
46
+ rolled_back: Whether rollback was performed.
47
+ changes: List of changes detected.
48
+ """
49
+
50
+ success: bool
51
+ plugin_id: str
52
+ old_version: str = ""
53
+ new_version: str = ""
54
+ duration_ms: float = 0
55
+ error: str | None = None
56
+ rolled_back: bool = False
57
+ changes: list[str] = field(default_factory=list)
58
+
59
+ def to_dict(self) -> dict[str, Any]:
60
+ """Convert to dictionary."""
61
+ return {
62
+ "success": self.success,
63
+ "plugin_id": self.plugin_id,
64
+ "old_version": self.old_version,
65
+ "new_version": self.new_version,
66
+ "duration_ms": self.duration_ms,
67
+ "error": self.error,
68
+ "rolled_back": self.rolled_back,
69
+ "changes": self.changes,
70
+ }
71
+
72
+
73
+ @dataclass
74
+ class FileChange:
75
+ """Represents a file change event.
76
+
77
+ Attributes:
78
+ path: File path.
79
+ event_type: Type of change (created, modified, deleted).
80
+ timestamp: When the change was detected.
81
+ content_hash: Hash of new content (if applicable).
82
+ """
83
+
84
+ path: str
85
+ event_type: str
86
+ timestamp: datetime = field(default_factory=datetime.utcnow)
87
+ content_hash: str = ""
88
+
89
+
90
+ class FileWatcher:
91
+ """Watches files for changes to trigger hot reload.
92
+
93
+ Uses polling by default; can be extended to use
94
+ inotify/FSEvents/ReadDirectoryChangesW for better performance.
95
+ """
96
+
97
+ def __init__(
98
+ self,
99
+ paths: list[str | Path],
100
+ patterns: list[str] | None = None,
101
+ poll_interval: float = 1.0,
102
+ ) -> None:
103
+ """Initialize the file watcher.
104
+
105
+ Args:
106
+ paths: Paths to watch (directories or files).
107
+ patterns: File patterns to watch (e.g., ["*.py"]).
108
+ poll_interval: Polling interval in seconds.
109
+ """
110
+ self._paths = [Path(p) for p in paths]
111
+ self._patterns = patterns or ["*.py"]
112
+ self._poll_interval = poll_interval
113
+ self._running = False
114
+ self._file_hashes: dict[str, str] = {}
115
+ self._callbacks: list[Callable[[FileChange], None]] = []
116
+ self._task: asyncio.Task | None = None
117
+
118
+ def on_change(self, callback: Callable[[FileChange], None]) -> None:
119
+ """Register a callback for file changes.
120
+
121
+ Args:
122
+ callback: Callback function.
123
+ """
124
+ self._callbacks.append(callback)
125
+
126
+ async def start(self) -> None:
127
+ """Start watching for changes."""
128
+ if self._running:
129
+ return
130
+
131
+ self._running = True
132
+ self._file_hashes = self._scan_files()
133
+ self._task = asyncio.create_task(self._watch_loop())
134
+ logger.info(f"Started file watcher for {len(self._paths)} paths")
135
+
136
+ async def stop(self) -> None:
137
+ """Stop watching for changes."""
138
+ self._running = False
139
+ if self._task:
140
+ self._task.cancel()
141
+ try:
142
+ await self._task
143
+ except asyncio.CancelledError:
144
+ pass
145
+ self._task = None
146
+ logger.info("Stopped file watcher")
147
+
148
+ def _scan_files(self) -> dict[str, str]:
149
+ """Scan watched paths and compute file hashes.
150
+
151
+ Returns:
152
+ Dict mapping file paths to content hashes.
153
+ """
154
+ hashes: dict[str, str] = {}
155
+
156
+ for base_path in self._paths:
157
+ if not base_path.exists():
158
+ continue
159
+
160
+ if base_path.is_file():
161
+ hashes[str(base_path)] = self._compute_hash(base_path)
162
+ else:
163
+ for pattern in self._patterns:
164
+ for file_path in base_path.rglob(pattern):
165
+ if file_path.is_file():
166
+ hashes[str(file_path)] = self._compute_hash(file_path)
167
+
168
+ return hashes
169
+
170
+ def _compute_hash(self, path: Path) -> str:
171
+ """Compute hash of file content.
172
+
173
+ Args:
174
+ path: File path.
175
+
176
+ Returns:
177
+ SHA256 hash of content.
178
+ """
179
+ try:
180
+ content = path.read_bytes()
181
+ return hashlib.sha256(content).hexdigest()
182
+ except Exception:
183
+ return ""
184
+
185
+ async def _watch_loop(self) -> None:
186
+ """Main watch loop."""
187
+ while self._running:
188
+ try:
189
+ await asyncio.sleep(self._poll_interval)
190
+ changes = self._detect_changes()
191
+ for change in changes:
192
+ for callback in self._callbacks:
193
+ try:
194
+ callback(change)
195
+ except Exception as e:
196
+ logger.error(f"File change callback error: {e}")
197
+ except asyncio.CancelledError:
198
+ break
199
+ except Exception as e:
200
+ logger.error(f"Watch loop error: {e}")
201
+
202
+ def _detect_changes(self) -> list[FileChange]:
203
+ """Detect file changes since last scan.
204
+
205
+ Returns:
206
+ List of file changes.
207
+ """
208
+ changes: list[FileChange] = []
209
+ current_hashes = self._scan_files()
210
+
211
+ # Check for modified and new files
212
+ for path, new_hash in current_hashes.items():
213
+ old_hash = self._file_hashes.get(path)
214
+ if old_hash is None:
215
+ changes.append(
216
+ FileChange(
217
+ path=path,
218
+ event_type="created",
219
+ content_hash=new_hash,
220
+ )
221
+ )
222
+ elif old_hash != new_hash:
223
+ changes.append(
224
+ FileChange(
225
+ path=path,
226
+ event_type="modified",
227
+ content_hash=new_hash,
228
+ )
229
+ )
230
+
231
+ # Check for deleted files
232
+ for path in self._file_hashes:
233
+ if path not in current_hashes:
234
+ changes.append(
235
+ FileChange(
236
+ path=path,
237
+ event_type="deleted",
238
+ )
239
+ )
240
+
241
+ self._file_hashes = current_hashes
242
+ return changes
243
+
244
+
245
+ class HotReloadManager:
246
+ """Manages hot reload for plugins.
247
+
248
+ This class coordinates the reload process:
249
+ 1. Detect changes (via file watcher or manual trigger)
250
+ 2. Save current plugin state
251
+ 3. Unload old version
252
+ 4. Load new version
253
+ 5. Restore state or rollback on failure
254
+ """
255
+
256
+ def __init__(
257
+ self,
258
+ strategy: ReloadStrategy = ReloadStrategy.DEBOUNCED,
259
+ debounce_delay: float = 0.5,
260
+ ) -> None:
261
+ """Initialize the hot reload manager.
262
+
263
+ Args:
264
+ strategy: Reload strategy.
265
+ debounce_delay: Delay for debounced strategy.
266
+ """
267
+ self._strategy = strategy
268
+ self._debounce_delay = debounce_delay
269
+ self._state_machines: dict[str, PluginStateMachine] = {}
270
+ self._watchers: dict[str, FileWatcher] = {}
271
+ self._pending_reloads: dict[str, asyncio.Task] = {}
272
+ self._saved_states: dict[str, dict[str, Any]] = {}
273
+
274
+ # Callbacks
275
+ self._before_reload: list[Callable[[str], Awaitable[None]]] = []
276
+ self._after_reload: list[Callable[[str, ReloadResult], Awaitable[None]]] = []
277
+
278
+ def register_plugin(
279
+ self,
280
+ plugin_id: str,
281
+ state_machine: PluginStateMachine,
282
+ watch_paths: list[str | Path] | None = None,
283
+ ) -> None:
284
+ """Register a plugin for hot reload.
285
+
286
+ Args:
287
+ plugin_id: Plugin identifier.
288
+ state_machine: Plugin's state machine.
289
+ watch_paths: Paths to watch for changes.
290
+ """
291
+ self._state_machines[plugin_id] = state_machine
292
+
293
+ if watch_paths and self._strategy != ReloadStrategy.MANUAL:
294
+ watcher = FileWatcher(watch_paths)
295
+ watcher.on_change(lambda change: self._on_file_change(plugin_id, change))
296
+ self._watchers[plugin_id] = watcher
297
+
298
+ def unregister_plugin(self, plugin_id: str) -> None:
299
+ """Unregister a plugin from hot reload.
300
+
301
+ Args:
302
+ plugin_id: Plugin identifier.
303
+ """
304
+ if plugin_id in self._watchers:
305
+ asyncio.create_task(self._watchers[plugin_id].stop())
306
+ del self._watchers[plugin_id]
307
+
308
+ if plugin_id in self._state_machines:
309
+ del self._state_machines[plugin_id]
310
+
311
+ if plugin_id in self._pending_reloads:
312
+ self._pending_reloads[plugin_id].cancel()
313
+ del self._pending_reloads[plugin_id]
314
+
315
+ def on_before_reload(
316
+ self, callback: Callable[[str], Awaitable[None]]
317
+ ) -> None:
318
+ """Register a callback to run before reload.
319
+
320
+ Args:
321
+ callback: Callback function (receives plugin_id).
322
+ """
323
+ self._before_reload.append(callback)
324
+
325
+ def on_after_reload(
326
+ self, callback: Callable[[str, ReloadResult], Awaitable[None]]
327
+ ) -> None:
328
+ """Register a callback to run after reload.
329
+
330
+ Args:
331
+ callback: Callback function (receives plugin_id, result).
332
+ """
333
+ self._after_reload.append(callback)
334
+
335
+ async def start_watching(self, plugin_id: str | None = None) -> None:
336
+ """Start watching for changes.
337
+
338
+ Args:
339
+ plugin_id: Specific plugin to watch, or None for all.
340
+ """
341
+ if plugin_id:
342
+ if plugin_id in self._watchers:
343
+ await self._watchers[plugin_id].start()
344
+ else:
345
+ for watcher in self._watchers.values():
346
+ await watcher.start()
347
+
348
+ async def stop_watching(self, plugin_id: str | None = None) -> None:
349
+ """Stop watching for changes.
350
+
351
+ Args:
352
+ plugin_id: Specific plugin to stop, or None for all.
353
+ """
354
+ if plugin_id:
355
+ if plugin_id in self._watchers:
356
+ await self._watchers[plugin_id].stop()
357
+ else:
358
+ for watcher in self._watchers.values():
359
+ await watcher.stop()
360
+
361
+ def _on_file_change(self, plugin_id: str, change: FileChange) -> None:
362
+ """Handle file change event.
363
+
364
+ Args:
365
+ plugin_id: Plugin that owns the file.
366
+ change: File change event.
367
+ """
368
+ logger.debug(f"File change detected for {plugin_id}: {change.path}")
369
+
370
+ if self._strategy == ReloadStrategy.IMMEDIATE:
371
+ asyncio.create_task(self.reload(plugin_id, changes=[change.path]))
372
+ elif self._strategy == ReloadStrategy.DEBOUNCED:
373
+ self._schedule_debounced_reload(plugin_id, change)
374
+
375
+ def _schedule_debounced_reload(
376
+ self, plugin_id: str, change: FileChange
377
+ ) -> None:
378
+ """Schedule a debounced reload.
379
+
380
+ Args:
381
+ plugin_id: Plugin to reload.
382
+ change: Triggering change.
383
+ """
384
+ # Cancel existing pending reload
385
+ if plugin_id in self._pending_reloads:
386
+ self._pending_reloads[plugin_id].cancel()
387
+
388
+ # Schedule new reload
389
+ async def delayed_reload():
390
+ await asyncio.sleep(self._debounce_delay)
391
+ await self.reload(plugin_id, changes=[change.path])
392
+
393
+ self._pending_reloads[plugin_id] = asyncio.create_task(delayed_reload())
394
+
395
+ async def reload(
396
+ self,
397
+ plugin_id: str,
398
+ changes: list[str] | None = None,
399
+ force: bool = False,
400
+ ) -> ReloadResult:
401
+ """Reload a plugin.
402
+
403
+ Args:
404
+ plugin_id: Plugin to reload.
405
+ changes: List of changed files.
406
+ force: Force reload even if no changes detected.
407
+
408
+ Returns:
409
+ ReloadResult with reload outcome.
410
+ """
411
+ start_time = time.perf_counter()
412
+
413
+ if plugin_id not in self._state_machines:
414
+ return ReloadResult(
415
+ success=False,
416
+ plugin_id=plugin_id,
417
+ error=f"Plugin {plugin_id} not registered for hot reload",
418
+ )
419
+
420
+ state_machine = self._state_machines[plugin_id]
421
+ old_version = state_machine.context.version
422
+
423
+ # Check if plugin can be reloaded
424
+ if state_machine.state not in {PluginState.ACTIVE, PluginState.LOADED}:
425
+ return ReloadResult(
426
+ success=False,
427
+ plugin_id=plugin_id,
428
+ old_version=old_version,
429
+ error=f"Cannot reload plugin in state {state_machine.state.value}",
430
+ )
431
+
432
+ try:
433
+ # Execute before callbacks
434
+ for callback in self._before_reload:
435
+ await callback(plugin_id)
436
+
437
+ # Save current state
438
+ self._saved_states[plugin_id] = self._save_state(state_machine)
439
+
440
+ # Transition to RELOADING
441
+ await state_machine.transition_async(
442
+ PluginState.RELOADING,
443
+ trigger="hot_reload",
444
+ metadata={"changes": changes},
445
+ )
446
+
447
+ # Perform reload (plugin-specific logic would go here)
448
+ # For now, we simulate by transitioning back to ACTIVE
449
+ await asyncio.sleep(0.1) # Simulate reload time
450
+
451
+ # Transition back to ACTIVE
452
+ await state_machine.transition_async(
453
+ PluginState.ACTIVE,
454
+ trigger="reload_complete",
455
+ )
456
+
457
+ duration_ms = (time.perf_counter() - start_time) * 1000
458
+ result = ReloadResult(
459
+ success=True,
460
+ plugin_id=plugin_id,
461
+ old_version=old_version,
462
+ new_version=state_machine.context.version,
463
+ duration_ms=duration_ms,
464
+ changes=changes or [],
465
+ )
466
+
467
+ # Execute after callbacks
468
+ for callback in self._after_reload:
469
+ await callback(plugin_id, result)
470
+
471
+ logger.info(
472
+ f"Hot reload completed for {plugin_id} in {duration_ms:.2f}ms"
473
+ )
474
+
475
+ return result
476
+
477
+ except Exception as e:
478
+ # Attempt rollback
479
+ rolled_back = await self._rollback(plugin_id)
480
+
481
+ duration_ms = (time.perf_counter() - start_time) * 1000
482
+ result = ReloadResult(
483
+ success=False,
484
+ plugin_id=plugin_id,
485
+ old_version=old_version,
486
+ duration_ms=duration_ms,
487
+ error=str(e),
488
+ rolled_back=rolled_back,
489
+ changes=changes or [],
490
+ )
491
+
492
+ logger.error(f"Hot reload failed for {plugin_id}: {e}")
493
+
494
+ # Execute after callbacks
495
+ for callback in self._after_reload:
496
+ await callback(plugin_id, result)
497
+
498
+ return result
499
+
500
+ def _save_state(self, state_machine: PluginStateMachine) -> dict[str, Any]:
501
+ """Save plugin state for potential rollback.
502
+
503
+ Args:
504
+ state_machine: Plugin's state machine.
505
+
506
+ Returns:
507
+ Saved state data.
508
+ """
509
+ return {
510
+ "state": state_machine.state.value,
511
+ "version": state_machine.context.version,
512
+ "config": state_machine.context.config.copy(),
513
+ "state_data": state_machine.context.state_data.copy(),
514
+ }
515
+
516
+ async def _rollback(self, plugin_id: str) -> bool:
517
+ """Attempt to rollback a failed reload.
518
+
519
+ Args:
520
+ plugin_id: Plugin to rollback.
521
+
522
+ Returns:
523
+ True if rollback succeeded.
524
+ """
525
+ if plugin_id not in self._saved_states:
526
+ return False
527
+
528
+ if plugin_id not in self._state_machines:
529
+ return False
530
+
531
+ state_machine = self._state_machines[plugin_id]
532
+ saved = self._saved_states[plugin_id]
533
+
534
+ try:
535
+ # Restore state
536
+ state_machine.context.version = saved["version"]
537
+ state_machine.context.config = saved["config"]
538
+ state_machine.context.state_data = saved["state_data"]
539
+
540
+ # Transition back to previous state
541
+ target_state = PluginState(saved["state"])
542
+ if state_machine.can_transition_to(target_state):
543
+ await state_machine.transition_async(
544
+ target_state,
545
+ trigger="rollback",
546
+ )
547
+ logger.info(f"Successfully rolled back {plugin_id}")
548
+ return True
549
+ else:
550
+ # Try to go to LOADED as fallback
551
+ if state_machine.can_transition_to(PluginState.LOADED):
552
+ await state_machine.transition_async(
553
+ PluginState.LOADED,
554
+ trigger="rollback_fallback",
555
+ )
556
+ return True
557
+
558
+ except Exception as e:
559
+ logger.error(f"Rollback failed for {plugin_id}: {e}")
560
+
561
+ return False
562
+
563
+ def get_status(self, plugin_id: str) -> dict[str, Any]:
564
+ """Get hot reload status for a plugin.
565
+
566
+ Args:
567
+ plugin_id: Plugin identifier.
568
+
569
+ Returns:
570
+ Status information.
571
+ """
572
+ if plugin_id not in self._state_machines:
573
+ return {"registered": False}
574
+
575
+ state_machine = self._state_machines[plugin_id]
576
+ watcher = self._watchers.get(plugin_id)
577
+
578
+ return {
579
+ "registered": True,
580
+ "state": state_machine.state.value,
581
+ "watching": watcher._running if watcher else False,
582
+ "strategy": self._strategy.value,
583
+ "has_pending_reload": plugin_id in self._pending_reloads,
584
+ }