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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. truthound_dashboard/api/alerts.py +258 -0
  2. truthound_dashboard/api/anomaly.py +1302 -0
  3. truthound_dashboard/api/cross_alerts.py +352 -0
  4. truthound_dashboard/api/deps.py +143 -0
  5. truthound_dashboard/api/drift_monitor.py +540 -0
  6. truthound_dashboard/api/lineage.py +1151 -0
  7. truthound_dashboard/api/maintenance.py +363 -0
  8. truthound_dashboard/api/middleware.py +373 -1
  9. truthound_dashboard/api/model_monitoring.py +805 -0
  10. truthound_dashboard/api/notifications_advanced.py +2452 -0
  11. truthound_dashboard/api/plugins.py +2096 -0
  12. truthound_dashboard/api/profile.py +211 -14
  13. truthound_dashboard/api/reports.py +853 -0
  14. truthound_dashboard/api/router.py +147 -0
  15. truthound_dashboard/api/rule_suggestions.py +310 -0
  16. truthound_dashboard/api/schema_evolution.py +231 -0
  17. truthound_dashboard/api/sources.py +47 -3
  18. truthound_dashboard/api/triggers.py +190 -0
  19. truthound_dashboard/api/validations.py +13 -0
  20. truthound_dashboard/api/validators.py +333 -4
  21. truthound_dashboard/api/versioning.py +309 -0
  22. truthound_dashboard/api/websocket.py +301 -0
  23. truthound_dashboard/core/__init__.py +27 -0
  24. truthound_dashboard/core/anomaly.py +1395 -0
  25. truthound_dashboard/core/anomaly_explainer.py +633 -0
  26. truthound_dashboard/core/cache.py +206 -0
  27. truthound_dashboard/core/cached_services.py +422 -0
  28. truthound_dashboard/core/charts.py +352 -0
  29. truthound_dashboard/core/connections.py +1069 -42
  30. truthound_dashboard/core/cross_alerts.py +837 -0
  31. truthound_dashboard/core/drift_monitor.py +1477 -0
  32. truthound_dashboard/core/drift_sampling.py +669 -0
  33. truthound_dashboard/core/i18n/__init__.py +42 -0
  34. truthound_dashboard/core/i18n/detector.py +173 -0
  35. truthound_dashboard/core/i18n/messages.py +564 -0
  36. truthound_dashboard/core/lineage.py +971 -0
  37. truthound_dashboard/core/maintenance.py +443 -5
  38. truthound_dashboard/core/model_monitoring.py +1043 -0
  39. truthound_dashboard/core/notifications/channels.py +1020 -1
  40. truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
  41. truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
  42. truthound_dashboard/core/notifications/deduplication/service.py +400 -0
  43. truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
  44. truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
  45. truthound_dashboard/core/notifications/dispatcher.py +43 -0
  46. truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
  47. truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
  48. truthound_dashboard/core/notifications/escalation/engine.py +429 -0
  49. truthound_dashboard/core/notifications/escalation/models.py +336 -0
  50. truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
  51. truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
  52. truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
  53. truthound_dashboard/core/notifications/events.py +49 -0
  54. truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
  55. truthound_dashboard/core/notifications/metrics/base.py +528 -0
  56. truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
  57. truthound_dashboard/core/notifications/routing/__init__.py +169 -0
  58. truthound_dashboard/core/notifications/routing/combinators.py +184 -0
  59. truthound_dashboard/core/notifications/routing/config.py +375 -0
  60. truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
  61. truthound_dashboard/core/notifications/routing/engine.py +382 -0
  62. truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
  63. truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
  64. truthound_dashboard/core/notifications/routing/rules.py +625 -0
  65. truthound_dashboard/core/notifications/routing/validator.py +678 -0
  66. truthound_dashboard/core/notifications/service.py +2 -0
  67. truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
  68. truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
  69. truthound_dashboard/core/notifications/throttling/builder.py +311 -0
  70. truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
  71. truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
  72. truthound_dashboard/core/openlineage.py +1028 -0
  73. truthound_dashboard/core/plugins/__init__.py +39 -0
  74. truthound_dashboard/core/plugins/docs/__init__.py +39 -0
  75. truthound_dashboard/core/plugins/docs/extractor.py +703 -0
  76. truthound_dashboard/core/plugins/docs/renderers.py +804 -0
  77. truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
  78. truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
  79. truthound_dashboard/core/plugins/hooks/manager.py +403 -0
  80. truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
  81. truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
  82. truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
  83. truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
  84. truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
  85. truthound_dashboard/core/plugins/loader.py +504 -0
  86. truthound_dashboard/core/plugins/registry.py +810 -0
  87. truthound_dashboard/core/plugins/reporter_executor.py +588 -0
  88. truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
  89. truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
  90. truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
  91. truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
  92. truthound_dashboard/core/plugins/sandbox.py +617 -0
  93. truthound_dashboard/core/plugins/security/__init__.py +68 -0
  94. truthound_dashboard/core/plugins/security/analyzer.py +535 -0
  95. truthound_dashboard/core/plugins/security/policies.py +311 -0
  96. truthound_dashboard/core/plugins/security/protocols.py +296 -0
  97. truthound_dashboard/core/plugins/security/signing.py +842 -0
  98. truthound_dashboard/core/plugins/security.py +446 -0
  99. truthound_dashboard/core/plugins/validator_executor.py +401 -0
  100. truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
  101. truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
  102. truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
  103. truthound_dashboard/core/plugins/versioning/semver.py +266 -0
  104. truthound_dashboard/core/profile_comparison.py +601 -0
  105. truthound_dashboard/core/report_history.py +570 -0
  106. truthound_dashboard/core/reporters/__init__.py +57 -0
  107. truthound_dashboard/core/reporters/base.py +296 -0
  108. truthound_dashboard/core/reporters/csv_reporter.py +155 -0
  109. truthound_dashboard/core/reporters/html_reporter.py +598 -0
  110. truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
  111. truthound_dashboard/core/reporters/i18n/base.py +494 -0
  112. truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
  113. truthound_dashboard/core/reporters/json_reporter.py +160 -0
  114. truthound_dashboard/core/reporters/junit_reporter.py +233 -0
  115. truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
  116. truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
  117. truthound_dashboard/core/reporters/registry.py +272 -0
  118. truthound_dashboard/core/rule_generator.py +2088 -0
  119. truthound_dashboard/core/scheduler.py +822 -12
  120. truthound_dashboard/core/schema_evolution.py +858 -0
  121. truthound_dashboard/core/services.py +152 -9
  122. truthound_dashboard/core/statistics.py +718 -0
  123. truthound_dashboard/core/streaming_anomaly.py +883 -0
  124. truthound_dashboard/core/triggers/__init__.py +45 -0
  125. truthound_dashboard/core/triggers/base.py +226 -0
  126. truthound_dashboard/core/triggers/evaluators.py +609 -0
  127. truthound_dashboard/core/triggers/factory.py +363 -0
  128. truthound_dashboard/core/unified_alerts.py +870 -0
  129. truthound_dashboard/core/validation_limits.py +509 -0
  130. truthound_dashboard/core/versioning.py +709 -0
  131. truthound_dashboard/core/websocket/__init__.py +59 -0
  132. truthound_dashboard/core/websocket/manager.py +512 -0
  133. truthound_dashboard/core/websocket/messages.py +130 -0
  134. truthound_dashboard/db/__init__.py +30 -0
  135. truthound_dashboard/db/models.py +3375 -3
  136. truthound_dashboard/main.py +22 -0
  137. truthound_dashboard/schemas/__init__.py +396 -1
  138. truthound_dashboard/schemas/anomaly.py +1258 -0
  139. truthound_dashboard/schemas/base.py +4 -0
  140. truthound_dashboard/schemas/cross_alerts.py +334 -0
  141. truthound_dashboard/schemas/drift_monitor.py +890 -0
  142. truthound_dashboard/schemas/lineage.py +428 -0
  143. truthound_dashboard/schemas/maintenance.py +154 -0
  144. truthound_dashboard/schemas/model_monitoring.py +374 -0
  145. truthound_dashboard/schemas/notifications_advanced.py +1363 -0
  146. truthound_dashboard/schemas/openlineage.py +704 -0
  147. truthound_dashboard/schemas/plugins.py +1293 -0
  148. truthound_dashboard/schemas/profile.py +420 -34
  149. truthound_dashboard/schemas/profile_comparison.py +242 -0
  150. truthound_dashboard/schemas/reports.py +285 -0
  151. truthound_dashboard/schemas/rule_suggestion.py +434 -0
  152. truthound_dashboard/schemas/schema_evolution.py +164 -0
  153. truthound_dashboard/schemas/source.py +117 -2
  154. truthound_dashboard/schemas/triggers.py +511 -0
  155. truthound_dashboard/schemas/unified_alerts.py +223 -0
  156. truthound_dashboard/schemas/validation.py +25 -1
  157. truthound_dashboard/schemas/validators/__init__.py +11 -0
  158. truthound_dashboard/schemas/validators/base.py +151 -0
  159. truthound_dashboard/schemas/versioning.py +152 -0
  160. truthound_dashboard/static/index.html +2 -2
  161. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/METADATA +147 -23
  162. truthound_dashboard-1.4.1.dist-info/RECORD +239 -0
  163. truthound_dashboard/static/assets/index-BZG20KuF.js +0 -586
  164. truthound_dashboard/static/assets/index-D_HyZ3pb.css +0 -1
  165. truthound_dashboard/static/assets/unmerged_dictionaries-CtpqQBm0.js +0 -1
  166. truthound_dashboard-1.3.1.dist-info/RECORD +0 -110
  167. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/WHEEL +0 -0
  168. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/entry_points.txt +0 -0
  169. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,867 @@
1
+ """YAML/JSON configuration parser for routing rules.
2
+
3
+ This module provides comprehensive configuration parsing for creating
4
+ routes and rules from YAML/JSON files or programmatic builders.
5
+
6
+ Features:
7
+ - RouteConfig and RoutingConfig dataclasses for structured configuration
8
+ - ConfigParser for parsing YAML/JSON configurations
9
+ - ConfigBuilder for programmatic configuration creation
10
+ - Nested rule support with combinators (all_of, any_of, not)
11
+ - Validation against RuleRegistry
12
+
13
+ Example YAML config:
14
+ version: "1.0"
15
+ defaults:
16
+ priority: 0
17
+ stop_on_match: false
18
+ routes:
19
+ - name: critical_alerts
20
+ rules:
21
+ - type: any_of
22
+ rules:
23
+ - type: severity
24
+ params:
25
+ min_level: critical
26
+ - type: all_of
27
+ rules:
28
+ - type: issue_count
29
+ params:
30
+ min_count: 10
31
+ - type: tag
32
+ params:
33
+ tags: ["production"]
34
+ actions: ["pagerduty", "slack-critical"]
35
+ priority: 100
36
+
37
+ Example usage:
38
+ # Parse from file
39
+ config = ConfigParser.parse_file(Path("routes.yaml"))
40
+
41
+ # Build programmatically
42
+ config = (
43
+ ConfigBuilder()
44
+ .with_defaults(priority=0)
45
+ .add_route(
46
+ name="critical",
47
+ rules=[{"type": "severity", "params": {"min_severity": "critical"}}],
48
+ actions=["pagerduty"],
49
+ priority=100,
50
+ )
51
+ .build()
52
+ )
53
+
54
+ # Export to YAML/JSON
55
+ yaml_str = ConfigParser.to_yaml(config)
56
+ json_str = ConfigParser.to_json(config)
57
+
58
+ # Validate configuration
59
+ errors = ConfigParser.validate(config)
60
+ """
61
+
62
+ from __future__ import annotations
63
+
64
+ import json
65
+ from dataclasses import dataclass, field
66
+ from pathlib import Path
67
+ from typing import TYPE_CHECKING, Any, Self
68
+
69
+ if TYPE_CHECKING:
70
+ from .engine import ActionRouter
71
+
72
+
73
+ @dataclass
74
+ class RouteConfig:
75
+ """Configuration for a single routing rule.
76
+
77
+ Attributes:
78
+ name: Unique route identifier.
79
+ rules: List of rule configurations (can be nested for combinators).
80
+ actions: List of action/channel identifiers to trigger on match.
81
+ priority: Route evaluation priority (higher = evaluated first).
82
+ stop_on_match: If True, stop evaluating lower priority routes on match.
83
+ metadata: Additional route metadata.
84
+ """
85
+
86
+ name: str
87
+ rules: list[dict[str, Any]]
88
+ actions: list[str]
89
+ priority: int = 0
90
+ stop_on_match: bool = False
91
+ metadata: dict[str, Any] = field(default_factory=dict)
92
+
93
+ def to_dict(self) -> dict[str, Any]:
94
+ """Serialize route configuration to dictionary."""
95
+ return {
96
+ "name": self.name,
97
+ "rules": self.rules,
98
+ "actions": self.actions,
99
+ "priority": self.priority,
100
+ "stop_on_match": self.stop_on_match,
101
+ "metadata": self.metadata,
102
+ }
103
+
104
+ @classmethod
105
+ def from_dict(cls, data: dict[str, Any], defaults: dict[str, Any] | None = None) -> RouteConfig:
106
+ """Create RouteConfig from dictionary.
107
+
108
+ Args:
109
+ data: Route configuration dictionary.
110
+ defaults: Optional default values to apply.
111
+
112
+ Returns:
113
+ RouteConfig instance.
114
+ """
115
+ defaults = defaults or {}
116
+
117
+ # Handle single rule vs rules list
118
+ rules = data.get("rules", [])
119
+ if "rule" in data and not rules:
120
+ # Support single rule format for backward compatibility
121
+ rules = [data["rule"]]
122
+
123
+ return cls(
124
+ name=data.get("name", "unnamed"),
125
+ rules=rules,
126
+ actions=data.get("actions", []),
127
+ priority=data.get("priority", defaults.get("priority", 0)),
128
+ stop_on_match=data.get("stop_on_match", defaults.get("stop_on_match", False)),
129
+ metadata=data.get("metadata", defaults.get("metadata", {})),
130
+ )
131
+
132
+
133
+ @dataclass
134
+ class RoutingConfig:
135
+ """Full routing configuration.
136
+
137
+ Attributes:
138
+ version: Configuration version string.
139
+ routes: List of route configurations.
140
+ defaults: Default values applied to routes.
141
+ """
142
+
143
+ version: str = "1.0"
144
+ routes: list[RouteConfig] = field(default_factory=list)
145
+ defaults: dict[str, Any] = field(default_factory=dict)
146
+
147
+ def to_dict(self) -> dict[str, Any]:
148
+ """Serialize routing configuration to dictionary."""
149
+ return {
150
+ "version": self.version,
151
+ "defaults": self.defaults,
152
+ "routes": [route.to_dict() for route in self.routes],
153
+ }
154
+
155
+ @classmethod
156
+ def from_dict(cls, data: dict[str, Any]) -> RoutingConfig:
157
+ """Create RoutingConfig from dictionary.
158
+
159
+ Args:
160
+ data: Configuration dictionary.
161
+
162
+ Returns:
163
+ RoutingConfig instance.
164
+ """
165
+ version = data.get("version", "1.0")
166
+ defaults = data.get("defaults", {})
167
+ routes_data = data.get("routes", [])
168
+
169
+ routes = [RouteConfig.from_dict(route_data, defaults) for route_data in routes_data]
170
+
171
+ return cls(
172
+ version=version,
173
+ routes=routes,
174
+ defaults=defaults,
175
+ )
176
+
177
+
178
+ class ConfigParser:
179
+ """Parser for YAML/JSON routing configurations.
180
+
181
+ Provides methods for parsing, serializing, and validating
182
+ routing configurations from various formats.
183
+
184
+ Example:
185
+ # Parse from YAML string
186
+ config = ConfigParser.parse_yaml(yaml_content)
187
+
188
+ # Parse from JSON string
189
+ config = ConfigParser.parse_json(json_content)
190
+
191
+ # Parse from file (auto-detect format)
192
+ config = ConfigParser.parse_file(Path("routes.yaml"))
193
+
194
+ # Export to YAML
195
+ yaml_str = ConfigParser.to_yaml(config)
196
+
197
+ # Validate configuration
198
+ errors = ConfigParser.validate(config)
199
+ """
200
+
201
+ @classmethod
202
+ def parse_yaml(cls, yaml_content: str) -> RoutingConfig:
203
+ """Parse routing configuration from YAML string.
204
+
205
+ Args:
206
+ yaml_content: YAML configuration string.
207
+
208
+ Returns:
209
+ Parsed RoutingConfig.
210
+
211
+ Raises:
212
+ ImportError: If PyYAML is not installed.
213
+ ValueError: If YAML is invalid.
214
+ """
215
+ try:
216
+ import yaml
217
+ except ImportError as err:
218
+ raise ImportError(
219
+ "PyYAML is required for YAML config parsing. "
220
+ "Install with: pip install pyyaml"
221
+ ) from err
222
+
223
+ try:
224
+ data = yaml.safe_load(yaml_content)
225
+ except yaml.YAMLError as e:
226
+ raise ValueError(f"Invalid YAML: {e}") from e
227
+
228
+ if not isinstance(data, dict):
229
+ raise ValueError("YAML content must be a mapping")
230
+
231
+ return RoutingConfig.from_dict(data)
232
+
233
+ @classmethod
234
+ def parse_json(cls, json_content: str) -> RoutingConfig:
235
+ """Parse routing configuration from JSON string.
236
+
237
+ Args:
238
+ json_content: JSON configuration string.
239
+
240
+ Returns:
241
+ Parsed RoutingConfig.
242
+
243
+ Raises:
244
+ ValueError: If JSON is invalid.
245
+ """
246
+ try:
247
+ data = json.loads(json_content)
248
+ except json.JSONDecodeError as e:
249
+ raise ValueError(f"Invalid JSON: {e}") from e
250
+
251
+ if not isinstance(data, dict):
252
+ raise ValueError("JSON content must be an object")
253
+
254
+ return RoutingConfig.from_dict(data)
255
+
256
+ @classmethod
257
+ def parse_file(cls, file_path: Path | str) -> RoutingConfig:
258
+ """Parse routing configuration from file.
259
+
260
+ Automatically detects format based on file extension.
261
+
262
+ Args:
263
+ file_path: Path to configuration file (.yaml, .yml, or .json).
264
+
265
+ Returns:
266
+ Parsed RoutingConfig.
267
+
268
+ Raises:
269
+ FileNotFoundError: If file doesn't exist.
270
+ ValueError: If file format is unsupported.
271
+ """
272
+ path = Path(file_path)
273
+
274
+ if not path.exists():
275
+ raise FileNotFoundError(f"Configuration file not found: {path}")
276
+
277
+ content = path.read_text(encoding="utf-8")
278
+
279
+ if path.suffix in (".yaml", ".yml"):
280
+ return cls.parse_yaml(content)
281
+ elif path.suffix == ".json":
282
+ return cls.parse_json(content)
283
+ else:
284
+ raise ValueError(f"Unsupported file format: {path.suffix}")
285
+
286
+ @classmethod
287
+ def to_yaml(cls, config: RoutingConfig) -> str:
288
+ """Export routing configuration to YAML string.
289
+
290
+ Args:
291
+ config: RoutingConfig to export.
292
+
293
+ Returns:
294
+ YAML configuration string.
295
+
296
+ Raises:
297
+ ImportError: If PyYAML is not installed.
298
+ """
299
+ try:
300
+ import yaml
301
+ except ImportError as err:
302
+ raise ImportError(
303
+ "PyYAML is required for YAML config export. "
304
+ "Install with: pip install pyyaml"
305
+ ) from err
306
+
307
+ data = config.to_dict()
308
+ return yaml.dump(data, default_flow_style=False, sort_keys=False, allow_unicode=True)
309
+
310
+ @classmethod
311
+ def to_json(cls, config: RoutingConfig, indent: int = 2) -> str:
312
+ """Export routing configuration to JSON string.
313
+
314
+ Args:
315
+ config: RoutingConfig to export.
316
+ indent: JSON indentation level.
317
+
318
+ Returns:
319
+ JSON configuration string.
320
+ """
321
+ data = config.to_dict()
322
+ return json.dumps(data, indent=indent, ensure_ascii=False)
323
+
324
+ @classmethod
325
+ def validate(
326
+ cls,
327
+ config: RoutingConfig,
328
+ max_depth: int = 10,
329
+ max_rules_per_combinator: int = 50,
330
+ check_circular_refs: bool = True,
331
+ ) -> list[str]:
332
+ """Validate routing configuration.
333
+
334
+ Checks:
335
+ - Route names are unique
336
+ - Rule types exist in RuleRegistry
337
+ - Required fields are present
338
+ - Actions are not empty
339
+ - Circular references in rules
340
+ - Maximum nesting depth
341
+ - Maximum rules per combinator
342
+ - Reserved field names
343
+
344
+ Args:
345
+ config: RoutingConfig to validate.
346
+ max_depth: Maximum nesting depth for rules (default: 10).
347
+ max_rules_per_combinator: Maximum rules per combinator (default: 50).
348
+ check_circular_refs: Whether to check for circular references (default: True).
349
+
350
+ Returns:
351
+ List of validation error messages (empty if valid).
352
+ """
353
+ from .rules import RuleRegistry
354
+ from .validator import RuleValidationConfig, RuleValidator
355
+
356
+ errors: list[str] = []
357
+
358
+ # Check version
359
+ if not config.version:
360
+ errors.append("Missing 'version' field")
361
+
362
+ # Check for duplicate route names
363
+ route_names: set[str] = set()
364
+ for i, route in enumerate(config.routes):
365
+ if route.name in route_names:
366
+ errors.append(f"Route {i}: Duplicate route name '{route.name}'")
367
+ route_names.add(route.name)
368
+
369
+ # Validate each route structure
370
+ route_errors = cls._validate_route(route, i, RuleRegistry)
371
+ errors.extend(route_errors)
372
+
373
+ # Use RuleValidator for comprehensive validation of each rule
374
+ if route.rules:
375
+ validation_config = RuleValidationConfig(
376
+ max_depth=max_depth,
377
+ max_rules_per_combinator=max_rules_per_combinator,
378
+ check_circular_refs=check_circular_refs,
379
+ )
380
+ validator = RuleValidator(validation_config)
381
+
382
+ for j, rule_data in enumerate(route.rules):
383
+ result = validator.validate(rule_data)
384
+ if not result.valid:
385
+ for error in result.errors:
386
+ path_prefix = f"Route {i} ('{route.name}')/rules[{j}]"
387
+ if error.path:
388
+ errors.append(f"{path_prefix}/{error.path}: {error.message}")
389
+ else:
390
+ errors.append(f"{path_prefix}: {error.message}")
391
+
392
+ return errors
393
+
394
+ @classmethod
395
+ def _validate_route(cls, route: RouteConfig, index: int, registry: type) -> list[str]:
396
+ """Validate a single route configuration.
397
+
398
+ Args:
399
+ route: RouteConfig to validate.
400
+ index: Route index for error messages.
401
+ registry: RuleRegistry class for rule type validation.
402
+
403
+ Returns:
404
+ List of validation errors.
405
+ """
406
+ errors: list[str] = []
407
+ prefix = f"Route {index} ('{route.name}')"
408
+
409
+ # Check required fields
410
+ if not route.name:
411
+ errors.append(f"{prefix}: Missing required field 'name'")
412
+
413
+ if not route.rules:
414
+ errors.append(f"{prefix}: Missing required field 'rules'")
415
+ else:
416
+ # Validate each rule in the rules list
417
+ for rule_idx, rule_data in enumerate(route.rules):
418
+ rule_errors = cls._validate_rule(rule_data, f"{prefix}/rules[{rule_idx}]", registry)
419
+ errors.extend(rule_errors)
420
+
421
+ if not route.actions:
422
+ errors.append(f"{prefix}: Missing required field 'actions'")
423
+ elif not isinstance(route.actions, list):
424
+ errors.append(f"{prefix}: 'actions' must be a list")
425
+
426
+ return errors
427
+
428
+ @classmethod
429
+ def _validate_rule(cls, data: dict[str, Any], prefix: str, registry: type) -> list[str]:
430
+ """Validate a rule configuration recursively.
431
+
432
+ Handles nested rules for combinators (all_of, any_of, not).
433
+
434
+ Args:
435
+ data: Rule configuration dictionary.
436
+ prefix: Error message prefix.
437
+ registry: RuleRegistry class.
438
+
439
+ Returns:
440
+ List of validation errors.
441
+ """
442
+ errors: list[str] = []
443
+
444
+ if not isinstance(data, dict):
445
+ errors.append(f"{prefix}: Rule must be an object")
446
+ return errors
447
+
448
+ rule_type = data.get("type")
449
+ if not rule_type:
450
+ errors.append(f"{prefix}: Rule missing required field 'type'")
451
+ return errors
452
+
453
+ # Check if rule type exists in registry
454
+ registered_types = registry.list_types()
455
+ if rule_type not in registered_types:
456
+ errors.append(f"{prefix}: Unknown rule type '{rule_type}'. Available: {registered_types}")
457
+ return errors
458
+
459
+ # Validate params if present
460
+ params = data.get("params", {})
461
+ if params and not isinstance(params, dict):
462
+ errors.append(f"{prefix}: 'params' must be an object")
463
+
464
+ # Validate nested rules for combinators
465
+ if rule_type in ("all_of", "any_of"):
466
+ nested_rules = data.get("rules", params.get("rules", []))
467
+ if not isinstance(nested_rules, list):
468
+ errors.append(f"{prefix}: '{rule_type}' requires 'rules' to be a list")
469
+ else:
470
+ for i, nested_data in enumerate(nested_rules):
471
+ nested_errors = cls._validate_rule(nested_data, f"{prefix}/{rule_type}[{i}]", registry)
472
+ errors.extend(nested_errors)
473
+
474
+ elif rule_type == "not":
475
+ nested_rule = data.get("rule", params.get("rule"))
476
+ if not nested_rule:
477
+ errors.append(f"{prefix}: 'not' rule requires nested 'rule' field")
478
+ elif isinstance(nested_rule, dict):
479
+ nested_errors = cls._validate_rule(nested_rule, f"{prefix}/not", registry)
480
+ errors.extend(nested_errors)
481
+
482
+ return errors
483
+
484
+ @classmethod
485
+ def to_action_router(cls, config: RoutingConfig) -> "ActionRouter":
486
+ """Convert RoutingConfig to ActionRouter.
487
+
488
+ Converts the parsed configuration into an ActionRouter
489
+ instance ready for event matching.
490
+
491
+ Args:
492
+ config: RoutingConfig to convert.
493
+
494
+ Returns:
495
+ Configured ActionRouter.
496
+ """
497
+ from .combinators import AllOf, AnyOf, NotRule
498
+ from .engine import ActionRouter, Route
499
+ from .rules import BaseRule, RuleRegistry
500
+
501
+ routes: list[Route] = []
502
+
503
+ for route_config in config.routes:
504
+ # Build the rule from rules list
505
+ rule = cls._build_rule_from_list(route_config.rules)
506
+ if rule is None:
507
+ continue
508
+
509
+ route = Route(
510
+ name=route_config.name,
511
+ rule=rule,
512
+ actions=route_config.actions,
513
+ priority=route_config.priority,
514
+ is_active=True,
515
+ stop_on_match=route_config.stop_on_match,
516
+ metadata=route_config.metadata,
517
+ )
518
+ routes.append(route)
519
+
520
+ return ActionRouter(routes=routes)
521
+
522
+ @classmethod
523
+ def _build_rule_from_list(cls, rules_list: list[dict[str, Any]]) -> "BaseRule | None":
524
+ """Build a rule from a list of rule configurations.
525
+
526
+ If multiple rules, wraps them in AllOf combinator.
527
+
528
+ Args:
529
+ rules_list: List of rule configurations.
530
+
531
+ Returns:
532
+ BaseRule instance or None if empty/invalid.
533
+ """
534
+ from .combinators import AllOf
535
+
536
+ if not rules_list:
537
+ return None
538
+
539
+ if len(rules_list) == 1:
540
+ return cls._build_rule(rules_list[0])
541
+
542
+ # Multiple rules -> combine with AllOf
543
+ rules = []
544
+ for rule_data in rules_list:
545
+ rule = cls._build_rule(rule_data)
546
+ if rule:
547
+ rules.append(rule)
548
+
549
+ if not rules:
550
+ return None
551
+
552
+ return AllOf(rules=rules)
553
+
554
+ @classmethod
555
+ def _build_rule(cls, data: dict[str, Any]) -> "BaseRule | None":
556
+ """Build a single rule from configuration.
557
+
558
+ Handles nested rules for combinators.
559
+
560
+ Args:
561
+ data: Rule configuration dictionary.
562
+
563
+ Returns:
564
+ BaseRule instance or None if invalid.
565
+ """
566
+ from .combinators import AllOf, AnyOf, NotRule
567
+ from .rules import BaseRule, RuleRegistry
568
+
569
+ rule_type = data.get("type")
570
+ if not rule_type:
571
+ return None
572
+
573
+ # Get params (support both top-level and nested params)
574
+ params = data.get("params", {})
575
+
576
+ # Handle combinator rules with nested rules
577
+ if rule_type == "all_of":
578
+ nested_rules_data = data.get("rules", params.get("rules", []))
579
+ nested_rules = []
580
+ for nested_data in nested_rules_data:
581
+ nested_rule = cls._build_rule(nested_data)
582
+ if nested_rule:
583
+ nested_rules.append(nested_rule)
584
+ return AllOf(rules=nested_rules)
585
+
586
+ elif rule_type == "any_of":
587
+ nested_rules_data = data.get("rules", params.get("rules", []))
588
+ nested_rules = []
589
+ for nested_data in nested_rules_data:
590
+ nested_rule = cls._build_rule(nested_data)
591
+ if nested_rule:
592
+ nested_rules.append(nested_rule)
593
+ return AnyOf(rules=nested_rules)
594
+
595
+ elif rule_type == "not":
596
+ nested_data = data.get("rule", params.get("rule"))
597
+ if nested_data:
598
+ nested_rule = cls._build_rule(nested_data)
599
+ if nested_rule:
600
+ return NotRule(rule=nested_rule)
601
+ return None
602
+
603
+ # Simple rules - merge top-level params with nested params
604
+ # Support both formats:
605
+ # 1. { type: "severity", min_severity: "critical" }
606
+ # 2. { type: "severity", params: { min_severity: "critical" } }
607
+ merged_params = {k: v for k, v in data.items() if k not in ("type", "params")}
608
+ merged_params.update(params)
609
+
610
+ return RuleRegistry.create(rule_type, **merged_params)
611
+
612
+
613
+ class ConfigBuilder:
614
+ """Builder for programmatically creating routing configurations.
615
+
616
+ Provides a fluent interface for constructing RoutingConfig
617
+ instances without manual dictionary manipulation.
618
+
619
+ Example:
620
+ config = (
621
+ ConfigBuilder()
622
+ .with_version("1.0")
623
+ .with_defaults(priority=0, stop_on_match=False)
624
+ .add_route(
625
+ name="critical_alerts",
626
+ rules=[
627
+ {"type": "severity", "params": {"min_severity": "critical"}}
628
+ ],
629
+ actions=["pagerduty"],
630
+ priority=100,
631
+ )
632
+ .add_route(
633
+ name="production_errors",
634
+ rules=[
635
+ {
636
+ "type": "all_of",
637
+ "rules": [
638
+ {"type": "tag", "params": {"tags": ["production"]}},
639
+ {"type": "status", "params": {"statuses": ["error"]}},
640
+ ],
641
+ }
642
+ ],
643
+ actions=["slack-alerts"],
644
+ priority=50,
645
+ )
646
+ .build()
647
+ )
648
+ """
649
+
650
+ def __init__(self) -> None:
651
+ """Initialize the builder."""
652
+ self._version: str = "1.0"
653
+ self._defaults: dict[str, Any] = {}
654
+ self._routes: list[RouteConfig] = []
655
+
656
+ def with_version(self, version: str) -> Self:
657
+ """Set the configuration version.
658
+
659
+ Args:
660
+ version: Version string.
661
+
662
+ Returns:
663
+ Self for method chaining.
664
+ """
665
+ self._version = version
666
+ return self
667
+
668
+ def with_defaults(self, **kwargs: Any) -> Self:
669
+ """Set default values for routes.
670
+
671
+ Supported defaults:
672
+ - priority: Default route priority
673
+ - stop_on_match: Default stop_on_match behavior
674
+ - metadata: Default metadata to apply
675
+
676
+ Args:
677
+ **kwargs: Default values.
678
+
679
+ Returns:
680
+ Self for method chaining.
681
+ """
682
+ self._defaults.update(kwargs)
683
+ return self
684
+
685
+ def add_route(
686
+ self,
687
+ name: str,
688
+ rules: list[dict[str, Any]],
689
+ actions: list[str],
690
+ priority: int | None = None,
691
+ stop_on_match: bool | None = None,
692
+ metadata: dict[str, Any] | None = None,
693
+ ) -> Self:
694
+ """Add a route to the configuration.
695
+
696
+ Args:
697
+ name: Unique route name.
698
+ rules: List of rule configurations.
699
+ actions: List of action/channel identifiers.
700
+ priority: Route priority (uses default if None).
701
+ stop_on_match: Stop on match behavior (uses default if None).
702
+ metadata: Route metadata (uses default if None).
703
+
704
+ Returns:
705
+ Self for method chaining.
706
+ """
707
+ route = RouteConfig(
708
+ name=name,
709
+ rules=rules,
710
+ actions=actions,
711
+ priority=priority if priority is not None else self._defaults.get("priority", 0),
712
+ stop_on_match=(
713
+ stop_on_match if stop_on_match is not None else self._defaults.get("stop_on_match", False)
714
+ ),
715
+ metadata=metadata if metadata is not None else dict(self._defaults.get("metadata", {})),
716
+ )
717
+ self._routes.append(route)
718
+ return self
719
+
720
+ def add_simple_route(
721
+ self,
722
+ name: str,
723
+ rule_type: str,
724
+ rule_params: dict[str, Any],
725
+ actions: list[str],
726
+ priority: int | None = None,
727
+ stop_on_match: bool | None = None,
728
+ ) -> Self:
729
+ """Add a simple route with a single rule.
730
+
731
+ Convenience method for routes with a single rule.
732
+
733
+ Args:
734
+ name: Unique route name.
735
+ rule_type: Rule type (e.g., "severity", "tag").
736
+ rule_params: Rule parameters.
737
+ actions: List of action/channel identifiers.
738
+ priority: Route priority.
739
+ stop_on_match: Stop on match behavior.
740
+
741
+ Returns:
742
+ Self for method chaining.
743
+ """
744
+ rules = [{"type": rule_type, "params": rule_params}]
745
+ return self.add_route(
746
+ name=name,
747
+ rules=rules,
748
+ actions=actions,
749
+ priority=priority,
750
+ stop_on_match=stop_on_match,
751
+ )
752
+
753
+ def add_combined_route(
754
+ self,
755
+ name: str,
756
+ combinator: str,
757
+ rule_configs: list[tuple[str, dict[str, Any]]],
758
+ actions: list[str],
759
+ priority: int | None = None,
760
+ stop_on_match: bool | None = None,
761
+ ) -> Self:
762
+ """Add a route with combined rules.
763
+
764
+ Convenience method for routes with multiple rules combined
765
+ by a combinator (all_of, any_of).
766
+
767
+ Args:
768
+ name: Unique route name.
769
+ combinator: Combinator type ("all_of" or "any_of").
770
+ rule_configs: List of (rule_type, params) tuples.
771
+ actions: List of action/channel identifiers.
772
+ priority: Route priority.
773
+ stop_on_match: Stop on match behavior.
774
+
775
+ Returns:
776
+ Self for method chaining.
777
+ """
778
+ nested_rules = [{"type": rtype, "params": params} for rtype, params in rule_configs]
779
+ rules = [{"type": combinator, "rules": nested_rules}]
780
+ return self.add_route(
781
+ name=name,
782
+ rules=rules,
783
+ actions=actions,
784
+ priority=priority,
785
+ stop_on_match=stop_on_match,
786
+ )
787
+
788
+ def clear_routes(self) -> Self:
789
+ """Clear all routes from the builder.
790
+
791
+ Returns:
792
+ Self for method chaining.
793
+ """
794
+ self._routes.clear()
795
+ return self
796
+
797
+ def remove_route(self, name: str) -> Self:
798
+ """Remove a route by name.
799
+
800
+ Args:
801
+ name: Route name to remove.
802
+
803
+ Returns:
804
+ Self for method chaining.
805
+ """
806
+ self._routes = [r for r in self._routes if r.name != name]
807
+ return self
808
+
809
+ def build(self) -> RoutingConfig:
810
+ """Build the RoutingConfig.
811
+
812
+ Returns:
813
+ Configured RoutingConfig instance.
814
+ """
815
+ return RoutingConfig(
816
+ version=self._version,
817
+ routes=list(self._routes),
818
+ defaults=dict(self._defaults),
819
+ )
820
+
821
+ def build_router(self) -> "ActionRouter":
822
+ """Build and convert to ActionRouter.
823
+
824
+ Convenience method that builds the config and
825
+ converts it to an ActionRouter in one step.
826
+
827
+ Returns:
828
+ Configured ActionRouter instance.
829
+ """
830
+ config = self.build()
831
+ return ConfigParser.to_action_router(config)
832
+
833
+
834
+ # Convenience function for quick parsing
835
+ def parse_routing_config(source: str | Path | dict[str, Any]) -> RoutingConfig:
836
+ """Parse routing configuration from various sources.
837
+
838
+ Args:
839
+ source: Configuration source - can be:
840
+ - Path to a file
841
+ - YAML or JSON string
842
+ - Dictionary
843
+
844
+ Returns:
845
+ Parsed RoutingConfig.
846
+
847
+ Raises:
848
+ ValueError: If source format cannot be determined.
849
+ """
850
+ if isinstance(source, Path):
851
+ return ConfigParser.parse_file(source)
852
+ elif isinstance(source, str):
853
+ # Check if it's a file path
854
+ path = Path(source)
855
+ if path.exists():
856
+ return ConfigParser.parse_file(path)
857
+
858
+ # Try parsing as YAML/JSON
859
+ source = source.strip()
860
+ if source.startswith("{"):
861
+ return ConfigParser.parse_json(source)
862
+ else:
863
+ return ConfigParser.parse_yaml(source)
864
+ elif isinstance(source, dict):
865
+ return RoutingConfig.from_dict(source)
866
+ else:
867
+ raise ValueError(f"Unsupported source type: {type(source)}")