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