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 @@
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