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
@@ -195,6 +195,131 @@ volumes:
195
195
  return template
196
196
 
197
197
 
198
+ def generate_cassandra_docker_compose_content(
199
+ cassandra_host: str = "cassandra",
200
+ cassandra_port: int = 9042,
201
+ cassandra_keyspace: str = "pyworkflow",
202
+ cassandra_user: str | None = None,
203
+ cassandra_password: str | None = None,
204
+ ) -> str:
205
+ """
206
+ Generate docker-compose.yml content for Cassandra-based PyWorkflow services.
207
+
208
+ Args:
209
+ cassandra_host: Cassandra host (container name for docker networking)
210
+ cassandra_port: Cassandra CQL native transport port
211
+ cassandra_keyspace: Cassandra keyspace name
212
+ cassandra_user: Optional Cassandra user (for authentication)
213
+ cassandra_password: Optional Cassandra password (for authentication)
214
+
215
+ Returns:
216
+ docker-compose.yml content as string
217
+
218
+ Example:
219
+ >>> compose_content = generate_cassandra_docker_compose_content()
220
+ """
221
+ # Build environment variables for dashboard
222
+ dashboard_env = f""" - DASHBOARD_STORAGE_TYPE=cassandra
223
+ - DASHBOARD_CASSANDRA_HOST={cassandra_host}
224
+ - DASHBOARD_CASSANDRA_PORT=9042
225
+ - DASHBOARD_CASSANDRA_KEYSPACE={cassandra_keyspace}"""
226
+
227
+ if cassandra_user and cassandra_password:
228
+ dashboard_env += f"""
229
+ - DASHBOARD_CASSANDRA_USER={cassandra_user}
230
+ - DASHBOARD_CASSANDRA_PASSWORD={cassandra_password}"""
231
+
232
+ # Cassandra environment for authentication
233
+ cassandra_env = ""
234
+ if cassandra_user and cassandra_password:
235
+ cassandra_env = f"""
236
+ - CASSANDRA_AUTHENTICATOR=PasswordAuthenticator
237
+ - CASSANDRA_AUTHORIZER=CassandraAuthorizer
238
+ - CASSANDRA_USER={cassandra_user}
239
+ - CASSANDRA_PASSWORD={cassandra_password}"""
240
+
241
+ template = f"""services:
242
+ cassandra:
243
+ image: cassandra:4.1
244
+ container_name: pyworkflow-cassandra
245
+ ports:
246
+ - "{cassandra_port}:9042"
247
+ environment:
248
+ - CASSANDRA_CLUSTER_NAME=pyworkflow-cluster
249
+ - CASSANDRA_DC=dc1
250
+ - CASSANDRA_RACK=rack1
251
+ - CASSANDRA_ENDPOINT_SNITCH=GossipingPropertyFileSnitch
252
+ - MAX_HEAP_SIZE=512M
253
+ - HEAP_NEWSIZE=128M{cassandra_env}
254
+ volumes:
255
+ - cassandra_data:/var/lib/cassandra
256
+ healthcheck:
257
+ test: ["CMD-SHELL", "cqlsh -e 'describe cluster' || exit 1"]
258
+ interval: 15s
259
+ timeout: 10s
260
+ retries: 10
261
+ start_period: 60s
262
+ restart: unless-stopped
263
+
264
+ redis:
265
+ image: redis:7-alpine
266
+ container_name: pyworkflow-redis
267
+ ports:
268
+ - "6379:6379"
269
+ volumes:
270
+ - redis_data:/data
271
+ healthcheck:
272
+ test: ["CMD", "redis-cli", "ping"]
273
+ interval: 5s
274
+ timeout: 3s
275
+ retries: 5
276
+ restart: unless-stopped
277
+
278
+ dashboard-backend:
279
+ image: yashabro/pyworkflow-dashboard-backend:latest
280
+ container_name: pyworkflow-dashboard-backend
281
+ working_dir: /app/project
282
+ ports:
283
+ - "8585:8585"
284
+ environment:
285
+ - DASHBOARD_PYWORKFLOW_CONFIG_PATH=/app/project/pyworkflow.config.yaml
286
+ {dashboard_env}
287
+ - DASHBOARD_HOST=0.0.0.0
288
+ - DASHBOARD_PORT=8585
289
+ - DASHBOARD_CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
290
+ - PYWORKFLOW_CELERY_BROKER=redis://redis:6379/0
291
+ - PYWORKFLOW_CELERY_RESULT_BACKEND=redis://redis:6379/1
292
+ - PYTHONPATH=/app/project
293
+ volumes:
294
+ - .:/app/project:ro
295
+ depends_on:
296
+ cassandra:
297
+ condition: service_healthy
298
+ redis:
299
+ condition: service_healthy
300
+ restart: unless-stopped
301
+
302
+ dashboard-frontend:
303
+ image: yashabro/pyworkflow-dashboard-frontend:latest
304
+ container_name: pyworkflow-dashboard-frontend
305
+ ports:
306
+ - "5173:80"
307
+ environment:
308
+ - VITE_API_URL=http://localhost:8585
309
+ depends_on:
310
+ - dashboard-backend
311
+ restart: unless-stopped
312
+
313
+ volumes:
314
+ cassandra_data:
315
+ driver: local
316
+ redis_data:
317
+ driver: local
318
+ """
319
+
320
+ return template
321
+
322
+
198
323
  def generate_postgres_docker_compose_content(
199
324
  postgres_host: str = "postgres",
200
325
  postgres_port: int = 5432,
@@ -301,6 +426,113 @@ volumes:
301
426
  return template
302
427
 
303
428
 
429
+ def generate_mysql_docker_compose_content(
430
+ mysql_host: str = "mysql",
431
+ mysql_port: int = 3306,
432
+ mysql_user: str = "pyworkflow",
433
+ mysql_password: str = "pyworkflow",
434
+ mysql_database: str = "pyworkflow",
435
+ ) -> str:
436
+ """
437
+ Generate docker-compose.yml content for MySQL-based PyWorkflow services.
438
+
439
+ Args:
440
+ mysql_host: MySQL host (container name for docker networking)
441
+ mysql_port: MySQL port
442
+ mysql_user: MySQL user
443
+ mysql_password: MySQL password
444
+ mysql_database: MySQL database name
445
+
446
+ Returns:
447
+ docker-compose.yml content as string
448
+
449
+ Example:
450
+ >>> compose_content = generate_mysql_docker_compose_content()
451
+ """
452
+ template = f"""services:
453
+ mysql:
454
+ image: mysql:8.0
455
+ container_name: pyworkflow-mysql
456
+ ports:
457
+ - "{mysql_port}:3306"
458
+ environment:
459
+ - MYSQL_ROOT_PASSWORD={mysql_password}
460
+ - MYSQL_USER={mysql_user}
461
+ - MYSQL_PASSWORD={mysql_password}
462
+ - MYSQL_DATABASE={mysql_database}
463
+ volumes:
464
+ - mysql_data:/var/lib/mysql
465
+ healthcheck:
466
+ test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "{mysql_user}", "-p{mysql_password}"]
467
+ interval: 5s
468
+ timeout: 3s
469
+ retries: 5
470
+ restart: unless-stopped
471
+
472
+ redis:
473
+ image: redis:7-alpine
474
+ container_name: pyworkflow-redis
475
+ ports:
476
+ - "6379:6379"
477
+ volumes:
478
+ - redis_data:/data
479
+ healthcheck:
480
+ test: ["CMD", "redis-cli", "ping"]
481
+ interval: 5s
482
+ timeout: 3s
483
+ retries: 5
484
+ restart: unless-stopped
485
+
486
+ dashboard-backend:
487
+ image: yashabro/pyworkflow-dashboard-backend:latest
488
+ container_name: pyworkflow-dashboard-backend
489
+ working_dir: /app/project
490
+ ports:
491
+ - "8585:8585"
492
+ environment:
493
+ - DASHBOARD_PYWORKFLOW_CONFIG_PATH=/app/project/pyworkflow.config.yaml
494
+ - DASHBOARD_STORAGE_TYPE=mysql
495
+ - DASHBOARD_MYSQL_HOST={mysql_host}
496
+ - DASHBOARD_MYSQL_PORT=3306
497
+ - DASHBOARD_MYSQL_USER={mysql_user}
498
+ - DASHBOARD_MYSQL_PASSWORD={mysql_password}
499
+ - DASHBOARD_MYSQL_DATABASE={mysql_database}
500
+ - DASHBOARD_HOST=0.0.0.0
501
+ - DASHBOARD_PORT=8585
502
+ - DASHBOARD_CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
503
+ - PYWORKFLOW_CELERY_BROKER=redis://redis:6379/0
504
+ - PYWORKFLOW_CELERY_RESULT_BACKEND=redis://redis:6379/1
505
+ - PYTHONPATH=/app/project
506
+ volumes:
507
+ - .:/app/project:ro
508
+ depends_on:
509
+ mysql:
510
+ condition: service_healthy
511
+ redis:
512
+ condition: service_healthy
513
+ restart: unless-stopped
514
+
515
+ dashboard-frontend:
516
+ image: yashabro/pyworkflow-dashboard-frontend:latest
517
+ container_name: pyworkflow-dashboard-frontend
518
+ ports:
519
+ - "5173:80"
520
+ environment:
521
+ - VITE_API_URL=http://localhost:8585
522
+ depends_on:
523
+ - dashboard-backend
524
+ restart: unless-stopped
525
+
526
+ volumes:
527
+ mysql_data:
528
+ driver: local
529
+ redis_data:
530
+ driver: local
531
+ """
532
+
533
+ return template
534
+
535
+
304
536
  def write_docker_compose(content: str, path: Path) -> None:
305
537
  """
306
538
  Write docker-compose.yml to file.
@@ -41,6 +41,13 @@ from pyworkflow.context.base import (
41
41
  )
42
42
  from pyworkflow.context.local import LocalContext
43
43
  from pyworkflow.context.mock import MockContext
44
+ from pyworkflow.context.step_context import (
45
+ StepContext,
46
+ get_step_context,
47
+ get_step_context_class,
48
+ has_step_context,
49
+ set_step_context,
50
+ )
44
51
 
45
52
  __all__ = [
46
53
  # Base context and helpers
@@ -49,6 +56,12 @@ __all__ = [
49
56
  "has_context",
50
57
  "set_context",
51
58
  "reset_context",
59
+ # Step context for distributed execution
60
+ "StepContext",
61
+ "get_step_context",
62
+ "has_step_context",
63
+ "set_step_context",
64
+ "get_step_context_class",
52
65
  # Context implementations
53
66
  "LocalContext",
54
67
  "MockContext",
@@ -281,6 +281,32 @@ class WorkflowContext(ABC):
281
281
  """Get the storage backend."""
282
282
  return None
283
283
 
284
+ @property
285
+ def runtime(self) -> str | None:
286
+ """
287
+ Get the runtime environment slug.
288
+
289
+ Returns the runtime identifier (e.g., "celery", "temporal") or None
290
+ for local/inline execution. Used to determine step dispatch behavior.
291
+
292
+ Returns:
293
+ Runtime slug string or None for local execution
294
+ """
295
+ return None # Default: local/inline execution
296
+
297
+ @property
298
+ def storage_config(self) -> dict[str, Any] | None:
299
+ """Get storage configuration for distributed workers."""
300
+ return None
301
+
302
+ def has_step_failed(self, step_id: str) -> bool:
303
+ """Check if a step has a recorded failure."""
304
+ return False # Default: no failures
305
+
306
+ def get_step_failure(self, step_id: str) -> dict[str, Any] | None:
307
+ """Get failure info for a step."""
308
+ return None # Default: no failure info
309
+
284
310
  def should_execute_step(self, step_id: str) -> bool:
285
311
  """Check if step should be executed (not already completed)."""
286
312
  return True # Default: always execute
@@ -97,6 +97,13 @@ class LocalContext(WorkflowContext):
97
97
  self._child_results: dict[str, dict[str, Any]] = {}
98
98
  self._pending_children: dict[str, str] = {} # child_id -> child_run_id
99
99
 
100
+ # Runtime environment (e.g., "celery", "temporal", None for local)
101
+ self._runtime: str | None = None
102
+ self._storage_config: dict[str, Any] | None = None
103
+
104
+ # Step failure tracking (for handling failures during replay)
105
+ self._step_failures: dict[str, dict[str, Any]] = {}
106
+
100
107
  # Replay state if resuming
101
108
  if event_log:
102
109
  self._is_replaying = True
@@ -134,6 +141,22 @@ class LocalContext(WorkflowContext):
134
141
  "last_error": event.data.get("error", ""),
135
142
  }
136
143
 
144
+ elif event.type == EventType.STEP_FAILED:
145
+ # Track step failures for distributed step dispatch
146
+ # If a step failed (not retrying), record it for replay detection
147
+ step_id = event.data.get("step_id")
148
+ is_retryable = event.data.get("is_retryable", True)
149
+ # Only track as a final failure if not retryable or if this is
150
+ # the last failure before a STEP_COMPLETED event
151
+ # For now, track all failures - the step decorator will check
152
+ # if there's also a STEP_COMPLETED event
153
+ if step_id and not is_retryable:
154
+ self._step_failures[step_id] = {
155
+ "error": event.data.get("error", "Unknown error"),
156
+ "error_type": event.data.get("error_type", "Exception"),
157
+ "is_retryable": is_retryable,
158
+ }
159
+
137
160
  elif event.type == EventType.CANCELLATION_REQUESTED:
138
161
  self._cancellation_requested = True
139
162
  self._cancellation_reason = event.data.get("reason")
@@ -208,6 +231,28 @@ class LocalContext(WorkflowContext):
208
231
  """Set replay mode."""
209
232
  self._is_replaying = value
210
233
 
234
+ @property
235
+ def runtime(self) -> str | None:
236
+ """
237
+ Get the runtime environment slug.
238
+
239
+ Returns the runtime identifier (e.g., "celery", "temporal") or None
240
+ for local/inline execution. Used to determine step dispatch behavior.
241
+
242
+ Returns:
243
+ Runtime slug string or None for local execution
244
+ """
245
+ return self._runtime
246
+
247
+ @property
248
+ def storage_config(self) -> dict[str, Any] | None:
249
+ """
250
+ Get storage configuration for distributed workers.
251
+
252
+ This is passed to step workers so they can connect to the same storage.
253
+ """
254
+ return self._storage_config
255
+
211
256
  # =========================================================================
212
257
  # Step result caching (for @step decorator compatibility)
213
258
  # =========================================================================
@@ -255,6 +300,41 @@ class LocalContext(WorkflowContext):
255
300
  """Clear retry state for a step."""
256
301
  self._retry_states.pop(step_id, None)
257
302
 
303
+ # =========================================================================
304
+ # Step failure tracking (for distributed step dispatch)
305
+ # =========================================================================
306
+
307
+ def has_step_failed(self, step_id: str) -> bool:
308
+ """
309
+ Check if a step has a recorded failure.
310
+
311
+ Used during replay to detect steps that failed on a remote worker.
312
+ """
313
+ return step_id in self._step_failures
314
+
315
+ def get_step_failure(self, step_id: str) -> dict[str, Any] | None:
316
+ """
317
+ Get step failure info.
318
+
319
+ Returns:
320
+ Dict with 'error', 'error_type', 'is_retryable' or None
321
+ """
322
+ return self._step_failures.get(step_id)
323
+
324
+ def record_step_failure(
325
+ self,
326
+ step_id: str,
327
+ error: str,
328
+ error_type: str,
329
+ is_retryable: bool,
330
+ ) -> None:
331
+ """Record a step failure for replay detection."""
332
+ self._step_failures[step_id] = {
333
+ "error": error,
334
+ "error_type": error_type,
335
+ "is_retryable": is_retryable,
336
+ }
337
+
258
338
  # =========================================================================
259
339
  # Sleep state management (for @step decorator and EventReplayer compatibility)
260
340
  # =========================================================================
@@ -0,0 +1,295 @@
1
+ """
2
+ Step Context - User-defined context accessible from steps during distributed execution.
3
+
4
+ StepContext provides a type-safe, immutable context that can be accessed from steps
5
+ running on remote Celery workers. Unlike WorkflowContext which is process-local,
6
+ StepContext is serialized and passed to workers.
7
+
8
+ Key design decisions:
9
+ - **Immutable in steps**: Steps can only read context, not mutate it. This prevents
10
+ race conditions when multiple steps execute in parallel.
11
+ - **Mutable in workflow**: Workflow code can update context via set_step_context().
12
+ Updates are recorded as CONTEXT_UPDATED events for deterministic replay.
13
+ - **User-extensible**: Users subclass StepContext to define their own typed fields.
14
+
15
+ Usage:
16
+ from pyworkflow.context import StepContext, get_step_context, set_step_context
17
+
18
+ # Define custom context
19
+ class OrderContext(StepContext):
20
+ workspace_id: str = ""
21
+ user_id: str = ""
22
+ order_id: str = ""
23
+
24
+ @workflow(context_class=OrderContext)
25
+ async def process_order(order_id: str, user_id: str):
26
+ # Initialize context in workflow
27
+ ctx = OrderContext(order_id=order_id, user_id=user_id)
28
+ await set_step_context(ctx) # Note: async call
29
+
30
+ # Update context (creates new immutable instance)
31
+ ctx = get_step_context()
32
+ ctx = ctx.with_updates(workspace_id="ws-123")
33
+ await set_step_context(ctx)
34
+
35
+ result = await validate_order()
36
+ return result
37
+
38
+ @step
39
+ async def validate_order():
40
+ # Read-only access in steps
41
+ ctx = get_step_context()
42
+ print(f"Validating order {ctx.order_id}")
43
+
44
+ # This would raise RuntimeError - context is read-only in steps:
45
+ # set_step_context(ctx.with_updates(workspace_id="new"))
46
+
47
+ return {"valid": True}
48
+ """
49
+
50
+ from contextvars import ContextVar, Token
51
+ from typing import Any, Self
52
+
53
+ from pydantic import BaseModel, ConfigDict
54
+
55
+
56
+ class StepContext(BaseModel):
57
+ """
58
+ Base class for user-defined step context.
59
+
60
+ StepContext is immutable (frozen) to prevent accidental mutation.
61
+ Use with_updates() to create a new context with modified values.
62
+
63
+ The context is automatically:
64
+ - Persisted to storage when set_step_context() is called in workflow code
65
+ - Loaded from storage when a step executes on a Celery worker
66
+ - Replayed from CONTEXT_UPDATED events during workflow resumption
67
+
68
+ Example:
69
+ class FlowContext(StepContext):
70
+ workspace_id: str = ""
71
+ user_id: str = ""
72
+ attachments: list[str] = []
73
+
74
+ @workflow(context_class=FlowContext)
75
+ async def my_workflow():
76
+ ctx = FlowContext(workspace_id="ws-123")
77
+ set_step_context(ctx)
78
+ ...
79
+ """
80
+
81
+ model_config = ConfigDict(frozen=True, extra="forbid")
82
+
83
+ def with_updates(self: Self, **kwargs: Any) -> Self:
84
+ """
85
+ Create a new context with updated values.
86
+
87
+ Since StepContext is immutable, this creates a new instance
88
+ with the specified fields updated.
89
+
90
+ Args:
91
+ **kwargs: Fields to update
92
+
93
+ Returns:
94
+ New StepContext instance with updated values
95
+
96
+ Example:
97
+ ctx = ctx.with_updates(workspace_id="ws-456", user_id="user-789")
98
+ """
99
+ return self.model_copy(update=kwargs)
100
+
101
+ def to_dict(self) -> dict[str, Any]:
102
+ """
103
+ Serialize context to dictionary for storage.
104
+
105
+ Returns:
106
+ Dictionary representation of the context
107
+ """
108
+ return self.model_dump()
109
+
110
+ @classmethod
111
+ def from_dict(cls, data: dict[str, Any]) -> Self:
112
+ """
113
+ Deserialize context from storage.
114
+
115
+ Args:
116
+ data: Dictionary representation of the context
117
+
118
+ Returns:
119
+ StepContext instance
120
+ """
121
+ return cls.model_validate(data)
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Context Variables
126
+ # ---------------------------------------------------------------------------
127
+
128
+ # Current step context (may be None if not set)
129
+ _step_context: ContextVar[StepContext | None] = ContextVar("step_context", default=None)
130
+
131
+ # Whether context is read-only (True when executing inside a step)
132
+ _step_context_readonly: ContextVar[bool] = ContextVar("step_context_readonly", default=False)
133
+
134
+ # The context class registered with the workflow (for deserialization)
135
+ _step_context_class: ContextVar[type[StepContext] | None] = ContextVar(
136
+ "step_context_class", default=None
137
+ )
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Public API
142
+ # ---------------------------------------------------------------------------
143
+
144
+
145
+ def get_step_context() -> StepContext:
146
+ """
147
+ Get the current step context.
148
+
149
+ This function can be called from both workflow code and step code.
150
+ In step code, the context is read-only.
151
+
152
+ Returns:
153
+ Current StepContext instance
154
+
155
+ Raises:
156
+ RuntimeError: If no step context is available
157
+
158
+ Example:
159
+ @step
160
+ async def my_step():
161
+ ctx = get_step_context()
162
+ print(f"Working in workspace: {ctx.workspace_id}")
163
+ """
164
+ ctx = _step_context.get()
165
+ if ctx is None:
166
+ raise RuntimeError(
167
+ "No step context available. "
168
+ "Ensure the workflow is decorated with @workflow(context_class=YourContext) "
169
+ "and set_step_context() was called."
170
+ )
171
+ return ctx
172
+
173
+
174
+ async def set_step_context(ctx: StepContext) -> None:
175
+ """
176
+ Set the current step context and persist to storage.
177
+
178
+ This function can only be called from workflow code, not from within steps.
179
+ When called, the context is persisted to storage and a CONTEXT_UPDATED event
180
+ is recorded for deterministic replay.
181
+
182
+ Args:
183
+ ctx: The StepContext instance to set
184
+
185
+ Raises:
186
+ RuntimeError: If called from within a step (read-only mode)
187
+ TypeError: If ctx is not a StepContext instance
188
+
189
+ Example:
190
+ @workflow(context_class=OrderContext)
191
+ async def my_workflow():
192
+ ctx = OrderContext(order_id="123")
193
+ await set_step_context(ctx) # OK - in workflow code
194
+
195
+ await my_step() # Step cannot call set_step_context()
196
+ """
197
+ if _step_context_readonly.get():
198
+ raise RuntimeError(
199
+ "Cannot modify step context within a step. "
200
+ "Context is read-only during step execution to prevent race conditions. "
201
+ "Return data from the step and update context in workflow code instead."
202
+ )
203
+
204
+ if not isinstance(ctx, StepContext):
205
+ raise TypeError(f"Expected StepContext instance, got {type(ctx).__name__}")
206
+
207
+ # Set the context in the contextvar
208
+ _step_context.set(ctx)
209
+
210
+ # Persist to storage if we're in a durable workflow
211
+ from pyworkflow.context import get_context, has_context
212
+
213
+ if has_context():
214
+ workflow_ctx = get_context()
215
+ if workflow_ctx.is_durable and workflow_ctx.storage is not None:
216
+ from pyworkflow.engine.events import create_context_updated_event
217
+
218
+ # Record CONTEXT_UPDATED event for replay
219
+ event = create_context_updated_event(
220
+ run_id=workflow_ctx.run_id,
221
+ context_data=ctx.to_dict(),
222
+ )
223
+ await workflow_ctx.storage.record_event(event)
224
+
225
+ # Update the WorkflowRun.context field
226
+ await workflow_ctx.storage.update_run_context(workflow_ctx.run_id, ctx.to_dict())
227
+
228
+
229
+ def has_step_context() -> bool:
230
+ """
231
+ Check if step context is available.
232
+
233
+ Returns:
234
+ True if step context is set, False otherwise
235
+ """
236
+ return _step_context.get() is not None
237
+
238
+
239
+ def get_step_context_class() -> type[StepContext] | None:
240
+ """
241
+ Get the registered step context class for the current workflow.
242
+
243
+ Returns:
244
+ The StepContext subclass, or None if not registered
245
+ """
246
+ return _step_context_class.get()
247
+
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # Internal API (for framework use)
251
+ # ---------------------------------------------------------------------------
252
+
253
+
254
+ def _set_step_context_internal(ctx: StepContext | None) -> Token[StepContext | None]:
255
+ """
256
+ Internal: Set step context without readonly check.
257
+
258
+ Used by the framework when loading context on workers.
259
+ """
260
+ return _step_context.set(ctx)
261
+
262
+
263
+ def _reset_step_context(token: Token[StepContext | None]) -> None:
264
+ """
265
+ Internal: Reset step context to previous value.
266
+ """
267
+ _step_context.reset(token)
268
+
269
+
270
+ def _set_step_context_readonly(readonly: bool) -> Token[bool]:
271
+ """
272
+ Internal: Set readonly mode for step execution.
273
+ """
274
+ return _step_context_readonly.set(readonly)
275
+
276
+
277
+ def _reset_step_context_readonly(token: Token[bool]) -> None:
278
+ """
279
+ Internal: Reset readonly mode.
280
+ """
281
+ _step_context_readonly.reset(token)
282
+
283
+
284
+ def _set_step_context_class(cls: type[StepContext] | None) -> Token[type[StepContext] | None]:
285
+ """
286
+ Internal: Set the context class for deserialization.
287
+ """
288
+ return _step_context_class.set(cls)
289
+
290
+
291
+ def _reset_step_context_class(token: Token[type[StepContext] | None]) -> None:
292
+ """
293
+ Internal: Reset context class.
294
+ """
295
+ _step_context_class.reset(token)