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.
- truthound_dashboard/api/alerts.py +258 -0
- truthound_dashboard/api/anomaly.py +1302 -0
- truthound_dashboard/api/cross_alerts.py +352 -0
- truthound_dashboard/api/deps.py +143 -0
- truthound_dashboard/api/drift_monitor.py +540 -0
- truthound_dashboard/api/lineage.py +1151 -0
- truthound_dashboard/api/maintenance.py +363 -0
- truthound_dashboard/api/middleware.py +373 -1
- truthound_dashboard/api/model_monitoring.py +805 -0
- truthound_dashboard/api/notifications_advanced.py +2452 -0
- truthound_dashboard/api/plugins.py +2096 -0
- truthound_dashboard/api/profile.py +211 -14
- truthound_dashboard/api/reports.py +853 -0
- truthound_dashboard/api/router.py +147 -0
- truthound_dashboard/api/rule_suggestions.py +310 -0
- truthound_dashboard/api/schema_evolution.py +231 -0
- truthound_dashboard/api/sources.py +47 -3
- truthound_dashboard/api/triggers.py +190 -0
- truthound_dashboard/api/validations.py +13 -0
- truthound_dashboard/api/validators.py +333 -4
- truthound_dashboard/api/versioning.py +309 -0
- truthound_dashboard/api/websocket.py +301 -0
- truthound_dashboard/core/__init__.py +27 -0
- truthound_dashboard/core/anomaly.py +1395 -0
- truthound_dashboard/core/anomaly_explainer.py +633 -0
- truthound_dashboard/core/cache.py +206 -0
- truthound_dashboard/core/cached_services.py +422 -0
- truthound_dashboard/core/charts.py +352 -0
- truthound_dashboard/core/connections.py +1069 -42
- truthound_dashboard/core/cross_alerts.py +837 -0
- truthound_dashboard/core/drift_monitor.py +1477 -0
- truthound_dashboard/core/drift_sampling.py +669 -0
- truthound_dashboard/core/i18n/__init__.py +42 -0
- truthound_dashboard/core/i18n/detector.py +173 -0
- truthound_dashboard/core/i18n/messages.py +564 -0
- truthound_dashboard/core/lineage.py +971 -0
- truthound_dashboard/core/maintenance.py +443 -5
- truthound_dashboard/core/model_monitoring.py +1043 -0
- truthound_dashboard/core/notifications/channels.py +1020 -1
- truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
- truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
- truthound_dashboard/core/notifications/deduplication/service.py +400 -0
- truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
- truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
- truthound_dashboard/core/notifications/dispatcher.py +43 -0
- truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
- truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
- truthound_dashboard/core/notifications/escalation/engine.py +429 -0
- truthound_dashboard/core/notifications/escalation/models.py +336 -0
- truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
- truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
- truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
- truthound_dashboard/core/notifications/events.py +49 -0
- truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
- truthound_dashboard/core/notifications/metrics/base.py +528 -0
- truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
- truthound_dashboard/core/notifications/routing/__init__.py +169 -0
- truthound_dashboard/core/notifications/routing/combinators.py +184 -0
- truthound_dashboard/core/notifications/routing/config.py +375 -0
- truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
- truthound_dashboard/core/notifications/routing/engine.py +382 -0
- truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
- truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
- truthound_dashboard/core/notifications/routing/rules.py +625 -0
- truthound_dashboard/core/notifications/routing/validator.py +678 -0
- truthound_dashboard/core/notifications/service.py +2 -0
- truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
- truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
- truthound_dashboard/core/notifications/throttling/builder.py +311 -0
- truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
- truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
- truthound_dashboard/core/openlineage.py +1028 -0
- truthound_dashboard/core/plugins/__init__.py +39 -0
- truthound_dashboard/core/plugins/docs/__init__.py +39 -0
- truthound_dashboard/core/plugins/docs/extractor.py +703 -0
- truthound_dashboard/core/plugins/docs/renderers.py +804 -0
- truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
- truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
- truthound_dashboard/core/plugins/hooks/manager.py +403 -0
- truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
- truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
- truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
- truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
- truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
- truthound_dashboard/core/plugins/loader.py +504 -0
- truthound_dashboard/core/plugins/registry.py +810 -0
- truthound_dashboard/core/plugins/reporter_executor.py +588 -0
- truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
- truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
- truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
- truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
- truthound_dashboard/core/plugins/sandbox.py +617 -0
- truthound_dashboard/core/plugins/security/__init__.py +68 -0
- truthound_dashboard/core/plugins/security/analyzer.py +535 -0
- truthound_dashboard/core/plugins/security/policies.py +311 -0
- truthound_dashboard/core/plugins/security/protocols.py +296 -0
- truthound_dashboard/core/plugins/security/signing.py +842 -0
- truthound_dashboard/core/plugins/security.py +446 -0
- truthound_dashboard/core/plugins/validator_executor.py +401 -0
- truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
- truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
- truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
- truthound_dashboard/core/plugins/versioning/semver.py +266 -0
- truthound_dashboard/core/profile_comparison.py +601 -0
- truthound_dashboard/core/report_history.py +570 -0
- truthound_dashboard/core/reporters/__init__.py +57 -0
- truthound_dashboard/core/reporters/base.py +296 -0
- truthound_dashboard/core/reporters/csv_reporter.py +155 -0
- truthound_dashboard/core/reporters/html_reporter.py +598 -0
- truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
- truthound_dashboard/core/reporters/i18n/base.py +494 -0
- truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
- truthound_dashboard/core/reporters/json_reporter.py +160 -0
- truthound_dashboard/core/reporters/junit_reporter.py +233 -0
- truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
- truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
- truthound_dashboard/core/reporters/registry.py +272 -0
- truthound_dashboard/core/rule_generator.py +2088 -0
- truthound_dashboard/core/scheduler.py +822 -12
- truthound_dashboard/core/schema_evolution.py +858 -0
- truthound_dashboard/core/services.py +152 -9
- truthound_dashboard/core/statistics.py +718 -0
- truthound_dashboard/core/streaming_anomaly.py +883 -0
- truthound_dashboard/core/triggers/__init__.py +45 -0
- truthound_dashboard/core/triggers/base.py +226 -0
- truthound_dashboard/core/triggers/evaluators.py +609 -0
- truthound_dashboard/core/triggers/factory.py +363 -0
- truthound_dashboard/core/unified_alerts.py +870 -0
- truthound_dashboard/core/validation_limits.py +509 -0
- truthound_dashboard/core/versioning.py +709 -0
- truthound_dashboard/core/websocket/__init__.py +59 -0
- truthound_dashboard/core/websocket/manager.py +512 -0
- truthound_dashboard/core/websocket/messages.py +130 -0
- truthound_dashboard/db/__init__.py +30 -0
- truthound_dashboard/db/models.py +3375 -3
- truthound_dashboard/main.py +22 -0
- truthound_dashboard/schemas/__init__.py +396 -1
- truthound_dashboard/schemas/anomaly.py +1258 -0
- truthound_dashboard/schemas/base.py +4 -0
- truthound_dashboard/schemas/cross_alerts.py +334 -0
- truthound_dashboard/schemas/drift_monitor.py +890 -0
- truthound_dashboard/schemas/lineage.py +428 -0
- truthound_dashboard/schemas/maintenance.py +154 -0
- truthound_dashboard/schemas/model_monitoring.py +374 -0
- truthound_dashboard/schemas/notifications_advanced.py +1363 -0
- truthound_dashboard/schemas/openlineage.py +704 -0
- truthound_dashboard/schemas/plugins.py +1293 -0
- truthound_dashboard/schemas/profile.py +420 -34
- truthound_dashboard/schemas/profile_comparison.py +242 -0
- truthound_dashboard/schemas/reports.py +285 -0
- truthound_dashboard/schemas/rule_suggestion.py +434 -0
- truthound_dashboard/schemas/schema_evolution.py +164 -0
- truthound_dashboard/schemas/source.py +117 -2
- truthound_dashboard/schemas/triggers.py +511 -0
- truthound_dashboard/schemas/unified_alerts.py +223 -0
- truthound_dashboard/schemas/validation.py +25 -1
- truthound_dashboard/schemas/validators/__init__.py +11 -0
- truthound_dashboard/schemas/validators/base.py +151 -0
- truthound_dashboard/schemas/versioning.py +152 -0
- truthound_dashboard/static/index.html +2 -2
- {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/METADATA +142 -18
- truthound_dashboard-1.4.0.dist-info/RECORD +239 -0
- truthound_dashboard/static/assets/index-BCA8H1hO.js +0 -574
- truthound_dashboard/static/assets/index-BNsSQ2fN.css +0 -1
- truthound_dashboard/static/assets/unmerged_dictionaries-CsJWCRx9.js +0 -1
- truthound_dashboard-1.3.0.dist-info/RECORD +0 -110
- {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/WHEEL +0 -0
- {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/entry_points.txt +0 -0
- {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""Plugin Loader for loading and parsing plugin packages.
|
|
2
|
+
|
|
3
|
+
This module handles loading plugins from various sources:
|
|
4
|
+
- Local file system
|
|
5
|
+
- Remote URLs
|
|
6
|
+
- Plugin marketplace
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import tempfile
|
|
15
|
+
import zipfile
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from .security import PluginSecurityManager
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PluginManifest:
|
|
28
|
+
"""Plugin manifest containing metadata and configuration.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
name: Plugin name.
|
|
32
|
+
version: Plugin version.
|
|
33
|
+
display_name: Display name.
|
|
34
|
+
description: Plugin description.
|
|
35
|
+
plugin_type: Type of plugin.
|
|
36
|
+
author: Author information.
|
|
37
|
+
dependencies: Plugin dependencies.
|
|
38
|
+
validators: List of validator definitions.
|
|
39
|
+
reporters: List of reporter definitions.
|
|
40
|
+
permissions: Required permissions.
|
|
41
|
+
entry_point: Main entry point module.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, data: dict[str, Any]) -> None:
|
|
45
|
+
"""Initialize manifest from dictionary.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
data: Manifest data dictionary.
|
|
49
|
+
"""
|
|
50
|
+
self.name = data.get("name", "")
|
|
51
|
+
self.version = data.get("version", "0.0.0")
|
|
52
|
+
self.display_name = data.get("display_name", self.name)
|
|
53
|
+
self.description = data.get("description", "")
|
|
54
|
+
self.plugin_type = data.get("type", "validator")
|
|
55
|
+
self.author = data.get("author", {})
|
|
56
|
+
self.license = data.get("license")
|
|
57
|
+
self.homepage = data.get("homepage")
|
|
58
|
+
self.repository = data.get("repository")
|
|
59
|
+
self.keywords = data.get("keywords", [])
|
|
60
|
+
self.categories = data.get("categories", [])
|
|
61
|
+
self.dependencies = data.get("dependencies", [])
|
|
62
|
+
self.python_version = data.get("python_version")
|
|
63
|
+
self.dashboard_version = data.get("dashboard_version")
|
|
64
|
+
self.permissions = data.get("permissions", [])
|
|
65
|
+
self.entry_point = data.get("entry_point")
|
|
66
|
+
self.validators = data.get("validators", [])
|
|
67
|
+
self.reporters = data.get("reporters", [])
|
|
68
|
+
self.icon = data.get("icon")
|
|
69
|
+
self.banner = data.get("banner")
|
|
70
|
+
self.readme = data.get("readme")
|
|
71
|
+
self.changelog = data.get("changelog")
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_json(cls, json_str: str) -> "PluginManifest":
|
|
75
|
+
"""Create manifest from JSON string.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
json_str: JSON string.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
PluginManifest instance.
|
|
82
|
+
"""
|
|
83
|
+
return cls(json.loads(json_str))
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_file(cls, path: Path) -> "PluginManifest":
|
|
87
|
+
"""Create manifest from file.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
path: Path to manifest file.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
PluginManifest instance.
|
|
94
|
+
"""
|
|
95
|
+
with open(path) as f:
|
|
96
|
+
return cls(json.load(f))
|
|
97
|
+
|
|
98
|
+
def to_dict(self) -> dict[str, Any]:
|
|
99
|
+
"""Convert manifest to dictionary.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Dictionary representation.
|
|
103
|
+
"""
|
|
104
|
+
return {
|
|
105
|
+
"name": self.name,
|
|
106
|
+
"version": self.version,
|
|
107
|
+
"display_name": self.display_name,
|
|
108
|
+
"description": self.description,
|
|
109
|
+
"type": self.plugin_type,
|
|
110
|
+
"author": self.author,
|
|
111
|
+
"license": self.license,
|
|
112
|
+
"homepage": self.homepage,
|
|
113
|
+
"repository": self.repository,
|
|
114
|
+
"keywords": self.keywords,
|
|
115
|
+
"categories": self.categories,
|
|
116
|
+
"dependencies": self.dependencies,
|
|
117
|
+
"python_version": self.python_version,
|
|
118
|
+
"dashboard_version": self.dashboard_version,
|
|
119
|
+
"permissions": self.permissions,
|
|
120
|
+
"entry_point": self.entry_point,
|
|
121
|
+
"validators": self.validators,
|
|
122
|
+
"reporters": self.reporters,
|
|
123
|
+
"icon": self.icon,
|
|
124
|
+
"banner": self.banner,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class PluginPackage:
|
|
129
|
+
"""Loaded plugin package ready for installation.
|
|
130
|
+
|
|
131
|
+
Attributes:
|
|
132
|
+
manifest: Plugin manifest.
|
|
133
|
+
path: Path to extracted plugin files.
|
|
134
|
+
checksum: Package checksum.
|
|
135
|
+
signature: Package signature if available.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(
|
|
139
|
+
self,
|
|
140
|
+
manifest: PluginManifest,
|
|
141
|
+
path: Path,
|
|
142
|
+
checksum: str,
|
|
143
|
+
signature: str | None = None,
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Initialize plugin package.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
manifest: Plugin manifest.
|
|
149
|
+
path: Path to extracted files.
|
|
150
|
+
checksum: Package checksum.
|
|
151
|
+
signature: Optional package signature.
|
|
152
|
+
"""
|
|
153
|
+
self.manifest = manifest
|
|
154
|
+
self.path = path
|
|
155
|
+
self.checksum = checksum
|
|
156
|
+
self.signature = signature
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def name(self) -> str:
|
|
160
|
+
"""Get plugin name."""
|
|
161
|
+
return self.manifest.name
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def version(self) -> str:
|
|
165
|
+
"""Get plugin version."""
|
|
166
|
+
return self.manifest.version
|
|
167
|
+
|
|
168
|
+
def get_validator_code(self, validator_name: str) -> str | None:
|
|
169
|
+
"""Get code for a specific validator.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
validator_name: Name of the validator.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Validator code if found.
|
|
176
|
+
"""
|
|
177
|
+
for v in self.manifest.validators:
|
|
178
|
+
if v.get("name") == validator_name:
|
|
179
|
+
code_file = v.get("code_file")
|
|
180
|
+
if code_file:
|
|
181
|
+
code_path = self.path / code_file
|
|
182
|
+
if code_path.exists():
|
|
183
|
+
return code_path.read_text()
|
|
184
|
+
return v.get("code")
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
def get_reporter_code(self, reporter_name: str) -> str | None:
|
|
188
|
+
"""Get code for a specific reporter.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
reporter_name: Name of the reporter.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Reporter code if found.
|
|
195
|
+
"""
|
|
196
|
+
for r in self.manifest.reporters:
|
|
197
|
+
if r.get("name") == reporter_name:
|
|
198
|
+
code_file = r.get("code_file")
|
|
199
|
+
if code_file:
|
|
200
|
+
code_path = self.path / code_file
|
|
201
|
+
if code_path.exists():
|
|
202
|
+
return code_path.read_text()
|
|
203
|
+
return r.get("code")
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
def get_template(self, reporter_name: str) -> str | None:
|
|
207
|
+
"""Get template for a specific reporter.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
reporter_name: Name of the reporter.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Template content if found.
|
|
214
|
+
"""
|
|
215
|
+
for r in self.manifest.reporters:
|
|
216
|
+
if r.get("name") == reporter_name:
|
|
217
|
+
template_file = r.get("template_file")
|
|
218
|
+
if template_file:
|
|
219
|
+
template_path = self.path / template_file
|
|
220
|
+
if template_path.exists():
|
|
221
|
+
return template_path.read_text()
|
|
222
|
+
return r.get("template")
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class PluginLoader:
|
|
227
|
+
"""Loader for plugin packages.
|
|
228
|
+
|
|
229
|
+
This class handles downloading, extracting, and parsing
|
|
230
|
+
plugin packages from various sources.
|
|
231
|
+
|
|
232
|
+
Attributes:
|
|
233
|
+
plugins_dir: Directory to store plugin packages.
|
|
234
|
+
security_manager: Optional security manager for verification.
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
def __init__(
|
|
238
|
+
self,
|
|
239
|
+
plugins_dir: Path | str | None = None,
|
|
240
|
+
security_manager: "PluginSecurityManager | None" = None,
|
|
241
|
+
) -> None:
|
|
242
|
+
"""Initialize the plugin loader.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
plugins_dir: Directory to store plugins.
|
|
246
|
+
security_manager: Optional security manager.
|
|
247
|
+
"""
|
|
248
|
+
if plugins_dir is None:
|
|
249
|
+
plugins_dir = Path.home() / ".truthound" / "plugins"
|
|
250
|
+
self.plugins_dir = Path(plugins_dir)
|
|
251
|
+
self.plugins_dir.mkdir(parents=True, exist_ok=True)
|
|
252
|
+
self.security_manager = security_manager
|
|
253
|
+
self._http_client: httpx.AsyncClient | None = None
|
|
254
|
+
|
|
255
|
+
async def _get_http_client(self) -> httpx.AsyncClient:
|
|
256
|
+
"""Get or create HTTP client."""
|
|
257
|
+
if self._http_client is None:
|
|
258
|
+
self._http_client = httpx.AsyncClient(timeout=60.0)
|
|
259
|
+
return self._http_client
|
|
260
|
+
|
|
261
|
+
async def close(self) -> None:
|
|
262
|
+
"""Close HTTP client."""
|
|
263
|
+
if self._http_client:
|
|
264
|
+
await self._http_client.aclose()
|
|
265
|
+
self._http_client = None
|
|
266
|
+
|
|
267
|
+
def _calculate_checksum(self, data: bytes) -> str:
|
|
268
|
+
"""Calculate SHA-256 checksum of data.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
data: Binary data.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
Hex-encoded checksum.
|
|
275
|
+
"""
|
|
276
|
+
return hashlib.sha256(data).hexdigest()
|
|
277
|
+
|
|
278
|
+
async def load_from_url(self, url: str) -> PluginPackage:
|
|
279
|
+
"""Load a plugin from a URL.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
url: URL to plugin package (.zip).
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
Loaded plugin package.
|
|
286
|
+
|
|
287
|
+
Raises:
|
|
288
|
+
ValueError: If download or extraction fails.
|
|
289
|
+
"""
|
|
290
|
+
logger.info(f"Downloading plugin from: {url}")
|
|
291
|
+
|
|
292
|
+
client = await self._get_http_client()
|
|
293
|
+
response = await client.get(url)
|
|
294
|
+
response.raise_for_status()
|
|
295
|
+
|
|
296
|
+
package_data = response.content
|
|
297
|
+
checksum = self._calculate_checksum(package_data)
|
|
298
|
+
|
|
299
|
+
# Extract signature from headers if present
|
|
300
|
+
signature = response.headers.get("X-Plugin-Signature")
|
|
301
|
+
|
|
302
|
+
return await self._load_from_bytes(package_data, checksum, signature)
|
|
303
|
+
|
|
304
|
+
async def load_from_file(self, path: Path | str) -> PluginPackage:
|
|
305
|
+
"""Load a plugin from a local file.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
path: Path to plugin package (.zip).
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
Loaded plugin package.
|
|
312
|
+
|
|
313
|
+
Raises:
|
|
314
|
+
ValueError: If file not found or extraction fails.
|
|
315
|
+
"""
|
|
316
|
+
path = Path(path)
|
|
317
|
+
if not path.exists():
|
|
318
|
+
raise ValueError(f"Plugin file not found: {path}")
|
|
319
|
+
|
|
320
|
+
logger.info(f"Loading plugin from: {path}")
|
|
321
|
+
|
|
322
|
+
package_data = path.read_bytes()
|
|
323
|
+
checksum = self._calculate_checksum(package_data)
|
|
324
|
+
|
|
325
|
+
# Check for signature file
|
|
326
|
+
sig_path = path.with_suffix(".sig")
|
|
327
|
+
signature = sig_path.read_text() if sig_path.exists() else None
|
|
328
|
+
|
|
329
|
+
return await self._load_from_bytes(package_data, checksum, signature)
|
|
330
|
+
|
|
331
|
+
async def load_from_directory(self, path: Path | str) -> PluginPackage:
|
|
332
|
+
"""Load a plugin from an extracted directory.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
path: Path to extracted plugin directory.
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
Loaded plugin package.
|
|
339
|
+
|
|
340
|
+
Raises:
|
|
341
|
+
ValueError: If manifest not found.
|
|
342
|
+
"""
|
|
343
|
+
path = Path(path)
|
|
344
|
+
manifest_path = path / "manifest.json"
|
|
345
|
+
|
|
346
|
+
if not manifest_path.exists():
|
|
347
|
+
raise ValueError(f"No manifest.json found in: {path}")
|
|
348
|
+
|
|
349
|
+
manifest = PluginManifest.from_file(manifest_path)
|
|
350
|
+
checksum = hashlib.sha256(manifest_path.read_bytes()).hexdigest()
|
|
351
|
+
|
|
352
|
+
logger.info(f"Loaded plugin from directory: {manifest.name} v{manifest.version}")
|
|
353
|
+
|
|
354
|
+
return PluginPackage(
|
|
355
|
+
manifest=manifest,
|
|
356
|
+
path=path,
|
|
357
|
+
checksum=checksum,
|
|
358
|
+
signature=None,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
async def _load_from_bytes(
|
|
362
|
+
self,
|
|
363
|
+
package_data: bytes,
|
|
364
|
+
checksum: str,
|
|
365
|
+
signature: str | None = None,
|
|
366
|
+
) -> PluginPackage:
|
|
367
|
+
"""Load plugin from raw bytes.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
package_data: Package bytes.
|
|
371
|
+
checksum: Package checksum.
|
|
372
|
+
signature: Optional signature.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
Loaded plugin package.
|
|
376
|
+
"""
|
|
377
|
+
# Create temp directory for extraction
|
|
378
|
+
extract_dir = Path(tempfile.mkdtemp(prefix="truthound_plugin_"))
|
|
379
|
+
|
|
380
|
+
try:
|
|
381
|
+
# Write to temp file and extract
|
|
382
|
+
temp_zip = extract_dir / "package.zip"
|
|
383
|
+
temp_zip.write_bytes(package_data)
|
|
384
|
+
|
|
385
|
+
with zipfile.ZipFile(temp_zip, "r") as zf:
|
|
386
|
+
zf.extractall(extract_dir)
|
|
387
|
+
|
|
388
|
+
temp_zip.unlink()
|
|
389
|
+
|
|
390
|
+
# Find manifest
|
|
391
|
+
manifest_path = extract_dir / "manifest.json"
|
|
392
|
+
if not manifest_path.exists():
|
|
393
|
+
# Check in subdirectory
|
|
394
|
+
subdirs = [d for d in extract_dir.iterdir() if d.is_dir()]
|
|
395
|
+
if subdirs:
|
|
396
|
+
manifest_path = subdirs[0] / "manifest.json"
|
|
397
|
+
if manifest_path.exists():
|
|
398
|
+
# Move contents up
|
|
399
|
+
for item in subdirs[0].iterdir():
|
|
400
|
+
item.rename(extract_dir / item.name)
|
|
401
|
+
manifest_path = extract_dir / "manifest.json"
|
|
402
|
+
|
|
403
|
+
if not manifest_path.exists():
|
|
404
|
+
raise ValueError("No manifest.json found in package")
|
|
405
|
+
|
|
406
|
+
manifest = PluginManifest.from_file(manifest_path)
|
|
407
|
+
|
|
408
|
+
# Move to permanent location
|
|
409
|
+
plugin_dir = self.plugins_dir / manifest.name / manifest.version
|
|
410
|
+
if plugin_dir.exists():
|
|
411
|
+
import shutil
|
|
412
|
+
shutil.rmtree(plugin_dir)
|
|
413
|
+
|
|
414
|
+
plugin_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
415
|
+
|
|
416
|
+
import shutil
|
|
417
|
+
shutil.move(str(extract_dir), str(plugin_dir))
|
|
418
|
+
|
|
419
|
+
logger.info(f"Loaded plugin: {manifest.name} v{manifest.version}")
|
|
420
|
+
|
|
421
|
+
return PluginPackage(
|
|
422
|
+
manifest=manifest,
|
|
423
|
+
path=plugin_dir,
|
|
424
|
+
checksum=checksum,
|
|
425
|
+
signature=signature,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
except Exception as e:
|
|
429
|
+
# Clean up on error
|
|
430
|
+
import shutil
|
|
431
|
+
if extract_dir.exists():
|
|
432
|
+
shutil.rmtree(extract_dir)
|
|
433
|
+
raise ValueError(f"Failed to load plugin: {e}") from e
|
|
434
|
+
|
|
435
|
+
async def list_local_plugins(self) -> list[PluginPackage]:
|
|
436
|
+
"""List all locally installed plugins.
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
List of plugin packages.
|
|
440
|
+
"""
|
|
441
|
+
packages = []
|
|
442
|
+
|
|
443
|
+
for plugin_dir in self.plugins_dir.iterdir():
|
|
444
|
+
if not plugin_dir.is_dir():
|
|
445
|
+
continue
|
|
446
|
+
|
|
447
|
+
for version_dir in plugin_dir.iterdir():
|
|
448
|
+
if not version_dir.is_dir():
|
|
449
|
+
continue
|
|
450
|
+
|
|
451
|
+
manifest_path = version_dir / "manifest.json"
|
|
452
|
+
if manifest_path.exists():
|
|
453
|
+
try:
|
|
454
|
+
manifest = PluginManifest.from_file(manifest_path)
|
|
455
|
+
checksum = hashlib.sha256(manifest_path.read_bytes()).hexdigest()
|
|
456
|
+
packages.append(
|
|
457
|
+
PluginPackage(
|
|
458
|
+
manifest=manifest,
|
|
459
|
+
path=version_dir,
|
|
460
|
+
checksum=checksum,
|
|
461
|
+
)
|
|
462
|
+
)
|
|
463
|
+
except Exception as e:
|
|
464
|
+
logger.warning(f"Failed to load plugin from {version_dir}: {e}")
|
|
465
|
+
|
|
466
|
+
return packages
|
|
467
|
+
|
|
468
|
+
async def unload_plugin(self, name: str, version: str | None = None) -> None:
|
|
469
|
+
"""Remove a plugin from local storage.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
name: Plugin name.
|
|
473
|
+
version: Specific version to remove (all if None).
|
|
474
|
+
"""
|
|
475
|
+
import shutil
|
|
476
|
+
|
|
477
|
+
plugin_dir = self.plugins_dir / name
|
|
478
|
+
|
|
479
|
+
if version:
|
|
480
|
+
version_dir = plugin_dir / version
|
|
481
|
+
if version_dir.exists():
|
|
482
|
+
shutil.rmtree(version_dir)
|
|
483
|
+
logger.info(f"Removed plugin: {name} v{version}")
|
|
484
|
+
else:
|
|
485
|
+
if plugin_dir.exists():
|
|
486
|
+
shutil.rmtree(plugin_dir)
|
|
487
|
+
logger.info(f"Removed all versions of plugin: {name}")
|
|
488
|
+
|
|
489
|
+
# Clean up empty parent directory
|
|
490
|
+
if plugin_dir.exists() and not any(plugin_dir.iterdir()):
|
|
491
|
+
plugin_dir.rmdir()
|
|
492
|
+
|
|
493
|
+
def get_plugin_path(self, name: str, version: str) -> Path | None:
|
|
494
|
+
"""Get path to a specific plugin version.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
name: Plugin name.
|
|
498
|
+
version: Plugin version.
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
Path to plugin directory if exists.
|
|
502
|
+
"""
|
|
503
|
+
path = self.plugins_dir / name / version
|
|
504
|
+
return path if path.exists() else None
|