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,1257 @@
1
+ """Workflow management commands."""
2
+
3
+ import asyncio
4
+ import inspect
5
+ import json
6
+ import sys
7
+ import threading
8
+ import time
9
+ from datetime import datetime
10
+ from typing import Any, get_type_hints
11
+
12
+ import click
13
+ from InquirerPy import inquirer
14
+
15
+ import pyworkflow
16
+ from pyworkflow import RunStatus
17
+ from pyworkflow.cli.output.formatters import (
18
+ clear_line,
19
+ format_event_type,
20
+ format_json,
21
+ format_key_value,
22
+ format_plain,
23
+ format_table,
24
+ hide_cursor,
25
+ move_cursor_up,
26
+ print_breadcrumb,
27
+ print_error,
28
+ print_info,
29
+ print_success,
30
+ print_warning,
31
+ show_cursor,
32
+ )
33
+ from pyworkflow.cli.output.styles import (
34
+ DIM,
35
+ PYWORKFLOW_STYLE,
36
+ RESET,
37
+ SPINNER_FRAMES,
38
+ SYMBOLS,
39
+ Colors,
40
+ )
41
+ from pyworkflow.cli.utils.async_helpers import async_command
42
+ from pyworkflow.cli.utils.discovery import discover_workflows
43
+ from pyworkflow.cli.utils.storage import create_storage
44
+
45
+
46
+ def _build_workflow_choices(workflows_dict: dict[str, Any]) -> list[dict[str, str]]:
47
+ """Build choices list for workflow selection."""
48
+ choices = []
49
+ for name, meta in workflows_dict.items():
50
+ description = ""
51
+ if meta.original_func.__doc__:
52
+ # Get first line of docstring
53
+ description = meta.original_func.__doc__.strip().split("\n")[0][:50]
54
+
55
+ display_name = f"{name} - {description}" if description else name
56
+
57
+ choices.append({"name": display_name, "value": name})
58
+ return choices
59
+
60
+
61
+ def _select_workflow(workflows_dict: dict[str, Any]) -> str | None:
62
+ """
63
+ Display an interactive workflow selection menu using InquirerPy (sync version).
64
+
65
+ Args:
66
+ workflows_dict: Dictionary of workflow name -> WorkflowMetadata
67
+
68
+ Returns:
69
+ Selected workflow name or None if cancelled
70
+ """
71
+ if not workflows_dict:
72
+ print_error("No workflows registered")
73
+ return None
74
+
75
+ choices = _build_workflow_choices(workflows_dict)
76
+
77
+ try:
78
+ result = inquirer.select(
79
+ message="Select workflow",
80
+ choices=choices,
81
+ style=PYWORKFLOW_STYLE,
82
+ pointer=SYMBOLS["pointer"],
83
+ qmark="?",
84
+ amark=SYMBOLS["success"],
85
+ ).execute()
86
+ return result
87
+ except KeyboardInterrupt:
88
+ print(f"\n{DIM}Cancelled{RESET}")
89
+ return None
90
+
91
+
92
+ async def _select_workflow_async(workflows_dict: dict[str, Any]) -> str | None:
93
+ """
94
+ Display an interactive workflow selection menu using InquirerPy (async version).
95
+
96
+ Args:
97
+ workflows_dict: Dictionary of workflow name -> WorkflowMetadata
98
+
99
+ Returns:
100
+ Selected workflow name or None if cancelled
101
+ """
102
+ if not workflows_dict:
103
+ print_error("No workflows registered")
104
+ return None
105
+
106
+ choices = _build_workflow_choices(workflows_dict)
107
+
108
+ try:
109
+ result = await inquirer.select( # type: ignore[func-returns-value]
110
+ message="Select workflow",
111
+ choices=choices,
112
+ style=PYWORKFLOW_STYLE,
113
+ pointer=SYMBOLS["pointer"],
114
+ qmark="?",
115
+ amark=SYMBOLS["success"],
116
+ ).execute_async()
117
+ return result
118
+ except KeyboardInterrupt:
119
+ print(f"\n{DIM}Cancelled{RESET}")
120
+ return None
121
+
122
+
123
+ def _get_workflow_parameters(func: Any) -> list[dict[str, Any]]:
124
+ """
125
+ Extract parameter information from a workflow function.
126
+
127
+ Args:
128
+ func: The workflow function to inspect
129
+
130
+ Returns:
131
+ List of parameter dicts with name, type, default, and required info
132
+ """
133
+ sig = inspect.signature(func)
134
+ params = []
135
+
136
+ # Try to get type hints
137
+ try:
138
+ hints = get_type_hints(func)
139
+ except Exception:
140
+ hints = {}
141
+
142
+ for param_name, param in sig.parameters.items():
143
+ # Skip *args and **kwargs
144
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
145
+ continue
146
+
147
+ param_info = {
148
+ "name": param_name,
149
+ "type": hints.get(param_name, Any),
150
+ "has_default": param.default is not inspect.Parameter.empty,
151
+ "default": param.default if param.default is not inspect.Parameter.empty else None,
152
+ "required": param.default is inspect.Parameter.empty,
153
+ }
154
+ params.append(param_info)
155
+
156
+ return params
157
+
158
+
159
+ def _get_type_name(type_hint: Any) -> str:
160
+ """Get a human-readable name for a type hint."""
161
+ if type_hint is Any:
162
+ return "any"
163
+ if hasattr(type_hint, "__name__"):
164
+ return type_hint.__name__
165
+ return str(type_hint)
166
+
167
+
168
+ def _parse_value(value_str: str, type_hint: Any) -> Any:
169
+ """
170
+ Parse a string value to the appropriate type.
171
+
172
+ Args:
173
+ value_str: The string value from user input
174
+ type_hint: The expected type
175
+
176
+ Returns:
177
+ Parsed value
178
+ """
179
+ if not value_str:
180
+ return None
181
+
182
+ # Handle common types
183
+ if type_hint is bool or (hasattr(type_hint, "__name__") and type_hint.__name__ == "bool"):
184
+ return value_str.lower() in ("true", "1", "yes", "y")
185
+
186
+ if type_hint is int or (hasattr(type_hint, "__name__") and type_hint.__name__ == "int"):
187
+ return int(value_str)
188
+
189
+ if type_hint is float or (hasattr(type_hint, "__name__") and type_hint.__name__ == "float"):
190
+ return float(value_str)
191
+
192
+ # Try JSON parsing for complex types (lists, dicts, etc.)
193
+ try:
194
+ return json.loads(value_str)
195
+ except json.JSONDecodeError:
196
+ # Return as string
197
+ return value_str
198
+
199
+
200
+ def _prompt_for_arguments(params: list[dict[str, Any]]) -> dict[str, Any]:
201
+ """
202
+ Interactively prompt user for workflow argument values using InquirerPy (sync).
203
+
204
+ Args:
205
+ params: List of parameter info dicts
206
+
207
+ Returns:
208
+ Dictionary of argument name -> value
209
+ """
210
+ if not params:
211
+ return {}
212
+
213
+ kwargs = {}
214
+
215
+ for param in params:
216
+ name = param["name"]
217
+ type_hint = param["type"]
218
+ has_default = param["has_default"]
219
+ default = param["default"]
220
+ required = param["required"]
221
+
222
+ type_name = _get_type_name(type_hint)
223
+
224
+ # Build instruction text
225
+ if required:
226
+ instruction = f"({type_name})"
227
+ else:
228
+ default_display = repr(default) if default is not None else "None"
229
+ instruction = f"({type_name}, default={default_display})"
230
+
231
+ try:
232
+ # Handle boolean type with confirm prompt
233
+ if type_hint is bool or (
234
+ hasattr(type_hint, "__name__") and type_hint.__name__ == "bool"
235
+ ):
236
+ default_val = default if has_default else False
237
+ value = inquirer.confirm(
238
+ message=name,
239
+ default=default_val,
240
+ style=PYWORKFLOW_STYLE,
241
+ qmark="?",
242
+ amark=SYMBOLS["success"],
243
+ instruction=instruction,
244
+ ).execute()
245
+ kwargs[name] = value
246
+
247
+ # Handle int type with number prompt
248
+ elif type_hint is int or (
249
+ hasattr(type_hint, "__name__") and type_hint.__name__ == "int"
250
+ ):
251
+ # InquirerPy number prompt needs a valid number or None, not empty string
252
+ default_val = default if has_default and default is not None else None
253
+ value_str = inquirer.number(
254
+ message=name,
255
+ default=default_val,
256
+ style=PYWORKFLOW_STYLE,
257
+ qmark="?",
258
+ amark=SYMBOLS["success"],
259
+ instruction=instruction,
260
+ float_allowed=False,
261
+ ).execute()
262
+ if value_str is not None:
263
+ kwargs[name] = int(value_str)
264
+ elif has_default:
265
+ kwargs[name] = default
266
+
267
+ # Handle float type with number prompt
268
+ elif type_hint is float or (
269
+ hasattr(type_hint, "__name__") and type_hint.__name__ == "float"
270
+ ):
271
+ # InquirerPy number prompt needs a valid number or None, not empty string
272
+ default_val = default if has_default and default is not None else None
273
+ value_str = inquirer.number(
274
+ message=name,
275
+ default=default_val,
276
+ style=PYWORKFLOW_STYLE,
277
+ qmark="?",
278
+ amark=SYMBOLS["success"],
279
+ instruction=instruction,
280
+ float_allowed=True,
281
+ ).execute()
282
+ if value_str is not None:
283
+ kwargs[name] = float(value_str)
284
+ elif has_default:
285
+ kwargs[name] = default
286
+
287
+ # Handle string/other types with text prompt
288
+ else:
289
+ if has_default and default is not None:
290
+ default_str = json.dumps(default) if not isinstance(default, str) else default
291
+ else:
292
+ default_str = ""
293
+
294
+ value_str = inquirer.text(
295
+ message=name,
296
+ default=default_str,
297
+ style=PYWORKFLOW_STYLE,
298
+ qmark="?",
299
+ amark=SYMBOLS["success"],
300
+ instruction=instruction,
301
+ mandatory=required,
302
+ ).execute()
303
+
304
+ if value_str == "" and has_default:
305
+ kwargs[name] = default
306
+ elif value_str == "" and not required:
307
+ # Skip optional params with no input
308
+ continue
309
+ elif value_str is not None:
310
+ kwargs[name] = _parse_value(value_str, type_hint)
311
+
312
+ except KeyboardInterrupt:
313
+ print(f"\n{DIM}Cancelled{RESET}")
314
+ raise click.Abort()
315
+
316
+ return kwargs
317
+
318
+
319
+ async def _prompt_for_arguments_async(params: list[dict[str, Any]]) -> dict[str, Any]:
320
+ """
321
+ Interactively prompt user for workflow argument values using InquirerPy (async).
322
+
323
+ Args:
324
+ params: List of parameter info dicts
325
+
326
+ Returns:
327
+ Dictionary of argument name -> value
328
+ """
329
+ if not params:
330
+ return {}
331
+
332
+ kwargs: dict[str, Any] = {}
333
+
334
+ for param in params:
335
+ name = param["name"]
336
+ type_hint = param["type"]
337
+ has_default = param["has_default"]
338
+ default = param["default"]
339
+ required = param["required"]
340
+
341
+ type_name = _get_type_name(type_hint)
342
+
343
+ # Build instruction text
344
+ if required:
345
+ instruction = f"({type_name})"
346
+ else:
347
+ default_display = repr(default) if default is not None else "None"
348
+ instruction = f"({type_name}, default={default_display})"
349
+
350
+ try:
351
+ # Handle boolean type with confirm prompt
352
+ if type_hint is bool or (
353
+ hasattr(type_hint, "__name__") and type_hint.__name__ == "bool"
354
+ ):
355
+ default_val = default if has_default else False
356
+ value = await inquirer.confirm( # type: ignore[func-returns-value]
357
+ message=name,
358
+ default=default_val,
359
+ style=PYWORKFLOW_STYLE,
360
+ qmark="?",
361
+ amark=SYMBOLS["success"],
362
+ instruction=instruction,
363
+ ).execute_async()
364
+ kwargs[name] = value
365
+
366
+ # Handle int type with number prompt
367
+ elif type_hint is int or (
368
+ hasattr(type_hint, "__name__") and type_hint.__name__ == "int"
369
+ ):
370
+ # InquirerPy number prompt needs a valid number or None, not empty string
371
+ default_val = default if has_default and default is not None else None
372
+ value_str = await inquirer.number( # type: ignore[func-returns-value]
373
+ message=name,
374
+ default=default_val,
375
+ style=PYWORKFLOW_STYLE,
376
+ qmark="?",
377
+ amark=SYMBOLS["success"],
378
+ instruction=instruction,
379
+ float_allowed=False,
380
+ ).execute_async()
381
+ if value_str is not None:
382
+ kwargs[name] = int(value_str)
383
+ elif has_default:
384
+ kwargs[name] = default
385
+
386
+ # Handle float type with number prompt
387
+ elif type_hint is float or (
388
+ hasattr(type_hint, "__name__") and type_hint.__name__ == "float"
389
+ ):
390
+ # InquirerPy number prompt needs a valid number or None, not empty string
391
+ default_val = default if has_default and default is not None else None
392
+ value_str = await inquirer.number( # type: ignore[func-returns-value]
393
+ message=name,
394
+ default=default_val,
395
+ style=PYWORKFLOW_STYLE,
396
+ qmark="?",
397
+ amark=SYMBOLS["success"],
398
+ instruction=instruction,
399
+ float_allowed=True,
400
+ ).execute_async()
401
+ if value_str is not None:
402
+ kwargs[name] = float(value_str)
403
+ elif has_default:
404
+ kwargs[name] = default
405
+
406
+ # Handle string/other types with text prompt
407
+ else:
408
+ if has_default and default is not None:
409
+ default_str = json.dumps(default) if not isinstance(default, str) else default
410
+ else:
411
+ default_str = ""
412
+
413
+ value_str = await inquirer.text( # type: ignore[func-returns-value]
414
+ message=name,
415
+ default=default_str,
416
+ style=PYWORKFLOW_STYLE,
417
+ qmark="?",
418
+ amark=SYMBOLS["success"],
419
+ instruction=instruction,
420
+ mandatory=required,
421
+ ).execute_async()
422
+
423
+ if value_str == "" and has_default:
424
+ kwargs[name] = default
425
+ elif value_str == "" and not required:
426
+ # Skip optional params with no input
427
+ continue
428
+ elif value_str is not None:
429
+ kwargs[name] = _parse_value(value_str, type_hint)
430
+
431
+ except KeyboardInterrupt:
432
+ print(f"\n{DIM}Cancelled{RESET}")
433
+ raise click.Abort()
434
+
435
+ return kwargs
436
+
437
+
438
+ class SpinnerDisplay:
439
+ """ANSI-based spinner display for watch mode."""
440
+
441
+ def __init__(self, message: str = "Running", detail_mode: bool = False):
442
+ self.message = message
443
+ self.running = False
444
+ self.frame_index = 0
445
+ self.thread: threading.Thread | None = None
446
+ self.events: list[Any] = []
447
+ self.status: RunStatus = RunStatus.RUNNING
448
+ self.elapsed: float = 0.0
449
+ self.lines_printed = 0
450
+ self.detail_mode = detail_mode
451
+ self._lock = threading.Lock()
452
+ self._setup_keyboard_listener()
453
+
454
+ def _setup_keyboard_listener(self) -> None:
455
+ """Setup keyboard listener for Ctrl+O toggle."""
456
+ self._original_settings = None
457
+ self._stdin_fd = None
458
+ self._keyboard_active = False
459
+
460
+ try:
461
+ import termios
462
+
463
+ self._stdin_fd = sys.stdin.fileno()
464
+ self._original_settings = termios.tcgetattr(self._stdin_fd)
465
+
466
+ # Set up non-canonical mode with echo disabled
467
+ new_settings = termios.tcgetattr(self._stdin_fd)
468
+ # Disable canonical mode (ICANON) and echo (ECHO)
469
+ new_settings[3] = new_settings[3] & ~(termios.ICANON | termios.ECHO)
470
+ # Set minimum characters to read to 0 (non-blocking)
471
+ new_settings[6][termios.VMIN] = 0
472
+ new_settings[6][termios.VTIME] = 0
473
+ termios.tcsetattr(self._stdin_fd, termios.TCSANOW, new_settings)
474
+ self._keyboard_active = True
475
+ except Exception:
476
+ # Not a terminal or termios not available
477
+ pass
478
+
479
+ def _check_keyboard(self) -> None:
480
+ """Check for keyboard input (Ctrl+O)."""
481
+ if not self._keyboard_active:
482
+ return
483
+
484
+ try:
485
+ import select
486
+
487
+ # Check if input is available (non-blocking)
488
+ if select.select([sys.stdin], [], [], 0)[0]:
489
+ char = sys.stdin.read(1)
490
+ if char:
491
+ # Ctrl+O is ASCII 15
492
+ if ord(char) == 15:
493
+ with self._lock:
494
+ self.detail_mode = not self.detail_mode
495
+ except Exception:
496
+ pass
497
+
498
+ def _restore_terminal(self) -> None:
499
+ """Restore original terminal settings."""
500
+ if self._original_settings and self._stdin_fd is not None:
501
+ try:
502
+ import termios
503
+
504
+ termios.tcsetattr(self._stdin_fd, termios.TCSADRAIN, self._original_settings)
505
+ except Exception:
506
+ pass
507
+ self._keyboard_active = False
508
+
509
+ def _format_step_call(self, event_data: dict[str, Any]) -> str:
510
+ """Format step call with arguments like: step_name(arg1, arg2, ...)"""
511
+ step_name = event_data.get("step_name", "unknown")
512
+
513
+ # Collect arguments
514
+ args_parts = []
515
+
516
+ # Check for args (positional)
517
+ if "args" in event_data and event_data["args"]:
518
+ args = event_data["args"]
519
+ if isinstance(args, list | tuple):
520
+ for arg in args:
521
+ arg_str = self._format_value_short(arg)
522
+ args_parts.append(arg_str)
523
+ else:
524
+ args_parts.append(self._format_value_short(args))
525
+
526
+ # Check for kwargs
527
+ if "kwargs" in event_data and event_data["kwargs"]:
528
+ kwargs = event_data["kwargs"]
529
+ if isinstance(kwargs, dict):
530
+ for k, v in kwargs.items():
531
+ val_str = self._format_value_short(v)
532
+ args_parts.append(f"{k}={val_str}")
533
+
534
+ # Build the call signature (show all args, values are already shortened)
535
+ if args_parts:
536
+ args_str = ", ".join(args_parts)
537
+ return f"{step_name}({args_str})"
538
+ else:
539
+ return f"{step_name}()"
540
+
541
+ def _format_value_short(self, value: Any) -> str:
542
+ """Format a value for short display."""
543
+ if value is None:
544
+ return "None"
545
+ if isinstance(value, str):
546
+ if len(value) > 15:
547
+ return f'"{value[:12]}..."'
548
+ return f'"{value}"'
549
+ if isinstance(value, int | float | bool):
550
+ return str(value)
551
+ if isinstance(value, dict):
552
+ return "{...}"
553
+ if isinstance(value, list | tuple):
554
+ return "[...]"
555
+ return str(value)[:15]
556
+
557
+ def _format_value_full(self, value: Any) -> str:
558
+ """Format a value for full display (no truncation)."""
559
+ if value is None:
560
+ return "None"
561
+ if isinstance(value, str):
562
+ return f'"{value}"'
563
+ if isinstance(value, int | float | bool):
564
+ return str(value)
565
+ if isinstance(value, dict | list | tuple):
566
+ return json.dumps(value, default=str)
567
+ return str(value)
568
+
569
+ def _format_step_call_full(self, event_data: dict[str, Any]) -> str:
570
+ """Format step call with full arguments (no truncation)."""
571
+ step_name = event_data.get("step_name", "unknown")
572
+
573
+ # Collect arguments
574
+ args_parts = []
575
+
576
+ # Check for args (positional)
577
+ if "args" in event_data and event_data["args"]:
578
+ args = event_data["args"]
579
+ if isinstance(args, list | tuple):
580
+ for arg in args:
581
+ arg_str = self._format_value_full(arg)
582
+ args_parts.append(arg_str)
583
+ else:
584
+ args_parts.append(self._format_value_full(args))
585
+
586
+ # Check for kwargs
587
+ if "kwargs" in event_data and event_data["kwargs"]:
588
+ kwargs = event_data["kwargs"]
589
+ if isinstance(kwargs, dict):
590
+ for k, v in kwargs.items():
591
+ val_str = self._format_value_full(v)
592
+ args_parts.append(f"{k}={val_str}")
593
+
594
+ # Build the call signature (no truncation)
595
+ if args_parts:
596
+ args_str = ", ".join(args_parts)
597
+ return f"{step_name}({args_str})"
598
+ else:
599
+ return f"{step_name}()"
600
+
601
+ def _format_event_detail(self, event: Any) -> list[str]:
602
+ """Format event with full details for detail mode (no truncation)."""
603
+ lines = []
604
+ time_str = event.timestamp.strftime("%H:%M:%S.%f")[:-3] if event.timestamp else "--:--:--"
605
+ event_type = format_event_type(event.type.value)
606
+
607
+ # Main event line
608
+ lines.append(f" {DIM}{time_str}{RESET} {event_type}")
609
+
610
+ if event.data:
611
+ # Show step call signature for step events (full arguments)
612
+ if "step_name" in event.data:
613
+ step_call = self._format_step_call_full(event.data)
614
+ lines.append(f" {Colors.CYAN}{step_call}{RESET}")
615
+
616
+ # Show result if present (for completed events) - no truncation
617
+ if "result" in event.data:
618
+ result = event.data["result"]
619
+ result_str = (
620
+ json.dumps(result, default=str, indent=2)
621
+ if not isinstance(result, str)
622
+ else result
623
+ )
624
+ # Handle multi-line results with proper indentation
625
+ result_lines = result_str.split("\n")
626
+ if len(result_lines) == 1:
627
+ lines.append(f" {Colors.GREEN}result:{RESET} {result_str}")
628
+ else:
629
+ lines.append(f" {Colors.GREEN}result:{RESET}")
630
+ for rline in result_lines:
631
+ lines.append(f" {rline}")
632
+
633
+ # Show error if present - no truncation
634
+ if "error" in event.data:
635
+ error_str = str(event.data["error"])
636
+ error_lines = error_str.split("\n")
637
+ if len(error_lines) == 1:
638
+ lines.append(f" {Colors.RED}error:{RESET} {error_str}")
639
+ else:
640
+ lines.append(f" {Colors.RED}error:{RESET}")
641
+ for eline in error_lines:
642
+ lines.append(f" {eline}")
643
+
644
+ # Show other relevant fields - no truncation
645
+ for key, value in event.data.items():
646
+ if key in ("step_name", "result", "error", "args", "kwargs"):
647
+ continue # Already shown or handled
648
+ value_str = str(value)
649
+ lines.append(f" {DIM}{key}:{RESET} {value_str}")
650
+
651
+ return lines
652
+
653
+ def _format_event_compact(self, event: Any) -> list[str]:
654
+ """Format event in compact mode."""
655
+ lines = []
656
+ time_str = event.timestamp.strftime("%H:%M:%S") if event.timestamp else "--:--:--"
657
+ event_type = format_event_type(event.type.value)
658
+
659
+ # Main event line
660
+ lines.append(f" {DIM}{time_str}{RESET} {event_type}")
661
+
662
+ # Show step call signature for step events
663
+ if event.data and "step_name" in event.data:
664
+ step_call = self._format_step_call(event.data)
665
+ lines.append(f" {Colors.CYAN}{step_call}{RESET}")
666
+
667
+ return lines
668
+
669
+ def _get_terminal_height(self) -> int:
670
+ """Get terminal height."""
671
+ try:
672
+ import shutil
673
+
674
+ return shutil.get_terminal_size().lines
675
+ except Exception:
676
+ return 24 # Default
677
+
678
+ def _render(self) -> None:
679
+ """Render current state."""
680
+ with self._lock:
681
+ # Clear previous output
682
+ if self.lines_printed > 0:
683
+ for _ in range(self.lines_printed):
684
+ move_cursor_up(1)
685
+ clear_line()
686
+
687
+ lines = []
688
+
689
+ # Spinner line with status
690
+ frame = SPINNER_FRAMES[self.frame_index]
691
+ status_color = (
692
+ Colors.BLUE
693
+ if self.status == RunStatus.RUNNING
694
+ else (
695
+ Colors.GREEN
696
+ if self.status == RunStatus.COMPLETED
697
+ else (Colors.RED if self.status == RunStatus.FAILED else Colors.YELLOW)
698
+ )
699
+ )
700
+ elapsed_str = f"{self.elapsed:.1f}s"
701
+ event_count = len(self.events)
702
+ mode_indicator = f" {Colors.PRIMARY}[DETAIL]{RESET}" if self.detail_mode else ""
703
+ lines.append(
704
+ f"{status_color}{frame}{RESET} {self.message} ({elapsed_str}) {DIM}[{event_count} events]{RESET}{mode_indicator}"
705
+ )
706
+ lines.append("")
707
+
708
+ # Events section - show ALL events
709
+ if self.events:
710
+ lines.append(f"{DIM}Events:{RESET}")
711
+
712
+ # Calculate available lines for events
713
+ terminal_height = self._get_terminal_height()
714
+ header_lines = 4 # spinner + blank + "Events:" + footer
715
+ max_event_lines = max(terminal_height - header_lines - 2, 10)
716
+
717
+ # Format all events
718
+ all_event_lines = []
719
+ for event in self.events:
720
+ if self.detail_mode:
721
+ all_event_lines.extend(self._format_event_detail(event))
722
+ else:
723
+ all_event_lines.extend(self._format_event_compact(event))
724
+
725
+ # If too many lines, show the most recent ones
726
+ if len(all_event_lines) > max_event_lines:
727
+ # Show indicator that there are more events
728
+ hidden_count = len(all_event_lines) - max_event_lines + 1
729
+ lines.append(f" {DIM}... ({hidden_count} earlier lines){RESET}")
730
+ all_event_lines = all_event_lines[-max_event_lines + 1 :]
731
+
732
+ lines.extend(all_event_lines)
733
+ lines.append("")
734
+
735
+ # Footer with keyboard hints
736
+ lines.append(f"{DIM}Ctrl+O: toggle details | Ctrl+C: stop watching{RESET}")
737
+
738
+ # Print all lines
739
+ for line in lines:
740
+ print(line)
741
+ self.lines_printed = len(lines)
742
+
743
+ # Advance spinner
744
+ self.frame_index = (self.frame_index + 1) % len(SPINNER_FRAMES)
745
+
746
+ def _spin(self) -> None:
747
+ """Background thread for spinner animation."""
748
+ while self.running:
749
+ self._check_keyboard()
750
+ self._render()
751
+ time.sleep(0.1)
752
+
753
+ def start(self) -> None:
754
+ """Start the spinner."""
755
+ hide_cursor()
756
+ self.running = True
757
+ self.thread = threading.Thread(target=self._spin, daemon=True)
758
+ self.thread.start()
759
+
760
+ def stop(self) -> None:
761
+ """Stop the spinner."""
762
+ self.running = False
763
+ if self.thread:
764
+ self.thread.join(timeout=0.5)
765
+
766
+ # Restore terminal settings
767
+ self._restore_terminal()
768
+
769
+ show_cursor()
770
+ # Final render
771
+ self._render()
772
+
773
+ def update(
774
+ self,
775
+ events: list[Any] | None = None,
776
+ status: RunStatus | None = None,
777
+ elapsed: float | None = None,
778
+ ) -> None:
779
+ """Update spinner state."""
780
+ with self._lock:
781
+ if events is not None:
782
+ self.events = events
783
+ if status is not None:
784
+ self.status = status
785
+ if elapsed is not None:
786
+ self.elapsed = elapsed
787
+
788
+
789
+ async def _watch_workflow(
790
+ run_id: str,
791
+ workflow_name: str,
792
+ storage: Any,
793
+ poll_interval: float = 0.5,
794
+ max_wait_for_start: float = 30.0,
795
+ debug: bool = False,
796
+ ) -> RunStatus:
797
+ """
798
+ Watch a workflow execution with ANSI spinner display.
799
+
800
+ Args:
801
+ run_id: Workflow run ID
802
+ workflow_name: Name of the workflow
803
+ storage: Storage backend
804
+ poll_interval: Seconds between polls
805
+ max_wait_for_start: Max seconds to wait for run to be created
806
+ debug: Start in detail mode (show args, results, errors)
807
+
808
+ Returns:
809
+ Final workflow status
810
+ """
811
+ start_time = datetime.now()
812
+ seen_event_ids: set[str] = set()
813
+ all_events: list[Any] = []
814
+
815
+ terminal_statuses = {
816
+ RunStatus.COMPLETED,
817
+ RunStatus.FAILED,
818
+ RunStatus.CANCELLED,
819
+ }
820
+
821
+ # Wait for the run to be created (with Celery, the worker creates it)
822
+ run = None
823
+ wait_start = datetime.now()
824
+ while run is None:
825
+ run = await pyworkflow.get_workflow_run(run_id, storage=storage)
826
+ if run is None:
827
+ elapsed = (datetime.now() - wait_start).total_seconds()
828
+ if elapsed > max_wait_for_start:
829
+ print_error("Timeout waiting for workflow run to be created")
830
+ print_info("Make sure Celery workers are running: pyworkflow worker run")
831
+ return RunStatus.FAILED
832
+ # Show waiting message
833
+ print(f"{DIM}Waiting for worker to start workflow... ({elapsed:.0f}s){RESET}", end="\r")
834
+ await asyncio.sleep(poll_interval)
835
+
836
+ # Clear waiting line
837
+ clear_line()
838
+
839
+ # Create spinner display
840
+ spinner = SpinnerDisplay(message=f"Running workflow: {workflow_name}", detail_mode=debug)
841
+ spinner.start()
842
+
843
+ try:
844
+ while True:
845
+ try:
846
+ # Fetch current status
847
+ run = await pyworkflow.get_workflow_run(run_id, storage=storage)
848
+ if not run:
849
+ spinner.stop()
850
+ print_error(f"Workflow run '{run_id}' not found")
851
+ return RunStatus.FAILED
852
+
853
+ status = run.status
854
+
855
+ # Fetch events
856
+ events = await pyworkflow.get_workflow_events(run_id, storage=storage)
857
+
858
+ # Track new events
859
+ for event in events:
860
+ if event.event_id not in seen_event_ids:
861
+ seen_event_ids.add(event.event_id)
862
+ all_events.append(event)
863
+
864
+ # Sort events by sequence
865
+ all_events.sort(key=lambda e: e.sequence or 0)
866
+
867
+ # Calculate elapsed time
868
+ elapsed = (datetime.now() - start_time).total_seconds()
869
+
870
+ # Update spinner
871
+ spinner.update(events=all_events, status=status, elapsed=elapsed)
872
+
873
+ # Check if workflow is done
874
+ if status in terminal_statuses:
875
+ await asyncio.sleep(0.3) # Brief pause for final update
876
+ spinner.stop()
877
+ return status
878
+
879
+ # Wait before next poll
880
+ await asyncio.sleep(poll_interval)
881
+
882
+ except KeyboardInterrupt:
883
+ spinner.stop()
884
+ print(f"\n{DIM}Watch interrupted{RESET}")
885
+ return RunStatus.RUNNING
886
+
887
+ except Exception as e:
888
+ spinner.stop()
889
+ print(f"\n{Colors.RED}Error watching workflow: {e}{RESET}")
890
+ return RunStatus.FAILED
891
+
892
+
893
+ @click.group(name="workflows")
894
+ def workflows() -> None:
895
+ """Manage workflows (list, info, run)."""
896
+ pass
897
+
898
+
899
+ @workflows.command(name="list")
900
+ @click.pass_context
901
+ def list_workflows_cmd(ctx: click.Context) -> None:
902
+ """
903
+ List all registered workflows.
904
+
905
+ Examples:
906
+
907
+ # List workflows from a specific module
908
+ pyworkflow --module myapp.workflows workflows list
909
+
910
+ # List workflows with JSON output
911
+ pyworkflow --module myapp.workflows --output json workflows list
912
+ """
913
+ # Get context data
914
+ module = ctx.obj["module"]
915
+ config = ctx.obj["config"]
916
+ output = ctx.obj["output"]
917
+
918
+ # Discover workflows
919
+ discover_workflows(module, config)
920
+
921
+ # Get registered workflows
922
+ workflows_dict = pyworkflow.list_workflows()
923
+
924
+ if not workflows_dict:
925
+ print_info("No workflows registered")
926
+ return
927
+
928
+ # Format output
929
+ if output == "json":
930
+ data = [
931
+ {
932
+ "name": name,
933
+ "max_duration": meta.max_duration or "None",
934
+ "tags": meta.tags or [],
935
+ }
936
+ for name, meta in workflows_dict.items()
937
+ ]
938
+ format_json(data)
939
+
940
+ elif output == "plain":
941
+ names = list(workflows_dict.keys())
942
+ format_plain(names)
943
+
944
+ else: # table (now displays as list)
945
+ data = [
946
+ {
947
+ "Name": name,
948
+ "Max Duration": meta.max_duration or "-",
949
+ "Tags": ", ".join(meta.tags) if meta.tags else "-",
950
+ }
951
+ for name, meta in workflows_dict.items()
952
+ ]
953
+ format_table(data, ["Name", "Max Duration", "Tags"], title="Registered Workflows")
954
+
955
+
956
+ @workflows.command(name="info")
957
+ @click.argument("workflow_name")
958
+ @click.pass_context
959
+ def workflow_info(ctx: click.Context, workflow_name: str) -> None:
960
+ """
961
+ Show detailed information about a workflow.
962
+
963
+ Args:
964
+ WORKFLOW_NAME: Name of the workflow to inspect
965
+
966
+ Examples:
967
+
968
+ pyworkflow --module myapp.workflows workflows info my_workflow
969
+ """
970
+ # Get context data
971
+ module = ctx.obj["module"]
972
+ config = ctx.obj["config"]
973
+ output = ctx.obj["output"]
974
+
975
+ # Discover workflows
976
+ discover_workflows(module, config)
977
+
978
+ # Get workflow metadata
979
+ workflow_meta = pyworkflow.get_workflow(workflow_name)
980
+
981
+ if not workflow_meta:
982
+ print_error(f"Workflow '{workflow_name}' not found")
983
+ raise click.Abort()
984
+
985
+ # Format output
986
+ if output == "json":
987
+ data = {
988
+ "name": workflow_meta.name,
989
+ "max_duration": workflow_meta.max_duration,
990
+ "tags": workflow_meta.tags or [],
991
+ "function": {
992
+ "name": workflow_meta.original_func.__name__,
993
+ "module": workflow_meta.original_func.__module__,
994
+ "doc": workflow_meta.original_func.__doc__,
995
+ },
996
+ }
997
+ format_json(data)
998
+
999
+ else: # table or plain (use key-value format)
1000
+ data = {
1001
+ "Name": workflow_meta.name,
1002
+ "Max Duration": workflow_meta.max_duration or "None",
1003
+ "Function": workflow_meta.original_func.__name__,
1004
+ "Module": workflow_meta.original_func.__module__,
1005
+ "Tags": ", ".join(workflow_meta.tags) if workflow_meta.tags else "-",
1006
+ }
1007
+
1008
+ if workflow_meta.original_func.__doc__:
1009
+ data["Description"] = workflow_meta.original_func.__doc__.strip()
1010
+
1011
+ format_key_value(data, title=f"Workflow: {workflow_name}")
1012
+
1013
+
1014
+ @workflows.command(name="run")
1015
+ @click.argument("workflow_name", required=False)
1016
+ @click.option(
1017
+ "--arg",
1018
+ multiple=True,
1019
+ help="Workflow argument in key=value format (can be repeated)",
1020
+ )
1021
+ @click.option(
1022
+ "--args-json",
1023
+ help="Workflow arguments as JSON string",
1024
+ )
1025
+ @click.option(
1026
+ "--durable/--no-durable",
1027
+ default=True,
1028
+ help="Run workflow in durable mode (default: durable)",
1029
+ )
1030
+ @click.option(
1031
+ "--idempotency-key",
1032
+ help="Idempotency key for workflow execution",
1033
+ )
1034
+ @click.option(
1035
+ "--no-wait",
1036
+ is_flag=True,
1037
+ default=False,
1038
+ help="Don't wait for workflow completion (just start and exit)",
1039
+ )
1040
+ @click.option(
1041
+ "--debug",
1042
+ is_flag=True,
1043
+ default=False,
1044
+ help="Show detailed event output (args, results, errors)",
1045
+ )
1046
+ @click.pass_context
1047
+ @async_command
1048
+ async def run_workflow(
1049
+ ctx: click.Context,
1050
+ workflow_name: str | None,
1051
+ arg: tuple,
1052
+ args_json: str | None,
1053
+ durable: bool,
1054
+ idempotency_key: str | None,
1055
+ no_wait: bool,
1056
+ debug: bool,
1057
+ ) -> None:
1058
+ """
1059
+ Execute a workflow and watch its progress.
1060
+
1061
+ By default, waits for the workflow to complete, showing real-time events.
1062
+ Use --no-wait to start the workflow and exit immediately.
1063
+
1064
+ When run without arguments, displays an interactive menu to select a workflow
1065
+ and prompts for any required arguments.
1066
+
1067
+ Args:
1068
+ WORKFLOW_NAME: Name of the workflow to run (optional, will prompt if not provided)
1069
+
1070
+ Examples:
1071
+
1072
+ # Interactive mode - select workflow and enter arguments
1073
+ pyworkflow --module myapp.workflows workflows run
1074
+
1075
+ # Run workflow with arguments
1076
+ pyworkflow --module myapp.workflows workflows run my_workflow \\
1077
+ --arg name=John --arg age=30
1078
+
1079
+ # Run workflow with JSON arguments
1080
+ pyworkflow --module myapp.workflows workflows run my_workflow \\
1081
+ --args-json '{"name": "John", "age": 30}'
1082
+
1083
+ # Run transient workflow
1084
+ pyworkflow --module myapp.workflows workflows run my_workflow \\
1085
+ --no-durable
1086
+
1087
+ # Run with idempotency key
1088
+ pyworkflow --module myapp.workflows workflows run my_workflow \\
1089
+ --idempotency-key unique-operation-id
1090
+ """
1091
+ # Get context data
1092
+ module = ctx.obj["module"]
1093
+ config = ctx.obj["config"]
1094
+ output = ctx.obj["output"]
1095
+ runtime_name = ctx.obj.get("runtime", "celery")
1096
+ storage_type = ctx.obj["storage_type"]
1097
+ storage_path = ctx.obj["storage_path"]
1098
+
1099
+ # Discover workflows
1100
+ discover_workflows(module, config)
1101
+
1102
+ # Get registered workflows
1103
+ workflows_dict = pyworkflow.list_workflows()
1104
+
1105
+ # Interactive mode: select workflow if not provided
1106
+ if not workflow_name:
1107
+ if not workflows_dict:
1108
+ print_error("No workflows registered")
1109
+ raise click.Abort()
1110
+
1111
+ # Show breadcrumb for step 1
1112
+ print_breadcrumb(["workflow", "arguments"], 0)
1113
+
1114
+ workflow_name = await _select_workflow_async(workflows_dict)
1115
+ if not workflow_name:
1116
+ raise click.Abort()
1117
+
1118
+ # Get workflow metadata
1119
+ workflow_meta = pyworkflow.get_workflow(workflow_name)
1120
+
1121
+ if not workflow_meta:
1122
+ print_error(f"Workflow '{workflow_name}' not found")
1123
+ raise click.Abort()
1124
+
1125
+ # Parse arguments
1126
+ kwargs = {}
1127
+
1128
+ # Parse --arg flags
1129
+ for arg_pair in arg:
1130
+ if "=" not in arg_pair:
1131
+ print_error(f"Invalid argument format: {arg_pair}. Expected key=value")
1132
+ raise click.Abort()
1133
+
1134
+ key, value = arg_pair.split("=", 1)
1135
+
1136
+ # Try to parse as JSON, fall back to string
1137
+ try:
1138
+ kwargs[key] = json.loads(value)
1139
+ except json.JSONDecodeError:
1140
+ kwargs[key] = value
1141
+
1142
+ # Parse --args-json
1143
+ if args_json:
1144
+ try:
1145
+ json_args = json.loads(args_json)
1146
+ if not isinstance(json_args, dict):
1147
+ print_error("--args-json must be a JSON object")
1148
+ raise click.Abort()
1149
+ kwargs.update(json_args)
1150
+ except json.JSONDecodeError as e:
1151
+ print_error(f"Invalid JSON in --args-json: {e}")
1152
+ raise click.Abort()
1153
+
1154
+ # Interactive mode: prompt for arguments if none provided
1155
+ if not kwargs and not arg and not args_json:
1156
+ params = _get_workflow_parameters(workflow_meta.original_func)
1157
+ if params:
1158
+ # Show breadcrumb for step 2
1159
+ print_breadcrumb(["workflow", "arguments"], 1)
1160
+
1161
+ prompted_kwargs = await _prompt_for_arguments_async(params)
1162
+ kwargs.update(prompted_kwargs)
1163
+ print() # Add spacing after prompts
1164
+
1165
+ # Create storage backend
1166
+ storage = create_storage(storage_type, storage_path, config)
1167
+
1168
+ # Execute workflow
1169
+ print_info(f"Starting workflow: {workflow_name}")
1170
+ print_info(f"Runtime: {runtime_name}")
1171
+ if kwargs:
1172
+ print_info(f"Arguments: {json.dumps(kwargs, indent=2)}")
1173
+
1174
+ # Celery runtime requires durable mode
1175
+ if runtime_name == "celery" and not durable:
1176
+ print_error("Celery runtime requires durable mode. Use --durable or --runtime local")
1177
+ raise click.Abort()
1178
+
1179
+ try:
1180
+ run_id = await pyworkflow.start(
1181
+ workflow_meta.func,
1182
+ **kwargs,
1183
+ runtime=runtime_name,
1184
+ durable=durable,
1185
+ storage=storage,
1186
+ idempotency_key=idempotency_key,
1187
+ )
1188
+
1189
+ # JSON output mode - just output and exit
1190
+ if output == "json":
1191
+ format_json({"run_id": run_id, "workflow_name": workflow_name, "runtime": runtime_name})
1192
+ return
1193
+
1194
+ # No-wait mode - start and exit immediately
1195
+ if no_wait:
1196
+ print_success("Workflow started successfully")
1197
+ print_info(f"Run ID: {run_id}")
1198
+ print_info(f"Runtime: {runtime_name}")
1199
+
1200
+ if durable:
1201
+ print_info(f"\nCheck status with: pyworkflow runs status {run_id}")
1202
+ print_info(f"View logs with: pyworkflow runs logs {run_id}")
1203
+
1204
+ if runtime_name == "celery":
1205
+ print_info("\nNote: Workflow dispatched to Celery workers.")
1206
+ print_info("Ensure workers are running: pyworkflow worker run")
1207
+ return
1208
+
1209
+ # Watch mode (default) - poll and display events until completion
1210
+ print(f"{DIM}Started workflow run: {run_id}{RESET}")
1211
+ print(f"{DIM}Watching for events... (Ctrl+C to stop watching){RESET}\n")
1212
+
1213
+ # Wait a moment for initial events to be recorded
1214
+ await asyncio.sleep(0.5)
1215
+
1216
+ # Watch the workflow
1217
+ final_status = await _watch_workflow(
1218
+ run_id=run_id,
1219
+ workflow_name=workflow_name,
1220
+ storage=storage,
1221
+ poll_interval=0.5,
1222
+ debug=debug,
1223
+ )
1224
+
1225
+ # Print final summary
1226
+ print()
1227
+ if final_status == RunStatus.COMPLETED:
1228
+ print_success("Workflow completed successfully")
1229
+ # Fetch and show result
1230
+ run = await pyworkflow.get_workflow_run(run_id, storage=storage)
1231
+ if run and run.result:
1232
+ try:
1233
+ result = json.loads(run.result)
1234
+ print(f"{DIM}Result:{RESET} {json.dumps(result, indent=2)}")
1235
+ except json.JSONDecodeError:
1236
+ print(f"{DIM}Result:{RESET} {run.result}")
1237
+ elif final_status == RunStatus.FAILED:
1238
+ print_error("Workflow failed")
1239
+ run = await pyworkflow.get_workflow_run(run_id, storage=storage)
1240
+ if run and run.error:
1241
+ print(f"{Colors.RED}Error:{RESET} {run.error}")
1242
+ raise click.Abort()
1243
+ elif final_status == RunStatus.CANCELLED:
1244
+ print_warning("Workflow was cancelled")
1245
+ else:
1246
+ # Still running (user interrupted watch)
1247
+ print_info(
1248
+ f"Workflow still running. Check status with: pyworkflow runs status {run_id}"
1249
+ )
1250
+
1251
+ except click.Abort:
1252
+ raise
1253
+ except Exception as e:
1254
+ print_error(f"Failed to start workflow: {e}")
1255
+ if ctx.obj["verbose"]:
1256
+ raise
1257
+ raise click.Abort()