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,238 @@
1
+ """``analogcli task search`` — cross-user task search, report table + CSV export."""
2
+
3
+ import json
4
+ from datetime import datetime, timedelta, timezone
5
+
6
+ import click
7
+
8
+ from ...context import get_client
9
+ from ...utils import (
10
+ OutputMode,
11
+ handle_api_error,
12
+ pick_output_mode,
13
+ print_search_table,
14
+ write_task_csv,
15
+ )
16
+
17
+ # Column sets for the report table and CSV export.
18
+ THIN_COLUMNS = ["id", "user_id", "type", "state", "created_at", "error"]
19
+ RICH_COLUMNS = THIN_COLUMNS + [
20
+ "device_name",
21
+ "backend",
22
+ "sample_rate",
23
+ "op_time",
24
+ "k0",
25
+ "has_result",
26
+ "has_log",
27
+ ]
28
+
29
+ # Per-request page size when gathering all matches across pages.
30
+ PAGE_SIZE = 1000
31
+
32
+
33
+ @click.command()
34
+ @click.option("--user-id", "user_id", multiple=True,
35
+ help="Filter by owning api_key (honoured only for system keys). Repeatable.")
36
+ @click.option("--me", is_flag=True, default=False,
37
+ help="Shortcut for --user-id $ANALOGCLOUD_API_KEY (your own jobs). "
38
+ "Combines with any --user-id values given.")
39
+ @click.option("--device", "device_name", multiple=True,
40
+ help="Filter by device name (matches run jobs). Repeatable.")
41
+ @click.option("--device-class", "device_class", multiple=True,
42
+ help="Filter by device class: LUCIDAC, REDAC or SIMULATOR. Repeatable.")
43
+ @click.option("--type", "task_type", multiple=True,
44
+ help="Filter by task type: compile or run. Repeatable.")
45
+ @click.option("--state", multiple=True,
46
+ help="Filter by latest state, e.g. QUEUED, INPROGRESS, SUCCEEDED. Repeatable.")
47
+ @click.option("--from", "from_", default=None,
48
+ help="Window start as an ISO 8601 datetime, e.g. 2026-05-01T00:00:00 "
49
+ "(default: 24h before --to). Mutually exclusive with --last-*.")
50
+ @click.option("--to", "to", default=None,
51
+ help="Window end as an ISO 8601 datetime, e.g. 2026-05-08T00:00:00 "
52
+ "(default: now). Window capped at 90 days. Mutually exclusive with --last-*.")
53
+ @click.option("--last-h", "last_h", default=None, type=int,
54
+ help="Show the last N hours (window = now-Nh .. now). "
55
+ "Excludes --from/--to and the other --last-* options.")
56
+ @click.option("--last-d", "last_d", default=None, type=int,
57
+ help="Show the last N days. Excludes --from/--to and the other --last-* options.")
58
+ @click.option("--last-m", "last_m", default=None, type=int,
59
+ help="Show the last N minutes. Excludes --from/--to and the other --last-* options.")
60
+ @click.option("--limit", default=None, type=int,
61
+ help="Cap the total number of jobs returned (default: all matches).")
62
+ @click.option("--recursive", is_flag=True, default=False,
63
+ help="Enrich each job with full detail (device/backend/...) via per-job lookups.")
64
+ @click.option("--csv", "csv_path", default=None, type=click.Path(dir_okay=False, writable=True),
65
+ help="Also write the result set to this path as CSV.")
66
+ @click.option("--ids-only", is_flag=True, default=False,
67
+ help="Emit one UUID per line on stdout (overrides --format).")
68
+ @click.option("--format", "format_", type=click.Choice(["text", "json"]), default=None,
69
+ help="Force the output format; defaults to auto-detect from TTY.")
70
+ @click.pass_context
71
+ @handle_api_error
72
+ def task_search(
73
+ ctx: click.Context,
74
+ user_id: tuple[str, ...],
75
+ me: bool,
76
+ device_name: tuple[str, ...],
77
+ device_class: tuple[str, ...],
78
+ task_type: tuple[str, ...],
79
+ state: tuple[str, ...],
80
+ from_: str | None,
81
+ to: str | None,
82
+ last_h: int | None,
83
+ last_d: int | None,
84
+ last_m: int | None,
85
+ limit: int | None,
86
+ recursive: bool,
87
+ csv_path: str | None,
88
+ ids_only: bool,
89
+ format_: str | None,
90
+ ) -> None:
91
+ """Search tasks and render a report table, optionally exporting CSV.
92
+
93
+ System keys search across all users (and may filter by ``--user-id``);
94
+ non-system keys are scoped to their own tasks. All matches are gathered
95
+ across pages up to ``--limit``.
96
+
97
+ \b
98
+ Time window — pick ONE style:
99
+ * --from / --to with ISO 8601 datetimes (e.g. 2026-05-01T00:00:00).
100
+ A bare datetime is read as UTC; a value with an offset is converted.
101
+ * --last-h / --last-d / --last-m for a window ending "now". These are
102
+ mutually exclusive with --from/--to and with each other.
103
+
104
+ \b
105
+ Examples:
106
+ # explicit ISO 8601 window (one week)
107
+ analogcli task search --from 2026-05-01T00:00:00 --to 2026-05-08T00:00:00
108
+ # from a fixed start up to the current moment; generate the ISO
109
+ # timestamp for "now" with the Linux `date` command:
110
+ analogcli task search --from 2026-05-01T00:00:00 --to "$(date -u +%Y-%m-%dT%H:%M:%S)"
111
+ # the last 6 hours / 7 days / 90 minutes
112
+ analogcli task search --last-h 6
113
+ analogcli task search --last-d 7
114
+ analogcli task search --last-m 90
115
+ # multiple owners (system key) — repeat the flag
116
+ analogcli task search --user-id alice-key --user-id bob-key
117
+ # just your own jobs (handy for a system key, which sees everyone by default)
118
+ analogcli task search --me
119
+ """
120
+ # Resolve the time window: --last-* is a convenience over --from/--to and
121
+ # may not be mixed with them or with each other.
122
+ last_opts = {"--last-h": last_h, "--last-d": last_d, "--last-m": last_m}
123
+ given_last = {name: val for name, val in last_opts.items() if val is not None}
124
+ if len(given_last) > 1:
125
+ raise click.UsageError(
126
+ "Only one of --last-h / --last-d / --last-m may be used at a time."
127
+ )
128
+ if given_last and (from_ is not None or to is not None):
129
+ raise click.UsageError(
130
+ "--last-h / --last-d / --last-m cannot be combined with --from / --to."
131
+ )
132
+ if given_last:
133
+ deltas = {
134
+ "--last-h": timedelta(hours=last_h or 0),
135
+ "--last-d": timedelta(days=last_d or 0),
136
+ "--last-m": timedelta(minutes=last_m or 0),
137
+ }
138
+ (name,) = given_last
139
+ if given_last[name] < 0:
140
+ raise click.UsageError(f"{name} must not be negative.")
141
+ now = datetime.now(timezone.utc)
142
+ to = now.isoformat()
143
+ from_ = (now - deltas[name]).isoformat()
144
+
145
+ _to_list = lambda t: list(t) if t else None # noqa: E731
146
+
147
+ uid_filter = _to_list(user_id)
148
+ dev_filter = _to_list(device_name)
149
+ cls_filter = _to_list(device_class)
150
+ type_filter = _to_list(task_type)
151
+ state_filter = _to_list(state)
152
+
153
+ columns = RICH_COLUMNS if recursive else THIN_COLUMNS
154
+
155
+ collected = []
156
+ offset = 0
157
+
158
+ with get_client(ctx) as client:
159
+ # --me folds the caller's own api_key into the user_id filter (deduped,
160
+ # order-preserving). For a non-system key the server force-scopes to
161
+ # the caller anyway, so this is a no-op there; it matters for a system
162
+ # key that would otherwise see every user's jobs.
163
+ if me:
164
+ uid_filter = list(dict.fromkeys((uid_filter or []) + [client.api_key]))
165
+
166
+ # Pagination loop — gather all matching pages up to limit.
167
+ while True:
168
+ remaining = None if limit is None else limit - len(collected)
169
+ if remaining is not None and remaining <= 0:
170
+ break
171
+ req_limit = PAGE_SIZE if remaining is None else min(PAGE_SIZE, remaining)
172
+ resp = client.search_tasks(
173
+ user_id=uid_filter,
174
+ device_name=dev_filter,
175
+ device_class=cls_filter,
176
+ type=type_filter,
177
+ state=state_filter,
178
+ from_=from_,
179
+ to=to,
180
+ limit=req_limit,
181
+ offset=offset,
182
+ )
183
+ collected.extend(resp.items)
184
+ if resp.next_offset is None:
185
+ break
186
+ offset = resp.next_offset
187
+
188
+ if limit is not None:
189
+ collected = collected[:limit]
190
+
191
+ # Optionally enrich each item with per-job detail.
192
+ details = {}
193
+ if recursive:
194
+ for item in collected:
195
+ details[item.id] = client.get_task_detail(str(item.id))
196
+
197
+ # Build flat row dicts.
198
+ def _attr_val(obj, name: str) -> str:
199
+ """Return a string rendering of *name* on *obj*; None or missing → ''."""
200
+ val = getattr(obj, name, None)
201
+ if val is None:
202
+ return ""
203
+ if hasattr(val, "value"):
204
+ return str(val.value)
205
+ return str(val)
206
+
207
+ rows = []
208
+ for item in collected:
209
+ row = {
210
+ "id": str(item.id),
211
+ "user_id": item.user_id,
212
+ "type": item.type.value,
213
+ "state": item.state.value,
214
+ "created_at": item.created_at.isoformat(),
215
+ "error": item.error or "",
216
+ }
217
+ if recursive:
218
+ detail = details.get(item.id)
219
+ for col in ["device_name", "backend", "sample_rate", "op_time", "k0", "has_result", "has_log"]:
220
+ row[col] = _attr_val(detail, col) if detail is not None else ""
221
+ rows.append(row)
222
+
223
+ # --ids-only overrides --format.
224
+ if ids_only:
225
+ for item in collected:
226
+ click.echo(str(item.id))
227
+ if csv_path is not None:
228
+ write_task_csv(rows, csv_path, columns)
229
+ return
230
+
231
+ mode = pick_output_mode(ctx, format_, follow=False)
232
+ if mode == OutputMode.JSON:
233
+ click.echo(json.dumps(rows, indent=2, default=str))
234
+ else:
235
+ print_search_table(rows, columns)
236
+
237
+ if csv_path is not None:
238
+ write_task_csv(rows, csv_path, columns)
@@ -0,0 +1,54 @@
1
+ """``analogcli task status`` — current state of a single task."""
2
+
3
+ import click
4
+ from rich.table import Table
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_status(ctx: click.Context, task_id: str, format_: str | None) -> None:
29
+ """Show the current state and state history of a single task."""
30
+ with get_client(ctx) as client:
31
+ status_obj = client.get_task_status(task_id)
32
+
33
+ mode = pick_output_mode(ctx, format_, follow=False)
34
+ if mode == OutputMode.JSON:
35
+ dump_task_json(status_obj)
36
+ return
37
+
38
+ state_val = status_obj.state
39
+ state_str = state_val.value if hasattr(state_val, "value") else str(state_val)
40
+ console.print(f"[cyan]ID:[/cyan] {status_obj.id}")
41
+ console.print(f"[cyan]State:[/cyan] {state_str}")
42
+ if status_obj.error:
43
+ console.print(f"[red]Error:[/red] {status_obj.error}")
44
+
45
+ table = Table(title="State history")
46
+ table.add_column("Timestamp", style="white")
47
+ table.add_column("State", style="green")
48
+ for entry in status_obj.state_history:
49
+ entry_state = entry.state
50
+ entry_state_str = (
51
+ entry_state.value if hasattr(entry_state, "value") else str(entry_state)
52
+ )
53
+ table.add_row(format_timestamp(entry.timestamp), entry_state_str)
54
+ console.print(table)
@@ -0,0 +1,67 @@
1
+ """``analogcli task wait`` — poll a task until terminal; exit code encodes the state."""
2
+
3
+ import sys
4
+ import time
5
+
6
+ import click
7
+
8
+ from ...context import get_client
9
+ from ...utils import handle_api_error
10
+
11
+ __all__ = ["task_wait", "get_client", "time", "_now"]
12
+
13
+
14
+ def _now() -> float:
15
+ """Return a monotonic timestamp in seconds for elapsed-time computation.
16
+
17
+ Wrapped in a helper so tests can patch a single, named symbol on this
18
+ module rather than monkey-patching ``time.monotonic`` globally.
19
+ """
20
+ return time.monotonic()
21
+
22
+
23
+ @click.command()
24
+ @click.argument("task_id")
25
+ @click.option(
26
+ "--poll-interval",
27
+ "poll_interval",
28
+ type=float,
29
+ default=1.0,
30
+ show_default=True,
31
+ help="Seconds between status polls.",
32
+ )
33
+ @click.option(
34
+ "--timeout",
35
+ "timeout",
36
+ type=float,
37
+ default=None,
38
+ help="Abort polling after this many seconds and exit 124.",
39
+ )
40
+ @click.pass_context
41
+ @handle_api_error
42
+ def task_wait(
43
+ ctx: click.Context,
44
+ task_id: str,
45
+ poll_interval: float,
46
+ timeout: float | None,
47
+ ) -> None:
48
+ """Wait until *task_id* reaches a terminal state.
49
+
50
+ Stdout stays empty; the terminal state is carried by the exit code:
51
+ 0 = SUCCEEDED, 1 = FAILED, 2 = CANCELLED, 124 = timeout.
52
+ """
53
+ start = _now()
54
+ with get_client(ctx) as client:
55
+ while True:
56
+ status = client.get_task_status(task_id)
57
+ raw_state = getattr(status.state, "value", status.state)
58
+ state = str(raw_state).lower()
59
+ if state == "succeeded":
60
+ sys.exit(0)
61
+ if state == "failed":
62
+ sys.exit(1)
63
+ if state == "cancelled":
64
+ sys.exit(2)
65
+ if timeout is not None and (_now() - start) >= timeout:
66
+ sys.exit(124)
67
+ time.sleep(poll_interval)
@@ -0,0 +1,18 @@
1
+ import os
2
+ import click
3
+ from .client import AnalogCloudClient
4
+
5
+
6
+ def get_client(ctx: click.Context) -> AnalogCloudClient:
7
+ """Create and return an Analog Cloud client with context configuration."""
8
+ config = ctx.obj["client_config"]
9
+
10
+ # Read API key from environment
11
+ api_key = os.environ.get("ANALOGCLOUD_API_KEY")
12
+ if not api_key:
13
+ raise click.ClickException(
14
+ "ANALOGCLOUD_API_KEY environment variable is not set. "
15
+ "Please set it to your Analog Cloud API key."
16
+ )
17
+
18
+ return AnalogCloudClient(**config, api_key=api_key)
@@ -0,0 +1,110 @@
1
+ """Entity-blob -> pybrid ``Computer`` construction and ASCII-tree rendering.
2
+
3
+ The helpers in this module let the CLI render a device's entity tree without
4
+ a live hardware connection. The entity blob is the raw ``pb.Module`` bytes
5
+ returned by the server's ``/api/v1/devices/{name}/entity`` endpoint.
6
+
7
+ Pybrid is imported lazily inside each function so callers that do not need
8
+ tree rendering (e.g. ``analogcli info --short``) don't pay the pybrid import
9
+ cost.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import TYPE_CHECKING
15
+
16
+ if TYPE_CHECKING:
17
+ from pybrid.redac.computer import REDAC as Computer
18
+
19
+
20
+ _SUPPORTED_DEVICE_TYPES = frozenset({"LUCIDAC", "REDAC"})
21
+
22
+
23
+ def build_computer_from_entity(entity_bytes: bytes, device_type: str) -> "Computer":
24
+ """Deserialize an entity blob into a populated pybrid ``Computer``.
25
+
26
+ The server stores the entity as a ``pb.File`` envelope wrapping the
27
+ raw ``pb.Module`` (see ``devices._fetch_entity``); this helper parses
28
+ the envelope, takes ``.module``, instantiates a ``LUCIDACController``
29
+ or ``REDACController`` based on *device_type*, walks the module's
30
+ entity-specification items through a ``LUCIDACDeserializer``, and
31
+ attaches the resulting carriers to the controller's ``.computer``.
32
+ No network or hardware access is performed.
33
+
34
+ Args:
35
+ entity_bytes: Raw serialized ``pb.File`` bytes (as returned by
36
+ the server's device-entity endpoint).
37
+ device_type: ``"LUCIDAC"`` or ``"REDAC"`` - drives the
38
+ controller-class selection.
39
+
40
+ Returns:
41
+ A pybrid ``Computer`` (``LUCIDAC`` or ``REDAC`` subclass) with
42
+ carriers populated from the entity blob.
43
+
44
+ Raises:
45
+ ValueError: If *device_type* is not a recognised physical-device
46
+ type (e.g. ``"SIMULATOR"``) or if *entity_bytes* cannot be
47
+ parsed as a ``pb.File``.
48
+ """
49
+ if device_type not in _SUPPORTED_DEVICE_TYPES:
50
+ raise ValueError(
51
+ f"Unsupported device_type {device_type!r}; "
52
+ f"expected one of {sorted(_SUPPORTED_DEVICE_TYPES)}"
53
+ )
54
+
55
+ import pybrid.base.proto.main_pb2 as pb
56
+ from pybrid.lucidac.controller import Controller as LUCIDACController
57
+ from pybrid.lucidac.protocol.serializer import LUCIDACDeserializer
58
+ from pybrid.redac.controller import Controller as REDACController
59
+ from pybrid.redac.device import Device
60
+ from pybrid.redac.entities import Path
61
+
62
+ file_msg = pb.File()
63
+ try:
64
+ file_msg.ParseFromString(entity_bytes)
65
+ except Exception as exc:
66
+ raise ValueError(f"entity_bytes is not a valid pb.File: {exc}") from exc
67
+ module = file_msg.module
68
+
69
+ controller_cls = LUCIDACController if device_type == "LUCIDAC" else REDACController
70
+ controller = controller_cls()
71
+
72
+ deserializer = LUCIDACDeserializer()
73
+ for item in module.items:
74
+ if not item.HasField("entity_specification"):
75
+ continue
76
+ entity = item.entity_specification.entity
77
+ try:
78
+ root_path = Path.parse(entity.id.strip("/"))
79
+ except Exception:
80
+ root_path = Path.make()
81
+
82
+ result = deserializer.deserialize_specification(entity, root_path)
83
+ carriers = result.carriers if isinstance(result, Device) else [result]
84
+ for carrier in carriers:
85
+ controller.computer.add_carrier(carrier)
86
+
87
+ return controller.computer
88
+
89
+
90
+ def render_entity_tree(entity_bytes: bytes, device_type: str) -> str:
91
+ """Return the pybrid ``TreeDisplay`` rendering of an entity blob.
92
+
93
+ Equivalent to building a ``Computer`` via :func:`build_computer_from_entity`
94
+ and running ``TreeDisplay.render(computer)``. The return value is the
95
+ same ASCII tree that ``pybrid display`` prints against a live device.
96
+
97
+ Args:
98
+ entity_bytes: Raw serialized ``pb.Module`` bytes.
99
+ device_type: ``"LUCIDAC"`` or ``"REDAC"``.
100
+
101
+ Returns:
102
+ A non-empty ASCII tree string.
103
+
104
+ Raises:
105
+ ValueError: Propagated from :func:`build_computer_from_entity`.
106
+ """
107
+ from pybrid.redac.display import TreeDisplay
108
+
109
+ computer = build_computer_from_entity(entity_bytes, device_type)
110
+ return TreeDisplay.render(computer)
@@ -0,0 +1,52 @@
1
+ """CLI entry point — registers all sub-commands."""
2
+
3
+ import click
4
+
5
+ from .commands import compile, info, run, status
6
+ from .commands.task import task
7
+
8
+
9
+ @click.group()
10
+ @click.option(
11
+ "-u",
12
+ "--base-url",
13
+ default="https://redac.anabrid.com",
14
+ help="Hostname of the Analog Cloud API server",
15
+ show_default=True,
16
+ )
17
+ @click.option(
18
+ "--timeout",
19
+ default=30.0,
20
+ type=float,
21
+ help="Request timeout in seconds",
22
+ show_default=True,
23
+ )
24
+ @click.pass_context
25
+ def cli(ctx: click.Context, base_url: str, timeout: float):
26
+ """Analog Cloud CLI - Command-line interface for the Analog Cloud system.
27
+
28
+ This CLI provides access to Analog Cloud's analog computing devices through
29
+ a REST API interface. You can submit tasks, monitor their progress,
30
+ and retrieve results.
31
+
32
+ Common workflow:
33
+ 1. Check system status: analogcli status
34
+ 2. Compile an ODE: analogcli compile my_ode.txt device.entity
35
+ 3. Run on a device: analogcli run compiled.apb --device lucidac-01
36
+ """
37
+ ctx.ensure_object(dict)
38
+ ctx.obj["client_config"] = {
39
+ "base_url": base_url,
40
+ "timeout": timeout,
41
+ }
42
+
43
+
44
+ cli.add_command(status.status)
45
+ cli.add_command(info.info)
46
+ cli.add_command(run.run)
47
+ cli.add_command(compile.compile)
48
+ cli.add_command(task)
49
+
50
+
51
+ if __name__ == "__main__":
52
+ cli()