truthound-dashboard 1.3.1__py3-none-any.whl → 1.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. truthound_dashboard/api/alerts.py +258 -0
  2. truthound_dashboard/api/anomaly.py +1302 -0
  3. truthound_dashboard/api/cross_alerts.py +352 -0
  4. truthound_dashboard/api/deps.py +143 -0
  5. truthound_dashboard/api/drift_monitor.py +540 -0
  6. truthound_dashboard/api/lineage.py +1151 -0
  7. truthound_dashboard/api/maintenance.py +363 -0
  8. truthound_dashboard/api/middleware.py +373 -1
  9. truthound_dashboard/api/model_monitoring.py +805 -0
  10. truthound_dashboard/api/notifications_advanced.py +2452 -0
  11. truthound_dashboard/api/plugins.py +2096 -0
  12. truthound_dashboard/api/profile.py +211 -14
  13. truthound_dashboard/api/reports.py +853 -0
  14. truthound_dashboard/api/router.py +147 -0
  15. truthound_dashboard/api/rule_suggestions.py +310 -0
  16. truthound_dashboard/api/schema_evolution.py +231 -0
  17. truthound_dashboard/api/sources.py +47 -3
  18. truthound_dashboard/api/triggers.py +190 -0
  19. truthound_dashboard/api/validations.py +13 -0
  20. truthound_dashboard/api/validators.py +333 -4
  21. truthound_dashboard/api/versioning.py +309 -0
  22. truthound_dashboard/api/websocket.py +301 -0
  23. truthound_dashboard/core/__init__.py +27 -0
  24. truthound_dashboard/core/anomaly.py +1395 -0
  25. truthound_dashboard/core/anomaly_explainer.py +633 -0
  26. truthound_dashboard/core/cache.py +206 -0
  27. truthound_dashboard/core/cached_services.py +422 -0
  28. truthound_dashboard/core/charts.py +352 -0
  29. truthound_dashboard/core/connections.py +1069 -42
  30. truthound_dashboard/core/cross_alerts.py +837 -0
  31. truthound_dashboard/core/drift_monitor.py +1477 -0
  32. truthound_dashboard/core/drift_sampling.py +669 -0
  33. truthound_dashboard/core/i18n/__init__.py +42 -0
  34. truthound_dashboard/core/i18n/detector.py +173 -0
  35. truthound_dashboard/core/i18n/messages.py +564 -0
  36. truthound_dashboard/core/lineage.py +971 -0
  37. truthound_dashboard/core/maintenance.py +443 -5
  38. truthound_dashboard/core/model_monitoring.py +1043 -0
  39. truthound_dashboard/core/notifications/channels.py +1020 -1
  40. truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
  41. truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
  42. truthound_dashboard/core/notifications/deduplication/service.py +400 -0
  43. truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
  44. truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
  45. truthound_dashboard/core/notifications/dispatcher.py +43 -0
  46. truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
  47. truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
  48. truthound_dashboard/core/notifications/escalation/engine.py +429 -0
  49. truthound_dashboard/core/notifications/escalation/models.py +336 -0
  50. truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
  51. truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
  52. truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
  53. truthound_dashboard/core/notifications/events.py +49 -0
  54. truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
  55. truthound_dashboard/core/notifications/metrics/base.py +528 -0
  56. truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
  57. truthound_dashboard/core/notifications/routing/__init__.py +169 -0
  58. truthound_dashboard/core/notifications/routing/combinators.py +184 -0
  59. truthound_dashboard/core/notifications/routing/config.py +375 -0
  60. truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
  61. truthound_dashboard/core/notifications/routing/engine.py +382 -0
  62. truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
  63. truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
  64. truthound_dashboard/core/notifications/routing/rules.py +625 -0
  65. truthound_dashboard/core/notifications/routing/validator.py +678 -0
  66. truthound_dashboard/core/notifications/service.py +2 -0
  67. truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
  68. truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
  69. truthound_dashboard/core/notifications/throttling/builder.py +311 -0
  70. truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
  71. truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
  72. truthound_dashboard/core/openlineage.py +1028 -0
  73. truthound_dashboard/core/plugins/__init__.py +39 -0
  74. truthound_dashboard/core/plugins/docs/__init__.py +39 -0
  75. truthound_dashboard/core/plugins/docs/extractor.py +703 -0
  76. truthound_dashboard/core/plugins/docs/renderers.py +804 -0
  77. truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
  78. truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
  79. truthound_dashboard/core/plugins/hooks/manager.py +403 -0
  80. truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
  81. truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
  82. truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
  83. truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
  84. truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
  85. truthound_dashboard/core/plugins/loader.py +504 -0
  86. truthound_dashboard/core/plugins/registry.py +810 -0
  87. truthound_dashboard/core/plugins/reporter_executor.py +588 -0
  88. truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
  89. truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
  90. truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
  91. truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
  92. truthound_dashboard/core/plugins/sandbox.py +617 -0
  93. truthound_dashboard/core/plugins/security/__init__.py +68 -0
  94. truthound_dashboard/core/plugins/security/analyzer.py +535 -0
  95. truthound_dashboard/core/plugins/security/policies.py +311 -0
  96. truthound_dashboard/core/plugins/security/protocols.py +296 -0
  97. truthound_dashboard/core/plugins/security/signing.py +842 -0
  98. truthound_dashboard/core/plugins/security.py +446 -0
  99. truthound_dashboard/core/plugins/validator_executor.py +401 -0
  100. truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
  101. truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
  102. truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
  103. truthound_dashboard/core/plugins/versioning/semver.py +266 -0
  104. truthound_dashboard/core/profile_comparison.py +601 -0
  105. truthound_dashboard/core/report_history.py +570 -0
  106. truthound_dashboard/core/reporters/__init__.py +57 -0
  107. truthound_dashboard/core/reporters/base.py +296 -0
  108. truthound_dashboard/core/reporters/csv_reporter.py +155 -0
  109. truthound_dashboard/core/reporters/html_reporter.py +598 -0
  110. truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
  111. truthound_dashboard/core/reporters/i18n/base.py +494 -0
  112. truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
  113. truthound_dashboard/core/reporters/json_reporter.py +160 -0
  114. truthound_dashboard/core/reporters/junit_reporter.py +233 -0
  115. truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
  116. truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
  117. truthound_dashboard/core/reporters/registry.py +272 -0
  118. truthound_dashboard/core/rule_generator.py +2088 -0
  119. truthound_dashboard/core/scheduler.py +822 -12
  120. truthound_dashboard/core/schema_evolution.py +858 -0
  121. truthound_dashboard/core/services.py +152 -9
  122. truthound_dashboard/core/statistics.py +718 -0
  123. truthound_dashboard/core/streaming_anomaly.py +883 -0
  124. truthound_dashboard/core/triggers/__init__.py +45 -0
  125. truthound_dashboard/core/triggers/base.py +226 -0
  126. truthound_dashboard/core/triggers/evaluators.py +609 -0
  127. truthound_dashboard/core/triggers/factory.py +363 -0
  128. truthound_dashboard/core/unified_alerts.py +870 -0
  129. truthound_dashboard/core/validation_limits.py +509 -0
  130. truthound_dashboard/core/versioning.py +709 -0
  131. truthound_dashboard/core/websocket/__init__.py +59 -0
  132. truthound_dashboard/core/websocket/manager.py +512 -0
  133. truthound_dashboard/core/websocket/messages.py +130 -0
  134. truthound_dashboard/db/__init__.py +30 -0
  135. truthound_dashboard/db/models.py +3375 -3
  136. truthound_dashboard/main.py +22 -0
  137. truthound_dashboard/schemas/__init__.py +396 -1
  138. truthound_dashboard/schemas/anomaly.py +1258 -0
  139. truthound_dashboard/schemas/base.py +4 -0
  140. truthound_dashboard/schemas/cross_alerts.py +334 -0
  141. truthound_dashboard/schemas/drift_monitor.py +890 -0
  142. truthound_dashboard/schemas/lineage.py +428 -0
  143. truthound_dashboard/schemas/maintenance.py +154 -0
  144. truthound_dashboard/schemas/model_monitoring.py +374 -0
  145. truthound_dashboard/schemas/notifications_advanced.py +1363 -0
  146. truthound_dashboard/schemas/openlineage.py +704 -0
  147. truthound_dashboard/schemas/plugins.py +1293 -0
  148. truthound_dashboard/schemas/profile.py +420 -34
  149. truthound_dashboard/schemas/profile_comparison.py +242 -0
  150. truthound_dashboard/schemas/reports.py +285 -0
  151. truthound_dashboard/schemas/rule_suggestion.py +434 -0
  152. truthound_dashboard/schemas/schema_evolution.py +164 -0
  153. truthound_dashboard/schemas/source.py +117 -2
  154. truthound_dashboard/schemas/triggers.py +511 -0
  155. truthound_dashboard/schemas/unified_alerts.py +223 -0
  156. truthound_dashboard/schemas/validation.py +25 -1
  157. truthound_dashboard/schemas/validators/__init__.py +11 -0
  158. truthound_dashboard/schemas/validators/base.py +151 -0
  159. truthound_dashboard/schemas/versioning.py +152 -0
  160. truthound_dashboard/static/index.html +2 -2
  161. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/METADATA +142 -22
  162. truthound_dashboard-1.4.0.dist-info/RECORD +239 -0
  163. truthound_dashboard/static/assets/index-BZG20KuF.js +0 -586
  164. truthound_dashboard/static/assets/index-D_HyZ3pb.css +0 -1
  165. truthound_dashboard/static/assets/unmerged_dictionaries-CtpqQBm0.js +0 -1
  166. truthound_dashboard-1.3.1.dist-info/RECORD +0 -110
  167. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/WHEEL +0 -0
  168. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/entry_points.txt +0 -0
  169. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1269 @@
1
+ """Expression engine for flexible routing rules.
2
+
3
+ This module provides a safe, AST-based expression evaluator for creating
4
+ dynamic routing rules using Python-like expressions.
5
+
6
+ Features:
7
+ - Safe evaluation using AST parsing (no exec/eval)
8
+ - Support for standard comparison and logical operators
9
+ - Attribute access for context fields
10
+ - Basic built-in functions (len, any, all, sum, min, max, abs)
11
+ - Timeout protection against infinite loops
12
+ - Whitelist-based security model
13
+
14
+ Example:
15
+ # Create context from validation result
16
+ context = ExpressionContext(
17
+ checkpoint_name="orders_validation",
18
+ action_type="check",
19
+ severity="critical",
20
+ issues=["null_values", "schema_mismatch"],
21
+ pass_rate=0.75,
22
+ timestamp=datetime.now(),
23
+ metadata={"environment": "production"},
24
+ )
25
+
26
+ # Evaluate expressions
27
+ evaluator = SafeExpressionEvaluator()
28
+ evaluator.evaluate("severity == 'critical'", context) # True
29
+ evaluator.evaluate("pass_rate < 0.8 and len(issues) > 0", context) # True
30
+ evaluator.evaluate("'production' in metadata.values()", context) # True
31
+
32
+ Security:
33
+ The evaluator uses a strict whitelist approach:
34
+ - Only allowed AST node types are processed
35
+ - No access to __builtins__, __import__, or dunder attributes
36
+ - Timeout protection against resource exhaustion
37
+ - No code execution (exec/eval) - only expression evaluation
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import ast
43
+ import operator
44
+ import signal
45
+ import threading
46
+ from dataclasses import dataclass, field
47
+ from datetime import datetime
48
+ from typing import TYPE_CHECKING, Any, Callable
49
+
50
+ from .rules import BaseRule, RuleRegistry
51
+
52
+ if TYPE_CHECKING:
53
+ from .engine import RouteContext
54
+
55
+
56
+ class ExpressionError(Exception):
57
+ """Raised when expression evaluation fails.
58
+
59
+ Attributes:
60
+ expression: The expression that failed.
61
+ reason: Description of why the evaluation failed.
62
+ """
63
+
64
+ def __init__(self, expression: str, reason: str) -> None:
65
+ self.expression = expression
66
+ self.reason = reason
67
+ super().__init__(f"Expression error: {reason} in '{expression}'")
68
+
69
+
70
+ class ExpressionTimeout(ExpressionError):
71
+ """Raised when expression evaluation times out."""
72
+
73
+ def __init__(self, expression: str, timeout_seconds: float) -> None:
74
+ super().__init__(
75
+ expression,
76
+ f"Evaluation timed out after {timeout_seconds}s",
77
+ )
78
+
79
+
80
+ class ExpressionSecurityError(ExpressionError):
81
+ """Raised when expression contains unsafe operations."""
82
+
83
+ def __init__(self, expression: str, unsafe_element: str) -> None:
84
+ super().__init__(
85
+ expression,
86
+ f"Unsafe element detected: {unsafe_element}",
87
+ )
88
+
89
+
90
+ @dataclass
91
+ class ExpressionContext:
92
+ """Context for expression evaluation.
93
+
94
+ This dataclass holds all the fields that can be accessed within
95
+ routing expressions. It provides a structured way to pass validation
96
+ results and metadata to the expression evaluator.
97
+
98
+ Attributes:
99
+ checkpoint_name: Name of the validation checkpoint.
100
+ action_type: Type of action (check, learn, profile, compare, scan, mask).
101
+ severity: Highest severity level (critical, high, medium, low, info).
102
+ issues: List of issue identifiers or descriptions.
103
+ pass_rate: Validation pass rate (0.0 to 1.0).
104
+ timestamp: When the validation occurred.
105
+ metadata: Custom fields for additional context.
106
+
107
+ Example:
108
+ context = ExpressionContext(
109
+ checkpoint_name="orders_validation",
110
+ action_type="check",
111
+ severity="critical",
112
+ issues=["null_values", "type_mismatch"],
113
+ pass_rate=0.85,
114
+ timestamp=datetime.now(),
115
+ metadata={
116
+ "environment": "production",
117
+ "table": "orders",
118
+ "row_count": 50000,
119
+ },
120
+ )
121
+
122
+ # Access in expressions:
123
+ # - context.severity == "critical"
124
+ # - context.pass_rate < 0.9
125
+ # - "null_values" in context.issues
126
+ # - context.metadata.get("environment") == "production"
127
+ """
128
+
129
+ checkpoint_name: str = ""
130
+ action_type: str = ""
131
+ severity: str = "info"
132
+ issues: list[str] = field(default_factory=list)
133
+ pass_rate: float = 1.0
134
+ timestamp: datetime = field(default_factory=datetime.utcnow)
135
+ metadata: dict[str, Any] = field(default_factory=dict)
136
+
137
+ def to_dict(self) -> dict[str, Any]:
138
+ """Convert context to dictionary.
139
+
140
+ Returns:
141
+ Dictionary containing all context fields.
142
+
143
+ Example:
144
+ context.to_dict()
145
+ # {
146
+ # "checkpoint_name": "orders_validation",
147
+ # "action_type": "check",
148
+ # "severity": "critical",
149
+ # ...
150
+ # }
151
+ """
152
+ return {
153
+ "checkpoint_name": self.checkpoint_name,
154
+ "action_type": self.action_type,
155
+ "severity": self.severity,
156
+ "issues": list(self.issues),
157
+ "pass_rate": self.pass_rate,
158
+ "timestamp": self.timestamp.isoformat() if self.timestamp else None,
159
+ "metadata": dict(self.metadata),
160
+ }
161
+
162
+ @classmethod
163
+ def from_validation_result(
164
+ cls,
165
+ result: dict[str, Any],
166
+ checkpoint_name: str = "",
167
+ action_type: str = "check",
168
+ ) -> "ExpressionContext":
169
+ """Create context from a validation result dictionary.
170
+
171
+ Args:
172
+ result: Validation result containing summary, issues, etc.
173
+ checkpoint_name: Name of the checkpoint (optional).
174
+ action_type: Type of action performed (default: "check").
175
+
176
+ Returns:
177
+ ExpressionContext populated from the validation result.
178
+
179
+ Example:
180
+ result = {
181
+ "summary": {
182
+ "total_issues": 5,
183
+ "passed": 45,
184
+ "failed": 5,
185
+ "pass_rate": 0.9,
186
+ "has_critical": True,
187
+ },
188
+ "issues": [
189
+ {"validator": "null_check", "severity": "critical"},
190
+ {"validator": "range_check", "severity": "high"},
191
+ ],
192
+ }
193
+ context = ExpressionContext.from_validation_result(result)
194
+ """
195
+ summary = result.get("summary", {})
196
+ issues = result.get("issues", [])
197
+
198
+ # Extract severity from summary or issues
199
+ severity = "info"
200
+ if summary.get("has_critical"):
201
+ severity = "critical"
202
+ elif summary.get("has_high"):
203
+ severity = "high"
204
+ elif summary.get("has_medium"):
205
+ severity = "medium"
206
+ elif summary.get("has_low"):
207
+ severity = "low"
208
+
209
+ # Extract issue identifiers
210
+ issue_list = []
211
+ for issue in issues:
212
+ if isinstance(issue, dict):
213
+ validator = issue.get("validator", "")
214
+ if validator:
215
+ issue_list.append(validator)
216
+ message = issue.get("message", "")
217
+ if message and message not in issue_list:
218
+ issue_list.append(message)
219
+ elif isinstance(issue, str):
220
+ issue_list.append(issue)
221
+
222
+ # Calculate pass rate
223
+ pass_rate = summary.get("pass_rate", 1.0)
224
+ if pass_rate is None:
225
+ passed = summary.get("passed", 0)
226
+ total = passed + summary.get("failed", 0)
227
+ pass_rate = passed / total if total > 0 else 1.0
228
+
229
+ # Build metadata from remaining fields
230
+ metadata: dict[str, Any] = {}
231
+ for key, value in result.items():
232
+ if key not in ("summary", "issues"):
233
+ metadata[key] = value
234
+
235
+ # Add summary fields to metadata for additional access
236
+ metadata["total_issues"] = summary.get("total_issues", len(issues))
237
+ metadata["passed"] = summary.get("passed", 0)
238
+ metadata["failed"] = summary.get("failed", 0)
239
+
240
+ return cls(
241
+ checkpoint_name=checkpoint_name,
242
+ action_type=action_type,
243
+ severity=severity,
244
+ issues=issue_list,
245
+ pass_rate=pass_rate,
246
+ timestamp=datetime.utcnow(),
247
+ metadata=metadata,
248
+ )
249
+
250
+
251
+ class SafeExpressionEvaluator:
252
+ """Safe expression evaluator using AST-based parsing.
253
+
254
+ This evaluator provides a secure way to evaluate Python-like expressions
255
+ without using exec() or eval(). It uses Python's AST module to parse
256
+ expressions and then walks the AST tree to evaluate nodes.
257
+
258
+ Security Features:
259
+ - Whitelist of allowed AST node types
260
+ - Blocked access to dunder attributes (__builtins__, etc.)
261
+ - Timeout protection against infinite loops
262
+ - No code execution - only expression evaluation
263
+ - Limited built-in functions (len, any, all, sum, min, max, abs)
264
+
265
+ Supported Operations:
266
+ - Comparisons: ==, !=, <, >, <=, >=
267
+ - Logical: and, or, not
268
+ - Membership: in, not in
269
+ - Arithmetic: +, -, *, /, //, %, **
270
+ - Attribute access: context.severity, context.metadata.get("key")
271
+ - Subscript access: context.issues[0], context.metadata["key"]
272
+ - Function calls: len(context.issues), any(x > 0 for x in items)
273
+ - List comprehensions: [x for x in items if x > 0]
274
+
275
+ Example:
276
+ evaluator = SafeExpressionEvaluator(timeout_seconds=1.0)
277
+
278
+ context = ExpressionContext(
279
+ severity="critical",
280
+ issues=["null_values", "duplicates"],
281
+ pass_rate=0.75,
282
+ )
283
+
284
+ # Simple comparisons
285
+ evaluator.evaluate("severity == 'critical'", context) # True
286
+ evaluator.evaluate("pass_rate < 0.8", context) # True
287
+
288
+ # Logical operators
289
+ evaluator.evaluate("severity == 'critical' and pass_rate < 0.9", context)
290
+
291
+ # Built-in functions
292
+ evaluator.evaluate("len(issues) > 1", context) # True
293
+ evaluator.evaluate("any(i.startswith('null') for i in issues)", context)
294
+
295
+ # Membership
296
+ evaluator.evaluate("'null_values' in issues", context) # True
297
+
298
+ Attributes:
299
+ timeout_seconds: Maximum evaluation time (default: 1.0).
300
+ max_iterations: Maximum loop iterations (default: 10000).
301
+ """
302
+
303
+ # Allowed AST node types for expression evaluation
304
+ ALLOWED_NODES: set[type[ast.AST]] = {
305
+ # Literals
306
+ ast.Constant,
307
+ ast.Num, # Python 3.7 compatibility
308
+ ast.Str, # Python 3.7 compatibility
309
+ ast.List,
310
+ ast.Tuple,
311
+ ast.Set,
312
+ ast.Dict,
313
+ # Variables and attributes
314
+ ast.Name,
315
+ ast.Attribute,
316
+ ast.Subscript,
317
+ ast.Index, # Python 3.8 compatibility
318
+ ast.Slice,
319
+ # Operators
320
+ ast.BinOp,
321
+ ast.UnaryOp,
322
+ ast.BoolOp,
323
+ ast.Compare,
324
+ # Comprehensions
325
+ ast.ListComp,
326
+ ast.SetComp,
327
+ ast.DictComp,
328
+ ast.GeneratorExp,
329
+ ast.comprehension,
330
+ # Function calls
331
+ ast.Call,
332
+ # Context
333
+ ast.Load,
334
+ ast.Store,
335
+ # Conditionals
336
+ ast.IfExp,
337
+ }
338
+
339
+ # Binary operators
340
+ BINARY_OPS: dict[type[ast.operator], Callable[[Any, Any], Any]] = {
341
+ ast.Add: operator.add,
342
+ ast.Sub: operator.sub,
343
+ ast.Mult: operator.mul,
344
+ ast.Div: operator.truediv,
345
+ ast.FloorDiv: operator.floordiv,
346
+ ast.Mod: operator.mod,
347
+ ast.Pow: operator.pow,
348
+ ast.LShift: operator.lshift,
349
+ ast.RShift: operator.rshift,
350
+ ast.BitOr: operator.or_,
351
+ ast.BitXor: operator.xor,
352
+ ast.BitAnd: operator.and_,
353
+ }
354
+
355
+ # Unary operators
356
+ UNARY_OPS: dict[type[ast.unaryop], Callable[[Any], Any]] = {
357
+ ast.UAdd: operator.pos,
358
+ ast.USub: operator.neg,
359
+ ast.Not: operator.not_,
360
+ ast.Invert: operator.invert,
361
+ }
362
+
363
+ # Comparison operators
364
+ COMPARE_OPS: dict[type[ast.cmpop], Callable[[Any, Any], bool]] = {
365
+ ast.Eq: operator.eq,
366
+ ast.NotEq: operator.ne,
367
+ ast.Lt: operator.lt,
368
+ ast.LtE: operator.le,
369
+ ast.Gt: operator.gt,
370
+ ast.GtE: operator.ge,
371
+ ast.Is: operator.is_,
372
+ ast.IsNot: operator.is_not,
373
+ ast.In: lambda x, y: x in y,
374
+ ast.NotIn: lambda x, y: x not in y,
375
+ }
376
+
377
+ # Allowed built-in functions
378
+ ALLOWED_FUNCTIONS: dict[str, Callable[..., Any]] = {
379
+ "len": len,
380
+ "any": any,
381
+ "all": all,
382
+ "sum": sum,
383
+ "min": min,
384
+ "max": max,
385
+ "abs": abs,
386
+ "round": round,
387
+ "bool": bool,
388
+ "int": int,
389
+ "float": float,
390
+ "str": str,
391
+ "list": list,
392
+ "tuple": tuple,
393
+ "set": set,
394
+ "dict": dict,
395
+ "sorted": sorted,
396
+ "reversed": lambda x: list(reversed(list(x))),
397
+ "enumerate": enumerate,
398
+ "zip": zip,
399
+ "range": range,
400
+ "filter": filter,
401
+ "map": map,
402
+ "isinstance": isinstance,
403
+ "hasattr": hasattr,
404
+ "getattr": getattr,
405
+ }
406
+
407
+ # Blocked attribute names (security)
408
+ BLOCKED_ATTRIBUTES: set[str] = {
409
+ "__builtins__",
410
+ "__import__",
411
+ "__class__",
412
+ "__bases__",
413
+ "__mro__",
414
+ "__subclasses__",
415
+ "__code__",
416
+ "__globals__",
417
+ "__locals__",
418
+ "__dict__",
419
+ "__module__",
420
+ "__name__",
421
+ "__qualname__",
422
+ "__annotations__",
423
+ "__func__",
424
+ "__self__",
425
+ "__call__",
426
+ "__getattribute__",
427
+ "__setattr__",
428
+ "__delattr__",
429
+ "__init__",
430
+ "__new__",
431
+ "__del__",
432
+ "__reduce__",
433
+ "__reduce_ex__",
434
+ "__getstate__",
435
+ "__setstate__",
436
+ }
437
+
438
+ def __init__(
439
+ self,
440
+ timeout_seconds: float = 1.0,
441
+ max_iterations: int = 10000,
442
+ ) -> None:
443
+ """Initialize the evaluator.
444
+
445
+ Args:
446
+ timeout_seconds: Maximum time allowed for evaluation.
447
+ max_iterations: Maximum iterations in comprehensions/loops.
448
+ """
449
+ self.timeout_seconds = timeout_seconds
450
+ self.max_iterations = max_iterations
451
+ self._iteration_count = 0
452
+ self._timed_out = False
453
+
454
+ def evaluate(
455
+ self,
456
+ expression: str,
457
+ context: ExpressionContext,
458
+ ) -> bool:
459
+ """Evaluate an expression against the given context.
460
+
461
+ Args:
462
+ expression: Python-like expression to evaluate.
463
+ context: Context containing values for the expression.
464
+
465
+ Returns:
466
+ Boolean result of the expression evaluation.
467
+
468
+ Raises:
469
+ ExpressionError: If expression is invalid or evaluation fails.
470
+ ExpressionTimeout: If evaluation exceeds timeout.
471
+ ExpressionSecurityError: If expression contains unsafe operations.
472
+
473
+ Example:
474
+ result = evaluator.evaluate(
475
+ "severity == 'critical' and pass_rate < 0.9",
476
+ context,
477
+ )
478
+ """
479
+ if not expression or not expression.strip():
480
+ raise ExpressionError(expression, "Empty expression")
481
+
482
+ # Reset iteration counter
483
+ self._iteration_count = 0
484
+ self._timed_out = False
485
+
486
+ try:
487
+ # Parse the expression
488
+ tree = ast.parse(expression, mode="eval")
489
+ except SyntaxError as e:
490
+ raise ExpressionError(expression, f"Syntax error: {e}") from e
491
+
492
+ # Validate AST nodes
493
+ self._validate_ast(tree, expression)
494
+
495
+ # Build evaluation namespace
496
+ namespace = self._build_namespace(context)
497
+
498
+ # Evaluate with timeout
499
+ result = self._evaluate_with_timeout(tree.body, namespace, expression)
500
+
501
+ # Convert to boolean
502
+ return bool(result)
503
+
504
+ def _validate_ast(self, tree: ast.AST, expression: str) -> None:
505
+ """Validate that all AST nodes are allowed.
506
+
507
+ Args:
508
+ tree: AST tree to validate.
509
+ expression: Original expression (for error messages).
510
+
511
+ Raises:
512
+ ExpressionSecurityError: If disallowed nodes are found.
513
+ """
514
+ for node in ast.walk(tree):
515
+ # Check node type
516
+ if type(node) not in self.ALLOWED_NODES and not isinstance(
517
+ node, ast.Expression
518
+ ):
519
+ raise ExpressionSecurityError(
520
+ expression,
521
+ f"Disallowed node type: {type(node).__name__}",
522
+ )
523
+
524
+ # Check for blocked attribute access
525
+ if isinstance(node, ast.Attribute):
526
+ if node.attr in self.BLOCKED_ATTRIBUTES:
527
+ raise ExpressionSecurityError(
528
+ expression,
529
+ f"Access to '{node.attr}' is not allowed",
530
+ )
531
+
532
+ # Check for blocked function names
533
+ if isinstance(node, ast.Name):
534
+ if node.id.startswith("__") and node.id.endswith("__"):
535
+ raise ExpressionSecurityError(
536
+ expression,
537
+ f"Access to '{node.id}' is not allowed",
538
+ )
539
+
540
+ def _build_namespace(self, context: ExpressionContext) -> dict[str, Any]:
541
+ """Build the namespace for expression evaluation.
542
+
543
+ Args:
544
+ context: Expression context.
545
+
546
+ Returns:
547
+ Dictionary with all available names.
548
+ """
549
+ # Start with allowed functions
550
+ namespace = dict(self.ALLOWED_FUNCTIONS)
551
+
552
+ # Add constants
553
+ namespace["True"] = True
554
+ namespace["False"] = False
555
+ namespace["None"] = None
556
+
557
+ # Add context as a named variable
558
+ namespace["context"] = context
559
+
560
+ # Also expose context fields directly for convenience
561
+ namespace["checkpoint_name"] = context.checkpoint_name
562
+ namespace["action_type"] = context.action_type
563
+ namespace["severity"] = context.severity
564
+ namespace["issues"] = context.issues
565
+ namespace["pass_rate"] = context.pass_rate
566
+ namespace["timestamp"] = context.timestamp
567
+ namespace["metadata"] = context.metadata
568
+
569
+ return namespace
570
+
571
+ def _evaluate_with_timeout(
572
+ self,
573
+ node: ast.AST,
574
+ namespace: dict[str, Any],
575
+ expression: str,
576
+ ) -> Any:
577
+ """Evaluate AST node with timeout protection.
578
+
579
+ Args:
580
+ node: AST node to evaluate.
581
+ namespace: Evaluation namespace.
582
+ expression: Original expression (for error messages).
583
+
584
+ Returns:
585
+ Evaluation result.
586
+
587
+ Raises:
588
+ ExpressionTimeout: If evaluation times out.
589
+ """
590
+ result: Any = None
591
+ error: Exception | None = None
592
+
593
+ def evaluate():
594
+ nonlocal result, error
595
+ try:
596
+ result = self._eval_node(node, namespace, expression)
597
+ except Exception as e:
598
+ error = e
599
+
600
+ # Use threading for timeout on all platforms
601
+ thread = threading.Thread(target=evaluate)
602
+ thread.start()
603
+ thread.join(timeout=self.timeout_seconds)
604
+
605
+ if thread.is_alive():
606
+ self._timed_out = True
607
+ raise ExpressionTimeout(expression, self.timeout_seconds)
608
+
609
+ if error is not None:
610
+ raise error
611
+
612
+ return result
613
+
614
+ def _check_iteration_limit(self, expression: str) -> None:
615
+ """Check if iteration limit has been exceeded.
616
+
617
+ Args:
618
+ expression: Original expression (for error messages).
619
+
620
+ Raises:
621
+ ExpressionError: If iteration limit exceeded.
622
+ """
623
+ self._iteration_count += 1
624
+ if self._iteration_count > self.max_iterations:
625
+ raise ExpressionError(
626
+ expression,
627
+ f"Iteration limit exceeded ({self.max_iterations})",
628
+ )
629
+
630
+ def _eval_node(
631
+ self,
632
+ node: ast.AST,
633
+ namespace: dict[str, Any],
634
+ expression: str,
635
+ ) -> Any:
636
+ """Evaluate a single AST node.
637
+
638
+ Args:
639
+ node: AST node to evaluate.
640
+ namespace: Evaluation namespace.
641
+ expression: Original expression (for error messages).
642
+
643
+ Returns:
644
+ Evaluation result.
645
+
646
+ Raises:
647
+ ExpressionError: If evaluation fails.
648
+ """
649
+ self._check_iteration_limit(expression)
650
+
651
+ # Constant values
652
+ if isinstance(node, ast.Constant):
653
+ return node.value
654
+
655
+ # Legacy numeric/string literals (Python 3.7)
656
+ if isinstance(node, ast.Num):
657
+ return node.n
658
+ if isinstance(node, ast.Str):
659
+ return node.s
660
+
661
+ # Variable lookup
662
+ if isinstance(node, ast.Name):
663
+ if node.id not in namespace:
664
+ raise ExpressionError(expression, f"Unknown name: {node.id}")
665
+ return namespace[node.id]
666
+
667
+ # Attribute access
668
+ if isinstance(node, ast.Attribute):
669
+ obj = self._eval_node(node.value, namespace, expression)
670
+ if node.attr in self.BLOCKED_ATTRIBUTES:
671
+ raise ExpressionSecurityError(
672
+ expression,
673
+ f"Access to '{node.attr}' is not allowed",
674
+ )
675
+ try:
676
+ return getattr(obj, node.attr)
677
+ except AttributeError:
678
+ raise ExpressionError(
679
+ expression,
680
+ f"'{type(obj).__name__}' has no attribute '{node.attr}'",
681
+ ) from None
682
+
683
+ # Subscript access (indexing)
684
+ if isinstance(node, ast.Subscript):
685
+ obj = self._eval_node(node.value, namespace, expression)
686
+ # Handle Python 3.8 vs 3.9+ differences
687
+ if isinstance(node.slice, ast.Index):
688
+ index = self._eval_node(node.slice.value, namespace, expression)
689
+ elif isinstance(node.slice, ast.Slice):
690
+ lower = (
691
+ self._eval_node(node.slice.lower, namespace, expression)
692
+ if node.slice.lower
693
+ else None
694
+ )
695
+ upper = (
696
+ self._eval_node(node.slice.upper, namespace, expression)
697
+ if node.slice.upper
698
+ else None
699
+ )
700
+ step = (
701
+ self._eval_node(node.slice.step, namespace, expression)
702
+ if node.slice.step
703
+ else None
704
+ )
705
+ index = slice(lower, upper, step)
706
+ else:
707
+ index = self._eval_node(node.slice, namespace, expression)
708
+ try:
709
+ return obj[index]
710
+ except (KeyError, IndexError, TypeError) as e:
711
+ raise ExpressionError(
712
+ expression,
713
+ f"Subscript error: {e}",
714
+ ) from None
715
+
716
+ # Binary operations
717
+ if isinstance(node, ast.BinOp):
718
+ left = self._eval_node(node.left, namespace, expression)
719
+ right = self._eval_node(node.right, namespace, expression)
720
+ op_func = self.BINARY_OPS.get(type(node.op))
721
+ if op_func is None:
722
+ raise ExpressionError(
723
+ expression,
724
+ f"Unsupported binary operator: {type(node.op).__name__}",
725
+ )
726
+ try:
727
+ return op_func(left, right)
728
+ except Exception as e:
729
+ raise ExpressionError(
730
+ expression,
731
+ f"Binary operation error: {e}",
732
+ ) from None
733
+
734
+ # Unary operations
735
+ if isinstance(node, ast.UnaryOp):
736
+ operand = self._eval_node(node.operand, namespace, expression)
737
+ op_func = self.UNARY_OPS.get(type(node.op))
738
+ if op_func is None:
739
+ raise ExpressionError(
740
+ expression,
741
+ f"Unsupported unary operator: {type(node.op).__name__}",
742
+ )
743
+ return op_func(operand)
744
+
745
+ # Boolean operations (and, or)
746
+ if isinstance(node, ast.BoolOp):
747
+ if isinstance(node.op, ast.And):
748
+ result = True
749
+ for value in node.values:
750
+ result = self._eval_node(value, namespace, expression)
751
+ if not result:
752
+ return False
753
+ return result
754
+ elif isinstance(node.op, ast.Or):
755
+ for value in node.values:
756
+ result = self._eval_node(value, namespace, expression)
757
+ if result:
758
+ return result
759
+ return False
760
+ else:
761
+ raise ExpressionError(
762
+ expression,
763
+ f"Unsupported boolean operator: {type(node.op).__name__}",
764
+ )
765
+
766
+ # Comparisons
767
+ if isinstance(node, ast.Compare):
768
+ left = self._eval_node(node.left, namespace, expression)
769
+ for op, comparator in zip(node.ops, node.comparators):
770
+ right = self._eval_node(comparator, namespace, expression)
771
+ op_func = self.COMPARE_OPS.get(type(op))
772
+ if op_func is None:
773
+ raise ExpressionError(
774
+ expression,
775
+ f"Unsupported comparison operator: {type(op).__name__}",
776
+ )
777
+ if not op_func(left, right):
778
+ return False
779
+ left = right
780
+ return True
781
+
782
+ # Function calls
783
+ if isinstance(node, ast.Call):
784
+ func = self._eval_node(node.func, namespace, expression)
785
+ args = [self._eval_node(arg, namespace, expression) for arg in node.args]
786
+ kwargs = {
787
+ kw.arg: self._eval_node(kw.value, namespace, expression)
788
+ for kw in node.keywords
789
+ if kw.arg is not None
790
+ }
791
+ try:
792
+ return func(*args, **kwargs)
793
+ except Exception as e:
794
+ raise ExpressionError(
795
+ expression,
796
+ f"Function call error: {e}",
797
+ ) from None
798
+
799
+ # List literal
800
+ if isinstance(node, ast.List):
801
+ return [self._eval_node(elt, namespace, expression) for elt in node.elts]
802
+
803
+ # Tuple literal
804
+ if isinstance(node, ast.Tuple):
805
+ return tuple(
806
+ self._eval_node(elt, namespace, expression) for elt in node.elts
807
+ )
808
+
809
+ # Set literal
810
+ if isinstance(node, ast.Set):
811
+ return {self._eval_node(elt, namespace, expression) for elt in node.elts}
812
+
813
+ # Dict literal
814
+ if isinstance(node, ast.Dict):
815
+ return {
816
+ self._eval_node(k, namespace, expression)
817
+ if k is not None
818
+ else None: self._eval_node(v, namespace, expression)
819
+ for k, v in zip(node.keys, node.values)
820
+ }
821
+
822
+ # List comprehension
823
+ if isinstance(node, ast.ListComp):
824
+ return self._eval_comprehension(
825
+ node.elt,
826
+ node.generators,
827
+ namespace,
828
+ expression,
829
+ list,
830
+ )
831
+
832
+ # Set comprehension
833
+ if isinstance(node, ast.SetComp):
834
+ return self._eval_comprehension(
835
+ node.elt,
836
+ node.generators,
837
+ namespace,
838
+ expression,
839
+ set,
840
+ )
841
+
842
+ # Dict comprehension
843
+ if isinstance(node, ast.DictComp):
844
+ return self._eval_dict_comprehension(
845
+ node.key,
846
+ node.value,
847
+ node.generators,
848
+ namespace,
849
+ expression,
850
+ )
851
+
852
+ # Generator expression
853
+ if isinstance(node, ast.GeneratorExp):
854
+ return self._eval_generator(
855
+ node.elt,
856
+ node.generators,
857
+ namespace,
858
+ expression,
859
+ )
860
+
861
+ # Conditional expression (ternary)
862
+ if isinstance(node, ast.IfExp):
863
+ test = self._eval_node(node.test, namespace, expression)
864
+ if test:
865
+ return self._eval_node(node.body, namespace, expression)
866
+ else:
867
+ return self._eval_node(node.orelse, namespace, expression)
868
+
869
+ raise ExpressionError(
870
+ expression,
871
+ f"Unsupported AST node type: {type(node).__name__}",
872
+ )
873
+
874
+ def _eval_comprehension(
875
+ self,
876
+ elt: ast.AST,
877
+ generators: list[ast.comprehension],
878
+ namespace: dict[str, Any],
879
+ expression: str,
880
+ result_type: type,
881
+ ) -> Any:
882
+ """Evaluate a list/set comprehension.
883
+
884
+ Args:
885
+ elt: Element expression.
886
+ generators: Comprehension generators.
887
+ namespace: Evaluation namespace.
888
+ expression: Original expression.
889
+ result_type: Result container type (list or set).
890
+
891
+ Returns:
892
+ Comprehension result.
893
+ """
894
+ if not generators:
895
+ return result_type()
896
+
897
+ return self._eval_comprehension_recursive(
898
+ elt,
899
+ generators,
900
+ 0,
901
+ namespace.copy(),
902
+ expression,
903
+ result_type,
904
+ )
905
+
906
+ def _eval_comprehension_recursive(
907
+ self,
908
+ elt: ast.AST,
909
+ generators: list[ast.comprehension],
910
+ gen_index: int,
911
+ namespace: dict[str, Any],
912
+ expression: str,
913
+ result_type: type,
914
+ ) -> Any:
915
+ """Recursively evaluate nested comprehension generators."""
916
+ if gen_index >= len(generators):
917
+ # Base case: evaluate element
918
+ value = self._eval_node(elt, namespace, expression)
919
+ return [value] if result_type == list else {value}
920
+
921
+ gen = generators[gen_index]
922
+ iterable = self._eval_node(gen.iter, namespace, expression)
923
+ result = [] if result_type == list else set()
924
+
925
+ for item in iterable:
926
+ self._check_iteration_limit(expression)
927
+
928
+ # Bind target variable
929
+ local_ns = namespace.copy()
930
+ self._assign_target(gen.target, item, local_ns, expression)
931
+
932
+ # Check conditions
933
+ if gen.ifs:
934
+ all_pass = True
935
+ for if_clause in gen.ifs:
936
+ if not self._eval_node(if_clause, local_ns, expression):
937
+ all_pass = False
938
+ break
939
+ if not all_pass:
940
+ continue
941
+
942
+ # Recurse to next generator or evaluate element
943
+ inner_result = self._eval_comprehension_recursive(
944
+ elt,
945
+ generators,
946
+ gen_index + 1,
947
+ local_ns,
948
+ expression,
949
+ result_type,
950
+ )
951
+
952
+ if result_type == list:
953
+ result.extend(inner_result)
954
+ else:
955
+ result.update(inner_result)
956
+
957
+ return result
958
+
959
+ def _eval_dict_comprehension(
960
+ self,
961
+ key: ast.AST,
962
+ value: ast.AST,
963
+ generators: list[ast.comprehension],
964
+ namespace: dict[str, Any],
965
+ expression: str,
966
+ ) -> dict[Any, Any]:
967
+ """Evaluate a dictionary comprehension."""
968
+ result: dict[Any, Any] = {}
969
+ self._eval_dict_comp_recursive(
970
+ key,
971
+ value,
972
+ generators,
973
+ 0,
974
+ namespace.copy(),
975
+ expression,
976
+ result,
977
+ )
978
+ return result
979
+
980
+ def _eval_dict_comp_recursive(
981
+ self,
982
+ key_node: ast.AST,
983
+ value_node: ast.AST,
984
+ generators: list[ast.comprehension],
985
+ gen_index: int,
986
+ namespace: dict[str, Any],
987
+ expression: str,
988
+ result: dict[Any, Any],
989
+ ) -> None:
990
+ """Recursively evaluate nested dict comprehension generators."""
991
+ if gen_index >= len(generators):
992
+ # Base case: evaluate key and value
993
+ k = self._eval_node(key_node, namespace, expression)
994
+ v = self._eval_node(value_node, namespace, expression)
995
+ result[k] = v
996
+ return
997
+
998
+ gen = generators[gen_index]
999
+ iterable = self._eval_node(gen.iter, namespace, expression)
1000
+
1001
+ for item in iterable:
1002
+ self._check_iteration_limit(expression)
1003
+
1004
+ # Bind target variable
1005
+ local_ns = namespace.copy()
1006
+ self._assign_target(gen.target, item, local_ns, expression)
1007
+
1008
+ # Check conditions
1009
+ if gen.ifs:
1010
+ all_pass = True
1011
+ for if_clause in gen.ifs:
1012
+ if not self._eval_node(if_clause, local_ns, expression):
1013
+ all_pass = False
1014
+ break
1015
+ if not all_pass:
1016
+ continue
1017
+
1018
+ # Recurse
1019
+ self._eval_dict_comp_recursive(
1020
+ key_node,
1021
+ value_node,
1022
+ generators,
1023
+ gen_index + 1,
1024
+ local_ns,
1025
+ expression,
1026
+ result,
1027
+ )
1028
+
1029
+ def _eval_generator(
1030
+ self,
1031
+ elt: ast.AST,
1032
+ generators: list[ast.comprehension],
1033
+ namespace: dict[str, Any],
1034
+ expression: str,
1035
+ ) -> Any:
1036
+ """Evaluate a generator expression.
1037
+
1038
+ Returns a generator object that can be consumed by functions like any(), all().
1039
+ """
1040
+
1041
+ def gen():
1042
+ yield from self._eval_comprehension_recursive(
1043
+ elt,
1044
+ generators,
1045
+ 0,
1046
+ namespace.copy(),
1047
+ expression,
1048
+ list,
1049
+ )
1050
+
1051
+ return gen()
1052
+
1053
+ def _assign_target(
1054
+ self,
1055
+ target: ast.AST,
1056
+ value: Any,
1057
+ namespace: dict[str, Any],
1058
+ expression: str,
1059
+ ) -> None:
1060
+ """Assign a value to a target (handles tuple unpacking).
1061
+
1062
+ Args:
1063
+ target: Assignment target AST node.
1064
+ value: Value to assign.
1065
+ namespace: Namespace to update.
1066
+ expression: Original expression.
1067
+ """
1068
+ if isinstance(target, ast.Name):
1069
+ namespace[target.id] = value
1070
+ elif isinstance(target, ast.Tuple):
1071
+ if not hasattr(value, "__iter__"):
1072
+ raise ExpressionError(
1073
+ expression,
1074
+ f"Cannot unpack non-iterable: {type(value).__name__}",
1075
+ )
1076
+ values = list(value)
1077
+ if len(values) != len(target.elts):
1078
+ raise ExpressionError(
1079
+ expression,
1080
+ f"Cannot unpack {len(values)} values into {len(target.elts)} targets",
1081
+ )
1082
+ for t, v in zip(target.elts, values):
1083
+ self._assign_target(t, v, namespace, expression)
1084
+ else:
1085
+ raise ExpressionError(
1086
+ expression,
1087
+ f"Unsupported assignment target: {type(target).__name__}",
1088
+ )
1089
+
1090
+
1091
+ @RuleRegistry.register("expression")
1092
+ @dataclass
1093
+ class ExpressionRule(BaseRule):
1094
+ """Rule that evaluates a Python-like expression.
1095
+
1096
+ This rule allows complex routing conditions using a safe expression
1097
+ language. Expressions can reference context fields and use standard
1098
+ operators.
1099
+
1100
+ Attributes:
1101
+ expression: Python-like expression to evaluate.
1102
+ timeout_seconds: Maximum evaluation time (default: 1.0).
1103
+
1104
+ Example:
1105
+ rule = ExpressionRule(
1106
+ expression="severity == 'critical' and pass_rate < 0.8"
1107
+ )
1108
+
1109
+ # Or for complex conditions:
1110
+ rule = ExpressionRule(
1111
+ expression='''
1112
+ (severity == 'critical' or len(issues) > 10)
1113
+ and 'production' in metadata.get('environment', '')
1114
+ '''
1115
+ )
1116
+
1117
+ Available Context Fields:
1118
+ - checkpoint_name: Name of the validation checkpoint
1119
+ - action_type: Type of action (check, learn, profile, etc.)
1120
+ - severity: Highest issue severity (critical, high, medium, low, info)
1121
+ - issues: List of issue identifiers
1122
+ - pass_rate: Validation pass rate (0.0 to 1.0)
1123
+ - timestamp: When validation occurred
1124
+ - metadata: Dictionary of custom fields
1125
+ - context: Full ExpressionContext object
1126
+
1127
+ Supported Operators:
1128
+ - Comparison: ==, !=, <, >, <=, >=, in, not in
1129
+ - Logical: and, or, not
1130
+ - Arithmetic: +, -, *, /, //, %, **
1131
+
1132
+ Supported Functions:
1133
+ - len, any, all, sum, min, max, abs, round
1134
+ - bool, int, float, str, list, tuple, set, dict
1135
+ - sorted, reversed, enumerate, zip, range
1136
+ - isinstance, hasattr, getattr
1137
+ """
1138
+
1139
+ expression: str = ""
1140
+ timeout_seconds: float = 1.0
1141
+
1142
+ _evaluator: SafeExpressionEvaluator = field(
1143
+ default_factory=SafeExpressionEvaluator,
1144
+ init=False,
1145
+ repr=False,
1146
+ )
1147
+
1148
+ def __post_init__(self) -> None:
1149
+ """Initialize the evaluator with configured timeout."""
1150
+ self._evaluator = SafeExpressionEvaluator(
1151
+ timeout_seconds=self.timeout_seconds,
1152
+ )
1153
+
1154
+ @classmethod
1155
+ def get_param_schema(cls) -> dict[str, Any]:
1156
+ """Get parameter schema for this rule type."""
1157
+ return {
1158
+ "expression": {
1159
+ "type": "string",
1160
+ "required": True,
1161
+ "description": "Python-like expression to evaluate against the context",
1162
+ },
1163
+ "timeout_seconds": {
1164
+ "type": "number",
1165
+ "required": False,
1166
+ "description": "Maximum evaluation time in seconds",
1167
+ "default": 1.0,
1168
+ "minimum": 0.1,
1169
+ "maximum": 10.0,
1170
+ },
1171
+ }
1172
+
1173
+ async def matches(self, context: "RouteContext") -> bool:
1174
+ """Check if the expression matches the context.
1175
+
1176
+ Args:
1177
+ context: The routing context to evaluate against.
1178
+
1179
+ Returns:
1180
+ True if the expression evaluates to True.
1181
+ """
1182
+ if not self.expression or not self.expression.strip():
1183
+ return False
1184
+
1185
+ # Build expression context from route context
1186
+ expr_context = self._build_expression_context(context)
1187
+
1188
+ try:
1189
+ return self._evaluator.evaluate(self.expression, expr_context)
1190
+ except (ExpressionError, ExpressionTimeout, ExpressionSecurityError):
1191
+ # Log error but return False for safety
1192
+ return False
1193
+
1194
+ def _build_expression_context(
1195
+ self,
1196
+ route_context: "RouteContext",
1197
+ ) -> ExpressionContext:
1198
+ """Build ExpressionContext from RouteContext.
1199
+
1200
+ Args:
1201
+ route_context: The routing context.
1202
+
1203
+ Returns:
1204
+ ExpressionContext for expression evaluation.
1205
+ """
1206
+ # Extract severity
1207
+ severity = route_context.get_severity() or "info"
1208
+
1209
+ # Extract issues from event data
1210
+ issues: list[str] = []
1211
+ if hasattr(route_context.event, "data"):
1212
+ event_issues = route_context.event.data.get("issues", [])
1213
+ for issue in event_issues:
1214
+ if isinstance(issue, dict):
1215
+ validator = issue.get("validator", "")
1216
+ if validator:
1217
+ issues.append(validator)
1218
+ elif isinstance(issue, str):
1219
+ issues.append(issue)
1220
+
1221
+ # Get pass rate
1222
+ pass_rate = route_context.get_pass_rate() or 1.0
1223
+
1224
+ # Get checkpoint name
1225
+ checkpoint_name = route_context.get_data_asset() or ""
1226
+
1227
+ # Get action type from event
1228
+ action_type = "check"
1229
+ if hasattr(route_context.event, "event_type"):
1230
+ event_type = route_context.event.event_type
1231
+ if "learn" in event_type:
1232
+ action_type = "learn"
1233
+ elif "profile" in event_type:
1234
+ action_type = "profile"
1235
+ elif "compare" in event_type or "drift" in event_type:
1236
+ action_type = "compare"
1237
+ elif "scan" in event_type:
1238
+ action_type = "scan"
1239
+ elif "mask" in event_type:
1240
+ action_type = "mask"
1241
+
1242
+ # Build metadata
1243
+ metadata = dict(route_context.metadata)
1244
+ if hasattr(route_context.event, "data"):
1245
+ metadata.update(route_context.event.data)
1246
+
1247
+ # Add additional context fields
1248
+ metadata["tags"] = route_context.get_tags()
1249
+ metadata["status"] = route_context.get_status()
1250
+ metadata["error_message"] = route_context.get_error_message()
1251
+ metadata["issue_count"] = route_context.get_issue_count()
1252
+
1253
+ return ExpressionContext(
1254
+ checkpoint_name=checkpoint_name,
1255
+ action_type=action_type,
1256
+ severity=severity,
1257
+ issues=issues,
1258
+ pass_rate=pass_rate,
1259
+ timestamp=route_context.timestamp,
1260
+ metadata=metadata,
1261
+ )
1262
+
1263
+ def to_dict(self) -> dict[str, Any]:
1264
+ """Serialize rule to dictionary."""
1265
+ return {
1266
+ "type": self.rule_type,
1267
+ "expression": self.expression,
1268
+ "timeout_seconds": self.timeout_seconds,
1269
+ }