pyworkflow-engine 0.1.37__tar.gz → 0.2.0__tar.gz

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 (229) hide show
  1. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/PKG-INFO +1 -1
  2. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyproject.toml +1 -1
  3. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/__init__.py +39 -1
  4. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/celery/scheduler.py +5 -3
  5. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/celery/tasks.py +153 -19
  6. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/hooks.py +15 -15
  7. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/runs.py +9 -9
  8. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/schedules.py +30 -24
  9. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/context/local.py +11 -0
  10. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/core/step.py +28 -9
  11. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/engine/events.py +160 -0
  12. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/engine/replay.py +49 -0
  13. pyworkflow_engine-0.2.0/pyworkflow/primitives/step_checkpoint.py +157 -0
  14. pyworkflow_engine-0.2.0/pyworkflow/primitives/step_hook.py +205 -0
  15. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/storage/base.py +221 -0
  16. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/storage/cassandra.py +59 -3
  17. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/storage/citus.py +17 -0
  18. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/storage/dynamodb.py +89 -39
  19. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/storage/file.py +50 -0
  20. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/storage/memory.py +166 -0
  21. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/storage/mysql.py +46 -0
  22. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/storage/postgres.py +54 -0
  23. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/storage/sqlite.py +54 -3
  24. pyworkflow_engine-0.2.0/pyworkflow/streams/__init__.py +83 -0
  25. pyworkflow_engine-0.2.0/pyworkflow/streams/checkpoint.py +201 -0
  26. pyworkflow_engine-0.2.0/pyworkflow/streams/consumer.py +124 -0
  27. pyworkflow_engine-0.2.0/pyworkflow/streams/context.py +91 -0
  28. pyworkflow_engine-0.2.0/pyworkflow/streams/decorator.py +131 -0
  29. pyworkflow_engine-0.2.0/pyworkflow/streams/dispatcher.py +233 -0
  30. pyworkflow_engine-0.2.0/pyworkflow/streams/emit.py +157 -0
  31. pyworkflow_engine-0.2.0/pyworkflow/streams/registry.py +190 -0
  32. pyworkflow_engine-0.2.0/pyworkflow/streams/signal.py +70 -0
  33. pyworkflow_engine-0.2.0/pyworkflow/streams/step_context.py +79 -0
  34. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow_engine.egg-info/SOURCES.txt +19 -0
  35. pyworkflow_engine-0.2.0/tests/integration/test_stream_e2e.py +308 -0
  36. pyworkflow_engine-0.2.0/tests/unit/test_emit.py +123 -0
  37. pyworkflow_engine-0.2.0/tests/unit/test_signal.py +76 -0
  38. pyworkflow_engine-0.2.0/tests/unit/test_step_checkpoint.py +102 -0
  39. pyworkflow_engine-0.2.0/tests/unit/test_step_hook.py +202 -0
  40. pyworkflow_engine-0.2.0/tests/unit/test_stream_storage.py +188 -0
  41. pyworkflow_engine-0.2.0/tests/unit/test_stream_workflow.py +203 -0
  42. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/CLAUDE.md +0 -0
  43. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/DISTRIBUTED.md +0 -0
  44. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/LICENSE +0 -0
  45. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/MANIFEST.in +0 -0
  46. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/README.md +0 -0
  47. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/RELEASING.md +0 -0
  48. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/architecture.md +0 -0
  49. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/concepts/cancellation.mdx +0 -0
  50. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/concepts/continue-as-new.mdx +0 -0
  51. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/concepts/events.mdx +0 -0
  52. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/concepts/fault-tolerance.mdx +0 -0
  53. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/concepts/hooks.mdx +0 -0
  54. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/concepts/limitations.mdx +0 -0
  55. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/concepts/schedules.mdx +0 -0
  56. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/concepts/sleep.mdx +0 -0
  57. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/concepts/step-context.mdx +0 -0
  58. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/concepts/steps.mdx +0 -0
  59. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/concepts/workflows.mdx +0 -0
  60. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/conventions.md +0 -0
  61. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/guides/brokers.mdx +0 -0
  62. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/guides/cli.mdx +0 -0
  63. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/guides/configuration.mdx +0 -0
  64. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/harness-gaps.md +0 -0
  65. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/introduction.mdx +0 -0
  66. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/layers.md +0 -0
  67. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/quickstart.mdx +0 -0
  68. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/__init__.py +0 -0
  69. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/__init__.py +0 -0
  70. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/docker-compose.yml +0 -0
  71. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/pyworkflow.config.yaml +0 -0
  72. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/__init__.py +0 -0
  73. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/basic.py +0 -0
  74. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/batch_processing.py +0 -0
  75. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/cancellation.py +0 -0
  76. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/child_workflow_from_step.py +0 -0
  77. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/child_workflow_patterns.py +0 -0
  78. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/child_workflows.py +0 -0
  79. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/continue_as_new.py +0 -0
  80. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/fault_tolerance.py +0 -0
  81. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/hooks.py +0 -0
  82. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/idempotency.py +0 -0
  83. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/long_running.py +0 -0
  84. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/retries.py +0 -0
  85. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/schedules.py +0 -0
  86. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/sleep_in_step.py +0 -0
  87. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/step_context.py +0 -0
  88. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/transient/01_basic_workflow.py +0 -0
  89. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/transient/02_fault_tolerance.py +0 -0
  90. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/transient/__init__.py +0 -0
  91. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/transient/pyworkflow.config.yaml +0 -0
  92. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/__init__.py +0 -0
  93. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/01_basic_workflow.py +0 -0
  94. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/02_file_storage.py +0 -0
  95. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/03_retries.py +0 -0
  96. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/04_long_running.py +0 -0
  97. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/05_event_log.py +0 -0
  98. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/06_idempotency.py +0 -0
  99. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/07_hooks.py +0 -0
  100. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/08_cancellation.py +0 -0
  101. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/09_child_workflows.py +0 -0
  102. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/10_child_workflow_patterns.py +0 -0
  103. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/11_continue_as_new.py +0 -0
  104. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/12_schedules.py +0 -0
  105. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/13_step_context.py +0 -0
  106. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/14_child_workflow_from_step.py +0 -0
  107. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/__init__.py +0 -0
  108. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/transient/01_quick_tasks.py +0 -0
  109. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/transient/02_retries.py +0 -0
  110. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/transient/03_sleep.py +0 -0
  111. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/transient/__init__.py +0 -0
  112. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/aws/__init__.py +0 -0
  113. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/aws/context.py +0 -0
  114. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/aws/handler.py +0 -0
  115. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/aws/testing.py +0 -0
  116. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/celery/__init__.py +0 -0
  117. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/celery/app.py +0 -0
  118. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/celery/loop.py +0 -0
  119. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/celery/singleton.py +0 -0
  120. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/__init__.py +0 -0
  121. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/__main__.py +0 -0
  122. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/__init__.py +0 -0
  123. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/quickstart.py +0 -0
  124. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/scheduler.py +0 -0
  125. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/setup.py +0 -0
  126. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/worker.py +0 -0
  127. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/workflows.py +0 -0
  128. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/output/__init__.py +0 -0
  129. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/output/formatters.py +0 -0
  130. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/output/styles.py +0 -0
  131. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/__init__.py +0 -0
  132. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/async_helpers.py +0 -0
  133. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/config.py +0 -0
  134. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/config_generator.py +0 -0
  135. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/discovery.py +0 -0
  136. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/docker_manager.py +0 -0
  137. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/interactive.py +0 -0
  138. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/storage.py +0 -0
  139. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/config.py +0 -0
  140. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/context/__init__.py +0 -0
  141. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/context/aws.py +0 -0
  142. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/context/base.py +0 -0
  143. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/context/mock.py +0 -0
  144. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/context/step_context.py +0 -0
  145. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/core/__init__.py +0 -0
  146. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/core/exceptions.py +0 -0
  147. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/core/registry.py +0 -0
  148. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/core/scheduled.py +0 -0
  149. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/core/validation.py +0 -0
  150. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/core/workflow.py +0 -0
  151. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/discovery.py +0 -0
  152. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/engine/__init__.py +0 -0
  153. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/engine/executor.py +0 -0
  154. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/observability/__init__.py +0 -0
  155. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/observability/logging.py +0 -0
  156. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/__init__.py +0 -0
  157. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/child_handle.py +0 -0
  158. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/child_workflow.py +0 -0
  159. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/continue_as_new.py +0 -0
  160. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/define_hook.py +0 -0
  161. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/hooks.py +0 -0
  162. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/resume_hook.py +0 -0
  163. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/schedule.py +0 -0
  164. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/shield.py +0 -0
  165. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/sleep.py +0 -0
  166. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/runtime/__init__.py +0 -0
  167. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/runtime/base.py +0 -0
  168. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/runtime/celery.py +0 -0
  169. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/runtime/factory.py +0 -0
  170. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/runtime/local.py +0 -0
  171. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/scheduler/__init__.py +0 -0
  172. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/scheduler/local.py +0 -0
  173. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/serialization/__init__.py +0 -0
  174. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/serialization/decoder.py +0 -0
  175. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/serialization/encoder.py +0 -0
  176. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/storage/__init__.py +0 -0
  177. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/storage/config.py +0 -0
  178. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/storage/migrations/__init__.py +0 -0
  179. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/storage/migrations/base.py +0 -0
  180. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/storage/schemas.py +0 -0
  181. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/utils/__init__.py +0 -0
  182. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/utils/duration.py +0 -0
  183. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/utils/helpers.py +0 -0
  184. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/utils/schedule.py +0 -0
  185. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/setup.cfg +0 -0
  186. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/integration/__init__.py +0 -0
  187. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/integration/test_cancellation.py +0 -0
  188. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/integration/test_cassandra_storage.py +0 -0
  189. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/integration/test_child_workflows.py +0 -0
  190. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/integration/test_continue_as_new.py +0 -0
  191. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/integration/test_dynamodb_storage.py +0 -0
  192. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/integration/test_fault_tolerance.py +0 -0
  193. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/integration/test_schedule_storage.py +0 -0
  194. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/integration/test_schema_migrations.py +0 -0
  195. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/integration/test_singleton.py +0 -0
  196. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/integration/test_workflow_suspended.py +0 -0
  197. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/__init__.py +0 -0
  198. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/backends/__init__.py +0 -0
  199. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/backends/test_cassandra_storage.py +0 -0
  200. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/backends/test_citus_storage.py +0 -0
  201. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/backends/test_dynamodb_storage.py +0 -0
  202. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/backends/test_postgres_storage.py +0 -0
  203. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/backends/test_sqlite_storage.py +0 -0
  204. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/conftest.py +0 -0
  205. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/storage/__init__.py +0 -0
  206. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/storage/test_migrations.py +0 -0
  207. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_cancellation.py +0 -0
  208. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_child_workflows.py +0 -0
  209. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_cli_worker.py +0 -0
  210. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_continue_as_new.py +0 -0
  211. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_event_limits.py +0 -0
  212. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_executor.py +0 -0
  213. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_fault_tolerance.py +0 -0
  214. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_force_local.py +0 -0
  215. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_hooks.py +0 -0
  216. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_parent_run_id.py +0 -0
  217. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_primitives_from_steps.py +0 -0
  218. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_registry.py +0 -0
  219. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_replay.py +0 -0
  220. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_retention.py +0 -0
  221. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_schedule_schemas.py +0 -0
  222. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_schedule_utils.py +0 -0
  223. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_scheduled_workflow.py +0 -0
  224. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_singleton.py +0 -0
  225. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_step.py +0 -0
  226. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_step_context.py +0 -0
  227. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_validation.py +0 -0
  228. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_workflow.py +0 -0
  229. {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_workflow_suspended.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyworkflow-engine
3
- Version: 0.1.37
3
+ Version: 0.2.0
4
4
  Summary: A Python implementation of durable, event-sourced workflows inspired by Vercel Workflow
5
5
  Author: PyWorkflow Contributors
6
6
  License: MIT
@@ -7,7 +7,7 @@ packages = [{include = "pyworkflow"}]
7
7
 
8
8
  [project]
9
9
  name = "pyworkflow-engine"
10
- version = "0.1.37"
10
+ version = "0.2.0"
11
11
  description = "A Python implementation of durable, event-sourced workflows inspired by Vercel Workflow"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.11"
@@ -29,7 +29,7 @@ Quick Start:
29
29
  >>> run_id = await start(my_workflow, "Alice")
30
30
  """
31
31
 
32
- __version__ = "0.1.37"
32
+ __version__ = "0.2.0"
33
33
 
34
34
  # Configuration
35
35
  from pyworkflow.config import (
@@ -135,6 +135,12 @@ from pyworkflow.primitives.schedule import (
135
135
  )
136
136
  from pyworkflow.primitives.shield import shield
137
137
  from pyworkflow.primitives.sleep import sleep
138
+ from pyworkflow.primitives.step_checkpoint import (
139
+ delete_step_checkpoint,
140
+ load_step_checkpoint,
141
+ save_step_checkpoint,
142
+ )
143
+ from pyworkflow.primitives.step_hook import step_hook
138
144
 
139
145
  # Runtime
140
146
  from pyworkflow.runtime import LocalRuntime, Runtime, get_runtime, register_runtime
@@ -156,6 +162,21 @@ from pyworkflow.storage.schemas import (
156
162
  WorkflowRun,
157
163
  )
158
164
 
165
+ # Streams (pub/sub signal pattern)
166
+ from pyworkflow.streams import (
167
+ CheckpointBackend,
168
+ Signal,
169
+ Stream,
170
+ StreamConsumer,
171
+ StreamStepContext,
172
+ emit,
173
+ get_checkpoint,
174
+ get_current_signal,
175
+ save_checkpoint,
176
+ stream_step,
177
+ stream_workflow,
178
+ )
179
+
159
180
  __all__ = [
160
181
  # Version
161
182
  "__version__",
@@ -264,4 +285,21 @@ __all__ = [
264
285
  "get_logger",
265
286
  "bind_workflow_context",
266
287
  "bind_step_context",
288
+ # Step checkpoint + hooks
289
+ "save_step_checkpoint",
290
+ "load_step_checkpoint",
291
+ "delete_step_checkpoint",
292
+ "step_hook",
293
+ # Streams
294
+ "stream_workflow",
295
+ "stream_step",
296
+ "emit",
297
+ "Signal",
298
+ "Stream",
299
+ "StreamStepContext",
300
+ "StreamConsumer",
301
+ "CheckpointBackend",
302
+ "get_current_signal",
303
+ "get_checkpoint",
304
+ "save_checkpoint",
267
305
  ]
@@ -181,9 +181,11 @@ class PyWorkflowScheduler(Scheduler):
181
181
  execute_scheduled_workflow_task.apply_async(
182
182
  kwargs={
183
183
  "schedule_id": schedule.schedule_id,
184
- "scheduled_time": schedule.next_run_time.isoformat()
185
- if schedule.next_run_time
186
- else now.isoformat(),
184
+ "scheduled_time": (
185
+ schedule.next_run_time.isoformat()
186
+ if schedule.next_run_time
187
+ else now.isoformat()
188
+ ),
187
189
  "storage_config": self._storage_config,
188
190
  },
189
191
  queue="pyworkflow.schedules",
@@ -198,14 +198,36 @@ def execute_step_task(
198
198
  _exec_lock_key, self.request.id or "unknown", expiry=self._lock_expiry
199
199
  )
200
200
  if not _exec_lock_acquired:
201
- logger.warning(
202
- "Step already being executed by another worker, skipping duplicate",
203
- run_id=run_id,
204
- step_id=step_id,
205
- step_name=step_name,
206
- existing_worker_task=_exec_lock_backend.get(_exec_lock_key),
207
- )
208
- return None
201
+ # Check for worker-loss re-delivery: when a worker dies mid-step
202
+ # (e.g., spot instance termination), Celery re-delivers the same
203
+ # message with the same task_id (task_reject_on_worker_lost=True).
204
+ # The dead worker's lock still exists in Redis (up to 1h TTL).
205
+ # If the lock holder matches our own task_id, this IS a re-delivery
206
+ # of the same task — force-acquire and proceed.
207
+ _holding_task_id = _exec_lock_backend.get(_exec_lock_key)
208
+ _our_task_id = self.request.id or "unknown"
209
+ if _holding_task_id == _our_task_id:
210
+ logger.warning(
211
+ "Step lock held by same task_id — worker-loss re-delivery detected, "
212
+ "force-acquiring lock",
213
+ run_id=run_id,
214
+ step_id=step_id,
215
+ step_name=step_name,
216
+ task_id=_our_task_id,
217
+ )
218
+ _exec_lock_backend.unlock(_exec_lock_key)
219
+ _exec_lock_acquired = _exec_lock_backend.lock(
220
+ _exec_lock_key, _our_task_id, expiry=self._lock_expiry
221
+ )
222
+ else:
223
+ logger.warning(
224
+ "Step already being executed by another worker, skipping duplicate",
225
+ run_id=run_id,
226
+ step_id=step_id,
227
+ step_name=step_name,
228
+ existing_worker_task=_holding_task_id,
229
+ )
230
+ return None
209
231
  else:
210
232
  _exec_lock_acquired = True # No Redis = no locking
211
233
 
@@ -279,6 +301,18 @@ def execute_step_task(
279
301
  step_id=step_id,
280
302
  )
281
303
 
304
+ # Set up step execution context for checkpoint/hook primitives
305
+ step_exec_key = f"{run_id}:{step_id}"
306
+ step_exec_tokens = None
307
+ try:
308
+ from pyworkflow.primitives.step_checkpoint import (
309
+ set_step_execution_context,
310
+ )
311
+
312
+ step_exec_tokens = set_step_execution_context(step_exec_key, storage)
313
+ except Exception as e:
314
+ logger.warning(f"Failed to set up step execution context: {e}")
315
+
282
316
  # Execute step function
283
317
  try:
284
318
  # Get the original function (unwrapped from decorator)
@@ -339,10 +373,29 @@ def execute_step_task(
339
373
  raise
340
374
 
341
375
  except SuspensionSignal as e:
342
- # A primitive (sleep, hook, child_workflow) raised SuspensionSignal.
343
- # This should not normally happen because primitives detect step worker
344
- # context and adjust behavior. Treat as a fatal error rather than
345
- # silently retrying, which would re-execute the step from scratch.
376
+ # Check if this is a step_hook suspension (supported)
377
+ if e.reason and e.reason.startswith("step_hook:"):
378
+ hook_id = e.data.get("hook_id")
379
+ logger.info(
380
+ f"Step suspended via step_hook: {step_name}",
381
+ run_id=run_id,
382
+ step_id=step_id,
383
+ hook_id=hook_id,
384
+ )
385
+ # Record STEP_SUSPENDED event so the workflow knows this step
386
+ # needs re-dispatch (clears in-progress state during replay)
387
+ run_async(
388
+ _record_step_suspended(
389
+ storage_config=storage_config,
390
+ run_id=run_id,
391
+ step_id=step_id,
392
+ step_name=step_name,
393
+ hook_id=hook_id or "",
394
+ )
395
+ )
396
+ return None
397
+
398
+ # Other SuspensionSignals are not supported from steps
346
399
  logger.error(
347
400
  f"Step raised SuspensionSignal (unsupported from steps): {step_name}",
348
401
  run_id=run_id,
@@ -449,6 +502,12 @@ def execute_step_task(
449
502
  with contextlib.suppress(Exception):
450
503
  _exec_lock_backend.unlock(_exec_lock_key)
451
504
 
505
+ # Clean up step execution context (checkpoint/hook primitives)
506
+ if step_exec_tokens is not None:
507
+ from pyworkflow.primitives.step_checkpoint import reset_step_execution_context
508
+
509
+ reset_step_execution_context(step_exec_tokens)
510
+
452
511
  # Clean up workflow context (must be reset before step context)
453
512
  if workflow_context_token is not None:
454
513
  from pyworkflow.context.base import reset_context
@@ -567,6 +626,63 @@ async def _record_step_completion_and_resume(
567
626
  )
568
627
 
569
628
 
629
+ async def _record_step_suspended(
630
+ storage_config: dict[str, Any] | None,
631
+ run_id: str,
632
+ step_id: str,
633
+ step_name: str,
634
+ hook_id: str,
635
+ ) -> None:
636
+ """
637
+ Record STEP_SUSPENDED event when a step suspends via step_hook.
638
+
639
+ This clears the step's in-progress state during replay so that the
640
+ workflow re-dispatches the step on resume (instead of thinking it's
641
+ still running).
642
+
643
+ Does NOT schedule workflow resumption — that happens when resume_hook()
644
+ is called externally.
645
+ """
646
+ from pyworkflow.engine.events import create_step_suspended_event
647
+
648
+ storage = _get_storage_backend(storage_config)
649
+ if hasattr(storage, "connect"):
650
+ await storage.connect()
651
+
652
+ # Wait for WORKFLOW_SUSPENDED event to avoid sequence number race
653
+ max_wait_attempts = 50
654
+ wait_interval = 0.01
655
+
656
+ for _attempt in range(max_wait_attempts):
657
+ has_suspended = await storage.has_event(
658
+ run_id, EventType.WORKFLOW_SUSPENDED.value, step_id=step_id
659
+ )
660
+ if has_suspended:
661
+ break
662
+ await asyncio.sleep(wait_interval)
663
+ else:
664
+ logger.warning(
665
+ "Timeout waiting for WORKFLOW_SUSPENDED before recording STEP_SUSPENDED",
666
+ run_id=run_id,
667
+ step_id=step_id,
668
+ )
669
+
670
+ event = create_step_suspended_event(
671
+ run_id=run_id,
672
+ step_id=step_id,
673
+ step_name=step_name,
674
+ hook_id=hook_id,
675
+ )
676
+ await storage.record_event(event)
677
+ logger.info(
678
+ "Step suspended event recorded",
679
+ run_id=run_id,
680
+ step_id=step_id,
681
+ step_name=step_name,
682
+ hook_id=hook_id,
683
+ )
684
+
685
+
570
686
  async def _record_step_failure_and_resume(
571
687
  storage_config: dict[str, Any] | None,
572
688
  run_id: str,
@@ -782,13 +898,31 @@ def start_workflow_task(
782
898
  _exec_lock_key, self.request.id or "unknown", expiry=self._lock_expiry
783
899
  )
784
900
  if not _exec_lock_acquired:
785
- logger.warning(
786
- "Workflow start already being executed by another worker, skipping duplicate",
787
- run_id=run_id,
788
- workflow_name=workflow_name,
789
- existing_worker_task=_exec_lock_backend.get(_exec_lock_key),
790
- )
791
- return run_id
901
+ # Check for worker-loss re-delivery (same logic as step tasks):
902
+ # if the lock holder matches our task_id, this is the same message
903
+ # re-delivered after the original worker died.
904
+ _holding_task_id = _exec_lock_backend.get(_exec_lock_key)
905
+ _our_task_id = self.request.id or "unknown"
906
+ if _holding_task_id == _our_task_id:
907
+ logger.warning(
908
+ "Workflow start lock held by same task_id — worker-loss re-delivery "
909
+ "detected, force-acquiring lock",
910
+ run_id=run_id,
911
+ workflow_name=workflow_name,
912
+ task_id=_our_task_id,
913
+ )
914
+ _exec_lock_backend.unlock(_exec_lock_key)
915
+ _exec_lock_acquired = _exec_lock_backend.lock(
916
+ _exec_lock_key, _our_task_id, expiry=self._lock_expiry
917
+ )
918
+ else:
919
+ logger.warning(
920
+ "Workflow start already being executed by another worker, skipping duplicate",
921
+ run_id=run_id,
922
+ workflow_name=workflow_name,
923
+ existing_worker_task=_holding_task_id,
924
+ )
925
+ return run_id
792
926
  else:
793
927
  _exec_lock_acquired = True # No Redis = no locking
794
928
 
@@ -299,12 +299,12 @@ async def list_hooks_cmd(
299
299
  "Name": hook.name or "-",
300
300
  "Status": hook.status.value,
301
301
  "Run ID": hook.run_id,
302
- "Created": hook.created_at.strftime("%Y-%m-%d %H:%M")
303
- if hook.created_at
304
- else "-",
305
- "Expires": hook.expires_at.strftime("%Y-%m-%d %H:%M")
306
- if hook.expires_at
307
- else "-",
302
+ "Created": (
303
+ hook.created_at.strftime("%Y-%m-%d %H:%M") if hook.created_at else "-"
304
+ ),
305
+ "Expires": (
306
+ hook.expires_at.strftime("%Y-%m-%d %H:%M") if hook.expires_at else "-"
307
+ ),
308
308
  }
309
309
  for hook in hooks_list
310
310
  ]
@@ -376,15 +376,15 @@ async def hook_info_cmd(ctx: click.Context, token: str) -> None:
376
376
  "Run ID": hook.run_id,
377
377
  "Name": hook.name or "-",
378
378
  "Status": hook.status.value,
379
- "Created": hook.created_at.strftime("%Y-%m-%d %H:%M:%S")
380
- if hook.created_at
381
- else "-",
382
- "Expires": hook.expires_at.strftime("%Y-%m-%d %H:%M:%S")
383
- if hook.expires_at
384
- else "-",
385
- "Received": hook.received_at.strftime("%Y-%m-%d %H:%M:%S")
386
- if hook.received_at
387
- else "-",
379
+ "Created": (
380
+ hook.created_at.strftime("%Y-%m-%d %H:%M:%S") if hook.created_at else "-"
381
+ ),
382
+ "Expires": (
383
+ hook.expires_at.strftime("%Y-%m-%d %H:%M:%S") if hook.expires_at else "-"
384
+ ),
385
+ "Received": (
386
+ hook.received_at.strftime("%Y-%m-%d %H:%M:%S") if hook.received_at else "-"
387
+ ),
388
388
  }
389
389
 
390
390
  # Show payload if received
@@ -151,9 +151,9 @@ async def list_runs(
151
151
  "Run ID": run.run_id,
152
152
  "Workflow": run.workflow_name,
153
153
  "Status": run.status.value,
154
- "Started": run.started_at.strftime("%Y-%m-%d %H:%M:%S")
155
- if run.started_at
156
- else "-",
154
+ "Started": (
155
+ run.started_at.strftime("%Y-%m-%d %H:%M:%S") if run.started_at else "-"
156
+ ),
157
157
  "Duration": durations.get(run.run_id, "-"),
158
158
  }
159
159
  for run in runs_list
@@ -238,9 +238,9 @@ async def run_status(ctx: click.Context, run_id: str) -> None:
238
238
  "Status": run.status.value,
239
239
  "Created": run.created_at.strftime("%Y-%m-%d %H:%M:%S") if run.created_at else "-",
240
240
  "Started": run.started_at.strftime("%Y-%m-%d %H:%M:%S") if run.started_at else "-",
241
- "Completed": run.completed_at.strftime("%Y-%m-%d %H:%M:%S")
242
- if run.completed_at
243
- else "-",
241
+ "Completed": (
242
+ run.completed_at.strftime("%Y-%m-%d %H:%M:%S") if run.completed_at else "-"
243
+ ),
244
244
  "Duration": duration_str,
245
245
  }
246
246
 
@@ -622,9 +622,9 @@ async def list_children(
622
622
  "Workflow": child.workflow_name,
623
623
  "Status": child.status.value,
624
624
  "Depth": child.nesting_depth,
625
- "Started": child.started_at.strftime("%Y-%m-%d %H:%M:%S")
626
- if child.started_at
627
- else "-",
625
+ "Started": (
626
+ child.started_at.strftime("%Y-%m-%d %H:%M:%S") if child.started_at else "-"
627
+ ),
628
628
  "Duration": _calc_duration(child),
629
629
  }
630
630
  for child in children
@@ -123,9 +123,9 @@ async def list_schedules_cmd(
123
123
  "Workflow": s.workflow_name,
124
124
  "Status": s.status.value,
125
125
  "Schedule": describe_schedule(s.spec),
126
- "Next Run": s.next_run_time.strftime("%Y-%m-%d %H:%M:%S")
127
- if s.next_run_time
128
- else "-",
126
+ "Next Run": (
127
+ s.next_run_time.strftime("%Y-%m-%d %H:%M:%S") if s.next_run_time else "-"
128
+ ),
129
129
  "Runs": f"{s.successful_runs}/{s.total_runs}",
130
130
  }
131
131
  for s in schedules_list
@@ -241,9 +241,9 @@ async def create_schedule_cmd(
241
241
  "status": schedule.status.value,
242
242
  "spec": describe_schedule(schedule.spec),
243
243
  "overlap_policy": schedule.overlap_policy.value,
244
- "next_run_time": schedule.next_run_time.isoformat()
245
- if schedule.next_run_time
246
- else None,
244
+ "next_run_time": (
245
+ schedule.next_run_time.isoformat() if schedule.next_run_time else None
246
+ ),
247
247
  }
248
248
  format_json(data)
249
249
  else:
@@ -308,9 +308,9 @@ async def show_schedule_cmd(
308
308
  "timezone": schedule.spec.timezone,
309
309
  },
310
310
  "overlap_policy": schedule.overlap_policy.value,
311
- "next_run_time": schedule.next_run_time.isoformat()
312
- if schedule.next_run_time
313
- else None,
311
+ "next_run_time": (
312
+ schedule.next_run_time.isoformat() if schedule.next_run_time else None
313
+ ),
314
314
  "last_run_at": schedule.last_run_at.isoformat() if schedule.last_run_at else None,
315
315
  "total_runs": schedule.total_runs,
316
316
  "successful_runs": schedule.successful_runs,
@@ -327,19 +327,25 @@ async def show_schedule_cmd(
327
327
  "Status": schedule.status.value,
328
328
  "Schedule": describe_schedule(schedule.spec),
329
329
  "Overlap Policy": schedule.overlap_policy.value,
330
- "Next Run": schedule.next_run_time.strftime("%Y-%m-%d %H:%M:%S")
331
- if schedule.next_run_time
332
- else "-",
333
- "Last Run": schedule.last_run_at.strftime("%Y-%m-%d %H:%M:%S")
334
- if schedule.last_run_at
335
- else "-",
330
+ "Next Run": (
331
+ schedule.next_run_time.strftime("%Y-%m-%d %H:%M:%S")
332
+ if schedule.next_run_time
333
+ else "-"
334
+ ),
335
+ "Last Run": (
336
+ schedule.last_run_at.strftime("%Y-%m-%d %H:%M:%S")
337
+ if schedule.last_run_at
338
+ else "-"
339
+ ),
336
340
  "Total Runs": schedule.total_runs,
337
341
  "Successful": schedule.successful_runs,
338
342
  "Failed": schedule.failed_runs,
339
343
  "Skipped": schedule.skipped_runs,
340
- "Created": schedule.created_at.strftime("%Y-%m-%d %H:%M:%S")
341
- if schedule.created_at
342
- else "-",
344
+ "Created": (
345
+ schedule.created_at.strftime("%Y-%m-%d %H:%M:%S")
346
+ if schedule.created_at
347
+ else "-"
348
+ ),
343
349
  }
344
350
  format_key_value(data, title=f"Schedule: {schedule_id}")
345
351
 
@@ -440,9 +446,9 @@ async def resume_schedule_cmd(
440
446
  data = {
441
447
  "schedule_id": schedule.schedule_id,
442
448
  "status": schedule.status.value,
443
- "next_run_time": schedule.next_run_time.isoformat()
444
- if schedule.next_run_time
445
- else None,
449
+ "next_run_time": (
450
+ schedule.next_run_time.isoformat() if schedule.next_run_time else None
451
+ ),
446
452
  }
447
453
  format_json(data)
448
454
 
@@ -774,9 +780,9 @@ async def update_schedule_cmd(
774
780
  "workflow_name": schedule.workflow_name,
775
781
  "spec": describe_schedule(schedule.spec),
776
782
  "overlap_policy": schedule.overlap_policy.value,
777
- "next_run_time": schedule.next_run_time.isoformat()
778
- if schedule.next_run_time
779
- else None,
783
+ "next_run_time": (
784
+ schedule.next_run_time.isoformat() if schedule.next_run_time else None
785
+ ),
780
786
  }
781
787
  format_json(data)
782
788
  else:
@@ -94,6 +94,10 @@ class LocalContext(WorkflowContext):
94
94
  self._cancellation_blocked: bool = False
95
95
  self._cancellation_reason: str | None = None
96
96
 
97
+ # Signal/stream state (used by EventReplayer for stream workflows)
98
+ self._signal_waits: dict[str, dict[str, Any]] = {}
99
+ self._received_signals: dict[str, dict[str, Any]] = {}
100
+
97
101
  # Child workflow state
98
102
  self._child_results: dict[str, dict[str, Any]] = {}
99
103
  self._pending_children: dict[str, str] = {} # child_id -> child_run_id
@@ -161,6 +165,13 @@ class LocalContext(WorkflowContext):
161
165
  # Step completed - no longer in progress
162
166
  self._steps_in_progress.discard(step_id)
163
167
 
168
+ elif event.type == EventType.STEP_SUSPENDED:
169
+ step_id = event.data.get("step_id")
170
+ if step_id:
171
+ # Step suspended via step_hook — no longer in progress,
172
+ # needs re-dispatch on workflow resume
173
+ self._steps_in_progress.discard(step_id)
174
+
164
175
  elif event.type == EventType.SLEEP_COMPLETED:
165
176
  sleep_id = event.data.get("sleep_id")
166
177
  self._completed_sleeps.add(sleep_id)
@@ -21,7 +21,7 @@ from typing import Any
21
21
  from loguru import logger
22
22
 
23
23
  from pyworkflow.context import get_context, has_context
24
- from pyworkflow.core.exceptions import FatalError, RetryableError
24
+ from pyworkflow.core.exceptions import FatalError, RetryableError, SuspensionSignal
25
25
  from pyworkflow.core.registry import register_step
26
26
  from pyworkflow.core.validation import validate_step_parameters
27
27
  from pyworkflow.engine.events import (
@@ -29,6 +29,10 @@ from pyworkflow.engine.events import (
29
29
  create_step_failed_event,
30
30
  create_step_started_event,
31
31
  )
32
+ from pyworkflow.primitives.step_checkpoint import (
33
+ reset_step_execution_context,
34
+ set_step_execution_context,
35
+ )
32
36
  from pyworkflow.serialization.encoder import serialize, serialize_args, serialize_kwargs
33
37
 
34
38
 
@@ -177,8 +181,6 @@ def step(
177
181
  step_id=step_id,
178
182
  )
179
183
  # Re-suspend and wait for existing task to complete
180
- from pyworkflow.core.exceptions import SuspensionSignal
181
-
182
184
  raise SuspensionSignal(
183
185
  reason=f"step_dispatch:{step_id}",
184
186
  step_id=step_id,
@@ -229,8 +231,6 @@ def step(
229
231
  current_attempt=current_attempt,
230
232
  resume_at=resume_at.isoformat(),
231
233
  )
232
- from pyworkflow.core.exceptions import SuspensionSignal
233
-
234
234
  raise SuspensionSignal(
235
235
  reason=f"retry:{step_id}",
236
236
  resume_at=resume_at,
@@ -268,6 +268,10 @@ def step(
268
268
  # Validate parameters before execution
269
269
  validate_step_parameters(func, args, kwargs, step_name)
270
270
 
271
+ # Set up step execution context for checkpoint/hook primitives
272
+ step_exec_key = f"{ctx.run_id}:{step_id}"
273
+ step_exec_tokens = set_step_execution_context(step_exec_key, ctx.storage)
274
+
271
275
  try:
272
276
  # Execute step function
273
277
  result = await func(*args, **kwargs)
@@ -295,6 +299,15 @@ def step(
295
299
 
296
300
  return result
297
301
 
302
+ except SuspensionSignal:
303
+ # step_hook() raised SuspensionSignal — propagate to suspend workflow
304
+ logger.info(
305
+ f"Step suspended via step_hook: {step_name}",
306
+ run_id=ctx.run_id,
307
+ step_id=step_id,
308
+ )
309
+ raise
310
+
298
311
  except FatalError as e:
299
312
  # Fatal error - don't retry
300
313
  logger.error(
@@ -393,8 +406,6 @@ def step(
393
406
 
394
407
  # Raise suspension signal to pause workflow
395
408
  # Note: The workflow-level exception handler will schedule automatic resumption
396
- from pyworkflow.core.exceptions import SuspensionSignal
397
-
398
409
  raise SuspensionSignal(
399
410
  reason=f"retry:{step_id}",
400
411
  resume_at=resume_at,
@@ -434,6 +445,9 @@ def step(
434
445
  else:
435
446
  raise
436
447
 
448
+ finally:
449
+ reset_step_execution_context(step_exec_tokens)
450
+
437
451
  # Register step
438
452
  register_step(
439
453
  name=step_name,
@@ -645,7 +659,6 @@ async def _dispatch_step_to_celery(
645
659
  SuspensionSignal: To pause workflow while step executes on worker
646
660
  """
647
661
  from pyworkflow.celery.tasks import execute_step_task
648
- from pyworkflow.core.exceptions import SuspensionSignal
649
662
  from pyworkflow.engine.events import EventType
650
663
 
651
664
  logger.info(
@@ -657,11 +670,17 @@ async def _dispatch_step_to_celery(
657
670
  # Defense-in-depth: check if STEP_STARTED was already recorded for this step.
658
671
  # This guards against duplicate dispatch when two resume tasks race and both
659
672
  # replay past the same step. If already started, re-suspend to wait.
673
+ # Exception: if STEP_SUSPENDED was recorded (step_hook suspension), the step
674
+ # needs re-dispatch so it can re-execute and find the HOOK_RECEIVED event.
660
675
  events = await ctx.storage.get_events(ctx.run_id)
661
676
  already_started = any(
662
677
  evt.type == EventType.STEP_STARTED and evt.data.get("step_id") == step_id for evt in events
663
678
  )
664
- if already_started:
679
+ was_suspended = any(
680
+ evt.type == EventType.STEP_SUSPENDED and evt.data.get("step_id") == step_id
681
+ for evt in events
682
+ )
683
+ if already_started and not was_suspended:
665
684
  logger.info(
666
685
  f"Step {step_name} already has STEP_STARTED event, re-suspending",
667
686
  run_id=ctx.run_id,