pyworkflow-engine 0.1.7__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 (196) hide show
  1. dashboard/backend/app/__init__.py +1 -0
  2. dashboard/backend/app/config.py +32 -0
  3. dashboard/backend/app/controllers/__init__.py +6 -0
  4. dashboard/backend/app/controllers/run_controller.py +86 -0
  5. dashboard/backend/app/controllers/workflow_controller.py +33 -0
  6. dashboard/backend/app/dependencies/__init__.py +5 -0
  7. dashboard/backend/app/dependencies/storage.py +50 -0
  8. dashboard/backend/app/repositories/__init__.py +6 -0
  9. dashboard/backend/app/repositories/run_repository.py +80 -0
  10. dashboard/backend/app/repositories/workflow_repository.py +27 -0
  11. dashboard/backend/app/rest/__init__.py +8 -0
  12. dashboard/backend/app/rest/v1/__init__.py +12 -0
  13. dashboard/backend/app/rest/v1/health.py +33 -0
  14. dashboard/backend/app/rest/v1/runs.py +133 -0
  15. dashboard/backend/app/rest/v1/workflows.py +41 -0
  16. dashboard/backend/app/schemas/__init__.py +23 -0
  17. dashboard/backend/app/schemas/common.py +16 -0
  18. dashboard/backend/app/schemas/event.py +24 -0
  19. dashboard/backend/app/schemas/hook.py +25 -0
  20. dashboard/backend/app/schemas/run.py +54 -0
  21. dashboard/backend/app/schemas/step.py +28 -0
  22. dashboard/backend/app/schemas/workflow.py +31 -0
  23. dashboard/backend/app/server.py +87 -0
  24. dashboard/backend/app/services/__init__.py +6 -0
  25. dashboard/backend/app/services/run_service.py +240 -0
  26. dashboard/backend/app/services/workflow_service.py +155 -0
  27. dashboard/backend/main.py +18 -0
  28. docs/concepts/cancellation.mdx +362 -0
  29. docs/concepts/continue-as-new.mdx +434 -0
  30. docs/concepts/events.mdx +266 -0
  31. docs/concepts/fault-tolerance.mdx +370 -0
  32. docs/concepts/hooks.mdx +552 -0
  33. docs/concepts/limitations.mdx +167 -0
  34. docs/concepts/schedules.mdx +775 -0
  35. docs/concepts/sleep.mdx +312 -0
  36. docs/concepts/steps.mdx +301 -0
  37. docs/concepts/workflows.mdx +255 -0
  38. docs/guides/cli.mdx +942 -0
  39. docs/guides/configuration.mdx +560 -0
  40. docs/introduction.mdx +155 -0
  41. docs/quickstart.mdx +279 -0
  42. examples/__init__.py +1 -0
  43. examples/celery/__init__.py +1 -0
  44. examples/celery/durable/docker-compose.yml +55 -0
  45. examples/celery/durable/pyworkflow.config.yaml +12 -0
  46. examples/celery/durable/workflows/__init__.py +122 -0
  47. examples/celery/durable/workflows/basic.py +87 -0
  48. examples/celery/durable/workflows/batch_processing.py +102 -0
  49. examples/celery/durable/workflows/cancellation.py +273 -0
  50. examples/celery/durable/workflows/child_workflow_patterns.py +240 -0
  51. examples/celery/durable/workflows/child_workflows.py +202 -0
  52. examples/celery/durable/workflows/continue_as_new.py +260 -0
  53. examples/celery/durable/workflows/fault_tolerance.py +210 -0
  54. examples/celery/durable/workflows/hooks.py +211 -0
  55. examples/celery/durable/workflows/idempotency.py +112 -0
  56. examples/celery/durable/workflows/long_running.py +99 -0
  57. examples/celery/durable/workflows/retries.py +101 -0
  58. examples/celery/durable/workflows/schedules.py +209 -0
  59. examples/celery/transient/01_basic_workflow.py +91 -0
  60. examples/celery/transient/02_fault_tolerance.py +257 -0
  61. examples/celery/transient/__init__.py +20 -0
  62. examples/celery/transient/pyworkflow.config.yaml +25 -0
  63. examples/local/__init__.py +1 -0
  64. examples/local/durable/01_basic_workflow.py +94 -0
  65. examples/local/durable/02_file_storage.py +132 -0
  66. examples/local/durable/03_retries.py +169 -0
  67. examples/local/durable/04_long_running.py +119 -0
  68. examples/local/durable/05_event_log.py +145 -0
  69. examples/local/durable/06_idempotency.py +148 -0
  70. examples/local/durable/07_hooks.py +334 -0
  71. examples/local/durable/08_cancellation.py +233 -0
  72. examples/local/durable/09_child_workflows.py +198 -0
  73. examples/local/durable/10_child_workflow_patterns.py +265 -0
  74. examples/local/durable/11_continue_as_new.py +249 -0
  75. examples/local/durable/12_schedules.py +198 -0
  76. examples/local/durable/__init__.py +1 -0
  77. examples/local/transient/01_quick_tasks.py +87 -0
  78. examples/local/transient/02_retries.py +130 -0
  79. examples/local/transient/03_sleep.py +141 -0
  80. examples/local/transient/__init__.py +1 -0
  81. pyworkflow/__init__.py +256 -0
  82. pyworkflow/aws/__init__.py +68 -0
  83. pyworkflow/aws/context.py +234 -0
  84. pyworkflow/aws/handler.py +184 -0
  85. pyworkflow/aws/testing.py +310 -0
  86. pyworkflow/celery/__init__.py +41 -0
  87. pyworkflow/celery/app.py +198 -0
  88. pyworkflow/celery/scheduler.py +315 -0
  89. pyworkflow/celery/tasks.py +1746 -0
  90. pyworkflow/cli/__init__.py +132 -0
  91. pyworkflow/cli/__main__.py +6 -0
  92. pyworkflow/cli/commands/__init__.py +1 -0
  93. pyworkflow/cli/commands/hooks.py +640 -0
  94. pyworkflow/cli/commands/quickstart.py +495 -0
  95. pyworkflow/cli/commands/runs.py +773 -0
  96. pyworkflow/cli/commands/scheduler.py +130 -0
  97. pyworkflow/cli/commands/schedules.py +794 -0
  98. pyworkflow/cli/commands/setup.py +703 -0
  99. pyworkflow/cli/commands/worker.py +413 -0
  100. pyworkflow/cli/commands/workflows.py +1257 -0
  101. pyworkflow/cli/output/__init__.py +1 -0
  102. pyworkflow/cli/output/formatters.py +321 -0
  103. pyworkflow/cli/output/styles.py +121 -0
  104. pyworkflow/cli/utils/__init__.py +1 -0
  105. pyworkflow/cli/utils/async_helpers.py +30 -0
  106. pyworkflow/cli/utils/config.py +130 -0
  107. pyworkflow/cli/utils/config_generator.py +344 -0
  108. pyworkflow/cli/utils/discovery.py +53 -0
  109. pyworkflow/cli/utils/docker_manager.py +651 -0
  110. pyworkflow/cli/utils/interactive.py +364 -0
  111. pyworkflow/cli/utils/storage.py +115 -0
  112. pyworkflow/config.py +329 -0
  113. pyworkflow/context/__init__.py +63 -0
  114. pyworkflow/context/aws.py +230 -0
  115. pyworkflow/context/base.py +416 -0
  116. pyworkflow/context/local.py +930 -0
  117. pyworkflow/context/mock.py +381 -0
  118. pyworkflow/core/__init__.py +0 -0
  119. pyworkflow/core/exceptions.py +353 -0
  120. pyworkflow/core/registry.py +313 -0
  121. pyworkflow/core/scheduled.py +328 -0
  122. pyworkflow/core/step.py +494 -0
  123. pyworkflow/core/workflow.py +294 -0
  124. pyworkflow/discovery.py +248 -0
  125. pyworkflow/engine/__init__.py +0 -0
  126. pyworkflow/engine/events.py +879 -0
  127. pyworkflow/engine/executor.py +682 -0
  128. pyworkflow/engine/replay.py +273 -0
  129. pyworkflow/observability/__init__.py +19 -0
  130. pyworkflow/observability/logging.py +234 -0
  131. pyworkflow/primitives/__init__.py +33 -0
  132. pyworkflow/primitives/child_handle.py +174 -0
  133. pyworkflow/primitives/child_workflow.py +372 -0
  134. pyworkflow/primitives/continue_as_new.py +101 -0
  135. pyworkflow/primitives/define_hook.py +150 -0
  136. pyworkflow/primitives/hooks.py +97 -0
  137. pyworkflow/primitives/resume_hook.py +210 -0
  138. pyworkflow/primitives/schedule.py +545 -0
  139. pyworkflow/primitives/shield.py +96 -0
  140. pyworkflow/primitives/sleep.py +100 -0
  141. pyworkflow/runtime/__init__.py +21 -0
  142. pyworkflow/runtime/base.py +179 -0
  143. pyworkflow/runtime/celery.py +310 -0
  144. pyworkflow/runtime/factory.py +101 -0
  145. pyworkflow/runtime/local.py +706 -0
  146. pyworkflow/scheduler/__init__.py +9 -0
  147. pyworkflow/scheduler/local.py +248 -0
  148. pyworkflow/serialization/__init__.py +0 -0
  149. pyworkflow/serialization/decoder.py +146 -0
  150. pyworkflow/serialization/encoder.py +162 -0
  151. pyworkflow/storage/__init__.py +54 -0
  152. pyworkflow/storage/base.py +612 -0
  153. pyworkflow/storage/config.py +185 -0
  154. pyworkflow/storage/dynamodb.py +1315 -0
  155. pyworkflow/storage/file.py +827 -0
  156. pyworkflow/storage/memory.py +549 -0
  157. pyworkflow/storage/postgres.py +1161 -0
  158. pyworkflow/storage/schemas.py +486 -0
  159. pyworkflow/storage/sqlite.py +1136 -0
  160. pyworkflow/utils/__init__.py +0 -0
  161. pyworkflow/utils/duration.py +177 -0
  162. pyworkflow/utils/schedule.py +391 -0
  163. pyworkflow_engine-0.1.7.dist-info/METADATA +687 -0
  164. pyworkflow_engine-0.1.7.dist-info/RECORD +196 -0
  165. pyworkflow_engine-0.1.7.dist-info/WHEEL +5 -0
  166. pyworkflow_engine-0.1.7.dist-info/entry_points.txt +2 -0
  167. pyworkflow_engine-0.1.7.dist-info/licenses/LICENSE +21 -0
  168. pyworkflow_engine-0.1.7.dist-info/top_level.txt +5 -0
  169. tests/examples/__init__.py +0 -0
  170. tests/integration/__init__.py +0 -0
  171. tests/integration/test_cancellation.py +330 -0
  172. tests/integration/test_child_workflows.py +439 -0
  173. tests/integration/test_continue_as_new.py +428 -0
  174. tests/integration/test_dynamodb_storage.py +1146 -0
  175. tests/integration/test_fault_tolerance.py +369 -0
  176. tests/integration/test_schedule_storage.py +484 -0
  177. tests/unit/__init__.py +0 -0
  178. tests/unit/backends/__init__.py +1 -0
  179. tests/unit/backends/test_dynamodb_storage.py +1554 -0
  180. tests/unit/backends/test_postgres_storage.py +1281 -0
  181. tests/unit/backends/test_sqlite_storage.py +1460 -0
  182. tests/unit/conftest.py +41 -0
  183. tests/unit/test_cancellation.py +364 -0
  184. tests/unit/test_child_workflows.py +680 -0
  185. tests/unit/test_continue_as_new.py +441 -0
  186. tests/unit/test_event_limits.py +316 -0
  187. tests/unit/test_executor.py +320 -0
  188. tests/unit/test_fault_tolerance.py +334 -0
  189. tests/unit/test_hooks.py +495 -0
  190. tests/unit/test_registry.py +261 -0
  191. tests/unit/test_replay.py +420 -0
  192. tests/unit/test_schedule_schemas.py +285 -0
  193. tests/unit/test_schedule_utils.py +286 -0
  194. tests/unit/test_scheduled_workflow.py +274 -0
  195. tests/unit/test_step.py +353 -0
  196. tests/unit/test_workflow.py +243 -0
@@ -0,0 +1,353 @@
1
+ """
2
+ Exception classes for workflow error handling.
3
+
4
+ PyWorkflow distinguishes between fatal errors (don't retry) and retriable errors
5
+ (automatic retry with configurable delay).
6
+ """
7
+
8
+ from datetime import datetime, timedelta
9
+ from typing import Any
10
+
11
+
12
+ class WorkflowError(Exception):
13
+ """Base exception for all workflow-related errors."""
14
+
15
+ pass
16
+
17
+
18
+ class FatalError(WorkflowError):
19
+ """
20
+ Non-retriable error that permanently fails the workflow.
21
+
22
+ Use FatalError for business logic errors, validation failures, or any error
23
+ where retrying won't help (e.g., insufficient funds, resource not found).
24
+
25
+ Example:
26
+ @step
27
+ async def validate_payment(order_id: str):
28
+ order = await get_order(order_id)
29
+ if order.total > customer.balance:
30
+ raise FatalError("Insufficient funds")
31
+ """
32
+
33
+ def __init__(self, message: str, **kwargs: Any) -> None:
34
+ super().__init__(message)
35
+ self.message = message
36
+ self.metadata = kwargs
37
+
38
+
39
+ class CancellationError(WorkflowError):
40
+ """
41
+ Raised when a workflow or step is cancelled.
42
+
43
+ Use CancellationError to handle graceful cancellation in workflows.
44
+ Workflows can catch this exception to perform cleanup operations
45
+ before terminating.
46
+
47
+ Note:
48
+ CancellationError is raised at checkpoint boundaries (before steps,
49
+ sleeps, hooks), not during step execution. Long-running steps can
50
+ call ``ctx.check_cancellation()`` for cooperative cancellation.
51
+
52
+ Example:
53
+ @workflow
54
+ async def order_workflow(order_id: str):
55
+ try:
56
+ await reserve_inventory()
57
+ await charge_payment()
58
+ await ship_order()
59
+ except CancellationError:
60
+ # Cleanup on cancellation
61
+ await release_inventory()
62
+ await refund_payment()
63
+ raise # Re-raise to mark as cancelled
64
+
65
+ Attributes:
66
+ message: Description of the cancellation
67
+ reason: Optional reason for cancellation (e.g., "user_requested", "timeout")
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ message: str = "Workflow was cancelled",
73
+ reason: str | None = None,
74
+ ) -> None:
75
+ super().__init__(message)
76
+ self.message = message
77
+ self.reason = reason
78
+
79
+
80
+ class RetryableError(WorkflowError):
81
+ """
82
+ Retriable error that triggers automatic retry with optional delay.
83
+
84
+ Use RetryableError for temporary failures like network errors, rate limits,
85
+ or transient service unavailability.
86
+
87
+ Args:
88
+ message: Error description
89
+ retry_after: Delay before retry as:
90
+ - str: Duration string ("30s", "5m", "1h")
91
+ - int: Seconds
92
+ - timedelta: Python timedelta object
93
+ - datetime: Specific time to retry
94
+ - None: Use default/exponential backoff
95
+
96
+ Examples:
97
+ # Retry with default delay (exponential backoff)
98
+ raise RetryableError("Network timeout")
99
+
100
+ # Retry after specific delay
101
+ raise RetryableError("Rate limited", retry_after="60s")
102
+
103
+ # Retry at specific time (from API retry-after header)
104
+ retry_time = datetime.now() + timedelta(seconds=api_retry_after)
105
+ raise RetryableError("API rate limit", retry_after=retry_time)
106
+ """
107
+
108
+ def __init__(
109
+ self,
110
+ message: str,
111
+ retry_after: str | int | timedelta | datetime | None = None,
112
+ **kwargs: Any,
113
+ ) -> None:
114
+ super().__init__(message)
115
+ self.message = message
116
+ self.retry_after = retry_after
117
+ self.metadata = kwargs
118
+
119
+ def get_retry_delay_seconds(self) -> int | None:
120
+ """
121
+ Get retry delay in seconds.
122
+
123
+ Returns:
124
+ Number of seconds to wait before retry, or None for default
125
+ """
126
+ if self.retry_after is None:
127
+ return None
128
+
129
+ if isinstance(self.retry_after, int):
130
+ return self.retry_after
131
+
132
+ if isinstance(self.retry_after, str):
133
+ return self._parse_duration_string(self.retry_after)
134
+
135
+ if isinstance(self.retry_after, timedelta):
136
+ return int(self.retry_after.total_seconds())
137
+
138
+ if isinstance(self.retry_after, datetime):
139
+ delta = self.retry_after - datetime.utcnow()
140
+ return max(0, int(delta.total_seconds()))
141
+
142
+ return None
143
+
144
+ @staticmethod
145
+ def _parse_duration_string(duration: str) -> int:
146
+ """Parse duration string like '30s', '5m', '1h' into seconds."""
147
+ import re
148
+
149
+ pattern = r"^(\d+)([smhdw])$"
150
+ match = re.match(pattern, duration.lower())
151
+ if not match:
152
+ raise ValueError(f"Invalid duration format: {duration}")
153
+
154
+ value, unit = match.groups()
155
+ value = int(value)
156
+
157
+ multipliers = {
158
+ "s": 1,
159
+ "m": 60,
160
+ "h": 3600,
161
+ "d": 86400,
162
+ "w": 604800,
163
+ }
164
+
165
+ return value * multipliers[unit]
166
+
167
+
168
+ class SuspensionSignal(Exception):
169
+ """
170
+ Internal signal to suspend workflow execution (not for user use).
171
+
172
+ Raised when a workflow hits a suspension point (sleep, hook) to signal
173
+ that it should pause and schedule resumption.
174
+
175
+ This is an internal implementation detail and should not be caught or
176
+ raised by user code.
177
+ """
178
+
179
+ def __init__(self, reason: str, **data: Any) -> None:
180
+ super().__init__(f"Workflow suspended: {reason}")
181
+ self.reason = reason
182
+ self.data = data
183
+
184
+
185
+ class ContinueAsNewSignal(Exception):
186
+ """
187
+ Internal signal to continue workflow as a new run.
188
+
189
+ Raised when workflow calls continue_as_new() to terminate
190
+ current execution and start fresh with new event history.
191
+
192
+ This is an internal implementation detail and should not be caught or
193
+ raised by user code.
194
+ """
195
+
196
+ def __init__(
197
+ self,
198
+ workflow_args: tuple = (),
199
+ workflow_kwargs: dict[str, Any] | None = None,
200
+ ) -> None:
201
+ super().__init__("Workflow continuing as new execution")
202
+ self.workflow_args = workflow_args
203
+ self.workflow_kwargs = workflow_kwargs or {}
204
+
205
+
206
+ class WorkflowNotFoundError(WorkflowError):
207
+ """Raised when a workflow run cannot be found."""
208
+
209
+ def __init__(self, run_id: str) -> None:
210
+ super().__init__(f"Workflow run not found: {run_id}")
211
+ self.run_id = run_id
212
+
213
+
214
+ class WorkflowTimeoutError(WorkflowError):
215
+ """Raised when a workflow exceeds its maximum duration."""
216
+
217
+ def __init__(self, run_id: str, max_duration: str) -> None:
218
+ super().__init__(f"Workflow {run_id} exceeded max duration: {max_duration}")
219
+ self.run_id = run_id
220
+ self.max_duration = max_duration
221
+
222
+
223
+ class StepNotFoundError(WorkflowError):
224
+ """Raised when a step cannot be found."""
225
+
226
+ def __init__(self, step_id: str) -> None:
227
+ super().__init__(f"Step not found: {step_id}")
228
+ self.step_id = step_id
229
+
230
+
231
+ class HookExpiredError(WorkflowError):
232
+ """Raised when a hook has expired without receiving data."""
233
+
234
+ def __init__(self, hook_id: str) -> None:
235
+ super().__init__(f"Hook expired: {hook_id}")
236
+ self.hook_id = hook_id
237
+
238
+
239
+ class InvalidTokenError(WorkflowError):
240
+ """Raised when a hook token is invalid or doesn't match."""
241
+
242
+ def __init__(self, hook_id: str) -> None:
243
+ super().__init__(f"Invalid token for hook: {hook_id}")
244
+ self.hook_id = hook_id
245
+
246
+
247
+ class HookNotFoundError(WorkflowError):
248
+ """Raised when a hook cannot be found by token."""
249
+
250
+ def __init__(self, token: str) -> None:
251
+ super().__init__(f"Hook not found for token: {token}")
252
+ self.token = token
253
+
254
+
255
+ class HookAlreadyReceivedError(WorkflowError):
256
+ """Raised when attempting to resume a hook that was already resumed."""
257
+
258
+ def __init__(self, hook_id: str) -> None:
259
+ super().__init__(f"Hook already received: {hook_id}")
260
+ self.hook_id = hook_id
261
+
262
+
263
+ class EventLimitExceededError(FatalError):
264
+ """
265
+ Raised when workflow exceeds maximum allowed events (hard limit).
266
+
267
+ This is a safety mechanism to prevent runaway workflows from consuming
268
+ excessive resources. The default limit is 50,000 events.
269
+ """
270
+
271
+ def __init__(self, run_id: str, event_count: int, limit: int) -> None:
272
+ super().__init__(
273
+ f"Workflow {run_id} exceeded maximum event limit: {event_count} >= {limit}"
274
+ )
275
+ self.run_id = run_id
276
+ self.event_count = event_count
277
+ self.limit = limit
278
+
279
+
280
+ class WorkflowAlreadyRunningError(WorkflowError):
281
+ """Raised when attempting to start a workflow that's already running."""
282
+
283
+ def __init__(self, run_id: str) -> None:
284
+ super().__init__(f"Workflow already running: {run_id}")
285
+ self.run_id = run_id
286
+
287
+
288
+ class SerializationError(WorkflowError):
289
+ """Raised when data cannot be serialized or deserialized."""
290
+
291
+ def __init__(self, message: str, data_type: type | None = None) -> None:
292
+ super().__init__(message)
293
+ self.data_type = data_type
294
+
295
+
296
+ class ContextError(WorkflowError):
297
+ """Raised when workflow context is not available or invalid."""
298
+
299
+ pass
300
+
301
+
302
+ class ChildWorkflowError(WorkflowError):
303
+ """Base exception for child workflow errors."""
304
+
305
+ pass
306
+
307
+
308
+ class ChildWorkflowFailedError(ChildWorkflowError):
309
+ """
310
+ Raised when a child workflow fails.
311
+
312
+ This exception is raised in the parent workflow when a child
313
+ workflow fails and wait_for_completion=True.
314
+
315
+ Attributes:
316
+ child_run_id: The failed child's run ID
317
+ child_workflow_name: The failed child's workflow name
318
+ error: The error message from the child
319
+ error_type: The exception type that caused the failure
320
+ """
321
+
322
+ def __init__(
323
+ self,
324
+ child_run_id: str,
325
+ child_workflow_name: str,
326
+ error: str,
327
+ error_type: str,
328
+ ) -> None:
329
+ super().__init__(f"Child workflow '{child_workflow_name}' ({child_run_id}) failed: {error}")
330
+ self.child_run_id = child_run_id
331
+ self.child_workflow_name = child_workflow_name
332
+ self.error = error
333
+ self.error_type = error_type
334
+
335
+
336
+ class MaxNestingDepthError(ChildWorkflowError):
337
+ """
338
+ Raised when maximum child workflow nesting depth is exceeded.
339
+
340
+ PyWorkflow limits child workflow nesting to 3 levels to prevent
341
+ runaway recursion and maintain reasonable execution complexity.
342
+ """
343
+
344
+ MAX_DEPTH = 3
345
+
346
+ def __init__(self, current_depth: int) -> None:
347
+ super().__init__(
348
+ f"Maximum nesting depth of {self.MAX_DEPTH} exceeded. "
349
+ f"Current depth: {current_depth}. "
350
+ f"Consider restructuring your workflow to reduce nesting."
351
+ )
352
+ self.current_depth = current_depth
353
+ self.max_depth = self.MAX_DEPTH
@@ -0,0 +1,313 @@
1
+ """
2
+ Global registry for workflows and steps.
3
+
4
+ The registry tracks all decorated workflows and steps, enabling:
5
+ - Lookup by name
6
+ - Metadata access
7
+ - Validation
8
+ """
9
+
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+
15
+ @dataclass
16
+ class WorkflowMetadata:
17
+ """Metadata for a registered workflow."""
18
+
19
+ name: str
20
+ func: Callable[..., Any]
21
+ original_func: Callable[..., Any] # Unwrapped function
22
+ max_duration: str | None = None
23
+ tags: list[str] | None = None
24
+ description: str | None = None # Docstring from the workflow function
25
+
26
+ def __post_init__(self) -> None:
27
+ if self.tags is None:
28
+ self.tags = []
29
+ # Auto-extract description from docstring if not provided
30
+ if self.description is None and self.original_func.__doc__:
31
+ self.description = self.original_func.__doc__.strip()
32
+
33
+
34
+ @dataclass
35
+ class StepMetadata:
36
+ """Metadata for a registered step."""
37
+
38
+ name: str
39
+ func: Callable[..., Any]
40
+ original_func: Callable[..., Any] # Unwrapped function
41
+ max_retries: int = 3
42
+ retry_delay: str = "exponential"
43
+ timeout: int | None = None
44
+ metadata: dict[str, Any] | None = None
45
+
46
+ def __post_init__(self) -> None:
47
+ if self.metadata is None:
48
+ self.metadata = {}
49
+
50
+
51
+ class WorkflowRegistry:
52
+ """
53
+ Global registry for workflows and steps.
54
+
55
+ This is a singleton that tracks all @workflow and @step decorated functions.
56
+ """
57
+
58
+ def __init__(self) -> None:
59
+ self._workflows: dict[str, WorkflowMetadata] = {}
60
+ self._steps: dict[str, StepMetadata] = {}
61
+ self._workflow_by_func: dict[Callable[..., Any], str] = {}
62
+ self._step_by_func: dict[Callable[..., Any], str] = {}
63
+
64
+ # Workflow registration
65
+
66
+ def register_workflow(
67
+ self,
68
+ name: str,
69
+ func: Callable[..., Any],
70
+ original_func: Callable[..., Any],
71
+ max_duration: str | None = None,
72
+ tags: list[str] | None = None,
73
+ ) -> None:
74
+ """
75
+ Register a workflow.
76
+
77
+ Args:
78
+ name: Workflow name (unique identifier)
79
+ func: Wrapped workflow function
80
+ original_func: Original unwrapped function
81
+ max_duration: Optional maximum duration
82
+ tags: Optional list of tags (max 3)
83
+ """
84
+ if name in self._workflows:
85
+ existing = self._workflows[name]
86
+ if existing.original_func is not original_func:
87
+ raise ValueError(
88
+ f"Workflow name '{name}' already registered with different function"
89
+ )
90
+ # Allow re-registration with same function (e.g., during hot reload)
91
+ return
92
+
93
+ workflow_meta = WorkflowMetadata(
94
+ name=name,
95
+ func=func,
96
+ original_func=original_func,
97
+ max_duration=max_duration,
98
+ tags=tags or [],
99
+ )
100
+
101
+ self._workflows[name] = workflow_meta
102
+ self._workflow_by_func[func] = name
103
+ self._workflow_by_func[original_func] = name
104
+
105
+ def get_workflow(self, name: str) -> WorkflowMetadata | None:
106
+ """
107
+ Get workflow metadata by name.
108
+
109
+ Args:
110
+ name: Workflow name
111
+
112
+ Returns:
113
+ WorkflowMetadata if found, None otherwise
114
+ """
115
+ return self._workflows.get(name)
116
+
117
+ def get_workflow_by_func(self, func: Callable[..., Any]) -> WorkflowMetadata | None:
118
+ """
119
+ Get workflow metadata by function reference.
120
+
121
+ Args:
122
+ func: Workflow function
123
+
124
+ Returns:
125
+ WorkflowMetadata if found, None otherwise
126
+ """
127
+ name = self._workflow_by_func.get(func)
128
+ return self._workflows.get(name) if name else None
129
+
130
+ def get_workflow_name(self, func: Callable[..., Any]) -> str | None:
131
+ """
132
+ Get workflow name from function reference.
133
+
134
+ Args:
135
+ func: Workflow function
136
+
137
+ Returns:
138
+ Workflow name if found, None otherwise
139
+ """
140
+ return self._workflow_by_func.get(func)
141
+
142
+ def list_workflows(self) -> dict[str, WorkflowMetadata]:
143
+ """Get all registered workflows."""
144
+ return self._workflows.copy()
145
+
146
+ # Step registration
147
+
148
+ def register_step(
149
+ self,
150
+ name: str,
151
+ func: Callable[..., Any],
152
+ original_func: Callable[..., Any],
153
+ max_retries: int = 3,
154
+ retry_delay: str = "exponential",
155
+ timeout: int | None = None,
156
+ metadata: dict[str, Any] | None = None,
157
+ ) -> None:
158
+ """
159
+ Register a step.
160
+
161
+ Args:
162
+ name: Step name
163
+ func: Wrapped step function
164
+ original_func: Original unwrapped function
165
+ max_retries: Maximum retry attempts
166
+ retry_delay: Retry delay strategy
167
+ timeout: Optional timeout in seconds
168
+ metadata: Optional metadata dict
169
+ """
170
+ if name in self._steps:
171
+ existing = self._steps[name]
172
+ if existing.original_func is not original_func:
173
+ raise ValueError(f"Step name '{name}' already registered with different function")
174
+ # Allow re-registration
175
+ return
176
+
177
+ step_meta = StepMetadata(
178
+ name=name,
179
+ func=func,
180
+ original_func=original_func,
181
+ max_retries=max_retries,
182
+ retry_delay=retry_delay,
183
+ timeout=timeout,
184
+ metadata=metadata or {},
185
+ )
186
+
187
+ self._steps[name] = step_meta
188
+ self._step_by_func[func] = name
189
+ self._step_by_func[original_func] = name
190
+
191
+ def get_step(self, name: str) -> StepMetadata | None:
192
+ """
193
+ Get step metadata by name.
194
+
195
+ Args:
196
+ name: Step name
197
+
198
+ Returns:
199
+ StepMetadata if found, None otherwise
200
+ """
201
+ return self._steps.get(name)
202
+
203
+ def get_step_by_func(self, func: Callable[..., Any]) -> StepMetadata | None:
204
+ """
205
+ Get step metadata by function reference.
206
+
207
+ Args:
208
+ func: Step function
209
+
210
+ Returns:
211
+ StepMetadata if found, None otherwise
212
+ """
213
+ name = self._step_by_func.get(func)
214
+ return self._steps.get(name) if name else None
215
+
216
+ def get_step_name(self, func: Callable[..., Any]) -> str | None:
217
+ """
218
+ Get step name from function reference.
219
+
220
+ Args:
221
+ func: Step function
222
+
223
+ Returns:
224
+ Step name if found, None otherwise
225
+ """
226
+ return self._step_by_func.get(func)
227
+
228
+ def list_steps(self) -> dict[str, StepMetadata]:
229
+ """Get all registered steps."""
230
+ return self._steps.copy()
231
+
232
+ def clear(self) -> None:
233
+ """Clear all registrations (useful for testing)."""
234
+ self._workflows.clear()
235
+ self._steps.clear()
236
+ self._workflow_by_func.clear()
237
+ self._step_by_func.clear()
238
+
239
+
240
+ # Global singleton registry
241
+ _registry = WorkflowRegistry()
242
+
243
+
244
+ # Public API
245
+
246
+
247
+ def register_workflow(
248
+ name: str,
249
+ func: Callable[..., Any],
250
+ original_func: Callable[..., Any],
251
+ max_duration: str | None = None,
252
+ tags: list[str] | None = None,
253
+ ) -> None:
254
+ """Register a workflow in the global registry."""
255
+ _registry.register_workflow(name, func, original_func, max_duration, tags)
256
+
257
+
258
+ def get_workflow(name: str) -> WorkflowMetadata | None:
259
+ """Get workflow metadata from global registry."""
260
+ return _registry.get_workflow(name)
261
+
262
+
263
+ def get_workflow_by_func(func: Callable[..., Any]) -> WorkflowMetadata | None:
264
+ """Get workflow metadata by function from global registry."""
265
+ return _registry.get_workflow_by_func(func)
266
+
267
+
268
+ def get_workflow_name(func: Callable[..., Any]) -> str | None:
269
+ """Get workflow name from function in global registry."""
270
+ return _registry.get_workflow_name(func)
271
+
272
+
273
+ def list_workflows() -> dict[str, WorkflowMetadata]:
274
+ """List all workflows in global registry."""
275
+ return _registry.list_workflows()
276
+
277
+
278
+ def register_step(
279
+ name: str,
280
+ func: Callable[..., Any],
281
+ original_func: Callable[..., Any],
282
+ max_retries: int = 3,
283
+ retry_delay: str = "exponential",
284
+ timeout: int | None = None,
285
+ metadata: dict[str, Any] | None = None,
286
+ ) -> None:
287
+ """Register a step in the global registry."""
288
+ _registry.register_step(name, func, original_func, max_retries, retry_delay, timeout, metadata)
289
+
290
+
291
+ def get_step(name: str) -> StepMetadata | None:
292
+ """Get step metadata from global registry."""
293
+ return _registry.get_step(name)
294
+
295
+
296
+ def get_step_by_func(func: Callable[..., Any]) -> StepMetadata | None:
297
+ """Get step metadata by function from global registry."""
298
+ return _registry.get_step_by_func(func)
299
+
300
+
301
+ def get_step_name(func: Callable[..., Any]) -> str | None:
302
+ """Get step name from function in global registry."""
303
+ return _registry.get_step_name(func)
304
+
305
+
306
+ def list_steps() -> dict[str, StepMetadata]:
307
+ """List all steps in global registry."""
308
+ return _registry.list_steps()
309
+
310
+
311
+ def clear_registry() -> None:
312
+ """Clear the global registry (for testing)."""
313
+ _registry.clear()