analogcli 0.1.4__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.
- anabrid/cloud/cli/__init__.py +0 -0
- anabrid/cloud/cli/client.py +385 -0
- anabrid/cloud/cli/commands/__init__.py +0 -0
- anabrid/cloud/cli/commands/compile.py +164 -0
- anabrid/cloud/cli/commands/info.py +97 -0
- anabrid/cloud/cli/commands/run.py +116 -0
- anabrid/cloud/cli/commands/status.py +20 -0
- anabrid/cloud/cli/commands/task/__init__.py +29 -0
- anabrid/cloud/cli/commands/task/delete.py +22 -0
- anabrid/cloud/cli/commands/task/info.py +66 -0
- anabrid/cloud/cli/commands/task/list.py +82 -0
- anabrid/cloud/cli/commands/task/logs.py +120 -0
- anabrid/cloud/cli/commands/task/result.py +64 -0
- anabrid/cloud/cli/commands/task/revoke.py +23 -0
- anabrid/cloud/cli/commands/task/search.py +238 -0
- anabrid/cloud/cli/commands/task/status.py +54 -0
- anabrid/cloud/cli/commands/task/wait.py +67 -0
- anabrid/cloud/cli/context.py +18 -0
- anabrid/cloud/cli/entity_tree.py +110 -0
- anabrid/cloud/cli/main.py +52 -0
- anabrid/cloud/cli/utils.py +421 -0
- analogcli-0.1.4.dist-info/METADATA +276 -0
- analogcli-0.1.4.dist-info/RECORD +25 -0
- analogcli-0.1.4.dist-info/WHEEL +4 -0
- analogcli-0.1.4.dist-info/entry_points.txt +5 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""Shared utilities and display helpers for CLI commands."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import datetime
|
|
5
|
+
import enum
|
|
6
|
+
import functools
|
|
7
|
+
import locale
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
16
|
+
from rich.syntax import Syntax
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
import json
|
|
19
|
+
|
|
20
|
+
from .client import AnalogCloudClient, AnalogCloudAPIError, TERMINAL_TASK_STATES
|
|
21
|
+
from .client import StateConflictError
|
|
22
|
+
from anabrid.cloud.models.api.v1 import (
|
|
23
|
+
TaskStatusResponse,
|
|
24
|
+
DeviceResponse,
|
|
25
|
+
SystemStatusResponse,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
console = Console()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
locale.setlocale(locale.LC_TIME, "")
|
|
33
|
+
except locale.Error:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def format_timestamp(value: Any) -> str:
|
|
38
|
+
"""Render *value* as a locale-aware date+time string for human display.
|
|
39
|
+
|
|
40
|
+
Server timestamps are stored as naive UTC; this helper treats a naive
|
|
41
|
+
``datetime`` as UTC, converts it to the user's local time zone, drops
|
|
42
|
+
the microsecond component (sub-second precision is noise for a human),
|
|
43
|
+
and renders via ``strftime("%c")`` so the output respects the
|
|
44
|
+
``LC_TIME`` environment locale (e.g. ``Fr 08 Mai 2026 17:24:25 CEST``
|
|
45
|
+
on a German system). Anything that is not a ``datetime`` is returned
|
|
46
|
+
via ``str(value)`` so callers can pass partial / missing fields
|
|
47
|
+
straight through.
|
|
48
|
+
"""
|
|
49
|
+
if not isinstance(value, datetime.datetime):
|
|
50
|
+
return str(value) if value is not None else ""
|
|
51
|
+
if value.tzinfo is None:
|
|
52
|
+
value = value.replace(tzinfo=datetime.timezone.utc)
|
|
53
|
+
return value.astimezone().replace(microsecond=0).strftime("%c")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def format_uptime(seconds: int) -> str:
|
|
57
|
+
"""Render an uptime *seconds* count as a ``?d ?h ?m ?s`` string.
|
|
58
|
+
|
|
59
|
+
Units above the largest non-zero one are dropped (``225`` becomes
|
|
60
|
+
``3m 45s``, not ``0d 0h 3m 45s``), while interior and trailing zero
|
|
61
|
+
units are kept once a larger unit is present (``3600`` becomes
|
|
62
|
+
``1h 0m 0s``) so the components stay aligned. A zero or negative
|
|
63
|
+
count - the latter being a nonsensical clock skew - clamps to
|
|
64
|
+
``0s`` rather than rendering empty.
|
|
65
|
+
"""
|
|
66
|
+
seconds = max(int(seconds), 0)
|
|
67
|
+
days, rem = divmod(seconds, 86_400)
|
|
68
|
+
hours, rem = divmod(rem, 3_600)
|
|
69
|
+
minutes, secs = divmod(rem, 60)
|
|
70
|
+
|
|
71
|
+
parts = [(days, "d"), (hours, "h"), (minutes, "m"), (secs, "s")]
|
|
72
|
+
# Drop leading zero units; keep everything from the first non-zero
|
|
73
|
+
# unit down to seconds. ``next`` defaults to the last index so an
|
|
74
|
+
# all-zero uptime still renders the trailing ``0s``.
|
|
75
|
+
first = next(
|
|
76
|
+
(i for i, (value, _) in enumerate(parts) if value), len(parts) - 1
|
|
77
|
+
)
|
|
78
|
+
return " ".join(f"{value}{label}" for value, label in parts[first:])
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class OutputMode(enum.Enum):
|
|
82
|
+
"""Selects how a CLI command renders its primary output."""
|
|
83
|
+
|
|
84
|
+
RICH = "rich"
|
|
85
|
+
TEXT = "text"
|
|
86
|
+
JSON = "json"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _stdout_is_tty() -> bool:
|
|
90
|
+
"""Return True if ``sys.stdout`` reports being attached to a TTY."""
|
|
91
|
+
isatty = getattr(sys.stdout, "isatty", None)
|
|
92
|
+
if isatty is None:
|
|
93
|
+
return False
|
|
94
|
+
try:
|
|
95
|
+
return bool(isatty())
|
|
96
|
+
except Exception:
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def pick_output_mode(
|
|
101
|
+
ctx: click.Context | None,
|
|
102
|
+
format: str | None,
|
|
103
|
+
follow: bool,
|
|
104
|
+
) -> OutputMode:
|
|
105
|
+
"""Select the output mode for a CLI command.
|
|
106
|
+
|
|
107
|
+
The CLI is used in two distinct contexts: an interactive terminal
|
|
108
|
+
(humans reading) and a pipeline such as ``analogcli task list | jq``
|
|
109
|
+
or a CI script (machines parsing). Rich panels and spinners help in
|
|
110
|
+
the first context and actively break the second — box-drawing
|
|
111
|
+
characters confuse ``grep``, colour escape sequences confuse
|
|
112
|
+
``jq``. This helper centralises that decision so each command stays
|
|
113
|
+
short: derive ``OutputMode`` once from
|
|
114
|
+
``(--format, --follow, sys.stdout.isatty())`` and dispatch to a Rich
|
|
115
|
+
or plain helper. The helpers themselves do not re-check.
|
|
116
|
+
|
|
117
|
+
The precedence captures "explicit user intent beats heuristics, an
|
|
118
|
+
active TTY means a human is watching, an inherited pipe means a
|
|
119
|
+
machine is watching":
|
|
120
|
+
|
|
121
|
+
- ``format == "json"`` → ``OutputMode.JSON`` (explicit wins)
|
|
122
|
+
- ``format == "text"`` → ``OutputMode.TEXT`` (explicit wins)
|
|
123
|
+
- ``format is None`` (auto-detect):
|
|
124
|
+
- ``follow`` is True → ``OutputMode.RICH`` (the user has opted
|
|
125
|
+
into an interactive watch loop, so Rich spinners/panels are
|
|
126
|
+
the right choice)
|
|
127
|
+
- else if stdout is a TTY → ``OutputMode.TEXT`` (human is
|
|
128
|
+
watching; structured but plain output)
|
|
129
|
+
- else → ``OutputMode.JSON`` (output is being piped; emit
|
|
130
|
+
machine-parsable JSON by default).
|
|
131
|
+
"""
|
|
132
|
+
if format == "json":
|
|
133
|
+
return OutputMode.JSON
|
|
134
|
+
if format == "text":
|
|
135
|
+
return OutputMode.TEXT
|
|
136
|
+
if format is not None:
|
|
137
|
+
raise click.UsageError(
|
|
138
|
+
f"Unknown --format value: {format!r}. Expected 'text' or 'json'."
|
|
139
|
+
)
|
|
140
|
+
if follow:
|
|
141
|
+
return OutputMode.RICH
|
|
142
|
+
if _stdout_is_tty():
|
|
143
|
+
return OutputMode.TEXT
|
|
144
|
+
return OutputMode.JSON
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def handle_api_error(func):
|
|
148
|
+
"""Decorator mapping known exceptions to CLI exit codes.
|
|
149
|
+
|
|
150
|
+
Mapping:
|
|
151
|
+
- ``StateConflictError`` → exit 4
|
|
152
|
+
- ``AnalogCloudAPIError`` → exit 3
|
|
153
|
+
- ``TimeoutError`` → exit 124
|
|
154
|
+
- any other exception propagates unchanged
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
@functools.wraps(func)
|
|
158
|
+
def wrapper(*args, **kwargs):
|
|
159
|
+
try:
|
|
160
|
+
return func(*args, **kwargs)
|
|
161
|
+
except StateConflictError as exc:
|
|
162
|
+
click.echo(f"Error: {exc}", err=True)
|
|
163
|
+
sys.exit(4)
|
|
164
|
+
except AnalogCloudAPIError as exc:
|
|
165
|
+
click.echo(f"Error: {exc}", err=True)
|
|
166
|
+
sys.exit(3)
|
|
167
|
+
except TimeoutError as exc:
|
|
168
|
+
click.echo(f"Error: timed out: {exc}", err=True)
|
|
169
|
+
sys.exit(124)
|
|
170
|
+
|
|
171
|
+
return wrapper
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def print_task_logs_plain(lines: list[str]) -> None:
|
|
175
|
+
"""Emit task log lines as raw newline-separated text on stdout."""
|
|
176
|
+
if not lines:
|
|
177
|
+
return
|
|
178
|
+
click.echo("\n".join(lines))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def print_task_table(tasks: list) -> None:
|
|
182
|
+
"""Render a table of task summaries for TTY consumption."""
|
|
183
|
+
if not tasks:
|
|
184
|
+
console.print("(no tasks)")
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
table = Table(title="Tasks")
|
|
188
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
189
|
+
table.add_column("Type", style="magenta")
|
|
190
|
+
table.add_column("State", style="green")
|
|
191
|
+
table.add_column("Created At", style="white")
|
|
192
|
+
|
|
193
|
+
def _field(item, name: str) -> Any:
|
|
194
|
+
if isinstance(item, dict):
|
|
195
|
+
return item.get(name)
|
|
196
|
+
return getattr(item, name, None)
|
|
197
|
+
|
|
198
|
+
for task in tasks:
|
|
199
|
+
raw_id = _field(task, "id")
|
|
200
|
+
id_str = str(raw_id) if raw_id is not None else ""
|
|
201
|
+
type_val = _field(task, "type")
|
|
202
|
+
type_str = type_val.value if hasattr(type_val, "value") else str(type_val)
|
|
203
|
+
state_val = _field(task, "state")
|
|
204
|
+
state_str = state_val.value if hasattr(state_val, "value") else str(state_val)
|
|
205
|
+
created_str = format_timestamp(_field(task, "created_at"))
|
|
206
|
+
table.add_row(id_str, type_str, state_str, created_str)
|
|
207
|
+
|
|
208
|
+
console.print(table)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def dump_task_json(obj: Any) -> None:
|
|
212
|
+
"""Serialise *obj* to stdout as a single JSON document."""
|
|
213
|
+
|
|
214
|
+
def _to_jsonable(value: Any) -> Any:
|
|
215
|
+
if hasattr(value, "model_dump"):
|
|
216
|
+
return value.model_dump(mode="json")
|
|
217
|
+
if isinstance(value, dict):
|
|
218
|
+
return {key: _to_jsonable(item) for key, item in value.items()}
|
|
219
|
+
if isinstance(value, list):
|
|
220
|
+
return [_to_jsonable(item) for item in value]
|
|
221
|
+
if isinstance(value, datetime.datetime):
|
|
222
|
+
return value.isoformat()
|
|
223
|
+
return value
|
|
224
|
+
|
|
225
|
+
payload = _to_jsonable(obj)
|
|
226
|
+
click.echo(json.dumps(payload, indent=2, default=str))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def print_search_table(rows: list[dict], columns: list[str]) -> None:
|
|
230
|
+
"""Render search-result *rows* as a Rich table over the given *columns*.
|
|
231
|
+
|
|
232
|
+
Each row is a flat ``dict`` keyed by column name; missing keys render as an
|
|
233
|
+
empty cell. Prints ``(no tasks)`` when *rows* is empty. Used by
|
|
234
|
+
``analogcli task search`` to surface the per-job ``user_id`` (and, with
|
|
235
|
+
``--recursive``, device metadata) that the plain task table omits.
|
|
236
|
+
|
|
237
|
+
A wide console (no fixed column limit) is used so that cell content is not
|
|
238
|
+
truncated with "…" regardless of how many columns are rendered.
|
|
239
|
+
"""
|
|
240
|
+
if not rows:
|
|
241
|
+
console.print("(no tasks)")
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
wide = Console(width=max(200, 20 * len(columns)))
|
|
245
|
+
table = Table()
|
|
246
|
+
for col in columns:
|
|
247
|
+
table.add_column(col)
|
|
248
|
+
|
|
249
|
+
for row in rows:
|
|
250
|
+
table.add_row(*[str(row.get(col, "")) for col in columns])
|
|
251
|
+
|
|
252
|
+
wide.print(table)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def write_task_csv(rows: list[dict], path: str, columns: list[str]) -> None:
|
|
256
|
+
"""Write search-result *rows* to *path* as CSV with a *columns* header.
|
|
257
|
+
|
|
258
|
+
Always emits the header row, so an empty *rows* yields a header-only file.
|
|
259
|
+
Cells absent from a row are written blank. Column order follows *columns*.
|
|
260
|
+
"""
|
|
261
|
+
with open(path, "w", newline="") as fh:
|
|
262
|
+
writer = csv.DictWriter(fh, fieldnames=columns, extrasaction="ignore")
|
|
263
|
+
writer.writeheader()
|
|
264
|
+
for row in rows:
|
|
265
|
+
writer.writerow(row)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def format_json_output(data: dict) -> str:
|
|
269
|
+
"""Return *data* as an indented JSON string."""
|
|
270
|
+
return json.dumps(data, indent=2)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def display_system_info(
|
|
274
|
+
system_info: SystemStatusResponse,
|
|
275
|
+
devices: list[DeviceResponse],
|
|
276
|
+
) -> None:
|
|
277
|
+
"""Render system status and device tables to the console."""
|
|
278
|
+
system_table = Table(title="System Status", show_header=False)
|
|
279
|
+
system_table.add_column("Property", style="cyan")
|
|
280
|
+
system_table.add_column("Value", style="white")
|
|
281
|
+
|
|
282
|
+
system_table.add_row("Domain", system_info.domain)
|
|
283
|
+
system_table.add_row("Version", system_info.version)
|
|
284
|
+
system_table.add_row("Uptime", format_uptime(system_info.uptime))
|
|
285
|
+
|
|
286
|
+
console.print(system_table)
|
|
287
|
+
console.print()
|
|
288
|
+
|
|
289
|
+
if not devices:
|
|
290
|
+
console.print("[yellow]No devices connected[/yellow]")
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
available = [d for d in devices if d.available]
|
|
294
|
+
unavailable = [d for d in devices if not d.available]
|
|
295
|
+
|
|
296
|
+
if available:
|
|
297
|
+
avail_table = Table(title="Available Devices")
|
|
298
|
+
avail_table.add_column("Name", style="green")
|
|
299
|
+
avail_table.add_column("Type", style="cyan")
|
|
300
|
+
avail_table.add_column("Queue", style="yellow")
|
|
301
|
+
|
|
302
|
+
for device in available:
|
|
303
|
+
avail_table.add_row(
|
|
304
|
+
device.name, device.type.value, str(device.queue_length)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
console.print(avail_table)
|
|
308
|
+
console.print()
|
|
309
|
+
|
|
310
|
+
if unavailable:
|
|
311
|
+
unavail_table = Table(title="Unavailable Devices")
|
|
312
|
+
unavail_table.add_column("Name", style="red")
|
|
313
|
+
unavail_table.add_column("Type", style="cyan")
|
|
314
|
+
unavail_table.add_column("Queue", style="yellow")
|
|
315
|
+
|
|
316
|
+
for device in unavailable:
|
|
317
|
+
unavail_table.add_row(
|
|
318
|
+
device.name, device.type.value, str(device.queue_length)
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
console.print(unavail_table)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def display_task_logs(lines: list[str]) -> None:
|
|
325
|
+
"""Render a list of log lines in a bordered panel."""
|
|
326
|
+
if not lines:
|
|
327
|
+
console.print("[yellow]No logs available[/yellow]")
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
console.print(Panel("\n".join(lines), title="Task Logs", border_style="blue"))
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def display_task_output(output_data: Any) -> None:
|
|
334
|
+
"""Render task output as formatted JSON."""
|
|
335
|
+
if output_data is None:
|
|
336
|
+
console.print("[yellow]No output data available[/yellow]")
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
json_str = (
|
|
340
|
+
format_json_output(output_data)
|
|
341
|
+
if isinstance(output_data, dict)
|
|
342
|
+
else json.dumps(output_data, indent=2)
|
|
343
|
+
)
|
|
344
|
+
syntax = Syntax(json_str, "json", theme="monokai", line_numbers=True)
|
|
345
|
+
console.print(Panel(syntax, title="Task Output", border_style="green"))
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def wait_for_task_with_progress(
|
|
349
|
+
client: AnalogCloudClient,
|
|
350
|
+
task_id: str,
|
|
351
|
+
task_description: str = "Running task",
|
|
352
|
+
) -> TaskStatusResponse:
|
|
353
|
+
"""Poll *task_id* with a Rich spinner until it reaches a terminal state."""
|
|
354
|
+
|
|
355
|
+
with Progress(
|
|
356
|
+
SpinnerColumn(),
|
|
357
|
+
TextColumn("[progress.description]{task.description}"),
|
|
358
|
+
console=console,
|
|
359
|
+
transient=True,
|
|
360
|
+
) as progress:
|
|
361
|
+
ptask = progress.add_task(
|
|
362
|
+
description=f"{task_description} (ID: {str(task_id)[:8]}...)", total=None
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
while True:
|
|
366
|
+
try:
|
|
367
|
+
status = client.get_task_status(task_id)
|
|
368
|
+
|
|
369
|
+
progress.update(
|
|
370
|
+
ptask,
|
|
371
|
+
description=f"{task_description} — {status.state.value} (ID: {str(task_id)[:8]}...)",
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
if status.state in TERMINAL_TASK_STATES:
|
|
375
|
+
break
|
|
376
|
+
|
|
377
|
+
time.sleep(1.0)
|
|
378
|
+
|
|
379
|
+
except AnalogCloudAPIError as exc:
|
|
380
|
+
progress.update(ptask, description=f"Error checking task status: {exc}")
|
|
381
|
+
raise
|
|
382
|
+
|
|
383
|
+
return status
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def validate_json_file(file_path: str) -> dict:
|
|
387
|
+
"""Load and validate a JSON file, printing user-friendly errors on failure."""
|
|
388
|
+
try:
|
|
389
|
+
from .client import load_json_file
|
|
390
|
+
|
|
391
|
+
return load_json_file(file_path)
|
|
392
|
+
except FileNotFoundError:
|
|
393
|
+
console.print(f"[red]Error: File not found: {file_path}[/red]")
|
|
394
|
+
raise click.Abort()
|
|
395
|
+
except ValueError as exc:
|
|
396
|
+
console.print(f"[red]Error: {exc}[/red]")
|
|
397
|
+
raise click.Abort()
|
|
398
|
+
except Exception as exc:
|
|
399
|
+
console.print(f"[red]Error reading file: {exc}[/red]")
|
|
400
|
+
raise click.Abort()
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def load_binary_file(file_path: str) -> bytes:
|
|
404
|
+
"""Read a binary file and return its contents.
|
|
405
|
+
|
|
406
|
+
Raises ``click.Abort`` with a user-friendly message on failure.
|
|
407
|
+
"""
|
|
408
|
+
import os
|
|
409
|
+
|
|
410
|
+
if not os.path.exists(file_path):
|
|
411
|
+
console.print(f"[red]Error: File not found: {file_path}[/red]")
|
|
412
|
+
raise click.Abort()
|
|
413
|
+
|
|
414
|
+
try:
|
|
415
|
+
with open(file_path, "rb") as f:
|
|
416
|
+
return f.read()
|
|
417
|
+
except Exception as exc:
|
|
418
|
+
console.print(f"[red]Error reading file: {exc}[/red]")
|
|
419
|
+
raise click.Abort()
|
|
420
|
+
|
|
421
|
+
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: analogcli
|
|
3
|
+
Version: 0.1.4
|
|
4
|
+
Summary: Command-line interface for the Analog Cloud system
|
|
5
|
+
Author-Email: Anabrid <info@anabrid.com>
|
|
6
|
+
Requires-Python: <4.0,>=3.11
|
|
7
|
+
Requires-Dist: click<9.0.0,>=8.1.7
|
|
8
|
+
Requires-Dist: httpx<1.0.0,>=0.25.0
|
|
9
|
+
Requires-Dist: analogcloud-common==0.1.4
|
|
10
|
+
Requires-Dist: numpy>=2.4
|
|
11
|
+
Requires-Dist: pydantic<3.0.0,>=2.0.0
|
|
12
|
+
Requires-Dist: rich<14.0.0,>=13.0.0
|
|
13
|
+
Requires-Dist: sympy>=1.14.0
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# Analog Cloud CLI
|
|
17
|
+
|
|
18
|
+
Command-line interface for the Analog Cloud analog computing system.
|
|
19
|
+
|
|
20
|
+
## Overview
|
|
21
|
+
|
|
22
|
+
The Analog Cloud CLI provides a convenient command-line interface to interact with Analog Cloud's analog computing devices through the REST API. You can submit tasks, monitor their progress, and retrieve results directly from your terminal.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
1. Navigate to the CLI directory:
|
|
27
|
+
```bash
|
|
28
|
+
cd components/cli
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
2. Install using pip:
|
|
32
|
+
```bash
|
|
33
|
+
pip install analogcli
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
3. The CLI tool will be available as `analogcli` command after installation.
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
### Basic Commands
|
|
41
|
+
|
|
42
|
+
The CLI connects to an Analog Cloud API server. By default, it connects to `localhost:8000`, but you can specify different host and port:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# Connect to default server
|
|
46
|
+
analogcli status
|
|
47
|
+
|
|
48
|
+
# Connect to different server
|
|
49
|
+
analogcli -h 192.168.1.100 -p 8080 status
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Available Commands
|
|
53
|
+
|
|
54
|
+
#### 1. System Status
|
|
55
|
+
Get system information and connected devices:
|
|
56
|
+
```bash
|
|
57
|
+
analogcli status
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
#### 2. Run Tasks
|
|
61
|
+
Execute a configuration on physical LUCIDAC device:
|
|
62
|
+
```bash
|
|
63
|
+
# Basic run
|
|
64
|
+
analogcli run config.json
|
|
65
|
+
|
|
66
|
+
# With custom parameters
|
|
67
|
+
analogcli run config.json --sample-rate 20000 --op-time 0.5 --device device1
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Options:
|
|
71
|
+
- `--sample-rate`: Number of samples per second (default: 10000)
|
|
72
|
+
- `--op-time`: Integration time in seconds (default: 0.1)
|
|
73
|
+
- `--device`: Specific device ID (auto-detected if not specified)
|
|
74
|
+
|
|
75
|
+
#### 3. Simulate Tasks
|
|
76
|
+
Execute a configuration on the built-in simulator:
|
|
77
|
+
```bash
|
|
78
|
+
# Basic simulation
|
|
79
|
+
analogcli simulate config.json
|
|
80
|
+
|
|
81
|
+
# With simulator options
|
|
82
|
+
analogcli simulate config.json --k0 1000 --with-limits --op-time 1.0
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Options:
|
|
86
|
+
- `--sample-rate`: Number of samples per second (default: 10000)
|
|
87
|
+
- `--op-time`: Integration time in seconds (default: 0.1)
|
|
88
|
+
- `--k0`: Acceleration factor (choices: 1, 10, 100, 1000, 10000; default: 10000)
|
|
89
|
+
- `--with-limits`: Enable integrator capacity limits
|
|
90
|
+
- `--device`: Device context (auto-detected if not specified)
|
|
91
|
+
|
|
92
|
+
#### 4. Compile Tasks
|
|
93
|
+
Compile an ODE specification to device configuration:
|
|
94
|
+
```bash
|
|
95
|
+
# Basic compilation
|
|
96
|
+
analogcli compiler ode.json
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
#### 5. Async submission with `--follow / --no-follow`
|
|
100
|
+
|
|
101
|
+
`compile` and `run` are interactive by default (`--follow`): the CLI
|
|
102
|
+
submits the task, polls the server, and prints logs and results in
|
|
103
|
+
place. For pipelines and long-running jobs use `--no-follow`:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
# Fire and forget — print only the UUID on stdout
|
|
107
|
+
TASK_ID=$(analogcli run config.json --no-follow)
|
|
108
|
+
|
|
109
|
+
# Use the id with task subcommands later
|
|
110
|
+
analogcli task wait "$TASK_ID"
|
|
111
|
+
analogcli task result "$TASK_ID" -o result.json
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
In `--no-follow` mode stdout is **strictly** the UUID; the "Task
|
|
115
|
+
submitted" notice goes to stderr so it does not corrupt pipelines.
|
|
116
|
+
|
|
117
|
+
### Task management — `analogcli task`
|
|
118
|
+
|
|
119
|
+
The `task` subcommand group inspects, awaits, and withdraws tasks
|
|
120
|
+
asynchronously. Output is TTY-aware: interactive terminals get Rich
|
|
121
|
+
panels and tables, pipes get JSON (or raw text for `task logs`).
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
analogcli task list [--state S] [--type T] [--limit N] [--offset N]
|
|
125
|
+
[--ids-only] [--format text|json]
|
|
126
|
+
analogcli task info <id> [--format text|json]
|
|
127
|
+
analogcli task status <id> [--format text|json]
|
|
128
|
+
analogcli task logs <id> [--follow] [--format text|json]
|
|
129
|
+
analogcli task result <id> [-o FILE]
|
|
130
|
+
analogcli task wait <id> [--poll-interval S] [--timeout S]
|
|
131
|
+
analogcli task revoke <id>
|
|
132
|
+
analogcli task delete <id>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
- `task list` enumerates tasks visible to your API key. Use
|
|
136
|
+
`--ids-only` to emit one UUID per line for shell loops; use
|
|
137
|
+
`--format json` for a structured dump.
|
|
138
|
+
- `task info` shows the full detail record (type-specific fields for
|
|
139
|
+
compile vs run).
|
|
140
|
+
- `task status` shows the current state and the state-transition
|
|
141
|
+
history.
|
|
142
|
+
- `task logs` prints the captured log output. With `--follow` the
|
|
143
|
+
command polls every second and emits only newly appended lines until
|
|
144
|
+
the task reaches a terminal state. Off a TTY the snapshot defaults
|
|
145
|
+
to raw text so `grep` works.
|
|
146
|
+
- `task result` writes the task's result payload. Compile tasks
|
|
147
|
+
produce binary device configurations (written to `-o FILE` or to
|
|
148
|
+
binary stdout); run tasks produce JSON (pretty-printed, written to
|
|
149
|
+
`-o FILE` or stdout).
|
|
150
|
+
- `task wait` blocks until the task reaches a terminal state and
|
|
151
|
+
carries the outcome via the exit code — stdout is left empty so the
|
|
152
|
+
shell can branch on `$?` without parsing output.
|
|
153
|
+
- `task revoke` withdraws a `QUEUED` task. It does *not* abort a task
|
|
154
|
+
that is already running (the worker keeps it). The wire-level
|
|
155
|
+
endpoint remains `POST /tasks/{id}/cancel`; the CLI verb is named
|
|
156
|
+
`revoke` because that better reflects the user-visible semantic.
|
|
157
|
+
- `task delete` removes a task and its blobs. The server refuses
|
|
158
|
+
`DELETE` on an `INPROGRESS` task whose worker run is less than two
|
|
159
|
+
minutes old, to protect the worker.
|
|
160
|
+
|
|
161
|
+
#### Exit-code contract
|
|
162
|
+
|
|
163
|
+
Shell scripts can rely on the following exit codes:
|
|
164
|
+
|
|
165
|
+
| Code | Meaning |
|
|
166
|
+
|------|---------|
|
|
167
|
+
| 0 | success / `SUCCEEDED` |
|
|
168
|
+
| 1 | task `FAILED` (for `wait`) |
|
|
169
|
+
| 2 | task `CANCELLED` (for `wait`) |
|
|
170
|
+
| 3 | transport / API error (connection refused, 5xx, etc.) |
|
|
171
|
+
| 4 | state conflict — e.g. `revoke` on a non-`QUEUED` task, or `delete` inside the worker grace window (HTTP 409) |
|
|
172
|
+
| 124 | timeout (matches GNU `timeout`); emitted by `task wait --timeout S` |
|
|
173
|
+
|
|
174
|
+
Example: revoke a queued task only if it is still queued, otherwise
|
|
175
|
+
fall through to waiting on it:
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
if ! analogcli task revoke "$TASK_ID" 2>/dev/null; then
|
|
179
|
+
case $? in
|
|
180
|
+
4) analogcli task wait "$TASK_ID" ;; # already running — wait it out
|
|
181
|
+
*) exit $? ;;
|
|
182
|
+
esac
|
|
183
|
+
fi
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### File Formats
|
|
187
|
+
|
|
188
|
+
#### Configuration Files (for run/simulate)
|
|
189
|
+
JSON files containing analog circuit configurations:
|
|
190
|
+
```json
|
|
191
|
+
{
|
|
192
|
+
"integrators": {
|
|
193
|
+
"int0": {"initial_value": 1.0}
|
|
194
|
+
},
|
|
195
|
+
"constants": {
|
|
196
|
+
"k1": 2.5
|
|
197
|
+
},
|
|
198
|
+
"connections": []
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
#### ODE Files (for compiler)
|
|
203
|
+
JSON files containing ODE specifications:
|
|
204
|
+
```json
|
|
205
|
+
{
|
|
206
|
+
"variables": ["x", "y"],
|
|
207
|
+
"equations": {
|
|
208
|
+
"x": "-y",
|
|
209
|
+
"y": "x"
|
|
210
|
+
},
|
|
211
|
+
"initial_conditions": {
|
|
212
|
+
"x": 1.0,
|
|
213
|
+
"y": 0.0
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Example Workflow
|
|
219
|
+
|
|
220
|
+
1. Check system status:
|
|
221
|
+
```bash
|
|
222
|
+
analogcli status
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
2. Compile an ODE to configuration:
|
|
226
|
+
```bash
|
|
227
|
+
analogcli compiler my_ode.json
|
|
228
|
+
# Save the output to compiled_config.json when prompted
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
3. Run the compiled configuration:
|
|
232
|
+
```bash
|
|
233
|
+
analogcli run compiled_config.json --sample-rate 25000 --op-time 0.2
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
4. Or simulate instead:
|
|
237
|
+
```bash
|
|
238
|
+
analogcli simulate compiled_config.json --k0 1000 --with-limits
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Environment Variables
|
|
242
|
+
|
|
243
|
+
| Variable | Description |
|
|
244
|
+
|----------|-------------|
|
|
245
|
+
| `ANALOGCLOUD_API_KEY` | API key for authentication (required) |
|
|
246
|
+
| `ANALOGCLOUD_URL` | Base URL of the Analog Cloud server |
|
|
247
|
+
|
|
248
|
+
## Development
|
|
249
|
+
|
|
250
|
+
### Running Tests
|
|
251
|
+
```bash
|
|
252
|
+
uv run pytest
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Code Formatting
|
|
256
|
+
```bash
|
|
257
|
+
uv run black .
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## Dependencies
|
|
261
|
+
|
|
262
|
+
- **click**: Command-line interface framework
|
|
263
|
+
- **httpx**: HTTP client for API communication
|
|
264
|
+
- **pydantic**: Data validation using the shared models
|
|
265
|
+
- **rich**: Rich text and beautiful formatting in the terminal
|
|
266
|
+
- **analogcloud-common**: Shared models and utilities
|
|
267
|
+
|
|
268
|
+
## Error Handling
|
|
269
|
+
|
|
270
|
+
The CLI provides user-friendly error messages for common issues:
|
|
271
|
+
- Connection errors to the API server
|
|
272
|
+
- Invalid JSON files
|
|
273
|
+
- Missing files
|
|
274
|
+
- API errors with detailed messages
|
|
275
|
+
|
|
276
|
+
Progress indicators show task status during execution, and results are displayed in a formatted, readable way.
|