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,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