truthound-dashboard 1.4.4__py3-none-any.whl → 1.5.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.
- truthound_dashboard/api/alerts.py +75 -86
- truthound_dashboard/api/anomaly.py +7 -13
- truthound_dashboard/api/cross_alerts.py +38 -52
- truthound_dashboard/api/drift.py +49 -59
- truthound_dashboard/api/drift_monitor.py +234 -79
- truthound_dashboard/api/enterprise_sampling.py +498 -0
- truthound_dashboard/api/history.py +57 -5
- truthound_dashboard/api/lineage.py +3 -48
- truthound_dashboard/api/maintenance.py +104 -49
- truthound_dashboard/api/mask.py +1 -2
- truthound_dashboard/api/middleware.py +2 -1
- truthound_dashboard/api/model_monitoring.py +435 -311
- truthound_dashboard/api/notifications.py +227 -191
- truthound_dashboard/api/notifications_advanced.py +21 -20
- truthound_dashboard/api/observability.py +586 -0
- truthound_dashboard/api/plugins.py +2 -433
- truthound_dashboard/api/profile.py +199 -37
- truthound_dashboard/api/quality_reporter.py +701 -0
- truthound_dashboard/api/reports.py +7 -16
- truthound_dashboard/api/router.py +66 -0
- truthound_dashboard/api/rule_suggestions.py +5 -5
- truthound_dashboard/api/scan.py +17 -19
- truthound_dashboard/api/schedules.py +85 -50
- truthound_dashboard/api/schema_evolution.py +6 -6
- truthound_dashboard/api/schema_watcher.py +667 -0
- truthound_dashboard/api/sources.py +98 -27
- truthound_dashboard/api/tiering.py +1323 -0
- truthound_dashboard/api/triggers.py +14 -11
- truthound_dashboard/api/validations.py +12 -11
- truthound_dashboard/api/versioning.py +1 -6
- truthound_dashboard/core/__init__.py +129 -3
- truthound_dashboard/core/actions/__init__.py +62 -0
- truthound_dashboard/core/actions/custom.py +426 -0
- truthound_dashboard/core/actions/notifications.py +910 -0
- truthound_dashboard/core/actions/storage.py +472 -0
- truthound_dashboard/core/actions/webhook.py +281 -0
- truthound_dashboard/core/anomaly.py +262 -67
- truthound_dashboard/core/anomaly_explainer.py +4 -3
- truthound_dashboard/core/backends/__init__.py +67 -0
- truthound_dashboard/core/backends/base.py +299 -0
- truthound_dashboard/core/backends/errors.py +191 -0
- truthound_dashboard/core/backends/factory.py +423 -0
- truthound_dashboard/core/backends/mock_backend.py +451 -0
- truthound_dashboard/core/backends/truthound_backend.py +718 -0
- truthound_dashboard/core/checkpoint/__init__.py +87 -0
- truthound_dashboard/core/checkpoint/adapters.py +814 -0
- truthound_dashboard/core/checkpoint/checkpoint.py +491 -0
- truthound_dashboard/core/checkpoint/runner.py +270 -0
- truthound_dashboard/core/connections.py +437 -10
- truthound_dashboard/core/converters/__init__.py +14 -0
- truthound_dashboard/core/converters/truthound.py +620 -0
- truthound_dashboard/core/cross_alerts.py +540 -320
- truthound_dashboard/core/datasource_factory.py +1672 -0
- truthound_dashboard/core/drift_monitor.py +216 -20
- truthound_dashboard/core/enterprise_sampling.py +1291 -0
- truthound_dashboard/core/interfaces/__init__.py +225 -0
- truthound_dashboard/core/interfaces/actions.py +652 -0
- truthound_dashboard/core/interfaces/base.py +247 -0
- truthound_dashboard/core/interfaces/checkpoint.py +676 -0
- truthound_dashboard/core/interfaces/protocols.py +664 -0
- truthound_dashboard/core/interfaces/reporters.py +650 -0
- truthound_dashboard/core/interfaces/routing.py +646 -0
- truthound_dashboard/core/interfaces/triggers.py +619 -0
- truthound_dashboard/core/lineage.py +407 -71
- truthound_dashboard/core/model_monitoring.py +431 -3
- truthound_dashboard/core/notifications/base.py +4 -0
- truthound_dashboard/core/notifications/channels.py +501 -1203
- truthound_dashboard/core/notifications/deduplication/__init__.py +81 -115
- truthound_dashboard/core/notifications/deduplication/service.py +131 -348
- truthound_dashboard/core/notifications/dispatcher.py +202 -11
- truthound_dashboard/core/notifications/escalation/__init__.py +119 -106
- truthound_dashboard/core/notifications/escalation/engine.py +168 -358
- truthound_dashboard/core/notifications/routing/__init__.py +88 -128
- truthound_dashboard/core/notifications/routing/engine.py +90 -317
- truthound_dashboard/core/notifications/stats_aggregator.py +246 -1
- truthound_dashboard/core/notifications/throttling/__init__.py +67 -50
- truthound_dashboard/core/notifications/throttling/builder.py +117 -255
- truthound_dashboard/core/notifications/truthound_adapter.py +842 -0
- truthound_dashboard/core/phase5/collaboration.py +1 -1
- truthound_dashboard/core/plugins/lifecycle/__init__.py +0 -13
- truthound_dashboard/core/quality_reporter.py +1359 -0
- truthound_dashboard/core/report_history.py +0 -6
- truthound_dashboard/core/reporters/__init__.py +175 -14
- truthound_dashboard/core/reporters/adapters.py +943 -0
- truthound_dashboard/core/reporters/base.py +0 -3
- truthound_dashboard/core/reporters/builtin/__init__.py +18 -0
- truthound_dashboard/core/reporters/builtin/csv_reporter.py +111 -0
- truthound_dashboard/core/reporters/builtin/html_reporter.py +270 -0
- truthound_dashboard/core/reporters/builtin/json_reporter.py +127 -0
- truthound_dashboard/core/reporters/compat.py +266 -0
- truthound_dashboard/core/reporters/csv_reporter.py +2 -35
- truthound_dashboard/core/reporters/factory.py +526 -0
- truthound_dashboard/core/reporters/interfaces.py +745 -0
- truthound_dashboard/core/reporters/registry.py +1 -10
- truthound_dashboard/core/scheduler.py +165 -0
- truthound_dashboard/core/schema_evolution.py +3 -3
- truthound_dashboard/core/schema_watcher.py +1528 -0
- truthound_dashboard/core/services.py +595 -76
- truthound_dashboard/core/store_manager.py +810 -0
- truthound_dashboard/core/streaming_anomaly.py +169 -4
- truthound_dashboard/core/tiering.py +1309 -0
- truthound_dashboard/core/triggers/evaluators.py +178 -8
- truthound_dashboard/core/truthound_adapter.py +2620 -197
- truthound_dashboard/core/unified_alerts.py +23 -20
- truthound_dashboard/db/__init__.py +8 -0
- truthound_dashboard/db/database.py +8 -2
- truthound_dashboard/db/models.py +944 -25
- truthound_dashboard/db/repository.py +2 -0
- truthound_dashboard/main.py +11 -0
- truthound_dashboard/schemas/__init__.py +177 -16
- truthound_dashboard/schemas/base.py +44 -23
- truthound_dashboard/schemas/collaboration.py +19 -6
- truthound_dashboard/schemas/cross_alerts.py +19 -3
- truthound_dashboard/schemas/drift.py +61 -55
- truthound_dashboard/schemas/drift_monitor.py +67 -23
- truthound_dashboard/schemas/enterprise_sampling.py +653 -0
- truthound_dashboard/schemas/lineage.py +0 -33
- truthound_dashboard/schemas/mask.py +10 -8
- truthound_dashboard/schemas/model_monitoring.py +89 -10
- truthound_dashboard/schemas/notifications_advanced.py +13 -0
- truthound_dashboard/schemas/observability.py +453 -0
- truthound_dashboard/schemas/plugins.py +0 -280
- truthound_dashboard/schemas/profile.py +154 -247
- truthound_dashboard/schemas/quality_reporter.py +403 -0
- truthound_dashboard/schemas/reports.py +2 -2
- truthound_dashboard/schemas/rule_suggestion.py +8 -1
- truthound_dashboard/schemas/scan.py +4 -24
- truthound_dashboard/schemas/schedule.py +11 -3
- truthound_dashboard/schemas/schema_watcher.py +727 -0
- truthound_dashboard/schemas/source.py +17 -2
- truthound_dashboard/schemas/tiering.py +822 -0
- truthound_dashboard/schemas/triggers.py +16 -0
- truthound_dashboard/schemas/unified_alerts.py +7 -0
- truthound_dashboard/schemas/validation.py +0 -13
- truthound_dashboard/schemas/validators/base.py +41 -21
- truthound_dashboard/schemas/validators/business_rule_validators.py +244 -0
- truthound_dashboard/schemas/validators/localization_validators.py +273 -0
- truthound_dashboard/schemas/validators/ml_feature_validators.py +308 -0
- truthound_dashboard/schemas/validators/profiling_validators.py +275 -0
- truthound_dashboard/schemas/validators/referential_validators.py +312 -0
- truthound_dashboard/schemas/validators/registry.py +93 -8
- truthound_dashboard/schemas/validators/timeseries_validators.py +389 -0
- truthound_dashboard/schemas/versioning.py +1 -6
- truthound_dashboard/static/index.html +2 -2
- truthound_dashboard-1.5.0.dist-info/METADATA +309 -0
- {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/RECORD +149 -148
- truthound_dashboard/core/plugins/hooks/__init__.py +0 -63
- truthound_dashboard/core/plugins/hooks/decorators.py +0 -367
- truthound_dashboard/core/plugins/hooks/manager.py +0 -403
- truthound_dashboard/core/plugins/hooks/protocols.py +0 -265
- truthound_dashboard/core/plugins/lifecycle/hot_reload.py +0 -584
- truthound_dashboard/core/reporters/junit_reporter.py +0 -233
- truthound_dashboard/core/reporters/markdown_reporter.py +0 -207
- truthound_dashboard/core/reporters/pdf_reporter.py +0 -209
- truthound_dashboard/static/assets/_baseUniq-BcrSP13d.js +0 -1
- truthound_dashboard/static/assets/arc-DlYjKwIL.js +0 -1
- truthound_dashboard/static/assets/architectureDiagram-VXUJARFQ-Bb2drbQM.js +0 -36
- truthound_dashboard/static/assets/blockDiagram-VD42YOAC-BlsPG1CH.js +0 -122
- truthound_dashboard/static/assets/c4Diagram-YG6GDRKO-B9JdUoaC.js +0 -10
- truthound_dashboard/static/assets/channel-Q6mHF1Hd.js +0 -1
- truthound_dashboard/static/assets/chunk-4BX2VUAB-DmyoPVuJ.js +0 -1
- truthound_dashboard/static/assets/chunk-55IACEB6-Bcz6Siv8.js +0 -1
- truthound_dashboard/static/assets/chunk-B4BG7PRW-Br3G5Rum.js +0 -165
- truthound_dashboard/static/assets/chunk-DI55MBZ5-DuM9c23u.js +0 -220
- truthound_dashboard/static/assets/chunk-FMBD7UC4-DNU-5mvT.js +0 -15
- truthound_dashboard/static/assets/chunk-QN33PNHL-Im2yNcmS.js +0 -1
- truthound_dashboard/static/assets/chunk-QZHKN3VN-kZr8XFm1.js +0 -1
- truthound_dashboard/static/assets/chunk-TZMSLE5B-Q__360q_.js +0 -1
- truthound_dashboard/static/assets/classDiagram-2ON5EDUG-vtixxUyK.js +0 -1
- truthound_dashboard/static/assets/classDiagram-v2-WZHVMYZB-vtixxUyK.js +0 -1
- truthound_dashboard/static/assets/clone-BOt2LwD0.js +0 -1
- truthound_dashboard/static/assets/cose-bilkent-S5V4N54A-CBDw6iac.js +0 -1
- truthound_dashboard/static/assets/dagre-6UL2VRFP-XdKqmmY9.js +0 -4
- truthound_dashboard/static/assets/diagram-PSM6KHXK-DAZ8nx9V.js +0 -24
- truthound_dashboard/static/assets/diagram-QEK2KX5R-BRvDTbGD.js +0 -43
- truthound_dashboard/static/assets/diagram-S2PKOQOG-bQcczUkl.js +0 -24
- truthound_dashboard/static/assets/erDiagram-Q2GNP2WA-DPje7VMN.js +0 -60
- truthound_dashboard/static/assets/flowDiagram-NV44I4VS-B7BVtFVS.js +0 -162
- truthound_dashboard/static/assets/ganttDiagram-JELNMOA3-D6WKSS7U.js +0 -267
- truthound_dashboard/static/assets/gitGraphDiagram-NY62KEGX-D3vtVd3y.js +0 -65
- truthound_dashboard/static/assets/graph-BKgNKZVp.js +0 -1
- truthound_dashboard/static/assets/index-C6JSrkHo.css +0 -1
- truthound_dashboard/static/assets/index-DkU82VsU.js +0 -1800
- truthound_dashboard/static/assets/infoDiagram-WHAUD3N6-DnNCT429.js +0 -2
- truthound_dashboard/static/assets/journeyDiagram-XKPGCS4Q-DGiMozqS.js +0 -139
- truthound_dashboard/static/assets/kanban-definition-3W4ZIXB7-BV2gUgli.js +0 -89
- truthound_dashboard/static/assets/katex-Cu_Erd72.js +0 -261
- truthound_dashboard/static/assets/layout-DI2MfQ5G.js +0 -1
- truthound_dashboard/static/assets/min-DYdgXVcT.js +0 -1
- truthound_dashboard/static/assets/mindmap-definition-VGOIOE7T-C7x4ruxz.js +0 -68
- truthound_dashboard/static/assets/pieDiagram-ADFJNKIX-CAJaAB9f.js +0 -30
- truthound_dashboard/static/assets/quadrantDiagram-AYHSOK5B-DeqwDI46.js +0 -7
- truthound_dashboard/static/assets/requirementDiagram-UZGBJVZJ-e3XDpZIM.js +0 -64
- truthound_dashboard/static/assets/sankeyDiagram-TZEHDZUN-CNnAv5Ux.js +0 -10
- truthound_dashboard/static/assets/sequenceDiagram-WL72ISMW-Dsne-Of3.js +0 -145
- truthound_dashboard/static/assets/stateDiagram-FKZM4ZOC-Ee0sQXyb.js +0 -1
- truthound_dashboard/static/assets/stateDiagram-v2-4FDKWEC3-B26KqW_W.js +0 -1
- truthound_dashboard/static/assets/timeline-definition-IT6M3QCI-DZYi2yl3.js +0 -61
- truthound_dashboard/static/assets/treemap-KMMF4GRG-CY3f8In2.js +0 -128
- truthound_dashboard/static/assets/unmerged_dictionaries-Dd7xcPWG.js +0 -1
- truthound_dashboard/static/assets/xychartDiagram-PRI3JC2R-CS7fydZZ.js +0 -7
- truthound_dashboard-1.4.4.dist-info/METADATA +0 -507
- {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/WHEEL +0 -0
- {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/entry_points.txt +0 -0
- {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,584 +0,0 @@
|
|
|
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
|
-
}
|