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
|
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]")
|