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.
Files changed (196) hide show
  1. dashboard/backend/app/__init__.py +1 -0
  2. dashboard/backend/app/config.py +32 -0
  3. dashboard/backend/app/controllers/__init__.py +6 -0
  4. dashboard/backend/app/controllers/run_controller.py +86 -0
  5. dashboard/backend/app/controllers/workflow_controller.py +33 -0
  6. dashboard/backend/app/dependencies/__init__.py +5 -0
  7. dashboard/backend/app/dependencies/storage.py +50 -0
  8. dashboard/backend/app/repositories/__init__.py +6 -0
  9. dashboard/backend/app/repositories/run_repository.py +80 -0
  10. dashboard/backend/app/repositories/workflow_repository.py +27 -0
  11. dashboard/backend/app/rest/__init__.py +8 -0
  12. dashboard/backend/app/rest/v1/__init__.py +12 -0
  13. dashboard/backend/app/rest/v1/health.py +33 -0
  14. dashboard/backend/app/rest/v1/runs.py +133 -0
  15. dashboard/backend/app/rest/v1/workflows.py +41 -0
  16. dashboard/backend/app/schemas/__init__.py +23 -0
  17. dashboard/backend/app/schemas/common.py +16 -0
  18. dashboard/backend/app/schemas/event.py +24 -0
  19. dashboard/backend/app/schemas/hook.py +25 -0
  20. dashboard/backend/app/schemas/run.py +54 -0
  21. dashboard/backend/app/schemas/step.py +28 -0
  22. dashboard/backend/app/schemas/workflow.py +31 -0
  23. dashboard/backend/app/server.py +87 -0
  24. dashboard/backend/app/services/__init__.py +6 -0
  25. dashboard/backend/app/services/run_service.py +240 -0
  26. dashboard/backend/app/services/workflow_service.py +155 -0
  27. dashboard/backend/main.py +18 -0
  28. docs/concepts/cancellation.mdx +362 -0
  29. docs/concepts/continue-as-new.mdx +434 -0
  30. docs/concepts/events.mdx +266 -0
  31. docs/concepts/fault-tolerance.mdx +370 -0
  32. docs/concepts/hooks.mdx +552 -0
  33. docs/concepts/limitations.mdx +167 -0
  34. docs/concepts/schedules.mdx +775 -0
  35. docs/concepts/sleep.mdx +312 -0
  36. docs/concepts/steps.mdx +301 -0
  37. docs/concepts/workflows.mdx +255 -0
  38. docs/guides/cli.mdx +942 -0
  39. docs/guides/configuration.mdx +560 -0
  40. docs/introduction.mdx +155 -0
  41. docs/quickstart.mdx +279 -0
  42. examples/__init__.py +1 -0
  43. examples/celery/__init__.py +1 -0
  44. examples/celery/durable/docker-compose.yml +55 -0
  45. examples/celery/durable/pyworkflow.config.yaml +12 -0
  46. examples/celery/durable/workflows/__init__.py +122 -0
  47. examples/celery/durable/workflows/basic.py +87 -0
  48. examples/celery/durable/workflows/batch_processing.py +102 -0
  49. examples/celery/durable/workflows/cancellation.py +273 -0
  50. examples/celery/durable/workflows/child_workflow_patterns.py +240 -0
  51. examples/celery/durable/workflows/child_workflows.py +202 -0
  52. examples/celery/durable/workflows/continue_as_new.py +260 -0
  53. examples/celery/durable/workflows/fault_tolerance.py +210 -0
  54. examples/celery/durable/workflows/hooks.py +211 -0
  55. examples/celery/durable/workflows/idempotency.py +112 -0
  56. examples/celery/durable/workflows/long_running.py +99 -0
  57. examples/celery/durable/workflows/retries.py +101 -0
  58. examples/celery/durable/workflows/schedules.py +209 -0
  59. examples/celery/transient/01_basic_workflow.py +91 -0
  60. examples/celery/transient/02_fault_tolerance.py +257 -0
  61. examples/celery/transient/__init__.py +20 -0
  62. examples/celery/transient/pyworkflow.config.yaml +25 -0
  63. examples/local/__init__.py +1 -0
  64. examples/local/durable/01_basic_workflow.py +94 -0
  65. examples/local/durable/02_file_storage.py +132 -0
  66. examples/local/durable/03_retries.py +169 -0
  67. examples/local/durable/04_long_running.py +119 -0
  68. examples/local/durable/05_event_log.py +145 -0
  69. examples/local/durable/06_idempotency.py +148 -0
  70. examples/local/durable/07_hooks.py +334 -0
  71. examples/local/durable/08_cancellation.py +233 -0
  72. examples/local/durable/09_child_workflows.py +198 -0
  73. examples/local/durable/10_child_workflow_patterns.py +265 -0
  74. examples/local/durable/11_continue_as_new.py +249 -0
  75. examples/local/durable/12_schedules.py +198 -0
  76. examples/local/durable/__init__.py +1 -0
  77. examples/local/transient/01_quick_tasks.py +87 -0
  78. examples/local/transient/02_retries.py +130 -0
  79. examples/local/transient/03_sleep.py +141 -0
  80. examples/local/transient/__init__.py +1 -0
  81. pyworkflow/__init__.py +256 -0
  82. pyworkflow/aws/__init__.py +68 -0
  83. pyworkflow/aws/context.py +234 -0
  84. pyworkflow/aws/handler.py +184 -0
  85. pyworkflow/aws/testing.py +310 -0
  86. pyworkflow/celery/__init__.py +41 -0
  87. pyworkflow/celery/app.py +198 -0
  88. pyworkflow/celery/scheduler.py +315 -0
  89. pyworkflow/celery/tasks.py +1746 -0
  90. pyworkflow/cli/__init__.py +132 -0
  91. pyworkflow/cli/__main__.py +6 -0
  92. pyworkflow/cli/commands/__init__.py +1 -0
  93. pyworkflow/cli/commands/hooks.py +640 -0
  94. pyworkflow/cli/commands/quickstart.py +495 -0
  95. pyworkflow/cli/commands/runs.py +773 -0
  96. pyworkflow/cli/commands/scheduler.py +130 -0
  97. pyworkflow/cli/commands/schedules.py +794 -0
  98. pyworkflow/cli/commands/setup.py +703 -0
  99. pyworkflow/cli/commands/worker.py +413 -0
  100. pyworkflow/cli/commands/workflows.py +1257 -0
  101. pyworkflow/cli/output/__init__.py +1 -0
  102. pyworkflow/cli/output/formatters.py +321 -0
  103. pyworkflow/cli/output/styles.py +121 -0
  104. pyworkflow/cli/utils/__init__.py +1 -0
  105. pyworkflow/cli/utils/async_helpers.py +30 -0
  106. pyworkflow/cli/utils/config.py +130 -0
  107. pyworkflow/cli/utils/config_generator.py +344 -0
  108. pyworkflow/cli/utils/discovery.py +53 -0
  109. pyworkflow/cli/utils/docker_manager.py +651 -0
  110. pyworkflow/cli/utils/interactive.py +364 -0
  111. pyworkflow/cli/utils/storage.py +115 -0
  112. pyworkflow/config.py +329 -0
  113. pyworkflow/context/__init__.py +63 -0
  114. pyworkflow/context/aws.py +230 -0
  115. pyworkflow/context/base.py +416 -0
  116. pyworkflow/context/local.py +930 -0
  117. pyworkflow/context/mock.py +381 -0
  118. pyworkflow/core/__init__.py +0 -0
  119. pyworkflow/core/exceptions.py +353 -0
  120. pyworkflow/core/registry.py +313 -0
  121. pyworkflow/core/scheduled.py +328 -0
  122. pyworkflow/core/step.py +494 -0
  123. pyworkflow/core/workflow.py +294 -0
  124. pyworkflow/discovery.py +248 -0
  125. pyworkflow/engine/__init__.py +0 -0
  126. pyworkflow/engine/events.py +879 -0
  127. pyworkflow/engine/executor.py +682 -0
  128. pyworkflow/engine/replay.py +273 -0
  129. pyworkflow/observability/__init__.py +19 -0
  130. pyworkflow/observability/logging.py +234 -0
  131. pyworkflow/primitives/__init__.py +33 -0
  132. pyworkflow/primitives/child_handle.py +174 -0
  133. pyworkflow/primitives/child_workflow.py +372 -0
  134. pyworkflow/primitives/continue_as_new.py +101 -0
  135. pyworkflow/primitives/define_hook.py +150 -0
  136. pyworkflow/primitives/hooks.py +97 -0
  137. pyworkflow/primitives/resume_hook.py +210 -0
  138. pyworkflow/primitives/schedule.py +545 -0
  139. pyworkflow/primitives/shield.py +96 -0
  140. pyworkflow/primitives/sleep.py +100 -0
  141. pyworkflow/runtime/__init__.py +21 -0
  142. pyworkflow/runtime/base.py +179 -0
  143. pyworkflow/runtime/celery.py +310 -0
  144. pyworkflow/runtime/factory.py +101 -0
  145. pyworkflow/runtime/local.py +706 -0
  146. pyworkflow/scheduler/__init__.py +9 -0
  147. pyworkflow/scheduler/local.py +248 -0
  148. pyworkflow/serialization/__init__.py +0 -0
  149. pyworkflow/serialization/decoder.py +146 -0
  150. pyworkflow/serialization/encoder.py +162 -0
  151. pyworkflow/storage/__init__.py +54 -0
  152. pyworkflow/storage/base.py +612 -0
  153. pyworkflow/storage/config.py +185 -0
  154. pyworkflow/storage/dynamodb.py +1315 -0
  155. pyworkflow/storage/file.py +827 -0
  156. pyworkflow/storage/memory.py +549 -0
  157. pyworkflow/storage/postgres.py +1161 -0
  158. pyworkflow/storage/schemas.py +486 -0
  159. pyworkflow/storage/sqlite.py +1136 -0
  160. pyworkflow/utils/__init__.py +0 -0
  161. pyworkflow/utils/duration.py +177 -0
  162. pyworkflow/utils/schedule.py +391 -0
  163. pyworkflow_engine-0.1.7.dist-info/METADATA +687 -0
  164. pyworkflow_engine-0.1.7.dist-info/RECORD +196 -0
  165. pyworkflow_engine-0.1.7.dist-info/WHEEL +5 -0
  166. pyworkflow_engine-0.1.7.dist-info/entry_points.txt +2 -0
  167. pyworkflow_engine-0.1.7.dist-info/licenses/LICENSE +21 -0
  168. pyworkflow_engine-0.1.7.dist-info/top_level.txt +5 -0
  169. tests/examples/__init__.py +0 -0
  170. tests/integration/__init__.py +0 -0
  171. tests/integration/test_cancellation.py +330 -0
  172. tests/integration/test_child_workflows.py +439 -0
  173. tests/integration/test_continue_as_new.py +428 -0
  174. tests/integration/test_dynamodb_storage.py +1146 -0
  175. tests/integration/test_fault_tolerance.py +369 -0
  176. tests/integration/test_schedule_storage.py +484 -0
  177. tests/unit/__init__.py +0 -0
  178. tests/unit/backends/__init__.py +1 -0
  179. tests/unit/backends/test_dynamodb_storage.py +1554 -0
  180. tests/unit/backends/test_postgres_storage.py +1281 -0
  181. tests/unit/backends/test_sqlite_storage.py +1460 -0
  182. tests/unit/conftest.py +41 -0
  183. tests/unit/test_cancellation.py +364 -0
  184. tests/unit/test_child_workflows.py +680 -0
  185. tests/unit/test_continue_as_new.py +441 -0
  186. tests/unit/test_event_limits.py +316 -0
  187. tests/unit/test_executor.py +320 -0
  188. tests/unit/test_fault_tolerance.py +334 -0
  189. tests/unit/test_hooks.py +495 -0
  190. tests/unit/test_registry.py +261 -0
  191. tests/unit/test_replay.py +420 -0
  192. tests/unit/test_schedule_schemas.py +285 -0
  193. tests/unit/test_schedule_utils.py +286 -0
  194. tests/unit/test_scheduled_workflow.py +274 -0
  195. tests/unit/test_step.py +353 -0
  196. tests/unit/test_workflow.py +243 -0
@@ -0,0 +1,294 @@
1
+ """
2
+ @workflow decorator for defining durable workflows.
3
+
4
+ Workflows are orchestration functions that coordinate steps. They are
5
+ decorated with @workflow to enable:
6
+ - Event sourcing and deterministic replay
7
+ - Suspension and resumption (sleep, hooks)
8
+ - Automatic state persistence
9
+ - Fault tolerance
10
+ """
11
+
12
+ import functools
13
+ from collections.abc import Callable
14
+ from typing import Any
15
+
16
+ from loguru import logger
17
+
18
+ from pyworkflow.context import LocalContext, reset_context, set_context
19
+ from pyworkflow.core.exceptions import CancellationError, ContinueAsNewSignal, SuspensionSignal
20
+ from pyworkflow.core.registry import register_workflow
21
+ from pyworkflow.engine.events import (
22
+ create_workflow_cancelled_event,
23
+ create_workflow_completed_event,
24
+ create_workflow_failed_event,
25
+ )
26
+ from pyworkflow.serialization.encoder import serialize
27
+
28
+
29
+ def workflow(
30
+ name: str | None = None,
31
+ durable: bool | None = None,
32
+ max_duration: str | None = None,
33
+ tags: list[str] | None = None,
34
+ recover_on_worker_loss: bool | None = None,
35
+ max_recovery_attempts: int | None = None,
36
+ ) -> Callable:
37
+ """
38
+ Decorator to mark async functions as workflows.
39
+
40
+ Workflows are orchestration functions that coordinate steps. They can be:
41
+ - Durable: Event-sourced, persistent, resumable (durable=True)
42
+ - Transient: Simple execution without persistence overhead (durable=False)
43
+
44
+ Args:
45
+ name: Optional workflow name (defaults to function name)
46
+ durable: Whether workflow is durable (None = use configured default)
47
+ max_duration: Optional max duration (e.g., "1h", "30m")
48
+ tags: Optional list of tags for categorization (max 3 tags)
49
+ recover_on_worker_loss: Whether to auto-recover on worker failure
50
+ (None = True for durable, False for transient)
51
+ max_recovery_attempts: Max recovery attempts on worker failure (default: 3)
52
+
53
+ Returns:
54
+ Decorated workflow function
55
+
56
+ Example (durable):
57
+ @workflow(name="process_order", durable=True)
58
+ async def process_order(order_id: str):
59
+ order = await validate_order(order_id)
60
+ payment = await charge_payment(order["total"])
61
+ await sleep("1h") # Can suspend and resume
62
+ return payment
63
+
64
+ Example (transient):
65
+ @workflow(durable=False)
66
+ async def quick_task():
67
+ result = await my_step()
68
+ return result
69
+
70
+ Example (use configured default):
71
+ @workflow
72
+ async def simple_workflow():
73
+ result = await my_step()
74
+ return result
75
+
76
+ Example (fault tolerant):
77
+ @workflow(durable=True, recover_on_worker_loss=True, max_recovery_attempts=5)
78
+ async def critical_workflow():
79
+ # Will auto-recover if worker crashes
80
+ result = await important_step()
81
+ return result
82
+
83
+ Example (with tags):
84
+ @workflow(tags=["backend", "critical"])
85
+ async def tagged_workflow():
86
+ result = await my_step()
87
+ return result
88
+ """
89
+
90
+ # Validate tags
91
+ validated_tags = tags or []
92
+ if len(validated_tags) > 3:
93
+ raise ValueError(
94
+ f"Workflows can have at most 3 tags, got {len(validated_tags)}: {validated_tags}"
95
+ )
96
+
97
+ def decorator(func: Callable) -> Callable:
98
+ workflow_name = name or func.__name__
99
+
100
+ @functools.wraps(func)
101
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
102
+ # This wrapper is called during execution by the executor
103
+ # The actual workflow function runs with a context set up
104
+ return await func(*args, **kwargs)
105
+
106
+ # Register workflow
107
+ register_workflow(
108
+ name=workflow_name,
109
+ func=wrapper,
110
+ original_func=func,
111
+ max_duration=max_duration,
112
+ tags=validated_tags,
113
+ )
114
+
115
+ # Store metadata on wrapper
116
+ wrapper.__workflow__ = True # type: ignore[attr-defined]
117
+ wrapper.__workflow_name__ = workflow_name # type: ignore[attr-defined]
118
+ wrapper.__workflow_durable__ = durable # type: ignore[attr-defined] # None = use config default
119
+ wrapper.__workflow_max_duration__ = max_duration # type: ignore[attr-defined]
120
+ wrapper.__workflow_tags__ = validated_tags # type: ignore[attr-defined]
121
+ wrapper.__workflow_recover_on_worker_loss__ = ( # type: ignore[attr-defined]
122
+ recover_on_worker_loss # None = use config default
123
+ )
124
+ wrapper.__workflow_max_recovery_attempts__ = ( # type: ignore[attr-defined]
125
+ max_recovery_attempts # None = use config default
126
+ )
127
+
128
+ return wrapper
129
+
130
+ return decorator
131
+
132
+
133
+ async def execute_workflow_with_context(
134
+ workflow_func: Callable,
135
+ run_id: str,
136
+ workflow_name: str,
137
+ storage: Any, # StorageBackend or None for transient
138
+ args: tuple,
139
+ kwargs: dict,
140
+ event_log: list | None = None,
141
+ durable: bool = True,
142
+ cancellation_requested: bool = False,
143
+ ) -> Any:
144
+ """
145
+ Execute workflow function with proper context setup.
146
+
147
+ This is called by the executor to run a workflow with:
148
+ - Context initialization
149
+ - Event logging (durable mode only)
150
+ - Error handling
151
+ - Suspension handling (durable mode only)
152
+ - Cancellation handling
153
+
154
+ Args:
155
+ workflow_func: The workflow function to execute
156
+ run_id: Unique run identifier
157
+ workflow_name: Workflow name
158
+ storage: Storage backend instance (None for transient)
159
+ args: Positional arguments
160
+ kwargs: Keyword arguments
161
+ event_log: Optional existing event log for replay
162
+ durable: Whether this is a durable workflow
163
+ cancellation_requested: Whether cancellation was requested before execution
164
+
165
+ Returns:
166
+ Workflow result
167
+
168
+ Raises:
169
+ SuspensionSignal: When workflow needs to suspend (durable only)
170
+ CancellationError: When workflow is cancelled
171
+ Exception: On workflow failure
172
+ """
173
+ # Determine if we're actually durable (need both flag and storage)
174
+ is_durable = durable and storage is not None
175
+
176
+ # Create workflow context using new LocalContext
177
+ ctx = LocalContext(
178
+ run_id=run_id,
179
+ workflow_name=workflow_name,
180
+ storage=storage,
181
+ event_log=event_log or [],
182
+ durable=is_durable,
183
+ )
184
+
185
+ # Set cancellation state if requested before execution
186
+ if cancellation_requested:
187
+ ctx.request_cancellation(reason="Cancellation requested before execution")
188
+
189
+ # Set as current context using new API
190
+ token = set_context(ctx)
191
+
192
+ try:
193
+ # Note: Event replay is handled by LocalContext in its constructor
194
+ # when event_log is provided
195
+
196
+ logger.info(
197
+ f"Executing workflow: {workflow_name}",
198
+ run_id=run_id,
199
+ workflow_name=workflow_name,
200
+ durable=is_durable,
201
+ is_replay=bool(event_log),
202
+ )
203
+
204
+ # Execute workflow function
205
+ result = await workflow_func(*args, **kwargs)
206
+
207
+ # Record completion event (durable mode only)
208
+ if is_durable:
209
+ # Validate event limits before recording completion
210
+ await ctx.validate_event_limits()
211
+
212
+ completion_event = create_workflow_completed_event(
213
+ run_id, serialize(result), workflow_name
214
+ )
215
+ await storage.record_event(completion_event)
216
+
217
+ logger.info(
218
+ f"Workflow completed: {workflow_name}",
219
+ run_id=run_id,
220
+ workflow_name=workflow_name,
221
+ durable=is_durable,
222
+ )
223
+
224
+ return result
225
+
226
+ except SuspensionSignal as e:
227
+ # Workflow suspended (sleep/hook) - only happens in durable mode
228
+ logger.info(
229
+ f"Workflow suspended: {e.reason}",
230
+ run_id=run_id,
231
+ workflow_name=workflow_name,
232
+ reason=e.reason,
233
+ )
234
+ raise
235
+
236
+ except ContinueAsNewSignal as e:
237
+ # Workflow continuing as new execution
238
+ logger.info(
239
+ f"Workflow continuing as new: {workflow_name}",
240
+ run_id=run_id,
241
+ workflow_name=workflow_name,
242
+ new_args=e.workflow_args,
243
+ new_kwargs=e.workflow_kwargs,
244
+ )
245
+ # Re-raise for caller (executor) to handle continuation
246
+ raise
247
+
248
+ except CancellationError as e:
249
+ # Workflow was cancelled
250
+ logger.info(
251
+ f"Workflow cancelled: {workflow_name}",
252
+ run_id=run_id,
253
+ workflow_name=workflow_name,
254
+ reason=e.reason,
255
+ )
256
+
257
+ # Record cancellation event (durable mode only)
258
+ if is_durable:
259
+ cancelled_event = create_workflow_cancelled_event(
260
+ run_id=run_id,
261
+ reason=e.reason,
262
+ cleanup_completed=True,
263
+ )
264
+ await storage.record_event(cancelled_event)
265
+
266
+ raise
267
+
268
+ except Exception as e:
269
+ # Workflow failed
270
+ logger.error(
271
+ f"Workflow failed: {workflow_name}",
272
+ run_id=run_id,
273
+ workflow_name=workflow_name,
274
+ error=str(e),
275
+ exc_info=True,
276
+ )
277
+
278
+ # Record failure event (durable mode only)
279
+ if is_durable:
280
+ import traceback
281
+
282
+ failure_event = create_workflow_failed_event(
283
+ run_id=run_id,
284
+ error=str(e),
285
+ error_type=type(e).__name__,
286
+ traceback=traceback.format_exc(),
287
+ )
288
+ await storage.record_event(failure_event)
289
+
290
+ raise
291
+
292
+ finally:
293
+ # Clear context using new API
294
+ reset_context(token)
@@ -0,0 +1,248 @@
1
+ """
2
+ Workflow discovery utilities.
3
+
4
+ This module provides functions to discover and register workflows by importing
5
+ Python modules containing @workflow decorated functions.
6
+ """
7
+
8
+ import importlib
9
+ import os
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from loguru import logger
15
+
16
+
17
+ class DiscoveryError(Exception):
18
+ """Raised when workflow discovery fails."""
19
+
20
+ pass
21
+
22
+
23
+ def _find_project_root() -> Path | None:
24
+ """
25
+ Find the project root by looking for common project markers.
26
+
27
+ Searches upward from the current directory for:
28
+ - pyproject.toml
29
+ - setup.py
30
+ - .git directory
31
+
32
+ Returns:
33
+ Path to project root if found, None otherwise
34
+ """
35
+ current = Path.cwd()
36
+
37
+ for path in [current, *list(current.parents)]:
38
+ if (path / "pyproject.toml").exists():
39
+ return path
40
+ if (path / "setup.py").exists():
41
+ return path
42
+ if (path / ".git").exists():
43
+ return path
44
+
45
+ return None
46
+
47
+
48
+ def _ensure_project_in_path() -> None:
49
+ """Add the project root and current directory to sys.path if not already present."""
50
+ # Add current directory first
51
+ cwd = str(Path.cwd())
52
+ if cwd not in sys.path:
53
+ sys.path.insert(0, cwd)
54
+ logger.debug(f"Added current directory to path: {cwd}")
55
+
56
+ # Add project root
57
+ project_root = _find_project_root()
58
+ if project_root:
59
+ root_str = str(project_root)
60
+ if root_str not in sys.path:
61
+ sys.path.insert(0, root_str)
62
+ logger.debug(f"Added project root to path: {root_str}")
63
+
64
+
65
+ def _import_module(module_path: str) -> bool:
66
+ """
67
+ Import a single module to trigger workflow registration.
68
+
69
+ Args:
70
+ module_path: Python module path (e.g., "myapp.workflows")
71
+
72
+ Returns:
73
+ True if import succeeded, False otherwise
74
+ """
75
+ try:
76
+ importlib.import_module(module_path)
77
+ logger.info(f"Discovered workflows from module: {module_path}")
78
+ return True
79
+ except ModuleNotFoundError as e:
80
+ logger.error(f"Failed to import module '{module_path}': {e}")
81
+ return False
82
+ except Exception as e:
83
+ logger.error(f"Error importing module '{module_path}': {e}")
84
+ return False
85
+
86
+
87
+ def _load_yaml_config() -> dict[str, Any] | None:
88
+ """
89
+ Load pyworkflow.config.yaml from current directory.
90
+
91
+ Returns:
92
+ Configuration dictionary if found, None otherwise
93
+ """
94
+ config_path = Path.cwd() / "pyworkflow.config.yaml"
95
+ if not config_path.exists():
96
+ return None
97
+
98
+ try:
99
+ import yaml
100
+
101
+ with open(config_path) as f:
102
+ config = yaml.safe_load(f)
103
+ logger.debug(f"Loaded config from: {config_path}")
104
+ return config
105
+ except ImportError:
106
+ logger.warning("PyYAML not installed, skipping YAML config")
107
+ return None
108
+ except Exception as e:
109
+ logger.error(f"Failed to load YAML config: {e}")
110
+ return None
111
+
112
+
113
+ def discover_workflows(
114
+ module_path: str | None = None,
115
+ config: dict[str, Any] | None = None,
116
+ config_path: str | Path | None = None,
117
+ ) -> None:
118
+ """
119
+ Import Python modules to trigger workflow registration.
120
+
121
+ Workflows are registered when their module is imported, as the @workflow
122
+ decorator registers them in the global registry. This function handles
123
+ importing the appropriate module based on configuration priority.
124
+
125
+ Priority:
126
+ 1. Explicit module_path argument
127
+ 2. PYWORKFLOW_DISCOVER environment variable
128
+ 3. Config dict (from YAML) with 'module' or 'modules' key
129
+ 4. pyworkflow.config.yaml in current directory
130
+
131
+ Args:
132
+ module_path: Explicit module path to import
133
+ config: Configuration dict containing 'module' or 'modules' key
134
+ config_path: Path to the config file. If provided, the directory
135
+ containing the config file will be added to sys.path for imports.
136
+
137
+ Raises:
138
+ DiscoveryError: If specified modules cannot be imported
139
+
140
+ Examples:
141
+ # Explicit module
142
+ discover_workflows("myapp.workflows")
143
+
144
+ # From config dict with path context
145
+ discover_workflows(config={"module": "myapp.workflows"}, config_path="/app/pyworkflow.config.yaml")
146
+
147
+ # From environment variable
148
+ os.environ["PYWORKFLOW_DISCOVER"] = "myapp.workflows"
149
+ discover_workflows()
150
+
151
+ # From pyworkflow.config.yaml in cwd
152
+ discover_workflows()
153
+ """
154
+ # Ensure project root is in Python path for module imports
155
+ _ensure_project_in_path()
156
+
157
+ # If config_path provided, add its directory to sys.path
158
+ # This allows importing modules relative to the config file location
159
+ if config_path:
160
+ config_dir = Path(config_path).parent.resolve()
161
+ config_dir_str = str(config_dir)
162
+ if config_dir_str not in sys.path:
163
+ sys.path.insert(0, config_dir_str)
164
+ logger.debug(f"Added config directory to path: {config_dir_str}")
165
+
166
+ # Priority 1: Explicit module path
167
+ if module_path:
168
+ logger.debug(f"Discovering from explicit module: {module_path}")
169
+ if not _import_module(module_path):
170
+ raise DiscoveryError(
171
+ f"Cannot import module '{module_path}'. "
172
+ f"Make sure the module exists and is in your Python path."
173
+ )
174
+ return
175
+
176
+ # Priority 2: Environment variable
177
+ env_modules = os.getenv("PYWORKFLOW_DISCOVER", "")
178
+ if env_modules:
179
+ logger.debug(f"Discovering from PYWORKFLOW_DISCOVER: {env_modules}")
180
+ env_module_list = [m.strip() for m in env_modules.split(",") if m.strip()]
181
+ failed = []
182
+ for module in env_module_list:
183
+ if not _import_module(module):
184
+ failed.append(module)
185
+
186
+ if failed:
187
+ raise DiscoveryError(
188
+ f"Cannot import modules from PYWORKFLOW_DISCOVER: {', '.join(failed)}. "
189
+ f"Make sure the modules exist and are in your Python path."
190
+ )
191
+ return
192
+
193
+ # Priority 3: Config dict (passed from configure_from_yaml)
194
+ if config:
195
+ modules: list[str] = []
196
+
197
+ if "module" in config:
198
+ modules.append(config["module"])
199
+ if "modules" in config:
200
+ modules.extend(config["modules"])
201
+
202
+ if modules:
203
+ logger.debug(f"Discovering from config: {modules}")
204
+ failed = []
205
+ for module in modules:
206
+ if not _import_module(module):
207
+ failed.append(module)
208
+
209
+ if failed:
210
+ project_root = _find_project_root()
211
+ raise DiscoveryError(
212
+ f"Cannot import modules from config: {', '.join(failed)}. "
213
+ f"Make sure the modules exist and are in your Python path.\n"
214
+ f" Current directory: {Path.cwd()}\n"
215
+ f" Project root: {project_root or 'not found'}"
216
+ )
217
+ return
218
+
219
+ # Priority 4: pyworkflow.config.yaml in cwd
220
+ yaml_config = _load_yaml_config()
221
+ if yaml_config:
222
+ # Support both 'module' (single) and 'modules' (list)
223
+ modules = []
224
+
225
+ if "module" in yaml_config:
226
+ modules.append(yaml_config["module"])
227
+ if "modules" in yaml_config:
228
+ modules.extend(yaml_config["modules"])
229
+
230
+ if modules:
231
+ logger.debug(f"Discovering from pyworkflow.config.yaml: {modules}")
232
+ failed = []
233
+ for module in modules:
234
+ if not _import_module(module):
235
+ failed.append(module)
236
+
237
+ if failed:
238
+ project_root = _find_project_root()
239
+ raise DiscoveryError(
240
+ f"Cannot import modules from pyworkflow.config.yaml: {', '.join(failed)}. "
241
+ f"Make sure the modules exist and are in your Python path.\n"
242
+ f" Current directory: {Path.cwd()}\n"
243
+ f" Project root: {project_root or 'not found'}"
244
+ )
245
+ return
246
+
247
+ # No discovery source found
248
+ logger.debug("No workflow module specified for discovery")
File without changes