pyworkflow-engine 0.1.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dashboard/backend/app/__init__.py +1 -0
- dashboard/backend/app/config.py +32 -0
- dashboard/backend/app/controllers/__init__.py +6 -0
- dashboard/backend/app/controllers/run_controller.py +86 -0
- dashboard/backend/app/controllers/workflow_controller.py +33 -0
- dashboard/backend/app/dependencies/__init__.py +5 -0
- dashboard/backend/app/dependencies/storage.py +50 -0
- dashboard/backend/app/repositories/__init__.py +6 -0
- dashboard/backend/app/repositories/run_repository.py +80 -0
- dashboard/backend/app/repositories/workflow_repository.py +27 -0
- dashboard/backend/app/rest/__init__.py +8 -0
- dashboard/backend/app/rest/v1/__init__.py +12 -0
- dashboard/backend/app/rest/v1/health.py +33 -0
- dashboard/backend/app/rest/v1/runs.py +133 -0
- dashboard/backend/app/rest/v1/workflows.py +41 -0
- dashboard/backend/app/schemas/__init__.py +23 -0
- dashboard/backend/app/schemas/common.py +16 -0
- dashboard/backend/app/schemas/event.py +24 -0
- dashboard/backend/app/schemas/hook.py +25 -0
- dashboard/backend/app/schemas/run.py +54 -0
- dashboard/backend/app/schemas/step.py +28 -0
- dashboard/backend/app/schemas/workflow.py +31 -0
- dashboard/backend/app/server.py +87 -0
- dashboard/backend/app/services/__init__.py +6 -0
- dashboard/backend/app/services/run_service.py +240 -0
- dashboard/backend/app/services/workflow_service.py +155 -0
- dashboard/backend/main.py +18 -0
- docs/concepts/cancellation.mdx +362 -0
- docs/concepts/continue-as-new.mdx +434 -0
- docs/concepts/events.mdx +266 -0
- docs/concepts/fault-tolerance.mdx +370 -0
- docs/concepts/hooks.mdx +552 -0
- docs/concepts/limitations.mdx +167 -0
- docs/concepts/schedules.mdx +775 -0
- docs/concepts/sleep.mdx +312 -0
- docs/concepts/steps.mdx +301 -0
- docs/concepts/workflows.mdx +255 -0
- docs/guides/cli.mdx +942 -0
- docs/guides/configuration.mdx +560 -0
- docs/introduction.mdx +155 -0
- docs/quickstart.mdx +279 -0
- examples/__init__.py +1 -0
- examples/celery/__init__.py +1 -0
- examples/celery/durable/docker-compose.yml +55 -0
- examples/celery/durable/pyworkflow.config.yaml +12 -0
- examples/celery/durable/workflows/__init__.py +122 -0
- examples/celery/durable/workflows/basic.py +87 -0
- examples/celery/durable/workflows/batch_processing.py +102 -0
- examples/celery/durable/workflows/cancellation.py +273 -0
- examples/celery/durable/workflows/child_workflow_patterns.py +240 -0
- examples/celery/durable/workflows/child_workflows.py +202 -0
- examples/celery/durable/workflows/continue_as_new.py +260 -0
- examples/celery/durable/workflows/fault_tolerance.py +210 -0
- examples/celery/durable/workflows/hooks.py +211 -0
- examples/celery/durable/workflows/idempotency.py +112 -0
- examples/celery/durable/workflows/long_running.py +99 -0
- examples/celery/durable/workflows/retries.py +101 -0
- examples/celery/durable/workflows/schedules.py +209 -0
- examples/celery/transient/01_basic_workflow.py +91 -0
- examples/celery/transient/02_fault_tolerance.py +257 -0
- examples/celery/transient/__init__.py +20 -0
- examples/celery/transient/pyworkflow.config.yaml +25 -0
- examples/local/__init__.py +1 -0
- examples/local/durable/01_basic_workflow.py +94 -0
- examples/local/durable/02_file_storage.py +132 -0
- examples/local/durable/03_retries.py +169 -0
- examples/local/durable/04_long_running.py +119 -0
- examples/local/durable/05_event_log.py +145 -0
- examples/local/durable/06_idempotency.py +148 -0
- examples/local/durable/07_hooks.py +334 -0
- examples/local/durable/08_cancellation.py +233 -0
- examples/local/durable/09_child_workflows.py +198 -0
- examples/local/durable/10_child_workflow_patterns.py +265 -0
- examples/local/durable/11_continue_as_new.py +249 -0
- examples/local/durable/12_schedules.py +198 -0
- examples/local/durable/__init__.py +1 -0
- examples/local/transient/01_quick_tasks.py +87 -0
- examples/local/transient/02_retries.py +130 -0
- examples/local/transient/03_sleep.py +141 -0
- examples/local/transient/__init__.py +1 -0
- pyworkflow/__init__.py +256 -0
- pyworkflow/aws/__init__.py +68 -0
- pyworkflow/aws/context.py +234 -0
- pyworkflow/aws/handler.py +184 -0
- pyworkflow/aws/testing.py +310 -0
- pyworkflow/celery/__init__.py +41 -0
- pyworkflow/celery/app.py +198 -0
- pyworkflow/celery/scheduler.py +315 -0
- pyworkflow/celery/tasks.py +1746 -0
- pyworkflow/cli/__init__.py +132 -0
- pyworkflow/cli/__main__.py +6 -0
- pyworkflow/cli/commands/__init__.py +1 -0
- pyworkflow/cli/commands/hooks.py +640 -0
- pyworkflow/cli/commands/quickstart.py +495 -0
- pyworkflow/cli/commands/runs.py +773 -0
- pyworkflow/cli/commands/scheduler.py +130 -0
- pyworkflow/cli/commands/schedules.py +794 -0
- pyworkflow/cli/commands/setup.py +703 -0
- pyworkflow/cli/commands/worker.py +413 -0
- pyworkflow/cli/commands/workflows.py +1257 -0
- pyworkflow/cli/output/__init__.py +1 -0
- pyworkflow/cli/output/formatters.py +321 -0
- pyworkflow/cli/output/styles.py +121 -0
- pyworkflow/cli/utils/__init__.py +1 -0
- pyworkflow/cli/utils/async_helpers.py +30 -0
- pyworkflow/cli/utils/config.py +130 -0
- pyworkflow/cli/utils/config_generator.py +344 -0
- pyworkflow/cli/utils/discovery.py +53 -0
- pyworkflow/cli/utils/docker_manager.py +651 -0
- pyworkflow/cli/utils/interactive.py +364 -0
- pyworkflow/cli/utils/storage.py +115 -0
- pyworkflow/config.py +329 -0
- pyworkflow/context/__init__.py +63 -0
- pyworkflow/context/aws.py +230 -0
- pyworkflow/context/base.py +416 -0
- pyworkflow/context/local.py +930 -0
- pyworkflow/context/mock.py +381 -0
- pyworkflow/core/__init__.py +0 -0
- pyworkflow/core/exceptions.py +353 -0
- pyworkflow/core/registry.py +313 -0
- pyworkflow/core/scheduled.py +328 -0
- pyworkflow/core/step.py +494 -0
- pyworkflow/core/workflow.py +294 -0
- pyworkflow/discovery.py +248 -0
- pyworkflow/engine/__init__.py +0 -0
- pyworkflow/engine/events.py +879 -0
- pyworkflow/engine/executor.py +682 -0
- pyworkflow/engine/replay.py +273 -0
- pyworkflow/observability/__init__.py +19 -0
- pyworkflow/observability/logging.py +234 -0
- pyworkflow/primitives/__init__.py +33 -0
- pyworkflow/primitives/child_handle.py +174 -0
- pyworkflow/primitives/child_workflow.py +372 -0
- pyworkflow/primitives/continue_as_new.py +101 -0
- pyworkflow/primitives/define_hook.py +150 -0
- pyworkflow/primitives/hooks.py +97 -0
- pyworkflow/primitives/resume_hook.py +210 -0
- pyworkflow/primitives/schedule.py +545 -0
- pyworkflow/primitives/shield.py +96 -0
- pyworkflow/primitives/sleep.py +100 -0
- pyworkflow/runtime/__init__.py +21 -0
- pyworkflow/runtime/base.py +179 -0
- pyworkflow/runtime/celery.py +310 -0
- pyworkflow/runtime/factory.py +101 -0
- pyworkflow/runtime/local.py +706 -0
- pyworkflow/scheduler/__init__.py +9 -0
- pyworkflow/scheduler/local.py +248 -0
- pyworkflow/serialization/__init__.py +0 -0
- pyworkflow/serialization/decoder.py +146 -0
- pyworkflow/serialization/encoder.py +162 -0
- pyworkflow/storage/__init__.py +54 -0
- pyworkflow/storage/base.py +612 -0
- pyworkflow/storage/config.py +185 -0
- pyworkflow/storage/dynamodb.py +1315 -0
- pyworkflow/storage/file.py +827 -0
- pyworkflow/storage/memory.py +549 -0
- pyworkflow/storage/postgres.py +1161 -0
- pyworkflow/storage/schemas.py +486 -0
- pyworkflow/storage/sqlite.py +1136 -0
- pyworkflow/utils/__init__.py +0 -0
- pyworkflow/utils/duration.py +177 -0
- pyworkflow/utils/schedule.py +391 -0
- pyworkflow_engine-0.1.7.dist-info/METADATA +687 -0
- pyworkflow_engine-0.1.7.dist-info/RECORD +196 -0
- pyworkflow_engine-0.1.7.dist-info/WHEEL +5 -0
- pyworkflow_engine-0.1.7.dist-info/entry_points.txt +2 -0
- pyworkflow_engine-0.1.7.dist-info/licenses/LICENSE +21 -0
- pyworkflow_engine-0.1.7.dist-info/top_level.txt +5 -0
- tests/examples/__init__.py +0 -0
- tests/integration/__init__.py +0 -0
- tests/integration/test_cancellation.py +330 -0
- tests/integration/test_child_workflows.py +439 -0
- tests/integration/test_continue_as_new.py +428 -0
- tests/integration/test_dynamodb_storage.py +1146 -0
- tests/integration/test_fault_tolerance.py +369 -0
- tests/integration/test_schedule_storage.py +484 -0
- tests/unit/__init__.py +0 -0
- tests/unit/backends/__init__.py +1 -0
- tests/unit/backends/test_dynamodb_storage.py +1554 -0
- tests/unit/backends/test_postgres_storage.py +1281 -0
- tests/unit/backends/test_sqlite_storage.py +1460 -0
- tests/unit/conftest.py +41 -0
- tests/unit/test_cancellation.py +364 -0
- tests/unit/test_child_workflows.py +680 -0
- tests/unit/test_continue_as_new.py +441 -0
- tests/unit/test_event_limits.py +316 -0
- tests/unit/test_executor.py +320 -0
- tests/unit/test_fault_tolerance.py +334 -0
- tests/unit/test_hooks.py +495 -0
- tests/unit/test_registry.py +261 -0
- tests/unit/test_replay.py +420 -0
- tests/unit/test_schedule_schemas.py +285 -0
- tests/unit/test_schedule_utils.py +286 -0
- tests/unit/test_scheduled_workflow.py +274 -0
- tests/unit/test_step.py +353 -0
- tests/unit/test_workflow.py +243 -0
|
@@ -0,0 +1,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()
|