pyworkflow-engine 0.1.7__py3-none-any.whl → 0.1.10__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.
- pyworkflow/__init__.py +10 -1
- pyworkflow/celery/tasks.py +272 -24
- pyworkflow/cli/__init__.py +4 -1
- pyworkflow/cli/commands/runs.py +4 -4
- pyworkflow/cli/commands/setup.py +203 -4
- pyworkflow/cli/utils/config_generator.py +76 -3
- pyworkflow/cli/utils/docker_manager.py +232 -0
- pyworkflow/config.py +94 -17
- pyworkflow/context/__init__.py +13 -0
- pyworkflow/context/base.py +26 -0
- pyworkflow/context/local.py +80 -0
- pyworkflow/context/step_context.py +295 -0
- pyworkflow/core/registry.py +6 -1
- pyworkflow/core/step.py +141 -0
- pyworkflow/core/workflow.py +56 -0
- pyworkflow/engine/events.py +30 -0
- pyworkflow/engine/replay.py +39 -0
- pyworkflow/primitives/child_workflow.py +1 -1
- pyworkflow/runtime/local.py +1 -1
- pyworkflow/storage/__init__.py +14 -0
- pyworkflow/storage/base.py +35 -0
- pyworkflow/storage/cassandra.py +1747 -0
- pyworkflow/storage/config.py +69 -0
- pyworkflow/storage/dynamodb.py +31 -2
- pyworkflow/storage/file.py +28 -0
- pyworkflow/storage/memory.py +18 -0
- pyworkflow/storage/mysql.py +1159 -0
- pyworkflow/storage/postgres.py +27 -2
- pyworkflow/storage/schemas.py +4 -3
- pyworkflow/storage/sqlite.py +25 -2
- {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.10.dist-info}/METADATA +7 -4
- pyworkflow_engine-0.1.10.dist-info/RECORD +91 -0
- pyworkflow_engine-0.1.10.dist-info/top_level.txt +1 -0
- dashboard/backend/app/__init__.py +0 -1
- dashboard/backend/app/config.py +0 -32
- dashboard/backend/app/controllers/__init__.py +0 -6
- dashboard/backend/app/controllers/run_controller.py +0 -86
- dashboard/backend/app/controllers/workflow_controller.py +0 -33
- dashboard/backend/app/dependencies/__init__.py +0 -5
- dashboard/backend/app/dependencies/storage.py +0 -50
- dashboard/backend/app/repositories/__init__.py +0 -6
- dashboard/backend/app/repositories/run_repository.py +0 -80
- dashboard/backend/app/repositories/workflow_repository.py +0 -27
- dashboard/backend/app/rest/__init__.py +0 -8
- dashboard/backend/app/rest/v1/__init__.py +0 -12
- dashboard/backend/app/rest/v1/health.py +0 -33
- dashboard/backend/app/rest/v1/runs.py +0 -133
- dashboard/backend/app/rest/v1/workflows.py +0 -41
- dashboard/backend/app/schemas/__init__.py +0 -23
- dashboard/backend/app/schemas/common.py +0 -16
- dashboard/backend/app/schemas/event.py +0 -24
- dashboard/backend/app/schemas/hook.py +0 -25
- dashboard/backend/app/schemas/run.py +0 -54
- dashboard/backend/app/schemas/step.py +0 -28
- dashboard/backend/app/schemas/workflow.py +0 -31
- dashboard/backend/app/server.py +0 -87
- dashboard/backend/app/services/__init__.py +0 -6
- dashboard/backend/app/services/run_service.py +0 -240
- dashboard/backend/app/services/workflow_service.py +0 -155
- dashboard/backend/main.py +0 -18
- docs/concepts/cancellation.mdx +0 -362
- docs/concepts/continue-as-new.mdx +0 -434
- docs/concepts/events.mdx +0 -266
- docs/concepts/fault-tolerance.mdx +0 -370
- docs/concepts/hooks.mdx +0 -552
- docs/concepts/limitations.mdx +0 -167
- docs/concepts/schedules.mdx +0 -775
- docs/concepts/sleep.mdx +0 -312
- docs/concepts/steps.mdx +0 -301
- docs/concepts/workflows.mdx +0 -255
- docs/guides/cli.mdx +0 -942
- docs/guides/configuration.mdx +0 -560
- docs/introduction.mdx +0 -155
- docs/quickstart.mdx +0 -279
- examples/__init__.py +0 -1
- examples/celery/__init__.py +0 -1
- examples/celery/durable/docker-compose.yml +0 -55
- examples/celery/durable/pyworkflow.config.yaml +0 -12
- examples/celery/durable/workflows/__init__.py +0 -122
- examples/celery/durable/workflows/basic.py +0 -87
- examples/celery/durable/workflows/batch_processing.py +0 -102
- examples/celery/durable/workflows/cancellation.py +0 -273
- examples/celery/durable/workflows/child_workflow_patterns.py +0 -240
- examples/celery/durable/workflows/child_workflows.py +0 -202
- examples/celery/durable/workflows/continue_as_new.py +0 -260
- examples/celery/durable/workflows/fault_tolerance.py +0 -210
- examples/celery/durable/workflows/hooks.py +0 -211
- examples/celery/durable/workflows/idempotency.py +0 -112
- examples/celery/durable/workflows/long_running.py +0 -99
- examples/celery/durable/workflows/retries.py +0 -101
- examples/celery/durable/workflows/schedules.py +0 -209
- examples/celery/transient/01_basic_workflow.py +0 -91
- examples/celery/transient/02_fault_tolerance.py +0 -257
- examples/celery/transient/__init__.py +0 -20
- examples/celery/transient/pyworkflow.config.yaml +0 -25
- examples/local/__init__.py +0 -1
- examples/local/durable/01_basic_workflow.py +0 -94
- examples/local/durable/02_file_storage.py +0 -132
- examples/local/durable/03_retries.py +0 -169
- examples/local/durable/04_long_running.py +0 -119
- examples/local/durable/05_event_log.py +0 -145
- examples/local/durable/06_idempotency.py +0 -148
- examples/local/durable/07_hooks.py +0 -334
- examples/local/durable/08_cancellation.py +0 -233
- examples/local/durable/09_child_workflows.py +0 -198
- examples/local/durable/10_child_workflow_patterns.py +0 -265
- examples/local/durable/11_continue_as_new.py +0 -249
- examples/local/durable/12_schedules.py +0 -198
- examples/local/durable/__init__.py +0 -1
- examples/local/transient/01_quick_tasks.py +0 -87
- examples/local/transient/02_retries.py +0 -130
- examples/local/transient/03_sleep.py +0 -141
- examples/local/transient/__init__.py +0 -1
- pyworkflow_engine-0.1.7.dist-info/RECORD +0 -196
- pyworkflow_engine-0.1.7.dist-info/top_level.txt +0 -5
- tests/examples/__init__.py +0 -0
- tests/integration/__init__.py +0 -0
- tests/integration/test_cancellation.py +0 -330
- tests/integration/test_child_workflows.py +0 -439
- tests/integration/test_continue_as_new.py +0 -428
- tests/integration/test_dynamodb_storage.py +0 -1146
- tests/integration/test_fault_tolerance.py +0 -369
- tests/integration/test_schedule_storage.py +0 -484
- tests/unit/__init__.py +0 -0
- tests/unit/backends/__init__.py +0 -1
- tests/unit/backends/test_dynamodb_storage.py +0 -1554
- tests/unit/backends/test_postgres_storage.py +0 -1281
- tests/unit/backends/test_sqlite_storage.py +0 -1460
- tests/unit/conftest.py +0 -41
- tests/unit/test_cancellation.py +0 -364
- tests/unit/test_child_workflows.py +0 -680
- tests/unit/test_continue_as_new.py +0 -441
- tests/unit/test_event_limits.py +0 -316
- tests/unit/test_executor.py +0 -320
- tests/unit/test_fault_tolerance.py +0 -334
- tests/unit/test_hooks.py +0 -495
- tests/unit/test_registry.py +0 -261
- tests/unit/test_replay.py +0 -420
- tests/unit/test_schedule_schemas.py +0 -285
- tests/unit/test_schedule_utils.py +0 -286
- tests/unit/test_scheduled_workflow.py +0 -274
- tests/unit/test_step.py +0 -353
- tests/unit/test_workflow.py +0 -243
- {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.10.dist-info}/WHEEL +0 -0
- {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.10.dist-info}/entry_points.txt +0 -0
- {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.10.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Unit tests for @scheduled_workflow decorator.
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
import pytest
|
|
6
|
-
|
|
7
|
-
from pyworkflow.core.scheduled import (
|
|
8
|
-
clear_scheduled_workflows,
|
|
9
|
-
get_scheduled_workflow,
|
|
10
|
-
list_scheduled_workflows,
|
|
11
|
-
register_scheduled_workflow,
|
|
12
|
-
scheduled_workflow,
|
|
13
|
-
unregister_scheduled_workflow,
|
|
14
|
-
)
|
|
15
|
-
from pyworkflow.storage.schemas import OverlapPolicy, ScheduleSpec
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
@pytest.fixture(autouse=True)
|
|
19
|
-
def reset_scheduled_workflows():
|
|
20
|
-
"""Reset scheduled workflows registry before each test."""
|
|
21
|
-
clear_scheduled_workflows()
|
|
22
|
-
yield
|
|
23
|
-
clear_scheduled_workflows()
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
class TestScheduledWorkflowDecorator:
|
|
27
|
-
"""Test the @scheduled_workflow decorator."""
|
|
28
|
-
|
|
29
|
-
def test_scheduled_workflow_with_cron(self):
|
|
30
|
-
"""Test scheduled workflow with cron expression."""
|
|
31
|
-
|
|
32
|
-
@scheduled_workflow(cron="0 9 * * *")
|
|
33
|
-
async def daily_job():
|
|
34
|
-
return "done"
|
|
35
|
-
|
|
36
|
-
# Check workflow attributes
|
|
37
|
-
assert hasattr(daily_job, "__workflow__")
|
|
38
|
-
assert daily_job.__workflow__ is True
|
|
39
|
-
assert daily_job.__workflow_name__ == "daily_job"
|
|
40
|
-
|
|
41
|
-
# Check schedule attributes
|
|
42
|
-
assert hasattr(daily_job, "__scheduled__")
|
|
43
|
-
assert daily_job.__scheduled__ is True
|
|
44
|
-
assert daily_job.__schedule_spec__.cron == "0 9 * * *"
|
|
45
|
-
|
|
46
|
-
def test_scheduled_workflow_with_interval(self):
|
|
47
|
-
"""Test scheduled workflow with interval."""
|
|
48
|
-
|
|
49
|
-
@scheduled_workflow(interval="5m")
|
|
50
|
-
async def frequent_job():
|
|
51
|
-
return "done"
|
|
52
|
-
|
|
53
|
-
assert daily_job.__schedule_spec__.interval == "5m"
|
|
54
|
-
|
|
55
|
-
def test_scheduled_workflow_with_custom_name(self):
|
|
56
|
-
"""Test scheduled workflow with custom name."""
|
|
57
|
-
|
|
58
|
-
@scheduled_workflow(cron="0 0 * * *", name="custom_name")
|
|
59
|
-
async def my_workflow():
|
|
60
|
-
return "done"
|
|
61
|
-
|
|
62
|
-
assert my_workflow.__workflow_name__ == "custom_name"
|
|
63
|
-
|
|
64
|
-
# Should be registered with custom name
|
|
65
|
-
meta = get_scheduled_workflow("custom_name")
|
|
66
|
-
assert meta is not None
|
|
67
|
-
assert meta.workflow_name == "custom_name"
|
|
68
|
-
|
|
69
|
-
def test_scheduled_workflow_with_overlap_policy(self):
|
|
70
|
-
"""Test scheduled workflow with overlap policy."""
|
|
71
|
-
|
|
72
|
-
@scheduled_workflow(
|
|
73
|
-
cron="*/5 * * * *",
|
|
74
|
-
overlap_policy=OverlapPolicy.BUFFER_ONE,
|
|
75
|
-
)
|
|
76
|
-
async def buffered_job():
|
|
77
|
-
return "done"
|
|
78
|
-
|
|
79
|
-
assert buffered_job.__overlap_policy__ == OverlapPolicy.BUFFER_ONE
|
|
80
|
-
|
|
81
|
-
meta = get_scheduled_workflow("buffered_job")
|
|
82
|
-
assert meta.overlap_policy == OverlapPolicy.BUFFER_ONE
|
|
83
|
-
|
|
84
|
-
def test_scheduled_workflow_with_timezone(self):
|
|
85
|
-
"""Test scheduled workflow with timezone."""
|
|
86
|
-
|
|
87
|
-
@scheduled_workflow(
|
|
88
|
-
cron="0 9 * * *",
|
|
89
|
-
timezone="America/New_York",
|
|
90
|
-
)
|
|
91
|
-
async def tz_job():
|
|
92
|
-
return "done"
|
|
93
|
-
|
|
94
|
-
assert tz_job.__schedule_spec__.timezone == "America/New_York"
|
|
95
|
-
|
|
96
|
-
def test_scheduled_workflow_with_workflow_options(self):
|
|
97
|
-
"""Test scheduled workflow with workflow-specific options."""
|
|
98
|
-
|
|
99
|
-
@scheduled_workflow(
|
|
100
|
-
cron="0 0 * * 0",
|
|
101
|
-
durable=True,
|
|
102
|
-
max_duration="2h",
|
|
103
|
-
recover_on_worker_loss=True,
|
|
104
|
-
max_recovery_attempts=5,
|
|
105
|
-
)
|
|
106
|
-
async def full_options_job():
|
|
107
|
-
return "done"
|
|
108
|
-
|
|
109
|
-
assert full_options_job.__workflow_durable__ is True
|
|
110
|
-
assert full_options_job.__workflow_max_duration__ == "2h"
|
|
111
|
-
assert full_options_job.__workflow_recover_on_worker_loss__ is True
|
|
112
|
-
assert full_options_job.__workflow_max_recovery_attempts__ == 5
|
|
113
|
-
|
|
114
|
-
def test_scheduled_workflow_requires_schedule(self):
|
|
115
|
-
"""Test that scheduled_workflow requires at least one schedule type."""
|
|
116
|
-
with pytest.raises(ValueError, match="requires at least one"):
|
|
117
|
-
|
|
118
|
-
@scheduled_workflow() # No cron, interval, or calendar
|
|
119
|
-
async def invalid_job():
|
|
120
|
-
return "done"
|
|
121
|
-
|
|
122
|
-
def test_scheduled_workflow_registered_in_registry(self):
|
|
123
|
-
"""Test that scheduled workflow is registered in both registries."""
|
|
124
|
-
from pyworkflow.core.registry import get_workflow
|
|
125
|
-
|
|
126
|
-
@scheduled_workflow(cron="0 0 * * *")
|
|
127
|
-
async def registered_job():
|
|
128
|
-
return "done"
|
|
129
|
-
|
|
130
|
-
# Should be in workflow registry
|
|
131
|
-
workflow_meta = get_workflow("registered_job")
|
|
132
|
-
assert workflow_meta is not None
|
|
133
|
-
assert workflow_meta.name == "registered_job"
|
|
134
|
-
|
|
135
|
-
# Should be in scheduled workflows registry
|
|
136
|
-
schedule_meta = get_scheduled_workflow("registered_job")
|
|
137
|
-
assert schedule_meta is not None
|
|
138
|
-
assert schedule_meta.workflow_name == "registered_job"
|
|
139
|
-
|
|
140
|
-
@pytest.mark.asyncio
|
|
141
|
-
async def test_scheduled_workflow_execution(self):
|
|
142
|
-
"""Test that scheduled workflow can be executed normally."""
|
|
143
|
-
|
|
144
|
-
@scheduled_workflow(interval="1h")
|
|
145
|
-
async def executable_job(x: int):
|
|
146
|
-
return x * 2
|
|
147
|
-
|
|
148
|
-
result = await executable_job(5)
|
|
149
|
-
assert result == 10
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
class TestScheduledWorkflowRegistry:
|
|
153
|
-
"""Test scheduled workflow registry functions."""
|
|
154
|
-
|
|
155
|
-
def test_get_scheduled_workflow(self):
|
|
156
|
-
"""Test getting a scheduled workflow by name."""
|
|
157
|
-
|
|
158
|
-
@scheduled_workflow(cron="0 9 * * *")
|
|
159
|
-
async def get_test():
|
|
160
|
-
pass
|
|
161
|
-
|
|
162
|
-
meta = get_scheduled_workflow("get_test")
|
|
163
|
-
assert meta is not None
|
|
164
|
-
assert meta.workflow_name == "get_test"
|
|
165
|
-
assert meta.spec.cron == "0 9 * * *"
|
|
166
|
-
|
|
167
|
-
def test_get_scheduled_workflow_not_found(self):
|
|
168
|
-
"""Test getting non-existent scheduled workflow."""
|
|
169
|
-
meta = get_scheduled_workflow("nonexistent")
|
|
170
|
-
assert meta is None
|
|
171
|
-
|
|
172
|
-
def test_list_scheduled_workflows(self):
|
|
173
|
-
"""Test listing all scheduled workflows."""
|
|
174
|
-
|
|
175
|
-
@scheduled_workflow(cron="0 9 * * *")
|
|
176
|
-
async def job1():
|
|
177
|
-
pass
|
|
178
|
-
|
|
179
|
-
@scheduled_workflow(interval="10m")
|
|
180
|
-
async def job2():
|
|
181
|
-
pass
|
|
182
|
-
|
|
183
|
-
workflows = list_scheduled_workflows()
|
|
184
|
-
assert len(workflows) == 2
|
|
185
|
-
assert "job1" in workflows
|
|
186
|
-
assert "job2" in workflows
|
|
187
|
-
|
|
188
|
-
def test_register_scheduled_workflow_manually(self):
|
|
189
|
-
"""Test manually registering a scheduled workflow."""
|
|
190
|
-
|
|
191
|
-
async def manual_job():
|
|
192
|
-
return "done"
|
|
193
|
-
|
|
194
|
-
spec = ScheduleSpec(cron="0 0 * * *")
|
|
195
|
-
register_scheduled_workflow(
|
|
196
|
-
"manual_job",
|
|
197
|
-
spec,
|
|
198
|
-
OverlapPolicy.SKIP,
|
|
199
|
-
manual_job,
|
|
200
|
-
)
|
|
201
|
-
|
|
202
|
-
meta = get_scheduled_workflow("manual_job")
|
|
203
|
-
assert meta is not None
|
|
204
|
-
assert meta.workflow_name == "manual_job"
|
|
205
|
-
|
|
206
|
-
def test_unregister_scheduled_workflow(self):
|
|
207
|
-
"""Test unregistering a scheduled workflow."""
|
|
208
|
-
|
|
209
|
-
@scheduled_workflow(cron="0 0 * * *")
|
|
210
|
-
async def unregister_test():
|
|
211
|
-
pass
|
|
212
|
-
|
|
213
|
-
# Should be registered
|
|
214
|
-
assert get_scheduled_workflow("unregister_test") is not None
|
|
215
|
-
|
|
216
|
-
# Unregister
|
|
217
|
-
result = unregister_scheduled_workflow("unregister_test")
|
|
218
|
-
assert result is True
|
|
219
|
-
|
|
220
|
-
# Should no longer be registered
|
|
221
|
-
assert get_scheduled_workflow("unregister_test") is None
|
|
222
|
-
|
|
223
|
-
def test_unregister_scheduled_workflow_not_found(self):
|
|
224
|
-
"""Test unregistering non-existent workflow."""
|
|
225
|
-
result = unregister_scheduled_workflow("nonexistent")
|
|
226
|
-
assert result is False
|
|
227
|
-
|
|
228
|
-
def test_clear_scheduled_workflows(self):
|
|
229
|
-
"""Test clearing all scheduled workflows."""
|
|
230
|
-
|
|
231
|
-
@scheduled_workflow(cron="0 9 * * *")
|
|
232
|
-
async def clear_test1():
|
|
233
|
-
pass
|
|
234
|
-
|
|
235
|
-
@scheduled_workflow(interval="5m")
|
|
236
|
-
async def clear_test2():
|
|
237
|
-
pass
|
|
238
|
-
|
|
239
|
-
# Should have 2 workflows
|
|
240
|
-
assert len(list_scheduled_workflows()) == 2
|
|
241
|
-
|
|
242
|
-
# Clear
|
|
243
|
-
clear_scheduled_workflows()
|
|
244
|
-
|
|
245
|
-
# Should be empty
|
|
246
|
-
assert len(list_scheduled_workflows()) == 0
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
class TestScheduledWorkflowMetadata:
|
|
250
|
-
"""Test ScheduledWorkflowMetadata dataclass."""
|
|
251
|
-
|
|
252
|
-
def test_scheduled_workflow_metadata_attributes(self):
|
|
253
|
-
"""Test ScheduledWorkflowMetadata has expected attributes."""
|
|
254
|
-
|
|
255
|
-
@scheduled_workflow(
|
|
256
|
-
cron="0 9 * * *",
|
|
257
|
-
overlap_policy=OverlapPolicy.CANCEL_OTHER,
|
|
258
|
-
)
|
|
259
|
-
async def metadata_test():
|
|
260
|
-
pass
|
|
261
|
-
|
|
262
|
-
meta = get_scheduled_workflow("metadata_test")
|
|
263
|
-
|
|
264
|
-
assert meta.workflow_name == "metadata_test"
|
|
265
|
-
assert meta.spec.cron == "0 9 * * *"
|
|
266
|
-
assert meta.overlap_policy == OverlapPolicy.CANCEL_OTHER
|
|
267
|
-
assert meta.func is not None
|
|
268
|
-
assert callable(meta.func)
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
# Need to define daily_job at module level for test_scheduled_workflow_with_interval
|
|
272
|
-
@scheduled_workflow(interval="5m")
|
|
273
|
-
async def daily_job():
|
|
274
|
-
return "done"
|
tests/unit/test_step.py
DELETED
|
@@ -1,353 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Unit tests for @step decorator and step execution.
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
import pytest
|
|
6
|
-
|
|
7
|
-
from pyworkflow.context import LocalContext, set_context
|
|
8
|
-
from pyworkflow.core.exceptions import FatalError, RetryableError, SuspensionSignal
|
|
9
|
-
from pyworkflow.core.step import _generate_step_id, step
|
|
10
|
-
from pyworkflow.engine.events import EventType
|
|
11
|
-
from pyworkflow.storage.file import FileStorageBackend
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
class TestStepDecorator:
|
|
15
|
-
"""Test the @step decorator."""
|
|
16
|
-
|
|
17
|
-
def test_step_decorator_basic(self):
|
|
18
|
-
"""Test basic step decoration."""
|
|
19
|
-
|
|
20
|
-
@step()
|
|
21
|
-
async def simple_step():
|
|
22
|
-
return "success"
|
|
23
|
-
|
|
24
|
-
# Check that step attributes are set
|
|
25
|
-
assert hasattr(simple_step, "__step__")
|
|
26
|
-
assert simple_step.__step__ is True
|
|
27
|
-
assert simple_step.__step_name__ == "simple_step"
|
|
28
|
-
assert simple_step.__step_max_retries__ == 3 # default
|
|
29
|
-
assert simple_step.__step_retry_delay__ == "exponential" # default
|
|
30
|
-
|
|
31
|
-
def test_step_decorator_with_name(self):
|
|
32
|
-
"""Test step decorator with custom name."""
|
|
33
|
-
|
|
34
|
-
@step(name="custom_step_name")
|
|
35
|
-
async def my_step():
|
|
36
|
-
return "success"
|
|
37
|
-
|
|
38
|
-
assert my_step.__step_name__ == "custom_step_name"
|
|
39
|
-
|
|
40
|
-
def test_step_decorator_with_retries(self):
|
|
41
|
-
"""Test step decorator with custom retry settings."""
|
|
42
|
-
|
|
43
|
-
@step(max_retries=5, retry_delay=10)
|
|
44
|
-
async def retry_step():
|
|
45
|
-
return "success"
|
|
46
|
-
|
|
47
|
-
assert retry_step.__step_max_retries__ == 5
|
|
48
|
-
assert retry_step.__step_retry_delay__ == 10
|
|
49
|
-
|
|
50
|
-
def test_step_decorator_with_timeout(self):
|
|
51
|
-
"""Test step decorator with timeout."""
|
|
52
|
-
|
|
53
|
-
@step(timeout=30)
|
|
54
|
-
async def timed_step():
|
|
55
|
-
return "success"
|
|
56
|
-
|
|
57
|
-
assert timed_step.__step_timeout__ == 30
|
|
58
|
-
|
|
59
|
-
def test_step_decorator_with_metadata(self):
|
|
60
|
-
"""Test step decorator with metadata."""
|
|
61
|
-
metadata = {"type": "api_call", "service": "payment"}
|
|
62
|
-
|
|
63
|
-
@step(metadata=metadata)
|
|
64
|
-
async def meta_step():
|
|
65
|
-
return "success"
|
|
66
|
-
|
|
67
|
-
assert meta_step.__step_metadata__ == metadata
|
|
68
|
-
|
|
69
|
-
@pytest.mark.asyncio
|
|
70
|
-
async def test_step_outside_workflow_context(self):
|
|
71
|
-
"""Test that step executes directly when outside workflow context."""
|
|
72
|
-
|
|
73
|
-
@step()
|
|
74
|
-
async def direct_step(x: int):
|
|
75
|
-
return x * 2
|
|
76
|
-
|
|
77
|
-
# Should execute directly without context
|
|
78
|
-
result = await direct_step(5)
|
|
79
|
-
assert result == 10
|
|
80
|
-
|
|
81
|
-
def test_step_registration(self):
|
|
82
|
-
"""Test that step is registered in global registry."""
|
|
83
|
-
from pyworkflow.core.registry import _registry
|
|
84
|
-
|
|
85
|
-
@step(name="registered_step")
|
|
86
|
-
async def my_step():
|
|
87
|
-
return "success"
|
|
88
|
-
|
|
89
|
-
# Check that it's registered
|
|
90
|
-
step_meta = _registry.get_step("registered_step")
|
|
91
|
-
assert step_meta is not None
|
|
92
|
-
assert step_meta.name == "registered_step"
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
class TestStepExecution:
|
|
96
|
-
"""Test step execution within workflow context."""
|
|
97
|
-
|
|
98
|
-
@pytest.mark.asyncio
|
|
99
|
-
async def test_step_execution_in_context(self, tmp_path):
|
|
100
|
-
"""Test step execution within a workflow context."""
|
|
101
|
-
|
|
102
|
-
@step()
|
|
103
|
-
async def context_step(value: str):
|
|
104
|
-
return f"processed: {value}"
|
|
105
|
-
|
|
106
|
-
# Create context
|
|
107
|
-
storage = FileStorageBackend(base_path=str(tmp_path))
|
|
108
|
-
ctx = LocalContext(
|
|
109
|
-
run_id="test_run",
|
|
110
|
-
workflow_name="test_workflow",
|
|
111
|
-
storage=storage,
|
|
112
|
-
)
|
|
113
|
-
set_context(ctx)
|
|
114
|
-
|
|
115
|
-
try:
|
|
116
|
-
result = await context_step("test")
|
|
117
|
-
assert result == "processed: test"
|
|
118
|
-
finally:
|
|
119
|
-
set_context(None)
|
|
120
|
-
|
|
121
|
-
@pytest.mark.asyncio
|
|
122
|
-
async def test_step_caches_result(self, tmp_path):
|
|
123
|
-
"""Test that step results are cached in context."""
|
|
124
|
-
call_count = 0
|
|
125
|
-
|
|
126
|
-
@step()
|
|
127
|
-
async def counting_step():
|
|
128
|
-
nonlocal call_count
|
|
129
|
-
call_count += 1
|
|
130
|
-
return "result"
|
|
131
|
-
|
|
132
|
-
storage = FileStorageBackend(base_path=str(tmp_path))
|
|
133
|
-
ctx = LocalContext(
|
|
134
|
-
run_id="test_run",
|
|
135
|
-
workflow_name="test_workflow",
|
|
136
|
-
storage=storage,
|
|
137
|
-
)
|
|
138
|
-
set_context(ctx)
|
|
139
|
-
|
|
140
|
-
try:
|
|
141
|
-
# First call - should execute
|
|
142
|
-
result1 = await counting_step()
|
|
143
|
-
assert result1 == "result"
|
|
144
|
-
assert call_count == 1
|
|
145
|
-
|
|
146
|
-
# Generate step ID to manually cache
|
|
147
|
-
step_id = _generate_step_id("counting_step", (), {})
|
|
148
|
-
|
|
149
|
-
# Verify result is cached
|
|
150
|
-
assert step_id in ctx.step_results
|
|
151
|
-
assert ctx.step_results[step_id] == "result"
|
|
152
|
-
|
|
153
|
-
finally:
|
|
154
|
-
set_context(None)
|
|
155
|
-
|
|
156
|
-
@pytest.mark.asyncio
|
|
157
|
-
async def test_step_uses_cached_result_on_replay(self, tmp_path):
|
|
158
|
-
"""Test that step uses cached result during replay."""
|
|
159
|
-
call_count = 0
|
|
160
|
-
|
|
161
|
-
@step()
|
|
162
|
-
async def cached_step():
|
|
163
|
-
nonlocal call_count
|
|
164
|
-
call_count += 1
|
|
165
|
-
return "original"
|
|
166
|
-
|
|
167
|
-
storage = FileStorageBackend(base_path=str(tmp_path))
|
|
168
|
-
ctx = LocalContext(
|
|
169
|
-
run_id="test_run",
|
|
170
|
-
workflow_name="test_workflow",
|
|
171
|
-
storage=storage,
|
|
172
|
-
)
|
|
173
|
-
|
|
174
|
-
# Pre-cache a result
|
|
175
|
-
step_id = _generate_step_id("cached_step", (), {})
|
|
176
|
-
ctx.cache_step_result(step_id, "cached_value")
|
|
177
|
-
|
|
178
|
-
set_context(ctx)
|
|
179
|
-
|
|
180
|
-
try:
|
|
181
|
-
# Should return cached value without executing
|
|
182
|
-
result = await cached_step()
|
|
183
|
-
assert result == "cached_value"
|
|
184
|
-
assert call_count == 0 # Should not have been called
|
|
185
|
-
|
|
186
|
-
finally:
|
|
187
|
-
set_context(None)
|
|
188
|
-
|
|
189
|
-
@pytest.mark.asyncio
|
|
190
|
-
async def test_step_records_events(self, tmp_path):
|
|
191
|
-
"""Test that step execution records events."""
|
|
192
|
-
|
|
193
|
-
@step()
|
|
194
|
-
async def event_step():
|
|
195
|
-
return "done"
|
|
196
|
-
|
|
197
|
-
storage = FileStorageBackend(base_path=str(tmp_path))
|
|
198
|
-
ctx = LocalContext(
|
|
199
|
-
run_id="test_run",
|
|
200
|
-
workflow_name="test_workflow",
|
|
201
|
-
storage=storage,
|
|
202
|
-
)
|
|
203
|
-
set_context(ctx)
|
|
204
|
-
|
|
205
|
-
try:
|
|
206
|
-
await event_step()
|
|
207
|
-
|
|
208
|
-
# Check events were recorded
|
|
209
|
-
events = await storage.get_events("test_run")
|
|
210
|
-
assert len(events) >= 2 # step.started and step.completed
|
|
211
|
-
|
|
212
|
-
event_types = [e.type for e in events]
|
|
213
|
-
assert EventType.STEP_STARTED in event_types
|
|
214
|
-
assert EventType.STEP_COMPLETED in event_types
|
|
215
|
-
|
|
216
|
-
finally:
|
|
217
|
-
set_context(None)
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
class TestStepErrorHandling:
|
|
221
|
-
"""Test step error handling."""
|
|
222
|
-
|
|
223
|
-
@pytest.mark.asyncio
|
|
224
|
-
async def test_step_fatal_error(self, tmp_path):
|
|
225
|
-
"""Test that FatalError is recorded and raised."""
|
|
226
|
-
|
|
227
|
-
@step()
|
|
228
|
-
async def fatal_step():
|
|
229
|
-
raise FatalError("Critical failure")
|
|
230
|
-
|
|
231
|
-
storage = FileStorageBackend(base_path=str(tmp_path))
|
|
232
|
-
ctx = LocalContext(
|
|
233
|
-
run_id="test_run",
|
|
234
|
-
workflow_name="test_workflow",
|
|
235
|
-
storage=storage,
|
|
236
|
-
)
|
|
237
|
-
set_context(ctx)
|
|
238
|
-
|
|
239
|
-
try:
|
|
240
|
-
with pytest.raises(FatalError, match="Critical failure"):
|
|
241
|
-
await fatal_step()
|
|
242
|
-
|
|
243
|
-
# Check that failure event was recorded
|
|
244
|
-
events = await storage.get_events("test_run")
|
|
245
|
-
event_types = [e.type for e in events]
|
|
246
|
-
assert EventType.STEP_FAILED in event_types
|
|
247
|
-
|
|
248
|
-
# Find the failure event
|
|
249
|
-
failure_events = [e for e in events if e.type == EventType.STEP_FAILED]
|
|
250
|
-
assert len(failure_events) == 1
|
|
251
|
-
assert failure_events[0].data["is_retryable"] is False
|
|
252
|
-
|
|
253
|
-
finally:
|
|
254
|
-
set_context(None)
|
|
255
|
-
|
|
256
|
-
@pytest.mark.asyncio
|
|
257
|
-
async def test_step_retryable_error(self, tmp_path):
|
|
258
|
-
"""Test that RetryableError triggers retry scheduling (SuspensionSignal)."""
|
|
259
|
-
|
|
260
|
-
@step()
|
|
261
|
-
async def retryable_step():
|
|
262
|
-
raise RetryableError("Temporary failure")
|
|
263
|
-
|
|
264
|
-
storage = FileStorageBackend(base_path=str(tmp_path))
|
|
265
|
-
ctx = LocalContext(
|
|
266
|
-
run_id="test_run",
|
|
267
|
-
workflow_name="test_workflow",
|
|
268
|
-
storage=storage,
|
|
269
|
-
)
|
|
270
|
-
set_context(ctx)
|
|
271
|
-
|
|
272
|
-
try:
|
|
273
|
-
# In durable mode, retryable errors trigger SuspensionSignal for retry scheduling
|
|
274
|
-
with pytest.raises(SuspensionSignal):
|
|
275
|
-
await retryable_step()
|
|
276
|
-
|
|
277
|
-
# Check that failure event was recorded
|
|
278
|
-
events = await storage.get_events("test_run")
|
|
279
|
-
failure_events = [e for e in events if e.type == EventType.STEP_FAILED]
|
|
280
|
-
assert len(failure_events) == 1
|
|
281
|
-
assert failure_events[0].data["is_retryable"] is True
|
|
282
|
-
|
|
283
|
-
finally:
|
|
284
|
-
set_context(None)
|
|
285
|
-
|
|
286
|
-
@pytest.mark.asyncio
|
|
287
|
-
async def test_step_unexpected_error_converted_to_retryable(self, tmp_path):
|
|
288
|
-
"""Test that unexpected errors trigger retry scheduling (SuspensionSignal)."""
|
|
289
|
-
|
|
290
|
-
@step()
|
|
291
|
-
async def unexpected_error_step():
|
|
292
|
-
raise ValueError("Unexpected")
|
|
293
|
-
|
|
294
|
-
storage = FileStorageBackend(base_path=str(tmp_path))
|
|
295
|
-
ctx = LocalContext(
|
|
296
|
-
run_id="test_run",
|
|
297
|
-
workflow_name="test_workflow",
|
|
298
|
-
storage=storage,
|
|
299
|
-
)
|
|
300
|
-
set_context(ctx)
|
|
301
|
-
|
|
302
|
-
try:
|
|
303
|
-
# Unexpected errors are treated as retryable and trigger SuspensionSignal
|
|
304
|
-
with pytest.raises(SuspensionSignal):
|
|
305
|
-
await unexpected_error_step()
|
|
306
|
-
|
|
307
|
-
# Check that failure event was recorded as retryable
|
|
308
|
-
events = await storage.get_events("test_run")
|
|
309
|
-
failure_events = [e for e in events if e.type == EventType.STEP_FAILED]
|
|
310
|
-
assert len(failure_events) == 1
|
|
311
|
-
assert failure_events[0].data["is_retryable"] is True
|
|
312
|
-
|
|
313
|
-
finally:
|
|
314
|
-
set_context(None)
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
class TestStepIDGeneration:
|
|
318
|
-
"""Test deterministic step ID generation."""
|
|
319
|
-
|
|
320
|
-
def test_generate_step_id_same_args(self):
|
|
321
|
-
"""Test that same arguments produce same step ID."""
|
|
322
|
-
step_id1 = _generate_step_id("test_step", (1, 2, 3), {"key": "value"})
|
|
323
|
-
step_id2 = _generate_step_id("test_step", (1, 2, 3), {"key": "value"})
|
|
324
|
-
|
|
325
|
-
assert step_id1 == step_id2
|
|
326
|
-
|
|
327
|
-
def test_generate_step_id_different_args(self):
|
|
328
|
-
"""Test that different arguments produce different step IDs."""
|
|
329
|
-
step_id1 = _generate_step_id("test_step", (1, 2, 3), {})
|
|
330
|
-
step_id2 = _generate_step_id("test_step", (4, 5, 6), {})
|
|
331
|
-
|
|
332
|
-
assert step_id1 != step_id2
|
|
333
|
-
|
|
334
|
-
def test_generate_step_id_different_kwargs(self):
|
|
335
|
-
"""Test that different kwargs produce different step IDs."""
|
|
336
|
-
step_id1 = _generate_step_id("test_step", (), {"a": 1})
|
|
337
|
-
step_id2 = _generate_step_id("test_step", (), {"a": 2})
|
|
338
|
-
|
|
339
|
-
assert step_id1 != step_id2
|
|
340
|
-
|
|
341
|
-
def test_generate_step_id_different_name(self):
|
|
342
|
-
"""Test that different names produce different step IDs."""
|
|
343
|
-
step_id1 = _generate_step_id("step_one", (1,), {})
|
|
344
|
-
step_id2 = _generate_step_id("step_two", (1,), {})
|
|
345
|
-
|
|
346
|
-
assert step_id1 != step_id2
|
|
347
|
-
|
|
348
|
-
def test_generate_step_id_format(self):
|
|
349
|
-
"""Test step ID format."""
|
|
350
|
-
step_id = _generate_step_id("my_step", (), {})
|
|
351
|
-
|
|
352
|
-
assert step_id.startswith("step_my_step_")
|
|
353
|
-
assert len(step_id) > len("step_my_step_")
|