truthound-dashboard 1.3.1__py3-none-any.whl → 1.4.1__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.1.dist-info}/METADATA +147 -23
  162. truthound_dashboard-1.4.1.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.1.dist-info}/WHEEL +0 -0
  168. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/entry_points.txt +0 -0
  169. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,774 @@
1
+ """Jinja2 template engine for routing rules.
2
+
3
+ This module provides Jinja2-based rule evaluation for flexible, template-driven
4
+ notification routing. It supports secure sandboxed execution and custom filters
5
+ for common data quality operations.
6
+
7
+ Features:
8
+ - SandboxedEnvironment for security
9
+ - Custom filters: severity_level, is_critical, format_issues
10
+ - Template-based condition evaluation
11
+ - Notification message formatting
12
+
13
+ Example:
14
+ from truthound_dashboard.core.notifications.routing.jinja2_engine import (
15
+ Jinja2Evaluator,
16
+ Jinja2Rule,
17
+ TemplateNotificationFormatter,
18
+ )
19
+
20
+ # Template-based rule
21
+ rule = Jinja2Rule(
22
+ template="{{ severity == 'critical' and issue_count > 5 }}",
23
+ expected_result="True",
24
+ )
25
+ matched = await rule.matches(context)
26
+
27
+ # Message formatting
28
+ formatter = TemplateNotificationFormatter()
29
+ message = formatter.format_message(
30
+ "Alert: {{ source_name }} has {{ issue_count }} issues",
31
+ event_dict,
32
+ )
33
+
34
+ Security:
35
+ - Uses jinja2.sandbox.SandboxedEnvironment
36
+ - Blocks filesystem and subprocess access
37
+ - Limited function calls
38
+ - Timeout protection via template complexity limits
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import signal
44
+ from contextlib import contextmanager
45
+ from dataclasses import dataclass
46
+ from typing import TYPE_CHECKING, Any, ClassVar
47
+
48
+ try:
49
+ from jinja2 import TemplateSyntaxError, UndefinedError
50
+ from jinja2.sandbox import SandboxedEnvironment
51
+
52
+ JINJA2_AVAILABLE = True
53
+ except ImportError:
54
+ JINJA2_AVAILABLE = False
55
+ SandboxedEnvironment = None # type: ignore
56
+ TemplateSyntaxError = Exception # type: ignore
57
+ UndefinedError = Exception # type: ignore
58
+
59
+ from .rules import BaseRule, RuleRegistry, Severity
60
+
61
+ if TYPE_CHECKING:
62
+ from .engine import RouteContext
63
+
64
+
65
+ class Jinja2TemplateError(Exception):
66
+ """Exception raised for Jinja2 template errors."""
67
+
68
+ pass
69
+
70
+
71
+ class Jinja2TimeoutError(Exception):
72
+ """Exception raised when template evaluation times out."""
73
+
74
+ pass
75
+
76
+
77
+ class Jinja2SecurityError(Exception):
78
+ """Exception raised for security violations in templates."""
79
+
80
+ pass
81
+
82
+
83
+ @contextmanager
84
+ def timeout_handler(seconds: int):
85
+ """Context manager for timeout protection on Unix systems.
86
+
87
+ On non-Unix systems (Windows), this is a no-op as SIGALRM is not available.
88
+
89
+ Args:
90
+ seconds: Maximum execution time in seconds.
91
+
92
+ Raises:
93
+ Jinja2TimeoutError: If execution exceeds timeout.
94
+ """
95
+
96
+ def _timeout_handler(signum: int, frame: Any) -> None:
97
+ raise Jinja2TimeoutError(f"Template evaluation timed out after {seconds} seconds")
98
+
99
+ # Check if SIGALRM is available (Unix only)
100
+ if hasattr(signal, "SIGALRM"):
101
+ old_handler = signal.signal(signal.SIGALRM, _timeout_handler)
102
+ signal.alarm(seconds)
103
+ try:
104
+ yield
105
+ finally:
106
+ signal.alarm(0)
107
+ signal.signal(signal.SIGALRM, old_handler)
108
+ else:
109
+ # On Windows, just yield without timeout protection
110
+ yield
111
+
112
+
113
+ # Custom Jinja2 filters
114
+ def severity_level(value: str) -> int:
115
+ """Convert severity string to numeric level.
116
+
117
+ Args:
118
+ value: Severity string (critical, high, medium, low, info).
119
+
120
+ Returns:
121
+ Numeric level (5=critical, 4=high, 3=medium, 2=low, 1=info, 0=unknown).
122
+
123
+ Example:
124
+ {{ severity | severity_level > 3 }} # critical or high
125
+ """
126
+ levels = {
127
+ "critical": 5,
128
+ "high": 4,
129
+ "medium": 3,
130
+ "low": 2,
131
+ "info": 1,
132
+ }
133
+ return levels.get(str(value).lower(), 0)
134
+
135
+
136
+ def is_critical(value: str) -> bool:
137
+ """Check if severity is critical.
138
+
139
+ Args:
140
+ value: Severity string.
141
+
142
+ Returns:
143
+ True if severity is critical.
144
+
145
+ Example:
146
+ {{ severity | is_critical }}
147
+ """
148
+ return str(value).lower() == "critical"
149
+
150
+
151
+ def is_high_or_critical(value: str) -> bool:
152
+ """Check if severity is high or critical.
153
+
154
+ Args:
155
+ value: Severity string.
156
+
157
+ Returns:
158
+ True if severity is high or critical.
159
+
160
+ Example:
161
+ {{ severity | is_high_or_critical }}
162
+ """
163
+ return str(value).lower() in ("critical", "high")
164
+
165
+
166
+ def format_issues(issues: list[dict[str, Any]], max_items: int = 5) -> str:
167
+ """Format a list of issues for display.
168
+
169
+ Args:
170
+ issues: List of issue dictionaries.
171
+ max_items: Maximum number of issues to include.
172
+
173
+ Returns:
174
+ Formatted string of issues.
175
+
176
+ Example:
177
+ {{ issues | format_issues(3) }}
178
+ """
179
+ if not issues:
180
+ return "No issues"
181
+
182
+ formatted = []
183
+ for issue in issues[:max_items]:
184
+ validator = issue.get("validator", "Unknown")
185
+ column = issue.get("column", "")
186
+ message = issue.get("message", issue.get("description", ""))
187
+ severity = issue.get("severity", "")
188
+
189
+ if column:
190
+ formatted.append(f"- [{severity}] {validator} ({column}): {message}")
191
+ else:
192
+ formatted.append(f"- [{severity}] {validator}: {message}")
193
+
194
+ if len(issues) > max_items:
195
+ formatted.append(f" ... and {len(issues) - max_items} more")
196
+
197
+ return "\n".join(formatted)
198
+
199
+
200
+ def format_percentage(value: float | int, decimals: int = 1) -> str:
201
+ """Format a number as a percentage.
202
+
203
+ Args:
204
+ value: Number to format (0-1 for rate, or 0-100).
205
+ decimals: Number of decimal places.
206
+
207
+ Returns:
208
+ Formatted percentage string.
209
+
210
+ Example:
211
+ {{ pass_rate | format_percentage }} # "95.5%"
212
+ """
213
+ if value is None:
214
+ return "N/A"
215
+
216
+ # Assume values <= 1 are rates (0-1), otherwise treat as percentage
217
+ if abs(value) <= 1:
218
+ value = value * 100
219
+
220
+ return f"{value:.{decimals}f}%"
221
+
222
+
223
+ def truncate_text(value: str, length: int = 100, suffix: str = "...") -> str:
224
+ """Truncate text to a maximum length.
225
+
226
+ Args:
227
+ value: Text to truncate.
228
+ length: Maximum length.
229
+ suffix: Suffix to append if truncated.
230
+
231
+ Returns:
232
+ Truncated text.
233
+
234
+ Example:
235
+ {{ message | truncate_text(50) }}
236
+ """
237
+ if not value:
238
+ return ""
239
+
240
+ text = str(value)
241
+ if len(text) <= length:
242
+ return text
243
+
244
+ return text[: length - len(suffix)] + suffix
245
+
246
+
247
+ def pluralize(count: int, singular: str, plural: str | None = None) -> str:
248
+ """Return singular or plural form based on count.
249
+
250
+ Args:
251
+ count: The count.
252
+ singular: Singular form.
253
+ plural: Plural form (default: singular + 's').
254
+
255
+ Returns:
256
+ Appropriate form for the count.
257
+
258
+ Example:
259
+ {{ issue_count }} {{ issue_count | pluralize('issue') }}
260
+ """
261
+ if plural is None:
262
+ plural = singular + "s"
263
+
264
+ return singular if count == 1 else plural
265
+
266
+
267
+ class Jinja2Evaluator:
268
+ """Jinja2 template evaluator with sandbox security.
269
+
270
+ Provides secure template evaluation using Jinja2's SandboxedEnvironment.
271
+ Includes custom filters for data quality operations.
272
+
273
+ Attributes:
274
+ env: The Jinja2 sandboxed environment.
275
+ timeout: Maximum evaluation time in seconds.
276
+
277
+ Example:
278
+ evaluator = Jinja2Evaluator(sandbox=True)
279
+ result = evaluator.evaluate(
280
+ "{{ source_name }}: {{ issue_count }} issues",
281
+ {"source_name": "users.csv", "issue_count": 5}
282
+ )
283
+ # Result: "users.csv: 5 issues"
284
+
285
+ is_match = evaluator.evaluate_condition(
286
+ "{{ severity == 'critical' and issue_count > 3 }}",
287
+ {"severity": "critical", "issue_count": 5}
288
+ )
289
+ # Result: True
290
+ """
291
+
292
+ # Default timeout for template evaluation (seconds)
293
+ DEFAULT_TIMEOUT: ClassVar[int] = 5
294
+
295
+ # Maximum template length to prevent DoS
296
+ MAX_TEMPLATE_LENGTH: ClassVar[int] = 10000
297
+
298
+ def __init__(self, sandbox: bool = True, timeout: int | None = None) -> None:
299
+ """Initialize the Jinja2 evaluator.
300
+
301
+ Args:
302
+ sandbox: If True, use SandboxedEnvironment for security.
303
+ timeout: Maximum evaluation time in seconds.
304
+
305
+ Raises:
306
+ ImportError: If jinja2 is not installed.
307
+ """
308
+ if not JINJA2_AVAILABLE:
309
+ raise ImportError(
310
+ "jinja2 is required for Jinja2 template support. "
311
+ "Install it with: pip install jinja2"
312
+ )
313
+
314
+ self.timeout = timeout or self.DEFAULT_TIMEOUT
315
+
316
+ if sandbox:
317
+ self.env = SandboxedEnvironment(
318
+ autoescape=False, # We handle escaping as needed
319
+ cache_size=100, # Cache compiled templates
320
+ )
321
+ else:
322
+ # Non-sandboxed environment - use with caution
323
+ from jinja2 import Environment
324
+
325
+ self.env = Environment(
326
+ autoescape=False,
327
+ cache_size=100,
328
+ )
329
+
330
+ # Register custom filters
331
+ self._register_filters()
332
+
333
+ def _register_filters(self) -> None:
334
+ """Register custom filters in the environment."""
335
+ self.env.filters["severity_level"] = severity_level
336
+ self.env.filters["is_critical"] = is_critical
337
+ self.env.filters["is_high_or_critical"] = is_high_or_critical
338
+ self.env.filters["format_issues"] = format_issues
339
+ self.env.filters["format_percentage"] = format_percentage
340
+ self.env.filters["truncate_text"] = truncate_text
341
+ self.env.filters["pluralize"] = pluralize
342
+
343
+ def _validate_template(self, template: str) -> None:
344
+ """Validate template for security and sanity.
345
+
346
+ Args:
347
+ template: Template string to validate.
348
+
349
+ Raises:
350
+ Jinja2SecurityError: If template contains dangerous patterns.
351
+ Jinja2TemplateError: If template is too long.
352
+ """
353
+ if len(template) > self.MAX_TEMPLATE_LENGTH:
354
+ raise Jinja2TemplateError(
355
+ f"Template exceeds maximum length of {self.MAX_TEMPLATE_LENGTH} characters"
356
+ )
357
+
358
+ # Check for potentially dangerous patterns
359
+ dangerous_patterns = [
360
+ "__class__",
361
+ "__mro__",
362
+ "__subclasses__",
363
+ "__globals__",
364
+ "__builtins__",
365
+ "__import__",
366
+ "subprocess",
367
+ "popen",
368
+ "system",
369
+ "eval(",
370
+ "exec(",
371
+ "compile(",
372
+ "open(",
373
+ "file(",
374
+ ]
375
+
376
+ template_lower = template.lower()
377
+ for pattern in dangerous_patterns:
378
+ if pattern.lower() in template_lower:
379
+ raise Jinja2SecurityError(
380
+ f"Template contains potentially dangerous pattern: {pattern}"
381
+ )
382
+
383
+ def evaluate(self, template: str, context: dict[str, Any]) -> str:
384
+ """Render a Jinja2 template with the given context.
385
+
386
+ Args:
387
+ template: Jinja2 template string.
388
+ context: Dictionary of variables available in template.
389
+
390
+ Returns:
391
+ Rendered template string.
392
+
393
+ Raises:
394
+ Jinja2TemplateError: If template is invalid.
395
+ Jinja2SecurityError: If template contains dangerous patterns.
396
+ Jinja2TimeoutError: If evaluation times out.
397
+
398
+ Example:
399
+ result = evaluator.evaluate(
400
+ "Source: {{ name }}, Issues: {{ count }}",
401
+ {"name": "users.csv", "count": 5}
402
+ )
403
+ """
404
+ self._validate_template(template)
405
+
406
+ try:
407
+ with timeout_handler(self.timeout):
408
+ compiled = self.env.from_string(template)
409
+ return compiled.render(**context)
410
+ except TemplateSyntaxError as e:
411
+ raise Jinja2TemplateError(f"Template syntax error: {e}") from e
412
+ except UndefinedError as e:
413
+ raise Jinja2TemplateError(f"Undefined variable in template: {e}") from e
414
+ except Jinja2TimeoutError:
415
+ raise
416
+ except Exception as e:
417
+ raise Jinja2TemplateError(f"Template evaluation error: {e}") from e
418
+
419
+ def evaluate_condition(self, template: str, context: dict[str, Any]) -> bool:
420
+ """Evaluate a template as a boolean condition.
421
+
422
+ The template should render to a value that can be interpreted as
423
+ boolean. Strings like "true", "True", "1", "yes" are True.
424
+ Strings like "false", "False", "0", "no", "" are False.
425
+
426
+ Args:
427
+ template: Jinja2 template that evaluates to a boolean-like value.
428
+ context: Dictionary of variables available in template.
429
+
430
+ Returns:
431
+ Boolean result of condition evaluation.
432
+
433
+ Raises:
434
+ Jinja2TemplateError: If template is invalid.
435
+
436
+ Example:
437
+ is_match = evaluator.evaluate_condition(
438
+ "{{ severity == 'critical' }}",
439
+ {"severity": "critical"}
440
+ ) # Returns True
441
+ """
442
+ result = self.evaluate(template, context)
443
+
444
+ # Normalize result to boolean
445
+ result_lower = result.strip().lower()
446
+
447
+ # Python boolean repr
448
+ if result_lower in ("true", "1", "yes", "on"):
449
+ return True
450
+ if result_lower in ("false", "0", "no", "off", "none", ""):
451
+ return False
452
+
453
+ # Non-empty string is truthy
454
+ return bool(result.strip())
455
+
456
+
457
+ @RuleRegistry.register("jinja2")
458
+ @dataclass
459
+ class Jinja2Rule(BaseRule):
460
+ """Rule that evaluates Jinja2 templates for matching.
461
+
462
+ Uses Jinja2 templates to create flexible, expression-based routing rules.
463
+ The template should evaluate to a boolean-like value.
464
+
465
+ Attributes:
466
+ template: Jinja2 template expression.
467
+ expected_result: Expected result for match (default "true").
468
+
469
+ Example templates:
470
+ - "{{ severity == 'critical' }}"
471
+ - "{{ severity | is_critical and issue_count > 5 }}"
472
+ - "{{ pass_rate < 0.9 and 'production' in tags }}"
473
+ - "{{ severity | severity_level >= 4 }}"
474
+
475
+ Available context variables:
476
+ - severity: Issue severity level
477
+ - issue_count: Number of issues
478
+ - pass_rate: Validation pass rate
479
+ - tags: List of context tags
480
+ - data_asset: Data asset name/path
481
+ - status: Validation status
482
+ - error_message: Error message if any
483
+ - metadata: Additional metadata dictionary
484
+ - event: Full event dictionary
485
+
486
+ Available filters:
487
+ - severity_level: Convert severity to numeric (5=critical to 1=info)
488
+ - is_critical: Check if severity is critical
489
+ - is_high_or_critical: Check if severity is high or critical
490
+ - format_issues: Format issue list for display
491
+ - format_percentage: Format number as percentage
492
+ - truncate_text: Truncate text with ellipsis
493
+ - pluralize: Singular/plural form based on count
494
+ """
495
+
496
+ template: str = "{{ true }}"
497
+ expected_result: str = "true"
498
+
499
+ # Class-level evaluator for reuse
500
+ _evaluator: ClassVar[Jinja2Evaluator | None] = None
501
+
502
+ @classmethod
503
+ def _get_evaluator(cls) -> Jinja2Evaluator:
504
+ """Get or create the Jinja2 evaluator."""
505
+ if cls._evaluator is None:
506
+ cls._evaluator = Jinja2Evaluator(sandbox=True)
507
+ return cls._evaluator
508
+
509
+ @classmethod
510
+ def get_param_schema(cls) -> dict[str, Any]:
511
+ """Get parameter schema for this rule type."""
512
+ return {
513
+ "template": {
514
+ "type": "string",
515
+ "required": True,
516
+ "description": "Jinja2 template expression (e.g., '{{ severity == \"critical\" }}')",
517
+ },
518
+ "expected_result": {
519
+ "type": "string",
520
+ "required": False,
521
+ "description": "Expected result for match (default: 'true')",
522
+ "default": "true",
523
+ },
524
+ }
525
+
526
+ def _build_context(self, context: "RouteContext") -> dict[str, Any]:
527
+ """Build template context from RouteContext.
528
+
529
+ Args:
530
+ context: The routing context.
531
+
532
+ Returns:
533
+ Dictionary suitable for template evaluation.
534
+ """
535
+ # Get event as dict if available
536
+ event_dict = {}
537
+ if hasattr(context.event, "to_dict"):
538
+ event_dict = context.event.to_dict()
539
+ elif hasattr(context.event, "__dict__"):
540
+ event_dict = {
541
+ k: v for k, v in context.event.__dict__.items() if not k.startswith("_")
542
+ }
543
+
544
+ return {
545
+ # Direct accessors
546
+ "severity": context.get_severity() or "",
547
+ "issue_count": context.get_issue_count() or 0,
548
+ "pass_rate": context.get_pass_rate() or 0.0,
549
+ "tags": context.get_tags(),
550
+ "data_asset": context.get_data_asset() or "",
551
+ "status": context.get_status() or "",
552
+ "error_message": context.get_error_message() or "",
553
+ # Full access
554
+ "metadata": context.metadata,
555
+ "event": event_dict,
556
+ # Timestamp
557
+ "timestamp": context.timestamp,
558
+ # Source info
559
+ "source_name": context.event.source_name if context.event else "",
560
+ "source_id": context.event.source_id if context.event else "",
561
+ # Helper values
562
+ "has_issues": (context.get_issue_count() or 0) > 0,
563
+ "is_failure": context.get_status() in ("failure", "error"),
564
+ }
565
+
566
+ async def matches(self, context: "RouteContext") -> bool:
567
+ """Check if the context matches this rule.
568
+
569
+ Args:
570
+ context: The routing context containing event and metadata.
571
+
572
+ Returns:
573
+ True if the template evaluates to the expected result.
574
+ """
575
+ try:
576
+ evaluator = self._get_evaluator()
577
+ template_context = self._build_context(context)
578
+
579
+ if self.expected_result.lower() == "true":
580
+ # Direct boolean evaluation
581
+ return evaluator.evaluate_condition(self.template, template_context)
582
+ else:
583
+ # String comparison
584
+ result = evaluator.evaluate(self.template, template_context)
585
+ return result.strip().lower() == self.expected_result.lower()
586
+
587
+ except (Jinja2TemplateError, Jinja2SecurityError, Jinja2TimeoutError):
588
+ # Template errors should not match
589
+ return False
590
+ except Exception:
591
+ # Any unexpected error should not match
592
+ return False
593
+
594
+ def to_dict(self) -> dict[str, Any]:
595
+ """Serialize rule to dictionary."""
596
+ return {
597
+ "type": self.rule_type,
598
+ "template": self.template,
599
+ "expected_result": self.expected_result,
600
+ }
601
+
602
+
603
+ class TemplateNotificationFormatter:
604
+ """Format notification messages using Jinja2 templates.
605
+
606
+ Provides a simple interface for creating dynamic notification messages
607
+ based on event data.
608
+
609
+ Example:
610
+ formatter = TemplateNotificationFormatter()
611
+
612
+ message = formatter.format_message(
613
+ "Validation failed for {{ source_name }}: "
614
+ "{{ issue_count }} {{ issue_count | pluralize('issue') }} found",
615
+ {"source_name": "users.csv", "issue_count": 5}
616
+ )
617
+ # Result: "Validation failed for users.csv: 5 issues found"
618
+
619
+ Default templates:
620
+ The formatter includes built-in templates for common notification types.
621
+ """
622
+
623
+ # Built-in notification templates
624
+ DEFAULT_TEMPLATES: ClassVar[dict[str, str]] = {
625
+ "validation_failed": (
626
+ "Validation Failed: {{ source_name }}\n"
627
+ "Severity: {{ severity }}\n"
628
+ "Issues: {{ issue_count }}\n"
629
+ "{% if pass_rate %}Pass Rate: {{ pass_rate | format_percentage }}{% endif %}\n"
630
+ "{% if issues %}{{ issues | format_issues(5) }}{% endif %}"
631
+ ),
632
+ "drift_detected": (
633
+ "Drift Detected\n"
634
+ "Baseline: {{ baseline_source_name }}\n"
635
+ "Current: {{ current_source_name }}\n"
636
+ "Drifted Columns: {{ drifted_columns }}/{{ total_columns }} "
637
+ "({{ (drifted_columns / total_columns * 100) | round(1) }}%)"
638
+ ),
639
+ "schedule_failed": (
640
+ "Scheduled Validation Failed\n"
641
+ "Schedule: {{ schedule_name }}\n"
642
+ "{% if error_message %}Error: {{ error_message | truncate_text(200) }}{% endif %}"
643
+ ),
644
+ "schema_changed": (
645
+ "Schema Changed: {{ source_name }}\n"
646
+ "Version: {{ from_version or 'N/A' }} -> {{ to_version }}\n"
647
+ "Changes: {{ total_changes }} ({{ breaking_changes }} breaking)"
648
+ ),
649
+ "generic": (
650
+ "{{ event_type | title }}\n"
651
+ "Source: {{ source_name }}\n"
652
+ "Time: {{ timestamp }}"
653
+ ),
654
+ }
655
+
656
+ def __init__(self, evaluator: Jinja2Evaluator | None = None) -> None:
657
+ """Initialize the formatter.
658
+
659
+ Args:
660
+ evaluator: Optional Jinja2Evaluator instance. If not provided,
661
+ creates a new sandboxed evaluator.
662
+ """
663
+ self._evaluator = evaluator or Jinja2Evaluator(sandbox=True)
664
+
665
+ def format_message(
666
+ self,
667
+ template: str,
668
+ event: dict[str, Any],
669
+ extra_context: dict[str, Any] | None = None,
670
+ ) -> str:
671
+ """Format a notification message using a template.
672
+
673
+ Args:
674
+ template: Jinja2 template string.
675
+ event: Event dictionary with data for template.
676
+ extra_context: Additional context variables.
677
+
678
+ Returns:
679
+ Formatted message string.
680
+
681
+ Raises:
682
+ Jinja2TemplateError: If template is invalid.
683
+
684
+ Example:
685
+ message = formatter.format_message(
686
+ "Alert: {{ source_name }} - {{ severity }}",
687
+ {"source_name": "users.csv", "severity": "critical"}
688
+ )
689
+ """
690
+ context = {**event}
691
+ if extra_context:
692
+ context.update(extra_context)
693
+
694
+ return self._evaluator.evaluate(template, context)
695
+
696
+ def format_with_default(
697
+ self,
698
+ event_type: str,
699
+ event: dict[str, Any],
700
+ custom_template: str | None = None,
701
+ ) -> str:
702
+ """Format a message using default or custom template.
703
+
704
+ Args:
705
+ event_type: Type of event (validation_failed, drift_detected, etc.).
706
+ event: Event dictionary.
707
+ custom_template: Optional custom template to use instead of default.
708
+
709
+ Returns:
710
+ Formatted message string.
711
+
712
+ Example:
713
+ message = formatter.format_with_default(
714
+ "validation_failed",
715
+ event_dict
716
+ )
717
+ """
718
+ template = custom_template or self.DEFAULT_TEMPLATES.get(
719
+ event_type, self.DEFAULT_TEMPLATES["generic"]
720
+ )
721
+
722
+ # Add event_type to context if not present
723
+ if "event_type" not in event:
724
+ event = {**event, "event_type": event_type}
725
+
726
+ return self.format_message(template, event)
727
+
728
+ def validate_template(self, template: str) -> tuple[bool, str | None]:
729
+ """Validate a template without rendering.
730
+
731
+ Args:
732
+ template: Template string to validate.
733
+
734
+ Returns:
735
+ Tuple of (is_valid, error_message).
736
+
737
+ Example:
738
+ is_valid, error = formatter.validate_template("{{ invalid }")
739
+ if not is_valid:
740
+ print(f"Template error: {error}")
741
+ """
742
+ try:
743
+ self._evaluator._validate_template(template)
744
+ # Try to compile the template
745
+ self._evaluator.env.from_string(template)
746
+ return True, None
747
+ except Jinja2SecurityError as e:
748
+ return False, f"Security error: {e}"
749
+ except Jinja2TemplateError as e:
750
+ return False, str(e)
751
+ except TemplateSyntaxError as e:
752
+ return False, f"Syntax error: {e}"
753
+ except Exception as e:
754
+ return False, f"Validation error: {e}"
755
+
756
+
757
+ # Export all public classes and exceptions
758
+ __all__ = [
759
+ "Jinja2Evaluator",
760
+ "Jinja2Rule",
761
+ "TemplateNotificationFormatter",
762
+ "Jinja2TemplateError",
763
+ "Jinja2TimeoutError",
764
+ "Jinja2SecurityError",
765
+ "JINJA2_AVAILABLE",
766
+ # Custom filters (for external use)
767
+ "severity_level",
768
+ "is_critical",
769
+ "is_high_or_critical",
770
+ "format_issues",
771
+ "format_percentage",
772
+ "truncate_text",
773
+ "pluralize",
774
+ ]