pyworkflow-engine 0.1.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dashboard/backend/app/__init__.py +1 -0
- dashboard/backend/app/config.py +32 -0
- dashboard/backend/app/controllers/__init__.py +6 -0
- dashboard/backend/app/controllers/run_controller.py +86 -0
- dashboard/backend/app/controllers/workflow_controller.py +33 -0
- dashboard/backend/app/dependencies/__init__.py +5 -0
- dashboard/backend/app/dependencies/storage.py +50 -0
- dashboard/backend/app/repositories/__init__.py +6 -0
- dashboard/backend/app/repositories/run_repository.py +80 -0
- dashboard/backend/app/repositories/workflow_repository.py +27 -0
- dashboard/backend/app/rest/__init__.py +8 -0
- dashboard/backend/app/rest/v1/__init__.py +12 -0
- dashboard/backend/app/rest/v1/health.py +33 -0
- dashboard/backend/app/rest/v1/runs.py +133 -0
- dashboard/backend/app/rest/v1/workflows.py +41 -0
- dashboard/backend/app/schemas/__init__.py +23 -0
- dashboard/backend/app/schemas/common.py +16 -0
- dashboard/backend/app/schemas/event.py +24 -0
- dashboard/backend/app/schemas/hook.py +25 -0
- dashboard/backend/app/schemas/run.py +54 -0
- dashboard/backend/app/schemas/step.py +28 -0
- dashboard/backend/app/schemas/workflow.py +31 -0
- dashboard/backend/app/server.py +87 -0
- dashboard/backend/app/services/__init__.py +6 -0
- dashboard/backend/app/services/run_service.py +240 -0
- dashboard/backend/app/services/workflow_service.py +155 -0
- dashboard/backend/main.py +18 -0
- docs/concepts/cancellation.mdx +362 -0
- docs/concepts/continue-as-new.mdx +434 -0
- docs/concepts/events.mdx +266 -0
- docs/concepts/fault-tolerance.mdx +370 -0
- docs/concepts/hooks.mdx +552 -0
- docs/concepts/limitations.mdx +167 -0
- docs/concepts/schedules.mdx +775 -0
- docs/concepts/sleep.mdx +312 -0
- docs/concepts/steps.mdx +301 -0
- docs/concepts/workflows.mdx +255 -0
- docs/guides/cli.mdx +942 -0
- docs/guides/configuration.mdx +560 -0
- docs/introduction.mdx +155 -0
- docs/quickstart.mdx +279 -0
- examples/__init__.py +1 -0
- examples/celery/__init__.py +1 -0
- examples/celery/durable/docker-compose.yml +55 -0
- examples/celery/durable/pyworkflow.config.yaml +12 -0
- examples/celery/durable/workflows/__init__.py +122 -0
- examples/celery/durable/workflows/basic.py +87 -0
- examples/celery/durable/workflows/batch_processing.py +102 -0
- examples/celery/durable/workflows/cancellation.py +273 -0
- examples/celery/durable/workflows/child_workflow_patterns.py +240 -0
- examples/celery/durable/workflows/child_workflows.py +202 -0
- examples/celery/durable/workflows/continue_as_new.py +260 -0
- examples/celery/durable/workflows/fault_tolerance.py +210 -0
- examples/celery/durable/workflows/hooks.py +211 -0
- examples/celery/durable/workflows/idempotency.py +112 -0
- examples/celery/durable/workflows/long_running.py +99 -0
- examples/celery/durable/workflows/retries.py +101 -0
- examples/celery/durable/workflows/schedules.py +209 -0
- examples/celery/transient/01_basic_workflow.py +91 -0
- examples/celery/transient/02_fault_tolerance.py +257 -0
- examples/celery/transient/__init__.py +20 -0
- examples/celery/transient/pyworkflow.config.yaml +25 -0
- examples/local/__init__.py +1 -0
- examples/local/durable/01_basic_workflow.py +94 -0
- examples/local/durable/02_file_storage.py +132 -0
- examples/local/durable/03_retries.py +169 -0
- examples/local/durable/04_long_running.py +119 -0
- examples/local/durable/05_event_log.py +145 -0
- examples/local/durable/06_idempotency.py +148 -0
- examples/local/durable/07_hooks.py +334 -0
- examples/local/durable/08_cancellation.py +233 -0
- examples/local/durable/09_child_workflows.py +198 -0
- examples/local/durable/10_child_workflow_patterns.py +265 -0
- examples/local/durable/11_continue_as_new.py +249 -0
- examples/local/durable/12_schedules.py +198 -0
- examples/local/durable/__init__.py +1 -0
- examples/local/transient/01_quick_tasks.py +87 -0
- examples/local/transient/02_retries.py +130 -0
- examples/local/transient/03_sleep.py +141 -0
- examples/local/transient/__init__.py +1 -0
- pyworkflow/__init__.py +256 -0
- pyworkflow/aws/__init__.py +68 -0
- pyworkflow/aws/context.py +234 -0
- pyworkflow/aws/handler.py +184 -0
- pyworkflow/aws/testing.py +310 -0
- pyworkflow/celery/__init__.py +41 -0
- pyworkflow/celery/app.py +198 -0
- pyworkflow/celery/scheduler.py +315 -0
- pyworkflow/celery/tasks.py +1746 -0
- pyworkflow/cli/__init__.py +132 -0
- pyworkflow/cli/__main__.py +6 -0
- pyworkflow/cli/commands/__init__.py +1 -0
- pyworkflow/cli/commands/hooks.py +640 -0
- pyworkflow/cli/commands/quickstart.py +495 -0
- pyworkflow/cli/commands/runs.py +773 -0
- pyworkflow/cli/commands/scheduler.py +130 -0
- pyworkflow/cli/commands/schedules.py +794 -0
- pyworkflow/cli/commands/setup.py +703 -0
- pyworkflow/cli/commands/worker.py +413 -0
- pyworkflow/cli/commands/workflows.py +1257 -0
- pyworkflow/cli/output/__init__.py +1 -0
- pyworkflow/cli/output/formatters.py +321 -0
- pyworkflow/cli/output/styles.py +121 -0
- pyworkflow/cli/utils/__init__.py +1 -0
- pyworkflow/cli/utils/async_helpers.py +30 -0
- pyworkflow/cli/utils/config.py +130 -0
- pyworkflow/cli/utils/config_generator.py +344 -0
- pyworkflow/cli/utils/discovery.py +53 -0
- pyworkflow/cli/utils/docker_manager.py +651 -0
- pyworkflow/cli/utils/interactive.py +364 -0
- pyworkflow/cli/utils/storage.py +115 -0
- pyworkflow/config.py +329 -0
- pyworkflow/context/__init__.py +63 -0
- pyworkflow/context/aws.py +230 -0
- pyworkflow/context/base.py +416 -0
- pyworkflow/context/local.py +930 -0
- pyworkflow/context/mock.py +381 -0
- pyworkflow/core/__init__.py +0 -0
- pyworkflow/core/exceptions.py +353 -0
- pyworkflow/core/registry.py +313 -0
- pyworkflow/core/scheduled.py +328 -0
- pyworkflow/core/step.py +494 -0
- pyworkflow/core/workflow.py +294 -0
- pyworkflow/discovery.py +248 -0
- pyworkflow/engine/__init__.py +0 -0
- pyworkflow/engine/events.py +879 -0
- pyworkflow/engine/executor.py +682 -0
- pyworkflow/engine/replay.py +273 -0
- pyworkflow/observability/__init__.py +19 -0
- pyworkflow/observability/logging.py +234 -0
- pyworkflow/primitives/__init__.py +33 -0
- pyworkflow/primitives/child_handle.py +174 -0
- pyworkflow/primitives/child_workflow.py +372 -0
- pyworkflow/primitives/continue_as_new.py +101 -0
- pyworkflow/primitives/define_hook.py +150 -0
- pyworkflow/primitives/hooks.py +97 -0
- pyworkflow/primitives/resume_hook.py +210 -0
- pyworkflow/primitives/schedule.py +545 -0
- pyworkflow/primitives/shield.py +96 -0
- pyworkflow/primitives/sleep.py +100 -0
- pyworkflow/runtime/__init__.py +21 -0
- pyworkflow/runtime/base.py +179 -0
- pyworkflow/runtime/celery.py +310 -0
- pyworkflow/runtime/factory.py +101 -0
- pyworkflow/runtime/local.py +706 -0
- pyworkflow/scheduler/__init__.py +9 -0
- pyworkflow/scheduler/local.py +248 -0
- pyworkflow/serialization/__init__.py +0 -0
- pyworkflow/serialization/decoder.py +146 -0
- pyworkflow/serialization/encoder.py +162 -0
- pyworkflow/storage/__init__.py +54 -0
- pyworkflow/storage/base.py +612 -0
- pyworkflow/storage/config.py +185 -0
- pyworkflow/storage/dynamodb.py +1315 -0
- pyworkflow/storage/file.py +827 -0
- pyworkflow/storage/memory.py +549 -0
- pyworkflow/storage/postgres.py +1161 -0
- pyworkflow/storage/schemas.py +486 -0
- pyworkflow/storage/sqlite.py +1136 -0
- pyworkflow/utils/__init__.py +0 -0
- pyworkflow/utils/duration.py +177 -0
- pyworkflow/utils/schedule.py +391 -0
- pyworkflow_engine-0.1.7.dist-info/METADATA +687 -0
- pyworkflow_engine-0.1.7.dist-info/RECORD +196 -0
- pyworkflow_engine-0.1.7.dist-info/WHEEL +5 -0
- pyworkflow_engine-0.1.7.dist-info/entry_points.txt +2 -0
- pyworkflow_engine-0.1.7.dist-info/licenses/LICENSE +21 -0
- pyworkflow_engine-0.1.7.dist-info/top_level.txt +5 -0
- tests/examples/__init__.py +0 -0
- tests/integration/__init__.py +0 -0
- tests/integration/test_cancellation.py +330 -0
- tests/integration/test_child_workflows.py +439 -0
- tests/integration/test_continue_as_new.py +428 -0
- tests/integration/test_dynamodb_storage.py +1146 -0
- tests/integration/test_fault_tolerance.py +369 -0
- tests/integration/test_schedule_storage.py +484 -0
- tests/unit/__init__.py +0 -0
- tests/unit/backends/__init__.py +1 -0
- tests/unit/backends/test_dynamodb_storage.py +1554 -0
- tests/unit/backends/test_postgres_storage.py +1281 -0
- tests/unit/backends/test_sqlite_storage.py +1460 -0
- tests/unit/conftest.py +41 -0
- tests/unit/test_cancellation.py +364 -0
- tests/unit/test_child_workflows.py +680 -0
- tests/unit/test_continue_as_new.py +441 -0
- tests/unit/test_event_limits.py +316 -0
- tests/unit/test_executor.py +320 -0
- tests/unit/test_fault_tolerance.py +334 -0
- tests/unit/test_hooks.py +495 -0
- tests/unit/test_registry.py +261 -0
- tests/unit/test_replay.py +420 -0
- tests/unit/test_schedule_schemas.py +285 -0
- tests/unit/test_schedule_utils.py +286 -0
- tests/unit/test_scheduled_workflow.py +274 -0
- tests/unit/test_step.py +353 -0
- tests/unit/test_workflow.py +243 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Durable Workflow - Idempotency
|
|
3
|
+
|
|
4
|
+
This example demonstrates idempotency key usage to prevent duplicate workflow execution.
|
|
5
|
+
- Same workflow called twice with same idempotency_key
|
|
6
|
+
- Second call returns same run_id without re-execution
|
|
7
|
+
- Prevents duplicate orders, payments, etc.
|
|
8
|
+
- Uses FileStorageBackend for persistence
|
|
9
|
+
|
|
10
|
+
Run: python examples/local/durable/06_idempotency.py 2>/dev/null
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import tempfile
|
|
15
|
+
|
|
16
|
+
from pyworkflow import (
|
|
17
|
+
configure,
|
|
18
|
+
get_workflow_run,
|
|
19
|
+
reset_config,
|
|
20
|
+
start,
|
|
21
|
+
step,
|
|
22
|
+
workflow,
|
|
23
|
+
)
|
|
24
|
+
from pyworkflow.storage import FileStorageBackend
|
|
25
|
+
|
|
26
|
+
# Execution counter to track step calls
|
|
27
|
+
execution_count = 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# --- Steps ---
|
|
31
|
+
@step()
|
|
32
|
+
async def create_order(order_id: str, amount: float) -> dict:
|
|
33
|
+
"""Create a new order."""
|
|
34
|
+
global execution_count
|
|
35
|
+
execution_count += 1
|
|
36
|
+
|
|
37
|
+
print(f" Creating order {order_id} for ${amount:.2f}...")
|
|
38
|
+
print(f" (Execution count: {execution_count})")
|
|
39
|
+
|
|
40
|
+
return {"order_id": order_id, "amount": amount, "status": "created"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@step()
|
|
44
|
+
async def charge_customer(order: dict) -> dict:
|
|
45
|
+
"""Charge the customer."""
|
|
46
|
+
print(f" Charging customer ${order['amount']:.2f}...")
|
|
47
|
+
return {**order, "charged": True}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@step()
|
|
51
|
+
async def send_confirmation(order: dict) -> dict:
|
|
52
|
+
"""Send order confirmation."""
|
|
53
|
+
print(f" Sending confirmation for order {order['order_id']}...")
|
|
54
|
+
return {**order, "confirmed": True}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# --- Workflow ---
|
|
58
|
+
@workflow(durable=True, tags=["local", "durable"])
|
|
59
|
+
async def order_workflow(order_id: str, amount: float) -> dict:
|
|
60
|
+
"""Complete order workflow (must be idempotent)."""
|
|
61
|
+
order = await create_order(order_id, amount)
|
|
62
|
+
order = await charge_customer(order)
|
|
63
|
+
order = await send_confirmation(order)
|
|
64
|
+
return order
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def main():
|
|
68
|
+
global execution_count
|
|
69
|
+
|
|
70
|
+
# Use temp directory (use real path for production)
|
|
71
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
72
|
+
print("=== Durable Workflow - Idempotency ===\n")
|
|
73
|
+
|
|
74
|
+
# Configure with FileStorageBackend (for persistence)
|
|
75
|
+
reset_config()
|
|
76
|
+
storage = FileStorageBackend(base_path=tmpdir)
|
|
77
|
+
configure(storage=storage, default_durable=True)
|
|
78
|
+
|
|
79
|
+
# Reset execution count
|
|
80
|
+
execution_count = 0
|
|
81
|
+
|
|
82
|
+
# First call with idempotency key
|
|
83
|
+
print("First call: Creating order with idempotency_key='order-unique-123'...\n")
|
|
84
|
+
run_id_1 = await start(
|
|
85
|
+
order_workflow, "order-unique-123", 99.99, idempotency_key="order-unique-123"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
run_1 = await get_workflow_run(run_id_1)
|
|
89
|
+
print("\nFirst call completed:")
|
|
90
|
+
print(f" Run ID: {run_id_1}")
|
|
91
|
+
print(f" Status: {run_1.status.value}")
|
|
92
|
+
print(f" Result: {run_1.result}")
|
|
93
|
+
print(f" Execution count: {execution_count}")
|
|
94
|
+
|
|
95
|
+
# Second call with SAME idempotency key
|
|
96
|
+
print("\n" + "=" * 60)
|
|
97
|
+
print("\nSecond call: Same idempotency_key='order-unique-123'...\n")
|
|
98
|
+
run_id_2 = await start(
|
|
99
|
+
order_workflow, "order-unique-123", 99.99, idempotency_key="order-unique-123"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
run_2 = await get_workflow_run(run_id_2)
|
|
103
|
+
print("\nSecond call result:")
|
|
104
|
+
print(f" Run ID: {run_id_2}")
|
|
105
|
+
print(f" Status: {run_2.status.value}")
|
|
106
|
+
print(f" Result: {run_2.result}")
|
|
107
|
+
print(f" Execution count: {execution_count} (not incremented!)")
|
|
108
|
+
|
|
109
|
+
# Verify they're the same
|
|
110
|
+
print("\n" + "=" * 60)
|
|
111
|
+
print("\n=== Verification ===")
|
|
112
|
+
print(f"run_id_1 == run_id_2: {run_id_1 == run_id_2}")
|
|
113
|
+
print(f"Workflow re-executed: {execution_count > 1}")
|
|
114
|
+
|
|
115
|
+
if run_id_1 == run_id_2:
|
|
116
|
+
print("\n✓ SUCCESS: Same run_id returned, workflow NOT re-executed!")
|
|
117
|
+
else:
|
|
118
|
+
print("\n✗ UNEXPECTED: Different run_id, workflow was re-executed!")
|
|
119
|
+
|
|
120
|
+
# Third call with DIFFERENT idempotency key
|
|
121
|
+
print("\n" + "=" * 60)
|
|
122
|
+
print("\nThird call: Different idempotency_key='order-unique-456'...\n")
|
|
123
|
+
run_id_3 = await start(
|
|
124
|
+
order_workflow, "order-unique-456", 149.99, idempotency_key="order-unique-456"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
run_3 = await get_workflow_run(run_id_3)
|
|
128
|
+
print("\nThird call result:")
|
|
129
|
+
print(f" Run ID: {run_id_3}")
|
|
130
|
+
print(f" Status: {run_3.status.value}")
|
|
131
|
+
print(f" Execution count: {execution_count} (incremented!)")
|
|
132
|
+
|
|
133
|
+
print("\n=== Use Cases ===")
|
|
134
|
+
print("✓ Prevent duplicate orders from retry logic")
|
|
135
|
+
print("✓ Ensure exactly-once payment processing")
|
|
136
|
+
print("✓ Handle duplicate webhook deliveries")
|
|
137
|
+
print("✓ Guarantee idempotent API endpoints")
|
|
138
|
+
|
|
139
|
+
print("\n=== Key Takeaways ===")
|
|
140
|
+
print("✓ Same idempotency_key returns same run_id")
|
|
141
|
+
print("✓ Workflow NOT re-executed on duplicate key")
|
|
142
|
+
print("✓ Different keys create new workflow executions")
|
|
143
|
+
print("✓ Critical for financial transactions and critical workflows")
|
|
144
|
+
print("✓ Works across process restarts (persisted to storage)")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Durable Workflow - Hooks Example
|
|
3
|
+
|
|
4
|
+
This example demonstrates the hooks feature for waiting on external events:
|
|
5
|
+
- Using hook() to suspend workflow and wait for external input
|
|
6
|
+
- Using define_hook() for typed hooks with Pydantic validation
|
|
7
|
+
- Using resume_hook() to deliver payloads from external systems
|
|
8
|
+
- Composite tokens (run_id:hook_id) for self-describing tokens
|
|
9
|
+
- on_created callback for receiving the generated token
|
|
10
|
+
|
|
11
|
+
Run: python examples/local/durable/07_hooks.py 2>/dev/null
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
|
|
18
|
+
from pyworkflow import (
|
|
19
|
+
configure,
|
|
20
|
+
define_hook,
|
|
21
|
+
get_workflow_events,
|
|
22
|
+
get_workflow_run,
|
|
23
|
+
hook,
|
|
24
|
+
reset_config,
|
|
25
|
+
resume,
|
|
26
|
+
resume_hook,
|
|
27
|
+
start,
|
|
28
|
+
step,
|
|
29
|
+
workflow,
|
|
30
|
+
)
|
|
31
|
+
from pyworkflow.storage import InMemoryStorageBackend
|
|
32
|
+
|
|
33
|
+
# Global storage reference for resumption
|
|
34
|
+
_storage = None
|
|
35
|
+
|
|
36
|
+
# Store tokens received from on_created callback
|
|
37
|
+
_captured_tokens = {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# --- Pydantic models for typed hooks ---
|
|
41
|
+
class ApprovalPayload(BaseModel):
|
|
42
|
+
"""Typed payload for approval hook."""
|
|
43
|
+
|
|
44
|
+
approved: bool
|
|
45
|
+
reviewer: str
|
|
46
|
+
comments: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Create typed hook
|
|
50
|
+
approval_hook = define_hook("approval", ApprovalPayload)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# --- Steps ---
|
|
54
|
+
@step()
|
|
55
|
+
async def prepare_order(order_id: str) -> dict:
|
|
56
|
+
"""Prepare the order for review."""
|
|
57
|
+
print(f" Preparing order {order_id}...")
|
|
58
|
+
return {"order_id": order_id, "status": "pending_approval"}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@step()
|
|
62
|
+
async def fulfill_order(order: dict) -> dict:
|
|
63
|
+
"""Fulfill the approved order."""
|
|
64
|
+
print(f" Fulfilling order {order['order_id']}...")
|
|
65
|
+
return {**order, "status": "fulfilled"}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@step()
|
|
69
|
+
async def cancel_order(order: dict, reason: str) -> dict:
|
|
70
|
+
"""Cancel the rejected order."""
|
|
71
|
+
print(f" Cancelling order {order['order_id']}: {reason}")
|
|
72
|
+
return {**order, "status": "cancelled", "reason": reason}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# --- Workflow with simple hook ---
|
|
76
|
+
@workflow(durable=True, name="simple_hook_workflow", tags=["local", "durable"])
|
|
77
|
+
async def simple_hook_workflow(order_id: str) -> dict:
|
|
78
|
+
"""
|
|
79
|
+
Workflow using simple hook() with untyped payload.
|
|
80
|
+
|
|
81
|
+
Demonstrates basic hook usage with auto-generated composite token.
|
|
82
|
+
Token format: run_id:hook_id (e.g., "run_abc123:hook_simple_approval_1")
|
|
83
|
+
"""
|
|
84
|
+
order = await prepare_order(order_id)
|
|
85
|
+
|
|
86
|
+
async def capture_token(token: str):
|
|
87
|
+
"""Capture the generated token for later use."""
|
|
88
|
+
_captured_tokens[order_id] = token
|
|
89
|
+
print(f" Hook created with token: {token}")
|
|
90
|
+
|
|
91
|
+
# Wait for external approval using simple hook
|
|
92
|
+
# Token is auto-generated in composite format: run_id:hook_id
|
|
93
|
+
approval = await hook(
|
|
94
|
+
"simple_approval",
|
|
95
|
+
timeout="24h", # Expire after 24 hours
|
|
96
|
+
on_created=capture_token,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if approval.get("approved"):
|
|
100
|
+
return await fulfill_order(order)
|
|
101
|
+
else:
|
|
102
|
+
return await cancel_order(order, approval.get("reason", "Rejected"))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# --- Workflow with typed hook ---
|
|
106
|
+
@workflow(durable=True, name="typed_hook_workflow", tags=["local", "durable"])
|
|
107
|
+
async def typed_hook_workflow(order_id: str) -> dict:
|
|
108
|
+
"""
|
|
109
|
+
Workflow using define_hook() for type-safe payloads.
|
|
110
|
+
|
|
111
|
+
Demonstrates typed hooks with Pydantic validation.
|
|
112
|
+
"""
|
|
113
|
+
order = await prepare_order(order_id)
|
|
114
|
+
|
|
115
|
+
async def capture_typed_token(token: str):
|
|
116
|
+
"""Capture the generated token for later use."""
|
|
117
|
+
_captured_tokens[f"typed:{order_id}"] = token
|
|
118
|
+
print(f" Typed hook created with token: {token}")
|
|
119
|
+
|
|
120
|
+
# Wait for typed approval - payload is validated against ApprovalPayload
|
|
121
|
+
approval: ApprovalPayload = await approval_hook(
|
|
122
|
+
on_created=capture_typed_token,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
print(f" Received: approved={approval.approved}, reviewer={approval.reviewer}")
|
|
126
|
+
|
|
127
|
+
if approval.approved:
|
|
128
|
+
return await fulfill_order(order)
|
|
129
|
+
else:
|
|
130
|
+
return await cancel_order(order, approval.comments or "No reason given")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# --- Workflow with on_created callback ---
|
|
134
|
+
@workflow(durable=True, name="callback_hook_workflow", tags=["local", "durable"])
|
|
135
|
+
async def callback_hook_workflow(order_id: str) -> dict:
|
|
136
|
+
"""
|
|
137
|
+
Workflow demonstrating on_created callback.
|
|
138
|
+
|
|
139
|
+
The callback is invoked when the hook is created,
|
|
140
|
+
allowing you to notify external systems with the token.
|
|
141
|
+
Token format is composite: run_id:hook_id
|
|
142
|
+
"""
|
|
143
|
+
order = await prepare_order(order_id)
|
|
144
|
+
|
|
145
|
+
async def on_hook_created(token: str):
|
|
146
|
+
# In real scenarios, you would notify external systems here
|
|
147
|
+
# e.g., send email, update database, register webhook URL
|
|
148
|
+
_captured_tokens[f"callback:{order_id}"] = token
|
|
149
|
+
print(f" Hook created! Token: {token}")
|
|
150
|
+
print(f" External system can POST to: /webhook/{token}")
|
|
151
|
+
|
|
152
|
+
# Wait for approval with on_created callback
|
|
153
|
+
approval = await hook(
|
|
154
|
+
"callback_approval",
|
|
155
|
+
on_created=on_hook_created,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if approval.get("approved"):
|
|
159
|
+
return await fulfill_order(order)
|
|
160
|
+
else:
|
|
161
|
+
return await cancel_order(order, approval.get("reason", "Rejected"))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def demo_simple_hook():
|
|
165
|
+
"""Demo simple hook workflow."""
|
|
166
|
+
print("\n" + "=" * 50)
|
|
167
|
+
print("Demo 1: Simple Hook with Composite Token")
|
|
168
|
+
print("=" * 50)
|
|
169
|
+
|
|
170
|
+
run_id = await start(simple_hook_workflow, "ORDER-001")
|
|
171
|
+
print(f"\nWorkflow started: {run_id}")
|
|
172
|
+
|
|
173
|
+
# Check status - should be suspended
|
|
174
|
+
run = await get_workflow_run(run_id)
|
|
175
|
+
print(f"Status: {run.status.value}")
|
|
176
|
+
|
|
177
|
+
# Get the token that was captured via on_created callback
|
|
178
|
+
token = _captured_tokens.get("ORDER-001")
|
|
179
|
+
print(f"\nCaptured token: {token}")
|
|
180
|
+
|
|
181
|
+
# Simulate external system calling resume_hook with the composite token
|
|
182
|
+
print("\n[External System] Sending approval...")
|
|
183
|
+
result = await resume_hook(
|
|
184
|
+
token=token,
|
|
185
|
+
payload={"approved": True, "approver": "manager@example.com"},
|
|
186
|
+
storage=_storage,
|
|
187
|
+
)
|
|
188
|
+
print(f"Resume result: {result.status}")
|
|
189
|
+
|
|
190
|
+
# In local mode without Celery, manually resume the workflow
|
|
191
|
+
await resume(run_id, storage=_storage)
|
|
192
|
+
|
|
193
|
+
# Check final status
|
|
194
|
+
run = await get_workflow_run(run_id)
|
|
195
|
+
print(f"\nFinal status: {run.status.value}")
|
|
196
|
+
print(f"Result: {run.result}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def demo_typed_hook():
|
|
200
|
+
"""Demo typed hook workflow."""
|
|
201
|
+
print("\n" + "=" * 50)
|
|
202
|
+
print("Demo 2: Typed Hook with Pydantic")
|
|
203
|
+
print("=" * 50)
|
|
204
|
+
|
|
205
|
+
run_id = await start(typed_hook_workflow, "ORDER-002")
|
|
206
|
+
print(f"\nWorkflow started: {run_id}")
|
|
207
|
+
|
|
208
|
+
# Check status - should be suspended
|
|
209
|
+
run = await get_workflow_run(run_id)
|
|
210
|
+
print(f"Status: {run.status.value}")
|
|
211
|
+
|
|
212
|
+
# Get the token that was captured via on_created callback
|
|
213
|
+
token = _captured_tokens.get("typed:ORDER-002")
|
|
214
|
+
print(f"\nCaptured token: {token}")
|
|
215
|
+
|
|
216
|
+
# Simulate external system calling resume_hook with typed payload
|
|
217
|
+
print("\n[External System] Sending typed approval...")
|
|
218
|
+
result = await resume_hook(
|
|
219
|
+
token=token,
|
|
220
|
+
payload={
|
|
221
|
+
"approved": True,
|
|
222
|
+
"reviewer": "jane.doe@example.com",
|
|
223
|
+
"comments": "Looks good!",
|
|
224
|
+
},
|
|
225
|
+
storage=_storage,
|
|
226
|
+
)
|
|
227
|
+
print(f"Resume result: {result.status}")
|
|
228
|
+
|
|
229
|
+
# In local mode without Celery, manually resume the workflow
|
|
230
|
+
await resume(run_id, storage=_storage)
|
|
231
|
+
|
|
232
|
+
# Check final status
|
|
233
|
+
run = await get_workflow_run(run_id)
|
|
234
|
+
print(f"\nFinal status: {run.status.value}")
|
|
235
|
+
print(f"Result: {run.result}")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
async def demo_rejection():
|
|
239
|
+
"""Demo hook rejection flow."""
|
|
240
|
+
print("\n" + "=" * 50)
|
|
241
|
+
print("Demo 3: Rejection Flow")
|
|
242
|
+
print("=" * 50)
|
|
243
|
+
|
|
244
|
+
run_id = await start(simple_hook_workflow, "ORDER-003")
|
|
245
|
+
print(f"\nWorkflow started: {run_id}")
|
|
246
|
+
|
|
247
|
+
# Get the token that was captured via on_created callback
|
|
248
|
+
token = _captured_tokens.get("ORDER-003")
|
|
249
|
+
|
|
250
|
+
# Simulate rejection
|
|
251
|
+
print("\n[External System] Sending rejection...")
|
|
252
|
+
result = await resume_hook(
|
|
253
|
+
token=token,
|
|
254
|
+
payload={"approved": False, "reason": "Insufficient inventory"},
|
|
255
|
+
storage=_storage,
|
|
256
|
+
)
|
|
257
|
+
print(f"Resume result: {result.status}")
|
|
258
|
+
|
|
259
|
+
# In local mode without Celery, manually resume the workflow
|
|
260
|
+
await resume(run_id, storage=_storage)
|
|
261
|
+
|
|
262
|
+
# Check final status
|
|
263
|
+
run = await get_workflow_run(run_id)
|
|
264
|
+
print(f"\nFinal status: {run.status.value}")
|
|
265
|
+
print(f"Result: {run.result}")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
async def demo_event_log():
|
|
269
|
+
"""Show the event log for a hook-based workflow."""
|
|
270
|
+
print("\n" + "=" * 50)
|
|
271
|
+
print("Demo 4: Event Log Inspection")
|
|
272
|
+
print("=" * 50)
|
|
273
|
+
|
|
274
|
+
run_id = await start(simple_hook_workflow, "ORDER-004")
|
|
275
|
+
|
|
276
|
+
# Get the token that was captured via on_created callback
|
|
277
|
+
token = _captured_tokens.get("ORDER-004")
|
|
278
|
+
|
|
279
|
+
# Resume hook using captured token
|
|
280
|
+
await resume_hook(
|
|
281
|
+
token=token,
|
|
282
|
+
payload={"approved": True},
|
|
283
|
+
storage=_storage,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
# Resume workflow in local mode
|
|
287
|
+
await resume(run_id, storage=_storage)
|
|
288
|
+
|
|
289
|
+
# Inspect event log
|
|
290
|
+
events = await get_workflow_events(run_id)
|
|
291
|
+
print(f"\nEvent Log ({len(events)} events):")
|
|
292
|
+
for event in events:
|
|
293
|
+
print(f" {event.sequence}: {event.type.value}")
|
|
294
|
+
if "hook" in event.type.value:
|
|
295
|
+
if "hook_id" in event.data:
|
|
296
|
+
print(f" hook_id: {event.data.get('hook_id', 'N/A')}")
|
|
297
|
+
if "token" in event.data:
|
|
298
|
+
print(f" token: {event.data.get('token', 'N/A')[:40]}...")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
async def main():
|
|
302
|
+
global _storage
|
|
303
|
+
|
|
304
|
+
# Configure with InMemoryStorageBackend
|
|
305
|
+
reset_config()
|
|
306
|
+
_storage = InMemoryStorageBackend()
|
|
307
|
+
configure(storage=_storage, default_durable=True)
|
|
308
|
+
|
|
309
|
+
print("=== Durable Workflow - Hooks Example ===")
|
|
310
|
+
print("""
|
|
311
|
+
This example demonstrates hooks for external event integration:
|
|
312
|
+
- hook(): Wait for external events
|
|
313
|
+
- define_hook(): Create typed hooks with validation
|
|
314
|
+
- resume_hook(): Deliver payloads from external systems
|
|
315
|
+
""")
|
|
316
|
+
|
|
317
|
+
await demo_simple_hook()
|
|
318
|
+
await demo_typed_hook()
|
|
319
|
+
await demo_rejection()
|
|
320
|
+
await demo_event_log()
|
|
321
|
+
|
|
322
|
+
print("\n" + "=" * 50)
|
|
323
|
+
print("Key Takeaways")
|
|
324
|
+
print("=" * 50)
|
|
325
|
+
print("- hook() suspends workflow until resume_hook() is called")
|
|
326
|
+
print("- Tokens are auto-generated in composite format: run_id:hook_id")
|
|
327
|
+
print("- Use on_created callback to capture the token for external systems")
|
|
328
|
+
print("- define_hook() provides type-safe payloads with Pydantic")
|
|
329
|
+
print("- Composite tokens are self-describing (contain run_id)")
|
|
330
|
+
print("- Events record hook creation and reception")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
if __name__ == "__main__":
|
|
334
|
+
asyncio.run(main())
|