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
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)