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,609 @@
1
+ """Trigger evaluator implementations.
2
+
3
+ Provides concrete implementations for all trigger types:
4
+ - CronTrigger: Cron expression based scheduling
5
+ - IntervalTrigger: Fixed time interval scheduling
6
+ - DataChangeTrigger: Profile-based change detection
7
+ - CompositeTrigger: Combine multiple triggers
8
+ - EventTrigger: Respond to system events
9
+ - ManualTrigger: API-only execution
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from datetime import datetime, timedelta
16
+ from typing import Any
17
+
18
+ from apscheduler.triggers.cron import CronTrigger as APCronTrigger
19
+ from apscheduler.triggers.interval import IntervalTrigger as APIntervalTrigger
20
+
21
+ from .base import (
22
+ BaseTrigger,
23
+ TriggerContext,
24
+ TriggerEvaluation,
25
+ TriggerRegistry,
26
+ )
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ @TriggerRegistry.register("cron")
32
+ class CronTrigger(BaseTrigger):
33
+ """Cron expression based trigger.
34
+
35
+ Uses standard cron format: minute hour day month weekday
36
+
37
+ Config:
38
+ expression: Cron expression string
39
+ timezone: Optional timezone (default: UTC)
40
+ """
41
+
42
+ def _validate_config(self) -> None:
43
+ """Validate cron configuration."""
44
+ expression = self.config.get("expression")
45
+ if not expression:
46
+ raise ValueError("Cron trigger requires 'expression' field")
47
+
48
+ # Validate by attempting to parse
49
+ try:
50
+ APCronTrigger.from_crontab(expression)
51
+ except Exception as e:
52
+ raise ValueError(f"Invalid cron expression: {e}")
53
+
54
+ async def evaluate(self, context: TriggerContext) -> TriggerEvaluation:
55
+ """Evaluate if cron trigger should fire.
56
+
57
+ For cron triggers, we check if we're past the scheduled time
58
+ since the last run.
59
+ """
60
+ expression = self.config.get("expression")
61
+ trigger = APCronTrigger.from_crontab(expression)
62
+
63
+ # Get next fire time from last run (or from epoch if never run)
64
+ base_time = context.last_run_at or datetime(2000, 1, 1)
65
+ next_fire = trigger.get_next_fire_time(None, base_time)
66
+
67
+ if next_fire is None:
68
+ return TriggerEvaluation(
69
+ should_trigger=False,
70
+ reason="No upcoming scheduled time",
71
+ )
72
+
73
+ should_trigger = context.current_time >= next_fire
74
+
75
+ return TriggerEvaluation(
76
+ should_trigger=should_trigger,
77
+ reason=(
78
+ f"Scheduled time reached ({next_fire.isoformat()})"
79
+ if should_trigger
80
+ else f"Waiting for scheduled time ({next_fire.isoformat()})"
81
+ ),
82
+ next_evaluation_at=next_fire if not should_trigger else None,
83
+ details={
84
+ "expression": expression,
85
+ "next_fire_time": next_fire.isoformat(),
86
+ },
87
+ )
88
+
89
+ def get_next_evaluation_time(
90
+ self, context: TriggerContext
91
+ ) -> datetime | None:
92
+ """Get next cron fire time."""
93
+ expression = self.config.get("expression")
94
+ try:
95
+ trigger = APCronTrigger.from_crontab(expression)
96
+ return trigger.get_next_fire_time(None, context.current_time)
97
+ except Exception:
98
+ return None
99
+
100
+ def get_description(self) -> str:
101
+ """Get human-readable description."""
102
+ return f"Cron: {self.config.get('expression', 'not configured')}"
103
+
104
+
105
+ @TriggerRegistry.register("interval")
106
+ class IntervalTrigger(BaseTrigger):
107
+ """Fixed time interval trigger.
108
+
109
+ Config:
110
+ seconds: Interval in seconds
111
+ minutes: Interval in minutes
112
+ hours: Interval in hours
113
+ days: Interval in days
114
+ """
115
+
116
+ def _validate_config(self) -> None:
117
+ """Validate interval configuration."""
118
+ has_interval = any(
119
+ self.config.get(key)
120
+ for key in ["seconds", "minutes", "hours", "days"]
121
+ )
122
+ if not has_interval:
123
+ raise ValueError(
124
+ "Interval trigger requires at least one of: seconds, minutes, hours, days"
125
+ )
126
+
127
+ def _get_total_seconds(self) -> int:
128
+ """Calculate total interval in seconds."""
129
+ total = 0
130
+ total += self.config.get("seconds", 0)
131
+ total += self.config.get("minutes", 0) * 60
132
+ total += self.config.get("hours", 0) * 3600
133
+ total += self.config.get("days", 0) * 86400
134
+ return total or 3600 # Default to 1 hour
135
+
136
+ async def evaluate(self, context: TriggerContext) -> TriggerEvaluation:
137
+ """Evaluate if interval trigger should fire."""
138
+ interval_seconds = self._get_total_seconds()
139
+
140
+ # If never run, trigger immediately
141
+ if context.last_run_at is None:
142
+ return TriggerEvaluation(
143
+ should_trigger=True,
144
+ reason="First run (never executed before)",
145
+ details={"interval_seconds": interval_seconds},
146
+ )
147
+
148
+ # Calculate next run time
149
+ next_run = context.last_run_at + timedelta(seconds=interval_seconds)
150
+ should_trigger = context.current_time >= next_run
151
+
152
+ return TriggerEvaluation(
153
+ should_trigger=should_trigger,
154
+ reason=(
155
+ f"Interval elapsed ({interval_seconds}s since last run)"
156
+ if should_trigger
157
+ else f"Waiting for interval ({(next_run - context.current_time).seconds}s remaining)"
158
+ ),
159
+ next_evaluation_at=next_run if not should_trigger else None,
160
+ details={
161
+ "interval_seconds": interval_seconds,
162
+ "next_run": next_run.isoformat(),
163
+ },
164
+ )
165
+
166
+ def get_next_evaluation_time(
167
+ self, context: TriggerContext
168
+ ) -> datetime | None:
169
+ """Get next interval fire time."""
170
+ if context.last_run_at is None:
171
+ return context.current_time
172
+ return context.last_run_at + timedelta(seconds=self._get_total_seconds())
173
+
174
+ def get_description(self) -> str:
175
+ """Get human-readable description."""
176
+ parts = []
177
+ if self.config.get("days"):
178
+ parts.append(f"{self.config['days']}d")
179
+ if self.config.get("hours"):
180
+ parts.append(f"{self.config['hours']}h")
181
+ if self.config.get("minutes"):
182
+ parts.append(f"{self.config['minutes']}m")
183
+ if self.config.get("seconds"):
184
+ parts.append(f"{self.config['seconds']}s")
185
+ return f"Every {' '.join(parts)}" if parts else "Interval: not configured"
186
+
187
+
188
+ @TriggerRegistry.register("data_change")
189
+ class DataChangeTrigger(BaseTrigger):
190
+ """Data change detection trigger.
191
+
192
+ Triggers when profile metrics change by more than a threshold.
193
+
194
+ Config:
195
+ change_threshold: Minimum change percentage (0.0-1.0)
196
+ metrics: List of metrics to monitor
197
+ check_interval_minutes: How often to check
198
+ """
199
+
200
+ DEFAULT_METRICS = ["row_count", "null_percentage", "distinct_count"]
201
+
202
+ def _validate_config(self) -> None:
203
+ """Validate data change configuration."""
204
+ threshold = self.config.get("change_threshold", 0.05)
205
+ if not 0.0 <= threshold <= 1.0:
206
+ raise ValueError("change_threshold must be between 0.0 and 1.0")
207
+
208
+ async def evaluate(self, context: TriggerContext) -> TriggerEvaluation:
209
+ """Evaluate if data has changed enough to trigger.
210
+
211
+ Compares current profile against baseline and checks if
212
+ any monitored metrics have changed beyond the threshold.
213
+ """
214
+ threshold = self.config.get("change_threshold", 0.05)
215
+ metrics = self.config.get("metrics", self.DEFAULT_METRICS)
216
+
217
+ # Need both profiles to compare
218
+ if context.profile_data is None:
219
+ return TriggerEvaluation(
220
+ should_trigger=False,
221
+ reason="No current profile data available",
222
+ details={"error": "missing_current_profile"},
223
+ )
224
+
225
+ if context.baseline_profile is None:
226
+ # First profile - trigger to establish baseline
227
+ return TriggerEvaluation(
228
+ should_trigger=True,
229
+ reason="First profile (no baseline to compare)",
230
+ details={"reason": "no_baseline"},
231
+ )
232
+
233
+ # Calculate changes for each metric
234
+ changes = {}
235
+ max_change = 0.0
236
+ triggered_metrics = []
237
+
238
+ for metric in metrics:
239
+ current = self._get_metric_value(context.profile_data, metric)
240
+ baseline = self._get_metric_value(context.baseline_profile, metric)
241
+
242
+ if current is None or baseline is None:
243
+ continue
244
+
245
+ # Calculate percentage change
246
+ if baseline == 0:
247
+ change = 1.0 if current != 0 else 0.0
248
+ else:
249
+ change = abs(current - baseline) / abs(baseline)
250
+
251
+ changes[metric] = {
252
+ "current": current,
253
+ "baseline": baseline,
254
+ "change": change,
255
+ }
256
+
257
+ if change >= threshold:
258
+ triggered_metrics.append(metric)
259
+ max_change = max(max_change, change)
260
+
261
+ should_trigger = len(triggered_metrics) > 0
262
+
263
+ return TriggerEvaluation(
264
+ should_trigger=should_trigger,
265
+ reason=(
266
+ f"Data change detected: {', '.join(triggered_metrics)} changed by >= {threshold*100:.0f}%"
267
+ if should_trigger
268
+ else f"No significant changes (max: {max_change*100:.1f}%, threshold: {threshold*100:.0f}%)"
269
+ ),
270
+ details={
271
+ "threshold": threshold,
272
+ "max_change": max_change,
273
+ "changes": changes,
274
+ "triggered_metrics": triggered_metrics,
275
+ },
276
+ confidence=max_change if should_trigger else 1.0 - max_change,
277
+ )
278
+
279
+ def _get_metric_value(
280
+ self, profile: dict[str, Any], metric: str
281
+ ) -> float | None:
282
+ """Extract metric value from profile data."""
283
+ # Handle top-level metrics
284
+ if metric in profile:
285
+ return float(profile[metric])
286
+
287
+ # Handle nested column metrics
288
+ if "columns" in profile:
289
+ for col in profile["columns"]:
290
+ if metric in col:
291
+ return float(col[metric])
292
+
293
+ # Handle summary metrics
294
+ if "summary" in profile and metric in profile["summary"]:
295
+ return float(profile["summary"][metric])
296
+
297
+ return None
298
+
299
+ def get_description(self) -> str:
300
+ """Get human-readable description."""
301
+ threshold = self.config.get("change_threshold", 0.05)
302
+ return f"Data change >= {threshold * 100:.0f}%"
303
+
304
+
305
+ @TriggerRegistry.register("composite")
306
+ class CompositeTrigger(BaseTrigger):
307
+ """Composite trigger that combines multiple triggers.
308
+
309
+ Config:
310
+ operator: "and" or "or"
311
+ triggers: List of trigger configurations
312
+ """
313
+
314
+ def _validate_config(self) -> None:
315
+ """Validate composite configuration."""
316
+ operator = self.config.get("operator", "and").lower()
317
+ if operator not in ("and", "or"):
318
+ raise ValueError("Composite operator must be 'and' or 'or'")
319
+
320
+ triggers = self.config.get("triggers", [])
321
+ if len(triggers) < 2:
322
+ raise ValueError("Composite trigger requires at least 2 sub-triggers")
323
+
324
+ # Validate sub-triggers don't contain composites (prevent nesting)
325
+ for trigger in triggers:
326
+ if trigger.get("type") == "composite":
327
+ raise ValueError("Nested composite triggers are not supported")
328
+
329
+ async def evaluate(self, context: TriggerContext) -> TriggerEvaluation:
330
+ """Evaluate composite trigger based on operator."""
331
+ operator = self.config.get("operator", "and").lower()
332
+ trigger_configs = self.config.get("triggers", [])
333
+
334
+ sub_results = []
335
+
336
+ for trigger_config in trigger_configs:
337
+ trigger_type = trigger_config.get("type")
338
+ trigger = TriggerRegistry.create(trigger_type, trigger_config)
339
+
340
+ if trigger is None:
341
+ sub_results.append({
342
+ "type": trigger_type,
343
+ "should_trigger": False,
344
+ "reason": f"Unknown trigger type: {trigger_type}",
345
+ })
346
+ continue
347
+
348
+ result = await trigger.evaluate(context)
349
+ sub_results.append({
350
+ "type": trigger_type,
351
+ "should_trigger": result.should_trigger,
352
+ "reason": result.reason,
353
+ })
354
+
355
+ # Apply operator logic
356
+ if operator == "and":
357
+ should_trigger = all(r["should_trigger"] for r in sub_results)
358
+ reason = "All conditions met" if should_trigger else "Not all conditions met"
359
+ else: # or
360
+ should_trigger = any(r["should_trigger"] for r in sub_results)
361
+ reason = "At least one condition met" if should_trigger else "No conditions met"
362
+
363
+ triggered_count = sum(1 for r in sub_results if r["should_trigger"])
364
+
365
+ return TriggerEvaluation(
366
+ should_trigger=should_trigger,
367
+ reason=f"{reason} ({triggered_count}/{len(sub_results)} triggers)",
368
+ details={
369
+ "operator": operator,
370
+ "sub_results": sub_results,
371
+ "triggered_count": triggered_count,
372
+ "total_count": len(sub_results),
373
+ },
374
+ )
375
+
376
+ def get_description(self) -> str:
377
+ """Get human-readable description."""
378
+ operator = self.config.get("operator", "and").upper()
379
+ triggers = self.config.get("triggers", [])
380
+ return f"Composite: {len(triggers)} triggers ({operator})"
381
+
382
+
383
+ @TriggerRegistry.register("event")
384
+ class EventTrigger(BaseTrigger):
385
+ """Event-based trigger.
386
+
387
+ Triggers in response to specific system events.
388
+
389
+ Config:
390
+ event_types: List of event types to respond to
391
+ source_filter: Optional list of source IDs to filter
392
+ """
393
+
394
+ VALID_EVENTS = {
395
+ "validation_completed",
396
+ "validation_failed",
397
+ "schema_changed",
398
+ "drift_detected",
399
+ "profile_updated",
400
+ "source_created",
401
+ "source_updated",
402
+ }
403
+
404
+ def _validate_config(self) -> None:
405
+ """Validate event configuration."""
406
+ event_types = self.config.get("event_types", [])
407
+ if not event_types:
408
+ raise ValueError("Event trigger requires 'event_types' list")
409
+
410
+ invalid_events = set(event_types) - self.VALID_EVENTS
411
+ if invalid_events:
412
+ raise ValueError(f"Invalid event types: {invalid_events}")
413
+
414
+ async def evaluate(self, context: TriggerContext) -> TriggerEvaluation:
415
+ """Evaluate if event matches configured types."""
416
+ event_types = set(self.config.get("event_types", []))
417
+ source_filter = self.config.get("source_filter")
418
+
419
+ # Check if there's event data in context
420
+ if context.event_data is None:
421
+ return TriggerEvaluation(
422
+ should_trigger=False,
423
+ reason="No event data in context",
424
+ )
425
+
426
+ event_type = context.event_data.get("type")
427
+ event_source = context.event_data.get("source_id")
428
+
429
+ # Check event type match
430
+ if event_type not in event_types:
431
+ return TriggerEvaluation(
432
+ should_trigger=False,
433
+ reason=f"Event type '{event_type}' not in configured types",
434
+ details={"event_type": event_type, "configured_types": list(event_types)},
435
+ )
436
+
437
+ # Check source filter if configured
438
+ if source_filter and event_source not in source_filter:
439
+ return TriggerEvaluation(
440
+ should_trigger=False,
441
+ reason=f"Event source '{event_source}' not in filter",
442
+ details={"event_source": event_source, "filter": source_filter},
443
+ )
444
+
445
+ return TriggerEvaluation(
446
+ should_trigger=True,
447
+ reason=f"Event '{event_type}' matched",
448
+ details={
449
+ "event_type": event_type,
450
+ "event_source": event_source,
451
+ "event_data": context.event_data,
452
+ },
453
+ )
454
+
455
+ def get_description(self) -> str:
456
+ """Get human-readable description."""
457
+ events = self.config.get("event_types", [])
458
+ return f"Events: {', '.join(events[:2])}{'...' if len(events) > 2 else ''}"
459
+
460
+
461
+ @TriggerRegistry.register("manual")
462
+ class ManualTrigger(BaseTrigger):
463
+ """Manual-only trigger.
464
+
465
+ Only triggers when explicitly invoked via API.
466
+ """
467
+
468
+ def _validate_config(self) -> None:
469
+ """No validation needed for manual trigger."""
470
+ pass
471
+
472
+ async def evaluate(self, context: TriggerContext) -> TriggerEvaluation:
473
+ """Manual triggers never auto-fire."""
474
+ # Check if manual trigger was requested via context
475
+ force_trigger = context.custom_data.get("force_trigger", False)
476
+
477
+ return TriggerEvaluation(
478
+ should_trigger=force_trigger,
479
+ reason=(
480
+ "Manually triggered via API"
481
+ if force_trigger
482
+ else "Manual trigger only (use API to execute)"
483
+ ),
484
+ details={"manual_only": True},
485
+ )
486
+
487
+ def get_description(self) -> str:
488
+ """Get human-readable description."""
489
+ return "Manual trigger only"
490
+
491
+
492
+ @TriggerRegistry.register("webhook")
493
+ class WebhookTrigger(BaseTrigger):
494
+ """Webhook-based trigger.
495
+
496
+ Triggers when receiving webhook requests from external systems.
497
+
498
+ Config:
499
+ webhook_secret: Optional secret for HMAC validation
500
+ allowed_sources: List of allowed source identifiers
501
+ payload_filters: JSON path filters for payload matching
502
+ require_signature: Whether to require signature validation
503
+ """
504
+
505
+ def _validate_config(self) -> None:
506
+ """Validate webhook configuration."""
507
+ # Webhook config is optional, but if require_signature is True,
508
+ # webhook_secret must be provided
509
+ if self.config.get("require_signature") and not self.config.get("webhook_secret"):
510
+ raise ValueError(
511
+ "webhook_secret is required when require_signature is True"
512
+ )
513
+
514
+ async def evaluate(self, context: TriggerContext) -> TriggerEvaluation:
515
+ """Evaluate if webhook trigger should fire.
516
+
517
+ Checks:
518
+ 1. Webhook data exists in context
519
+ 2. Source is in allowed_sources (if configured)
520
+ 3. Payload matches filters (if configured)
521
+ 4. Signature is valid (if required)
522
+ """
523
+ # Check for webhook data in context
524
+ webhook_data = context.custom_data.get("webhook_data")
525
+ if webhook_data is None:
526
+ return TriggerEvaluation(
527
+ should_trigger=False,
528
+ reason="No webhook data in context",
529
+ details={"waiting_for_webhook": True},
530
+ )
531
+
532
+ source = webhook_data.get("source", "")
533
+ payload = webhook_data.get("payload", {})
534
+ signature_valid = webhook_data.get("signature_valid", True)
535
+
536
+ # Check signature if required
537
+ require_signature = self.config.get("require_signature", False)
538
+ if require_signature and not signature_valid:
539
+ return TriggerEvaluation(
540
+ should_trigger=False,
541
+ reason="Invalid webhook signature",
542
+ details={"error": "invalid_signature", "source": source},
543
+ )
544
+
545
+ # Check allowed sources
546
+ allowed_sources = self.config.get("allowed_sources")
547
+ if allowed_sources and source not in allowed_sources:
548
+ return TriggerEvaluation(
549
+ should_trigger=False,
550
+ reason=f"Source '{source}' not in allowed sources",
551
+ details={
552
+ "source": source,
553
+ "allowed_sources": allowed_sources,
554
+ },
555
+ )
556
+
557
+ # Check payload filters
558
+ payload_filters = self.config.get("payload_filters", {})
559
+ if payload_filters:
560
+ filters_match = self._check_payload_filters(payload, payload_filters)
561
+ if not filters_match:
562
+ return TriggerEvaluation(
563
+ should_trigger=False,
564
+ reason="Payload does not match filters",
565
+ details={
566
+ "source": source,
567
+ "filters": payload_filters,
568
+ },
569
+ )
570
+
571
+ return TriggerEvaluation(
572
+ should_trigger=True,
573
+ reason=f"Webhook received from '{source}'",
574
+ details={
575
+ "source": source,
576
+ "event_type": webhook_data.get("event_type", "unknown"),
577
+ "payload_keys": list(payload.keys()) if payload else [],
578
+ },
579
+ )
580
+
581
+ def _check_payload_filters(
582
+ self, payload: dict[str, Any], filters: dict[str, Any]
583
+ ) -> bool:
584
+ """Check if payload matches the configured filters.
585
+
586
+ Simple key-value matching. For nested paths, use dot notation.
587
+ """
588
+ for key, expected_value in filters.items():
589
+ # Support dot notation for nested keys
590
+ parts = key.split(".")
591
+ value = payload
592
+ for part in parts:
593
+ if isinstance(value, dict):
594
+ value = value.get(part)
595
+ else:
596
+ value = None
597
+ break
598
+
599
+ if value != expected_value:
600
+ return False
601
+
602
+ return True
603
+
604
+ def get_description(self) -> str:
605
+ """Get human-readable description."""
606
+ allowed = self.config.get("allowed_sources", [])
607
+ if allowed:
608
+ return f"Webhook: {', '.join(allowed[:2])}{'...' if len(allowed) > 2 else ''}"
609
+ return "Webhook trigger"