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.
@@ -0,0 +1,116 @@
1
+ """CLI command for executing a compiled module on a physical device."""
2
+
3
+ import click
4
+ import json
5
+ from ..context import get_client
6
+ from ..utils import (
7
+ handle_api_error,
8
+ load_binary_file,
9
+ wait_for_task_with_progress,
10
+ display_task_logs,
11
+ display_task_output,
12
+ console,
13
+ )
14
+ from anabrid.cloud.models.enums import TaskStateEnum
15
+
16
+
17
+ @click.command()
18
+ @click.argument("module_file", type=click.Path(exists=True))
19
+ @click.option(
20
+ "--sample-rate",
21
+ default=10000,
22
+ type=int,
23
+ show_default=True,
24
+ help="ADC samples drawn per second",
25
+ )
26
+ @click.option(
27
+ "--op-time",
28
+ default=0.1,
29
+ type=float,
30
+ show_default=True,
31
+ help="Wall-clock integration time in seconds",
32
+ )
33
+ @click.option(
34
+ "--device",
35
+ help="Device name to run on. If omitted, the first available LUCIDAC is used.",
36
+ )
37
+ @click.option(
38
+ "--output",
39
+ "-o",
40
+ default=None,
41
+ help="Save run result JSON to this file path",
42
+ )
43
+ @click.option(
44
+ "--follow/--no-follow",
45
+ default=True,
46
+ show_default=True,
47
+ help="Wait for the task to finish; with --no-follow only the UUID is printed.",
48
+ )
49
+ @click.pass_context
50
+ @handle_api_error
51
+ def run(
52
+ ctx: click.Context,
53
+ module_file: str,
54
+ sample_rate: int,
55
+ op_time: float,
56
+ device: str | None,
57
+ output: str | None,
58
+ follow: bool,
59
+ ):
60
+ """Execute a compiled module on a physical LUCIDAC device.
61
+
62
+ MODULE_FILE should be a compiled protobuf configuration blob (.apb).
63
+
64
+ Example:
65
+ analogcli run compiled.apb --sample-rate 20000 --op-time 0.5 --device lucidac-01
66
+ """
67
+ module_bytes = load_binary_file(module_file)
68
+
69
+ with get_client(ctx) as client:
70
+ if not device:
71
+ devices = client.get_devices()
72
+ lucidacs = [
73
+ d for d in devices if "lucidac" in d.name.lower() and d.available
74
+ ]
75
+ if not lucidacs:
76
+ console.print("[red]No available LUCIDAC device found[/red]")
77
+ return
78
+ device = lucidacs[0].name
79
+ if follow:
80
+ console.print(f"[yellow]Using device: {device}[/yellow]")
81
+
82
+ if follow:
83
+ console.print(f"[blue]Submitting run task to device {device}...[/blue]")
84
+ task_id = client.submit_run_task(
85
+ device_name=device,
86
+ module=module_bytes,
87
+ sample_rate=sample_rate,
88
+ op_time=op_time,
89
+ )
90
+
91
+ if not follow:
92
+ click.echo(task_id)
93
+ click.echo(f"Task submitted: {task_id}", err=True)
94
+ return
95
+
96
+ console.print(f"[green]Task submitted with ID: {task_id}[/green]")
97
+
98
+ final_status = wait_for_task_with_progress(client, task_id, "Running on device")
99
+
100
+ log_response = client.get_task_log(task_id)
101
+ display_task_logs(log_response.lines)
102
+
103
+ if final_status.state == TaskStateEnum.SUCCEEDED:
104
+ console.print("\n[bold green]Task completed successfully![/bold green]")
105
+
106
+ result = client.get_task_result(task_id)
107
+ console.print()
108
+ display_task_output(result)
109
+
110
+ if output:
111
+ with open(output, "w") as f:
112
+ json.dump(result, f, indent=2)
113
+ console.print(f"[green]Result saved to {output}[/green]")
114
+ else:
115
+ error = final_status.error or "(no detail)"
116
+ console.print(f"\n[bold red]Task failed: {error}[/bold red]")
@@ -0,0 +1,20 @@
1
+ """CLI command for displaying system and device status."""
2
+
3
+ import click
4
+ from ..context import get_client
5
+ from ..utils import handle_api_error, display_system_info
6
+
7
+
8
+ @click.command()
9
+ @click.pass_context
10
+ @handle_api_error
11
+ def status(ctx: click.Context):
12
+ """Display system status and all connected devices.
13
+
14
+ Shows the hub domain, version, uptime, and a table of available
15
+ and unavailable devices with their current queue lengths.
16
+ """
17
+ with get_client(ctx) as client:
18
+ system_info = client.get_system_status()
19
+ devices = client.get_devices()
20
+ display_system_info(system_info, devices)
@@ -0,0 +1,29 @@
1
+ """Task management commands registered under ``analogcli task``."""
2
+
3
+ import click
4
+
5
+ from .delete import task_delete
6
+ from .info import task_info
7
+ from .list import task_list
8
+ from .logs import task_logs
9
+ from .result import task_result
10
+ from .revoke import task_revoke
11
+ from .search import task_search
12
+ from .status import task_status
13
+ from .wait import task_wait
14
+
15
+
16
+ @click.group(name="task")
17
+ def task() -> None:
18
+ """Operations on tasks (list / search / info / status / logs / result / wait / revoke / delete)."""
19
+
20
+
21
+ task.add_command(task_list, name="list")
22
+ task.add_command(task_search, name="search")
23
+ task.add_command(task_info, name="info")
24
+ task.add_command(task_status, name="status")
25
+ task.add_command(task_logs, name="logs")
26
+ task.add_command(task_result, name="result")
27
+ task.add_command(task_wait, name="wait")
28
+ task.add_command(task_revoke, name="revoke")
29
+ task.add_command(task_delete, name="delete")
@@ -0,0 +1,22 @@
1
+ """``analogcli task delete`` — permanently delete a task and its blobs."""
2
+
3
+ import click
4
+
5
+ from ...context import get_client
6
+ from ...utils import handle_api_error
7
+
8
+ __all__ = ["task_delete", "get_client"]
9
+
10
+
11
+ @click.command()
12
+ @click.argument("task_id")
13
+ @click.pass_context
14
+ @handle_api_error
15
+ def task_delete(ctx: click.Context, task_id: str) -> None:
16
+ """Permanently delete *task_id* and its associated blobs.
17
+
18
+ Stdout stays empty on success; a 409 state conflict is mapped to
19
+ exit code 4 by the ``handle_api_error`` decorator.
20
+ """
21
+ with get_client(ctx) as client:
22
+ client.delete_task(task_id)
@@ -0,0 +1,66 @@
1
+ """``analogcli task info`` — detailed task view."""
2
+
3
+ import click
4
+ from rich.panel import Panel
5
+
6
+ from ...context import get_client
7
+ from ...utils import (
8
+ OutputMode,
9
+ console,
10
+ dump_task_json,
11
+ format_timestamp,
12
+ handle_api_error,
13
+ pick_output_mode,
14
+ )
15
+
16
+
17
+ @click.command()
18
+ @click.argument("task_id")
19
+ @click.option(
20
+ "--format",
21
+ "format_",
22
+ type=click.Choice(["text", "json"]),
23
+ default=None,
24
+ help="Force the output format; defaults to auto-detect from TTY.",
25
+ )
26
+ @click.pass_context
27
+ @handle_api_error
28
+ def task_info(ctx: click.Context, task_id: str, format_: str | None) -> None:
29
+ """Show detailed information for a single task."""
30
+ with get_client(ctx) as client:
31
+ detail = client.get_task_detail(task_id)
32
+
33
+ mode = pick_output_mode(ctx, format_, follow=False)
34
+ if mode == OutputMode.JSON:
35
+ dump_task_json(detail)
36
+ return
37
+
38
+ type_val = detail.type
39
+ type_str = type_val.value if hasattr(type_val, "value") else str(type_val)
40
+ state_val = detail.state
41
+ state_str = state_val.value if hasattr(state_val, "value") else str(state_val)
42
+ created_str = format_timestamp(detail.created_at)
43
+
44
+ lines = [
45
+ f"[cyan]ID:[/cyan] {detail.id}",
46
+ f"[cyan]Type:[/cyan] {type_str}",
47
+ f"[cyan]State:[/cyan] {state_str}",
48
+ f"[cyan]Created:[/cyan] {created_str}",
49
+ ]
50
+ if getattr(detail, "error", None):
51
+ lines.append(f"[red]Error:[/red] {detail.error}")
52
+ if hasattr(detail, "backend"):
53
+ backend = detail.backend
54
+ backend_str = backend.value if hasattr(backend, "value") else str(backend)
55
+ lines.append(f"[cyan]Backend:[/cyan] {backend_str}")
56
+ lines.append(f"[cyan]k0:[/cyan] {detail.k0}")
57
+ if hasattr(detail, "device_name"):
58
+ lines.append(f"[cyan]Device:[/cyan] {detail.device_name}")
59
+ lines.append(f"[cyan]Sample rate:[/cyan] {detail.sample_rate}")
60
+ lines.append(f"[cyan]Op time:[/cyan] {detail.op_time}")
61
+ if hasattr(detail, "has_result"):
62
+ lines.append(f"[cyan]Has result:[/cyan] {detail.has_result}")
63
+ if hasattr(detail, "has_log"):
64
+ lines.append(f"[cyan]Has log:[/cyan] {detail.has_log}")
65
+
66
+ console.print(Panel("\n".join(lines), title=f"Task {detail.id}"))
@@ -0,0 +1,82 @@
1
+ """``analogcli task list`` — paginated, filterable task listing."""
2
+
3
+ import click
4
+
5
+ from ...context import get_client
6
+ from ...utils import (
7
+ OutputMode,
8
+ dump_task_json,
9
+ handle_api_error,
10
+ pick_output_mode,
11
+ print_task_table,
12
+ )
13
+
14
+
15
+ @click.command()
16
+ @click.option(
17
+ "--state",
18
+ default=None,
19
+ help="Filter tasks by state (e.g. QUEUED, INPROGRESS, SUCCEEDED).",
20
+ )
21
+ @click.option(
22
+ "--type",
23
+ "task_type",
24
+ default=None,
25
+ help="Filter tasks by type (e.g. compile, run).",
26
+ )
27
+ @click.option(
28
+ "--limit",
29
+ default=None,
30
+ type=int,
31
+ help="Maximum number of tasks to return.",
32
+ )
33
+ @click.option(
34
+ "--offset",
35
+ default=None,
36
+ type=int,
37
+ help="Number of tasks to skip before the page.",
38
+ )
39
+ @click.option(
40
+ "--ids-only",
41
+ is_flag=True,
42
+ default=False,
43
+ help="Emit one UUID per line on stdout (overrides --format).",
44
+ )
45
+ @click.option(
46
+ "--format",
47
+ "format_",
48
+ type=click.Choice(["text", "json"]),
49
+ default=None,
50
+ help="Force the output format; defaults to auto-detect from TTY.",
51
+ )
52
+ @click.pass_context
53
+ @handle_api_error
54
+ def task_list(
55
+ ctx: click.Context,
56
+ state: str | None,
57
+ task_type: str | None,
58
+ limit: int | None,
59
+ offset: int | None,
60
+ ids_only: bool,
61
+ format_: str | None,
62
+ ) -> None:
63
+ """List tasks visible to the current API key."""
64
+ with get_client(ctx) as client:
65
+ tasks = client.list_tasks(
66
+ state=state,
67
+ type=task_type,
68
+ limit=limit,
69
+ offset=offset,
70
+ )
71
+
72
+ if ids_only:
73
+ for task in tasks:
74
+ task_id = task.id if hasattr(task, "id") else task["id"]
75
+ click.echo(str(task_id))
76
+ return
77
+
78
+ mode = pick_output_mode(ctx, format_, follow=False)
79
+ if mode == OutputMode.JSON:
80
+ dump_task_json(tasks)
81
+ else:
82
+ print_task_table(tasks)
@@ -0,0 +1,120 @@
1
+ """``analogcli task logs`` — fetch (and optionally follow) a task's log output."""
2
+
3
+ import time
4
+
5
+ import click
6
+ from rich.panel import Panel
7
+
8
+ from ...context import get_client
9
+ from ...utils import (
10
+ OutputMode,
11
+ console,
12
+ dump_task_json,
13
+ handle_api_error,
14
+ pick_output_mode,
15
+ print_task_logs_plain,
16
+ )
17
+
18
+ __all__ = ["task_logs", "get_client", "time", "POLL_INTERVAL"]
19
+
20
+
21
+ POLL_INTERVAL: float = 1.0
22
+ """Seconds between polls in ``--follow`` mode. Patched to 0 in tests."""
23
+
24
+
25
+ _TERMINAL_STATE_VALUES = frozenset({"succeeded", "failed", "cancelled"})
26
+ """Lowercase wire-level values considered terminal for follow-mode polling."""
27
+
28
+
29
+ def _state_value(state: object) -> str:
30
+ """Return the lowercase string value of a state enum or raw string."""
31
+ raw = getattr(state, "value", state)
32
+ return str(raw).lower()
33
+
34
+
35
+ def _pick_logs_output_mode(
36
+ ctx: click.Context | None, format_: str | None
37
+ ) -> OutputMode:
38
+ """Pick the output mode for ``task logs`` snapshot output.
39
+
40
+ Logs are text-native: the natural downstream consumer of piped log
41
+ output is ``grep``, not ``jq``. That makes the JSON-by-default
42
+ behaviour of the shared ``pick_output_mode`` (designed for
43
+ structured commands like ``task list``) the wrong choice here. This
44
+ helper overrides the auto-detect branch to default to TEXT off-TTY
45
+ while still honouring an explicit ``--format`` verbatim, so the
46
+ user can opt into JSON when they want it.
47
+ """
48
+ if format_ is not None:
49
+ return pick_output_mode(ctx, format_, follow=False)
50
+ if _stdout_is_tty():
51
+ return OutputMode.RICH
52
+ return OutputMode.TEXT
53
+
54
+
55
+ def _stdout_is_tty() -> bool:
56
+ """Return True if ``sys.stdout`` reports being attached to a TTY."""
57
+ import sys
58
+
59
+ isatty = getattr(sys.stdout, "isatty", None)
60
+ if isatty is None:
61
+ return False
62
+ try:
63
+ return bool(isatty())
64
+ except Exception:
65
+ return False
66
+
67
+
68
+ @click.command()
69
+ @click.argument("task_id")
70
+ @click.option(
71
+ "--follow",
72
+ is_flag=True,
73
+ default=False,
74
+ help="Continuously poll for new log lines until the task reaches a terminal state.",
75
+ )
76
+ @click.option(
77
+ "--format",
78
+ "format_",
79
+ type=click.Choice(["text", "json"]),
80
+ default=None,
81
+ help="Force the output format; defaults to auto-detect from TTY.",
82
+ )
83
+ @click.pass_context
84
+ @handle_api_error
85
+ def task_logs(
86
+ ctx: click.Context, task_id: str, follow: bool, format_: str | None
87
+ ) -> None:
88
+ """Show the log output for *task_id*.
89
+
90
+ Snapshot mode (default): fetch once and render to stdout. On a TTY the
91
+ log lines appear in a Rich panel; off a TTY the raw newline-joined
92
+ lines are printed so ``grep`` works.
93
+
94
+ With ``--follow``: poll periodically and emit only newly appended lines
95
+ until the task reaches a terminal state.
96
+ """
97
+ with get_client(ctx) as client:
98
+ if not follow:
99
+ log = client.get_task_log(task_id)
100
+ lines = log.lines
101
+ mode = _pick_logs_output_mode(ctx, format_)
102
+ if mode == OutputMode.JSON:
103
+ dump_task_json(log)
104
+ elif mode == OutputMode.TEXT:
105
+ print_task_logs_plain(lines)
106
+ else:
107
+ console.print(Panel("\n".join(lines), title="Task logs"))
108
+ return
109
+
110
+ printed = 0
111
+ while True:
112
+ status = client.get_task_status(task_id)
113
+ log = client.get_task_log(task_id)
114
+ lines = log.lines
115
+ for line in lines[printed:]:
116
+ click.echo(line)
117
+ printed = len(lines)
118
+ if _state_value(status.state) in _TERMINAL_STATE_VALUES:
119
+ break
120
+ time.sleep(POLL_INTERVAL)
@@ -0,0 +1,64 @@
1
+ """``analogcli task result`` — fetch a task's result payload (compile bytes or run JSON)."""
2
+
3
+ import json
4
+ import sys
5
+
6
+ import click
7
+
8
+ from ...context import get_client
9
+ from ...utils import handle_api_error
10
+
11
+ __all__ = ["task_result", "get_client"]
12
+
13
+
14
+ def _to_jsonable(payload: object) -> object:
15
+ """Return a JSON-serialisable representation of *payload*.
16
+
17
+ Pydantic models are dumped via ``model_dump(mode="json")``; everything
18
+ else is returned unchanged so ``json.dumps(..., default=str)`` can
19
+ handle leaf types.
20
+ """
21
+ if hasattr(payload, "model_dump"):
22
+ return payload.model_dump(mode="json")
23
+ return payload
24
+
25
+
26
+ @click.command()
27
+ @click.argument("task_id")
28
+ @click.option(
29
+ "-o",
30
+ "--output",
31
+ "output",
32
+ type=click.Path(dir_okay=False, writable=True),
33
+ default=None,
34
+ help="Write the result to FILE instead of stdout.",
35
+ )
36
+ @click.pass_context
37
+ @handle_api_error
38
+ def task_result(ctx: click.Context, task_id: str, output: str | None) -> None:
39
+ """Fetch the result payload for *task_id*.
40
+
41
+ Compile tasks return a binary protobuf blob; run tasks return a JSON
42
+ document. The discrimination is driven by the wire-level content-type
43
+ returned from ``client.get_task_result``.
44
+
45
+ When ``-o FILE`` is given the payload is written to that path; otherwise
46
+ it is written to stdout (binary for compile, JSON for run).
47
+ """
48
+ with get_client(ctx) as client:
49
+ payload = client.get_task_result(task_id)
50
+
51
+ if isinstance(payload, bytes):
52
+ if output is not None:
53
+ with open(output, "wb") as f:
54
+ f.write(payload)
55
+ else:
56
+ sys.stdout.buffer.write(payload)
57
+ return
58
+
59
+ jsonable = _to_jsonable(payload)
60
+ if output is not None:
61
+ with open(output, "w") as f:
62
+ json.dump(jsonable, f, indent=2, default=str)
63
+ else:
64
+ click.echo(json.dumps(jsonable, indent=2, default=str))
@@ -0,0 +1,23 @@
1
+ """``analogcli task revoke`` — request cancellation of a queued/running task."""
2
+
3
+ import click
4
+
5
+ from ...context import get_client
6
+ from ...utils import handle_api_error
7
+
8
+ __all__ = ["task_revoke", "get_client"]
9
+
10
+
11
+ @click.command()
12
+ @click.argument("task_id")
13
+ @click.pass_context
14
+ @handle_api_error
15
+ def task_revoke(ctx: click.Context, task_id: str) -> None:
16
+ """Request cancellation of *task_id*.
17
+
18
+ Wire-level endpoint is unchanged (``POST /tasks/{id}/cancel``).
19
+ Stdout stays empty on success; a 409 state conflict is mapped to
20
+ exit code 4 by the ``handle_api_error`` decorator.
21
+ """
22
+ with get_client(ctx) as client:
23
+ client.cancel_task(task_id)