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 @@
|
|
|
1
|
+
"""PyWorkflow CLI output formatting package."""
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Output formatting utilities using ANSI colors."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pyworkflow.cli.output.styles import (
|
|
9
|
+
BOLD,
|
|
10
|
+
DIM,
|
|
11
|
+
EVENT_COLORS,
|
|
12
|
+
RESET,
|
|
13
|
+
STATUS_COLORS,
|
|
14
|
+
SYMBOLS,
|
|
15
|
+
Colors,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def print_colored(text: str, color: str, bold: bool = False, file: Any = None) -> None:
|
|
20
|
+
"""
|
|
21
|
+
Print text with ANSI color.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
text: Text to print
|
|
25
|
+
color: ANSI color code
|
|
26
|
+
bold: Whether to make text bold
|
|
27
|
+
file: Output file (default: stdout)
|
|
28
|
+
"""
|
|
29
|
+
if file is None:
|
|
30
|
+
file = sys.stdout
|
|
31
|
+
prefix = f"{BOLD}" if bold else ""
|
|
32
|
+
print(f"{prefix}{color}{text}{RESET}", file=file)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def print_breadcrumb(steps: list[str], current_index: int) -> None:
|
|
36
|
+
"""
|
|
37
|
+
Print a breadcrumb navigation display.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
steps: List of step names
|
|
41
|
+
current_index: Index of the current step (0-based)
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
print_breadcrumb(["workflow", "arguments"], 1)
|
|
45
|
+
# Output: workflow › arguments
|
|
46
|
+
"""
|
|
47
|
+
parts = []
|
|
48
|
+
for i, step in enumerate(steps):
|
|
49
|
+
if i < current_index:
|
|
50
|
+
# Completed step - dim
|
|
51
|
+
parts.append(f"{DIM}{step}{RESET}")
|
|
52
|
+
elif i == current_index:
|
|
53
|
+
# Current step - primary color + bold
|
|
54
|
+
parts.append(f"{Colors.PRIMARY}{BOLD}{step}{RESET}")
|
|
55
|
+
else:
|
|
56
|
+
# Future step - dim
|
|
57
|
+
parts.append(f"{DIM}{step}{RESET}")
|
|
58
|
+
|
|
59
|
+
breadcrumb = SYMBOLS["breadcrumb_sep"].join(parts)
|
|
60
|
+
print(f"\n{breadcrumb}\n")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def print_list(
|
|
64
|
+
items: list[dict[str, Any]],
|
|
65
|
+
title: str | None = None,
|
|
66
|
+
key_field: str = "name",
|
|
67
|
+
detail_fields: list[str] | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""
|
|
70
|
+
Print items as a simple indented list.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
items: List of dictionaries to display
|
|
74
|
+
title: Optional header title
|
|
75
|
+
key_field: Primary field to display for each item
|
|
76
|
+
detail_fields: Additional fields to show indented below each item
|
|
77
|
+
"""
|
|
78
|
+
if title:
|
|
79
|
+
print(f"\n{Colors.PRIMARY}{BOLD}{title}{RESET}\n")
|
|
80
|
+
|
|
81
|
+
if not items:
|
|
82
|
+
print(f" {DIM}No items to display{RESET}")
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
for item in items:
|
|
86
|
+
# Print main item
|
|
87
|
+
name = item.get(key_field, "Unknown")
|
|
88
|
+
print(f" {Colors.bold(name)}")
|
|
89
|
+
|
|
90
|
+
# Print detail fields if provided
|
|
91
|
+
if detail_fields:
|
|
92
|
+
for field in detail_fields:
|
|
93
|
+
if field in item and item[field]:
|
|
94
|
+
value = item[field]
|
|
95
|
+
# Format status specially
|
|
96
|
+
if field.lower() == "status":
|
|
97
|
+
color = STATUS_COLORS.get(str(value).lower(), "")
|
|
98
|
+
value = f"{color}{value}{RESET}"
|
|
99
|
+
label = field.replace("_", " ").title()
|
|
100
|
+
print(f" {DIM}{label}:{RESET} {value}")
|
|
101
|
+
|
|
102
|
+
print() # Blank line between items
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def format_key_value(
|
|
106
|
+
data: dict[str, Any],
|
|
107
|
+
title: str | None = None,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""
|
|
110
|
+
Print key-value pairs with formatting.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
data: Dictionary of key-value pairs
|
|
114
|
+
title: Optional title
|
|
115
|
+
"""
|
|
116
|
+
if title:
|
|
117
|
+
print(f"\n{Colors.PRIMARY}{BOLD}{title}{RESET}\n")
|
|
118
|
+
|
|
119
|
+
for key, value in data.items():
|
|
120
|
+
# Format value based on type
|
|
121
|
+
if isinstance(value, datetime):
|
|
122
|
+
value_str = value.strftime("%Y-%m-%d %H:%M:%S")
|
|
123
|
+
elif isinstance(value, dict):
|
|
124
|
+
value_str = json.dumps(value, indent=2)
|
|
125
|
+
elif value is None:
|
|
126
|
+
value_str = f"{DIM}None{RESET}"
|
|
127
|
+
else:
|
|
128
|
+
value_str = str(value)
|
|
129
|
+
|
|
130
|
+
# Apply special formatting for status
|
|
131
|
+
if key.lower() == "status":
|
|
132
|
+
color = STATUS_COLORS.get(value_str.lower(), "")
|
|
133
|
+
value_str = f"{color}{value_str}{RESET}"
|
|
134
|
+
|
|
135
|
+
print(f" {Colors.CYAN}{key}:{RESET} {value_str}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def format_json(data: Any, indent: int = 2) -> None:
|
|
139
|
+
"""
|
|
140
|
+
Print data as formatted JSON.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
data: Data to format as JSON
|
|
144
|
+
indent: JSON indentation level
|
|
145
|
+
"""
|
|
146
|
+
json_str = json.dumps(data, indent=indent, default=str)
|
|
147
|
+
print(json_str)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def format_plain(data: list[str]) -> None:
|
|
151
|
+
"""
|
|
152
|
+
Print data as plain text (one item per line).
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
data: List of strings to print
|
|
156
|
+
"""
|
|
157
|
+
for item in data:
|
|
158
|
+
print(item)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def format_status(status: str) -> str:
|
|
162
|
+
"""
|
|
163
|
+
Return status string with ANSI color.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
status: Status string
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Colored status string
|
|
170
|
+
"""
|
|
171
|
+
color = STATUS_COLORS.get(status.lower(), "")
|
|
172
|
+
return f"{color}{status}{RESET}"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def format_event_type(event_type: str) -> str:
|
|
176
|
+
"""
|
|
177
|
+
Return event type string with ANSI color.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
event_type: Event type string
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Colored event type string
|
|
184
|
+
"""
|
|
185
|
+
color = EVENT_COLORS.get(event_type.lower(), "")
|
|
186
|
+
return f"{color}{event_type}{RESET}"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def print_success(message: str) -> None:
|
|
190
|
+
"""Print success message with green checkmark."""
|
|
191
|
+
print(f"{Colors.GREEN}{SYMBOLS['success']}{RESET} {message}")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def print_error(message: str) -> None:
|
|
195
|
+
"""Print error message with red X to stderr."""
|
|
196
|
+
print(f"{Colors.RED}{SYMBOLS['error']}{RESET} {message}", file=sys.stderr)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def print_warning(message: str) -> None:
|
|
200
|
+
"""Print warning message with yellow warning symbol."""
|
|
201
|
+
print(f"{Colors.YELLOW}{SYMBOLS['warning']}{RESET} {message}")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def print_info(message: str) -> None:
|
|
205
|
+
"""Print info message with blue info symbol."""
|
|
206
|
+
print(f"{Colors.PRIMARY}{SYMBOLS['info']}{RESET} {message}")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def clear_line() -> None:
|
|
210
|
+
"""Clear the current terminal line."""
|
|
211
|
+
sys.stdout.write("\033[2K\r")
|
|
212
|
+
sys.stdout.flush()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def move_cursor_up(lines: int = 1) -> None:
|
|
216
|
+
"""Move cursor up N lines."""
|
|
217
|
+
sys.stdout.write(f"\033[{lines}A")
|
|
218
|
+
sys.stdout.flush()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def hide_cursor() -> None:
|
|
222
|
+
"""Hide the terminal cursor."""
|
|
223
|
+
sys.stdout.write("\033[?25l")
|
|
224
|
+
sys.stdout.flush()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def show_cursor() -> None:
|
|
228
|
+
"""Show the terminal cursor."""
|
|
229
|
+
sys.stdout.write("\033[?25h")
|
|
230
|
+
sys.stdout.flush()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# Deprecated aliases for backwards compatibility during transition
|
|
234
|
+
def format_table(
|
|
235
|
+
data: list[dict[str, Any]],
|
|
236
|
+
columns: list[str],
|
|
237
|
+
title: str | None = None,
|
|
238
|
+
) -> None:
|
|
239
|
+
"""
|
|
240
|
+
Format data as a simple list (replacing Rich tables).
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
data: List of dictionaries containing row data
|
|
244
|
+
columns: List of column names to display
|
|
245
|
+
title: Optional table title
|
|
246
|
+
"""
|
|
247
|
+
if title:
|
|
248
|
+
print(f"\n{Colors.PRIMARY}{BOLD}{title}{RESET}\n")
|
|
249
|
+
|
|
250
|
+
if not data:
|
|
251
|
+
print(f" {DIM}No data to display{RESET}")
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
for row in data:
|
|
255
|
+
# Print first column as the main item (bold)
|
|
256
|
+
first_col = columns[0] if columns else None
|
|
257
|
+
if first_col and first_col in row:
|
|
258
|
+
print(f" {Colors.bold(str(row[first_col]))}")
|
|
259
|
+
|
|
260
|
+
# Print remaining columns as details
|
|
261
|
+
for col in columns[1:]:
|
|
262
|
+
if col in row:
|
|
263
|
+
value = row[col]
|
|
264
|
+
# Format status specially
|
|
265
|
+
if col.lower() == "status":
|
|
266
|
+
value = format_status(str(value))
|
|
267
|
+
print(f" {DIM}{col}:{RESET} {value}")
|
|
268
|
+
|
|
269
|
+
print() # Blank line between items
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def format_panel(
|
|
273
|
+
content: str,
|
|
274
|
+
title: str | None = None,
|
|
275
|
+
border_style: str = "blue",
|
|
276
|
+
) -> None:
|
|
277
|
+
"""
|
|
278
|
+
Print content with a simple title header (replacing Rich panels).
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
content: Content to display
|
|
282
|
+
title: Optional title
|
|
283
|
+
border_style: Ignored (kept for compatibility)
|
|
284
|
+
"""
|
|
285
|
+
if title:
|
|
286
|
+
print(f"\n{Colors.PRIMARY}{BOLD}{title}{RESET}")
|
|
287
|
+
print(f"{DIM}{'─' * len(title)}{RESET}")
|
|
288
|
+
print(content)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def format_tree(data: dict[str, Any], title: str = "Data") -> None:
|
|
292
|
+
"""
|
|
293
|
+
Print data as an indented tree structure.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
data: Nested dictionary to display
|
|
297
|
+
title: Tree root title
|
|
298
|
+
"""
|
|
299
|
+
print(f"\n{Colors.bold(title)}")
|
|
300
|
+
_print_tree_nodes(data, indent=2)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _print_tree_nodes(data: Any, indent: int = 0) -> None:
|
|
304
|
+
"""Helper to recursively print tree nodes."""
|
|
305
|
+
prefix = " " * indent
|
|
306
|
+
if isinstance(data, dict):
|
|
307
|
+
for key, value in data.items():
|
|
308
|
+
if isinstance(value, dict | list):
|
|
309
|
+
print(f"{prefix}{Colors.CYAN}{key}{RESET}:")
|
|
310
|
+
_print_tree_nodes(value, indent + 2)
|
|
311
|
+
else:
|
|
312
|
+
print(f"{prefix}{Colors.CYAN}{key}:{RESET} {value}")
|
|
313
|
+
elif isinstance(data, list):
|
|
314
|
+
for i, item in enumerate(data):
|
|
315
|
+
if isinstance(item, dict | list):
|
|
316
|
+
print(f"{prefix}{DIM}{i}:{RESET}")
|
|
317
|
+
_print_tree_nodes(item, indent + 2)
|
|
318
|
+
else:
|
|
319
|
+
print(f"{prefix}{DIM}{i}:{RESET} {item}")
|
|
320
|
+
else:
|
|
321
|
+
print(f"{prefix}{data}")
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Styles and themes for PyWorkflow CLI using InquirerPy."""
|
|
2
|
+
|
|
3
|
+
from InquirerPy.utils import get_style
|
|
4
|
+
|
|
5
|
+
# Primary brand color
|
|
6
|
+
PRIMARY_COLOR = "#1c64fd"
|
|
7
|
+
|
|
8
|
+
# InquirerPy style configuration
|
|
9
|
+
PYWORKFLOW_STYLE = get_style(
|
|
10
|
+
{
|
|
11
|
+
"questionmark": f"{PRIMARY_COLOR} bold",
|
|
12
|
+
"answermark": PRIMARY_COLOR,
|
|
13
|
+
"answer": f"{PRIMARY_COLOR} bold",
|
|
14
|
+
"pointer": f"{PRIMARY_COLOR} bold",
|
|
15
|
+
"checkbox": PRIMARY_COLOR,
|
|
16
|
+
"marker": PRIMARY_COLOR,
|
|
17
|
+
"question": "",
|
|
18
|
+
"instruction": "italic #666666",
|
|
19
|
+
"input": PRIMARY_COLOR,
|
|
20
|
+
"fuzzy_prompt": PRIMARY_COLOR,
|
|
21
|
+
"fuzzy_match": f"{PRIMARY_COLOR} bold",
|
|
22
|
+
},
|
|
23
|
+
style_override=False,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# ANSI color codes for terminal output
|
|
27
|
+
RESET = "\033[0m"
|
|
28
|
+
BOLD = "\033[1m"
|
|
29
|
+
DIM = "\033[2m"
|
|
30
|
+
ITALIC = "\033[3m"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Colors:
|
|
34
|
+
"""ANSI color codes for CLI output."""
|
|
35
|
+
|
|
36
|
+
# Primary brand color (approximated in ANSI - bright blue)
|
|
37
|
+
PRIMARY = "\033[38;2;28;100;253m" # RGB: #1c64fd
|
|
38
|
+
|
|
39
|
+
# Status colors
|
|
40
|
+
GREEN = "\033[32m"
|
|
41
|
+
RED = "\033[31m"
|
|
42
|
+
YELLOW = "\033[33m"
|
|
43
|
+
BLUE = "\033[34m"
|
|
44
|
+
MAGENTA = "\033[35m"
|
|
45
|
+
CYAN = "\033[36m"
|
|
46
|
+
WHITE = "\033[37m"
|
|
47
|
+
GRAY = "\033[90m"
|
|
48
|
+
|
|
49
|
+
# Bright variants
|
|
50
|
+
BRIGHT_GREEN = "\033[92m"
|
|
51
|
+
BRIGHT_RED = "\033[91m"
|
|
52
|
+
BRIGHT_YELLOW = "\033[93m"
|
|
53
|
+
BRIGHT_BLUE = "\033[94m"
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def reset(cls) -> str:
|
|
57
|
+
return RESET
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def bold(cls, text: str) -> str:
|
|
61
|
+
return f"{BOLD}{text}{RESET}"
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def dim(cls, text: str) -> str:
|
|
65
|
+
return f"{DIM}{text}{RESET}"
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def italic(cls, text: str) -> str:
|
|
69
|
+
return f"{ITALIC}{text}{RESET}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Status color mapping
|
|
73
|
+
STATUS_COLORS: dict[str, str] = {
|
|
74
|
+
"completed": Colors.GREEN,
|
|
75
|
+
"running": Colors.BLUE,
|
|
76
|
+
"suspended": Colors.YELLOW,
|
|
77
|
+
"failed": Colors.RED,
|
|
78
|
+
"cancelled": Colors.MAGENTA,
|
|
79
|
+
"pending": Colors.CYAN,
|
|
80
|
+
# Hook statuses
|
|
81
|
+
"received": Colors.GREEN,
|
|
82
|
+
"expired": Colors.RED,
|
|
83
|
+
"disposed": Colors.GRAY,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# Event type color mapping
|
|
87
|
+
EVENT_COLORS: dict[str, str] = {
|
|
88
|
+
"workflow_started": Colors.BLUE,
|
|
89
|
+
"workflow_completed": Colors.GREEN,
|
|
90
|
+
"workflow_failed": Colors.RED,
|
|
91
|
+
"workflow_cancelled": Colors.RED,
|
|
92
|
+
"workflow_interrupted": Colors.RED,
|
|
93
|
+
"step_started": Colors.CYAN,
|
|
94
|
+
"step_completed": Colors.GREEN,
|
|
95
|
+
"step_failed": Colors.RED,
|
|
96
|
+
"step_cancelled": Colors.RED,
|
|
97
|
+
"step_retrying": Colors.YELLOW,
|
|
98
|
+
"sleep_started": Colors.MAGENTA,
|
|
99
|
+
"sleep_completed": Colors.MAGENTA,
|
|
100
|
+
"hook_created": Colors.YELLOW,
|
|
101
|
+
"hook_received": Colors.GREEN,
|
|
102
|
+
"cancellation_requested": Colors.RED,
|
|
103
|
+
"cancellation.requested": Colors.RED,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# Spinner frames for watch mode
|
|
107
|
+
SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
108
|
+
|
|
109
|
+
# UI symbols
|
|
110
|
+
SYMBOLS = {
|
|
111
|
+
"pointer": "❯",
|
|
112
|
+
"success": "✓",
|
|
113
|
+
"error": "✗",
|
|
114
|
+
"warning": "⚠",
|
|
115
|
+
"info": "ℹ",
|
|
116
|
+
"bullet": "•",
|
|
117
|
+
"arrow": "→",
|
|
118
|
+
"breadcrumb_sep": " › ",
|
|
119
|
+
"checkbox_on": "◉",
|
|
120
|
+
"checkbox_off": "○",
|
|
121
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""PyWorkflow CLI utilities package."""
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Async helpers for Click commands."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import functools
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any, TypeVar
|
|
7
|
+
|
|
8
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def async_command(f: F) -> F:
|
|
12
|
+
"""
|
|
13
|
+
Decorator to make Click commands work with async functions.
|
|
14
|
+
|
|
15
|
+
Click doesn't natively support async functions, so this decorator
|
|
16
|
+
wraps async functions with asyncio.run() to make them work with Click.
|
|
17
|
+
|
|
18
|
+
Example:
|
|
19
|
+
@click.command()
|
|
20
|
+
@async_command
|
|
21
|
+
async def my_command():
|
|
22
|
+
result = await some_async_function()
|
|
23
|
+
print(result)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
@functools.wraps(f)
|
|
27
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
28
|
+
return asyncio.run(f(*args, **kwargs))
|
|
29
|
+
|
|
30
|
+
return wrapper # type: ignore[return-value]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Configuration file loading utilities."""
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def find_config_file() -> Path | None:
|
|
11
|
+
"""
|
|
12
|
+
Find configuration file in current directory or parents.
|
|
13
|
+
|
|
14
|
+
Searches for configuration files in this order:
|
|
15
|
+
1. pyworkflow.toml
|
|
16
|
+
2. .pyworkflow.toml
|
|
17
|
+
3. pyproject.toml (with [tool.pyworkflow] section)
|
|
18
|
+
|
|
19
|
+
The search starts in the current directory and walks up the directory
|
|
20
|
+
tree until a config file is found or the root is reached.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Path to config file if found, None otherwise
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
config_path = find_config_file()
|
|
27
|
+
if config_path:
|
|
28
|
+
print(f"Found config at: {config_path}")
|
|
29
|
+
"""
|
|
30
|
+
current = Path.cwd()
|
|
31
|
+
|
|
32
|
+
# Walk up the directory tree
|
|
33
|
+
for path in [current] + list(current.parents):
|
|
34
|
+
# Check for dedicated pyworkflow config files
|
|
35
|
+
for name in ["pyworkflow.toml", ".pyworkflow.toml"]:
|
|
36
|
+
config_path = path / name
|
|
37
|
+
if config_path.exists():
|
|
38
|
+
logger.debug(f"Found config file: {config_path}")
|
|
39
|
+
return config_path
|
|
40
|
+
|
|
41
|
+
# Check for pyproject.toml with [tool.pyworkflow] section
|
|
42
|
+
pyproject = path / "pyproject.toml"
|
|
43
|
+
if pyproject.exists():
|
|
44
|
+
try:
|
|
45
|
+
with open(pyproject, "rb") as f:
|
|
46
|
+
data = tomllib.load(f)
|
|
47
|
+
if "tool" in data and "pyworkflow" in data["tool"]:
|
|
48
|
+
logger.debug(f"Found pyworkflow config in pyproject.toml: {pyproject}")
|
|
49
|
+
return pyproject
|
|
50
|
+
except Exception as e:
|
|
51
|
+
logger.warning(f"Failed to parse pyproject.toml at {pyproject}: {e}")
|
|
52
|
+
|
|
53
|
+
logger.debug("No config file found")
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_config() -> dict[str, Any]:
|
|
58
|
+
"""
|
|
59
|
+
Load CLI configuration from file.
|
|
60
|
+
|
|
61
|
+
Searches for and loads configuration from pyworkflow.toml,
|
|
62
|
+
.pyworkflow.toml, or pyproject.toml files.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Configuration dictionary. Returns empty dict if no config file found.
|
|
66
|
+
|
|
67
|
+
Examples:
|
|
68
|
+
config = load_config()
|
|
69
|
+
module = config.get("module")
|
|
70
|
+
storage_type = config.get("storage", {}).get("type")
|
|
71
|
+
"""
|
|
72
|
+
config_path = find_config_file()
|
|
73
|
+
if not config_path:
|
|
74
|
+
logger.debug("No configuration file found, using defaults")
|
|
75
|
+
return {}
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
with open(config_path, "rb") as f:
|
|
79
|
+
data = tomllib.load(f)
|
|
80
|
+
logger.info(f"Loaded configuration from: {config_path}")
|
|
81
|
+
|
|
82
|
+
# Handle pyproject.toml format
|
|
83
|
+
if config_path.name == "pyproject.toml":
|
|
84
|
+
config = data.get("tool", {}).get("pyworkflow", {})
|
|
85
|
+
else:
|
|
86
|
+
# For dedicated pyworkflow.toml files, get the pyworkflow section
|
|
87
|
+
config = data.get("pyworkflow", data)
|
|
88
|
+
|
|
89
|
+
logger.debug(f"Configuration loaded: {config}")
|
|
90
|
+
return config
|
|
91
|
+
|
|
92
|
+
except Exception as e:
|
|
93
|
+
logger.error(f"Failed to load configuration from {config_path}: {e}")
|
|
94
|
+
return {}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_config_value(
|
|
98
|
+
config: dict[str, Any],
|
|
99
|
+
*keys: str,
|
|
100
|
+
default: Any = None,
|
|
101
|
+
) -> Any:
|
|
102
|
+
"""
|
|
103
|
+
Get nested configuration value.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
config: Configuration dictionary
|
|
107
|
+
*keys: Sequence of keys to traverse
|
|
108
|
+
default: Default value if key path doesn't exist
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Configuration value or default
|
|
112
|
+
|
|
113
|
+
Examples:
|
|
114
|
+
config = {"storage": {"type": "file", "base_path": "./data"}}
|
|
115
|
+
|
|
116
|
+
# Get nested value
|
|
117
|
+
storage_type = get_config_value(config, "storage", "type") # "file"
|
|
118
|
+
|
|
119
|
+
# With default
|
|
120
|
+
timeout = get_config_value(config, "timeout", default=30) # 30
|
|
121
|
+
"""
|
|
122
|
+
value: Any = config
|
|
123
|
+
for key in keys:
|
|
124
|
+
if isinstance(value, dict):
|
|
125
|
+
value = value.get(key)
|
|
126
|
+
if value is None:
|
|
127
|
+
return default
|
|
128
|
+
else:
|
|
129
|
+
return default
|
|
130
|
+
return value if value is not None else default
|