truthound-dashboard 1.4.3__py3-none-any.whl → 1.5.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.
- truthound_dashboard/api/alerts.py +75 -86
- truthound_dashboard/api/anomaly.py +7 -13
- truthound_dashboard/api/cross_alerts.py +38 -52
- truthound_dashboard/api/drift.py +49 -59
- truthound_dashboard/api/drift_monitor.py +234 -79
- truthound_dashboard/api/enterprise_sampling.py +498 -0
- truthound_dashboard/api/history.py +57 -5
- truthound_dashboard/api/lineage.py +3 -48
- truthound_dashboard/api/maintenance.py +104 -49
- truthound_dashboard/api/mask.py +1 -2
- truthound_dashboard/api/middleware.py +2 -1
- truthound_dashboard/api/model_monitoring.py +435 -311
- truthound_dashboard/api/notifications.py +227 -191
- truthound_dashboard/api/notifications_advanced.py +21 -20
- truthound_dashboard/api/observability.py +586 -0
- truthound_dashboard/api/plugins.py +2 -433
- truthound_dashboard/api/profile.py +199 -37
- truthound_dashboard/api/quality_reporter.py +701 -0
- truthound_dashboard/api/reports.py +7 -16
- truthound_dashboard/api/router.py +66 -0
- truthound_dashboard/api/rule_suggestions.py +5 -5
- truthound_dashboard/api/scan.py +17 -19
- truthound_dashboard/api/schedules.py +85 -50
- truthound_dashboard/api/schema_evolution.py +6 -6
- truthound_dashboard/api/schema_watcher.py +667 -0
- truthound_dashboard/api/sources.py +98 -27
- truthound_dashboard/api/tiering.py +1323 -0
- truthound_dashboard/api/triggers.py +14 -11
- truthound_dashboard/api/validations.py +12 -11
- truthound_dashboard/api/versioning.py +1 -6
- truthound_dashboard/core/__init__.py +129 -3
- truthound_dashboard/core/actions/__init__.py +62 -0
- truthound_dashboard/core/actions/custom.py +426 -0
- truthound_dashboard/core/actions/notifications.py +910 -0
- truthound_dashboard/core/actions/storage.py +472 -0
- truthound_dashboard/core/actions/webhook.py +281 -0
- truthound_dashboard/core/anomaly.py +262 -67
- truthound_dashboard/core/anomaly_explainer.py +4 -3
- truthound_dashboard/core/backends/__init__.py +67 -0
- truthound_dashboard/core/backends/base.py +299 -0
- truthound_dashboard/core/backends/errors.py +191 -0
- truthound_dashboard/core/backends/factory.py +423 -0
- truthound_dashboard/core/backends/mock_backend.py +451 -0
- truthound_dashboard/core/backends/truthound_backend.py +718 -0
- truthound_dashboard/core/checkpoint/__init__.py +87 -0
- truthound_dashboard/core/checkpoint/adapters.py +814 -0
- truthound_dashboard/core/checkpoint/checkpoint.py +491 -0
- truthound_dashboard/core/checkpoint/runner.py +270 -0
- truthound_dashboard/core/connections.py +437 -10
- truthound_dashboard/core/converters/__init__.py +14 -0
- truthound_dashboard/core/converters/truthound.py +620 -0
- truthound_dashboard/core/cross_alerts.py +540 -320
- truthound_dashboard/core/datasource_factory.py +1672 -0
- truthound_dashboard/core/drift_monitor.py +216 -20
- truthound_dashboard/core/enterprise_sampling.py +1291 -0
- truthound_dashboard/core/interfaces/__init__.py +225 -0
- truthound_dashboard/core/interfaces/actions.py +652 -0
- truthound_dashboard/core/interfaces/base.py +247 -0
- truthound_dashboard/core/interfaces/checkpoint.py +676 -0
- truthound_dashboard/core/interfaces/protocols.py +664 -0
- truthound_dashboard/core/interfaces/reporters.py +650 -0
- truthound_dashboard/core/interfaces/routing.py +646 -0
- truthound_dashboard/core/interfaces/triggers.py +619 -0
- truthound_dashboard/core/lineage.py +407 -71
- truthound_dashboard/core/model_monitoring.py +431 -3
- truthound_dashboard/core/notifications/base.py +4 -0
- truthound_dashboard/core/notifications/channels.py +501 -1203
- truthound_dashboard/core/notifications/deduplication/__init__.py +81 -115
- truthound_dashboard/core/notifications/deduplication/service.py +131 -348
- truthound_dashboard/core/notifications/dispatcher.py +202 -11
- truthound_dashboard/core/notifications/escalation/__init__.py +119 -106
- truthound_dashboard/core/notifications/escalation/engine.py +168 -358
- truthound_dashboard/core/notifications/routing/__init__.py +88 -128
- truthound_dashboard/core/notifications/routing/engine.py +90 -317
- truthound_dashboard/core/notifications/stats_aggregator.py +246 -1
- truthound_dashboard/core/notifications/throttling/__init__.py +67 -50
- truthound_dashboard/core/notifications/throttling/builder.py +117 -255
- truthound_dashboard/core/notifications/truthound_adapter.py +842 -0
- truthound_dashboard/core/phase5/collaboration.py +1 -1
- truthound_dashboard/core/plugins/lifecycle/__init__.py +0 -13
- truthound_dashboard/core/quality_reporter.py +1359 -0
- truthound_dashboard/core/report_history.py +0 -6
- truthound_dashboard/core/reporters/__init__.py +175 -14
- truthound_dashboard/core/reporters/adapters.py +943 -0
- truthound_dashboard/core/reporters/base.py +0 -3
- truthound_dashboard/core/reporters/builtin/__init__.py +18 -0
- truthound_dashboard/core/reporters/builtin/csv_reporter.py +111 -0
- truthound_dashboard/core/reporters/builtin/html_reporter.py +270 -0
- truthound_dashboard/core/reporters/builtin/json_reporter.py +127 -0
- truthound_dashboard/core/reporters/compat.py +266 -0
- truthound_dashboard/core/reporters/csv_reporter.py +2 -35
- truthound_dashboard/core/reporters/factory.py +526 -0
- truthound_dashboard/core/reporters/interfaces.py +745 -0
- truthound_dashboard/core/reporters/registry.py +1 -10
- truthound_dashboard/core/scheduler.py +165 -0
- truthound_dashboard/core/schema_evolution.py +3 -3
- truthound_dashboard/core/schema_watcher.py +1528 -0
- truthound_dashboard/core/services.py +595 -76
- truthound_dashboard/core/store_manager.py +810 -0
- truthound_dashboard/core/streaming_anomaly.py +169 -4
- truthound_dashboard/core/tiering.py +1309 -0
- truthound_dashboard/core/triggers/evaluators.py +178 -8
- truthound_dashboard/core/truthound_adapter.py +2620 -197
- truthound_dashboard/core/unified_alerts.py +23 -20
- truthound_dashboard/db/__init__.py +8 -0
- truthound_dashboard/db/database.py +8 -2
- truthound_dashboard/db/models.py +944 -25
- truthound_dashboard/db/repository.py +2 -0
- truthound_dashboard/main.py +11 -0
- truthound_dashboard/schemas/__init__.py +177 -16
- truthound_dashboard/schemas/base.py +44 -23
- truthound_dashboard/schemas/collaboration.py +19 -6
- truthound_dashboard/schemas/cross_alerts.py +19 -3
- truthound_dashboard/schemas/drift.py +61 -55
- truthound_dashboard/schemas/drift_monitor.py +67 -23
- truthound_dashboard/schemas/enterprise_sampling.py +653 -0
- truthound_dashboard/schemas/lineage.py +0 -33
- truthound_dashboard/schemas/mask.py +10 -8
- truthound_dashboard/schemas/model_monitoring.py +89 -10
- truthound_dashboard/schemas/notifications_advanced.py +13 -0
- truthound_dashboard/schemas/observability.py +453 -0
- truthound_dashboard/schemas/plugins.py +0 -280
- truthound_dashboard/schemas/profile.py +154 -247
- truthound_dashboard/schemas/quality_reporter.py +403 -0
- truthound_dashboard/schemas/reports.py +2 -2
- truthound_dashboard/schemas/rule_suggestion.py +8 -1
- truthound_dashboard/schemas/scan.py +4 -24
- truthound_dashboard/schemas/schedule.py +11 -3
- truthound_dashboard/schemas/schema_watcher.py +727 -0
- truthound_dashboard/schemas/source.py +17 -2
- truthound_dashboard/schemas/tiering.py +822 -0
- truthound_dashboard/schemas/triggers.py +16 -0
- truthound_dashboard/schemas/unified_alerts.py +7 -0
- truthound_dashboard/schemas/validation.py +0 -13
- truthound_dashboard/schemas/validators/base.py +41 -21
- truthound_dashboard/schemas/validators/business_rule_validators.py +244 -0
- truthound_dashboard/schemas/validators/localization_validators.py +273 -0
- truthound_dashboard/schemas/validators/ml_feature_validators.py +308 -0
- truthound_dashboard/schemas/validators/profiling_validators.py +275 -0
- truthound_dashboard/schemas/validators/referential_validators.py +312 -0
- truthound_dashboard/schemas/validators/registry.py +93 -8
- truthound_dashboard/schemas/validators/timeseries_validators.py +389 -0
- truthound_dashboard/schemas/versioning.py +1 -6
- truthound_dashboard/static/index.html +2 -2
- truthound_dashboard-1.5.0.dist-info/METADATA +309 -0
- {truthound_dashboard-1.4.3.dist-info → truthound_dashboard-1.5.0.dist-info}/RECORD +149 -148
- truthound_dashboard/core/plugins/hooks/__init__.py +0 -63
- truthound_dashboard/core/plugins/hooks/decorators.py +0 -367
- truthound_dashboard/core/plugins/hooks/manager.py +0 -403
- truthound_dashboard/core/plugins/hooks/protocols.py +0 -265
- truthound_dashboard/core/plugins/lifecycle/hot_reload.py +0 -584
- truthound_dashboard/core/reporters/junit_reporter.py +0 -233
- truthound_dashboard/core/reporters/markdown_reporter.py +0 -207
- truthound_dashboard/core/reporters/pdf_reporter.py +0 -209
- truthound_dashboard/static/assets/_baseUniq-BcrSP13d.js +0 -1
- truthound_dashboard/static/assets/arc-DlYjKwIL.js +0 -1
- truthound_dashboard/static/assets/architectureDiagram-VXUJARFQ-Bb2drbQM.js +0 -36
- truthound_dashboard/static/assets/blockDiagram-VD42YOAC-BlsPG1CH.js +0 -122
- truthound_dashboard/static/assets/c4Diagram-YG6GDRKO-B9JdUoaC.js +0 -10
- truthound_dashboard/static/assets/channel-Q6mHF1Hd.js +0 -1
- truthound_dashboard/static/assets/chunk-4BX2VUAB-DmyoPVuJ.js +0 -1
- truthound_dashboard/static/assets/chunk-55IACEB6-Bcz6Siv8.js +0 -1
- truthound_dashboard/static/assets/chunk-B4BG7PRW-Br3G5Rum.js +0 -165
- truthound_dashboard/static/assets/chunk-DI55MBZ5-DuM9c23u.js +0 -220
- truthound_dashboard/static/assets/chunk-FMBD7UC4-DNU-5mvT.js +0 -15
- truthound_dashboard/static/assets/chunk-QN33PNHL-Im2yNcmS.js +0 -1
- truthound_dashboard/static/assets/chunk-QZHKN3VN-kZr8XFm1.js +0 -1
- truthound_dashboard/static/assets/chunk-TZMSLE5B-Q__360q_.js +0 -1
- truthound_dashboard/static/assets/classDiagram-2ON5EDUG-vtixxUyK.js +0 -1
- truthound_dashboard/static/assets/classDiagram-v2-WZHVMYZB-vtixxUyK.js +0 -1
- truthound_dashboard/static/assets/clone-BOt2LwD0.js +0 -1
- truthound_dashboard/static/assets/cose-bilkent-S5V4N54A-CBDw6iac.js +0 -1
- truthound_dashboard/static/assets/dagre-6UL2VRFP-XdKqmmY9.js +0 -4
- truthound_dashboard/static/assets/diagram-PSM6KHXK-DAZ8nx9V.js +0 -24
- truthound_dashboard/static/assets/diagram-QEK2KX5R-BRvDTbGD.js +0 -43
- truthound_dashboard/static/assets/diagram-S2PKOQOG-bQcczUkl.js +0 -24
- truthound_dashboard/static/assets/erDiagram-Q2GNP2WA-DPje7VMN.js +0 -60
- truthound_dashboard/static/assets/flowDiagram-NV44I4VS-B7BVtFVS.js +0 -162
- truthound_dashboard/static/assets/ganttDiagram-JELNMOA3-D6WKSS7U.js +0 -267
- truthound_dashboard/static/assets/gitGraphDiagram-NY62KEGX-D3vtVd3y.js +0 -65
- truthound_dashboard/static/assets/graph-BKgNKZVp.js +0 -1
- truthound_dashboard/static/assets/index-C6JSrkHo.css +0 -1
- truthound_dashboard/static/assets/index-DkU82VsU.js +0 -1800
- truthound_dashboard/static/assets/infoDiagram-WHAUD3N6-DnNCT429.js +0 -2
- truthound_dashboard/static/assets/journeyDiagram-XKPGCS4Q-DGiMozqS.js +0 -139
- truthound_dashboard/static/assets/kanban-definition-3W4ZIXB7-BV2gUgli.js +0 -89
- truthound_dashboard/static/assets/katex-Cu_Erd72.js +0 -261
- truthound_dashboard/static/assets/layout-DI2MfQ5G.js +0 -1
- truthound_dashboard/static/assets/min-DYdgXVcT.js +0 -1
- truthound_dashboard/static/assets/mindmap-definition-VGOIOE7T-C7x4ruxz.js +0 -68
- truthound_dashboard/static/assets/pieDiagram-ADFJNKIX-CAJaAB9f.js +0 -30
- truthound_dashboard/static/assets/quadrantDiagram-AYHSOK5B-DeqwDI46.js +0 -7
- truthound_dashboard/static/assets/requirementDiagram-UZGBJVZJ-e3XDpZIM.js +0 -64
- truthound_dashboard/static/assets/sankeyDiagram-TZEHDZUN-CNnAv5Ux.js +0 -10
- truthound_dashboard/static/assets/sequenceDiagram-WL72ISMW-Dsne-Of3.js +0 -145
- truthound_dashboard/static/assets/stateDiagram-FKZM4ZOC-Ee0sQXyb.js +0 -1
- truthound_dashboard/static/assets/stateDiagram-v2-4FDKWEC3-B26KqW_W.js +0 -1
- truthound_dashboard/static/assets/timeline-definition-IT6M3QCI-DZYi2yl3.js +0 -61
- truthound_dashboard/static/assets/treemap-KMMF4GRG-CY3f8In2.js +0 -128
- truthound_dashboard/static/assets/unmerged_dictionaries-Dd7xcPWG.js +0 -1
- truthound_dashboard/static/assets/xychartDiagram-PRI3JC2R-CS7fydZZ.js +0 -7
- truthound_dashboard-1.4.3.dist-info/METADATA +0 -505
- {truthound_dashboard-1.4.3.dist-info → truthound_dashboard-1.5.0.dist-info}/WHEEL +0 -0
- {truthound_dashboard-1.4.3.dist-info → truthound_dashboard-1.5.0.dist-info}/entry_points.txt +0 -0
- {truthound_dashboard-1.4.3.dist-info → truthound_dashboard-1.5.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
"""Routing interfaces for checkpoint-based validation pipelines.
|
|
2
|
+
|
|
3
|
+
Routing rules determine which actions to execute based on validation
|
|
4
|
+
results. They enable complex conditional logic for notifications and
|
|
5
|
+
post-validation processing.
|
|
6
|
+
|
|
7
|
+
This module defines abstract interfaces for routing that are loosely
|
|
8
|
+
coupled from truthound's checkpoint.routing module.
|
|
9
|
+
|
|
10
|
+
Routing features:
|
|
11
|
+
- Jinja2-based rule expressions
|
|
12
|
+
- Compound rules (AllOf, AnyOf, Not)
|
|
13
|
+
- Priority-based routing
|
|
14
|
+
- Action fanout (parallel execution)
|
|
15
|
+
- Context-based routing
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from abc import ABC, abstractmethod
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from enum import Enum
|
|
23
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from truthound_dashboard.core.interfaces.actions import (
|
|
27
|
+
ActionContext,
|
|
28
|
+
ActionProtocol,
|
|
29
|
+
ActionResult,
|
|
30
|
+
)
|
|
31
|
+
from truthound_dashboard.core.interfaces.checkpoint import CheckpointResult
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RouteMode(str, Enum):
|
|
35
|
+
"""How routes are evaluated."""
|
|
36
|
+
|
|
37
|
+
FIRST_MATCH = "first_match" # Stop at first matching route
|
|
38
|
+
ALL_MATCHES = "all_matches" # Execute all matching routes
|
|
39
|
+
PRIORITY = "priority" # Execute in priority order
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RoutePriority(int, Enum):
|
|
43
|
+
"""Priority levels for routes."""
|
|
44
|
+
|
|
45
|
+
CRITICAL = 100
|
|
46
|
+
HIGH = 75
|
|
47
|
+
MEDIUM = 50
|
|
48
|
+
LOW = 25
|
|
49
|
+
DEFAULT = 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class RouteContext:
|
|
54
|
+
"""Context passed to routing rule evaluation.
|
|
55
|
+
|
|
56
|
+
Attributes:
|
|
57
|
+
checkpoint_result: The validation result.
|
|
58
|
+
run_id: Unique run identifier.
|
|
59
|
+
checkpoint_name: Name of the checkpoint.
|
|
60
|
+
tags: Tags from the checkpoint.
|
|
61
|
+
metadata: Additional metadata.
|
|
62
|
+
variables: Custom variables for rule evaluation.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
checkpoint_result: "CheckpointResult"
|
|
66
|
+
run_id: str
|
|
67
|
+
checkpoint_name: str
|
|
68
|
+
tags: dict[str, str] = field(default_factory=dict)
|
|
69
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
70
|
+
variables: dict[str, Any] = field(default_factory=dict)
|
|
71
|
+
|
|
72
|
+
def to_template_context(self) -> dict[str, Any]:
|
|
73
|
+
"""Convert to Jinja2 template context.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Dictionary suitable for Jinja2 rendering.
|
|
77
|
+
"""
|
|
78
|
+
result = self.checkpoint_result
|
|
79
|
+
return {
|
|
80
|
+
# Result properties
|
|
81
|
+
"status": result.status.value,
|
|
82
|
+
"passed": result.status.value == "success",
|
|
83
|
+
"failed": result.status.value == "failure",
|
|
84
|
+
"has_critical": getattr(result, "has_critical", False),
|
|
85
|
+
"has_high": getattr(result, "has_high", False),
|
|
86
|
+
"issue_count": getattr(result, "issue_count", 0),
|
|
87
|
+
"critical_count": getattr(result, "critical_count", 0),
|
|
88
|
+
"high_count": getattr(result, "high_count", 0),
|
|
89
|
+
"medium_count": getattr(result, "medium_count", 0),
|
|
90
|
+
"low_count": getattr(result, "low_count", 0),
|
|
91
|
+
"row_count": getattr(result, "row_count", 0),
|
|
92
|
+
"column_count": getattr(result, "column_count", 0),
|
|
93
|
+
# Context properties
|
|
94
|
+
"run_id": self.run_id,
|
|
95
|
+
"checkpoint_name": self.checkpoint_name,
|
|
96
|
+
"tags": self.tags,
|
|
97
|
+
"metadata": self.metadata,
|
|
98
|
+
# Custom variables
|
|
99
|
+
**self.variables,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@runtime_checkable
|
|
104
|
+
class RoutingRuleProtocol(Protocol):
|
|
105
|
+
"""Protocol for routing rule implementations.
|
|
106
|
+
|
|
107
|
+
Routing rules evaluate checkpoint results and return True
|
|
108
|
+
if the associated actions should be executed.
|
|
109
|
+
|
|
110
|
+
Example:
|
|
111
|
+
class SeverityRule:
|
|
112
|
+
def __init__(self, min_severity: str):
|
|
113
|
+
self.min_severity = min_severity
|
|
114
|
+
|
|
115
|
+
def evaluate(self, context: RouteContext) -> bool:
|
|
116
|
+
return context.checkpoint_result.has_severity(self.min_severity)
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def name(self) -> str:
|
|
121
|
+
"""Get rule name."""
|
|
122
|
+
...
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def expression(self) -> str:
|
|
126
|
+
"""Get the rule expression (for Jinja2 rules)."""
|
|
127
|
+
...
|
|
128
|
+
|
|
129
|
+
def evaluate(self, context: RouteContext) -> bool:
|
|
130
|
+
"""Evaluate the rule against the context.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
context: Routing context with checkpoint result.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
True if rule matches (actions should execute).
|
|
137
|
+
"""
|
|
138
|
+
...
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class BaseRoutingRule(ABC):
|
|
142
|
+
"""Abstract base class for routing rules.
|
|
143
|
+
|
|
144
|
+
Provides common functionality for all routing rules.
|
|
145
|
+
Subclasses must implement the evaluate method.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def __init__(
|
|
149
|
+
self,
|
|
150
|
+
name: str = "",
|
|
151
|
+
description: str = "",
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Initialize rule.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
name: Rule name.
|
|
157
|
+
description: Rule description.
|
|
158
|
+
"""
|
|
159
|
+
self._name = name or self.__class__.__name__
|
|
160
|
+
self._description = description
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def name(self) -> str:
|
|
164
|
+
"""Get rule name."""
|
|
165
|
+
return self._name
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def expression(self) -> str:
|
|
169
|
+
"""Get the rule expression."""
|
|
170
|
+
return ""
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def description(self) -> str:
|
|
174
|
+
"""Get rule description."""
|
|
175
|
+
return self._description
|
|
176
|
+
|
|
177
|
+
@abstractmethod
|
|
178
|
+
def evaluate(self, context: RouteContext) -> bool:
|
|
179
|
+
"""Evaluate the rule."""
|
|
180
|
+
...
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class Jinja2Rule(BaseRoutingRule):
|
|
184
|
+
"""Jinja2 expression-based routing rule.
|
|
185
|
+
|
|
186
|
+
Evaluates a Jinja2 expression against the routing context.
|
|
187
|
+
The expression should evaluate to a boolean.
|
|
188
|
+
|
|
189
|
+
Example:
|
|
190
|
+
rule = Jinja2Rule("critical_alert", "has_critical or critical_count > 0")
|
|
191
|
+
if rule.evaluate(context):
|
|
192
|
+
# Send critical alert
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
def __init__(
|
|
196
|
+
self,
|
|
197
|
+
name: str,
|
|
198
|
+
expression: str,
|
|
199
|
+
description: str = "",
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Initialize Jinja2 rule.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
name: Rule name.
|
|
205
|
+
expression: Jinja2 expression that evaluates to boolean.
|
|
206
|
+
description: Rule description.
|
|
207
|
+
"""
|
|
208
|
+
super().__init__(name=name, description=description)
|
|
209
|
+
self._expression = expression
|
|
210
|
+
self._compiled: Any | None = None
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def expression(self) -> str:
|
|
214
|
+
"""Get the Jinja2 expression."""
|
|
215
|
+
return self._expression
|
|
216
|
+
|
|
217
|
+
def evaluate(self, context: RouteContext) -> bool:
|
|
218
|
+
"""Evaluate the Jinja2 expression.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
context: Routing context.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
True if expression evaluates to truthy.
|
|
225
|
+
"""
|
|
226
|
+
try:
|
|
227
|
+
from jinja2 import Environment
|
|
228
|
+
except ImportError:
|
|
229
|
+
# Fallback to simple eval for basic expressions
|
|
230
|
+
return self._fallback_evaluate(context)
|
|
231
|
+
|
|
232
|
+
env = Environment()
|
|
233
|
+
template_str = "{{ " + self._expression + " }}"
|
|
234
|
+
template = env.from_string(template_str)
|
|
235
|
+
result = template.render(**context.to_template_context())
|
|
236
|
+
return result.lower() in ("true", "1", "yes")
|
|
237
|
+
|
|
238
|
+
def _fallback_evaluate(self, context: RouteContext) -> bool:
|
|
239
|
+
"""Fallback evaluation without Jinja2."""
|
|
240
|
+
ctx = context.to_template_context()
|
|
241
|
+
|
|
242
|
+
# Handle simple expressions
|
|
243
|
+
expr = self._expression.strip()
|
|
244
|
+
|
|
245
|
+
# Direct variable lookup
|
|
246
|
+
if expr in ctx:
|
|
247
|
+
return bool(ctx[expr])
|
|
248
|
+
|
|
249
|
+
# Simple comparisons
|
|
250
|
+
for op in [" > ", " >= ", " < ", " <= ", " == ", " != "]:
|
|
251
|
+
if op in expr:
|
|
252
|
+
left, right = expr.split(op, 1)
|
|
253
|
+
left_val = ctx.get(left.strip(), left.strip())
|
|
254
|
+
right_val = ctx.get(right.strip(), right.strip())
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
left_val = float(left_val) if not isinstance(left_val, bool) else left_val
|
|
258
|
+
right_val = float(right_val) if not isinstance(right_val, bool) else right_val
|
|
259
|
+
except (ValueError, TypeError):
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
if op == " > ":
|
|
263
|
+
return left_val > right_val
|
|
264
|
+
elif op == " >= ":
|
|
265
|
+
return left_val >= right_val
|
|
266
|
+
elif op == " < ":
|
|
267
|
+
return left_val < right_val
|
|
268
|
+
elif op == " <= ":
|
|
269
|
+
return left_val <= right_val
|
|
270
|
+
elif op == " == ":
|
|
271
|
+
return left_val == right_val
|
|
272
|
+
elif op == " != ":
|
|
273
|
+
return left_val != right_val
|
|
274
|
+
|
|
275
|
+
# Boolean operations
|
|
276
|
+
if " or " in expr:
|
|
277
|
+
parts = expr.split(" or ")
|
|
278
|
+
return any(bool(ctx.get(p.strip(), False)) for p in parts)
|
|
279
|
+
|
|
280
|
+
if " and " in expr:
|
|
281
|
+
parts = expr.split(" and ")
|
|
282
|
+
return all(bool(ctx.get(p.strip(), False)) for p in parts)
|
|
283
|
+
|
|
284
|
+
return False
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class AllOf(BaseRoutingRule):
|
|
288
|
+
"""Compound rule that matches when ALL child rules match.
|
|
289
|
+
|
|
290
|
+
Example:
|
|
291
|
+
rule = AllOf([
|
|
292
|
+
Jinja2Rule("critical", "has_critical"),
|
|
293
|
+
Jinja2Rule("high_count", "high_count > 10"),
|
|
294
|
+
])
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
def __init__(
|
|
298
|
+
self,
|
|
299
|
+
rules: list[BaseRoutingRule],
|
|
300
|
+
name: str = "",
|
|
301
|
+
description: str = "",
|
|
302
|
+
) -> None:
|
|
303
|
+
"""Initialize AllOf rule.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
rules: Child rules that must all match.
|
|
307
|
+
name: Rule name.
|
|
308
|
+
description: Rule description.
|
|
309
|
+
"""
|
|
310
|
+
super().__init__(name=name or "AllOf", description=description)
|
|
311
|
+
self._rules = rules
|
|
312
|
+
|
|
313
|
+
@property
|
|
314
|
+
def rules(self) -> list[BaseRoutingRule]:
|
|
315
|
+
"""Get child rules."""
|
|
316
|
+
return self._rules
|
|
317
|
+
|
|
318
|
+
@property
|
|
319
|
+
def expression(self) -> str:
|
|
320
|
+
"""Get combined expression."""
|
|
321
|
+
exprs = [r.expression for r in self._rules if r.expression]
|
|
322
|
+
return " and ".join(f"({e})" for e in exprs)
|
|
323
|
+
|
|
324
|
+
def evaluate(self, context: RouteContext) -> bool:
|
|
325
|
+
"""Evaluate all child rules.
|
|
326
|
+
|
|
327
|
+
Returns True only if all rules match.
|
|
328
|
+
"""
|
|
329
|
+
return all(rule.evaluate(context) for rule in self._rules)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class AnyOf(BaseRoutingRule):
|
|
333
|
+
"""Compound rule that matches when ANY child rule matches.
|
|
334
|
+
|
|
335
|
+
Example:
|
|
336
|
+
rule = AnyOf([
|
|
337
|
+
Jinja2Rule("critical", "has_critical"),
|
|
338
|
+
Jinja2Rule("error", "status == 'error'"),
|
|
339
|
+
])
|
|
340
|
+
"""
|
|
341
|
+
|
|
342
|
+
def __init__(
|
|
343
|
+
self,
|
|
344
|
+
rules: list[BaseRoutingRule],
|
|
345
|
+
name: str = "",
|
|
346
|
+
description: str = "",
|
|
347
|
+
) -> None:
|
|
348
|
+
"""Initialize AnyOf rule.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
rules: Child rules (any one can match).
|
|
352
|
+
name: Rule name.
|
|
353
|
+
description: Rule description.
|
|
354
|
+
"""
|
|
355
|
+
super().__init__(name=name or "AnyOf", description=description)
|
|
356
|
+
self._rules = rules
|
|
357
|
+
|
|
358
|
+
@property
|
|
359
|
+
def rules(self) -> list[BaseRoutingRule]:
|
|
360
|
+
"""Get child rules."""
|
|
361
|
+
return self._rules
|
|
362
|
+
|
|
363
|
+
@property
|
|
364
|
+
def expression(self) -> str:
|
|
365
|
+
"""Get combined expression."""
|
|
366
|
+
exprs = [r.expression for r in self._rules if r.expression]
|
|
367
|
+
return " or ".join(f"({e})" for e in exprs)
|
|
368
|
+
|
|
369
|
+
def evaluate(self, context: RouteContext) -> bool:
|
|
370
|
+
"""Evaluate all child rules.
|
|
371
|
+
|
|
372
|
+
Returns True if any rule matches.
|
|
373
|
+
"""
|
|
374
|
+
return any(rule.evaluate(context) for rule in self._rules)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
class NotRule(BaseRoutingRule):
|
|
378
|
+
"""Compound rule that inverts another rule.
|
|
379
|
+
|
|
380
|
+
Example:
|
|
381
|
+
rule = NotRule(Jinja2Rule("success", "passed"))
|
|
382
|
+
# Matches when validation did NOT pass
|
|
383
|
+
"""
|
|
384
|
+
|
|
385
|
+
def __init__(
|
|
386
|
+
self,
|
|
387
|
+
rule: BaseRoutingRule,
|
|
388
|
+
name: str = "",
|
|
389
|
+
description: str = "",
|
|
390
|
+
) -> None:
|
|
391
|
+
"""Initialize Not rule.
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
rule: Rule to invert.
|
|
395
|
+
name: Rule name.
|
|
396
|
+
description: Rule description.
|
|
397
|
+
"""
|
|
398
|
+
super().__init__(name=name or "Not", description=description)
|
|
399
|
+
self._rule = rule
|
|
400
|
+
|
|
401
|
+
@property
|
|
402
|
+
def rule(self) -> BaseRoutingRule:
|
|
403
|
+
"""Get the inverted rule."""
|
|
404
|
+
return self._rule
|
|
405
|
+
|
|
406
|
+
@property
|
|
407
|
+
def expression(self) -> str:
|
|
408
|
+
"""Get negated expression."""
|
|
409
|
+
return f"not ({self._rule.expression})"
|
|
410
|
+
|
|
411
|
+
def evaluate(self, context: RouteContext) -> bool:
|
|
412
|
+
"""Evaluate the inverted rule.
|
|
413
|
+
|
|
414
|
+
Returns True if the child rule does NOT match.
|
|
415
|
+
"""
|
|
416
|
+
return not self._rule.evaluate(context)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
class AlwaysRule(BaseRoutingRule):
|
|
420
|
+
"""Rule that always matches."""
|
|
421
|
+
|
|
422
|
+
def __init__(self, name: str = "always") -> None:
|
|
423
|
+
super().__init__(name=name, description="Always matches")
|
|
424
|
+
|
|
425
|
+
@property
|
|
426
|
+
def expression(self) -> str:
|
|
427
|
+
return "True"
|
|
428
|
+
|
|
429
|
+
def evaluate(self, context: RouteContext) -> bool:
|
|
430
|
+
return True
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
class NeverRule(BaseRoutingRule):
|
|
434
|
+
"""Rule that never matches."""
|
|
435
|
+
|
|
436
|
+
def __init__(self, name: str = "never") -> None:
|
|
437
|
+
super().__init__(name=name, description="Never matches")
|
|
438
|
+
|
|
439
|
+
@property
|
|
440
|
+
def expression(self) -> str:
|
|
441
|
+
return "False"
|
|
442
|
+
|
|
443
|
+
def evaluate(self, context: RouteContext) -> bool:
|
|
444
|
+
return False
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# =============================================================================
|
|
448
|
+
# Route Definition
|
|
449
|
+
# =============================================================================
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
@dataclass
|
|
453
|
+
class Route:
|
|
454
|
+
"""A route defines a rule and its associated actions.
|
|
455
|
+
|
|
456
|
+
When the rule matches, the actions are executed.
|
|
457
|
+
|
|
458
|
+
Attributes:
|
|
459
|
+
name: Route name for identification.
|
|
460
|
+
rule: Routing rule to evaluate.
|
|
461
|
+
actions: Actions to execute when rule matches.
|
|
462
|
+
priority: Priority for route ordering.
|
|
463
|
+
enabled: Whether this route is enabled.
|
|
464
|
+
metadata: Additional metadata.
|
|
465
|
+
stop_on_match: Stop evaluating other routes after this one matches.
|
|
466
|
+
"""
|
|
467
|
+
|
|
468
|
+
name: str
|
|
469
|
+
rule: BaseRoutingRule
|
|
470
|
+
actions: list[str] # Action names
|
|
471
|
+
priority: RoutePriority = RoutePriority.DEFAULT
|
|
472
|
+
enabled: bool = True
|
|
473
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
474
|
+
stop_on_match: bool = False
|
|
475
|
+
|
|
476
|
+
def evaluate(self, context: RouteContext) -> bool:
|
|
477
|
+
"""Evaluate the route's rule.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
context: Routing context.
|
|
481
|
+
|
|
482
|
+
Returns:
|
|
483
|
+
True if route matches.
|
|
484
|
+
"""
|
|
485
|
+
if not self.enabled:
|
|
486
|
+
return False
|
|
487
|
+
return self.rule.evaluate(context)
|
|
488
|
+
|
|
489
|
+
def to_dict(self) -> dict[str, Any]:
|
|
490
|
+
"""Convert to dictionary."""
|
|
491
|
+
return {
|
|
492
|
+
"name": self.name,
|
|
493
|
+
"rule_expression": self.rule.expression,
|
|
494
|
+
"actions": self.actions,
|
|
495
|
+
"priority": self.priority.value,
|
|
496
|
+
"enabled": self.enabled,
|
|
497
|
+
"metadata": self.metadata,
|
|
498
|
+
"stop_on_match": self.stop_on_match,
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
# =============================================================================
|
|
503
|
+
# Router Protocol
|
|
504
|
+
# =============================================================================
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
@runtime_checkable
|
|
508
|
+
class RouterProtocol(Protocol):
|
|
509
|
+
"""Protocol for router implementations.
|
|
510
|
+
|
|
511
|
+
Routers evaluate routes and determine which actions to execute.
|
|
512
|
+
|
|
513
|
+
Example:
|
|
514
|
+
router = Router(mode=RouteMode.FIRST_MATCH)
|
|
515
|
+
router.add_route(Route(
|
|
516
|
+
name="critical_alert",
|
|
517
|
+
rule=Jinja2Rule("critical", "has_critical"),
|
|
518
|
+
actions=["pagerduty", "slack_critical"],
|
|
519
|
+
))
|
|
520
|
+
|
|
521
|
+
matched_actions = router.route(context)
|
|
522
|
+
"""
|
|
523
|
+
|
|
524
|
+
@property
|
|
525
|
+
def mode(self) -> RouteMode:
|
|
526
|
+
"""Get routing mode."""
|
|
527
|
+
...
|
|
528
|
+
|
|
529
|
+
@property
|
|
530
|
+
def routes(self) -> list[Route]:
|
|
531
|
+
"""Get all routes."""
|
|
532
|
+
...
|
|
533
|
+
|
|
534
|
+
def add_route(self, route: Route) -> None:
|
|
535
|
+
"""Add a route to the router.
|
|
536
|
+
|
|
537
|
+
Args:
|
|
538
|
+
route: Route to add.
|
|
539
|
+
"""
|
|
540
|
+
...
|
|
541
|
+
|
|
542
|
+
def remove_route(self, name: str) -> bool:
|
|
543
|
+
"""Remove a route by name.
|
|
544
|
+
|
|
545
|
+
Args:
|
|
546
|
+
name: Route name.
|
|
547
|
+
|
|
548
|
+
Returns:
|
|
549
|
+
True if route was removed.
|
|
550
|
+
"""
|
|
551
|
+
...
|
|
552
|
+
|
|
553
|
+
def route(self, context: RouteContext) -> list[str]:
|
|
554
|
+
"""Evaluate routes and return matching action names.
|
|
555
|
+
|
|
556
|
+
Args:
|
|
557
|
+
context: Routing context.
|
|
558
|
+
|
|
559
|
+
Returns:
|
|
560
|
+
List of action names to execute.
|
|
561
|
+
"""
|
|
562
|
+
...
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
class Router:
|
|
566
|
+
"""Default router implementation.
|
|
567
|
+
|
|
568
|
+
Evaluates routes based on the configured mode and returns
|
|
569
|
+
the actions that should be executed.
|
|
570
|
+
"""
|
|
571
|
+
|
|
572
|
+
def __init__(
|
|
573
|
+
self,
|
|
574
|
+
mode: RouteMode = RouteMode.ALL_MATCHES,
|
|
575
|
+
routes: list[Route] | None = None,
|
|
576
|
+
) -> None:
|
|
577
|
+
"""Initialize router.
|
|
578
|
+
|
|
579
|
+
Args:
|
|
580
|
+
mode: Routing mode.
|
|
581
|
+
routes: Initial routes.
|
|
582
|
+
"""
|
|
583
|
+
self._mode = mode
|
|
584
|
+
self._routes: list[Route] = routes or []
|
|
585
|
+
|
|
586
|
+
@property
|
|
587
|
+
def mode(self) -> RouteMode:
|
|
588
|
+
"""Get routing mode."""
|
|
589
|
+
return self._mode
|
|
590
|
+
|
|
591
|
+
@property
|
|
592
|
+
def routes(self) -> list[Route]:
|
|
593
|
+
"""Get all routes."""
|
|
594
|
+
return self._routes.copy()
|
|
595
|
+
|
|
596
|
+
def add_route(self, route: Route) -> None:
|
|
597
|
+
"""Add a route."""
|
|
598
|
+
self._routes.append(route)
|
|
599
|
+
# Re-sort by priority if in priority mode
|
|
600
|
+
if self._mode == RouteMode.PRIORITY:
|
|
601
|
+
self._routes.sort(key=lambda r: r.priority.value, reverse=True)
|
|
602
|
+
|
|
603
|
+
def remove_route(self, name: str) -> bool:
|
|
604
|
+
"""Remove a route by name."""
|
|
605
|
+
for i, route in enumerate(self._routes):
|
|
606
|
+
if route.name == name:
|
|
607
|
+
del self._routes[i]
|
|
608
|
+
return True
|
|
609
|
+
return False
|
|
610
|
+
|
|
611
|
+
def route(self, context: RouteContext) -> list[str]:
|
|
612
|
+
"""Evaluate routes and return matching action names.
|
|
613
|
+
|
|
614
|
+
Args:
|
|
615
|
+
context: Routing context.
|
|
616
|
+
|
|
617
|
+
Returns:
|
|
618
|
+
List of unique action names to execute.
|
|
619
|
+
"""
|
|
620
|
+
actions: list[str] = []
|
|
621
|
+
seen: set[str] = set()
|
|
622
|
+
|
|
623
|
+
# Sort routes if in priority mode
|
|
624
|
+
routes = self._routes
|
|
625
|
+
if self._mode == RouteMode.PRIORITY:
|
|
626
|
+
routes = sorted(routes, key=lambda r: r.priority.value, reverse=True)
|
|
627
|
+
|
|
628
|
+
for route in routes:
|
|
629
|
+
if route.evaluate(context):
|
|
630
|
+
for action in route.actions:
|
|
631
|
+
if action not in seen:
|
|
632
|
+
actions.append(action)
|
|
633
|
+
seen.add(action)
|
|
634
|
+
|
|
635
|
+
# Stop if configured or first match mode
|
|
636
|
+
if route.stop_on_match or self._mode == RouteMode.FIRST_MATCH:
|
|
637
|
+
break
|
|
638
|
+
|
|
639
|
+
return actions
|
|
640
|
+
|
|
641
|
+
def to_dict(self) -> dict[str, Any]:
|
|
642
|
+
"""Convert to dictionary."""
|
|
643
|
+
return {
|
|
644
|
+
"mode": self._mode.value,
|
|
645
|
+
"routes": [r.to_dict() for r in self._routes],
|
|
646
|
+
}
|