hexaqueue-cli 0.1.0__tar.gz → 0.3.0__tar.gz
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.
- {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/PKG-INFO +6 -5
- {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/pyproject.toml +9 -5
- {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/pyproject.toml.orig +7 -5
- {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/adapters/__init__.py +2 -2
- hexaqueue_cli-0.3.0/src/hexaqueue_cli/adapters/presenter.py +371 -0
- hexaqueue_cli-0.3.0/src/hexaqueue_cli/commands/__init__.py +13 -0
- {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/commands/run.py +22 -23
- hexaqueue_cli-0.3.0/src/hexaqueue_cli/commands/workflow.py +298 -0
- {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/domain/__init__.py +2 -0
- hexaqueue_cli-0.3.0/src/hexaqueue_cli/domain/options.py +25 -0
- hexaqueue_cli-0.3.0/src/hexaqueue_cli/infra/__init__.py +8 -0
- hexaqueue_cli-0.3.0/src/hexaqueue_cli/infra/options.py +73 -0
- {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/main.py +9 -40
- hexaqueue_cli-0.1.0/src/hexaqueue_cli/commands/__init__.py +0 -7
- hexaqueue_cli-0.1.0/src/hexaqueue_cli/infra/__init__.py +0 -7
- {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/README.md +0 -0
- {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/__init__.py +0 -0
- {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/adapters/local.py +0 -0
- {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/domain/parser.py +0 -0
- {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/domain/session.py +0 -0
- {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/infra/bootstrap.py +0 -0
- {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/ports/__init__.py +0 -0
- {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/ports/client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hexaqueue-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Hexaqueue CLI - Modern developer and operator terminal interface
|
|
5
5
|
Author: Richard West
|
|
6
6
|
Author-email: Richard West <dopplereffect.us@gmail.com>
|
|
@@ -8,10 +8,11 @@ License-Expression: Apache-2.0
|
|
|
8
8
|
Requires-Dist: hexaqueue-core
|
|
9
9
|
Requires-Dist: hexaqueue-server
|
|
10
10
|
Requires-Dist: hexaqueue-worker
|
|
11
|
-
Requires-Dist:
|
|
12
|
-
Requires-Dist: hexastack-
|
|
13
|
-
Requires-Dist: hexastack-
|
|
14
|
-
Requires-Dist: hexastack-
|
|
11
|
+
Requires-Dist: hexaqueue-workflow
|
|
12
|
+
Requires-Dist: hexastack-cli>=0.6.0
|
|
13
|
+
Requires-Dist: hexastack-core>=0.6.0
|
|
14
|
+
Requires-Dist: hexastack-cqrs>=0.6.0
|
|
15
|
+
Requires-Dist: hexastack-grpc>=0.6.0
|
|
15
16
|
Requires-Dist: pyyaml>=6.0.2
|
|
16
17
|
Requires-Dist: rich>=13.8.0
|
|
17
18
|
Requires-Dist: typer>=0.15.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "hexaqueue-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Hexaqueue CLI - Modern developer and operator terminal interface"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -9,10 +9,11 @@ dependencies = [
|
|
|
9
9
|
"hexaqueue-core",
|
|
10
10
|
"hexaqueue-server",
|
|
11
11
|
"hexaqueue-worker",
|
|
12
|
-
"
|
|
13
|
-
"hexastack-
|
|
14
|
-
"hexastack-
|
|
15
|
-
"hexastack-
|
|
12
|
+
"hexaqueue-workflow",
|
|
13
|
+
"hexastack-cli>=0.6.0",
|
|
14
|
+
"hexastack-core>=0.6.0",
|
|
15
|
+
"hexastack-cqrs>=0.6.0",
|
|
16
|
+
"hexastack-grpc>=0.6.0",
|
|
16
17
|
"pyyaml>=6.0.2",
|
|
17
18
|
"rich>=13.8.0",
|
|
18
19
|
"typer>=0.15.0",
|
|
@@ -37,3 +38,6 @@ workspace = true
|
|
|
37
38
|
|
|
38
39
|
[tool.uv.sources.hexaqueue-worker]
|
|
39
40
|
workspace = true
|
|
41
|
+
|
|
42
|
+
[tool.uv.sources.hexaqueue-workflow]
|
|
43
|
+
workspace = true
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "hexaqueue-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Hexaqueue CLI - Modern developer and operator terminal interface"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -12,10 +12,11 @@ dependencies = [
|
|
|
12
12
|
"hexaqueue-core",
|
|
13
13
|
"hexaqueue-server",
|
|
14
14
|
"hexaqueue-worker",
|
|
15
|
-
"
|
|
16
|
-
"hexastack-
|
|
17
|
-
"hexastack-
|
|
18
|
-
"hexastack-
|
|
15
|
+
"hexaqueue-workflow",
|
|
16
|
+
"hexastack-cli>=0.6.0",
|
|
17
|
+
"hexastack-core>=0.6.0",
|
|
18
|
+
"hexastack-cqrs>=0.6.0",
|
|
19
|
+
"hexastack-grpc>=0.6.0",
|
|
19
20
|
"pyyaml>=6.0.2",
|
|
20
21
|
"rich>=13.8.0",
|
|
21
22
|
"typer>=0.15.0",
|
|
@@ -32,3 +33,4 @@ build-backend = "uv_build"
|
|
|
32
33
|
hexaqueue-core = { workspace = true }
|
|
33
34
|
hexaqueue-server = { workspace = true }
|
|
34
35
|
hexaqueue-worker = { workspace = true }
|
|
36
|
+
hexaqueue-workflow = { workspace = true }
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Terminal and pipeline output presenters for Hexaqueue CLI commands.
|
|
2
|
+
|
|
3
|
+
Notes/Architectural Intent:
|
|
4
|
+
Provides formatted output rendering across multiple formats (table, json, markdown, plain, rich).
|
|
5
|
+
Ensures seamless piping into downstream Unix utilities when non-interactive, while
|
|
6
|
+
delivering rich visual dashboards for human terminal operators.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.panel import Panel
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
|
|
21
|
+
from hexaqueue_cli.domain.options import OutputFormat
|
|
22
|
+
from hexaqueue_core.domain.job import JobSpec
|
|
23
|
+
from hexaqueue_server.domain.models import RunStatusReport
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"CliPresenter",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CliPresenter:
|
|
31
|
+
"""Presenter formatting CLI models and reports across supported output formats.
|
|
32
|
+
|
|
33
|
+
Notes/Architectural Intent:
|
|
34
|
+
Implements rendering for table, json, markdown, plain, and rich.
|
|
35
|
+
Allows commands to dispatch results cleanly without mixing presentation logic
|
|
36
|
+
with orchestration.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
console: Console | None = None,
|
|
42
|
+
stderr_console: Console | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Initialize CliPresenter with optional consoles.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
console: Optional Rich console for stdout.
|
|
48
|
+
stderr_console: Optional Rich console for stderr.
|
|
49
|
+
"""
|
|
50
|
+
no_color = bool(os.environ.get("NO_COLOR"))
|
|
51
|
+
self._console = console or Console(no_color=no_color, highlight=not no_color)
|
|
52
|
+
self._stderr = stderr_console or Console(
|
|
53
|
+
stderr=True, no_color=no_color, highlight=not no_color
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def render_run_status(
|
|
57
|
+
self,
|
|
58
|
+
report: RunStatusReport,
|
|
59
|
+
format_type: str | OutputFormat = OutputFormat.TABLE,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Render a pipeline run status report in the chosen format.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
report: RunStatusReport domain model.
|
|
65
|
+
format_type: Chosen output format string or OutputFormat enum.
|
|
66
|
+
"""
|
|
67
|
+
fmt = (
|
|
68
|
+
format_type.value
|
|
69
|
+
if isinstance(format_type, OutputFormat)
|
|
70
|
+
else str(format_type).lower().strip()
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
data = {
|
|
74
|
+
"run_id": report.run_id,
|
|
75
|
+
"state": (
|
|
76
|
+
report.state.value
|
|
77
|
+
if hasattr(report.state, "value")
|
|
78
|
+
else str(report.state)
|
|
79
|
+
),
|
|
80
|
+
"outcome": (
|
|
81
|
+
report.outcome.value
|
|
82
|
+
if hasattr(report.outcome, "value")
|
|
83
|
+
else (str(report.outcome) if report.outcome else None)
|
|
84
|
+
),
|
|
85
|
+
"total_jobs": report.total_jobs,
|
|
86
|
+
"completed_jobs": report.completed_jobs,
|
|
87
|
+
"failed_jobs": report.failed_jobs,
|
|
88
|
+
"pending_jobs": report.pending_jobs,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if fmt == OutputFormat.JSON.value:
|
|
92
|
+
sys.stdout.write(json.dumps(data, indent=2) + "\n")
|
|
93
|
+
sys.stdout.flush()
|
|
94
|
+
elif fmt == OutputFormat.MARKDOWN.value:
|
|
95
|
+
lines = [
|
|
96
|
+
f"### Run Status: {report.run_id}",
|
|
97
|
+
"",
|
|
98
|
+
"| Field | Value |",
|
|
99
|
+
"|---|---|",
|
|
100
|
+
f"| Run ID | `{report.run_id}` |",
|
|
101
|
+
f"| State | **{data['state']}** |",
|
|
102
|
+
f"| Outcome | {data['outcome'] or '-'} |",
|
|
103
|
+
f"| Total Jobs | {report.total_jobs} |",
|
|
104
|
+
f"| Completed | {report.completed_jobs} |",
|
|
105
|
+
f"| Failed | {report.failed_jobs} |",
|
|
106
|
+
f"| Pending | {report.pending_jobs} |",
|
|
107
|
+
]
|
|
108
|
+
sys.stdout.write("\n".join(lines) + "\n")
|
|
109
|
+
sys.stdout.flush()
|
|
110
|
+
elif fmt == OutputFormat.PLAIN.value:
|
|
111
|
+
for k, v in data.items():
|
|
112
|
+
sys.stdout.write(f"{k}\t{v}\n")
|
|
113
|
+
sys.stdout.flush()
|
|
114
|
+
else:
|
|
115
|
+
# table or rich
|
|
116
|
+
table = Table(title=f"Run Status: {report.run_id}")
|
|
117
|
+
table.add_column("Run ID", style="cyan")
|
|
118
|
+
table.add_column("State", style="magenta")
|
|
119
|
+
table.add_column(
|
|
120
|
+
"Outcome",
|
|
121
|
+
style="green"
|
|
122
|
+
if str(report.outcome) in ("COMPLETED", "SUCCEEDED")
|
|
123
|
+
else "yellow",
|
|
124
|
+
)
|
|
125
|
+
table.add_column("Total", justify="center")
|
|
126
|
+
table.add_column("Completed", justify="center", style="green")
|
|
127
|
+
table.add_column("Failed", justify="center", style="red")
|
|
128
|
+
table.add_column("Pending", justify="center", style="yellow")
|
|
129
|
+
table.add_row(
|
|
130
|
+
report.run_id,
|
|
131
|
+
data["state"],
|
|
132
|
+
str(report.outcome) if report.outcome else "-",
|
|
133
|
+
str(report.total_jobs),
|
|
134
|
+
str(report.completed_jobs),
|
|
135
|
+
str(report.failed_jobs),
|
|
136
|
+
str(report.pending_jobs),
|
|
137
|
+
)
|
|
138
|
+
self._console.print(table)
|
|
139
|
+
|
|
140
|
+
def render_job(
|
|
141
|
+
self,
|
|
142
|
+
job: JobSpec,
|
|
143
|
+
format_type: str | OutputFormat = OutputFormat.TABLE,
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Render a single JobSpec in the chosen format.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
job: JobSpec domain model.
|
|
149
|
+
format_type: Chosen output format string or OutputFormat enum.
|
|
150
|
+
"""
|
|
151
|
+
fmt = (
|
|
152
|
+
format_type.value
|
|
153
|
+
if isinstance(format_type, OutputFormat)
|
|
154
|
+
else str(format_type).lower().strip()
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
state_val = job.state.value if hasattr(job.state, "value") else str(job.state)
|
|
158
|
+
outcome_val = (
|
|
159
|
+
job.outcome.value
|
|
160
|
+
if hasattr(job.outcome, "value")
|
|
161
|
+
else (str(job.outcome) if job.outcome else None)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
data = {
|
|
165
|
+
"id": job.id,
|
|
166
|
+
"run_id": job.run_id,
|
|
167
|
+
"name": job.name,
|
|
168
|
+
"state": state_val,
|
|
169
|
+
"outcome": outcome_val,
|
|
170
|
+
"command": job.command,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if fmt == OutputFormat.JSON.value:
|
|
174
|
+
sys.stdout.write(json.dumps(data, indent=2) + "\n")
|
|
175
|
+
sys.stdout.flush()
|
|
176
|
+
elif fmt == OutputFormat.MARKDOWN.value:
|
|
177
|
+
lines = [
|
|
178
|
+
f"### Job Status: {job.id}",
|
|
179
|
+
"",
|
|
180
|
+
"| Field | Value |",
|
|
181
|
+
"|---|---|",
|
|
182
|
+
f"| Job ID | `{job.id}` |",
|
|
183
|
+
f"| Run ID | `{job.run_id}` |",
|
|
184
|
+
f"| Name | {job.name} |",
|
|
185
|
+
f"| State | **{state_val}** |",
|
|
186
|
+
f"| Outcome | {outcome_val or '-'} |",
|
|
187
|
+
f"| Command | `{job.command}` |",
|
|
188
|
+
]
|
|
189
|
+
sys.stdout.write("\n".join(lines) + "\n")
|
|
190
|
+
sys.stdout.flush()
|
|
191
|
+
elif fmt == OutputFormat.PLAIN.value:
|
|
192
|
+
for k, v in data.items():
|
|
193
|
+
sys.stdout.write(f"{k}\t{v}\n")
|
|
194
|
+
sys.stdout.flush()
|
|
195
|
+
else:
|
|
196
|
+
table = Table(title=f"Job Status: {job.id}")
|
|
197
|
+
table.add_column("Job ID", style="cyan")
|
|
198
|
+
table.add_column("Run ID", style="blue")
|
|
199
|
+
table.add_column("Name")
|
|
200
|
+
table.add_column("State", style="magenta")
|
|
201
|
+
table.add_column("Outcome")
|
|
202
|
+
table.add_row(
|
|
203
|
+
job.id,
|
|
204
|
+
job.run_id,
|
|
205
|
+
job.name,
|
|
206
|
+
state_val,
|
|
207
|
+
str(job.outcome) if job.outcome else "-",
|
|
208
|
+
)
|
|
209
|
+
self._console.print(table)
|
|
210
|
+
|
|
211
|
+
def _serialize_checkpoints(self, checkpoints: list[Any]) -> list[dict[str, Any]]:
|
|
212
|
+
"""Extract serialized dictionary representations of step checkpoints."""
|
|
213
|
+
chk_list: list[dict[str, Any]] = []
|
|
214
|
+
for chk in checkpoints:
|
|
215
|
+
chk_status = (
|
|
216
|
+
chk.status.value if hasattr(chk.status, "value") else str(chk.status)
|
|
217
|
+
)
|
|
218
|
+
payload = (
|
|
219
|
+
chk.output_payload
|
|
220
|
+
if not isinstance(chk.output_payload, BaseModel)
|
|
221
|
+
else chk.output_payload.model_dump()
|
|
222
|
+
)
|
|
223
|
+
chk_list.append(
|
|
224
|
+
{
|
|
225
|
+
"stage_name": chk.stage_name,
|
|
226
|
+
"step_name": chk.step_name,
|
|
227
|
+
"status": chk_status,
|
|
228
|
+
"attempt_number": chk.attempt_number,
|
|
229
|
+
"duration_seconds": round(chk.duration_seconds, 3),
|
|
230
|
+
"output_payload": payload,
|
|
231
|
+
}
|
|
232
|
+
)
|
|
233
|
+
return chk_list
|
|
234
|
+
|
|
235
|
+
def _render_workflow_json(self, data: dict[str, Any]) -> None:
|
|
236
|
+
"""Output workflow status report as indented JSON."""
|
|
237
|
+
sys.stdout.write(json.dumps(data, indent=2, default=str) + "\n")
|
|
238
|
+
sys.stdout.flush()
|
|
239
|
+
|
|
240
|
+
def _render_workflow_markdown(self, data: dict[str, Any]) -> None:
|
|
241
|
+
"""Output workflow status report as Markdown."""
|
|
242
|
+
lines = [
|
|
243
|
+
f"### Workflow Run: {data['workflow_name']} (`{data['run_id']}`)",
|
|
244
|
+
"",
|
|
245
|
+
f"- **Status**: `{data['status']}`",
|
|
246
|
+
]
|
|
247
|
+
if data.get("current_stage"):
|
|
248
|
+
lines.append(f"- **Current Stage**: {data['current_stage']}")
|
|
249
|
+
if data.get("error_summary"):
|
|
250
|
+
lines.append(f"- **Error**: {data['error_summary']}")
|
|
251
|
+
lines.extend(
|
|
252
|
+
[
|
|
253
|
+
"",
|
|
254
|
+
"| Stage | Step | Status | Attempt | Duration (s) |",
|
|
255
|
+
"|---|---|---|---|---|",
|
|
256
|
+
]
|
|
257
|
+
)
|
|
258
|
+
for c in data.get("checkpoints", []):
|
|
259
|
+
lines.append(
|
|
260
|
+
f"| {c['stage_name']} | {c['step_name']} | {c['status']} | {c['attempt_number']} | {c['duration_seconds']} |"
|
|
261
|
+
)
|
|
262
|
+
sys.stdout.write("\n".join(lines) + "\n")
|
|
263
|
+
sys.stdout.flush()
|
|
264
|
+
|
|
265
|
+
def _render_workflow_plain(self, data: dict[str, Any]) -> None:
|
|
266
|
+
"""Output workflow status report as plain tab-delimited text."""
|
|
267
|
+
sys.stdout.write(f"run_id\t{data['run_id']}\n")
|
|
268
|
+
sys.stdout.write(f"workflow\t{data['workflow_name']}\n")
|
|
269
|
+
sys.stdout.write(f"status\t{data['status']}\n")
|
|
270
|
+
for c in data.get("checkpoints", []):
|
|
271
|
+
sys.stdout.write(
|
|
272
|
+
f"{c['stage_name']}\t{c['step_name']}\t{c['status']}\t{c['duration_seconds']}\n"
|
|
273
|
+
)
|
|
274
|
+
sys.stdout.flush()
|
|
275
|
+
|
|
276
|
+
def _render_workflow_table(self, data: dict[str, Any]) -> None:
|
|
277
|
+
"""Output workflow status report as interactive Rich table and panel."""
|
|
278
|
+
header_table = Table.grid(padding=(0, 2))
|
|
279
|
+
header_table.add_column(style="bold")
|
|
280
|
+
header_table.add_column()
|
|
281
|
+
header_table.add_row("Run ID:", str(data["run_id"]))
|
|
282
|
+
header_table.add_row("Workflow:", str(data["workflow_name"]))
|
|
283
|
+
header_table.add_row("Status:", f"[bold cyan]{data['status']}[/]")
|
|
284
|
+
if data.get("current_stage"):
|
|
285
|
+
header_table.add_row("Current Stage:", str(data["current_stage"]))
|
|
286
|
+
if data.get("error_summary"):
|
|
287
|
+
header_table.add_row("Error:", f"[bold red]{data['error_summary']}[/]")
|
|
288
|
+
|
|
289
|
+
self._console.print(
|
|
290
|
+
Panel(
|
|
291
|
+
header_table,
|
|
292
|
+
title=f"Workflow Run: {data['workflow_name']}",
|
|
293
|
+
expand=False,
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
chk_list = data.get("checkpoints", [])
|
|
298
|
+
if not chk_list:
|
|
299
|
+
self._console.print("[dim]No step checkpoints recorded yet.[/]")
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
table = Table(title="Step Checkpoints & Staged Artifacts", expand=True)
|
|
303
|
+
table.add_column("Stage", style="cyan")
|
|
304
|
+
table.add_column("Step", style="bold")
|
|
305
|
+
table.add_column("Status", justify="center")
|
|
306
|
+
table.add_column("Attempt", justify="right")
|
|
307
|
+
table.add_column("Duration (s)", justify="right")
|
|
308
|
+
table.add_column("Artifact / Output", style="dim")
|
|
309
|
+
|
|
310
|
+
for chk in chk_list:
|
|
311
|
+
chk_color = (
|
|
312
|
+
"[green]COMPLETED[/]"
|
|
313
|
+
if chk["status"] == "COMPLETED"
|
|
314
|
+
else (
|
|
315
|
+
"[red]FAILED[/]"
|
|
316
|
+
if chk["status"] == "FAILED"
|
|
317
|
+
else str(chk["status"])
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
payload_repr = str(chk["output_payload"])
|
|
321
|
+
if len(payload_repr) > 30:
|
|
322
|
+
payload_repr = payload_repr[:30] + "..."
|
|
323
|
+
|
|
324
|
+
table.add_row(
|
|
325
|
+
chk["stage_name"],
|
|
326
|
+
chk["step_name"],
|
|
327
|
+
chk_color,
|
|
328
|
+
str(chk["attempt_number"]),
|
|
329
|
+
f"{chk['duration_seconds']:.3f}",
|
|
330
|
+
payload_repr,
|
|
331
|
+
)
|
|
332
|
+
self._console.print(table)
|
|
333
|
+
|
|
334
|
+
def render_workflow_state(
|
|
335
|
+
self,
|
|
336
|
+
state: Any,
|
|
337
|
+
checkpoints: list[Any],
|
|
338
|
+
format_type: str | OutputFormat = OutputFormat.TABLE,
|
|
339
|
+
) -> None:
|
|
340
|
+
"""Render workflow execution state and checkpoints in the chosen format.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
state: WorkflowExecutionState instance.
|
|
344
|
+
checkpoints: List of CheckpointRecord instances.
|
|
345
|
+
format_type: Chosen output format string or OutputFormat enum.
|
|
346
|
+
"""
|
|
347
|
+
fmt = (
|
|
348
|
+
format_type.value
|
|
349
|
+
if isinstance(format_type, OutputFormat)
|
|
350
|
+
else str(format_type).lower().strip()
|
|
351
|
+
)
|
|
352
|
+
status_val = (
|
|
353
|
+
state.status.value if hasattr(state.status, "value") else str(state.status)
|
|
354
|
+
)
|
|
355
|
+
data = {
|
|
356
|
+
"run_id": state.run_id,
|
|
357
|
+
"workflow_name": state.workflow_name,
|
|
358
|
+
"status": status_val,
|
|
359
|
+
"current_stage": getattr(state, "current_stage", None),
|
|
360
|
+
"error_summary": getattr(state, "error_summary", None),
|
|
361
|
+
"checkpoints": self._serialize_checkpoints(checkpoints),
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if fmt == OutputFormat.JSON.value:
|
|
365
|
+
self._render_workflow_json(data)
|
|
366
|
+
elif fmt == OutputFormat.MARKDOWN.value:
|
|
367
|
+
self._render_workflow_markdown(data)
|
|
368
|
+
elif fmt == OutputFormat.PLAIN.value:
|
|
369
|
+
self._render_workflow_plain(data)
|
|
370
|
+
else:
|
|
371
|
+
self._render_workflow_table(data)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Commands package for hexaqueue-cli.
|
|
2
|
+
|
|
3
|
+
Notes/Architectural Intent:
|
|
4
|
+
Exposes Typer subcommand modules for batch run pipelines and distributed workflow execution.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from hexaqueue_cli.commands.run import app as run_app
|
|
8
|
+
from hexaqueue_cli.commands.workflow import app as workflow_app
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"run_app",
|
|
12
|
+
"workflow_app",
|
|
13
|
+
]
|
|
@@ -10,11 +10,12 @@ from pathlib import Path
|
|
|
10
10
|
|
|
11
11
|
import typer
|
|
12
12
|
from rich.console import Console
|
|
13
|
-
from rich.table import Table
|
|
14
13
|
|
|
15
14
|
from hexaqueue_cli.adapters.local import LocalClientAdapter
|
|
15
|
+
from hexaqueue_cli.adapters.presenter import CliPresenter
|
|
16
16
|
from hexaqueue_cli.domain.parser import parse_run_spec_from_file
|
|
17
17
|
from hexaqueue_cli.domain.session import get_default_session
|
|
18
|
+
from hexaqueue_cli.infra.options import format_option, resolve_format
|
|
18
19
|
from hexaqueue_core.domain.lifecycle import RunState
|
|
19
20
|
|
|
20
21
|
app = typer.Typer(help="Pipeline run management commands.")
|
|
@@ -27,6 +28,7 @@ def submit_cmd(
|
|
|
27
28
|
watch: bool = typer.Option(
|
|
28
29
|
False, "--watch", "-w", help="Watch run execution until completion"
|
|
29
30
|
),
|
|
31
|
+
format_type: str = format_option(),
|
|
30
32
|
) -> None:
|
|
31
33
|
"""Submit a DAG pipeline definition file."""
|
|
32
34
|
try:
|
|
@@ -35,41 +37,38 @@ def submit_cmd(
|
|
|
35
37
|
console.print(f"[bold red]Error parsing pipeline spec:[/] {e}")
|
|
36
38
|
raise typer.Exit(code=1) from e
|
|
37
39
|
|
|
40
|
+
resolved_fmt = resolve_format(format_type)
|
|
41
|
+
presenter = CliPresenter(console=console)
|
|
42
|
+
|
|
38
43
|
async def _run() -> None:
|
|
39
44
|
session = get_default_session()
|
|
40
45
|
await session.start()
|
|
41
46
|
client = LocalClientAdapter(session=session)
|
|
42
47
|
|
|
43
48
|
report = await client.submit_run(submission)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
49
|
+
if resolved_fmt in ("table", "rich"):
|
|
50
|
+
console.print(
|
|
51
|
+
f"[bold green]✓[/] Run '[bold cyan]{report.run_id}[/]' submitted ({report.total_jobs} jobs)"
|
|
52
|
+
)
|
|
53
|
+
else:
|
|
54
|
+
presenter.render_run_status(report, resolved_fmt)
|
|
55
|
+
|
|
56
|
+
if not watch:
|
|
57
|
+
return
|
|
47
58
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
report = await client.get_run_status(report.run_id)
|
|
59
|
+
with console.status(f"[bold blue]Executing run {report.run_id}...[/]"):
|
|
60
|
+
while report.state not in (RunState.DONE, RunState.BLOCKED):
|
|
61
|
+
await asyncio.sleep(0.1)
|
|
62
|
+
report = await client.get_run_status(report.run_id)
|
|
53
63
|
|
|
64
|
+
if resolved_fmt in ("table", "rich"):
|
|
54
65
|
outcome_str = (
|
|
55
66
|
f"[bold green]{report.outcome}[/]"
|
|
56
|
-
if report.outcome
|
|
67
|
+
if str(report.outcome) in ("COMPLETED", "SUCCEEDED")
|
|
57
68
|
else f"[bold red]{report.outcome}[/]"
|
|
58
69
|
)
|
|
59
70
|
console.print(f"Run completed with status: {outcome_str}")
|
|
60
|
-
|
|
61
|
-
table = Table(title=f"Run Summary: {report.run_id}")
|
|
62
|
-
table.add_column("Total", justify="center")
|
|
63
|
-
table.add_column("Completed", justify="center", style="green")
|
|
64
|
-
table.add_column("Failed", justify="center", style="red")
|
|
65
|
-
table.add_column("Pending", justify="center", style="yellow")
|
|
66
|
-
table.add_row(
|
|
67
|
-
str(report.total_jobs),
|
|
68
|
-
str(report.completed_jobs),
|
|
69
|
-
str(report.failed_jobs),
|
|
70
|
-
str(report.pending_jobs),
|
|
71
|
-
)
|
|
72
|
-
console.print(table)
|
|
71
|
+
presenter.render_run_status(report, resolved_fmt)
|
|
73
72
|
|
|
74
73
|
asyncio.run(_run())
|
|
75
74
|
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""CLI commands for Hexaflow distributed workflow management in Hexaqueue.
|
|
2
|
+
|
|
3
|
+
Notes/Architectural Intent:
|
|
4
|
+
Provides subcommands for submitting workflow DAG definitions to the cluster,
|
|
5
|
+
inspecting step checkpoints and staged artifact URIs, resuming suspended workflows,
|
|
6
|
+
and aborting active workflows with reverse compensation unwinding.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import importlib.util
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from hexaflow.adapters.storage.sqlite import SqliteStateStore
|
|
17
|
+
from hexaflow.domain.models import WorkflowDefinition
|
|
18
|
+
from hexaflow.dsl.builder import Workflow
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
|
|
21
|
+
from hexaqueue_cli.adapters.presenter import CliPresenter
|
|
22
|
+
from hexaqueue_cli.infra.options import format_option, resolve_format
|
|
23
|
+
from hexaqueue_workflow.adapters.engines.distributed import HexaqueueDistributedEngine
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(
|
|
26
|
+
name="workflow",
|
|
27
|
+
help="Hexaflow distributed workflow orchestration and cluster scheduling commands.",
|
|
28
|
+
no_args_is_help=True,
|
|
29
|
+
)
|
|
30
|
+
console = Console()
|
|
31
|
+
presenter = CliPresenter(console=console)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_workflow_from_target(target: str) -> WorkflowDefinition:
|
|
35
|
+
"""Dynamically load a Workflow or WorkflowDefinition from a file target.
|
|
36
|
+
|
|
37
|
+
Supports 'path/to/file.py:workflow_name' or 'path/to/file.py' looking for
|
|
38
|
+
the 'workflow' variable or an instance of Workflow/WorkflowDefinition.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
target: String path or module identifier (e.g. 'flows/pipeline.py:main_flow').
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Compiled WorkflowDefinition.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
typer.BadParameter: If file or workflow variable cannot be loaded.
|
|
48
|
+
|
|
49
|
+
Notes/Architectural Intent:
|
|
50
|
+
Enables seamless loading of user-defined DAG workflows without requiring
|
|
51
|
+
prior compilation or separate registration steps.
|
|
52
|
+
"""
|
|
53
|
+
if ":" in target:
|
|
54
|
+
file_part, attr_name = target.split(":", 1)
|
|
55
|
+
else:
|
|
56
|
+
file_part, attr_name = target, None
|
|
57
|
+
|
|
58
|
+
file_path = Path(file_part).resolve()
|
|
59
|
+
if not file_path.exists():
|
|
60
|
+
msg = f"Workflow file '{file_path}' does not exist"
|
|
61
|
+
raise typer.BadParameter(msg)
|
|
62
|
+
|
|
63
|
+
spec = importlib.util.spec_from_file_location("dynamic_workflow_module", file_path)
|
|
64
|
+
if not spec or not spec.loader:
|
|
65
|
+
msg = f"Cannot load Python module from '{file_path}'"
|
|
66
|
+
raise typer.BadParameter(msg)
|
|
67
|
+
|
|
68
|
+
module = importlib.util.module_from_spec(spec)
|
|
69
|
+
spec.loader.exec_module(module)
|
|
70
|
+
|
|
71
|
+
wf_candidate: Any = None
|
|
72
|
+
if attr_name:
|
|
73
|
+
if not hasattr(module, attr_name):
|
|
74
|
+
msg = f"Module '{file_path}' does not define attribute '{attr_name}'"
|
|
75
|
+
raise typer.BadParameter(msg)
|
|
76
|
+
wf_candidate = getattr(module, attr_name)
|
|
77
|
+
else:
|
|
78
|
+
if hasattr(module, "workflow"):
|
|
79
|
+
wf_candidate = module.workflow
|
|
80
|
+
else:
|
|
81
|
+
for item in vars(module).values():
|
|
82
|
+
if isinstance(item, (Workflow, WorkflowDefinition)):
|
|
83
|
+
wf_candidate = item
|
|
84
|
+
break
|
|
85
|
+
|
|
86
|
+
if wf_candidate is None:
|
|
87
|
+
msg = f"No Workflow or WorkflowDefinition instance found in '{file_path}'"
|
|
88
|
+
raise typer.BadParameter(msg)
|
|
89
|
+
|
|
90
|
+
if isinstance(wf_candidate, Workflow):
|
|
91
|
+
return wf_candidate.compile()
|
|
92
|
+
if isinstance(wf_candidate, WorkflowDefinition):
|
|
93
|
+
return wf_candidate
|
|
94
|
+
|
|
95
|
+
msg = f"Target object '{wf_candidate}' is not a Workflow or WorkflowDefinition"
|
|
96
|
+
raise typer.BadParameter(msg)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_inputs_arg(inputs: str | None) -> dict[str, Any]:
|
|
100
|
+
"""Parse JSON string or key=val pairs into a dictionary.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
inputs: Raw CLI inputs argument string.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Dictionary of input arguments.
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
typer.BadParameter: If JSON decoding fails.
|
|
110
|
+
"""
|
|
111
|
+
if not inputs:
|
|
112
|
+
return {}
|
|
113
|
+
try:
|
|
114
|
+
parsed = json.loads(inputs)
|
|
115
|
+
if isinstance(parsed, dict):
|
|
116
|
+
return parsed
|
|
117
|
+
msg = "Input JSON must be an object/dict"
|
|
118
|
+
raise ValueError(msg)
|
|
119
|
+
except Exception as e:
|
|
120
|
+
msg = f"Invalid JSON inputs string: {e}"
|
|
121
|
+
raise typer.BadParameter(msg) from e
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@app.command("submit")
|
|
125
|
+
def submit_cmd(
|
|
126
|
+
target: str = typer.Argument(
|
|
127
|
+
...,
|
|
128
|
+
help="Path to workflow file, e.g. 'pipeline.py' or 'pipeline.py:my_flow'",
|
|
129
|
+
),
|
|
130
|
+
inputs: str | None = typer.Option(
|
|
131
|
+
None, "--inputs", "-i", help="JSON dictionary of initial inputs"
|
|
132
|
+
),
|
|
133
|
+
db_path: str = typer.Option(
|
|
134
|
+
".hexaflow/state.db", "--db", help="Path to SQLite state database"
|
|
135
|
+
),
|
|
136
|
+
watch: bool = typer.Option(
|
|
137
|
+
False, "--watch", "-w", help="Watch workflow execution until completion"
|
|
138
|
+
),
|
|
139
|
+
format_type: str = format_option(),
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Submit a workflow definition for distributed cluster execution.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
target: Target file path or specifier.
|
|
145
|
+
inputs: Optional JSON inputs string.
|
|
146
|
+
db_path: SQLite state store database path.
|
|
147
|
+
watch: Whether to block and watch until terminal completion.
|
|
148
|
+
format_type: Output presentation format.
|
|
149
|
+
"""
|
|
150
|
+
workflow = load_workflow_from_target(target)
|
|
151
|
+
initial_inputs = parse_inputs_arg(inputs)
|
|
152
|
+
resolved_fmt = resolve_format(format_type)
|
|
153
|
+
|
|
154
|
+
store = SqliteStateStore(db_path=db_path)
|
|
155
|
+
engine = HexaqueueDistributedEngine(state_store=store)
|
|
156
|
+
|
|
157
|
+
if resolved_fmt in ("table", "rich"):
|
|
158
|
+
console.print(
|
|
159
|
+
f"[bold green]Submitting workflow:[/] [cyan]{workflow.name}[/] "
|
|
160
|
+
f"({len(workflow.stages)} stages, {sum(len(s.steps) for s in workflow.stages)} steps)"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
async def _execute() -> None:
|
|
164
|
+
state = await engine.run_async(workflow, initial_inputs=initial_inputs)
|
|
165
|
+
_render_workflow_state(state, store, resolved_fmt)
|
|
166
|
+
|
|
167
|
+
asyncio.run(_execute())
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@app.command("status")
|
|
171
|
+
def status_cmd(
|
|
172
|
+
run_id: str = typer.Argument(
|
|
173
|
+
..., help="Workflow execution run identifier to inspect"
|
|
174
|
+
),
|
|
175
|
+
db_path: str = typer.Option(
|
|
176
|
+
".hexaflow/state.db", "--db", help="Path to SQLite state database"
|
|
177
|
+
),
|
|
178
|
+
format_type: str = format_option(),
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Inspect status, checkpoints, and staged artifacts for a workflow run.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
run_id: Run identifier to query.
|
|
184
|
+
db_path: SQLite state store database path.
|
|
185
|
+
format_type: Output presentation format.
|
|
186
|
+
"""
|
|
187
|
+
resolved_fmt = resolve_format(format_type)
|
|
188
|
+
store = SqliteStateStore(db_path=db_path)
|
|
189
|
+
state = store.get_run(run_id)
|
|
190
|
+
|
|
191
|
+
if not state:
|
|
192
|
+
console.print(
|
|
193
|
+
f"[bold red]Error:[/] Workflow run '{run_id}' not found in database '{db_path}'."
|
|
194
|
+
)
|
|
195
|
+
raise typer.Exit(code=1)
|
|
196
|
+
|
|
197
|
+
_render_workflow_state(state, store, resolved_fmt)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@app.command("resume")
|
|
201
|
+
def resume_cmd(
|
|
202
|
+
run_id: str = typer.Argument(..., help="Suspended workflow run identifier"),
|
|
203
|
+
target: str = typer.Argument(
|
|
204
|
+
...,
|
|
205
|
+
help="Path to workflow file, e.g. 'pipeline.py' or 'pipeline.py:my_flow'",
|
|
206
|
+
),
|
|
207
|
+
inputs: str | None = typer.Option(
|
|
208
|
+
None, "--inputs", "-i", help="Patch inputs for resuming step frontier"
|
|
209
|
+
),
|
|
210
|
+
skip_steps: list[str] = typer.Option(
|
|
211
|
+
None, "--skip", help="Step names to explicitly skip"
|
|
212
|
+
),
|
|
213
|
+
db_path: str = typer.Option(
|
|
214
|
+
".hexaflow/state.db", "--db", help="Path to SQLite state database"
|
|
215
|
+
),
|
|
216
|
+
format_type: str = format_option(),
|
|
217
|
+
) -> None:
|
|
218
|
+
"""Resume execution of a suspended workflow run from its latest checkpoints.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
run_id: Suspended run identifier.
|
|
222
|
+
target: Target workflow file specifier.
|
|
223
|
+
inputs: Optional patch inputs.
|
|
224
|
+
skip_steps: Optional step names to skip.
|
|
225
|
+
db_path: SQLite state database path.
|
|
226
|
+
format_type: Output presentation format.
|
|
227
|
+
"""
|
|
228
|
+
workflow = load_workflow_from_target(target)
|
|
229
|
+
patch_inputs = parse_inputs_arg(inputs)
|
|
230
|
+
resolved_fmt = resolve_format(format_type)
|
|
231
|
+
|
|
232
|
+
store = SqliteStateStore(db_path=db_path)
|
|
233
|
+
engine = HexaqueueDistributedEngine(state_store=store)
|
|
234
|
+
|
|
235
|
+
if resolved_fmt in ("table", "rich"):
|
|
236
|
+
console.print(f"[bold yellow]Resuming workflow run:[/] [cyan]{run_id}[/]")
|
|
237
|
+
|
|
238
|
+
async def _resume() -> None:
|
|
239
|
+
state = await engine.resume_async(
|
|
240
|
+
run_id=run_id,
|
|
241
|
+
workflow=workflow,
|
|
242
|
+
patch_inputs=patch_inputs,
|
|
243
|
+
skip_steps=set(skip_steps or ()),
|
|
244
|
+
)
|
|
245
|
+
_render_workflow_state(state, store, resolved_fmt)
|
|
246
|
+
|
|
247
|
+
asyncio.run(_resume())
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@app.command("abort")
|
|
251
|
+
def abort_cmd(
|
|
252
|
+
run_id: str = typer.Argument(..., help="Workflow run identifier to abort"),
|
|
253
|
+
target: str = typer.Argument(..., help="Path to workflow file"),
|
|
254
|
+
db_path: str = typer.Option(
|
|
255
|
+
".hexaflow/state.db", "--db", help="Path to SQLite state database"
|
|
256
|
+
),
|
|
257
|
+
format_type: str = format_option(),
|
|
258
|
+
) -> None:
|
|
259
|
+
"""Abort an active or suspended workflow, unwinding step compensations in reverse order.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
run_id: Run identifier to abort.
|
|
263
|
+
target: Workflow file specifier containing compensations.
|
|
264
|
+
db_path: SQLite state database path.
|
|
265
|
+
format_type: Output presentation format.
|
|
266
|
+
"""
|
|
267
|
+
workflow = load_workflow_from_target(target)
|
|
268
|
+
resolved_fmt = resolve_format(format_type)
|
|
269
|
+
store = SqliteStateStore(db_path=db_path)
|
|
270
|
+
engine = HexaqueueDistributedEngine(state_store=store)
|
|
271
|
+
|
|
272
|
+
if resolved_fmt in ("table", "rich"):
|
|
273
|
+
console.print(f"[bold red]Aborting workflow run:[/] [cyan]{run_id}[/]")
|
|
274
|
+
|
|
275
|
+
async def _abort() -> None:
|
|
276
|
+
state = await engine.abort_async(run_id=run_id, workflow=workflow)
|
|
277
|
+
_render_workflow_state(state, store, resolved_fmt)
|
|
278
|
+
|
|
279
|
+
asyncio.run(_abort())
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _render_workflow_state(
|
|
283
|
+
state: Any, store: SqliteStateStore, format_type: str = "table"
|
|
284
|
+
) -> None:
|
|
285
|
+
"""Render workflow status summary and step checkpoint table."""
|
|
286
|
+
checkpoints = store.get_checkpoints(state.run_id)
|
|
287
|
+
presenter.render_workflow_state(state, checkpoints, format_type=format_type)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
__all__ = [
|
|
291
|
+
"abort_cmd",
|
|
292
|
+
"app",
|
|
293
|
+
"load_workflow_from_target",
|
|
294
|
+
"parse_inputs_arg",
|
|
295
|
+
"resume_cmd",
|
|
296
|
+
"status_cmd",
|
|
297
|
+
"submit_cmd",
|
|
298
|
+
]
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Domain package for hexaqueue-cli."""
|
|
2
2
|
|
|
3
|
+
from hexaqueue_cli.domain.options import OutputFormat
|
|
3
4
|
from hexaqueue_cli.domain.parser import (
|
|
4
5
|
parse_run_spec_from_dict,
|
|
5
6
|
parse_run_spec_from_file,
|
|
@@ -13,6 +14,7 @@ from hexaqueue_cli.domain.session import (
|
|
|
13
14
|
__all__ = [
|
|
14
15
|
"get_default_session",
|
|
15
16
|
"LocalCliSession",
|
|
17
|
+
"OutputFormat",
|
|
16
18
|
"parse_run_spec_from_dict",
|
|
17
19
|
"parse_run_spec_from_file",
|
|
18
20
|
"set_default_session",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Domain representations for CLI output presentation formats.
|
|
2
|
+
|
|
3
|
+
Notes/Architectural Intent:
|
|
4
|
+
Defines the canonical output format enumeration shared across CLI presenters,
|
|
5
|
+
command runners, and reporting bridges. Fulfills the cross-ecosystem CLI alignment invariant.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"OutputFormat",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OutputFormat(StrEnum):
|
|
18
|
+
"""Supported output presentation formats for CLI commands."""
|
|
19
|
+
|
|
20
|
+
AUTO = "auto"
|
|
21
|
+
JSON = "json"
|
|
22
|
+
MARKDOWN = "markdown"
|
|
23
|
+
PLAIN = "plain"
|
|
24
|
+
RICH = "rich"
|
|
25
|
+
TABLE = "table"
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""CLI option factories and formatting resolvers for Hexaqueue CLI applications.
|
|
2
|
+
|
|
3
|
+
Notes/Architectural Intent:
|
|
4
|
+
Provides standardized Typer option factories and stdout pipe-detection routines.
|
|
5
|
+
When format is 'auto', automatically routes to structured JSON when piped into
|
|
6
|
+
downstream Unix utilities (jq, grep) or styled Rich/table output when attached to an
|
|
7
|
+
interactive TTY. Aligns with the Hexa ecosystem CLI standards.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from hexaqueue_cli.domain.options import OutputFormat
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"format_option",
|
|
22
|
+
"resolve_format",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def resolve_format(
|
|
27
|
+
format_type: str | OutputFormat,
|
|
28
|
+
default_tty: str = "table",
|
|
29
|
+
default_pipe: str = "json",
|
|
30
|
+
) -> str:
|
|
31
|
+
"""Resolve an output format string, handling auto-detection for pipes.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
format_type: The format chosen by user or default ('auto', 'table', 'json', etc.).
|
|
35
|
+
default_tty: Default format when connected to an interactive terminal.
|
|
36
|
+
default_pipe: Default format when stdout is piped or redirected.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Normalized lowercase format string (e.g. 'table', 'json', 'rich').
|
|
40
|
+
|
|
41
|
+
Notes/Architectural Intent:
|
|
42
|
+
When format_type is 'auto', inspects sys.stdout.isatty() to choose between
|
|
43
|
+
structured machine-readable format for pipelines or visual format for humans.
|
|
44
|
+
"""
|
|
45
|
+
raw = (
|
|
46
|
+
format_type.value
|
|
47
|
+
if isinstance(format_type, Enum)
|
|
48
|
+
else str(format_type).lower().strip()
|
|
49
|
+
)
|
|
50
|
+
if raw == OutputFormat.AUTO.value:
|
|
51
|
+
if not sys.stdout.isatty():
|
|
52
|
+
return default_pipe
|
|
53
|
+
return default_tty
|
|
54
|
+
return raw
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def format_option(
|
|
58
|
+
default: str = "table",
|
|
59
|
+
help_text: str = "Output presentation format (table, json, markdown, rich, plain, auto).",
|
|
60
|
+
) -> Any:
|
|
61
|
+
"""Construct a standardized Typer option for format selection.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
default: Default format option value.
|
|
65
|
+
help_text: Help string to render in CLI usage catalogs.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Configured Typer Option specification.
|
|
69
|
+
|
|
70
|
+
Notes/Architectural Intent:
|
|
71
|
+
Standardizes '-f' / '--format' flag across all Hexaqueue CLI commands.
|
|
72
|
+
"""
|
|
73
|
+
return typer.Option(default, "-f", "--format", help=help_text)
|
|
@@ -8,10 +8,12 @@ import asyncio
|
|
|
8
8
|
|
|
9
9
|
import typer
|
|
10
10
|
from rich.console import Console
|
|
11
|
-
from rich.table import Table
|
|
12
11
|
|
|
13
12
|
from hexaqueue_cli.adapters.local import LocalClientAdapter
|
|
13
|
+
from hexaqueue_cli.adapters.presenter import CliPresenter
|
|
14
14
|
from hexaqueue_cli.commands.run import app as run_app
|
|
15
|
+
from hexaqueue_cli.commands.workflow import app as workflow_app
|
|
16
|
+
from hexaqueue_cli.infra.options import format_option, resolve_format
|
|
15
17
|
|
|
16
18
|
app = typer.Typer(
|
|
17
19
|
name="hq",
|
|
@@ -19,6 +21,7 @@ app = typer.Typer(
|
|
|
19
21
|
no_args_is_help=True,
|
|
20
22
|
)
|
|
21
23
|
app.add_typer(run_app, name="run")
|
|
24
|
+
app.add_typer(workflow_app, name="workflow")
|
|
22
25
|
|
|
23
26
|
console = Console()
|
|
24
27
|
|
|
@@ -26,58 +29,24 @@ console = Console()
|
|
|
26
29
|
@app.command("status")
|
|
27
30
|
def status_cmd(
|
|
28
31
|
target_id: str = typer.Argument(..., help="Run ID or Job ID to inspect"),
|
|
32
|
+
format_type: str = format_option(),
|
|
29
33
|
) -> None:
|
|
30
34
|
"""Check status of a run or job."""
|
|
35
|
+
resolved_fmt = resolve_format(format_type)
|
|
36
|
+
presenter = CliPresenter(console=console)
|
|
31
37
|
|
|
32
38
|
async def _status() -> None:
|
|
33
39
|
client = LocalClientAdapter()
|
|
34
40
|
try:
|
|
35
41
|
report = await client.get_run_status(target_id)
|
|
36
|
-
|
|
37
|
-
table.add_column("Run ID", style="cyan")
|
|
38
|
-
table.add_column("State", style="magenta")
|
|
39
|
-
table.add_column(
|
|
40
|
-
"Outcome",
|
|
41
|
-
style="green"
|
|
42
|
-
if report.outcome in ("COMPLETED", "SUCCEEDED")
|
|
43
|
-
else "yellow",
|
|
44
|
-
)
|
|
45
|
-
table.add_column("Total", justify="center")
|
|
46
|
-
table.add_column("Completed", justify="center", style="green")
|
|
47
|
-
table.add_column("Failed", justify="center", style="red")
|
|
48
|
-
table.add_column("Pending", justify="center", style="yellow")
|
|
49
|
-
table.add_row(
|
|
50
|
-
report.run_id,
|
|
51
|
-
report.state.value
|
|
52
|
-
if hasattr(report.state, "value")
|
|
53
|
-
else str(report.state),
|
|
54
|
-
str(report.outcome) if report.outcome else "-",
|
|
55
|
-
str(report.total_jobs),
|
|
56
|
-
str(report.completed_jobs),
|
|
57
|
-
str(report.failed_jobs),
|
|
58
|
-
str(report.pending_jobs),
|
|
59
|
-
)
|
|
60
|
-
console.print(table)
|
|
42
|
+
presenter.render_run_status(report, resolved_fmt)
|
|
61
43
|
return
|
|
62
44
|
except Exception:
|
|
63
45
|
pass
|
|
64
46
|
|
|
65
47
|
try:
|
|
66
48
|
job = await client.get_job(target_id)
|
|
67
|
-
|
|
68
|
-
table.add_column("Job ID", style="cyan")
|
|
69
|
-
table.add_column("Run ID", style="blue")
|
|
70
|
-
table.add_column("Name")
|
|
71
|
-
table.add_column("State", style="magenta")
|
|
72
|
-
table.add_column("Outcome")
|
|
73
|
-
table.add_row(
|
|
74
|
-
job.id,
|
|
75
|
-
job.run_id,
|
|
76
|
-
job.name,
|
|
77
|
-
job.state.value if hasattr(job.state, "value") else str(job.state),
|
|
78
|
-
str(job.outcome) if job.outcome else "-",
|
|
79
|
-
)
|
|
80
|
-
console.print(table)
|
|
49
|
+
presenter.render_job(job, resolved_fmt)
|
|
81
50
|
except Exception as e:
|
|
82
51
|
console.print(f"[bold red]Error querying status for '{target_id}':[/] {e}")
|
|
83
52
|
raise typer.Exit(code=1) from e
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|