pyworkflow-engine 0.1.7__py3-none-any.whl → 0.1.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. pyworkflow/__init__.py +10 -1
  2. pyworkflow/celery/tasks.py +272 -24
  3. pyworkflow/cli/__init__.py +4 -1
  4. pyworkflow/cli/commands/runs.py +4 -4
  5. pyworkflow/cli/commands/setup.py +203 -4
  6. pyworkflow/cli/utils/config_generator.py +76 -3
  7. pyworkflow/cli/utils/docker_manager.py +232 -0
  8. pyworkflow/context/__init__.py +13 -0
  9. pyworkflow/context/base.py +26 -0
  10. pyworkflow/context/local.py +80 -0
  11. pyworkflow/context/step_context.py +295 -0
  12. pyworkflow/core/registry.py +6 -1
  13. pyworkflow/core/step.py +141 -0
  14. pyworkflow/core/workflow.py +56 -0
  15. pyworkflow/engine/events.py +30 -0
  16. pyworkflow/engine/replay.py +39 -0
  17. pyworkflow/primitives/child_workflow.py +1 -1
  18. pyworkflow/runtime/local.py +1 -1
  19. pyworkflow/storage/__init__.py +14 -0
  20. pyworkflow/storage/base.py +35 -0
  21. pyworkflow/storage/cassandra.py +1747 -0
  22. pyworkflow/storage/config.py +69 -0
  23. pyworkflow/storage/dynamodb.py +31 -2
  24. pyworkflow/storage/file.py +28 -0
  25. pyworkflow/storage/memory.py +18 -0
  26. pyworkflow/storage/mysql.py +1159 -0
  27. pyworkflow/storage/postgres.py +27 -2
  28. pyworkflow/storage/schemas.py +4 -3
  29. pyworkflow/storage/sqlite.py +25 -2
  30. {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.9.dist-info}/METADATA +7 -4
  31. pyworkflow_engine-0.1.9.dist-info/RECORD +91 -0
  32. pyworkflow_engine-0.1.9.dist-info/top_level.txt +1 -0
  33. dashboard/backend/app/__init__.py +0 -1
  34. dashboard/backend/app/config.py +0 -32
  35. dashboard/backend/app/controllers/__init__.py +0 -6
  36. dashboard/backend/app/controllers/run_controller.py +0 -86
  37. dashboard/backend/app/controllers/workflow_controller.py +0 -33
  38. dashboard/backend/app/dependencies/__init__.py +0 -5
  39. dashboard/backend/app/dependencies/storage.py +0 -50
  40. dashboard/backend/app/repositories/__init__.py +0 -6
  41. dashboard/backend/app/repositories/run_repository.py +0 -80
  42. dashboard/backend/app/repositories/workflow_repository.py +0 -27
  43. dashboard/backend/app/rest/__init__.py +0 -8
  44. dashboard/backend/app/rest/v1/__init__.py +0 -12
  45. dashboard/backend/app/rest/v1/health.py +0 -33
  46. dashboard/backend/app/rest/v1/runs.py +0 -133
  47. dashboard/backend/app/rest/v1/workflows.py +0 -41
  48. dashboard/backend/app/schemas/__init__.py +0 -23
  49. dashboard/backend/app/schemas/common.py +0 -16
  50. dashboard/backend/app/schemas/event.py +0 -24
  51. dashboard/backend/app/schemas/hook.py +0 -25
  52. dashboard/backend/app/schemas/run.py +0 -54
  53. dashboard/backend/app/schemas/step.py +0 -28
  54. dashboard/backend/app/schemas/workflow.py +0 -31
  55. dashboard/backend/app/server.py +0 -87
  56. dashboard/backend/app/services/__init__.py +0 -6
  57. dashboard/backend/app/services/run_service.py +0 -240
  58. dashboard/backend/app/services/workflow_service.py +0 -155
  59. dashboard/backend/main.py +0 -18
  60. docs/concepts/cancellation.mdx +0 -362
  61. docs/concepts/continue-as-new.mdx +0 -434
  62. docs/concepts/events.mdx +0 -266
  63. docs/concepts/fault-tolerance.mdx +0 -370
  64. docs/concepts/hooks.mdx +0 -552
  65. docs/concepts/limitations.mdx +0 -167
  66. docs/concepts/schedules.mdx +0 -775
  67. docs/concepts/sleep.mdx +0 -312
  68. docs/concepts/steps.mdx +0 -301
  69. docs/concepts/workflows.mdx +0 -255
  70. docs/guides/cli.mdx +0 -942
  71. docs/guides/configuration.mdx +0 -560
  72. docs/introduction.mdx +0 -155
  73. docs/quickstart.mdx +0 -279
  74. examples/__init__.py +0 -1
  75. examples/celery/__init__.py +0 -1
  76. examples/celery/durable/docker-compose.yml +0 -55
  77. examples/celery/durable/pyworkflow.config.yaml +0 -12
  78. examples/celery/durable/workflows/__init__.py +0 -122
  79. examples/celery/durable/workflows/basic.py +0 -87
  80. examples/celery/durable/workflows/batch_processing.py +0 -102
  81. examples/celery/durable/workflows/cancellation.py +0 -273
  82. examples/celery/durable/workflows/child_workflow_patterns.py +0 -240
  83. examples/celery/durable/workflows/child_workflows.py +0 -202
  84. examples/celery/durable/workflows/continue_as_new.py +0 -260
  85. examples/celery/durable/workflows/fault_tolerance.py +0 -210
  86. examples/celery/durable/workflows/hooks.py +0 -211
  87. examples/celery/durable/workflows/idempotency.py +0 -112
  88. examples/celery/durable/workflows/long_running.py +0 -99
  89. examples/celery/durable/workflows/retries.py +0 -101
  90. examples/celery/durable/workflows/schedules.py +0 -209
  91. examples/celery/transient/01_basic_workflow.py +0 -91
  92. examples/celery/transient/02_fault_tolerance.py +0 -257
  93. examples/celery/transient/__init__.py +0 -20
  94. examples/celery/transient/pyworkflow.config.yaml +0 -25
  95. examples/local/__init__.py +0 -1
  96. examples/local/durable/01_basic_workflow.py +0 -94
  97. examples/local/durable/02_file_storage.py +0 -132
  98. examples/local/durable/03_retries.py +0 -169
  99. examples/local/durable/04_long_running.py +0 -119
  100. examples/local/durable/05_event_log.py +0 -145
  101. examples/local/durable/06_idempotency.py +0 -148
  102. examples/local/durable/07_hooks.py +0 -334
  103. examples/local/durable/08_cancellation.py +0 -233
  104. examples/local/durable/09_child_workflows.py +0 -198
  105. examples/local/durable/10_child_workflow_patterns.py +0 -265
  106. examples/local/durable/11_continue_as_new.py +0 -249
  107. examples/local/durable/12_schedules.py +0 -198
  108. examples/local/durable/__init__.py +0 -1
  109. examples/local/transient/01_quick_tasks.py +0 -87
  110. examples/local/transient/02_retries.py +0 -130
  111. examples/local/transient/03_sleep.py +0 -141
  112. examples/local/transient/__init__.py +0 -1
  113. pyworkflow_engine-0.1.7.dist-info/RECORD +0 -196
  114. pyworkflow_engine-0.1.7.dist-info/top_level.txt +0 -5
  115. tests/examples/__init__.py +0 -0
  116. tests/integration/__init__.py +0 -0
  117. tests/integration/test_cancellation.py +0 -330
  118. tests/integration/test_child_workflows.py +0 -439
  119. tests/integration/test_continue_as_new.py +0 -428
  120. tests/integration/test_dynamodb_storage.py +0 -1146
  121. tests/integration/test_fault_tolerance.py +0 -369
  122. tests/integration/test_schedule_storage.py +0 -484
  123. tests/unit/__init__.py +0 -0
  124. tests/unit/backends/__init__.py +0 -1
  125. tests/unit/backends/test_dynamodb_storage.py +0 -1554
  126. tests/unit/backends/test_postgres_storage.py +0 -1281
  127. tests/unit/backends/test_sqlite_storage.py +0 -1460
  128. tests/unit/conftest.py +0 -41
  129. tests/unit/test_cancellation.py +0 -364
  130. tests/unit/test_child_workflows.py +0 -680
  131. tests/unit/test_continue_as_new.py +0 -441
  132. tests/unit/test_event_limits.py +0 -316
  133. tests/unit/test_executor.py +0 -320
  134. tests/unit/test_fault_tolerance.py +0 -334
  135. tests/unit/test_hooks.py +0 -495
  136. tests/unit/test_registry.py +0 -261
  137. tests/unit/test_replay.py +0 -420
  138. tests/unit/test_schedule_schemas.py +0 -285
  139. tests/unit/test_schedule_utils.py +0 -286
  140. tests/unit/test_scheduled_workflow.py +0 -274
  141. tests/unit/test_step.py +0 -353
  142. tests/unit/test_workflow.py +0 -243
  143. {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.9.dist-info}/WHEEL +0 -0
  144. {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.9.dist-info}/entry_points.txt +0 -0
  145. {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.9.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_")