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,294 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@workflow decorator for defining durable workflows.
|
|
3
|
+
|
|
4
|
+
Workflows are orchestration functions that coordinate steps. They are
|
|
5
|
+
decorated with @workflow to enable:
|
|
6
|
+
- Event sourcing and deterministic replay
|
|
7
|
+
- Suspension and resumption (sleep, hooks)
|
|
8
|
+
- Automatic state persistence
|
|
9
|
+
- Fault tolerance
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import functools
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from loguru import logger
|
|
17
|
+
|
|
18
|
+
from pyworkflow.context import LocalContext, reset_context, set_context
|
|
19
|
+
from pyworkflow.core.exceptions import CancellationError, ContinueAsNewSignal, SuspensionSignal
|
|
20
|
+
from pyworkflow.core.registry import register_workflow
|
|
21
|
+
from pyworkflow.engine.events import (
|
|
22
|
+
create_workflow_cancelled_event,
|
|
23
|
+
create_workflow_completed_event,
|
|
24
|
+
create_workflow_failed_event,
|
|
25
|
+
)
|
|
26
|
+
from pyworkflow.serialization.encoder import serialize
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def workflow(
|
|
30
|
+
name: str | None = None,
|
|
31
|
+
durable: bool | None = None,
|
|
32
|
+
max_duration: str | None = None,
|
|
33
|
+
tags: list[str] | None = None,
|
|
34
|
+
recover_on_worker_loss: bool | None = None,
|
|
35
|
+
max_recovery_attempts: int | None = None,
|
|
36
|
+
) -> Callable:
|
|
37
|
+
"""
|
|
38
|
+
Decorator to mark async functions as workflows.
|
|
39
|
+
|
|
40
|
+
Workflows are orchestration functions that coordinate steps. They can be:
|
|
41
|
+
- Durable: Event-sourced, persistent, resumable (durable=True)
|
|
42
|
+
- Transient: Simple execution without persistence overhead (durable=False)
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
name: Optional workflow name (defaults to function name)
|
|
46
|
+
durable: Whether workflow is durable (None = use configured default)
|
|
47
|
+
max_duration: Optional max duration (e.g., "1h", "30m")
|
|
48
|
+
tags: Optional list of tags for categorization (max 3 tags)
|
|
49
|
+
recover_on_worker_loss: Whether to auto-recover on worker failure
|
|
50
|
+
(None = True for durable, False for transient)
|
|
51
|
+
max_recovery_attempts: Max recovery attempts on worker failure (default: 3)
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Decorated workflow function
|
|
55
|
+
|
|
56
|
+
Example (durable):
|
|
57
|
+
@workflow(name="process_order", durable=True)
|
|
58
|
+
async def process_order(order_id: str):
|
|
59
|
+
order = await validate_order(order_id)
|
|
60
|
+
payment = await charge_payment(order["total"])
|
|
61
|
+
await sleep("1h") # Can suspend and resume
|
|
62
|
+
return payment
|
|
63
|
+
|
|
64
|
+
Example (transient):
|
|
65
|
+
@workflow(durable=False)
|
|
66
|
+
async def quick_task():
|
|
67
|
+
result = await my_step()
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
Example (use configured default):
|
|
71
|
+
@workflow
|
|
72
|
+
async def simple_workflow():
|
|
73
|
+
result = await my_step()
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
Example (fault tolerant):
|
|
77
|
+
@workflow(durable=True, recover_on_worker_loss=True, max_recovery_attempts=5)
|
|
78
|
+
async def critical_workflow():
|
|
79
|
+
# Will auto-recover if worker crashes
|
|
80
|
+
result = await important_step()
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
Example (with tags):
|
|
84
|
+
@workflow(tags=["backend", "critical"])
|
|
85
|
+
async def tagged_workflow():
|
|
86
|
+
result = await my_step()
|
|
87
|
+
return result
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
# Validate tags
|
|
91
|
+
validated_tags = tags or []
|
|
92
|
+
if len(validated_tags) > 3:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"Workflows can have at most 3 tags, got {len(validated_tags)}: {validated_tags}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def decorator(func: Callable) -> Callable:
|
|
98
|
+
workflow_name = name or func.__name__
|
|
99
|
+
|
|
100
|
+
@functools.wraps(func)
|
|
101
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
102
|
+
# This wrapper is called during execution by the executor
|
|
103
|
+
# The actual workflow function runs with a context set up
|
|
104
|
+
return await func(*args, **kwargs)
|
|
105
|
+
|
|
106
|
+
# Register workflow
|
|
107
|
+
register_workflow(
|
|
108
|
+
name=workflow_name,
|
|
109
|
+
func=wrapper,
|
|
110
|
+
original_func=func,
|
|
111
|
+
max_duration=max_duration,
|
|
112
|
+
tags=validated_tags,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Store metadata on wrapper
|
|
116
|
+
wrapper.__workflow__ = True # type: ignore[attr-defined]
|
|
117
|
+
wrapper.__workflow_name__ = workflow_name # type: ignore[attr-defined]
|
|
118
|
+
wrapper.__workflow_durable__ = durable # type: ignore[attr-defined] # None = use config default
|
|
119
|
+
wrapper.__workflow_max_duration__ = max_duration # type: ignore[attr-defined]
|
|
120
|
+
wrapper.__workflow_tags__ = validated_tags # type: ignore[attr-defined]
|
|
121
|
+
wrapper.__workflow_recover_on_worker_loss__ = ( # type: ignore[attr-defined]
|
|
122
|
+
recover_on_worker_loss # None = use config default
|
|
123
|
+
)
|
|
124
|
+
wrapper.__workflow_max_recovery_attempts__ = ( # type: ignore[attr-defined]
|
|
125
|
+
max_recovery_attempts # None = use config default
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return wrapper
|
|
129
|
+
|
|
130
|
+
return decorator
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def execute_workflow_with_context(
|
|
134
|
+
workflow_func: Callable,
|
|
135
|
+
run_id: str,
|
|
136
|
+
workflow_name: str,
|
|
137
|
+
storage: Any, # StorageBackend or None for transient
|
|
138
|
+
args: tuple,
|
|
139
|
+
kwargs: dict,
|
|
140
|
+
event_log: list | None = None,
|
|
141
|
+
durable: bool = True,
|
|
142
|
+
cancellation_requested: bool = False,
|
|
143
|
+
) -> Any:
|
|
144
|
+
"""
|
|
145
|
+
Execute workflow function with proper context setup.
|
|
146
|
+
|
|
147
|
+
This is called by the executor to run a workflow with:
|
|
148
|
+
- Context initialization
|
|
149
|
+
- Event logging (durable mode only)
|
|
150
|
+
- Error handling
|
|
151
|
+
- Suspension handling (durable mode only)
|
|
152
|
+
- Cancellation handling
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
workflow_func: The workflow function to execute
|
|
156
|
+
run_id: Unique run identifier
|
|
157
|
+
workflow_name: Workflow name
|
|
158
|
+
storage: Storage backend instance (None for transient)
|
|
159
|
+
args: Positional arguments
|
|
160
|
+
kwargs: Keyword arguments
|
|
161
|
+
event_log: Optional existing event log for replay
|
|
162
|
+
durable: Whether this is a durable workflow
|
|
163
|
+
cancellation_requested: Whether cancellation was requested before execution
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Workflow result
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
SuspensionSignal: When workflow needs to suspend (durable only)
|
|
170
|
+
CancellationError: When workflow is cancelled
|
|
171
|
+
Exception: On workflow failure
|
|
172
|
+
"""
|
|
173
|
+
# Determine if we're actually durable (need both flag and storage)
|
|
174
|
+
is_durable = durable and storage is not None
|
|
175
|
+
|
|
176
|
+
# Create workflow context using new LocalContext
|
|
177
|
+
ctx = LocalContext(
|
|
178
|
+
run_id=run_id,
|
|
179
|
+
workflow_name=workflow_name,
|
|
180
|
+
storage=storage,
|
|
181
|
+
event_log=event_log or [],
|
|
182
|
+
durable=is_durable,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Set cancellation state if requested before execution
|
|
186
|
+
if cancellation_requested:
|
|
187
|
+
ctx.request_cancellation(reason="Cancellation requested before execution")
|
|
188
|
+
|
|
189
|
+
# Set as current context using new API
|
|
190
|
+
token = set_context(ctx)
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
# Note: Event replay is handled by LocalContext in its constructor
|
|
194
|
+
# when event_log is provided
|
|
195
|
+
|
|
196
|
+
logger.info(
|
|
197
|
+
f"Executing workflow: {workflow_name}",
|
|
198
|
+
run_id=run_id,
|
|
199
|
+
workflow_name=workflow_name,
|
|
200
|
+
durable=is_durable,
|
|
201
|
+
is_replay=bool(event_log),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Execute workflow function
|
|
205
|
+
result = await workflow_func(*args, **kwargs)
|
|
206
|
+
|
|
207
|
+
# Record completion event (durable mode only)
|
|
208
|
+
if is_durable:
|
|
209
|
+
# Validate event limits before recording completion
|
|
210
|
+
await ctx.validate_event_limits()
|
|
211
|
+
|
|
212
|
+
completion_event = create_workflow_completed_event(
|
|
213
|
+
run_id, serialize(result), workflow_name
|
|
214
|
+
)
|
|
215
|
+
await storage.record_event(completion_event)
|
|
216
|
+
|
|
217
|
+
logger.info(
|
|
218
|
+
f"Workflow completed: {workflow_name}",
|
|
219
|
+
run_id=run_id,
|
|
220
|
+
workflow_name=workflow_name,
|
|
221
|
+
durable=is_durable,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
return result
|
|
225
|
+
|
|
226
|
+
except SuspensionSignal as e:
|
|
227
|
+
# Workflow suspended (sleep/hook) - only happens in durable mode
|
|
228
|
+
logger.info(
|
|
229
|
+
f"Workflow suspended: {e.reason}",
|
|
230
|
+
run_id=run_id,
|
|
231
|
+
workflow_name=workflow_name,
|
|
232
|
+
reason=e.reason,
|
|
233
|
+
)
|
|
234
|
+
raise
|
|
235
|
+
|
|
236
|
+
except ContinueAsNewSignal as e:
|
|
237
|
+
# Workflow continuing as new execution
|
|
238
|
+
logger.info(
|
|
239
|
+
f"Workflow continuing as new: {workflow_name}",
|
|
240
|
+
run_id=run_id,
|
|
241
|
+
workflow_name=workflow_name,
|
|
242
|
+
new_args=e.workflow_args,
|
|
243
|
+
new_kwargs=e.workflow_kwargs,
|
|
244
|
+
)
|
|
245
|
+
# Re-raise for caller (executor) to handle continuation
|
|
246
|
+
raise
|
|
247
|
+
|
|
248
|
+
except CancellationError as e:
|
|
249
|
+
# Workflow was cancelled
|
|
250
|
+
logger.info(
|
|
251
|
+
f"Workflow cancelled: {workflow_name}",
|
|
252
|
+
run_id=run_id,
|
|
253
|
+
workflow_name=workflow_name,
|
|
254
|
+
reason=e.reason,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# Record cancellation event (durable mode only)
|
|
258
|
+
if is_durable:
|
|
259
|
+
cancelled_event = create_workflow_cancelled_event(
|
|
260
|
+
run_id=run_id,
|
|
261
|
+
reason=e.reason,
|
|
262
|
+
cleanup_completed=True,
|
|
263
|
+
)
|
|
264
|
+
await storage.record_event(cancelled_event)
|
|
265
|
+
|
|
266
|
+
raise
|
|
267
|
+
|
|
268
|
+
except Exception as e:
|
|
269
|
+
# Workflow failed
|
|
270
|
+
logger.error(
|
|
271
|
+
f"Workflow failed: {workflow_name}",
|
|
272
|
+
run_id=run_id,
|
|
273
|
+
workflow_name=workflow_name,
|
|
274
|
+
error=str(e),
|
|
275
|
+
exc_info=True,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# Record failure event (durable mode only)
|
|
279
|
+
if is_durable:
|
|
280
|
+
import traceback
|
|
281
|
+
|
|
282
|
+
failure_event = create_workflow_failed_event(
|
|
283
|
+
run_id=run_id,
|
|
284
|
+
error=str(e),
|
|
285
|
+
error_type=type(e).__name__,
|
|
286
|
+
traceback=traceback.format_exc(),
|
|
287
|
+
)
|
|
288
|
+
await storage.record_event(failure_event)
|
|
289
|
+
|
|
290
|
+
raise
|
|
291
|
+
|
|
292
|
+
finally:
|
|
293
|
+
# Clear context using new API
|
|
294
|
+
reset_context(token)
|
pyworkflow/discovery.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Workflow discovery utilities.
|
|
3
|
+
|
|
4
|
+
This module provides functions to discover and register workflows by importing
|
|
5
|
+
Python modules containing @workflow decorated functions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from loguru import logger
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DiscoveryError(Exception):
|
|
18
|
+
"""Raised when workflow discovery fails."""
|
|
19
|
+
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _find_project_root() -> Path | None:
|
|
24
|
+
"""
|
|
25
|
+
Find the project root by looking for common project markers.
|
|
26
|
+
|
|
27
|
+
Searches upward from the current directory for:
|
|
28
|
+
- pyproject.toml
|
|
29
|
+
- setup.py
|
|
30
|
+
- .git directory
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Path to project root if found, None otherwise
|
|
34
|
+
"""
|
|
35
|
+
current = Path.cwd()
|
|
36
|
+
|
|
37
|
+
for path in [current, *list(current.parents)]:
|
|
38
|
+
if (path / "pyproject.toml").exists():
|
|
39
|
+
return path
|
|
40
|
+
if (path / "setup.py").exists():
|
|
41
|
+
return path
|
|
42
|
+
if (path / ".git").exists():
|
|
43
|
+
return path
|
|
44
|
+
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _ensure_project_in_path() -> None:
|
|
49
|
+
"""Add the project root and current directory to sys.path if not already present."""
|
|
50
|
+
# Add current directory first
|
|
51
|
+
cwd = str(Path.cwd())
|
|
52
|
+
if cwd not in sys.path:
|
|
53
|
+
sys.path.insert(0, cwd)
|
|
54
|
+
logger.debug(f"Added current directory to path: {cwd}")
|
|
55
|
+
|
|
56
|
+
# Add project root
|
|
57
|
+
project_root = _find_project_root()
|
|
58
|
+
if project_root:
|
|
59
|
+
root_str = str(project_root)
|
|
60
|
+
if root_str not in sys.path:
|
|
61
|
+
sys.path.insert(0, root_str)
|
|
62
|
+
logger.debug(f"Added project root to path: {root_str}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _import_module(module_path: str) -> bool:
|
|
66
|
+
"""
|
|
67
|
+
Import a single module to trigger workflow registration.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
module_path: Python module path (e.g., "myapp.workflows")
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
True if import succeeded, False otherwise
|
|
74
|
+
"""
|
|
75
|
+
try:
|
|
76
|
+
importlib.import_module(module_path)
|
|
77
|
+
logger.info(f"Discovered workflows from module: {module_path}")
|
|
78
|
+
return True
|
|
79
|
+
except ModuleNotFoundError as e:
|
|
80
|
+
logger.error(f"Failed to import module '{module_path}': {e}")
|
|
81
|
+
return False
|
|
82
|
+
except Exception as e:
|
|
83
|
+
logger.error(f"Error importing module '{module_path}': {e}")
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _load_yaml_config() -> dict[str, Any] | None:
|
|
88
|
+
"""
|
|
89
|
+
Load pyworkflow.config.yaml from current directory.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Configuration dictionary if found, None otherwise
|
|
93
|
+
"""
|
|
94
|
+
config_path = Path.cwd() / "pyworkflow.config.yaml"
|
|
95
|
+
if not config_path.exists():
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
import yaml
|
|
100
|
+
|
|
101
|
+
with open(config_path) as f:
|
|
102
|
+
config = yaml.safe_load(f)
|
|
103
|
+
logger.debug(f"Loaded config from: {config_path}")
|
|
104
|
+
return config
|
|
105
|
+
except ImportError:
|
|
106
|
+
logger.warning("PyYAML not installed, skipping YAML config")
|
|
107
|
+
return None
|
|
108
|
+
except Exception as e:
|
|
109
|
+
logger.error(f"Failed to load YAML config: {e}")
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def discover_workflows(
|
|
114
|
+
module_path: str | None = None,
|
|
115
|
+
config: dict[str, Any] | None = None,
|
|
116
|
+
config_path: str | Path | None = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""
|
|
119
|
+
Import Python modules to trigger workflow registration.
|
|
120
|
+
|
|
121
|
+
Workflows are registered when their module is imported, as the @workflow
|
|
122
|
+
decorator registers them in the global registry. This function handles
|
|
123
|
+
importing the appropriate module based on configuration priority.
|
|
124
|
+
|
|
125
|
+
Priority:
|
|
126
|
+
1. Explicit module_path argument
|
|
127
|
+
2. PYWORKFLOW_DISCOVER environment variable
|
|
128
|
+
3. Config dict (from YAML) with 'module' or 'modules' key
|
|
129
|
+
4. pyworkflow.config.yaml in current directory
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
module_path: Explicit module path to import
|
|
133
|
+
config: Configuration dict containing 'module' or 'modules' key
|
|
134
|
+
config_path: Path to the config file. If provided, the directory
|
|
135
|
+
containing the config file will be added to sys.path for imports.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
DiscoveryError: If specified modules cannot be imported
|
|
139
|
+
|
|
140
|
+
Examples:
|
|
141
|
+
# Explicit module
|
|
142
|
+
discover_workflows("myapp.workflows")
|
|
143
|
+
|
|
144
|
+
# From config dict with path context
|
|
145
|
+
discover_workflows(config={"module": "myapp.workflows"}, config_path="/app/pyworkflow.config.yaml")
|
|
146
|
+
|
|
147
|
+
# From environment variable
|
|
148
|
+
os.environ["PYWORKFLOW_DISCOVER"] = "myapp.workflows"
|
|
149
|
+
discover_workflows()
|
|
150
|
+
|
|
151
|
+
# From pyworkflow.config.yaml in cwd
|
|
152
|
+
discover_workflows()
|
|
153
|
+
"""
|
|
154
|
+
# Ensure project root is in Python path for module imports
|
|
155
|
+
_ensure_project_in_path()
|
|
156
|
+
|
|
157
|
+
# If config_path provided, add its directory to sys.path
|
|
158
|
+
# This allows importing modules relative to the config file location
|
|
159
|
+
if config_path:
|
|
160
|
+
config_dir = Path(config_path).parent.resolve()
|
|
161
|
+
config_dir_str = str(config_dir)
|
|
162
|
+
if config_dir_str not in sys.path:
|
|
163
|
+
sys.path.insert(0, config_dir_str)
|
|
164
|
+
logger.debug(f"Added config directory to path: {config_dir_str}")
|
|
165
|
+
|
|
166
|
+
# Priority 1: Explicit module path
|
|
167
|
+
if module_path:
|
|
168
|
+
logger.debug(f"Discovering from explicit module: {module_path}")
|
|
169
|
+
if not _import_module(module_path):
|
|
170
|
+
raise DiscoveryError(
|
|
171
|
+
f"Cannot import module '{module_path}'. "
|
|
172
|
+
f"Make sure the module exists and is in your Python path."
|
|
173
|
+
)
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
# Priority 2: Environment variable
|
|
177
|
+
env_modules = os.getenv("PYWORKFLOW_DISCOVER", "")
|
|
178
|
+
if env_modules:
|
|
179
|
+
logger.debug(f"Discovering from PYWORKFLOW_DISCOVER: {env_modules}")
|
|
180
|
+
env_module_list = [m.strip() for m in env_modules.split(",") if m.strip()]
|
|
181
|
+
failed = []
|
|
182
|
+
for module in env_module_list:
|
|
183
|
+
if not _import_module(module):
|
|
184
|
+
failed.append(module)
|
|
185
|
+
|
|
186
|
+
if failed:
|
|
187
|
+
raise DiscoveryError(
|
|
188
|
+
f"Cannot import modules from PYWORKFLOW_DISCOVER: {', '.join(failed)}. "
|
|
189
|
+
f"Make sure the modules exist and are in your Python path."
|
|
190
|
+
)
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
# Priority 3: Config dict (passed from configure_from_yaml)
|
|
194
|
+
if config:
|
|
195
|
+
modules: list[str] = []
|
|
196
|
+
|
|
197
|
+
if "module" in config:
|
|
198
|
+
modules.append(config["module"])
|
|
199
|
+
if "modules" in config:
|
|
200
|
+
modules.extend(config["modules"])
|
|
201
|
+
|
|
202
|
+
if modules:
|
|
203
|
+
logger.debug(f"Discovering from config: {modules}")
|
|
204
|
+
failed = []
|
|
205
|
+
for module in modules:
|
|
206
|
+
if not _import_module(module):
|
|
207
|
+
failed.append(module)
|
|
208
|
+
|
|
209
|
+
if failed:
|
|
210
|
+
project_root = _find_project_root()
|
|
211
|
+
raise DiscoveryError(
|
|
212
|
+
f"Cannot import modules from config: {', '.join(failed)}. "
|
|
213
|
+
f"Make sure the modules exist and are in your Python path.\n"
|
|
214
|
+
f" Current directory: {Path.cwd()}\n"
|
|
215
|
+
f" Project root: {project_root or 'not found'}"
|
|
216
|
+
)
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
# Priority 4: pyworkflow.config.yaml in cwd
|
|
220
|
+
yaml_config = _load_yaml_config()
|
|
221
|
+
if yaml_config:
|
|
222
|
+
# Support both 'module' (single) and 'modules' (list)
|
|
223
|
+
modules = []
|
|
224
|
+
|
|
225
|
+
if "module" in yaml_config:
|
|
226
|
+
modules.append(yaml_config["module"])
|
|
227
|
+
if "modules" in yaml_config:
|
|
228
|
+
modules.extend(yaml_config["modules"])
|
|
229
|
+
|
|
230
|
+
if modules:
|
|
231
|
+
logger.debug(f"Discovering from pyworkflow.config.yaml: {modules}")
|
|
232
|
+
failed = []
|
|
233
|
+
for module in modules:
|
|
234
|
+
if not _import_module(module):
|
|
235
|
+
failed.append(module)
|
|
236
|
+
|
|
237
|
+
if failed:
|
|
238
|
+
project_root = _find_project_root()
|
|
239
|
+
raise DiscoveryError(
|
|
240
|
+
f"Cannot import modules from pyworkflow.config.yaml: {', '.join(failed)}. "
|
|
241
|
+
f"Make sure the modules exist and are in your Python path.\n"
|
|
242
|
+
f" Current directory: {Path.cwd()}\n"
|
|
243
|
+
f" Project root: {project_root or 'not found'}"
|
|
244
|
+
)
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
# No discovery source found
|
|
248
|
+
logger.debug("No workflow module specified for discovery")
|
|
File without changes
|