truthound-dashboard 1.4.4__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.
Files changed (205) hide show
  1. truthound_dashboard/api/alerts.py +75 -86
  2. truthound_dashboard/api/anomaly.py +7 -13
  3. truthound_dashboard/api/cross_alerts.py +38 -52
  4. truthound_dashboard/api/drift.py +49 -59
  5. truthound_dashboard/api/drift_monitor.py +234 -79
  6. truthound_dashboard/api/enterprise_sampling.py +498 -0
  7. truthound_dashboard/api/history.py +57 -5
  8. truthound_dashboard/api/lineage.py +3 -48
  9. truthound_dashboard/api/maintenance.py +104 -49
  10. truthound_dashboard/api/mask.py +1 -2
  11. truthound_dashboard/api/middleware.py +2 -1
  12. truthound_dashboard/api/model_monitoring.py +435 -311
  13. truthound_dashboard/api/notifications.py +227 -191
  14. truthound_dashboard/api/notifications_advanced.py +21 -20
  15. truthound_dashboard/api/observability.py +586 -0
  16. truthound_dashboard/api/plugins.py +2 -433
  17. truthound_dashboard/api/profile.py +199 -37
  18. truthound_dashboard/api/quality_reporter.py +701 -0
  19. truthound_dashboard/api/reports.py +7 -16
  20. truthound_dashboard/api/router.py +66 -0
  21. truthound_dashboard/api/rule_suggestions.py +5 -5
  22. truthound_dashboard/api/scan.py +17 -19
  23. truthound_dashboard/api/schedules.py +85 -50
  24. truthound_dashboard/api/schema_evolution.py +6 -6
  25. truthound_dashboard/api/schema_watcher.py +667 -0
  26. truthound_dashboard/api/sources.py +98 -27
  27. truthound_dashboard/api/tiering.py +1323 -0
  28. truthound_dashboard/api/triggers.py +14 -11
  29. truthound_dashboard/api/validations.py +12 -11
  30. truthound_dashboard/api/versioning.py +1 -6
  31. truthound_dashboard/core/__init__.py +129 -3
  32. truthound_dashboard/core/actions/__init__.py +62 -0
  33. truthound_dashboard/core/actions/custom.py +426 -0
  34. truthound_dashboard/core/actions/notifications.py +910 -0
  35. truthound_dashboard/core/actions/storage.py +472 -0
  36. truthound_dashboard/core/actions/webhook.py +281 -0
  37. truthound_dashboard/core/anomaly.py +262 -67
  38. truthound_dashboard/core/anomaly_explainer.py +4 -3
  39. truthound_dashboard/core/backends/__init__.py +67 -0
  40. truthound_dashboard/core/backends/base.py +299 -0
  41. truthound_dashboard/core/backends/errors.py +191 -0
  42. truthound_dashboard/core/backends/factory.py +423 -0
  43. truthound_dashboard/core/backends/mock_backend.py +451 -0
  44. truthound_dashboard/core/backends/truthound_backend.py +718 -0
  45. truthound_dashboard/core/checkpoint/__init__.py +87 -0
  46. truthound_dashboard/core/checkpoint/adapters.py +814 -0
  47. truthound_dashboard/core/checkpoint/checkpoint.py +491 -0
  48. truthound_dashboard/core/checkpoint/runner.py +270 -0
  49. truthound_dashboard/core/connections.py +437 -10
  50. truthound_dashboard/core/converters/__init__.py +14 -0
  51. truthound_dashboard/core/converters/truthound.py +620 -0
  52. truthound_dashboard/core/cross_alerts.py +540 -320
  53. truthound_dashboard/core/datasource_factory.py +1672 -0
  54. truthound_dashboard/core/drift_monitor.py +216 -20
  55. truthound_dashboard/core/enterprise_sampling.py +1291 -0
  56. truthound_dashboard/core/interfaces/__init__.py +225 -0
  57. truthound_dashboard/core/interfaces/actions.py +652 -0
  58. truthound_dashboard/core/interfaces/base.py +247 -0
  59. truthound_dashboard/core/interfaces/checkpoint.py +676 -0
  60. truthound_dashboard/core/interfaces/protocols.py +664 -0
  61. truthound_dashboard/core/interfaces/reporters.py +650 -0
  62. truthound_dashboard/core/interfaces/routing.py +646 -0
  63. truthound_dashboard/core/interfaces/triggers.py +619 -0
  64. truthound_dashboard/core/lineage.py +407 -71
  65. truthound_dashboard/core/model_monitoring.py +431 -3
  66. truthound_dashboard/core/notifications/base.py +4 -0
  67. truthound_dashboard/core/notifications/channels.py +501 -1203
  68. truthound_dashboard/core/notifications/deduplication/__init__.py +81 -115
  69. truthound_dashboard/core/notifications/deduplication/service.py +131 -348
  70. truthound_dashboard/core/notifications/dispatcher.py +202 -11
  71. truthound_dashboard/core/notifications/escalation/__init__.py +119 -106
  72. truthound_dashboard/core/notifications/escalation/engine.py +168 -358
  73. truthound_dashboard/core/notifications/routing/__init__.py +88 -128
  74. truthound_dashboard/core/notifications/routing/engine.py +90 -317
  75. truthound_dashboard/core/notifications/stats_aggregator.py +246 -1
  76. truthound_dashboard/core/notifications/throttling/__init__.py +67 -50
  77. truthound_dashboard/core/notifications/throttling/builder.py +117 -255
  78. truthound_dashboard/core/notifications/truthound_adapter.py +842 -0
  79. truthound_dashboard/core/phase5/collaboration.py +1 -1
  80. truthound_dashboard/core/plugins/lifecycle/__init__.py +0 -13
  81. truthound_dashboard/core/quality_reporter.py +1359 -0
  82. truthound_dashboard/core/report_history.py +0 -6
  83. truthound_dashboard/core/reporters/__init__.py +175 -14
  84. truthound_dashboard/core/reporters/adapters.py +943 -0
  85. truthound_dashboard/core/reporters/base.py +0 -3
  86. truthound_dashboard/core/reporters/builtin/__init__.py +18 -0
  87. truthound_dashboard/core/reporters/builtin/csv_reporter.py +111 -0
  88. truthound_dashboard/core/reporters/builtin/html_reporter.py +270 -0
  89. truthound_dashboard/core/reporters/builtin/json_reporter.py +127 -0
  90. truthound_dashboard/core/reporters/compat.py +266 -0
  91. truthound_dashboard/core/reporters/csv_reporter.py +2 -35
  92. truthound_dashboard/core/reporters/factory.py +526 -0
  93. truthound_dashboard/core/reporters/interfaces.py +745 -0
  94. truthound_dashboard/core/reporters/registry.py +1 -10
  95. truthound_dashboard/core/scheduler.py +165 -0
  96. truthound_dashboard/core/schema_evolution.py +3 -3
  97. truthound_dashboard/core/schema_watcher.py +1528 -0
  98. truthound_dashboard/core/services.py +595 -76
  99. truthound_dashboard/core/store_manager.py +810 -0
  100. truthound_dashboard/core/streaming_anomaly.py +169 -4
  101. truthound_dashboard/core/tiering.py +1309 -0
  102. truthound_dashboard/core/triggers/evaluators.py +178 -8
  103. truthound_dashboard/core/truthound_adapter.py +2620 -197
  104. truthound_dashboard/core/unified_alerts.py +23 -20
  105. truthound_dashboard/db/__init__.py +8 -0
  106. truthound_dashboard/db/database.py +8 -2
  107. truthound_dashboard/db/models.py +944 -25
  108. truthound_dashboard/db/repository.py +2 -0
  109. truthound_dashboard/main.py +11 -0
  110. truthound_dashboard/schemas/__init__.py +177 -16
  111. truthound_dashboard/schemas/base.py +44 -23
  112. truthound_dashboard/schemas/collaboration.py +19 -6
  113. truthound_dashboard/schemas/cross_alerts.py +19 -3
  114. truthound_dashboard/schemas/drift.py +61 -55
  115. truthound_dashboard/schemas/drift_monitor.py +67 -23
  116. truthound_dashboard/schemas/enterprise_sampling.py +653 -0
  117. truthound_dashboard/schemas/lineage.py +0 -33
  118. truthound_dashboard/schemas/mask.py +10 -8
  119. truthound_dashboard/schemas/model_monitoring.py +89 -10
  120. truthound_dashboard/schemas/notifications_advanced.py +13 -0
  121. truthound_dashboard/schemas/observability.py +453 -0
  122. truthound_dashboard/schemas/plugins.py +0 -280
  123. truthound_dashboard/schemas/profile.py +154 -247
  124. truthound_dashboard/schemas/quality_reporter.py +403 -0
  125. truthound_dashboard/schemas/reports.py +2 -2
  126. truthound_dashboard/schemas/rule_suggestion.py +8 -1
  127. truthound_dashboard/schemas/scan.py +4 -24
  128. truthound_dashboard/schemas/schedule.py +11 -3
  129. truthound_dashboard/schemas/schema_watcher.py +727 -0
  130. truthound_dashboard/schemas/source.py +17 -2
  131. truthound_dashboard/schemas/tiering.py +822 -0
  132. truthound_dashboard/schemas/triggers.py +16 -0
  133. truthound_dashboard/schemas/unified_alerts.py +7 -0
  134. truthound_dashboard/schemas/validation.py +0 -13
  135. truthound_dashboard/schemas/validators/base.py +41 -21
  136. truthound_dashboard/schemas/validators/business_rule_validators.py +244 -0
  137. truthound_dashboard/schemas/validators/localization_validators.py +273 -0
  138. truthound_dashboard/schemas/validators/ml_feature_validators.py +308 -0
  139. truthound_dashboard/schemas/validators/profiling_validators.py +275 -0
  140. truthound_dashboard/schemas/validators/referential_validators.py +312 -0
  141. truthound_dashboard/schemas/validators/registry.py +93 -8
  142. truthound_dashboard/schemas/validators/timeseries_validators.py +389 -0
  143. truthound_dashboard/schemas/versioning.py +1 -6
  144. truthound_dashboard/static/index.html +2 -2
  145. truthound_dashboard-1.5.0.dist-info/METADATA +309 -0
  146. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/RECORD +149 -148
  147. truthound_dashboard/core/plugins/hooks/__init__.py +0 -63
  148. truthound_dashboard/core/plugins/hooks/decorators.py +0 -367
  149. truthound_dashboard/core/plugins/hooks/manager.py +0 -403
  150. truthound_dashboard/core/plugins/hooks/protocols.py +0 -265
  151. truthound_dashboard/core/plugins/lifecycle/hot_reload.py +0 -584
  152. truthound_dashboard/core/reporters/junit_reporter.py +0 -233
  153. truthound_dashboard/core/reporters/markdown_reporter.py +0 -207
  154. truthound_dashboard/core/reporters/pdf_reporter.py +0 -209
  155. truthound_dashboard/static/assets/_baseUniq-BcrSP13d.js +0 -1
  156. truthound_dashboard/static/assets/arc-DlYjKwIL.js +0 -1
  157. truthound_dashboard/static/assets/architectureDiagram-VXUJARFQ-Bb2drbQM.js +0 -36
  158. truthound_dashboard/static/assets/blockDiagram-VD42YOAC-BlsPG1CH.js +0 -122
  159. truthound_dashboard/static/assets/c4Diagram-YG6GDRKO-B9JdUoaC.js +0 -10
  160. truthound_dashboard/static/assets/channel-Q6mHF1Hd.js +0 -1
  161. truthound_dashboard/static/assets/chunk-4BX2VUAB-DmyoPVuJ.js +0 -1
  162. truthound_dashboard/static/assets/chunk-55IACEB6-Bcz6Siv8.js +0 -1
  163. truthound_dashboard/static/assets/chunk-B4BG7PRW-Br3G5Rum.js +0 -165
  164. truthound_dashboard/static/assets/chunk-DI55MBZ5-DuM9c23u.js +0 -220
  165. truthound_dashboard/static/assets/chunk-FMBD7UC4-DNU-5mvT.js +0 -15
  166. truthound_dashboard/static/assets/chunk-QN33PNHL-Im2yNcmS.js +0 -1
  167. truthound_dashboard/static/assets/chunk-QZHKN3VN-kZr8XFm1.js +0 -1
  168. truthound_dashboard/static/assets/chunk-TZMSLE5B-Q__360q_.js +0 -1
  169. truthound_dashboard/static/assets/classDiagram-2ON5EDUG-vtixxUyK.js +0 -1
  170. truthound_dashboard/static/assets/classDiagram-v2-WZHVMYZB-vtixxUyK.js +0 -1
  171. truthound_dashboard/static/assets/clone-BOt2LwD0.js +0 -1
  172. truthound_dashboard/static/assets/cose-bilkent-S5V4N54A-CBDw6iac.js +0 -1
  173. truthound_dashboard/static/assets/dagre-6UL2VRFP-XdKqmmY9.js +0 -4
  174. truthound_dashboard/static/assets/diagram-PSM6KHXK-DAZ8nx9V.js +0 -24
  175. truthound_dashboard/static/assets/diagram-QEK2KX5R-BRvDTbGD.js +0 -43
  176. truthound_dashboard/static/assets/diagram-S2PKOQOG-bQcczUkl.js +0 -24
  177. truthound_dashboard/static/assets/erDiagram-Q2GNP2WA-DPje7VMN.js +0 -60
  178. truthound_dashboard/static/assets/flowDiagram-NV44I4VS-B7BVtFVS.js +0 -162
  179. truthound_dashboard/static/assets/ganttDiagram-JELNMOA3-D6WKSS7U.js +0 -267
  180. truthound_dashboard/static/assets/gitGraphDiagram-NY62KEGX-D3vtVd3y.js +0 -65
  181. truthound_dashboard/static/assets/graph-BKgNKZVp.js +0 -1
  182. truthound_dashboard/static/assets/index-C6JSrkHo.css +0 -1
  183. truthound_dashboard/static/assets/index-DkU82VsU.js +0 -1800
  184. truthound_dashboard/static/assets/infoDiagram-WHAUD3N6-DnNCT429.js +0 -2
  185. truthound_dashboard/static/assets/journeyDiagram-XKPGCS4Q-DGiMozqS.js +0 -139
  186. truthound_dashboard/static/assets/kanban-definition-3W4ZIXB7-BV2gUgli.js +0 -89
  187. truthound_dashboard/static/assets/katex-Cu_Erd72.js +0 -261
  188. truthound_dashboard/static/assets/layout-DI2MfQ5G.js +0 -1
  189. truthound_dashboard/static/assets/min-DYdgXVcT.js +0 -1
  190. truthound_dashboard/static/assets/mindmap-definition-VGOIOE7T-C7x4ruxz.js +0 -68
  191. truthound_dashboard/static/assets/pieDiagram-ADFJNKIX-CAJaAB9f.js +0 -30
  192. truthound_dashboard/static/assets/quadrantDiagram-AYHSOK5B-DeqwDI46.js +0 -7
  193. truthound_dashboard/static/assets/requirementDiagram-UZGBJVZJ-e3XDpZIM.js +0 -64
  194. truthound_dashboard/static/assets/sankeyDiagram-TZEHDZUN-CNnAv5Ux.js +0 -10
  195. truthound_dashboard/static/assets/sequenceDiagram-WL72ISMW-Dsne-Of3.js +0 -145
  196. truthound_dashboard/static/assets/stateDiagram-FKZM4ZOC-Ee0sQXyb.js +0 -1
  197. truthound_dashboard/static/assets/stateDiagram-v2-4FDKWEC3-B26KqW_W.js +0 -1
  198. truthound_dashboard/static/assets/timeline-definition-IT6M3QCI-DZYi2yl3.js +0 -61
  199. truthound_dashboard/static/assets/treemap-KMMF4GRG-CY3f8In2.js +0 -128
  200. truthound_dashboard/static/assets/unmerged_dictionaries-Dd7xcPWG.js +0 -1
  201. truthound_dashboard/static/assets/xychartDiagram-PRI3JC2R-CS7fydZZ.js +0 -7
  202. truthound_dashboard-1.4.4.dist-info/METADATA +0 -507
  203. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/WHEEL +0 -0
  204. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/entry_points.txt +0 -0
  205. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,652 @@
1
+ """Action interfaces for checkpoint-based validation pipelines.
2
+
3
+ Actions are executed after validation completes. They can store results,
4
+ send notifications, or integrate with external systems.
5
+
6
+ This module defines abstract interfaces for actions that are loosely
7
+ coupled from truthound's checkpoint.actions module.
8
+
9
+ Supported action types:
10
+ - Storage: Store validation results to filesystem, S3, GCS
11
+ - Notifications: Slack, Email, Teams, Discord, Telegram, PagerDuty
12
+ - Webhook: Call any HTTP endpoint
13
+ - Custom: Execute Python callbacks or shell commands
14
+ - DataDocs: Generate HTML/Markdown documentation
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from abc import ABC, abstractmethod
20
+ from dataclasses import dataclass, field
21
+ from datetime import datetime
22
+ from enum import Enum
23
+ from typing import TYPE_CHECKING, Any, Callable, Protocol, runtime_checkable
24
+
25
+ if TYPE_CHECKING:
26
+ from truthound_dashboard.core.interfaces.checkpoint import CheckpointResult
27
+
28
+
29
+ class NotifyCondition(str, Enum):
30
+ """Conditions under which actions are triggered.
31
+
32
+ Maps to truthound's notify_on parameter for actions.
33
+ """
34
+
35
+ ALWAYS = "always" # Every run
36
+ SUCCESS = "success" # Validation passed
37
+ FAILURE = "failure" # Validation failed
38
+ ERROR = "error" # System error occurred
39
+ FAILURE_OR_ERROR = "failure_or_error" # Failure or error
40
+ NOT_SUCCESS = "not_success" # Any non-success status
41
+ WARNING = "warning" # Warning status
42
+
43
+
44
+ class ActionStatus(str, Enum):
45
+ """Status of an action execution."""
46
+
47
+ PENDING = "pending"
48
+ RUNNING = "running"
49
+ SUCCESS = "success"
50
+ FAILURE = "failure"
51
+ SKIPPED = "skipped" # Skipped due to notify_on condition
52
+ COMPENSATED = "compensated" # Rolled back (for transactional actions)
53
+
54
+
55
+ @dataclass
56
+ class ActionConfig:
57
+ """Base configuration for all actions.
58
+
59
+ Attributes:
60
+ name: Action name for identification.
61
+ notify_on: When to execute this action.
62
+ enabled: Whether this action is enabled.
63
+ timeout_seconds: Maximum execution time.
64
+ retry_count: Number of retries on failure.
65
+ retry_delay_seconds: Delay between retries.
66
+ metadata: Additional metadata for the action.
67
+ """
68
+
69
+ name: str = ""
70
+ notify_on: NotifyCondition = NotifyCondition.FAILURE
71
+ enabled: bool = True
72
+ timeout_seconds: int = 30
73
+ retry_count: int = 0
74
+ retry_delay_seconds: int = 5
75
+ metadata: dict[str, Any] = field(default_factory=dict)
76
+
77
+ def to_dict(self) -> dict[str, Any]:
78
+ """Convert to dictionary."""
79
+ return {
80
+ "name": self.name,
81
+ "notify_on": self.notify_on.value,
82
+ "enabled": self.enabled,
83
+ "timeout_seconds": self.timeout_seconds,
84
+ "retry_count": self.retry_count,
85
+ "retry_delay_seconds": self.retry_delay_seconds,
86
+ "metadata": self.metadata,
87
+ }
88
+
89
+ @classmethod
90
+ def from_dict(cls, data: dict[str, Any]) -> "ActionConfig":
91
+ """Create from dictionary."""
92
+ notify_on = data.get("notify_on", "failure")
93
+ if isinstance(notify_on, str):
94
+ notify_on = NotifyCondition(notify_on)
95
+ return cls(
96
+ name=data.get("name", ""),
97
+ notify_on=notify_on,
98
+ enabled=data.get("enabled", True),
99
+ timeout_seconds=data.get("timeout_seconds", 30),
100
+ retry_count=data.get("retry_count", 0),
101
+ retry_delay_seconds=data.get("retry_delay_seconds", 5),
102
+ metadata=data.get("metadata", {}),
103
+ )
104
+
105
+
106
+ @dataclass
107
+ class ActionResult:
108
+ """Result of an action execution.
109
+
110
+ Attributes:
111
+ action_name: Name of the action.
112
+ action_type: Type of action (notification, storage, etc.).
113
+ status: Execution status.
114
+ message: Human-readable message.
115
+ started_at: When execution started.
116
+ completed_at: When execution completed.
117
+ duration_ms: Execution duration in milliseconds.
118
+ details: Additional result details.
119
+ error: Error message if failed.
120
+ """
121
+
122
+ action_name: str
123
+ action_type: str
124
+ status: ActionStatus
125
+ message: str = ""
126
+ started_at: datetime | None = None
127
+ completed_at: datetime | None = None
128
+ duration_ms: float = 0.0
129
+ details: dict[str, Any] = field(default_factory=dict)
130
+ error: str | None = None
131
+
132
+ def to_dict(self) -> dict[str, Any]:
133
+ """Convert to dictionary."""
134
+ return {
135
+ "action_name": self.action_name,
136
+ "action_type": self.action_type,
137
+ "status": self.status.value,
138
+ "message": self.message,
139
+ "started_at": self.started_at.isoformat() if self.started_at else None,
140
+ "completed_at": self.completed_at.isoformat() if self.completed_at else None,
141
+ "duration_ms": self.duration_ms,
142
+ "details": self.details,
143
+ "error": self.error,
144
+ }
145
+
146
+
147
+ @dataclass
148
+ class ActionContext:
149
+ """Context passed to action execution.
150
+
151
+ Contains all information needed for an action to execute,
152
+ including the checkpoint result and environment variables.
153
+
154
+ Attributes:
155
+ checkpoint_result: The validation result.
156
+ run_id: Unique run identifier.
157
+ checkpoint_name: Name of the checkpoint.
158
+ tags: Tags from the checkpoint.
159
+ metadata: Additional metadata.
160
+ environment: Environment variables (may contain secrets).
161
+ """
162
+
163
+ checkpoint_result: "CheckpointResult"
164
+ run_id: str
165
+ checkpoint_name: str
166
+ tags: dict[str, str] = field(default_factory=dict)
167
+ metadata: dict[str, Any] = field(default_factory=dict)
168
+ environment: dict[str, str] = field(default_factory=dict)
169
+
170
+
171
+ @runtime_checkable
172
+ class ActionProtocol(Protocol):
173
+ """Protocol for synchronous action implementations.
174
+
175
+ Actions are executed after validation completes. Each action
176
+ must implement the execute method.
177
+
178
+ Example:
179
+ class SlackNotification:
180
+ def execute(self, context: ActionContext) -> ActionResult:
181
+ # Send Slack message
182
+ return ActionResult(
183
+ action_name="slack",
184
+ action_type="notification",
185
+ status=ActionStatus.SUCCESS,
186
+ )
187
+ """
188
+
189
+ @property
190
+ def name(self) -> str:
191
+ """Get action name."""
192
+ ...
193
+
194
+ @property
195
+ def action_type(self) -> str:
196
+ """Get action type (notification, storage, webhook, custom)."""
197
+ ...
198
+
199
+ @property
200
+ def config(self) -> ActionConfig:
201
+ """Get action configuration."""
202
+ ...
203
+
204
+ def should_execute(self, context: ActionContext) -> bool:
205
+ """Check if action should execute based on notify_on condition.
206
+
207
+ Args:
208
+ context: Execution context with checkpoint result.
209
+
210
+ Returns:
211
+ True if action should execute.
212
+ """
213
+ ...
214
+
215
+ def execute(self, context: ActionContext) -> ActionResult:
216
+ """Execute the action synchronously.
217
+
218
+ Args:
219
+ context: Execution context.
220
+
221
+ Returns:
222
+ Action result.
223
+ """
224
+ ...
225
+
226
+
227
+ @runtime_checkable
228
+ class AsyncActionProtocol(Protocol):
229
+ """Protocol for asynchronous action implementations.
230
+
231
+ For high-throughput scenarios, use async actions that don't
232
+ block the event loop.
233
+
234
+ Example:
235
+ class AsyncSlackNotification:
236
+ async def execute_async(self, context: ActionContext) -> ActionResult:
237
+ async with aiohttp.ClientSession() as session:
238
+ await session.post(webhook_url, json=payload)
239
+ return ActionResult(...)
240
+ """
241
+
242
+ @property
243
+ def name(self) -> str:
244
+ """Get action name."""
245
+ ...
246
+
247
+ @property
248
+ def action_type(self) -> str:
249
+ """Get action type."""
250
+ ...
251
+
252
+ @property
253
+ def config(self) -> ActionConfig:
254
+ """Get action configuration."""
255
+ ...
256
+
257
+ def should_execute(self, context: ActionContext) -> bool:
258
+ """Check if action should execute."""
259
+ ...
260
+
261
+ async def execute_async(self, context: ActionContext) -> ActionResult:
262
+ """Execute the action asynchronously.
263
+
264
+ Args:
265
+ context: Execution context.
266
+
267
+ Returns:
268
+ Action result.
269
+ """
270
+ ...
271
+
272
+
273
+ class CompensatableActionProtocol(Protocol):
274
+ """Protocol for actions that support rollback (compensation).
275
+
276
+ Compensatable actions can be rolled back if a subsequent action
277
+ fails, supporting the Saga pattern for distributed transactions.
278
+
279
+ Example:
280
+ class DatabaseUpdateAction:
281
+ def execute(self, context):
282
+ self.backup_id = create_backup()
283
+ update_database(context.checkpoint_result)
284
+ return ActionResult(status=ActionStatus.SUCCESS)
285
+
286
+ def compensate(self, context, execute_result):
287
+ restore_from_backup(self.backup_id)
288
+ return ActionResult(status=ActionStatus.COMPENSATED)
289
+ """
290
+
291
+ def execute(self, context: ActionContext) -> ActionResult:
292
+ """Execute the forward action."""
293
+ ...
294
+
295
+ def compensate(
296
+ self, context: ActionContext, execute_result: ActionResult
297
+ ) -> ActionResult:
298
+ """Compensate (rollback) the action.
299
+
300
+ Args:
301
+ context: Original execution context.
302
+ execute_result: Result from the execute() call.
303
+
304
+ Returns:
305
+ Compensation result.
306
+ """
307
+ ...
308
+
309
+
310
+ class BaseAction(ABC):
311
+ """Abstract base class for actions.
312
+
313
+ Provides common functionality for all actions including
314
+ notify_on condition checking and configuration management.
315
+
316
+ Subclasses must implement:
317
+ - action_type property
318
+ - _do_execute method
319
+ """
320
+
321
+ def __init__(self, config: ActionConfig | dict[str, Any] | None = None) -> None:
322
+ """Initialize action.
323
+
324
+ Args:
325
+ config: Action configuration or dict.
326
+ """
327
+ if config is None:
328
+ self._config = ActionConfig()
329
+ elif isinstance(config, dict):
330
+ self._config = ActionConfig.from_dict(config)
331
+ else:
332
+ self._config = config
333
+
334
+ @property
335
+ def name(self) -> str:
336
+ """Get action name."""
337
+ return self._config.name or self.__class__.__name__
338
+
339
+ @property
340
+ @abstractmethod
341
+ def action_type(self) -> str:
342
+ """Get action type."""
343
+ ...
344
+
345
+ @property
346
+ def config(self) -> ActionConfig:
347
+ """Get action configuration."""
348
+ return self._config
349
+
350
+ def should_execute(self, context: ActionContext) -> bool:
351
+ """Check if action should execute based on notify_on condition.
352
+
353
+ Args:
354
+ context: Execution context.
355
+
356
+ Returns:
357
+ True if action should execute.
358
+ """
359
+ if not self._config.enabled:
360
+ return False
361
+
362
+ result = context.checkpoint_result
363
+ condition = self._config.notify_on
364
+
365
+ if condition == NotifyCondition.ALWAYS:
366
+ return True
367
+ elif condition == NotifyCondition.SUCCESS:
368
+ return result.status.value == "success"
369
+ elif condition == NotifyCondition.FAILURE:
370
+ return result.status.value == "failure"
371
+ elif condition == NotifyCondition.ERROR:
372
+ return result.status.value == "error"
373
+ elif condition == NotifyCondition.FAILURE_OR_ERROR:
374
+ return result.status.value in ("failure", "error")
375
+ elif condition == NotifyCondition.NOT_SUCCESS:
376
+ return result.status.value != "success"
377
+ elif condition == NotifyCondition.WARNING:
378
+ return result.status.value == "warning"
379
+
380
+ return True
381
+
382
+ def execute(self, context: ActionContext) -> ActionResult:
383
+ """Execute the action.
384
+
385
+ Args:
386
+ context: Execution context.
387
+
388
+ Returns:
389
+ Action result.
390
+ """
391
+ if not self.should_execute(context):
392
+ return ActionResult(
393
+ action_name=self.name,
394
+ action_type=self.action_type,
395
+ status=ActionStatus.SKIPPED,
396
+ message=f"Skipped due to notify_on={self._config.notify_on.value}",
397
+ )
398
+
399
+ started_at = datetime.now()
400
+ try:
401
+ result = self._do_execute(context)
402
+ completed_at = datetime.now()
403
+ result.started_at = started_at
404
+ result.completed_at = completed_at
405
+ result.duration_ms = (completed_at - started_at).total_seconds() * 1000
406
+ return result
407
+ except Exception as e:
408
+ completed_at = datetime.now()
409
+ return ActionResult(
410
+ action_name=self.name,
411
+ action_type=self.action_type,
412
+ status=ActionStatus.FAILURE,
413
+ message=f"Action failed: {str(e)}",
414
+ started_at=started_at,
415
+ completed_at=completed_at,
416
+ duration_ms=(completed_at - started_at).total_seconds() * 1000,
417
+ error=str(e),
418
+ )
419
+
420
+ @abstractmethod
421
+ def _do_execute(self, context: ActionContext) -> ActionResult:
422
+ """Perform the actual action execution.
423
+
424
+ Subclasses must implement this method.
425
+
426
+ Args:
427
+ context: Execution context.
428
+
429
+ Returns:
430
+ Action result.
431
+ """
432
+ ...
433
+
434
+
435
+ class AsyncBaseAction(ABC):
436
+ """Abstract base class for async actions.
437
+
438
+ Similar to BaseAction but for asynchronous execution.
439
+ """
440
+
441
+ def __init__(self, config: ActionConfig | dict[str, Any] | None = None) -> None:
442
+ """Initialize action."""
443
+ if config is None:
444
+ self._config = ActionConfig()
445
+ elif isinstance(config, dict):
446
+ self._config = ActionConfig.from_dict(config)
447
+ else:
448
+ self._config = config
449
+
450
+ @property
451
+ def name(self) -> str:
452
+ """Get action name."""
453
+ return self._config.name or self.__class__.__name__
454
+
455
+ @property
456
+ @abstractmethod
457
+ def action_type(self) -> str:
458
+ """Get action type."""
459
+ ...
460
+
461
+ @property
462
+ def config(self) -> ActionConfig:
463
+ """Get action configuration."""
464
+ return self._config
465
+
466
+ def should_execute(self, context: ActionContext) -> bool:
467
+ """Check if action should execute."""
468
+ if not self._config.enabled:
469
+ return False
470
+
471
+ result = context.checkpoint_result
472
+ condition = self._config.notify_on
473
+
474
+ if condition == NotifyCondition.ALWAYS:
475
+ return True
476
+ elif condition == NotifyCondition.SUCCESS:
477
+ return result.status.value == "success"
478
+ elif condition == NotifyCondition.FAILURE:
479
+ return result.status.value == "failure"
480
+ elif condition == NotifyCondition.ERROR:
481
+ return result.status.value == "error"
482
+ elif condition == NotifyCondition.FAILURE_OR_ERROR:
483
+ return result.status.value in ("failure", "error")
484
+ elif condition == NotifyCondition.NOT_SUCCESS:
485
+ return result.status.value != "success"
486
+
487
+ return True
488
+
489
+ async def execute_async(self, context: ActionContext) -> ActionResult:
490
+ """Execute the action asynchronously."""
491
+ if not self.should_execute(context):
492
+ return ActionResult(
493
+ action_name=self.name,
494
+ action_type=self.action_type,
495
+ status=ActionStatus.SKIPPED,
496
+ message=f"Skipped due to notify_on={self._config.notify_on.value}",
497
+ )
498
+
499
+ started_at = datetime.now()
500
+ try:
501
+ result = await self._do_execute_async(context)
502
+ completed_at = datetime.now()
503
+ result.started_at = started_at
504
+ result.completed_at = completed_at
505
+ result.duration_ms = (completed_at - started_at).total_seconds() * 1000
506
+ return result
507
+ except Exception as e:
508
+ completed_at = datetime.now()
509
+ return ActionResult(
510
+ action_name=self.name,
511
+ action_type=self.action_type,
512
+ status=ActionStatus.FAILURE,
513
+ message=f"Action failed: {str(e)}",
514
+ started_at=started_at,
515
+ completed_at=completed_at,
516
+ duration_ms=(completed_at - started_at).total_seconds() * 1000,
517
+ error=str(e),
518
+ )
519
+
520
+ @abstractmethod
521
+ async def _do_execute_async(self, context: ActionContext) -> ActionResult:
522
+ """Perform the actual async action execution."""
523
+ ...
524
+
525
+
526
+ # =============================================================================
527
+ # Action Registry
528
+ # =============================================================================
529
+
530
+
531
+ class ActionRegistry:
532
+ """Registry for action types.
533
+
534
+ Allows registering and retrieving action implementations by name.
535
+ Supports both built-in and custom action types.
536
+
537
+ Example:
538
+ registry = ActionRegistry()
539
+ registry.register("slack", SlackNotificationAction)
540
+ registry.register("email", EmailNotificationAction)
541
+
542
+ action = registry.create("slack", config={"webhook_url": "..."})
543
+ """
544
+
545
+ def __init__(self) -> None:
546
+ """Initialize registry."""
547
+ self._actions: dict[str, type[BaseAction | AsyncBaseAction]] = {}
548
+ self._factories: dict[str, Callable[..., BaseAction | AsyncBaseAction]] = {}
549
+
550
+ def register(
551
+ self,
552
+ name: str,
553
+ action_class: type[BaseAction | AsyncBaseAction],
554
+ ) -> None:
555
+ """Register an action class.
556
+
557
+ Args:
558
+ name: Action type name.
559
+ action_class: Action class to register.
560
+ """
561
+ self._actions[name] = action_class
562
+
563
+ def register_factory(
564
+ self,
565
+ name: str,
566
+ factory: Callable[..., BaseAction | AsyncBaseAction],
567
+ ) -> None:
568
+ """Register an action factory function.
569
+
570
+ Args:
571
+ name: Action type name.
572
+ factory: Factory function that creates actions.
573
+ """
574
+ self._factories[name] = factory
575
+
576
+ def create(
577
+ self,
578
+ name: str,
579
+ config: ActionConfig | dict[str, Any] | None = None,
580
+ **kwargs: Any,
581
+ ) -> BaseAction | AsyncBaseAction:
582
+ """Create an action instance.
583
+
584
+ Args:
585
+ name: Action type name.
586
+ config: Action configuration.
587
+ **kwargs: Additional arguments for the action.
588
+
589
+ Returns:
590
+ Action instance.
591
+
592
+ Raises:
593
+ KeyError: If action type is not registered.
594
+ """
595
+ if name in self._factories:
596
+ return self._factories[name](config=config, **kwargs)
597
+
598
+ if name in self._actions:
599
+ return self._actions[name](config=config, **kwargs)
600
+
601
+ raise KeyError(f"Action type not found: {name}")
602
+
603
+ def list_actions(self) -> list[str]:
604
+ """List all registered action types.
605
+
606
+ Returns:
607
+ List of action type names.
608
+ """
609
+ return list(set(self._actions.keys()) | set(self._factories.keys()))
610
+
611
+ def has_action(self, name: str) -> bool:
612
+ """Check if an action type is registered.
613
+
614
+ Args:
615
+ name: Action type name.
616
+
617
+ Returns:
618
+ True if action type is registered.
619
+ """
620
+ return name in self._actions or name in self._factories
621
+
622
+
623
+ # Global action registry
624
+ _action_registry: ActionRegistry | None = None
625
+
626
+
627
+ def get_action_registry() -> ActionRegistry:
628
+ """Get the global action registry.
629
+
630
+ Returns:
631
+ Global ActionRegistry instance.
632
+ """
633
+ global _action_registry
634
+ if _action_registry is None:
635
+ _action_registry = ActionRegistry()
636
+ return _action_registry
637
+
638
+
639
+ def register_action(name: str) -> Callable[[type], type]:
640
+ """Decorator to register an action class.
641
+
642
+ Example:
643
+ @register_action("my_custom")
644
+ class MyCustomAction(BaseAction):
645
+ ...
646
+ """
647
+
648
+ def decorator(cls: type) -> type:
649
+ get_action_registry().register(name, cls)
650
+ return cls
651
+
652
+ return decorator