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,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()
|