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,810 @@
1
+ """Plugin Registry for managing installed plugins.
2
+
3
+ This module provides the central registry for plugin management,
4
+ including discovery, registration, and lifecycle management.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from datetime import datetime
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from sqlalchemy import select
14
+ from sqlalchemy.ext.asyncio import AsyncSession
15
+
16
+ from truthound_dashboard.db.models import (
17
+ CustomReporter,
18
+ CustomValidator,
19
+ Plugin,
20
+ PluginStatus,
21
+ PluginType,
22
+ )
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Sequence
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class PluginRegistry:
31
+ """Central registry for plugin management.
32
+
33
+ This class provides methods to register, discover, install,
34
+ enable/disable, and uninstall plugins.
35
+
36
+ Attributes:
37
+ _validators: Cached custom validators by name.
38
+ _reporters: Cached custom reporters by name.
39
+ _plugins: Cached plugins by name.
40
+ """
41
+
42
+ def __init__(self) -> None:
43
+ """Initialize the plugin registry."""
44
+ self._validators: dict[str, CustomValidator] = {}
45
+ self._reporters: dict[str, CustomReporter] = {}
46
+ self._plugins: dict[str, Plugin] = {}
47
+ self._initialized = False
48
+
49
+ async def initialize(self, session: AsyncSession) -> None:
50
+ """Initialize the registry by loading enabled plugins.
51
+
52
+ Args:
53
+ session: Database session.
54
+ """
55
+ if self._initialized:
56
+ return
57
+
58
+ logger.info("Initializing plugin registry...")
59
+
60
+ # Load enabled plugins
61
+ stmt = select(Plugin).where(Plugin.is_enabled == True) # noqa: E712
62
+ result = await session.execute(stmt)
63
+ plugins = result.scalars().all()
64
+
65
+ for plugin in plugins:
66
+ self._plugins[plugin.name] = plugin
67
+ logger.debug(f"Loaded plugin: {plugin.name} v{plugin.version}")
68
+
69
+ # Load enabled validators
70
+ stmt = select(CustomValidator).where(CustomValidator.is_enabled == True) # noqa: E712
71
+ result = await session.execute(stmt)
72
+ validators = result.scalars().all()
73
+
74
+ for validator in validators:
75
+ self._validators[validator.name] = validator
76
+ logger.debug(f"Loaded validator: {validator.name}")
77
+
78
+ # Load enabled reporters
79
+ stmt = select(CustomReporter).where(CustomReporter.is_enabled == True) # noqa: E712
80
+ result = await session.execute(stmt)
81
+ reporters = result.scalars().all()
82
+
83
+ for reporter in reporters:
84
+ self._reporters[reporter.name] = reporter
85
+ logger.debug(f"Loaded reporter: {reporter.name}")
86
+
87
+ self._initialized = True
88
+ logger.info(
89
+ f"Plugin registry initialized: {len(self._plugins)} plugins, "
90
+ f"{len(self._validators)} validators, {len(self._reporters)} reporters"
91
+ )
92
+
93
+ def clear_cache(self) -> None:
94
+ """Clear all cached plugins, validators, and reporters."""
95
+ self._validators.clear()
96
+ self._reporters.clear()
97
+ self._plugins.clear()
98
+ self._initialized = False
99
+ logger.info("Plugin registry cache cleared")
100
+
101
+ # =========================================================================
102
+ # Plugin Management
103
+ # =========================================================================
104
+
105
+ async def list_plugins(
106
+ self,
107
+ session: AsyncSession,
108
+ plugin_type: PluginType | None = None,
109
+ status: PluginStatus | None = None,
110
+ search: str | None = None,
111
+ offset: int = 0,
112
+ limit: int = 50,
113
+ ) -> tuple[Sequence[Plugin], int]:
114
+ """List plugins with optional filtering.
115
+
116
+ Args:
117
+ session: Database session.
118
+ plugin_type: Filter by plugin type.
119
+ status: Filter by status.
120
+ search: Search in name, display_name, description.
121
+ offset: Pagination offset.
122
+ limit: Pagination limit.
123
+
124
+ Returns:
125
+ Tuple of (plugins list, total count).
126
+ """
127
+ stmt = select(Plugin)
128
+
129
+ if plugin_type:
130
+ stmt = stmt.where(Plugin.type == plugin_type.value)
131
+ if status:
132
+ stmt = stmt.where(Plugin.status == status.value)
133
+ if search:
134
+ search_pattern = f"%{search}%"
135
+ stmt = stmt.where(
136
+ (Plugin.name.ilike(search_pattern))
137
+ | (Plugin.display_name.ilike(search_pattern))
138
+ | (Plugin.description.ilike(search_pattern))
139
+ )
140
+
141
+ # Get total count
142
+ count_result = await session.execute(
143
+ select(Plugin.id).select_from(stmt.subquery())
144
+ )
145
+ total = len(count_result.all())
146
+
147
+ # Apply pagination
148
+ stmt = stmt.offset(offset).limit(limit).order_by(Plugin.created_at.desc())
149
+ result = await session.execute(stmt)
150
+ plugins = result.scalars().all()
151
+
152
+ return plugins, total
153
+
154
+ async def get_plugin(
155
+ self,
156
+ session: AsyncSession,
157
+ plugin_id: str | None = None,
158
+ name: str | None = None,
159
+ ) -> Plugin | None:
160
+ """Get a plugin by ID or name.
161
+
162
+ Args:
163
+ session: Database session.
164
+ plugin_id: Plugin ID.
165
+ name: Plugin name.
166
+
167
+ Returns:
168
+ Plugin if found, None otherwise.
169
+ """
170
+ if plugin_id:
171
+ stmt = select(Plugin).where(Plugin.id == plugin_id)
172
+ elif name:
173
+ stmt = select(Plugin).where(Plugin.name == name)
174
+ else:
175
+ return None
176
+
177
+ result = await session.execute(stmt)
178
+ return result.scalar_one_or_none()
179
+
180
+ async def register_plugin(
181
+ self,
182
+ session: AsyncSession,
183
+ name: str,
184
+ display_name: str,
185
+ description: str,
186
+ version: str,
187
+ plugin_type: PluginType,
188
+ **kwargs: Any,
189
+ ) -> Plugin:
190
+ """Register a new plugin.
191
+
192
+ Args:
193
+ session: Database session.
194
+ name: Plugin name (unique).
195
+ display_name: Display name.
196
+ description: Plugin description.
197
+ version: Plugin version.
198
+ plugin_type: Plugin type.
199
+ **kwargs: Additional plugin attributes.
200
+
201
+ Returns:
202
+ Created plugin.
203
+
204
+ Raises:
205
+ ValueError: If plugin with same name exists.
206
+ """
207
+ existing = await self.get_plugin(session, name=name)
208
+ if existing:
209
+ raise ValueError(f"Plugin with name '{name}' already exists")
210
+
211
+ plugin = Plugin(
212
+ name=name,
213
+ display_name=display_name,
214
+ description=description,
215
+ version=version,
216
+ type=plugin_type.value,
217
+ **kwargs,
218
+ )
219
+ session.add(plugin)
220
+ await session.flush()
221
+
222
+ logger.info(f"Registered plugin: {name} v{version}")
223
+ return plugin
224
+
225
+ async def install_plugin(
226
+ self,
227
+ session: AsyncSession,
228
+ plugin_id: str,
229
+ enable: bool = True,
230
+ ) -> Plugin:
231
+ """Install a plugin.
232
+
233
+ Args:
234
+ session: Database session.
235
+ plugin_id: Plugin ID.
236
+ enable: Whether to enable after installation.
237
+
238
+ Returns:
239
+ Updated plugin.
240
+
241
+ Raises:
242
+ ValueError: If plugin not found.
243
+ """
244
+ plugin = await self.get_plugin(session, plugin_id=plugin_id)
245
+ if not plugin:
246
+ raise ValueError(f"Plugin {plugin_id} not found")
247
+
248
+ plugin.install()
249
+ if enable:
250
+ plugin.enable()
251
+ self._plugins[plugin.name] = plugin
252
+
253
+ await session.flush()
254
+ logger.info(f"Installed plugin: {plugin.name} v{plugin.version}")
255
+ return plugin
256
+
257
+ async def uninstall_plugin(
258
+ self,
259
+ session: AsyncSession,
260
+ plugin_id: str,
261
+ remove_data: bool = False,
262
+ ) -> None:
263
+ """Uninstall a plugin.
264
+
265
+ Args:
266
+ session: Database session.
267
+ plugin_id: Plugin ID.
268
+ remove_data: Whether to remove plugin data.
269
+ """
270
+ plugin = await self.get_plugin(session, plugin_id=plugin_id)
271
+ if not plugin:
272
+ raise ValueError(f"Plugin {plugin_id} not found")
273
+
274
+ # Remove from cache
275
+ if plugin.name in self._plugins:
276
+ del self._plugins[plugin.name]
277
+
278
+ if remove_data:
279
+ # Delete plugin entirely
280
+ await session.delete(plugin)
281
+ logger.info(f"Removed plugin: {plugin.name}")
282
+ else:
283
+ # Just mark as uninstalled
284
+ plugin.uninstall()
285
+ logger.info(f"Uninstalled plugin: {plugin.name}")
286
+
287
+ await session.flush()
288
+
289
+ async def enable_plugin(
290
+ self,
291
+ session: AsyncSession,
292
+ plugin_id: str,
293
+ ) -> Plugin:
294
+ """Enable a plugin.
295
+
296
+ Args:
297
+ session: Database session.
298
+ plugin_id: Plugin ID.
299
+
300
+ Returns:
301
+ Updated plugin.
302
+ """
303
+ plugin = await self.get_plugin(session, plugin_id=plugin_id)
304
+ if not plugin:
305
+ raise ValueError(f"Plugin {plugin_id} not found")
306
+
307
+ plugin.enable()
308
+ self._plugins[plugin.name] = plugin
309
+ await session.flush()
310
+
311
+ logger.info(f"Enabled plugin: {plugin.name}")
312
+ return plugin
313
+
314
+ async def disable_plugin(
315
+ self,
316
+ session: AsyncSession,
317
+ plugin_id: str,
318
+ ) -> Plugin:
319
+ """Disable a plugin.
320
+
321
+ Args:
322
+ session: Database session.
323
+ plugin_id: Plugin ID.
324
+
325
+ Returns:
326
+ Updated plugin.
327
+ """
328
+ plugin = await self.get_plugin(session, plugin_id=plugin_id)
329
+ if not plugin:
330
+ raise ValueError(f"Plugin {plugin_id} not found")
331
+
332
+ plugin.disable()
333
+ if plugin.name in self._plugins:
334
+ del self._plugins[plugin.name]
335
+ await session.flush()
336
+
337
+ logger.info(f"Disabled plugin: {plugin.name}")
338
+ return plugin
339
+
340
+ # =========================================================================
341
+ # Custom Validator Management
342
+ # =========================================================================
343
+
344
+ async def list_validators(
345
+ self,
346
+ session: AsyncSession,
347
+ plugin_id: str | None = None,
348
+ category: str | None = None,
349
+ enabled_only: bool = False,
350
+ search: str | None = None,
351
+ offset: int = 0,
352
+ limit: int = 50,
353
+ ) -> tuple[Sequence[CustomValidator], int]:
354
+ """List custom validators with optional filtering.
355
+
356
+ Args:
357
+ session: Database session.
358
+ plugin_id: Filter by plugin.
359
+ category: Filter by category.
360
+ enabled_only: Only return enabled validators.
361
+ search: Search in name, display_name, description.
362
+ offset: Pagination offset.
363
+ limit: Pagination limit.
364
+
365
+ Returns:
366
+ Tuple of (validators list, total count).
367
+ """
368
+ stmt = select(CustomValidator)
369
+
370
+ if plugin_id:
371
+ stmt = stmt.where(CustomValidator.plugin_id == plugin_id)
372
+ if category:
373
+ stmt = stmt.where(CustomValidator.category == category)
374
+ if enabled_only:
375
+ stmt = stmt.where(CustomValidator.is_enabled == True) # noqa: E712
376
+ if search:
377
+ search_pattern = f"%{search}%"
378
+ stmt = stmt.where(
379
+ (CustomValidator.name.ilike(search_pattern))
380
+ | (CustomValidator.display_name.ilike(search_pattern))
381
+ | (CustomValidator.description.ilike(search_pattern))
382
+ )
383
+
384
+ # Get total count
385
+ count_result = await session.execute(
386
+ select(CustomValidator.id).select_from(stmt.subquery())
387
+ )
388
+ total = len(count_result.all())
389
+
390
+ # Apply pagination
391
+ stmt = stmt.offset(offset).limit(limit).order_by(CustomValidator.created_at.desc())
392
+ result = await session.execute(stmt)
393
+ validators = result.scalars().all()
394
+
395
+ return validators, total
396
+
397
+ async def get_validator(
398
+ self,
399
+ session: AsyncSession,
400
+ validator_id: str | None = None,
401
+ name: str | None = None,
402
+ ) -> CustomValidator | None:
403
+ """Get a custom validator by ID or name.
404
+
405
+ Args:
406
+ session: Database session.
407
+ validator_id: Validator ID.
408
+ name: Validator name.
409
+
410
+ Returns:
411
+ CustomValidator if found, None otherwise.
412
+ """
413
+ if validator_id:
414
+ stmt = select(CustomValidator).where(CustomValidator.id == validator_id)
415
+ elif name:
416
+ stmt = select(CustomValidator).where(CustomValidator.name == name)
417
+ else:
418
+ return None
419
+
420
+ result = await session.execute(stmt)
421
+ return result.scalar_one_or_none()
422
+
423
+ async def register_validator(
424
+ self,
425
+ session: AsyncSession,
426
+ name: str,
427
+ display_name: str,
428
+ description: str,
429
+ category: str,
430
+ code: str,
431
+ plugin_id: str | None = None,
432
+ **kwargs: Any,
433
+ ) -> CustomValidator:
434
+ """Register a new custom validator.
435
+
436
+ Args:
437
+ session: Database session.
438
+ name: Validator name (unique).
439
+ display_name: Display name.
440
+ description: Validator description.
441
+ category: Validator category.
442
+ code: Python code implementing the validator.
443
+ plugin_id: Optional parent plugin ID.
444
+ **kwargs: Additional validator attributes.
445
+
446
+ Returns:
447
+ Created validator.
448
+
449
+ Raises:
450
+ ValueError: If validator with same name exists.
451
+ """
452
+ existing = await self.get_validator(session, name=name)
453
+ if existing:
454
+ raise ValueError(f"Validator with name '{name}' already exists")
455
+
456
+ validator = CustomValidator(
457
+ name=name,
458
+ display_name=display_name,
459
+ description=description,
460
+ category=category,
461
+ code=code,
462
+ plugin_id=plugin_id,
463
+ **kwargs,
464
+ )
465
+ session.add(validator)
466
+ await session.flush()
467
+
468
+ # Update plugin validator count if applicable
469
+ if plugin_id:
470
+ plugin = await self.get_plugin(session, plugin_id=plugin_id)
471
+ if plugin:
472
+ plugin.validators_count += 1
473
+
474
+ self._validators[name] = validator
475
+ logger.info(f"Registered custom validator: {name}")
476
+ return validator
477
+
478
+ async def update_validator(
479
+ self,
480
+ session: AsyncSession,
481
+ validator_id: str,
482
+ **updates: Any,
483
+ ) -> CustomValidator:
484
+ """Update a custom validator.
485
+
486
+ Args:
487
+ session: Database session.
488
+ validator_id: Validator ID.
489
+ **updates: Fields to update.
490
+
491
+ Returns:
492
+ Updated validator.
493
+ """
494
+ validator = await self.get_validator(session, validator_id=validator_id)
495
+ if not validator:
496
+ raise ValueError(f"Validator {validator_id} not found")
497
+
498
+ for key, value in updates.items():
499
+ if hasattr(validator, key) and value is not None:
500
+ setattr(validator, key, value)
501
+
502
+ await session.flush()
503
+
504
+ # Update cache
505
+ if validator.is_enabled:
506
+ self._validators[validator.name] = validator
507
+ elif validator.name in self._validators:
508
+ del self._validators[validator.name]
509
+
510
+ logger.info(f"Updated custom validator: {validator.name}")
511
+ return validator
512
+
513
+ async def delete_validator(
514
+ self,
515
+ session: AsyncSession,
516
+ validator_id: str,
517
+ ) -> None:
518
+ """Delete a custom validator.
519
+
520
+ Args:
521
+ session: Database session.
522
+ validator_id: Validator ID.
523
+ """
524
+ validator = await self.get_validator(session, validator_id=validator_id)
525
+ if not validator:
526
+ raise ValueError(f"Validator {validator_id} not found")
527
+
528
+ # Remove from cache
529
+ if validator.name in self._validators:
530
+ del self._validators[validator.name]
531
+
532
+ # Update plugin validator count if applicable
533
+ if validator.plugin_id:
534
+ plugin = await self.get_plugin(session, plugin_id=validator.plugin_id)
535
+ if plugin and plugin.validators_count > 0:
536
+ plugin.validators_count -= 1
537
+
538
+ await session.delete(validator)
539
+ await session.flush()
540
+ logger.info(f"Deleted custom validator: {validator.name}")
541
+
542
+ def get_cached_validator(self, name: str) -> CustomValidator | None:
543
+ """Get a validator from cache by name.
544
+
545
+ Args:
546
+ name: Validator name.
547
+
548
+ Returns:
549
+ CustomValidator if found in cache, None otherwise.
550
+ """
551
+ return self._validators.get(name)
552
+
553
+ # =========================================================================
554
+ # Custom Reporter Management
555
+ # =========================================================================
556
+
557
+ async def list_reporters(
558
+ self,
559
+ session: AsyncSession,
560
+ plugin_id: str | None = None,
561
+ enabled_only: bool = False,
562
+ search: str | None = None,
563
+ offset: int = 0,
564
+ limit: int = 50,
565
+ ) -> tuple[Sequence[CustomReporter], int]:
566
+ """List custom reporters with optional filtering.
567
+
568
+ Args:
569
+ session: Database session.
570
+ plugin_id: Filter by plugin.
571
+ enabled_only: Only return enabled reporters.
572
+ search: Search in name, display_name, description.
573
+ offset: Pagination offset.
574
+ limit: Pagination limit.
575
+
576
+ Returns:
577
+ Tuple of (reporters list, total count).
578
+ """
579
+ stmt = select(CustomReporter)
580
+
581
+ if plugin_id:
582
+ stmt = stmt.where(CustomReporter.plugin_id == plugin_id)
583
+ if enabled_only:
584
+ stmt = stmt.where(CustomReporter.is_enabled == True) # noqa: E712
585
+ if search:
586
+ search_pattern = f"%{search}%"
587
+ stmt = stmt.where(
588
+ (CustomReporter.name.ilike(search_pattern))
589
+ | (CustomReporter.display_name.ilike(search_pattern))
590
+ | (CustomReporter.description.ilike(search_pattern))
591
+ )
592
+
593
+ # Get total count
594
+ count_result = await session.execute(
595
+ select(CustomReporter.id).select_from(stmt.subquery())
596
+ )
597
+ total = len(count_result.all())
598
+
599
+ # Apply pagination
600
+ stmt = stmt.offset(offset).limit(limit).order_by(CustomReporter.created_at.desc())
601
+ result = await session.execute(stmt)
602
+ reporters = result.scalars().all()
603
+
604
+ return reporters, total
605
+
606
+ async def get_reporter(
607
+ self,
608
+ session: AsyncSession,
609
+ reporter_id: str | None = None,
610
+ name: str | None = None,
611
+ ) -> CustomReporter | None:
612
+ """Get a custom reporter by ID or name.
613
+
614
+ Args:
615
+ session: Database session.
616
+ reporter_id: Reporter ID.
617
+ name: Reporter name.
618
+
619
+ Returns:
620
+ CustomReporter if found, None otherwise.
621
+ """
622
+ if reporter_id:
623
+ stmt = select(CustomReporter).where(CustomReporter.id == reporter_id)
624
+ elif name:
625
+ stmt = select(CustomReporter).where(CustomReporter.name == name)
626
+ else:
627
+ return None
628
+
629
+ result = await session.execute(stmt)
630
+ return result.scalar_one_or_none()
631
+
632
+ async def register_reporter(
633
+ self,
634
+ session: AsyncSession,
635
+ name: str,
636
+ display_name: str,
637
+ description: str,
638
+ plugin_id: str | None = None,
639
+ **kwargs: Any,
640
+ ) -> CustomReporter:
641
+ """Register a new custom reporter.
642
+
643
+ Args:
644
+ session: Database session.
645
+ name: Reporter name (unique).
646
+ display_name: Display name.
647
+ description: Reporter description.
648
+ plugin_id: Optional parent plugin ID.
649
+ **kwargs: Additional reporter attributes.
650
+
651
+ Returns:
652
+ Created reporter.
653
+
654
+ Raises:
655
+ ValueError: If reporter with same name exists.
656
+ """
657
+ existing = await self.get_reporter(session, name=name)
658
+ if existing:
659
+ raise ValueError(f"Reporter with name '{name}' already exists")
660
+
661
+ reporter = CustomReporter(
662
+ name=name,
663
+ display_name=display_name,
664
+ description=description,
665
+ plugin_id=plugin_id,
666
+ **kwargs,
667
+ )
668
+ session.add(reporter)
669
+ await session.flush()
670
+
671
+ # Update plugin reporter count if applicable
672
+ if plugin_id:
673
+ plugin = await self.get_plugin(session, plugin_id=plugin_id)
674
+ if plugin:
675
+ plugin.reporters_count += 1
676
+
677
+ self._reporters[name] = reporter
678
+ logger.info(f"Registered custom reporter: {name}")
679
+ return reporter
680
+
681
+ async def update_reporter(
682
+ self,
683
+ session: AsyncSession,
684
+ reporter_id: str,
685
+ **updates: Any,
686
+ ) -> CustomReporter:
687
+ """Update a custom reporter.
688
+
689
+ Args:
690
+ session: Database session.
691
+ reporter_id: Reporter ID.
692
+ **updates: Fields to update.
693
+
694
+ Returns:
695
+ Updated reporter.
696
+ """
697
+ reporter = await self.get_reporter(session, reporter_id=reporter_id)
698
+ if not reporter:
699
+ raise ValueError(f"Reporter {reporter_id} not found")
700
+
701
+ for key, value in updates.items():
702
+ if hasattr(reporter, key) and value is not None:
703
+ setattr(reporter, key, value)
704
+
705
+ await session.flush()
706
+
707
+ # Update cache
708
+ if reporter.is_enabled:
709
+ self._reporters[reporter.name] = reporter
710
+ elif reporter.name in self._reporters:
711
+ del self._reporters[reporter.name]
712
+
713
+ logger.info(f"Updated custom reporter: {reporter.name}")
714
+ return reporter
715
+
716
+ async def delete_reporter(
717
+ self,
718
+ session: AsyncSession,
719
+ reporter_id: str,
720
+ ) -> None:
721
+ """Delete a custom reporter.
722
+
723
+ Args:
724
+ session: Database session.
725
+ reporter_id: Reporter ID.
726
+ """
727
+ reporter = await self.get_reporter(session, reporter_id=reporter_id)
728
+ if not reporter:
729
+ raise ValueError(f"Reporter {reporter_id} not found")
730
+
731
+ # Remove from cache
732
+ if reporter.name in self._reporters:
733
+ del self._reporters[reporter.name]
734
+
735
+ # Update plugin reporter count if applicable
736
+ if reporter.plugin_id:
737
+ plugin = await self.get_plugin(session, plugin_id=reporter.plugin_id)
738
+ if plugin and plugin.reporters_count > 0:
739
+ plugin.reporters_count -= 1
740
+
741
+ await session.delete(reporter)
742
+ await session.flush()
743
+ logger.info(f"Deleted custom reporter: {reporter.name}")
744
+
745
+ def get_cached_reporter(self, name: str) -> CustomReporter | None:
746
+ """Get a reporter from cache by name.
747
+
748
+ Args:
749
+ name: Reporter name.
750
+
751
+ Returns:
752
+ CustomReporter if found in cache, None otherwise.
753
+ """
754
+ return self._reporters.get(name)
755
+
756
+ # =========================================================================
757
+ # Statistics
758
+ # =========================================================================
759
+
760
+ async def get_statistics(self, session: AsyncSession) -> dict[str, Any]:
761
+ """Get plugin system statistics.
762
+
763
+ Args:
764
+ session: Database session.
765
+
766
+ Returns:
767
+ Dictionary of statistics.
768
+ """
769
+ # Count plugins by type
770
+ plugins_result = await session.execute(select(Plugin))
771
+ plugins = plugins_result.scalars().all()
772
+
773
+ total_plugins = len(plugins)
774
+ installed = sum(1 for p in plugins if p.status in (PluginStatus.INSTALLED.value, PluginStatus.ENABLED.value))
775
+ enabled = sum(1 for p in plugins if p.is_enabled)
776
+
777
+ by_type = {}
778
+ for ptype in PluginType:
779
+ by_type[ptype.value] = sum(1 for p in plugins if p.type == ptype.value)
780
+
781
+ # Count validators and reporters
782
+ validators_result = await session.execute(select(CustomValidator))
783
+ validators = validators_result.scalars().all()
784
+
785
+ reporters_result = await session.execute(select(CustomReporter))
786
+ reporters = reporters_result.scalars().all()
787
+
788
+ # Get categories
789
+ categories = set()
790
+ for v in validators:
791
+ if v.category:
792
+ categories.add(v.category)
793
+
794
+ return {
795
+ "total_plugins": total_plugins,
796
+ "installed_plugins": installed,
797
+ "enabled_plugins": enabled,
798
+ "plugins_by_type": by_type,
799
+ "total_validators": len(validators),
800
+ "enabled_validators": sum(1 for v in validators if v.is_enabled),
801
+ "total_reporters": len(reporters),
802
+ "enabled_reporters": sum(1 for r in reporters if r.is_enabled),
803
+ "validator_categories": sorted(list(categories)),
804
+ "cached_validators": len(self._validators),
805
+ "cached_reporters": len(self._reporters),
806
+ }
807
+
808
+
809
+ # Global registry instance
810
+ plugin_registry = PluginRegistry()