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
@@ -221,9 +221,53 @@ async def test_source_connection(
221
221
  async def get_supported_types() -> dict:
222
222
  """Get list of supported source types.
223
223
 
224
+ Returns comprehensive information about each source type including
225
+ field definitions for dynamic form rendering.
226
+
227
+ Returns:
228
+ List of supported source types with field definitions.
229
+ """
230
+ from truthound_dashboard.core.connections import (
231
+ get_source_type_categories,
232
+ get_supported_source_types,
233
+ )
234
+
235
+ return {
236
+ "success": True,
237
+ "data": {
238
+ "types": get_supported_source_types(),
239
+ "categories": get_source_type_categories(),
240
+ },
241
+ }
242
+
243
+
244
+ @router.post(
245
+ "/test-connection",
246
+ response_model=dict,
247
+ summary="Test connection configuration",
248
+ description="Test a connection configuration before creating a source",
249
+ )
250
+ async def test_connection_config(
251
+ request: dict,
252
+ ) -> dict:
253
+ """Test connection configuration before creating a source.
254
+
255
+ This endpoint allows testing connection settings without
256
+ persisting them to the database.
257
+
258
+ Args:
259
+ request: Dictionary with 'type' and 'config' keys.
260
+
224
261
  Returns:
225
- List of supported source types with required/optional fields.
262
+ Connection test result with success status and message.
226
263
  """
227
- from truthound_dashboard.core.connections import get_supported_source_types
264
+ from truthound_dashboard.core.connections import test_connection
265
+
266
+ source_type = request.get("type")
267
+ config = request.get("config", {})
228
268
 
229
- return {"success": True, "data": get_supported_source_types()}
269
+ if not source_type:
270
+ raise HTTPException(status_code=400, detail="Source type is required")
271
+
272
+ result = await test_connection(source_type, config)
273
+ return {"success": True, "data": result}
@@ -0,0 +1,190 @@
1
+ """Trigger monitoring and webhook API endpoints.
2
+
3
+ This module provides endpoints for:
4
+ - Trigger monitoring status
5
+ - Webhook trigger reception
6
+ - Trigger status queries
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from typing import Any
13
+
14
+ from fastapi import APIRouter, Header, HTTPException
15
+
16
+ from truthound_dashboard.core.scheduler import get_scheduler
17
+ from truthound_dashboard.schemas.triggers import (
18
+ TriggerCheckStatus,
19
+ TriggerMonitoringResponse,
20
+ TriggerMonitoringStats,
21
+ WebhookTriggerRequest,
22
+ WebhookTriggerResponse,
23
+ )
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ router = APIRouter(prefix="/triggers", tags=["triggers"])
28
+
29
+
30
+ @router.get(
31
+ "/monitoring",
32
+ response_model=TriggerMonitoringResponse,
33
+ summary="Get trigger monitoring status",
34
+ )
35
+ async def get_trigger_monitoring() -> TriggerMonitoringResponse:
36
+ """Get current trigger monitoring status.
37
+
38
+ Returns:
39
+ Trigger monitoring stats and schedule statuses.
40
+ """
41
+ scheduler = get_scheduler()
42
+ status = scheduler.get_trigger_monitoring_status()
43
+ schedules = await scheduler.get_trigger_check_statuses()
44
+
45
+ # Calculate aggregate stats
46
+ active_data_change = sum(
47
+ 1 for s in schedules if s["trigger_type"] == "data_change"
48
+ )
49
+ active_webhook = sum(
50
+ 1 for s in schedules if s["trigger_type"] == "webhook"
51
+ )
52
+ active_composite = sum(
53
+ 1 for s in schedules if s["trigger_type"] == "composite"
54
+ )
55
+
56
+ # Calculate average check interval
57
+ check_intervals = [
58
+ s.get("check_interval_minutes", 5) * 60 for s in schedules
59
+ ]
60
+ avg_interval = (
61
+ sum(check_intervals) / len(check_intervals)
62
+ if check_intervals
63
+ else 300.0
64
+ )
65
+
66
+ # Find next scheduled check
67
+ next_checks = [
68
+ s["next_check_at"] for s in schedules if s.get("next_check_at")
69
+ ]
70
+ next_check = min(next_checks) if next_checks else None
71
+
72
+ stats = TriggerMonitoringStats(
73
+ total_schedules=len(schedules),
74
+ active_data_change_triggers=active_data_change,
75
+ active_webhook_triggers=active_webhook,
76
+ active_composite_triggers=active_composite,
77
+ total_checks_last_hour=status.get("checks_last_hour", 0),
78
+ total_triggers_last_hour=status.get("triggers_last_hour", 0),
79
+ average_check_interval_seconds=avg_interval,
80
+ next_scheduled_check_at=next_check,
81
+ )
82
+
83
+ schedule_statuses = [
84
+ TriggerCheckStatus(**s) for s in schedules
85
+ ]
86
+
87
+ return TriggerMonitoringResponse(
88
+ stats=stats,
89
+ schedules=schedule_statuses,
90
+ checker_running=status.get("checker_running", False),
91
+ checker_interval_seconds=status.get("checker_interval_seconds", 300),
92
+ last_checker_run_at=status.get("last_checker_run_at"),
93
+ )
94
+
95
+
96
+ @router.get(
97
+ "/schedules/{schedule_id}/status",
98
+ summary="Get trigger status for a specific schedule",
99
+ )
100
+ async def get_schedule_trigger_status(schedule_id: str) -> dict[str, Any]:
101
+ """Get trigger status for a specific schedule.
102
+
103
+ Args:
104
+ schedule_id: Schedule ID to query.
105
+
106
+ Returns:
107
+ Trigger status for the schedule.
108
+ """
109
+ scheduler = get_scheduler()
110
+ schedules = await scheduler.get_trigger_check_statuses()
111
+
112
+ for schedule in schedules:
113
+ if schedule["schedule_id"] == schedule_id:
114
+ return schedule
115
+
116
+ raise HTTPException(
117
+ status_code=404,
118
+ detail=f"Schedule {schedule_id} not found or not using monitored trigger type",
119
+ )
120
+
121
+
122
+ @router.post(
123
+ "/webhook",
124
+ response_model=WebhookTriggerResponse,
125
+ summary="Receive webhook trigger",
126
+ )
127
+ async def receive_webhook(
128
+ request: WebhookTriggerRequest,
129
+ x_webhook_signature: str | None = Header(
130
+ default=None, description="HMAC-SHA256 signature for verification"
131
+ ),
132
+ ) -> WebhookTriggerResponse:
133
+ """Receive and process an incoming webhook trigger.
134
+
135
+ This endpoint is called by external systems (Airflow, Dagster, Prefect, etc.)
136
+ to trigger validations when data pipelines complete.
137
+
138
+ Args:
139
+ request: Webhook trigger request.
140
+ x_webhook_signature: Optional signature for verification.
141
+
142
+ Returns:
143
+ Webhook trigger response with triggered schedules.
144
+ """
145
+ scheduler = get_scheduler()
146
+
147
+ result = await scheduler.trigger_webhook(
148
+ source=request.source,
149
+ event_type=request.event_type,
150
+ payload=request.payload,
151
+ schedule_id=request.schedule_id,
152
+ source_id=request.source_id,
153
+ signature=x_webhook_signature,
154
+ )
155
+
156
+ return WebhookTriggerResponse(
157
+ accepted=result["accepted"],
158
+ triggered_schedules=result["triggered_schedules"],
159
+ message=result["message"],
160
+ request_id=result["request_id"],
161
+ )
162
+
163
+
164
+ @router.post(
165
+ "/webhook/test",
166
+ summary="Test webhook configuration",
167
+ )
168
+ async def test_webhook(
169
+ source: str = "test",
170
+ event_type: str = "test_event",
171
+ ) -> dict[str, Any]:
172
+ """Test webhook endpoint without triggering any schedules.
173
+
174
+ Useful for verifying connectivity and configuration.
175
+
176
+ Args:
177
+ source: Test source name.
178
+ event_type: Test event type.
179
+
180
+ Returns:
181
+ Test result.
182
+ """
183
+ return {
184
+ "success": True,
185
+ "message": "Webhook endpoint is accessible",
186
+ "received": {
187
+ "source": source,
188
+ "event_type": event_type,
189
+ },
190
+ }
@@ -71,10 +71,23 @@ async def run_validation(
71
71
  # Simple mode: use validator names list (backward compatible)
72
72
  validators = request.validators
73
73
 
74
+ # Convert custom validators to internal format
75
+ custom_validators = None
76
+ if request.custom_validators:
77
+ custom_validators = [
78
+ {
79
+ "validator_id": cv.validator_id,
80
+ "column": cv.column,
81
+ "params": cv.params or {},
82
+ }
83
+ for cv in request.custom_validators
84
+ ]
85
+
74
86
  validation = await service.run_validation(
75
87
  source_id,
76
88
  validators=validators,
77
89
  validator_params=validator_params,
90
+ custom_validators=custom_validators,
78
91
  schema_path=request.schema_path,
79
92
  auto_schema=request.auto_schema,
80
93
  columns=request.columns,
@@ -1,29 +1,50 @@
1
1
  """Validators API endpoints.
2
2
 
3
3
  This module provides API endpoints for validator discovery and configuration.
4
+ Includes both built-in truthound validators and user-defined custom validators.
4
5
  """
5
6
 
6
7
  from __future__ import annotations
7
8
 
8
- from fastapi import APIRouter, Query
9
+ import logging
10
+ from collections import defaultdict
11
+ from typing import Annotated, Any
12
+
13
+ from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
14
+ from sqlalchemy.ext.asyncio import AsyncSession
15
+
16
+ from truthound_dashboard.core.plugins import CustomValidatorExecutor
17
+ from truthound_dashboard.core.plugins.registry import plugin_registry
18
+ from truthound_dashboard.core.plugins.validator_executor import ValidatorContext
9
19
 
10
20
  from ..schemas.validators import (
11
21
  VALIDATOR_REGISTRY,
22
+ CustomValidatorExecuteRequest,
23
+ CustomValidatorExecuteResponse,
24
+ UnifiedValidatorDefinition,
25
+ UnifiedValidatorListResponse,
12
26
  ValidatorCategory,
13
27
  ValidatorDefinition,
28
+ ValidatorSource,
14
29
  get_validator_by_name,
15
30
  get_validators_by_category,
16
31
  search_validators,
17
32
  )
33
+ from .deps import SourceServiceDep, get_session
34
+
35
+ logger = logging.getLogger(__name__)
18
36
 
19
37
  router = APIRouter()
20
38
 
39
+ # Dependencies
40
+ SessionDep = Annotated[AsyncSession, Depends(get_session)]
41
+
21
42
 
22
43
  @router.get(
23
44
  "/validators",
24
45
  response_model=list[ValidatorDefinition],
25
- summary="List all validators",
26
- description="Returns all available validators with their parameter definitions.",
46
+ summary="List built-in validators",
47
+ description="Returns all built-in validators with their parameter definitions.",
27
48
  )
28
49
  async def list_validators(
29
50
  category: ValidatorCategory | None = Query(
@@ -33,7 +54,7 @@ async def list_validators(
33
54
  default=None, description="Search by name, description, or tags"
34
55
  ),
35
56
  ) -> list[ValidatorDefinition]:
36
- """List all validators, optionally filtered.
57
+ """List built-in validators, optionally filtered.
37
58
 
38
59
  Args:
39
60
  category: Optional category filter.
@@ -49,6 +70,122 @@ async def list_validators(
49
70
  return VALIDATOR_REGISTRY
50
71
 
51
72
 
73
+ @router.get(
74
+ "/validators/unified",
75
+ response_model=UnifiedValidatorListResponse,
76
+ summary="List all validators (built-in + custom)",
77
+ description="Returns unified list of both built-in and custom validators.",
78
+ )
79
+ async def list_unified_validators(
80
+ session: SessionDep,
81
+ category: str | None = Query(
82
+ default=None, description="Filter by category"
83
+ ),
84
+ source: ValidatorSource | None = Query(
85
+ default=None, description="Filter by source (builtin or custom)"
86
+ ),
87
+ search: str | None = Query(
88
+ default=None, description="Search by name, description, or tags"
89
+ ),
90
+ enabled_only: bool = Query(
91
+ default=False, description="Only return enabled validators"
92
+ ),
93
+ offset: int = Query(default=0, ge=0),
94
+ limit: int = Query(default=100, ge=1, le=500),
95
+ ) -> UnifiedValidatorListResponse:
96
+ """List all validators (built-in + custom).
97
+
98
+ This endpoint provides a unified view of all available validators,
99
+ combining truthound's built-in validators with user-defined custom validators.
100
+
101
+ Args:
102
+ session: Database session.
103
+ category: Optional category filter.
104
+ source: Optional source filter (builtin or custom).
105
+ search: Optional search query.
106
+ enabled_only: Only return enabled validators.
107
+ offset: Pagination offset.
108
+ limit: Pagination limit.
109
+
110
+ Returns:
111
+ Unified list of validators with metadata.
112
+ """
113
+ unified_validators: list[UnifiedValidatorDefinition] = []
114
+
115
+ # 1. Get built-in validators
116
+ builtin_count = 0
117
+ if source is None or source == ValidatorSource.BUILTIN:
118
+ builtin_validators = VALIDATOR_REGISTRY
119
+
120
+ # Apply filters
121
+ if search:
122
+ search_lower = search.lower()
123
+ builtin_validators = [
124
+ v for v in builtin_validators
125
+ if (
126
+ search_lower in v.name.lower()
127
+ or search_lower in v.display_name.lower()
128
+ or search_lower in v.description.lower()
129
+ or any(search_lower in tag.lower() for tag in v.tags)
130
+ )
131
+ ]
132
+
133
+ if category:
134
+ builtin_validators = [
135
+ v for v in builtin_validators
136
+ if v.category.value == category
137
+ ]
138
+
139
+ for v in builtin_validators:
140
+ unified_validators.append(UnifiedValidatorDefinition.from_builtin(v))
141
+ builtin_count = len(builtin_validators)
142
+
143
+ # 2. Get custom validators
144
+ custom_count = 0
145
+ if source is None or source == ValidatorSource.CUSTOM:
146
+ custom_validators, total_custom = await plugin_registry.list_validators(
147
+ session=session,
148
+ category=category,
149
+ enabled_only=enabled_only,
150
+ search=search,
151
+ offset=0,
152
+ limit=500, # Get all for now, apply pagination later
153
+ )
154
+ for cv in custom_validators:
155
+ unified_validators.append(UnifiedValidatorDefinition.from_custom(cv))
156
+ custom_count = len(custom_validators)
157
+
158
+ # Calculate category summary
159
+ category_counts: dict[str, dict[str, int]] = defaultdict(
160
+ lambda: {"builtin": 0, "custom": 0}
161
+ )
162
+ for v in unified_validators:
163
+ category_counts[v.category][v.source.value] += 1
164
+
165
+ categories = [
166
+ {
167
+ "name": cat,
168
+ "label": cat.replace("_", " ").title(),
169
+ "builtin_count": counts["builtin"],
170
+ "custom_count": counts["custom"],
171
+ "total": counts["builtin"] + counts["custom"],
172
+ }
173
+ for cat, counts in sorted(category_counts.items())
174
+ ]
175
+
176
+ # Apply pagination
177
+ total = len(unified_validators)
178
+ paginated = unified_validators[offset : offset + limit]
179
+
180
+ return UnifiedValidatorListResponse(
181
+ data=paginated,
182
+ total=total,
183
+ builtin_count=builtin_count,
184
+ custom_count=custom_count,
185
+ categories=categories,
186
+ )
187
+
188
+
52
189
  @router.get(
53
190
  "/validators/categories",
54
191
  response_model=list[dict[str, str]],
@@ -83,3 +220,195 @@ async def get_validator(name: str) -> ValidatorDefinition | None:
83
220
  Validator definition if found.
84
221
  """
85
222
  return get_validator_by_name(name)
223
+
224
+
225
+ # =============================================================================
226
+ # Custom Validator Execution Endpoints
227
+ # =============================================================================
228
+
229
+
230
+ @router.post(
231
+ "/validators/custom/{validator_id}/execute",
232
+ response_model=CustomValidatorExecuteResponse,
233
+ summary="Execute custom validator",
234
+ description="Execute a custom validator against a data source column.",
235
+ )
236
+ async def execute_custom_validator(
237
+ session: SessionDep,
238
+ source_service: SourceServiceDep,
239
+ validator_id: Annotated[str, Path(description="Custom validator ID")],
240
+ request: CustomValidatorExecuteRequest,
241
+ ) -> CustomValidatorExecuteResponse:
242
+ """Execute a custom validator on a specific data source and column.
243
+
244
+ This endpoint allows direct execution of a custom validator without
245
+ going through the full validation pipeline. Useful for testing and
246
+ one-off validations.
247
+
248
+ Args:
249
+ session: Database session.
250
+ source_service: Source service for data access.
251
+ validator_id: ID of the custom validator.
252
+ request: Execution request with source_id, column_name, and params.
253
+
254
+ Returns:
255
+ Execution result with validation status and issues.
256
+
257
+ Raises:
258
+ HTTPException: 404 if validator or source not found.
259
+ """
260
+ # Get the custom validator
261
+ validator = await plugin_registry.get_validator(session, validator_id=validator_id)
262
+ if not validator:
263
+ raise HTTPException(
264
+ status_code=status.HTTP_404_NOT_FOUND,
265
+ detail=f"Custom validator {validator_id} not found",
266
+ )
267
+
268
+ if not validator.is_enabled:
269
+ raise HTTPException(
270
+ status_code=status.HTTP_400_BAD_REQUEST,
271
+ detail=f"Custom validator {validator.name} is disabled",
272
+ )
273
+
274
+ # Get the data source
275
+ source = await source_service.get_by_id(request.source_id)
276
+ if not source:
277
+ raise HTTPException(
278
+ status_code=status.HTTP_404_NOT_FOUND,
279
+ detail=f"Data source {request.source_id} not found",
280
+ )
281
+
282
+ # Load data from source
283
+ try:
284
+ import polars as pl
285
+
286
+ # Read data based on source type
287
+ if source.type == "csv":
288
+ df = pl.read_csv(source.path)
289
+ elif source.type == "parquet":
290
+ df = pl.read_parquet(source.path)
291
+ elif source.type == "json":
292
+ df = pl.read_json(source.path)
293
+ else:
294
+ raise HTTPException(
295
+ status_code=status.HTTP_400_BAD_REQUEST,
296
+ detail=f"Unsupported source type: {source.type}",
297
+ )
298
+
299
+ # Check if column exists
300
+ if request.column_name not in df.columns:
301
+ raise HTTPException(
302
+ status_code=status.HTTP_400_BAD_REQUEST,
303
+ detail=f"Column '{request.column_name}' not found in source",
304
+ )
305
+
306
+ # Apply sample size if specified
307
+ if request.sample_size and request.sample_size < len(df):
308
+ df = df.sample(request.sample_size)
309
+
310
+ # Get column values
311
+ column_values = df[request.column_name].to_list()
312
+
313
+ # Get column schema info
314
+ column_schema = {
315
+ "dtype": str(df[request.column_name].dtype),
316
+ "null_count": df[request.column_name].null_count(),
317
+ }
318
+
319
+ except HTTPException:
320
+ raise
321
+ except Exception as e:
322
+ logger.error(f"Failed to load data from source: {e}")
323
+ return CustomValidatorExecuteResponse(
324
+ success=False,
325
+ error=f"Failed to load data: {str(e)}",
326
+ )
327
+
328
+ # Create execution context
329
+ context = ValidatorContext(
330
+ column_name=request.column_name,
331
+ column_values=column_values,
332
+ parameters=request.param_values,
333
+ schema=column_schema,
334
+ row_count=len(column_values),
335
+ )
336
+
337
+ # Execute the validator
338
+ executor = CustomValidatorExecutor()
339
+ result = await executor.execute(
340
+ validator=validator,
341
+ context=context,
342
+ session=session,
343
+ source_id=request.source_id,
344
+ )
345
+
346
+ await session.commit()
347
+
348
+ return CustomValidatorExecuteResponse(
349
+ success=True,
350
+ passed=result.passed,
351
+ execution_time_ms=result.execution_time_ms,
352
+ issues=result.issues,
353
+ message=result.message,
354
+ details=result.details,
355
+ )
356
+
357
+
358
+ @router.post(
359
+ "/validators/custom/{validator_id}/execute-preview",
360
+ response_model=CustomValidatorExecuteResponse,
361
+ summary="Preview custom validator execution",
362
+ description="Execute a custom validator on sample data for preview.",
363
+ )
364
+ async def preview_custom_validator_execution(
365
+ session: SessionDep,
366
+ validator_id: Annotated[str, Path(description="Custom validator ID")],
367
+ test_data: dict[str, Any],
368
+ ) -> CustomValidatorExecuteResponse:
369
+ """Preview custom validator execution with provided test data.
370
+
371
+ This endpoint allows testing a saved custom validator with arbitrary
372
+ test data without needing a data source.
373
+
374
+ Args:
375
+ session: Database session.
376
+ validator_id: ID of the custom validator.
377
+ test_data: Test data containing column_name, values, and params.
378
+
379
+ Returns:
380
+ Execution result with validation status and issues.
381
+ """
382
+ # Get the custom validator
383
+ validator = await plugin_registry.get_validator(session, validator_id=validator_id)
384
+ if not validator:
385
+ raise HTTPException(
386
+ status_code=status.HTTP_404_NOT_FOUND,
387
+ detail=f"Custom validator {validator_id} not found",
388
+ )
389
+
390
+ # Create context from test data
391
+ context = ValidatorContext(
392
+ column_name=test_data.get("column_name", "test_column"),
393
+ column_values=test_data.get("values", []),
394
+ parameters=test_data.get("params", {}),
395
+ schema=test_data.get("schema", {}),
396
+ row_count=len(test_data.get("values", [])),
397
+ )
398
+
399
+ # Execute the validator (without logging)
400
+ executor = CustomValidatorExecutor(log_executions=False)
401
+ result = await executor.execute(
402
+ validator=validator,
403
+ context=context,
404
+ session=None,
405
+ )
406
+
407
+ return CustomValidatorExecuteResponse(
408
+ success=True,
409
+ passed=result.passed,
410
+ execution_time_ms=result.execution_time_ms,
411
+ issues=result.issues,
412
+ message=result.message,
413
+ details=result.details,
414
+ )