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,640 @@
1
+ """Hook management commands."""
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ import click
7
+ from InquirerPy import inquirer
8
+
9
+ from pyworkflow.cli.output.formatters import (
10
+ format_json,
11
+ format_key_value,
12
+ format_plain,
13
+ format_table,
14
+ print_breadcrumb,
15
+ print_error,
16
+ print_info,
17
+ print_success,
18
+ )
19
+ from pyworkflow.cli.output.styles import (
20
+ DIM,
21
+ PYWORKFLOW_STYLE,
22
+ RESET,
23
+ SYMBOLS,
24
+ Colors,
25
+ )
26
+ from pyworkflow.cli.utils.async_helpers import async_command
27
+ from pyworkflow.cli.utils.storage import create_storage
28
+ from pyworkflow.storage.schemas import HookStatus
29
+
30
+
31
+ @click.group(name="hooks")
32
+ def hooks() -> None:
33
+ """Manage hooks (list, info, resume)."""
34
+ pass
35
+
36
+
37
+ def _build_hook_choices(hooks_list: list[Any]) -> list[dict[str, str]]:
38
+ """Build choices list for hook selection."""
39
+ choices = []
40
+ for hook in hooks_list:
41
+ name = hook.name or hook.hook_id
42
+ display = f"{name} - {hook.run_id}"
43
+ if hook.expires_at:
44
+ display += f" (expires: {hook.expires_at.strftime('%Y-%m-%d %H:%M')})"
45
+ choices.append({"name": display, "value": hook.token})
46
+ return choices
47
+
48
+
49
+ async def _select_pending_hook_async(storage: Any) -> str | None:
50
+ """
51
+ Display an interactive menu to select a pending hook.
52
+
53
+ Args:
54
+ storage: Storage backend
55
+
56
+ Returns:
57
+ Selected hook token or None if cancelled
58
+ """
59
+ hooks_list = await storage.list_hooks(status=HookStatus.PENDING)
60
+
61
+ if not hooks_list:
62
+ print_info("No pending hooks found")
63
+ return None
64
+
65
+ choices = _build_hook_choices(hooks_list)
66
+
67
+ try:
68
+ result = await inquirer.select( # type: ignore[func-returns-value]
69
+ message="Select hook to resume",
70
+ choices=choices,
71
+ style=PYWORKFLOW_STYLE,
72
+ pointer=SYMBOLS["pointer"],
73
+ qmark="?",
74
+ amark=SYMBOLS["success"],
75
+ ).execute_async()
76
+ return result
77
+ except KeyboardInterrupt:
78
+ print(f"\n{DIM}Cancelled{RESET}")
79
+ return None
80
+
81
+
82
+ async def _prompt_for_payload_async(hook: Any) -> dict[str, Any]:
83
+ """
84
+ Interactively prompt for payload fields based on hook's schema.
85
+
86
+ Args:
87
+ hook: Hook object with optional payload_schema
88
+
89
+ Returns:
90
+ Dictionary of field values
91
+ """
92
+ # If no schema, prompt for raw JSON
93
+ if not hook.payload_schema:
94
+ try:
95
+ raw = await inquirer.text( # type: ignore[func-returns-value]
96
+ message="Enter payload (JSON)",
97
+ default="{}",
98
+ style=PYWORKFLOW_STYLE,
99
+ qmark="?",
100
+ amark=SYMBOLS["success"],
101
+ ).execute_async()
102
+ return json.loads(raw) if raw else {}
103
+ except KeyboardInterrupt:
104
+ print(f"\n{DIM}Cancelled{RESET}")
105
+ raise click.Abort()
106
+
107
+ # Parse JSON schema
108
+ schema = json.loads(hook.payload_schema)
109
+ properties = schema.get("properties", {})
110
+ required = set(schema.get("required", []))
111
+
112
+ payload: dict[str, Any] = {}
113
+
114
+ for field_name, field_schema in properties.items():
115
+ field_type = field_schema.get("type", "string")
116
+ default = field_schema.get("default")
117
+ is_required = field_name in required
118
+
119
+ # Build instruction text
120
+ if is_required:
121
+ instruction = f"({field_type}, required)"
122
+ else:
123
+ default_display = repr(default) if default is not None else "None"
124
+ instruction = f"({field_type}, default={default_display})"
125
+
126
+ try:
127
+ # Handle boolean type with confirm prompt
128
+ if field_type == "boolean":
129
+ default_val = default if default is not None else False
130
+ value = await inquirer.confirm( # type: ignore[func-returns-value]
131
+ message=field_name,
132
+ default=default_val,
133
+ style=PYWORKFLOW_STYLE,
134
+ qmark="?",
135
+ amark=SYMBOLS["success"],
136
+ instruction=instruction,
137
+ ).execute_async()
138
+ payload[field_name] = value
139
+
140
+ # Handle integer type with number prompt
141
+ elif field_type == "integer":
142
+ default_val = default if default is not None else None
143
+ value = await inquirer.number( # type: ignore[func-returns-value]
144
+ message=field_name,
145
+ default=default_val,
146
+ style=PYWORKFLOW_STYLE,
147
+ qmark="?",
148
+ amark=SYMBOLS["success"],
149
+ instruction=instruction,
150
+ float_allowed=False,
151
+ ).execute_async()
152
+ if value is not None:
153
+ payload[field_name] = int(value)
154
+ elif default is not None:
155
+ payload[field_name] = default
156
+
157
+ # Handle number/float type with number prompt
158
+ elif field_type == "number":
159
+ default_val = default if default is not None else None
160
+ value = await inquirer.number( # type: ignore[func-returns-value]
161
+ message=field_name,
162
+ default=default_val,
163
+ style=PYWORKFLOW_STYLE,
164
+ qmark="?",
165
+ amark=SYMBOLS["success"],
166
+ instruction=instruction,
167
+ float_allowed=True,
168
+ ).execute_async()
169
+ if value is not None:
170
+ payload[field_name] = float(value)
171
+ elif default is not None:
172
+ payload[field_name] = default
173
+
174
+ # Handle string/object/array types with text prompt
175
+ else:
176
+ if default is not None:
177
+ default_str = json.dumps(default) if not isinstance(default, str) else default
178
+ else:
179
+ default_str = ""
180
+
181
+ value_str = await inquirer.text( # type: ignore[func-returns-value]
182
+ message=field_name,
183
+ default=default_str,
184
+ style=PYWORKFLOW_STYLE,
185
+ qmark="?",
186
+ amark=SYMBOLS["success"],
187
+ instruction=instruction,
188
+ mandatory=is_required,
189
+ ).execute_async()
190
+
191
+ if value_str == "" and default is not None:
192
+ payload[field_name] = default
193
+ elif value_str == "" and not is_required:
194
+ continue # Skip optional fields with no input
195
+ elif value_str:
196
+ # Try JSON parse for complex types
197
+ if field_type in ("object", "array"):
198
+ try:
199
+ payload[field_name] = json.loads(value_str)
200
+ except json.JSONDecodeError:
201
+ payload[field_name] = value_str
202
+ else:
203
+ payload[field_name] = value_str
204
+
205
+ except KeyboardInterrupt:
206
+ print(f"\n{DIM}Cancelled{RESET}")
207
+ raise click.Abort()
208
+
209
+ return payload
210
+
211
+
212
+ @hooks.command(name="list")
213
+ @click.option(
214
+ "--run-id",
215
+ help="Filter by workflow run ID",
216
+ )
217
+ @click.option(
218
+ "--status",
219
+ type=click.Choice([s.value for s in HookStatus], case_sensitive=False),
220
+ help="Filter by hook status",
221
+ )
222
+ @click.option(
223
+ "--limit",
224
+ type=int,
225
+ default=20,
226
+ help="Maximum number of hooks to display (default: 20)",
227
+ )
228
+ @click.pass_context
229
+ @async_command
230
+ async def list_hooks_cmd(
231
+ ctx: click.Context,
232
+ run_id: str | None,
233
+ status: str | None,
234
+ limit: int,
235
+ ) -> None:
236
+ """
237
+ List hooks.
238
+
239
+ Examples:
240
+
241
+ # List all hooks
242
+ pyworkflow hooks list
243
+
244
+ # List pending hooks only
245
+ pyworkflow hooks list --status pending
246
+
247
+ # List hooks for specific workflow run
248
+ pyworkflow hooks list --run-id run_abc123
249
+ """
250
+ # Get context data
251
+ config = ctx.obj["config"]
252
+ output = ctx.obj["output"]
253
+ storage_type = ctx.obj["storage_type"]
254
+ storage_path = ctx.obj["storage_path"]
255
+
256
+ # Create storage backend
257
+ storage = create_storage(storage_type, storage_path, config)
258
+
259
+ # Parse status filter
260
+ status_filter = HookStatus(status) if status else None
261
+
262
+ # List hooks
263
+ try:
264
+ hooks_list = await storage.list_hooks(
265
+ run_id=run_id,
266
+ status=status_filter,
267
+ limit=limit,
268
+ )
269
+
270
+ if not hooks_list:
271
+ print_info("No hooks found")
272
+ return
273
+
274
+ # Format output
275
+ if output == "json":
276
+ data = [
277
+ {
278
+ "token": hook.token,
279
+ "hook_id": hook.hook_id,
280
+ "run_id": hook.run_id,
281
+ "name": hook.name,
282
+ "status": hook.status.value,
283
+ "created_at": hook.created_at.isoformat() if hook.created_at else None,
284
+ "expires_at": hook.expires_at.isoformat() if hook.expires_at else None,
285
+ "has_schema": hook.payload_schema is not None,
286
+ }
287
+ for hook in hooks_list
288
+ ]
289
+ format_json(data)
290
+
291
+ elif output == "plain":
292
+ tokens = [hook.token for hook in hooks_list]
293
+ format_plain(tokens)
294
+
295
+ else: # table
296
+ data = [
297
+ {
298
+ "Token": hook.token,
299
+ "Name": hook.name or "-",
300
+ "Status": hook.status.value,
301
+ "Run ID": hook.run_id,
302
+ "Created": hook.created_at.strftime("%Y-%m-%d %H:%M")
303
+ if hook.created_at
304
+ else "-",
305
+ "Expires": hook.expires_at.strftime("%Y-%m-%d %H:%M")
306
+ if hook.expires_at
307
+ else "-",
308
+ }
309
+ for hook in hooks_list
310
+ ]
311
+ format_table(
312
+ data,
313
+ ["Token", "Name", "Status", "Run ID", "Created", "Expires"],
314
+ title="Hooks",
315
+ )
316
+
317
+ except Exception as e:
318
+ print_error(f"Failed to list hooks: {e}")
319
+ if ctx.obj["verbose"]:
320
+ raise
321
+ raise click.Abort()
322
+
323
+
324
+ @hooks.command(name="info")
325
+ @click.argument("token")
326
+ @click.pass_context
327
+ @async_command
328
+ async def hook_info_cmd(ctx: click.Context, token: str) -> None:
329
+ """
330
+ Show hook details.
331
+
332
+ Args:
333
+ TOKEN: Hook token (format: run_id:hook_id)
334
+
335
+ Examples:
336
+
337
+ pyworkflow hooks info run_abc123:hook_approval_1
338
+ """
339
+ # Get context data
340
+ config = ctx.obj["config"]
341
+ output = ctx.obj["output"]
342
+ storage_type = ctx.obj["storage_type"]
343
+ storage_path = ctx.obj["storage_path"]
344
+
345
+ # Create storage backend
346
+ storage = create_storage(storage_type, storage_path, config)
347
+
348
+ # Get hook by token
349
+ try:
350
+ hook = await storage.get_hook_by_token(token)
351
+
352
+ if not hook:
353
+ print_error(f"Hook not found: {token}")
354
+ raise click.Abort()
355
+
356
+ # Format output
357
+ if output == "json":
358
+ data = {
359
+ "token": hook.token,
360
+ "hook_id": hook.hook_id,
361
+ "run_id": hook.run_id,
362
+ "name": hook.name,
363
+ "status": hook.status.value,
364
+ "created_at": hook.created_at.isoformat() if hook.created_at else None,
365
+ "expires_at": hook.expires_at.isoformat() if hook.expires_at else None,
366
+ "received_at": hook.received_at.isoformat() if hook.received_at else None,
367
+ "payload": json.loads(hook.payload) if hook.payload else None,
368
+ "payload_schema": json.loads(hook.payload_schema) if hook.payload_schema else None,
369
+ }
370
+ format_json(data)
371
+
372
+ else: # table or plain (use key-value format)
373
+ data = {
374
+ "Token": hook.token,
375
+ "Hook ID": hook.hook_id,
376
+ "Run ID": hook.run_id,
377
+ "Name": hook.name or "-",
378
+ "Status": hook.status.value,
379
+ "Created": hook.created_at.strftime("%Y-%m-%d %H:%M:%S")
380
+ if hook.created_at
381
+ else "-",
382
+ "Expires": hook.expires_at.strftime("%Y-%m-%d %H:%M:%S")
383
+ if hook.expires_at
384
+ else "-",
385
+ "Received": hook.received_at.strftime("%Y-%m-%d %H:%M:%S")
386
+ if hook.received_at
387
+ else "-",
388
+ }
389
+
390
+ # Show payload if received
391
+ if hook.payload:
392
+ try:
393
+ payload = json.loads(hook.payload)
394
+ data["Payload"] = json.dumps(payload, indent=2)
395
+ except json.JSONDecodeError:
396
+ data["Payload"] = hook.payload
397
+
398
+ # Show schema summary if available
399
+ if hook.payload_schema:
400
+ try:
401
+ schema = json.loads(hook.payload_schema)
402
+ fields = list(schema.get("properties", {}).keys())
403
+ required = schema.get("required", [])
404
+ data["Schema Fields"] = ", ".join(
405
+ f"{f}*" if f in required else f for f in fields
406
+ )
407
+ except json.JSONDecodeError:
408
+ data["Schema Fields"] = "Invalid schema"
409
+
410
+ format_key_value(data, title=f"Hook: {token}")
411
+
412
+ except click.Abort:
413
+ raise
414
+ except Exception as e:
415
+ print_error(f"Failed to get hook info: {e}")
416
+ if ctx.obj["verbose"]:
417
+ raise
418
+ raise click.Abort()
419
+
420
+
421
+ @hooks.command(name="run")
422
+ @click.argument("run_id")
423
+ @click.pass_context
424
+ @async_command
425
+ async def hooks_by_run_cmd(ctx: click.Context, run_id: str) -> None:
426
+ """
427
+ Show all hooks for a specific workflow run.
428
+
429
+ Args:
430
+ RUN_ID: Workflow run ID
431
+
432
+ Examples:
433
+
434
+ pyworkflow hooks run run_9b7d9218ebe341ca
435
+ """
436
+ # Get context data
437
+ config = ctx.obj["config"]
438
+ output = ctx.obj["output"]
439
+ storage_type = ctx.obj["storage_type"]
440
+ storage_path = ctx.obj["storage_path"]
441
+
442
+ # Create storage backend
443
+ storage = create_storage(storage_type, storage_path, config)
444
+
445
+ try:
446
+ # Get all hooks for this run
447
+ hooks_list = await storage.list_hooks(run_id=run_id)
448
+
449
+ if not hooks_list:
450
+ print_info(f"No hooks found for run: {run_id}")
451
+ return
452
+
453
+ # Format output
454
+ if output == "json":
455
+ data = {
456
+ "run_id": run_id,
457
+ "hook_count": len(hooks_list),
458
+ "hooks": [
459
+ {
460
+ "token": hook.token,
461
+ "hook_id": hook.hook_id,
462
+ "name": hook.name,
463
+ "status": hook.status.value,
464
+ "created_at": hook.created_at.isoformat() if hook.created_at else None,
465
+ "expires_at": hook.expires_at.isoformat() if hook.expires_at else None,
466
+ "received_at": hook.received_at.isoformat() if hook.received_at else None,
467
+ "payload": json.loads(hook.payload) if hook.payload else None,
468
+ "has_schema": hook.payload_schema is not None,
469
+ }
470
+ for hook in hooks_list
471
+ ],
472
+ }
473
+ format_json(data)
474
+
475
+ else: # table or plain
476
+ print(f"\n{Colors.PRIMARY}{Colors.bold(f'Hooks for Run: {run_id}')}{RESET}")
477
+ print(f"{DIM}{'─' * 60}{RESET}")
478
+ print(f"Total hooks: {len(hooks_list)}\n")
479
+
480
+ for i, hook in enumerate(hooks_list, 1):
481
+ # Status color
482
+ status_color = {
483
+ "pending": Colors.YELLOW,
484
+ "received": Colors.GREEN,
485
+ "expired": Colors.RED,
486
+ "disposed": Colors.GRAY,
487
+ }.get(hook.status.value, "")
488
+
489
+ print(f"{Colors.bold(f'{i}. {hook.name or hook.hook_id}')}")
490
+ print(f" Token: {hook.token}")
491
+ print(f" Status: {status_color}{hook.status.value}{RESET}")
492
+ print(
493
+ f" Created: {hook.created_at.strftime('%Y-%m-%d %H:%M:%S') if hook.created_at else '-'}"
494
+ )
495
+
496
+ if hook.expires_at:
497
+ print(f" Expires: {hook.expires_at.strftime('%Y-%m-%d %H:%M:%S')}")
498
+
499
+ if hook.received_at:
500
+ print(f" Received: {hook.received_at.strftime('%Y-%m-%d %H:%M:%S')}")
501
+
502
+ # Show payload if received
503
+ if hook.payload:
504
+ try:
505
+ payload = json.loads(hook.payload)
506
+ print(f" Payload: {json.dumps(payload)}")
507
+ except json.JSONDecodeError:
508
+ print(f" Payload: {hook.payload}")
509
+
510
+ # Show schema fields if available
511
+ if hook.payload_schema:
512
+ try:
513
+ schema = json.loads(hook.payload_schema)
514
+ fields = list(schema.get("properties", {}).keys())
515
+ required = schema.get("required", [])
516
+ fields_str = ", ".join(f"{f}*" if f in required else f for f in fields)
517
+ print(f" Schema: {fields_str}")
518
+ except json.JSONDecodeError:
519
+ pass
520
+
521
+ print() # Blank line between hooks
522
+
523
+ except Exception as e:
524
+ print_error(f"Failed to get hooks for run: {e}")
525
+ if ctx.obj["verbose"]:
526
+ raise
527
+ raise click.Abort()
528
+
529
+
530
+ @hooks.command(name="resume")
531
+ @click.argument("token", required=False)
532
+ @click.option(
533
+ "--payload",
534
+ "-p",
535
+ help="JSON payload to send (skip interactive prompt)",
536
+ )
537
+ @click.option(
538
+ "--payload-file",
539
+ "-f",
540
+ type=click.Path(exists=True),
541
+ help="Read payload from JSON file",
542
+ )
543
+ @click.pass_context
544
+ @async_command
545
+ async def resume_hook_cmd(
546
+ ctx: click.Context,
547
+ token: str | None,
548
+ payload: str | None,
549
+ payload_file: str | None,
550
+ ) -> None:
551
+ """
552
+ Resume a pending hook with payload.
553
+
554
+ When run without arguments, displays an interactive flow:
555
+ 1. Select a pending hook from the list
556
+ 2. Enter values for each payload field (based on schema)
557
+
558
+ Args:
559
+ TOKEN: Hook token (optional, will prompt if not provided)
560
+
561
+ Examples:
562
+
563
+ # Interactive mode - select hook and enter payload
564
+ pyworkflow hooks resume
565
+
566
+ # Direct mode with inline payload
567
+ pyworkflow hooks resume run_abc123:hook_approval_1 --payload '{"approved": true}'
568
+
569
+ # Direct mode with payload from file
570
+ pyworkflow hooks resume run_abc123:hook_approval_1 --payload-file payload.json
571
+ """
572
+ # Get context data
573
+ config = ctx.obj["config"]
574
+ output = ctx.obj["output"]
575
+ storage_type = ctx.obj["storage_type"]
576
+ storage_path = ctx.obj["storage_path"]
577
+
578
+ # Create storage backend
579
+ storage = create_storage(storage_type, storage_path, config)
580
+
581
+ try:
582
+ # Step 1: Select hook if not provided
583
+ if not token:
584
+ print_breadcrumb(["hook", "payload"], 0)
585
+ token = await _select_pending_hook_async(storage)
586
+ if not token:
587
+ raise click.Abort()
588
+
589
+ # Get hook details (for schema)
590
+ hook = await storage.get_hook_by_token(token)
591
+ if not hook:
592
+ print_error(f"Hook not found: {token}")
593
+ raise click.Abort()
594
+
595
+ if hook.status != HookStatus.PENDING:
596
+ print_error(f"Hook is not pending (status: {hook.status.value})")
597
+ raise click.Abort()
598
+
599
+ # Step 2: Get payload
600
+ if payload_file:
601
+ with open(payload_file) as f:
602
+ payload_data = json.load(f)
603
+ elif payload:
604
+ try:
605
+ payload_data = json.loads(payload)
606
+ except json.JSONDecodeError as e:
607
+ print_error(f"Invalid JSON payload: {e}")
608
+ raise click.Abort()
609
+ else:
610
+ # Interactive mode - prompt for payload fields
611
+ print_breadcrumb(["hook", "payload"], 1)
612
+ payload_data = await _prompt_for_payload_async(hook)
613
+ print() # Add spacing after prompts
614
+
615
+ # Resume the hook
616
+ from pyworkflow.primitives.resume_hook import resume_hook
617
+
618
+ result = await resume_hook(token, payload_data, storage=storage)
619
+
620
+ # Output result
621
+ if output == "json":
622
+ format_json(
623
+ {
624
+ "run_id": result.run_id,
625
+ "hook_id": result.hook_id,
626
+ "status": result.status,
627
+ }
628
+ )
629
+ else:
630
+ print_success(f"Hook resumed: {result.hook_id}")
631
+ print_info(f"Run ID: {result.run_id}")
632
+ print_info(f"Payload: {json.dumps(payload_data)}")
633
+
634
+ except click.Abort:
635
+ raise
636
+ except Exception as e:
637
+ print_error(f"Failed to resume hook: {e}")
638
+ if ctx.obj["verbose"]:
639
+ raise
640
+ raise click.Abort()