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.
- truthound_dashboard/api/alerts.py +258 -0
- truthound_dashboard/api/anomaly.py +1302 -0
- truthound_dashboard/api/cross_alerts.py +352 -0
- truthound_dashboard/api/deps.py +143 -0
- truthound_dashboard/api/drift_monitor.py +540 -0
- truthound_dashboard/api/lineage.py +1151 -0
- truthound_dashboard/api/maintenance.py +363 -0
- truthound_dashboard/api/middleware.py +373 -1
- truthound_dashboard/api/model_monitoring.py +805 -0
- truthound_dashboard/api/notifications_advanced.py +2452 -0
- truthound_dashboard/api/plugins.py +2096 -0
- truthound_dashboard/api/profile.py +211 -14
- truthound_dashboard/api/reports.py +853 -0
- truthound_dashboard/api/router.py +147 -0
- truthound_dashboard/api/rule_suggestions.py +310 -0
- truthound_dashboard/api/schema_evolution.py +231 -0
- truthound_dashboard/api/sources.py +47 -3
- truthound_dashboard/api/triggers.py +190 -0
- truthound_dashboard/api/validations.py +13 -0
- truthound_dashboard/api/validators.py +333 -4
- truthound_dashboard/api/versioning.py +309 -0
- truthound_dashboard/api/websocket.py +301 -0
- truthound_dashboard/core/__init__.py +27 -0
- truthound_dashboard/core/anomaly.py +1395 -0
- truthound_dashboard/core/anomaly_explainer.py +633 -0
- truthound_dashboard/core/cache.py +206 -0
- truthound_dashboard/core/cached_services.py +422 -0
- truthound_dashboard/core/charts.py +352 -0
- truthound_dashboard/core/connections.py +1069 -42
- truthound_dashboard/core/cross_alerts.py +837 -0
- truthound_dashboard/core/drift_monitor.py +1477 -0
- truthound_dashboard/core/drift_sampling.py +669 -0
- truthound_dashboard/core/i18n/__init__.py +42 -0
- truthound_dashboard/core/i18n/detector.py +173 -0
- truthound_dashboard/core/i18n/messages.py +564 -0
- truthound_dashboard/core/lineage.py +971 -0
- truthound_dashboard/core/maintenance.py +443 -5
- truthound_dashboard/core/model_monitoring.py +1043 -0
- truthound_dashboard/core/notifications/channels.py +1020 -1
- truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
- truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
- truthound_dashboard/core/notifications/deduplication/service.py +400 -0
- truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
- truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
- truthound_dashboard/core/notifications/dispatcher.py +43 -0
- truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
- truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
- truthound_dashboard/core/notifications/escalation/engine.py +429 -0
- truthound_dashboard/core/notifications/escalation/models.py +336 -0
- truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
- truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
- truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
- truthound_dashboard/core/notifications/events.py +49 -0
- truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
- truthound_dashboard/core/notifications/metrics/base.py +528 -0
- truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
- truthound_dashboard/core/notifications/routing/__init__.py +169 -0
- truthound_dashboard/core/notifications/routing/combinators.py +184 -0
- truthound_dashboard/core/notifications/routing/config.py +375 -0
- truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
- truthound_dashboard/core/notifications/routing/engine.py +382 -0
- truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
- truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
- truthound_dashboard/core/notifications/routing/rules.py +625 -0
- truthound_dashboard/core/notifications/routing/validator.py +678 -0
- truthound_dashboard/core/notifications/service.py +2 -0
- truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
- truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
- truthound_dashboard/core/notifications/throttling/builder.py +311 -0
- truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
- truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
- truthound_dashboard/core/openlineage.py +1028 -0
- truthound_dashboard/core/plugins/__init__.py +39 -0
- truthound_dashboard/core/plugins/docs/__init__.py +39 -0
- truthound_dashboard/core/plugins/docs/extractor.py +703 -0
- truthound_dashboard/core/plugins/docs/renderers.py +804 -0
- truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
- truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
- truthound_dashboard/core/plugins/hooks/manager.py +403 -0
- truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
- truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
- truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
- truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
- truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
- truthound_dashboard/core/plugins/loader.py +504 -0
- truthound_dashboard/core/plugins/registry.py +810 -0
- truthound_dashboard/core/plugins/reporter_executor.py +588 -0
- truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
- truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
- truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
- truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
- truthound_dashboard/core/plugins/sandbox.py +617 -0
- truthound_dashboard/core/plugins/security/__init__.py +68 -0
- truthound_dashboard/core/plugins/security/analyzer.py +535 -0
- truthound_dashboard/core/plugins/security/policies.py +311 -0
- truthound_dashboard/core/plugins/security/protocols.py +296 -0
- truthound_dashboard/core/plugins/security/signing.py +842 -0
- truthound_dashboard/core/plugins/security.py +446 -0
- truthound_dashboard/core/plugins/validator_executor.py +401 -0
- truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
- truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
- truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
- truthound_dashboard/core/plugins/versioning/semver.py +266 -0
- truthound_dashboard/core/profile_comparison.py +601 -0
- truthound_dashboard/core/report_history.py +570 -0
- truthound_dashboard/core/reporters/__init__.py +57 -0
- truthound_dashboard/core/reporters/base.py +296 -0
- truthound_dashboard/core/reporters/csv_reporter.py +155 -0
- truthound_dashboard/core/reporters/html_reporter.py +598 -0
- truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
- truthound_dashboard/core/reporters/i18n/base.py +494 -0
- truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
- truthound_dashboard/core/reporters/json_reporter.py +160 -0
- truthound_dashboard/core/reporters/junit_reporter.py +233 -0
- truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
- truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
- truthound_dashboard/core/reporters/registry.py +272 -0
- truthound_dashboard/core/rule_generator.py +2088 -0
- truthound_dashboard/core/scheduler.py +822 -12
- truthound_dashboard/core/schema_evolution.py +858 -0
- truthound_dashboard/core/services.py +152 -9
- truthound_dashboard/core/statistics.py +718 -0
- truthound_dashboard/core/streaming_anomaly.py +883 -0
- truthound_dashboard/core/triggers/__init__.py +45 -0
- truthound_dashboard/core/triggers/base.py +226 -0
- truthound_dashboard/core/triggers/evaluators.py +609 -0
- truthound_dashboard/core/triggers/factory.py +363 -0
- truthound_dashboard/core/unified_alerts.py +870 -0
- truthound_dashboard/core/validation_limits.py +509 -0
- truthound_dashboard/core/versioning.py +709 -0
- truthound_dashboard/core/websocket/__init__.py +59 -0
- truthound_dashboard/core/websocket/manager.py +512 -0
- truthound_dashboard/core/websocket/messages.py +130 -0
- truthound_dashboard/db/__init__.py +30 -0
- truthound_dashboard/db/models.py +3375 -3
- truthound_dashboard/main.py +22 -0
- truthound_dashboard/schemas/__init__.py +396 -1
- truthound_dashboard/schemas/anomaly.py +1258 -0
- truthound_dashboard/schemas/base.py +4 -0
- truthound_dashboard/schemas/cross_alerts.py +334 -0
- truthound_dashboard/schemas/drift_monitor.py +890 -0
- truthound_dashboard/schemas/lineage.py +428 -0
- truthound_dashboard/schemas/maintenance.py +154 -0
- truthound_dashboard/schemas/model_monitoring.py +374 -0
- truthound_dashboard/schemas/notifications_advanced.py +1363 -0
- truthound_dashboard/schemas/openlineage.py +704 -0
- truthound_dashboard/schemas/plugins.py +1293 -0
- truthound_dashboard/schemas/profile.py +420 -34
- truthound_dashboard/schemas/profile_comparison.py +242 -0
- truthound_dashboard/schemas/reports.py +285 -0
- truthound_dashboard/schemas/rule_suggestion.py +434 -0
- truthound_dashboard/schemas/schema_evolution.py +164 -0
- truthound_dashboard/schemas/source.py +117 -2
- truthound_dashboard/schemas/triggers.py +511 -0
- truthound_dashboard/schemas/unified_alerts.py +223 -0
- truthound_dashboard/schemas/validation.py +25 -1
- truthound_dashboard/schemas/validators/__init__.py +11 -0
- truthound_dashboard/schemas/validators/base.py +151 -0
- truthound_dashboard/schemas/versioning.py +152 -0
- truthound_dashboard/static/index.html +2 -2
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/METADATA +147 -23
- truthound_dashboard-1.4.1.dist-info/RECORD +239 -0
- truthound_dashboard/static/assets/index-BZG20KuF.js +0 -586
- truthound_dashboard/static/assets/index-D_HyZ3pb.css +0 -1
- truthound_dashboard/static/assets/unmerged_dictionaries-CtpqQBm0.js +0 -1
- truthound_dashboard-1.3.1.dist-info/RECORD +0 -110
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/WHEEL +0 -0
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/entry_points.txt +0 -0
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.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
|
+
}
|