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
tests/unit/test_hooks.py
DELETED
|
@@ -1,495 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Unit tests for hooks feature.
|
|
3
|
-
|
|
4
|
-
Tests cover:
|
|
5
|
-
- Hook primitive function
|
|
6
|
-
- MockContext hook behavior
|
|
7
|
-
- TypedHook with Pydantic validation
|
|
8
|
-
- resume_hook functionality (event-based idempotency)
|
|
9
|
-
- Token parsing helpers
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
from datetime import UTC, datetime, timedelta
|
|
13
|
-
|
|
14
|
-
import pytest
|
|
15
|
-
from pydantic import BaseModel, ValidationError
|
|
16
|
-
|
|
17
|
-
from pyworkflow import (
|
|
18
|
-
HookAlreadyReceivedError,
|
|
19
|
-
HookExpiredError,
|
|
20
|
-
HookNotFoundError,
|
|
21
|
-
InvalidTokenError,
|
|
22
|
-
MockContext,
|
|
23
|
-
ResumeResult,
|
|
24
|
-
define_hook,
|
|
25
|
-
hook,
|
|
26
|
-
resume_hook,
|
|
27
|
-
set_context,
|
|
28
|
-
)
|
|
29
|
-
from pyworkflow.engine.events import create_hook_created_event, create_hook_received_event
|
|
30
|
-
from pyworkflow.primitives.resume_hook import (
|
|
31
|
-
create_hook_token,
|
|
32
|
-
parse_hook_token,
|
|
33
|
-
)
|
|
34
|
-
from pyworkflow.storage.memory import InMemoryStorageBackend
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
class TestHookPrimitive:
|
|
38
|
-
"""Test the hook() primitive function."""
|
|
39
|
-
|
|
40
|
-
@pytest.mark.asyncio
|
|
41
|
-
async def test_hook_requires_context(self):
|
|
42
|
-
"""Test that hook() raises error without workflow context."""
|
|
43
|
-
with pytest.raises(RuntimeError, match="must be called within a workflow context"):
|
|
44
|
-
await hook("test_hook")
|
|
45
|
-
|
|
46
|
-
@pytest.mark.asyncio
|
|
47
|
-
async def test_hook_with_mock_context(self):
|
|
48
|
-
"""Test hook() with MockContext returns mock payload."""
|
|
49
|
-
ctx = MockContext(
|
|
50
|
-
run_id="test_run",
|
|
51
|
-
workflow_name="test_workflow",
|
|
52
|
-
mock_hooks={"approval": {"approved": True}},
|
|
53
|
-
)
|
|
54
|
-
set_context(ctx)
|
|
55
|
-
|
|
56
|
-
try:
|
|
57
|
-
result = await hook("approval")
|
|
58
|
-
assert result == {"approved": True}
|
|
59
|
-
finally:
|
|
60
|
-
set_context(None)
|
|
61
|
-
|
|
62
|
-
@pytest.mark.asyncio
|
|
63
|
-
async def test_hook_generates_composite_token(self):
|
|
64
|
-
"""Test hook() generates composite token in format run_id:hook_id."""
|
|
65
|
-
ctx = MockContext(
|
|
66
|
-
run_id="test_run",
|
|
67
|
-
workflow_name="test_workflow",
|
|
68
|
-
)
|
|
69
|
-
set_context(ctx)
|
|
70
|
-
|
|
71
|
-
try:
|
|
72
|
-
await hook("approval")
|
|
73
|
-
|
|
74
|
-
# Check that composite token was generated
|
|
75
|
-
assert len(ctx.hooks) == 1
|
|
76
|
-
token = ctx.hooks[0]["token"]
|
|
77
|
-
assert token.startswith("test_run:")
|
|
78
|
-
assert "approval" in token
|
|
79
|
-
finally:
|
|
80
|
-
set_context(None)
|
|
81
|
-
|
|
82
|
-
@pytest.mark.asyncio
|
|
83
|
-
async def test_hook_with_timeout(self):
|
|
84
|
-
"""Test hook() with timeout parameter."""
|
|
85
|
-
ctx = MockContext(
|
|
86
|
-
run_id="test_run",
|
|
87
|
-
workflow_name="test_workflow",
|
|
88
|
-
)
|
|
89
|
-
set_context(ctx)
|
|
90
|
-
|
|
91
|
-
try:
|
|
92
|
-
await hook("approval", timeout="24h")
|
|
93
|
-
|
|
94
|
-
# Check that timeout was tracked (parsed to seconds)
|
|
95
|
-
assert ctx.hooks[0]["timeout"] == 86400 # 24 hours in seconds
|
|
96
|
-
finally:
|
|
97
|
-
set_context(None)
|
|
98
|
-
|
|
99
|
-
@pytest.mark.asyncio
|
|
100
|
-
async def test_hook_with_on_created_callback(self):
|
|
101
|
-
"""Test hook() with on_created callback receives composite token."""
|
|
102
|
-
callback_called = False
|
|
103
|
-
callback_token = None
|
|
104
|
-
|
|
105
|
-
async def on_created(token: str):
|
|
106
|
-
nonlocal callback_called, callback_token
|
|
107
|
-
callback_called = True
|
|
108
|
-
callback_token = token
|
|
109
|
-
|
|
110
|
-
ctx = MockContext(
|
|
111
|
-
run_id="test_run",
|
|
112
|
-
workflow_name="test_workflow",
|
|
113
|
-
)
|
|
114
|
-
set_context(ctx)
|
|
115
|
-
|
|
116
|
-
try:
|
|
117
|
-
await hook("approval", on_created=on_created)
|
|
118
|
-
|
|
119
|
-
assert callback_called
|
|
120
|
-
# Token should be composite format: run_id:hook_id
|
|
121
|
-
assert callback_token.startswith("test_run:")
|
|
122
|
-
finally:
|
|
123
|
-
set_context(None)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
class TestMockContextHooks:
|
|
127
|
-
"""Test MockContext hook tracking."""
|
|
128
|
-
|
|
129
|
-
@pytest.mark.asyncio
|
|
130
|
-
async def test_mock_context_tracks_hooks(self):
|
|
131
|
-
"""Test that MockContext tracks all hook calls."""
|
|
132
|
-
ctx = MockContext(run_id="test", workflow_name="test")
|
|
133
|
-
set_context(ctx)
|
|
134
|
-
|
|
135
|
-
try:
|
|
136
|
-
await hook("hook1")
|
|
137
|
-
await hook("hook2")
|
|
138
|
-
await hook("hook3")
|
|
139
|
-
|
|
140
|
-
assert ctx.hook_count == 3
|
|
141
|
-
assert ctx.hook_names == ["hook1", "hook2", "hook3"]
|
|
142
|
-
finally:
|
|
143
|
-
set_context(None)
|
|
144
|
-
|
|
145
|
-
@pytest.mark.asyncio
|
|
146
|
-
async def test_mock_context_default_payload(self):
|
|
147
|
-
"""Test that MockContext returns default mock payload."""
|
|
148
|
-
ctx = MockContext(run_id="test", workflow_name="test")
|
|
149
|
-
set_context(ctx)
|
|
150
|
-
|
|
151
|
-
try:
|
|
152
|
-
result = await hook("unknown_hook")
|
|
153
|
-
assert result == {"hook": "unknown_hook", "mock": True}
|
|
154
|
-
finally:
|
|
155
|
-
set_context(None)
|
|
156
|
-
|
|
157
|
-
def test_mock_context_reset_clears_hooks(self):
|
|
158
|
-
"""Test that reset() clears hook tracking."""
|
|
159
|
-
ctx = MockContext(run_id="test", workflow_name="test")
|
|
160
|
-
ctx._hooks.append({"name": "test_hook", "token": "abc", "timeout": None})
|
|
161
|
-
|
|
162
|
-
ctx.reset()
|
|
163
|
-
|
|
164
|
-
assert ctx.hook_count == 0
|
|
165
|
-
assert ctx.hooks == []
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
class TestTypedHook:
|
|
169
|
-
"""Test TypedHook with Pydantic validation."""
|
|
170
|
-
|
|
171
|
-
@pytest.mark.asyncio
|
|
172
|
-
async def test_typed_hook_validates_payload(self):
|
|
173
|
-
"""Test that TypedHook validates payload against schema."""
|
|
174
|
-
|
|
175
|
-
class ApprovalPayload(BaseModel):
|
|
176
|
-
approved: bool
|
|
177
|
-
reviewer: str
|
|
178
|
-
|
|
179
|
-
approval = define_hook("approval", ApprovalPayload)
|
|
180
|
-
|
|
181
|
-
ctx = MockContext(
|
|
182
|
-
run_id="test",
|
|
183
|
-
workflow_name="test",
|
|
184
|
-
mock_hooks={"approval": {"approved": True, "reviewer": "john"}},
|
|
185
|
-
)
|
|
186
|
-
set_context(ctx)
|
|
187
|
-
|
|
188
|
-
try:
|
|
189
|
-
result = await approval()
|
|
190
|
-
assert isinstance(result, ApprovalPayload)
|
|
191
|
-
assert result.approved is True
|
|
192
|
-
assert result.reviewer == "john"
|
|
193
|
-
finally:
|
|
194
|
-
set_context(None)
|
|
195
|
-
|
|
196
|
-
@pytest.mark.asyncio
|
|
197
|
-
async def test_typed_hook_raises_validation_error(self):
|
|
198
|
-
"""Test that TypedHook raises ValidationError for invalid payload."""
|
|
199
|
-
|
|
200
|
-
class ApprovalPayload(BaseModel):
|
|
201
|
-
approved: bool
|
|
202
|
-
reviewer: str
|
|
203
|
-
|
|
204
|
-
approval = define_hook("approval", ApprovalPayload)
|
|
205
|
-
|
|
206
|
-
ctx = MockContext(
|
|
207
|
-
run_id="test",
|
|
208
|
-
workflow_name="test",
|
|
209
|
-
mock_hooks={"approval": {"approved": "not_a_bool"}}, # Missing reviewer
|
|
210
|
-
)
|
|
211
|
-
set_context(ctx)
|
|
212
|
-
|
|
213
|
-
try:
|
|
214
|
-
with pytest.raises(ValidationError):
|
|
215
|
-
await approval()
|
|
216
|
-
finally:
|
|
217
|
-
set_context(None)
|
|
218
|
-
|
|
219
|
-
@pytest.mark.asyncio
|
|
220
|
-
async def test_typed_hook_with_timeout(self):
|
|
221
|
-
"""Test TypedHook with timeout parameter."""
|
|
222
|
-
|
|
223
|
-
class Payload(BaseModel):
|
|
224
|
-
data: str
|
|
225
|
-
|
|
226
|
-
my_hook = define_hook("my_hook", Payload)
|
|
227
|
-
|
|
228
|
-
ctx = MockContext(
|
|
229
|
-
run_id="test",
|
|
230
|
-
workflow_name="test",
|
|
231
|
-
mock_hooks={"my_hook": {"data": "test_data"}},
|
|
232
|
-
)
|
|
233
|
-
set_context(ctx)
|
|
234
|
-
|
|
235
|
-
try:
|
|
236
|
-
result = await my_hook(timeout="1h")
|
|
237
|
-
assert result.data == "test_data"
|
|
238
|
-
|
|
239
|
-
# Check tracking - token should be composite format
|
|
240
|
-
assert ctx.hooks[0]["token"].startswith("test:")
|
|
241
|
-
assert ctx.hooks[0]["timeout"] == 3600
|
|
242
|
-
finally:
|
|
243
|
-
set_context(None)
|
|
244
|
-
|
|
245
|
-
def test_typed_hook_repr(self):
|
|
246
|
-
"""Test TypedHook string representation."""
|
|
247
|
-
|
|
248
|
-
class MyPayload(BaseModel):
|
|
249
|
-
value: int
|
|
250
|
-
|
|
251
|
-
my_hook = define_hook("test_hook", MyPayload)
|
|
252
|
-
assert repr(my_hook) == "TypedHook(name='test_hook', schema=MyPayload)"
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
class TestResumeHook:
|
|
256
|
-
"""Test resume_hook functionality with event-based idempotency."""
|
|
257
|
-
|
|
258
|
-
@pytest.mark.asyncio
|
|
259
|
-
async def test_resume_hook_invalid_token_format(self):
|
|
260
|
-
"""Test resume_hook raises error for invalid token format."""
|
|
261
|
-
storage = InMemoryStorageBackend()
|
|
262
|
-
|
|
263
|
-
with pytest.raises(InvalidTokenError):
|
|
264
|
-
await resume_hook("invalid_token_no_colon", {"data": "test"}, storage=storage)
|
|
265
|
-
|
|
266
|
-
@pytest.mark.asyncio
|
|
267
|
-
async def test_resume_hook_not_found(self):
|
|
268
|
-
"""Test resume_hook raises error for unknown token (no HOOK_CREATED event)."""
|
|
269
|
-
storage = InMemoryStorageBackend()
|
|
270
|
-
|
|
271
|
-
# Create a run but no hook event
|
|
272
|
-
from pyworkflow.storage.schemas import RunStatus, WorkflowRun
|
|
273
|
-
|
|
274
|
-
run = WorkflowRun(
|
|
275
|
-
run_id="run_123",
|
|
276
|
-
workflow_name="test_workflow",
|
|
277
|
-
status=RunStatus.SUSPENDED,
|
|
278
|
-
)
|
|
279
|
-
await storage.create_run(run)
|
|
280
|
-
|
|
281
|
-
# Use valid composite token format but non-existent hook
|
|
282
|
-
with pytest.raises(HookNotFoundError):
|
|
283
|
-
await resume_hook("run_123:hook_456", {"data": "test"}, storage=storage)
|
|
284
|
-
|
|
285
|
-
@pytest.mark.asyncio
|
|
286
|
-
async def test_resume_hook_already_received(self):
|
|
287
|
-
"""Test resume_hook raises error for already received hook."""
|
|
288
|
-
storage = InMemoryStorageBackend()
|
|
289
|
-
|
|
290
|
-
# Create run
|
|
291
|
-
from pyworkflow.storage.schemas import RunStatus, WorkflowRun
|
|
292
|
-
|
|
293
|
-
run = WorkflowRun(
|
|
294
|
-
run_id="run_456",
|
|
295
|
-
workflow_name="test_workflow",
|
|
296
|
-
status=RunStatus.SUSPENDED,
|
|
297
|
-
)
|
|
298
|
-
await storage.create_run(run)
|
|
299
|
-
|
|
300
|
-
# Record HOOK_CREATED event
|
|
301
|
-
event = create_hook_created_event(
|
|
302
|
-
run_id="run_456",
|
|
303
|
-
hook_id="hook_123",
|
|
304
|
-
hook_name="approval",
|
|
305
|
-
token="run_456:hook_123",
|
|
306
|
-
)
|
|
307
|
-
await storage.record_event(event)
|
|
308
|
-
|
|
309
|
-
# Record HOOK_RECEIVED event (already received)
|
|
310
|
-
received_event = create_hook_received_event(
|
|
311
|
-
run_id="run_456",
|
|
312
|
-
hook_id="hook_123",
|
|
313
|
-
payload="{}",
|
|
314
|
-
)
|
|
315
|
-
await storage.record_event(received_event)
|
|
316
|
-
|
|
317
|
-
with pytest.raises(HookAlreadyReceivedError):
|
|
318
|
-
await resume_hook("run_456:hook_123", {"data": "test"}, storage=storage)
|
|
319
|
-
|
|
320
|
-
@pytest.mark.asyncio
|
|
321
|
-
async def test_resume_hook_expired(self):
|
|
322
|
-
"""Test resume_hook raises error for expired hook."""
|
|
323
|
-
storage = InMemoryStorageBackend()
|
|
324
|
-
|
|
325
|
-
# Create run
|
|
326
|
-
from pyworkflow.storage.schemas import RunStatus, WorkflowRun
|
|
327
|
-
|
|
328
|
-
run = WorkflowRun(
|
|
329
|
-
run_id="run_456",
|
|
330
|
-
workflow_name="test_workflow",
|
|
331
|
-
status=RunStatus.SUSPENDED,
|
|
332
|
-
)
|
|
333
|
-
await storage.create_run(run)
|
|
334
|
-
|
|
335
|
-
# Record HOOK_CREATED event with past expiration
|
|
336
|
-
past_time = datetime.now(UTC) - timedelta(hours=1)
|
|
337
|
-
event = create_hook_created_event(
|
|
338
|
-
run_id="run_456",
|
|
339
|
-
hook_id="hook_123",
|
|
340
|
-
hook_name="approval",
|
|
341
|
-
token="run_456:hook_123",
|
|
342
|
-
expires_at=past_time,
|
|
343
|
-
)
|
|
344
|
-
await storage.record_event(event)
|
|
345
|
-
|
|
346
|
-
with pytest.raises(HookExpiredError):
|
|
347
|
-
await resume_hook("run_456:hook_123", {"data": "test"}, storage=storage)
|
|
348
|
-
|
|
349
|
-
@pytest.mark.asyncio
|
|
350
|
-
async def test_resume_hook_success(self):
|
|
351
|
-
"""Test successful hook resumption."""
|
|
352
|
-
storage = InMemoryStorageBackend()
|
|
353
|
-
|
|
354
|
-
# Create run
|
|
355
|
-
from pyworkflow.storage.schemas import RunStatus, WorkflowRun
|
|
356
|
-
|
|
357
|
-
run = WorkflowRun(
|
|
358
|
-
run_id="run_456",
|
|
359
|
-
workflow_name="test_workflow",
|
|
360
|
-
status=RunStatus.SUSPENDED,
|
|
361
|
-
)
|
|
362
|
-
await storage.create_run(run)
|
|
363
|
-
|
|
364
|
-
# Record HOOK_CREATED event
|
|
365
|
-
event = create_hook_created_event(
|
|
366
|
-
run_id="run_456",
|
|
367
|
-
hook_id="hook_123",
|
|
368
|
-
hook_name="approval",
|
|
369
|
-
token="run_456:hook_123",
|
|
370
|
-
)
|
|
371
|
-
await storage.record_event(event)
|
|
372
|
-
|
|
373
|
-
# Resume the hook using composite token
|
|
374
|
-
result = await resume_hook("run_456:hook_123", {"approved": True}, storage=storage)
|
|
375
|
-
|
|
376
|
-
assert isinstance(result, ResumeResult)
|
|
377
|
-
assert result.run_id == "run_456"
|
|
378
|
-
assert result.hook_id == "hook_123"
|
|
379
|
-
assert result.status == "resumed"
|
|
380
|
-
|
|
381
|
-
# Check HOOK_RECEIVED event was recorded
|
|
382
|
-
events = await storage.get_events("run_456")
|
|
383
|
-
hook_received_events = [e for e in events if e.type.value == "hook.received"]
|
|
384
|
-
assert len(hook_received_events) == 1
|
|
385
|
-
assert hook_received_events[0].data["hook_id"] == "hook_123"
|
|
386
|
-
|
|
387
|
-
@pytest.mark.asyncio
|
|
388
|
-
async def test_resume_hook_with_expiration_not_expired(self):
|
|
389
|
-
"""Test resume_hook succeeds when expiration is in the future."""
|
|
390
|
-
storage = InMemoryStorageBackend()
|
|
391
|
-
|
|
392
|
-
# Create run
|
|
393
|
-
from pyworkflow.storage.schemas import RunStatus, WorkflowRun
|
|
394
|
-
|
|
395
|
-
run = WorkflowRun(
|
|
396
|
-
run_id="run_456",
|
|
397
|
-
workflow_name="test_workflow",
|
|
398
|
-
status=RunStatus.SUSPENDED,
|
|
399
|
-
)
|
|
400
|
-
await storage.create_run(run)
|
|
401
|
-
|
|
402
|
-
# Record HOOK_CREATED event with future expiration
|
|
403
|
-
future_time = datetime.now(UTC) + timedelta(hours=1)
|
|
404
|
-
event = create_hook_created_event(
|
|
405
|
-
run_id="run_456",
|
|
406
|
-
hook_id="hook_123",
|
|
407
|
-
hook_name="approval",
|
|
408
|
-
token="run_456:hook_123",
|
|
409
|
-
expires_at=future_time,
|
|
410
|
-
)
|
|
411
|
-
await storage.record_event(event)
|
|
412
|
-
|
|
413
|
-
# Should succeed
|
|
414
|
-
result = await resume_hook("run_456:hook_123", {"approved": True}, storage=storage)
|
|
415
|
-
assert result.status == "resumed"
|
|
416
|
-
|
|
417
|
-
@pytest.mark.asyncio
|
|
418
|
-
async def test_resume_hook_requires_storage(self):
|
|
419
|
-
"""Test resume_hook raises error without configured storage."""
|
|
420
|
-
# Reset any global config
|
|
421
|
-
from pyworkflow.config import reset_config
|
|
422
|
-
|
|
423
|
-
reset_config()
|
|
424
|
-
|
|
425
|
-
with pytest.raises(RuntimeError, match="No storage backend configured"):
|
|
426
|
-
await resume_hook("run_123:hook_456", {"data": "test"})
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
class TestTokenParsing:
|
|
430
|
-
"""Test token parsing helper functions."""
|
|
431
|
-
|
|
432
|
-
def test_parse_valid_token(self):
|
|
433
|
-
"""Test parsing a valid composite token."""
|
|
434
|
-
run_id, hook_id = parse_hook_token("run_abc123:hook_approval_1")
|
|
435
|
-
assert run_id == "run_abc123"
|
|
436
|
-
assert hook_id == "hook_approval_1"
|
|
437
|
-
|
|
438
|
-
def test_parse_token_with_colons_in_hook_id(self):
|
|
439
|
-
"""Test parsing token where hook_id contains colons."""
|
|
440
|
-
run_id, hook_id = parse_hook_token("run_abc:hook:with:colons")
|
|
441
|
-
assert run_id == "run_abc"
|
|
442
|
-
assert hook_id == "hook:with:colons"
|
|
443
|
-
|
|
444
|
-
def test_parse_token_no_separator(self):
|
|
445
|
-
"""Test parsing token without separator raises error."""
|
|
446
|
-
with pytest.raises(InvalidTokenError, match="Invalid token format"):
|
|
447
|
-
parse_hook_token("invalid_token_no_colon")
|
|
448
|
-
|
|
449
|
-
def test_parse_token_empty_run_id(self):
|
|
450
|
-
"""Test parsing token with empty run_id raises error."""
|
|
451
|
-
with pytest.raises(InvalidTokenError, match="Invalid token format"):
|
|
452
|
-
parse_hook_token(":hook_123")
|
|
453
|
-
|
|
454
|
-
def test_parse_token_empty_hook_id(self):
|
|
455
|
-
"""Test parsing token with empty hook_id raises error."""
|
|
456
|
-
with pytest.raises(InvalidTokenError, match="Invalid token format"):
|
|
457
|
-
parse_hook_token("run_123:")
|
|
458
|
-
|
|
459
|
-
def test_create_token(self):
|
|
460
|
-
"""Test creating composite token."""
|
|
461
|
-
token = create_hook_token("run_abc123", "hook_approval_1")
|
|
462
|
-
assert token == "run_abc123:hook_approval_1"
|
|
463
|
-
|
|
464
|
-
def test_roundtrip_token(self):
|
|
465
|
-
"""Test token creation and parsing roundtrip."""
|
|
466
|
-
original_run_id = "run_xyz789"
|
|
467
|
-
original_hook_id = "hook_payment_2"
|
|
468
|
-
|
|
469
|
-
token = create_hook_token(original_run_id, original_hook_id)
|
|
470
|
-
parsed_run_id, parsed_hook_id = parse_hook_token(token)
|
|
471
|
-
|
|
472
|
-
assert parsed_run_id == original_run_id
|
|
473
|
-
assert parsed_hook_id == original_hook_id
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
class TestHookExceptions:
|
|
477
|
-
"""Test hook-related exception classes."""
|
|
478
|
-
|
|
479
|
-
def test_hook_not_found_error(self):
|
|
480
|
-
"""Test HookNotFoundError contains token."""
|
|
481
|
-
error = HookNotFoundError("my_token_123")
|
|
482
|
-
assert error.token == "my_token_123"
|
|
483
|
-
assert "my_token_123" in str(error)
|
|
484
|
-
|
|
485
|
-
def test_hook_already_received_error(self):
|
|
486
|
-
"""Test HookAlreadyReceivedError contains hook_id."""
|
|
487
|
-
error = HookAlreadyReceivedError("hook_abc")
|
|
488
|
-
assert error.hook_id == "hook_abc"
|
|
489
|
-
assert "hook_abc" in str(error)
|
|
490
|
-
|
|
491
|
-
def test_hook_expired_error(self):
|
|
492
|
-
"""Test HookExpiredError contains hook_id."""
|
|
493
|
-
error = HookExpiredError("hook_xyz")
|
|
494
|
-
assert error.hook_id == "hook_xyz"
|
|
495
|
-
assert "hook_xyz" in str(error)
|