lumilake-cli 0.1.0.dev1__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.
@@ -0,0 +1,644 @@
1
+ """Job submission, monitoring, and result retrieval commands."""
2
+
3
+ import json
4
+ import time
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import typer
9
+
10
+ from ..core import logging
11
+ from ..core.http import HttpError, client_from_config
12
+ from ..core.query import extend_params
13
+ from ..core.typer import get_typer
14
+
15
+ app = get_typer(help="Submit, monitor, and manage optimization jobs.")
16
+
17
+
18
+ def _unwrap(response_json: dict[str, Any]) -> dict[str, Any]:
19
+ """Unwrap the ``{"ok": ..., "data": {...}}`` envelope used by all API responses.
20
+
21
+ Returns the inner ``data`` payload when present, or the raw response otherwise.
22
+ """
23
+ return response_json.get("data", response_json)
24
+
25
+
26
+ def _build_inputs(
27
+ input_values: list[str] | None,
28
+ input_files: list[str] | None,
29
+ input_json: Path | None,
30
+ ) -> dict[str, Any]:
31
+ """Build the inputs dict from CLI flags.
32
+
33
+ Raises typer.Exit on validation errors.
34
+ """
35
+ inputs: dict[str, Any] = {}
36
+
37
+ # --input-json takes a full JSON object
38
+ if input_json is not None:
39
+ if not input_json.is_file():
40
+ logging.error(f"Input JSON file not found: {input_json}")
41
+ raise typer.Exit(code=1)
42
+ try:
43
+ parsed = json.loads(input_json.read_text())
44
+ except json.JSONDecodeError as exc:
45
+ logging.error(f"Invalid JSON in {input_json}: {exc}")
46
+ raise typer.Exit(code=1)
47
+ if not isinstance(parsed, dict):
48
+ logging.error(f"{input_json} must contain a JSON object")
49
+ raise typer.Exit(code=1)
50
+ inputs.update(parsed)
51
+
52
+ # --input-file Name=path.txt (one value per non-empty line)
53
+ if input_files:
54
+ for raw in input_files:
55
+ if "=" not in raw:
56
+ logging.error(f"Invalid --input-file {raw!r}. Expected Name=path.txt")
57
+ raise typer.Exit(code=1)
58
+ name, file_path_str = raw.split("=", 1)
59
+ name = name.strip()
60
+ if not name:
61
+ logging.error(f"Empty input name in --input-file {raw!r}")
62
+ raise typer.Exit(code=1)
63
+ file_path = Path(file_path_str.strip())
64
+ if not file_path.is_file():
65
+ logging.error(f"Input file not found: {file_path}")
66
+ raise typer.Exit(code=1)
67
+ lines = [
68
+ line.strip()
69
+ for line in file_path.read_text().splitlines()
70
+ if line.strip()
71
+ ]
72
+ if not lines:
73
+ logging.error(f"Input file is empty: {file_path}")
74
+ raise typer.Exit(code=1)
75
+ if name in inputs:
76
+ logging.error(f"Duplicate input name: {name!r}")
77
+ raise typer.Exit(code=1)
78
+ inputs[name] = lines
79
+
80
+ # --input Name=val1,val2,val3 (comma-separated; repeatable for different names)
81
+ if input_values:
82
+ for raw in input_values:
83
+ if "=" not in raw:
84
+ logging.error(f"Invalid --input {raw!r}. Expected Name=val1,val2,...")
85
+ raise typer.Exit(code=1)
86
+ name, values_str = raw.split("=", 1)
87
+ name = name.strip()
88
+ if not name:
89
+ logging.error(f"Empty input name in --input {raw!r}")
90
+ raise typer.Exit(code=1)
91
+ if name in inputs:
92
+ logging.error(f"Duplicate input name: {name!r}")
93
+ raise typer.Exit(code=1)
94
+ values = [v.strip() for v in values_str.split(",") if v.strip()]
95
+ if not values:
96
+ logging.error(f"Empty value list in --input {raw!r}")
97
+ raise typer.Exit(code=1)
98
+ inputs[name] = values
99
+
100
+ return inputs
101
+
102
+
103
+ @app.command()
104
+ def submit(
105
+ workflow: Path = typer.Argument(..., help="Path to workflow file"),
106
+ workflow_format: str = typer.Option(
107
+ "n8n", "--format", "-f", help="Workflow format (native|n8n|yaml)"
108
+ ),
109
+ priority: str = typer.Option(
110
+ "medium", "--priority", "-p", help="Job priority (low|medium|high)"
111
+ ),
112
+ name: str | None = typer.Option(None, "--name", "-n", help="Job name"),
113
+ output_type: str = typer.Option(
114
+ "s3", "--output-type", help="Output location type (s3|db)"
115
+ ),
116
+ output_prefix: str | None = typer.Option(
117
+ None,
118
+ "--output-prefix",
119
+ help="S3 output prefix (required when --output-type s3)",
120
+ ),
121
+ output_connection_string: str | None = typer.Option(
122
+ None,
123
+ "--output-connection-string",
124
+ help="S3 connection string (s3://user:pass@endpoint/bucket)",
125
+ ),
126
+ output_table: str | None = typer.Option(
127
+ None, "--output-table", help="DB output table (required when --output-type db)"
128
+ ),
129
+ output_column: str | None = typer.Option(
130
+ None,
131
+ "--output-column",
132
+ help="DB output column (required when --output-type db)",
133
+ ),
134
+ input_batch_size: int | None = typer.Option(
135
+ None, "--batch-size", help="Input batch size"
136
+ ),
137
+ input_values: list[str] | None = typer.Option(
138
+ None, "--input", help="Input as Name=v1,v2,v3 (repeatable for different names)"
139
+ ),
140
+ input_files: list[str] | None = typer.Option(
141
+ None, "--input-file", help="Input from file as Name=path.txt (repeatable)"
142
+ ),
143
+ input_json: Path | None = typer.Option(
144
+ None, "--input-json", help="JSON file with full inputs object"
145
+ ),
146
+ ) -> None:
147
+ """Submit a workflow for optimization and execution."""
148
+ if not workflow.exists():
149
+ logging.error(f"Workflow file not found: {workflow}")
150
+ raise typer.Exit(code=1)
151
+ if workflow_format not in {"native", "n8n", "yaml"}:
152
+ logging.error(f"Unsupported workflow format: {workflow_format}")
153
+ raise typer.Exit(code=1)
154
+
155
+ inputs = _build_inputs(input_values, input_files, input_json)
156
+ if not inputs:
157
+ logging.error(
158
+ "At least one input is required. "
159
+ "Use --input, --input-file, or --input-json."
160
+ )
161
+ raise typer.Exit(code=1)
162
+
163
+ workflow_text = workflow.read_text()
164
+
165
+ if output_type == "s3":
166
+ if not output_prefix:
167
+ logging.error("--output-prefix is required when --output-type is s3")
168
+ raise typer.Exit(code=1)
169
+ output_location: dict[str, Any] = {"type": "s3", "prefix": output_prefix}
170
+ if output_connection_string:
171
+ output_location["connection_string"] = output_connection_string
172
+ elif output_type == "db":
173
+ if not output_table or not output_column:
174
+ logging.error("--output-table and --output-column required for db output")
175
+ raise typer.Exit(code=1)
176
+ output_location = {
177
+ "type": "db",
178
+ "table": output_table,
179
+ "column": output_column,
180
+ }
181
+ else:
182
+ logging.error(f"Unsupported output type: {output_type}")
183
+ raise typer.Exit(code=1)
184
+
185
+ item: dict[str, Any] = {
186
+ "workflow": workflow_text,
187
+ "inputs": inputs,
188
+ "output_location": output_location,
189
+ "name": name or workflow.stem,
190
+ }
191
+ if input_batch_size is not None:
192
+ item["input_batch_size"] = input_batch_size
193
+
194
+ payload: dict[str, Any] = {
195
+ "data": [item],
196
+ "priority": priority,
197
+ }
198
+
199
+ client = client_from_config()
200
+ try:
201
+ response = client.post(
202
+ "/jobs",
203
+ version_prefix=True,
204
+ json=payload,
205
+ headers={"Workflow-Format": workflow_format},
206
+ )
207
+ except HttpError as exc:
208
+ logging.error(str(exc))
209
+ raise typer.Exit(code=1)
210
+ logging.log(json.dumps(response.json(), indent=2))
211
+
212
+
213
+ @app.command("list")
214
+ def list_jobs(
215
+ page: int = typer.Option(1, "--page", help="Page number"),
216
+ page_size: int = typer.Option(10, "--page-size", help="Results per page"),
217
+ status: list[str] | None = typer.Option(
218
+ None, "--status", "-s", help="Filter by status (repeatable)"
219
+ ),
220
+ include_all: bool = typer.Option(
221
+ False, "--all", help="Include all users' jobs (requires admin scope)"
222
+ ),
223
+ ) -> None:
224
+ """List submitted jobs."""
225
+ client = client_from_config()
226
+ params: list[tuple[str, str]] = [
227
+ ("page", str(page)),
228
+ ("page_size", str(page_size)),
229
+ ]
230
+ extend_params(params, "status", status)
231
+ if include_all:
232
+ params.append(("include_all", "true"))
233
+ try:
234
+ response = client.get("/jobs", version_prefix=True, params=params)
235
+ except HttpError as exc:
236
+ logging.error(str(exc))
237
+ raise typer.Exit(code=1)
238
+ logging.log(json.dumps(response.json(), indent=2))
239
+
240
+
241
+ @app.command("info")
242
+ def job_info(
243
+ job_id: str = typer.Argument(..., help="Job identifier"),
244
+ ) -> None:
245
+ """Retrieve the current status and metadata for a job."""
246
+ client = client_from_config()
247
+ try:
248
+ response = client.get(f"/jobs/{job_id}", version_prefix=True)
249
+ except HttpError as exc:
250
+ logging.error(str(exc))
251
+ raise typer.Exit(code=1)
252
+ logging.log(json.dumps(response.json(), indent=2))
253
+
254
+
255
+ @app.command()
256
+ def progress(
257
+ job_id: str = typer.Argument(..., help="Job identifier"),
258
+ ) -> None:
259
+ """Get detailed progress information for a job."""
260
+ client = client_from_config()
261
+ try:
262
+ response = client.get(f"/jobs/{job_id}/progress", version_prefix=True)
263
+ except HttpError as exc:
264
+ logging.error(str(exc))
265
+ raise typer.Exit(code=1)
266
+ logging.log(json.dumps(response.json(), indent=2))
267
+
268
+
269
+ @app.command()
270
+ def result(
271
+ job_id: str = typer.Argument(..., help="Job identifier"),
272
+ ) -> None:
273
+ """Retrieve the result of a completed job."""
274
+ client = client_from_config()
275
+ try:
276
+ response = client.get(f"/jobs/{job_id}/result", version_prefix=True)
277
+ except HttpError as exc:
278
+ logging.error(str(exc))
279
+ raise typer.Exit(code=1)
280
+ logging.log(json.dumps(response.json(), indent=2))
281
+
282
+
283
+ @app.command()
284
+ def inputs(
285
+ job_id: str = typer.Argument(..., help="Job identifier"),
286
+ ) -> None:
287
+ """Retrieve the inputs of a submitted job."""
288
+ client = client_from_config()
289
+ try:
290
+ response = client.get(f"/jobs/{job_id}/inputs", version_prefix=True)
291
+ except HttpError as exc:
292
+ logging.error(str(exc))
293
+ raise typer.Exit(code=1)
294
+ logging.log(json.dumps(response.json(), indent=2))
295
+
296
+
297
+ @app.command()
298
+ def cancel(
299
+ job_id: str = typer.Argument(..., help="Job identifier"),
300
+ ) -> None:
301
+ """Cancel a running job."""
302
+ client = client_from_config()
303
+ try:
304
+ response = client.post(f"/jobs/{job_id}/cancel", version_prefix=True)
305
+ except HttpError as exc:
306
+ logging.error(str(exc))
307
+ raise typer.Exit(code=1)
308
+ logging.log(json.dumps(response.json(), indent=2))
309
+
310
+
311
+ def _poll_once(client: Any, job_id: str) -> tuple[dict[str, Any], str, dict[str, Any]]:
312
+ """Fetch job status and progress. Returns (job, status, progress)."""
313
+ job_resp = client.get(f"/jobs/{job_id}", version_prefix=True)
314
+ job = _unwrap(job_resp.json())
315
+ status = job.get("status", "")
316
+
317
+ prog: dict[str, Any] = {}
318
+ try:
319
+ prog_resp = client.get(f"/jobs/{job_id}/progress", version_prefix=True)
320
+ prog = _unwrap(prog_resp.json()).get("progress", {})
321
+ except HttpError:
322
+ pass
323
+ return job, status, prog
324
+
325
+
326
+ def _format_progress_line(
327
+ job_id: str, status: str, prog: dict[str, Any], elapsed: float
328
+ ) -> str:
329
+ """Build a single-line progress summary for non-live mode.
330
+
331
+ Uses only ASCII characters — no special fonts needed.
332
+ """
333
+ parts = [f"[{job_id}] {status}"]
334
+ batch_progress = prog.get("batch_progress") if isinstance(prog, dict) else None
335
+ if isinstance(batch_progress, dict):
336
+ overall = batch_progress.get("overall_progress")
337
+ if isinstance(overall, dict):
338
+ processed = overall.get("processed_runtime_nodes_raw")
339
+ total_raw = overall.get("raw_nodes")
340
+ eta = overall.get("eta_seconds")
341
+ if processed is not None and total_raw is not None:
342
+ pct = processed / total_raw * 100 if total_raw > 0 else 0
343
+ width = 20
344
+ filled = int(width * pct / 100)
345
+ bar = "#" * filled + "-" * (width - filled)
346
+ parts.append(f"[{bar}] {processed}/{total_raw} nodes ({pct:.0f}%)")
347
+ if eta is not None:
348
+ parts.append(f"ETA {eta:.0f}s")
349
+ completed = batch_progress.get("completed", 0)
350
+ total = batch_progress.get("total", 0)
351
+ running = batch_progress.get("running", 0)
352
+ if total > 0:
353
+ parts.append(f"batches {completed}/{total} done, {running} running")
354
+ parts.append(f"{elapsed:.0f}s")
355
+ return " ".join(parts)
356
+
357
+
358
+ def _build_progress_panel(job_id: str, progress: dict[str, Any]) -> Any:
359
+ """Build a Rich Panel with a batch-level progress table for ``progress``.
360
+
361
+ Renders one ``Overall`` row (raw-node progress + outcome counters) and
362
+ one ``Running batches`` row (active batch progress + average elapsed
363
+ time), wrapped in a header line that summarizes raw/FlowMesh node
364
+ totals, ETA, and the done/running batch counts.
365
+ """
366
+ from rich.console import Group
367
+ from rich.panel import Panel
368
+ from rich.table import Table
369
+ from rich.text import Text
370
+
371
+ def _int(value: Any) -> int:
372
+ if isinstance(value, (int, float)):
373
+ return max(0, int(value))
374
+ return 0
375
+
376
+ def _bar(completed: int, total: int) -> str:
377
+ pct = min(100.0, max(0.0, completed / total * 100)) if total > 0 else 0.0
378
+ width = 20
379
+ filled = int(width * pct / 100)
380
+ return f"{'#' * filled}{'-' * (width - filled)} {pct:>5.1f}%"
381
+
382
+ if not isinstance(progress, dict) or "batch_progress" not in progress:
383
+ return Panel(
384
+ Text("Waiting for batch progress...", style="dim"),
385
+ title=f"[bold cyan]Job {job_id}[/bold cyan]",
386
+ border_style="blue",
387
+ )
388
+
389
+ bp = progress["batch_progress"]
390
+ if not isinstance(bp, dict):
391
+ return Panel(
392
+ Text("Invalid progress data", style="red"),
393
+ title=f"[bold cyan]Job {job_id}[/bold cyan]",
394
+ border_style="red",
395
+ )
396
+
397
+ overall = bp.get("overall_progress", {})
398
+ batches = bp.get("batches", [])
399
+
400
+ pending_raw = _int(overall.get("pending_runtime_nodes_raw"))
401
+ processing_raw = _int(overall.get("processing_runtime_nodes_raw"))
402
+ processed_raw = _int(overall.get("processed_runtime_nodes_raw"))
403
+ total_raw = pending_raw + processing_raw + processed_raw
404
+ raw_nodes = _int(overall.get("raw_nodes"))
405
+ fm_nodes = _int(overall.get("flowmesh_nodes"))
406
+
407
+ o_succ = o_fail = o_disp = o_pend = 0
408
+ r_succ = r_fail = r_disp = r_pend = r_total = r_count = 0
409
+ r_elapsed_sum = 0.0
410
+ r_elapsed_n = 0
411
+ for b in batches:
412
+ if not isinstance(b, dict):
413
+ continue
414
+ n = b.get("nodes", {})
415
+ if not isinstance(n, dict):
416
+ continue
417
+ o_succ += _int(n.get("succeeded"))
418
+ o_fail += _int(n.get("failed"))
419
+ o_disp += _int(n.get("dispatched"))
420
+ o_pend += _int(n.get("pending"))
421
+ if b.get("status") == "RUNNING":
422
+ r_count += 1
423
+ r_succ += _int(n.get("succeeded"))
424
+ r_fail += _int(n.get("failed"))
425
+ r_disp += _int(n.get("dispatched"))
426
+ r_pend += _int(n.get("pending"))
427
+ r_total += _int(n.get("total"))
428
+ elapsed = b.get("elapsed_time")
429
+ if isinstance(elapsed, (int, float)):
430
+ r_elapsed_sum += float(elapsed)
431
+ r_elapsed_n += 1
432
+
433
+ r_done = r_succ + r_fail
434
+ r_time = f"{r_elapsed_sum / r_elapsed_n:.1f}s(avg)" if r_elapsed_n > 0 else "---"
435
+
436
+ table = Table(
437
+ title=f"Job {job_id} - Progress",
438
+ show_header=True,
439
+ header_style="bold",
440
+ )
441
+ table.add_column("Scope", style="cyan", width=24)
442
+ table.add_column("Progress", width=28)
443
+ table.add_column("Raw Nodes", justify="right", width=14)
444
+ table.add_column("S/F/R/P (succeed/fail/run/pend)", width=28)
445
+
446
+ table.add_row(
447
+ "Overall",
448
+ _bar(processed_raw, total_raw),
449
+ f"{processed_raw}/{total_raw}",
450
+ f"S:{o_succ} F:{o_fail} R:{o_disp} P:{o_pend}",
451
+ )
452
+ table.add_row(
453
+ f"Running ({r_count} batches)",
454
+ _bar(r_done, r_total),
455
+ str(processing_raw),
456
+ f"S:{r_succ} F:{r_fail} R:{r_disp} P:{r_pend}; t={r_time}",
457
+ )
458
+
459
+ summary_parts = [f"Raw/FlowMesh nodes: {raw_nodes}/{fm_nodes}"]
460
+ eta = bp.get("eta_seconds")
461
+ if isinstance(eta, (int, float)):
462
+ summary_parts.append(f"ETA: {eta:.1f}s")
463
+ c_batches = int(bp.get("completed", 0))
464
+ r_batches = int(bp.get("running", 0))
465
+ summary_parts.append(f"Batches done/running: {c_batches}/{r_batches}")
466
+ header = Text("; ".join(summary_parts), style="bold cyan")
467
+
468
+ return Panel(
469
+ Group(header, table),
470
+ title="Batch Progress",
471
+ border_style="blue",
472
+ padding=(1, 2),
473
+ )
474
+
475
+
476
+ @app.command()
477
+ def watch(
478
+ job_id: str = typer.Argument(..., help="Job identifier"),
479
+ interval: float = typer.Option(
480
+ 3.0, "--interval", "-i", help="Polling interval in seconds"
481
+ ),
482
+ timeout: float = typer.Option(
483
+ 180.0, "--timeout", "-t", help="Max seconds to wait before giving up"
484
+ ),
485
+ live: bool = typer.Option(
486
+ False, "--live", "-l", help="Rich live progress table (for interactive use)"
487
+ ),
488
+ ) -> None:
489
+ """Monitor a job by polling until completion.
490
+
491
+ Default mode appends one line per status change (suitable for CI/logs).
492
+ Use --live for a rich interactive progress table with batch details.
493
+ """
494
+ client = client_from_config()
495
+ last_line: str | None = None
496
+ start = time.time()
497
+
498
+ from contextlib import AbstractContextManager, nullcontext
499
+
500
+ live_ctx: AbstractContextManager[Any]
501
+ if live:
502
+ from rich.console import Console
503
+ from rich.live import Live
504
+
505
+ console = Console(stderr=True)
506
+ live_ctx = Live(
507
+ _build_progress_panel(job_id, {}),
508
+ console=console,
509
+ refresh_per_second=4,
510
+ )
511
+ else:
512
+ live_ctx = nullcontext()
513
+
514
+ try:
515
+ with live_ctx as live_display:
516
+ while True:
517
+ try:
518
+ job, status, prog = _poll_once(client, job_id)
519
+ except HttpError as exc:
520
+ logging.warning(f"Error fetching job: {exc}")
521
+ time.sleep(interval)
522
+ continue
523
+
524
+ elapsed = time.time() - start
525
+
526
+ if status == "cancelled":
527
+ logging.error(f"Job {job_id} was cancelled.")
528
+ logging.log(json.dumps(job, indent=2))
529
+ raise typer.Exit(code=1)
530
+
531
+ if live_display is not None:
532
+ live_display.update(_build_progress_panel(job_id, prog))
533
+ else:
534
+ line = _format_progress_line(job_id, status, prog, elapsed)
535
+ # Compare without the elapsed suffix so we print on
536
+ # status/progress changes, not just because time passed.
537
+ line_key = line.rsplit(" ", 1)[0]
538
+ if line_key != last_line:
539
+ logging.info(line)
540
+ last_line = line_key
541
+
542
+ if status in {"completed", "failed"}:
543
+ if live_display is not None:
544
+ live_display.update(_build_progress_panel(job_id, prog))
545
+ logging.log(json.dumps(job, indent=2))
546
+ try:
547
+ result_resp = client.get(
548
+ f"/jobs/{job_id}/result", version_prefix=True
549
+ )
550
+ logging.log(json.dumps(_unwrap(result_resp.json()), indent=2))
551
+ except HttpError:
552
+ logging.warning("Could not fetch job result.")
553
+ if status == "failed":
554
+ raise typer.Exit(code=1)
555
+ return
556
+
557
+ if elapsed > timeout:
558
+ logging.error(
559
+ f"Job {job_id} did not finish within {timeout}s. "
560
+ "It is still running — use a larger --timeout or check later."
561
+ )
562
+ raise typer.Exit(code=1)
563
+
564
+ time.sleep(interval)
565
+ except KeyboardInterrupt:
566
+ logging.warning("Cancelled by user.")
567
+ raise typer.Exit(code=1)
568
+
569
+
570
+ @app.command()
571
+ def artifact(
572
+ job_id: str = typer.Argument(..., help="Job identifier"),
573
+ path: str = typer.Option(..., "--path", help="Artifact path to download"),
574
+ output: Path = typer.Option(..., "--output", "-o", help="Output file path"),
575
+ ) -> None:
576
+ """Download a job artifact."""
577
+ client = client_from_config()
578
+ try:
579
+ output.parent.mkdir(parents=True, exist_ok=True)
580
+ client.download(
581
+ f"/jobs/{job_id}/artifact",
582
+ output,
583
+ version_prefix=True,
584
+ params={"path": path},
585
+ )
586
+ except HttpError as exc:
587
+ logging.error(str(exc))
588
+ raise typer.Exit(code=1)
589
+ except OSError as exc:
590
+ logging.error(f"Failed to write {output}: {exc}")
591
+ raise typer.Exit(code=1)
592
+ logging.success(f"Artifact saved to {output}")
593
+
594
+
595
+ @app.command()
596
+ def preview(
597
+ workflow: Path = typer.Argument(..., help="Path to workflow file"),
598
+ workflow_format: str = typer.Option(
599
+ "n8n", "--format", "-f", help="Workflow format (native|n8n|yaml)"
600
+ ),
601
+ input_values: list[str] | None = typer.Option(
602
+ None, "--input", help="Input as Name=v1,v2,v3 (repeatable for different names)"
603
+ ),
604
+ input_files: list[str] | None = typer.Option(
605
+ None, "--input-file", help="Input from file as Name=path.txt (repeatable)"
606
+ ),
607
+ input_json: Path | None = typer.Option(
608
+ None, "--input-json", help="JSON file with full inputs object"
609
+ ),
610
+ ) -> None:
611
+ """Preview the optimization schedule for a workflow without executing it.
612
+
613
+ Accepts the same payload shape as submit. The preview endpoint ignores
614
+ output_location and priority.
615
+ """
616
+ if not workflow.exists():
617
+ logging.error(f"Workflow file not found: {workflow}")
618
+ raise typer.Exit(code=1)
619
+
620
+ inputs = _build_inputs(input_values, input_files, input_json)
621
+ if not inputs:
622
+ logging.error(
623
+ "At least one input is required. "
624
+ "Use --input, --input-file, or --input-json."
625
+ )
626
+ raise typer.Exit(code=1)
627
+
628
+ workflow_text = workflow.read_text()
629
+ payload: dict[str, Any] = {
630
+ "data": [{"workflow": workflow_text, "inputs": inputs}],
631
+ }
632
+
633
+ client = client_from_config()
634
+ try:
635
+ response = client.post(
636
+ "/jobs/preview",
637
+ version_prefix=True,
638
+ json=payload,
639
+ headers={"Workflow-Format": workflow_format},
640
+ )
641
+ except HttpError as exc:
642
+ logging.error(str(exc))
643
+ raise typer.Exit(code=1)
644
+ logging.log(json.dumps(response.json(), indent=2))