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.
- dashboard/backend/app/__init__.py +1 -0
- dashboard/backend/app/config.py +32 -0
- dashboard/backend/app/controllers/__init__.py +6 -0
- dashboard/backend/app/controllers/run_controller.py +86 -0
- dashboard/backend/app/controllers/workflow_controller.py +33 -0
- dashboard/backend/app/dependencies/__init__.py +5 -0
- dashboard/backend/app/dependencies/storage.py +50 -0
- dashboard/backend/app/repositories/__init__.py +6 -0
- dashboard/backend/app/repositories/run_repository.py +80 -0
- dashboard/backend/app/repositories/workflow_repository.py +27 -0
- dashboard/backend/app/rest/__init__.py +8 -0
- dashboard/backend/app/rest/v1/__init__.py +12 -0
- dashboard/backend/app/rest/v1/health.py +33 -0
- dashboard/backend/app/rest/v1/runs.py +133 -0
- dashboard/backend/app/rest/v1/workflows.py +41 -0
- dashboard/backend/app/schemas/__init__.py +23 -0
- dashboard/backend/app/schemas/common.py +16 -0
- dashboard/backend/app/schemas/event.py +24 -0
- dashboard/backend/app/schemas/hook.py +25 -0
- dashboard/backend/app/schemas/run.py +54 -0
- dashboard/backend/app/schemas/step.py +28 -0
- dashboard/backend/app/schemas/workflow.py +31 -0
- dashboard/backend/app/server.py +87 -0
- dashboard/backend/app/services/__init__.py +6 -0
- dashboard/backend/app/services/run_service.py +240 -0
- dashboard/backend/app/services/workflow_service.py +155 -0
- dashboard/backend/main.py +18 -0
- docs/concepts/cancellation.mdx +362 -0
- docs/concepts/continue-as-new.mdx +434 -0
- docs/concepts/events.mdx +266 -0
- docs/concepts/fault-tolerance.mdx +370 -0
- docs/concepts/hooks.mdx +552 -0
- docs/concepts/limitations.mdx +167 -0
- docs/concepts/schedules.mdx +775 -0
- docs/concepts/sleep.mdx +312 -0
- docs/concepts/steps.mdx +301 -0
- docs/concepts/workflows.mdx +255 -0
- docs/guides/cli.mdx +942 -0
- docs/guides/configuration.mdx +560 -0
- docs/introduction.mdx +155 -0
- docs/quickstart.mdx +279 -0
- examples/__init__.py +1 -0
- examples/celery/__init__.py +1 -0
- examples/celery/durable/docker-compose.yml +55 -0
- examples/celery/durable/pyworkflow.config.yaml +12 -0
- examples/celery/durable/workflows/__init__.py +122 -0
- examples/celery/durable/workflows/basic.py +87 -0
- examples/celery/durable/workflows/batch_processing.py +102 -0
- examples/celery/durable/workflows/cancellation.py +273 -0
- examples/celery/durable/workflows/child_workflow_patterns.py +240 -0
- examples/celery/durable/workflows/child_workflows.py +202 -0
- examples/celery/durable/workflows/continue_as_new.py +260 -0
- examples/celery/durable/workflows/fault_tolerance.py +210 -0
- examples/celery/durable/workflows/hooks.py +211 -0
- examples/celery/durable/workflows/idempotency.py +112 -0
- examples/celery/durable/workflows/long_running.py +99 -0
- examples/celery/durable/workflows/retries.py +101 -0
- examples/celery/durable/workflows/schedules.py +209 -0
- examples/celery/transient/01_basic_workflow.py +91 -0
- examples/celery/transient/02_fault_tolerance.py +257 -0
- examples/celery/transient/__init__.py +20 -0
- examples/celery/transient/pyworkflow.config.yaml +25 -0
- examples/local/__init__.py +1 -0
- examples/local/durable/01_basic_workflow.py +94 -0
- examples/local/durable/02_file_storage.py +132 -0
- examples/local/durable/03_retries.py +169 -0
- examples/local/durable/04_long_running.py +119 -0
- examples/local/durable/05_event_log.py +145 -0
- examples/local/durable/06_idempotency.py +148 -0
- examples/local/durable/07_hooks.py +334 -0
- examples/local/durable/08_cancellation.py +233 -0
- examples/local/durable/09_child_workflows.py +198 -0
- examples/local/durable/10_child_workflow_patterns.py +265 -0
- examples/local/durable/11_continue_as_new.py +249 -0
- examples/local/durable/12_schedules.py +198 -0
- examples/local/durable/__init__.py +1 -0
- examples/local/transient/01_quick_tasks.py +87 -0
- examples/local/transient/02_retries.py +130 -0
- examples/local/transient/03_sleep.py +141 -0
- examples/local/transient/__init__.py +1 -0
- pyworkflow/__init__.py +256 -0
- pyworkflow/aws/__init__.py +68 -0
- pyworkflow/aws/context.py +234 -0
- pyworkflow/aws/handler.py +184 -0
- pyworkflow/aws/testing.py +310 -0
- pyworkflow/celery/__init__.py +41 -0
- pyworkflow/celery/app.py +198 -0
- pyworkflow/celery/scheduler.py +315 -0
- pyworkflow/celery/tasks.py +1746 -0
- pyworkflow/cli/__init__.py +132 -0
- pyworkflow/cli/__main__.py +6 -0
- pyworkflow/cli/commands/__init__.py +1 -0
- pyworkflow/cli/commands/hooks.py +640 -0
- pyworkflow/cli/commands/quickstart.py +495 -0
- pyworkflow/cli/commands/runs.py +773 -0
- pyworkflow/cli/commands/scheduler.py +130 -0
- pyworkflow/cli/commands/schedules.py +794 -0
- pyworkflow/cli/commands/setup.py +703 -0
- pyworkflow/cli/commands/worker.py +413 -0
- pyworkflow/cli/commands/workflows.py +1257 -0
- pyworkflow/cli/output/__init__.py +1 -0
- pyworkflow/cli/output/formatters.py +321 -0
- pyworkflow/cli/output/styles.py +121 -0
- pyworkflow/cli/utils/__init__.py +1 -0
- pyworkflow/cli/utils/async_helpers.py +30 -0
- pyworkflow/cli/utils/config.py +130 -0
- pyworkflow/cli/utils/config_generator.py +344 -0
- pyworkflow/cli/utils/discovery.py +53 -0
- pyworkflow/cli/utils/docker_manager.py +651 -0
- pyworkflow/cli/utils/interactive.py +364 -0
- pyworkflow/cli/utils/storage.py +115 -0
- pyworkflow/config.py +329 -0
- pyworkflow/context/__init__.py +63 -0
- pyworkflow/context/aws.py +230 -0
- pyworkflow/context/base.py +416 -0
- pyworkflow/context/local.py +930 -0
- pyworkflow/context/mock.py +381 -0
- pyworkflow/core/__init__.py +0 -0
- pyworkflow/core/exceptions.py +353 -0
- pyworkflow/core/registry.py +313 -0
- pyworkflow/core/scheduled.py +328 -0
- pyworkflow/core/step.py +494 -0
- pyworkflow/core/workflow.py +294 -0
- pyworkflow/discovery.py +248 -0
- pyworkflow/engine/__init__.py +0 -0
- pyworkflow/engine/events.py +879 -0
- pyworkflow/engine/executor.py +682 -0
- pyworkflow/engine/replay.py +273 -0
- pyworkflow/observability/__init__.py +19 -0
- pyworkflow/observability/logging.py +234 -0
- pyworkflow/primitives/__init__.py +33 -0
- pyworkflow/primitives/child_handle.py +174 -0
- pyworkflow/primitives/child_workflow.py +372 -0
- pyworkflow/primitives/continue_as_new.py +101 -0
- pyworkflow/primitives/define_hook.py +150 -0
- pyworkflow/primitives/hooks.py +97 -0
- pyworkflow/primitives/resume_hook.py +210 -0
- pyworkflow/primitives/schedule.py +545 -0
- pyworkflow/primitives/shield.py +96 -0
- pyworkflow/primitives/sleep.py +100 -0
- pyworkflow/runtime/__init__.py +21 -0
- pyworkflow/runtime/base.py +179 -0
- pyworkflow/runtime/celery.py +310 -0
- pyworkflow/runtime/factory.py +101 -0
- pyworkflow/runtime/local.py +706 -0
- pyworkflow/scheduler/__init__.py +9 -0
- pyworkflow/scheduler/local.py +248 -0
- pyworkflow/serialization/__init__.py +0 -0
- pyworkflow/serialization/decoder.py +146 -0
- pyworkflow/serialization/encoder.py +162 -0
- pyworkflow/storage/__init__.py +54 -0
- pyworkflow/storage/base.py +612 -0
- pyworkflow/storage/config.py +185 -0
- pyworkflow/storage/dynamodb.py +1315 -0
- pyworkflow/storage/file.py +827 -0
- pyworkflow/storage/memory.py +549 -0
- pyworkflow/storage/postgres.py +1161 -0
- pyworkflow/storage/schemas.py +486 -0
- pyworkflow/storage/sqlite.py +1136 -0
- pyworkflow/utils/__init__.py +0 -0
- pyworkflow/utils/duration.py +177 -0
- pyworkflow/utils/schedule.py +391 -0
- pyworkflow_engine-0.1.7.dist-info/METADATA +687 -0
- pyworkflow_engine-0.1.7.dist-info/RECORD +196 -0
- pyworkflow_engine-0.1.7.dist-info/WHEEL +5 -0
- pyworkflow_engine-0.1.7.dist-info/entry_points.txt +2 -0
- pyworkflow_engine-0.1.7.dist-info/licenses/LICENSE +21 -0
- pyworkflow_engine-0.1.7.dist-info/top_level.txt +5 -0
- tests/examples/__init__.py +0 -0
- tests/integration/__init__.py +0 -0
- tests/integration/test_cancellation.py +330 -0
- tests/integration/test_child_workflows.py +439 -0
- tests/integration/test_continue_as_new.py +428 -0
- tests/integration/test_dynamodb_storage.py +1146 -0
- tests/integration/test_fault_tolerance.py +369 -0
- tests/integration/test_schedule_storage.py +484 -0
- tests/unit/__init__.py +0 -0
- tests/unit/backends/__init__.py +1 -0
- tests/unit/backends/test_dynamodb_storage.py +1554 -0
- tests/unit/backends/test_postgres_storage.py +1281 -0
- tests/unit/backends/test_sqlite_storage.py +1460 -0
- tests/unit/conftest.py +41 -0
- tests/unit/test_cancellation.py +364 -0
- tests/unit/test_child_workflows.py +680 -0
- tests/unit/test_continue_as_new.py +441 -0
- tests/unit/test_event_limits.py +316 -0
- tests/unit/test_executor.py +320 -0
- tests/unit/test_fault_tolerance.py +334 -0
- tests/unit/test_hooks.py +495 -0
- tests/unit/test_registry.py +261 -0
- tests/unit/test_replay.py +420 -0
- tests/unit/test_schedule_schemas.py +285 -0
- tests/unit/test_schedule_utils.py +286 -0
- tests/unit/test_scheduled_workflow.py +274 -0
- tests/unit/test_step.py +353 -0
- 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()
|