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.
File without changes
@@ -0,0 +1,385 @@
1
+ """HTTP client for the Analog Cloud v1 API."""
2
+
3
+ import base64
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any
7
+ from uuid import UUID
8
+
9
+ import httpx
10
+
11
+ from anabrid.cloud.models.api.v1 import (
12
+ CompileTaskDetailResponse,
13
+ DeviceResponse,
14
+ RunTaskDetailResponse,
15
+ SystemStatusResponse,
16
+ TaskCreatedResponse,
17
+ TaskLogResponse,
18
+ TaskSearchResponse,
19
+ TaskStatusResponse,
20
+ TaskSummaryResponse,
21
+ UserInfoResponse,
22
+ )
23
+ from anabrid.cloud.models.enums import DeviceTypeEnum, TaskStateEnum
24
+
25
+
26
+ class AnalogCloudAPIError(Exception):
27
+ """Raised when the Analog Cloud API returns an error or the request fails."""
28
+
29
+
30
+ class StateConflictError(AnalogCloudAPIError):
31
+ """HTTP 409 — the requested operation is incompatible with the task's
32
+ current state.
33
+
34
+ Raised when the server refuses an action because the target task is
35
+ not in a state from which the action is allowed. The CLI maps this
36
+ to exit code 4 so shell scripts can distinguish "you asked for
37
+ something the task can no longer do" from a generic transport
38
+ failure (exit 3).
39
+
40
+ Current 409 sites on the server:
41
+
42
+ - ``POST /tasks/{id}/cancel`` — the task is not ``QUEUED``. Cancel
43
+ only withdraws queued work; once the worker has picked the task up
44
+ (``INPROGRESS``) or it has reached a terminal state
45
+ (``SUCCEEDED``/``FAILED``/``CANCELLED``), the cancel request is
46
+ refused.
47
+ - ``DELETE /tasks/{id}`` — the task is ``INPROGRESS`` and its latest
48
+ ``INPROGRESS`` transition is less than 2 minutes old. The grace
49
+ window protects an in-flight worker run from having its row
50
+ yanked out from under it.
51
+ """
52
+
53
+
54
+ TERMINAL_TASK_STATES: frozenset = frozenset(
55
+ {TaskStateEnum.SUCCEEDED, TaskStateEnum.FAILED, TaskStateEnum.CANCELLED}
56
+ )
57
+
58
+
59
+ class AnalogCloudClient:
60
+ """Synchronous HTTP client for the Analog Cloud v1 API."""
61
+
62
+ def __init__(
63
+ self,
64
+ base_url: str = "https://redac.anabrid.com",
65
+ timeout: float = 30.0,
66
+ api_key: str = None,
67
+ ):
68
+ """Initialise client.
69
+
70
+ Parameters
71
+ ----------
72
+ base_url:
73
+ Base URL of the Analog Cloud server.
74
+ timeout:
75
+ Per-request timeout in seconds.
76
+ api_key:
77
+ Bearer token. Required; raises ``ValueError`` when absent.
78
+ """
79
+ if not api_key:
80
+ raise ValueError(
81
+ "API key is required. "
82
+ "Please set the ANALOGCLOUD_API_KEY environment variable."
83
+ )
84
+
85
+ self.base_url = base_url
86
+ self.timeout = timeout
87
+ self.api_key = api_key
88
+ self.client = httpx.Client(
89
+ timeout=timeout,
90
+ headers={"Authorization": f"Bearer {api_key}"},
91
+ )
92
+
93
+ def __enter__(self) -> "AnalogCloudClient":
94
+ """Support use as a context manager."""
95
+ return self
96
+
97
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
98
+ """Close the underlying HTTP client on context-manager exit."""
99
+ self.client.close()
100
+
101
+ def close(self) -> None:
102
+ """Close the underlying HTTP client."""
103
+ self.client.close()
104
+
105
+ # ------------------------------------------------------------------
106
+ # Internal helpers
107
+ # ------------------------------------------------------------------
108
+
109
+ def _make_request(self, method: str, endpoint: str, **kwargs) -> httpx.Response:
110
+ """Execute an HTTP request and raise ``AnalogCloudAPIError`` on failure."""
111
+ url = f"{self.base_url}{endpoint}"
112
+ try:
113
+ response = self.client.request(method, url, **kwargs)
114
+ response.raise_for_status()
115
+ return response
116
+ except httpx.HTTPStatusError as exc:
117
+ try:
118
+ detail = exc.response.json().get("detail", str(exc))
119
+ except Exception:
120
+ detail = str(exc)
121
+ if exc.response.status_code == 409:
122
+ raise StateConflictError(f"API Error: {detail}") from exc
123
+ raise AnalogCloudAPIError(f"API Error: {detail}") from exc
124
+ except httpx.RequestError as exc:
125
+ raise AnalogCloudAPIError(f"Connection Error: {exc}") from exc
126
+
127
+ # ------------------------------------------------------------------
128
+ # System
129
+ # ------------------------------------------------------------------
130
+
131
+ def get_system_status(self) -> SystemStatusResponse:
132
+ """Return current system status (version, domain, uptime)."""
133
+ response = self._make_request("GET", "/api/v1/system/status")
134
+ return SystemStatusResponse(**response.json())
135
+
136
+ # ------------------------------------------------------------------
137
+ # Users
138
+ # ------------------------------------------------------------------
139
+
140
+ def get_user_info(self) -> UserInfoResponse:
141
+ """Return profile and rate-limit quota for the authenticated user."""
142
+ response = self._make_request("GET", "/api/v1/users/me")
143
+ return UserInfoResponse(**response.json())
144
+
145
+ # ------------------------------------------------------------------
146
+ # Devices
147
+ # ------------------------------------------------------------------
148
+
149
+ def get_devices(self) -> list[DeviceResponse]:
150
+ """Return all registered devices with their current queue lengths."""
151
+ response = self._make_request("GET", "/api/v1/devices")
152
+ return [DeviceResponse(**d) for d in response.json()]
153
+
154
+ def get_device_entity(self, device_name: str) -> bytes:
155
+ """Return the raw protobuf entity blob for *device_name*.
156
+
157
+ Raises ``AnalogCloudAPIError`` when the device is unknown or has no entity.
158
+ """
159
+ response = self._make_request("GET", f"/api/v1/devices/{device_name}/entity")
160
+ return response.content
161
+
162
+ # ------------------------------------------------------------------
163
+ # Tasks — submission
164
+ # ------------------------------------------------------------------
165
+
166
+ def submit_compile_task(
167
+ self,
168
+ ode_source: str,
169
+ backend: DeviceTypeEnum,
170
+ module: bytes,
171
+ *,
172
+ k0: int | None = None,
173
+ ) -> UUID:
174
+ """Submit a compile task and return its UUID.
175
+
176
+ Parameters
177
+ ----------
178
+ ode_source:
179
+ Raw ODE source text (analang format).
180
+ backend:
181
+ Target device type (must not be SIMULATOR).
182
+ module:
183
+ Protobuf entity blob for the target device. Base64-encoded on
184
+ the wire.
185
+ k0:
186
+ Optional time-scale factor override.
187
+ """
188
+ payload = {
189
+ "ode_source": ode_source,
190
+ "backend": backend.value,
191
+ "module": base64.b64encode(module).decode("ascii"),
192
+ "k0": k0,
193
+ }
194
+ response = self._make_request("POST", "/api/v1/tasks/compile", json=payload)
195
+ return TaskCreatedResponse(**response.json()).id
196
+
197
+ def submit_run_task(
198
+ self,
199
+ device_name: str,
200
+ module: bytes,
201
+ *,
202
+ sample_rate: int = 10000,
203
+ op_time: float = 0.1,
204
+ ) -> UUID:
205
+ """Submit a run task and return its UUID.
206
+
207
+ Parameters
208
+ ----------
209
+ device_name:
210
+ Name of the registered device to run on.
211
+ module:
212
+ Compiled protobuf configuration blob. Base64-encoded on the wire.
213
+ sample_rate:
214
+ ADC samples drawn per second.
215
+ op_time:
216
+ Wall-clock integration time in seconds.
217
+ """
218
+ payload = {
219
+ "device_name": device_name,
220
+ "module": base64.b64encode(module).decode("ascii"),
221
+ "sample_rate": sample_rate,
222
+ "op_time": op_time,
223
+ }
224
+ response = self._make_request("POST", "/api/v1/tasks/run", json=payload)
225
+ return TaskCreatedResponse(**response.json()).id
226
+
227
+ # ------------------------------------------------------------------
228
+ # Tasks — polling and status
229
+ # ------------------------------------------------------------------
230
+
231
+ def list_tasks(
232
+ self,
233
+ state: str | None = None,
234
+ type: str | None = None,
235
+ limit: int | None = None,
236
+ offset: int | None = None,
237
+ ) -> list[TaskSummaryResponse]:
238
+ """List tasks visible to the current API key.
239
+
240
+ Query parameters with ``None`` values are omitted from the request.
241
+ """
242
+ params: dict[str, Any] = {}
243
+ if state is not None:
244
+ params["state"] = state
245
+ if type is not None:
246
+ params["type"] = type
247
+ if limit is not None:
248
+ params["limit"] = limit
249
+ if offset is not None:
250
+ params["offset"] = offset
251
+ response = self._make_request("GET", "/api/v1/tasks", params=params)
252
+ return [TaskSummaryResponse(**item) for item in response.json()]
253
+
254
+ def search_tasks(
255
+ self,
256
+ *,
257
+ user_id: list[str] | None = None,
258
+ device_name: list[str] | None = None,
259
+ device_class: list[str] | None = None,
260
+ type: list[str] | None = None,
261
+ state: list[str] | None = None,
262
+ from_: str | None = None,
263
+ to: str | None = None,
264
+ limit: int = 50,
265
+ offset: int = 0,
266
+ ) -> TaskSearchResponse:
267
+ """Search tasks via ``POST /api/v1/tasks/search`` and return one page.
268
+
269
+ List-valued filters left ``None`` are omitted from the body; ``from_``
270
+ is sent under the reserved-word alias ``from``. The caller's privilege
271
+ (system vs. non-system) is resolved server-side; this method forwards a
272
+ single page and the pagination loop lives in the command layer.
273
+ """
274
+ body: dict = {"limit": limit, "offset": offset}
275
+ if user_id is not None:
276
+ body["user_id"] = user_id
277
+ if device_name is not None:
278
+ body["device_name"] = device_name
279
+ if device_class is not None:
280
+ body["device_class"] = device_class
281
+ if type is not None:
282
+ body["type"] = type
283
+ if state is not None:
284
+ body["state"] = state
285
+ if from_ is not None:
286
+ body["from"] = from_
287
+ if to is not None:
288
+ body["to"] = to
289
+ response = self._make_request("POST", "/api/v1/tasks/search", json=body)
290
+ return TaskSearchResponse(**response.json())
291
+
292
+ def get_task_status(self, task_id: str | UUID) -> TaskStatusResponse:
293
+ """Return the current state and full state history for *task_id*."""
294
+ response = self._make_request("GET", f"/api/v1/tasks/{task_id}/status")
295
+ return TaskStatusResponse(**response.json())
296
+
297
+ def get_task_log(self, task_id: str | UUID) -> TaskLogResponse:
298
+ """Return the log lines for *task_id*."""
299
+ response = self._make_request("GET", f"/api/v1/tasks/{task_id}/log")
300
+ return TaskLogResponse(**response.json())
301
+
302
+ def get_task_detail(
303
+ self, task_id: str | UUID
304
+ ) -> CompileTaskDetailResponse | RunTaskDetailResponse:
305
+ """Return the full task detail, discriminated by ``type`` field.
306
+
307
+ Raises ``AnalogCloudAPIError`` when the ``type`` field is not recognised.
308
+ """
309
+ response = self._make_request("GET", f"/api/v1/tasks/{task_id}")
310
+ data = response.json()
311
+ task_type = data.get("type")
312
+ if task_type == "compile":
313
+ return CompileTaskDetailResponse(**data)
314
+ if task_type == "run":
315
+ return RunTaskDetailResponse(**data)
316
+ raise AnalogCloudAPIError(f"unexpected task type: {task_type!r}")
317
+
318
+ def get_task_result(self, task_id: str | UUID) -> bytes | Any:
319
+ """Return the task result.
320
+
321
+ Compile tasks return raw ``bytes`` (protobuf blob).
322
+ Run tasks return the JSON-deserialised Python value.
323
+
324
+ Raises ``AnalogCloudAPIError`` for unrecognised content types or malformed
325
+ JSON bodies.
326
+ """
327
+ response = self._make_request("GET", f"/api/v1/tasks/{task_id}/result")
328
+ content_type = response.headers.get("content-type", "")
329
+ if content_type.startswith("application/octet-stream"):
330
+ return response.content
331
+ if content_type.startswith("application/json"):
332
+ try:
333
+ return response.json()
334
+ except ValueError as exc:
335
+ raise AnalogCloudAPIError(f"invalid JSON in result body: {exc}") from exc
336
+ raise AnalogCloudAPIError(f"unexpected result content-type: {content_type!r}")
337
+
338
+ def cancel_task(self, task_id: str | UUID) -> TaskStatusResponse:
339
+ """Request cancellation of a queued task.
340
+
341
+ Returns the updated ``TaskStatusResponse``.
342
+ """
343
+ response = self._make_request("POST", f"/api/v1/tasks/{task_id}/cancel")
344
+ return TaskStatusResponse(**response.json())
345
+
346
+ def delete_task(self, task_id: str | UUID) -> None:
347
+ """Permanently delete a task and its blobs.
348
+
349
+ Expects HTTP 204; raises ``AnalogCloudAPIError`` on failure.
350
+ """
351
+ self._make_request("DELETE", f"/api/v1/tasks/{task_id}")
352
+
353
+ def wait_for_task_completion(
354
+ self, task_id: str | UUID, poll_interval: float = 1.0
355
+ ) -> TaskStatusResponse:
356
+ """Poll *task_id* until it reaches a terminal state and return it.
357
+
358
+ Terminal states: SUCCEEDED, FAILED, CANCELLED.
359
+ """
360
+ import time
361
+
362
+ while True:
363
+ status = self.get_task_status(task_id)
364
+ if status.state in TERMINAL_TASK_STATES:
365
+ return status
366
+ time.sleep(poll_interval)
367
+
368
+
369
+ def load_json_file(file_path: str) -> dict:
370
+ """Load and parse a JSON file.
371
+
372
+ Raises ``FileNotFoundError`` when the path does not exist,
373
+ ``ValueError`` when the content is not valid JSON.
374
+ """
375
+ path = Path(file_path)
376
+ if not path.exists():
377
+ raise FileNotFoundError(f"File not found: {file_path}")
378
+
379
+ try:
380
+ with open(path, "r") as f:
381
+ return json.load(f)
382
+ except json.JSONDecodeError as exc:
383
+ raise ValueError(f"Invalid JSON in file {file_path}: {exc}") from exc
384
+ except Exception as exc:
385
+ raise RuntimeError(f"Error reading file {file_path}: {exc}") from exc
File without changes
@@ -0,0 +1,164 @@
1
+ """CLI command for compiling ODE sources to device configurations."""
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+ from ..context import get_client
7
+ from ..utils import (
8
+ handle_api_error,
9
+ load_binary_file,
10
+ wait_for_task_with_progress,
11
+ display_task_logs,
12
+ console,
13
+ )
14
+ from anabrid.cloud.models.enums import DeviceTypeEnum, TaskStateEnum
15
+
16
+
17
+ @click.command()
18
+ @click.argument("ode_file", type=click.Path(exists=True))
19
+ @click.argument("module_file", type=click.Path(exists=True), required=False)
20
+ @click.option(
21
+ "--device",
22
+ "device_name",
23
+ default=None,
24
+ help=(
25
+ "Device name to compile for. When given, the CLI fetches the device's "
26
+ "entity blob and derives the backend automatically; MODULE_FILE and "
27
+ "--backend must be omitted."
28
+ ),
29
+ )
30
+ @click.option(
31
+ "--backend",
32
+ type=click.Choice(
33
+ [t.value for t in DeviceTypeEnum if t != DeviceTypeEnum.SIMULATOR]
34
+ ),
35
+ default=None,
36
+ help=(
37
+ f"Target device type for compilation "
38
+ f"(default: {DeviceTypeEnum.LUCIDAC.value} when MODULE_FILE is given)."
39
+ ),
40
+ )
41
+ @click.option(
42
+ "--k0",
43
+ default=None,
44
+ type=int,
45
+ help="Time-scale factor override",
46
+ )
47
+ @click.option(
48
+ "--output",
49
+ "-o",
50
+ default=None,
51
+ help="Save compiled module bytes to this file path",
52
+ )
53
+ @click.option(
54
+ "--follow/--no-follow",
55
+ default=True,
56
+ show_default=True,
57
+ help="Wait for the task to finish; with --no-follow only the UUID is printed.",
58
+ )
59
+ @click.pass_context
60
+ @handle_api_error
61
+ def compile(
62
+ ctx: click.Context,
63
+ ode_file: str,
64
+ module_file: str | None,
65
+ device_name: str | None,
66
+ backend: str | None,
67
+ k0: int | None,
68
+ output: str | None,
69
+ follow: bool,
70
+ ):
71
+ """Compile an ODE source file to a device configuration module.
72
+
73
+ ODE_FILE is a text file containing the ODE in analang format.
74
+
75
+ There are two ways to select the target:
76
+
77
+ \b
78
+ 1. Explicit: pass MODULE_FILE (obtained via
79
+ ``analogcli info <device> --save-entity``) and optionally --backend.
80
+ 2. By device: pass --device <name> to let the CLI fetch the entity blob
81
+ and pick the backend automatically.
82
+
83
+ The compiled module bytes are printed to stdout or saved with --output.
84
+
85
+ Examples:
86
+
87
+ \b
88
+ analogcli compile my_ode.txt device.entity --backend LUCIDAC
89
+ analogcli compile my_ode.txt --device lucidac-01
90
+ """
91
+ if device_name and module_file:
92
+ raise click.UsageError(
93
+ "MODULE_FILE and --device are mutually exclusive; pass exactly one."
94
+ )
95
+ if device_name and backend:
96
+ raise click.UsageError(
97
+ "--backend is derived from --device; do not pass both."
98
+ )
99
+ if not device_name and not module_file:
100
+ raise click.UsageError(
101
+ "Either MODULE_FILE or --device must be provided."
102
+ )
103
+
104
+ ode_source = Path(ode_file).read_text()
105
+
106
+ with get_client(ctx) as client:
107
+ if device_name:
108
+ devices = client.get_devices()
109
+ device = next((d for d in devices if d.name == device_name), None)
110
+ if device is None:
111
+ console.print(f"[red]Device '{device_name}' not found[/red]")
112
+ raise click.Abort()
113
+ if device.type == DeviceTypeEnum.SIMULATOR:
114
+ console.print(
115
+ f"[red]Device '{device_name}' is a SIMULATOR; "
116
+ "compile requires a hardware backend.[/red]"
117
+ )
118
+ raise click.Abort()
119
+ backend_enum = device.type
120
+ module_bytes = client.get_device_entity(device_name)
121
+ else:
122
+ backend_enum = DeviceTypeEnum(backend or DeviceTypeEnum.LUCIDAC.value)
123
+ module_bytes = load_binary_file(module_file)
124
+
125
+ if follow:
126
+ console.print("[blue]Submitting compile task...[/blue]")
127
+ task_id = client.submit_compile_task(
128
+ ode_source=ode_source,
129
+ backend=backend_enum,
130
+ module=module_bytes,
131
+ k0=k0,
132
+ )
133
+
134
+ if not follow:
135
+ click.echo(task_id)
136
+ click.echo(f"Task submitted: {task_id}", err=True)
137
+ return
138
+
139
+ console.print(f"[green]Task submitted with ID: {task_id}[/green]")
140
+
141
+ final_status = wait_for_task_with_progress(client, task_id, "Compiling ODE")
142
+
143
+ log_response = client.get_task_log(task_id)
144
+ display_task_logs(log_response.lines)
145
+
146
+ if final_status.state == TaskStateEnum.SUCCEEDED:
147
+ console.print(
148
+ "\n[bold green]Compilation completed successfully![/bold green]"
149
+ )
150
+
151
+ result_bytes = client.get_task_result(task_id)
152
+
153
+ if output:
154
+ with open(output, "wb") as f:
155
+ f.write(result_bytes)
156
+ console.print(f"[green]Compiled module saved to {output}[/green]")
157
+ else:
158
+ console.print(
159
+ f"[green]Compiled module: {len(result_bytes)} bytes "
160
+ "(use --output to save)[/green]"
161
+ )
162
+ else:
163
+ error = final_status.error or "(no detail)"
164
+ console.print(f"\n[bold red]Compilation failed: {error}[/bold red]")
@@ -0,0 +1,97 @@
1
+ """CLI command for displaying device information."""
2
+
3
+ import click
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+ from ..context import get_client
8
+ from ..utils import handle_api_error
9
+
10
+ console = Console()
11
+
12
+ # Device type whose entity is ``null`` per spec: skip entity fetch and tree.
13
+ _SIMULATOR_TYPE = "SIMULATOR"
14
+
15
+
16
+ @click.command()
17
+ @click.argument("device_name")
18
+ @click.option(
19
+ "--short",
20
+ is_flag=True,
21
+ default=False,
22
+ help="Suppress the entity tree and print only the summary table.",
23
+ )
24
+ @click.option(
25
+ "--save-entity",
26
+ default=None,
27
+ metavar="PATH",
28
+ help="Save the raw entity blob to PATH.",
29
+ )
30
+ @click.pass_context
31
+ @handle_api_error
32
+ def info(ctx: click.Context, device_name: str, short: bool, save_entity: str | None):
33
+ """Show information about a registered device.
34
+
35
+ DEVICE_NAME is the name of the device as registered in the hub.
36
+
37
+ By default, the summary table is followed by the pybrid entity tree.
38
+ Pass ``--short`` to suppress the tree. SIMULATOR devices always show
39
+ only the table (their entity is null).
40
+
41
+ With ``--save-entity PATH``, the raw protobuf entity bytes are written
42
+ to PATH.
43
+ """
44
+ with get_client(ctx) as client:
45
+ devices = client.get_devices()
46
+ device = next((d for d in devices if d.name == device_name), None)
47
+
48
+ if device is None:
49
+ console.print(f"[red]Device '{device_name}' not found[/red]")
50
+ raise click.Abort()
51
+
52
+ info_table = Table(
53
+ title=f"Device Information: {device_name}", show_header=False
54
+ )
55
+ info_table.add_column("Property", style="cyan")
56
+ info_table.add_column("Value", style="white")
57
+
58
+ info_table.add_row("Name", device.name)
59
+ info_table.add_row("Type", device.type.value)
60
+
61
+ availability_text = (
62
+ "[green]Available[/green]" if device.available else "[red]Unavailable[/red]"
63
+ )
64
+ info_table.add_row("Status", availability_text)
65
+ info_table.add_row("Queue Length", str(device.queue_length))
66
+
67
+ console.print(info_table)
68
+
69
+ device_type = device.type.value
70
+ is_simulator = device_type == _SIMULATOR_TYPE
71
+
72
+ # SIMULATOR entity is null: skip both the entity fetch and the tree.
73
+ if is_simulator:
74
+ return
75
+
76
+ # Only fetch the entity blob when something actually needs it.
77
+ need_entity = bool(save_entity) or not short
78
+ if not need_entity:
79
+ return
80
+
81
+ try:
82
+ entity_bytes = client.get_device_entity(device_name)
83
+
84
+ if save_entity:
85
+ with open(save_entity, "wb") as f:
86
+ f.write(entity_bytes)
87
+ console.print(f"[green]Entity saved to {save_entity}[/green]")
88
+
89
+ if not short:
90
+ from ..entity_tree import render_entity_tree
91
+
92
+ tree = render_entity_tree(entity_bytes, device_type)
93
+ console.print()
94
+ console.print(tree)
95
+
96
+ except Exception as exc:
97
+ console.print(f"[yellow]Entity configuration not available: {exc}[/yellow]")