holocubic-cli-python 0.1.0__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.
- holocubic_cli_python/__init__.py +3 -0
- holocubic_cli_python/__main__.py +3 -0
- holocubic_cli_python/app.py +85 -0
- holocubic_cli_python/cli.py +582 -0
- holocubic_cli_python/client.py +375 -0
- holocubic_cli_python/config.py +133 -0
- holocubic_cli_python/errors.py +27 -0
- holocubic_cli_python/models.py +179 -0
- holocubic_cli_python/remote_path.py +62 -0
- holocubic_cli_python/transfer.py +661 -0
- holocubic_cli_python/transport.py +99 -0
- holocubic_cli_python/url.py +52 -0
- holocubic_cli_python-0.1.0.dist-info/METADATA +67 -0
- holocubic_cli_python-0.1.0.dist-info/RECORD +17 -0
- holocubic_cli_python-0.1.0.dist-info/WHEEL +4 -0
- holocubic_cli_python-0.1.0.dist-info/entry_points.txt +2 -0
- holocubic_cli_python-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""Validated HoloCubic DevTools API v1 client."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .errors import CubicError, is_not_found
|
|
6
|
+
from .models import (
|
|
7
|
+
LEGACY_V1_CAPABILITIES,
|
|
8
|
+
AppsResult,
|
|
9
|
+
DeviceInfo,
|
|
10
|
+
DevRunResult,
|
|
11
|
+
ListResult,
|
|
12
|
+
ReadChunk,
|
|
13
|
+
RemoteEntry,
|
|
14
|
+
RemoteStat,
|
|
15
|
+
UploadResult,
|
|
16
|
+
)
|
|
17
|
+
from .remote_path import normalize_remote_path
|
|
18
|
+
from .transport import HttpTransport
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
DEFAULT_MAX_CODE_BYTES = 192 * 1024
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _object(value: Any, label: str) -> dict[str, Any]:
|
|
25
|
+
if not isinstance(value, dict):
|
|
26
|
+
raise CubicError(
|
|
27
|
+
f"Device returned an invalid {label} response.", code="INVALID_RESPONSE"
|
|
28
|
+
)
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _string(value: Any, label: str) -> str:
|
|
33
|
+
if not isinstance(value, str):
|
|
34
|
+
raise CubicError(
|
|
35
|
+
f"Device response is missing {label}.", code="INVALID_RESPONSE"
|
|
36
|
+
)
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _integer(value: Any, label: str, minimum: int = 0) -> int:
|
|
41
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
|
|
42
|
+
raise CubicError(
|
|
43
|
+
f"Device response has an invalid {label}.", code="INVALID_RESPONSE"
|
|
44
|
+
)
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _bool(value: Any, label: str) -> bool:
|
|
49
|
+
if not isinstance(value, bool):
|
|
50
|
+
raise CubicError(
|
|
51
|
+
f"Device response has an invalid {label}.", code="INVALID_RESPONSE"
|
|
52
|
+
)
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _lua_array(value: Any, label: str) -> list[Any]:
|
|
57
|
+
if isinstance(value, list):
|
|
58
|
+
return value
|
|
59
|
+
if isinstance(value, dict) and not value:
|
|
60
|
+
return []
|
|
61
|
+
raise CubicError(
|
|
62
|
+
f"Device {label} response is missing a valid array.", code="INVALID_RESPONSE"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _entry(value: Any) -> RemoteEntry:
|
|
67
|
+
item = _object(value, "directory entry")
|
|
68
|
+
return RemoteEntry(
|
|
69
|
+
name=_string(item.get("name"), "entry.name"),
|
|
70
|
+
path=normalize_remote_path(_string(item.get("path"), "entry.path")),
|
|
71
|
+
size=_integer(item.get("size"), "entry.size"),
|
|
72
|
+
is_dir=_bool(item.get("is_dir"), "entry.is_dir"),
|
|
73
|
+
ext=item.get("ext") if isinstance(item.get("ext"), str) else "",
|
|
74
|
+
mime=item.get("mime")
|
|
75
|
+
if isinstance(item.get("mime"), str)
|
|
76
|
+
else "application/octet-stream",
|
|
77
|
+
category=item.get("category")
|
|
78
|
+
if isinstance(item.get("category"), str)
|
|
79
|
+
else "other",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class CubicClient:
|
|
84
|
+
def __init__(self, base_url: str, timeout_ms: int = 60_000) -> None:
|
|
85
|
+
self.transport = HttpTransport(base_url, timeout_ms)
|
|
86
|
+
self._cached_info: DeviceInfo | None = None
|
|
87
|
+
|
|
88
|
+
def info(self, force: bool = False) -> DeviceInfo:
|
|
89
|
+
if self._cached_info is not None and not force:
|
|
90
|
+
return self._cached_info
|
|
91
|
+
raw = _object(self.transport.json("info"), "info")
|
|
92
|
+
if raw.get("ok") is not True:
|
|
93
|
+
raise CubicError(
|
|
94
|
+
"Device handshake did not return ok=true.", code="INVALID_RESPONSE"
|
|
95
|
+
)
|
|
96
|
+
api_version = (
|
|
97
|
+
1
|
|
98
|
+
if raw.get("api_version") is None
|
|
99
|
+
else _integer(raw.get("api_version"), "api_version", 1)
|
|
100
|
+
)
|
|
101
|
+
if api_version > 1:
|
|
102
|
+
raise CubicError(
|
|
103
|
+
f"Unsupported DevTools API version: {api_version}.",
|
|
104
|
+
code="UNSUPPORTED_API",
|
|
105
|
+
)
|
|
106
|
+
root_path = _string(raw.get("root_path"), "root_path")
|
|
107
|
+
if normalize_remote_path(root_path) != "/sd":
|
|
108
|
+
raise CubicError(
|
|
109
|
+
f"Unsupported device root path: {root_path}.", code="INVALID_RESPONSE"
|
|
110
|
+
)
|
|
111
|
+
explicit = raw.get("capabilities")
|
|
112
|
+
capabilities = (
|
|
113
|
+
tuple(item for item in explicit if isinstance(item, str))
|
|
114
|
+
if isinstance(explicit, list)
|
|
115
|
+
else LEGACY_V1_CAPABILITIES
|
|
116
|
+
)
|
|
117
|
+
info = DeviceInfo(
|
|
118
|
+
api_version=api_version,
|
|
119
|
+
version=raw.get("version") if isinstance(raw.get("version"), str) else None,
|
|
120
|
+
route_base=raw.get("route_base")
|
|
121
|
+
if isinstance(raw.get("route_base"), str)
|
|
122
|
+
else "/devtools",
|
|
123
|
+
root_path=root_path,
|
|
124
|
+
chunk_size=_integer(raw.get("chunk_size"), "chunk_size", 1),
|
|
125
|
+
max_file_size=_integer(raw.get("max_file_size"), "max_file_size", 1),
|
|
126
|
+
max_code_bytes=(
|
|
127
|
+
DEFAULT_MAX_CODE_BYTES
|
|
128
|
+
if raw.get("max_code_bytes") is None
|
|
129
|
+
else _integer(raw.get("max_code_bytes"), "max_code_bytes", 1)
|
|
130
|
+
),
|
|
131
|
+
run_app_id=_string(raw.get("run_app_id"), "run_app_id"),
|
|
132
|
+
run_app_main=normalize_remote_path(
|
|
133
|
+
_string(raw.get("run_app_main"), "run_app_main")
|
|
134
|
+
),
|
|
135
|
+
capabilities=capabilities,
|
|
136
|
+
raw=raw,
|
|
137
|
+
)
|
|
138
|
+
self._cached_info = info
|
|
139
|
+
return info
|
|
140
|
+
|
|
141
|
+
def require_capability(self, capability: str) -> None:
|
|
142
|
+
if capability not in self.info().capabilities:
|
|
143
|
+
raise CubicError(
|
|
144
|
+
f"Device does not support capability {capability}.",
|
|
145
|
+
code="UNSUPPORTED_CAPABILITY",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def list(self, remote_path: str = "/sd") -> ListResult:
|
|
149
|
+
self.require_capability("fs.list")
|
|
150
|
+
raw = _object(
|
|
151
|
+
self.transport.json(
|
|
152
|
+
"list", query={"path": normalize_remote_path(remote_path)}
|
|
153
|
+
),
|
|
154
|
+
"list",
|
|
155
|
+
)
|
|
156
|
+
return ListResult(
|
|
157
|
+
path=normalize_remote_path(_string(raw.get("path"), "list.path")),
|
|
158
|
+
parent=normalize_remote_path(_string(raw.get("parent"), "list.parent")),
|
|
159
|
+
dir_count=_integer(raw.get("dir_count"), "list.dir_count"),
|
|
160
|
+
file_count=_integer(raw.get("file_count"), "list.file_count"),
|
|
161
|
+
total_bytes=_integer(raw.get("total_bytes"), "list.total_bytes"),
|
|
162
|
+
items=tuple(
|
|
163
|
+
_entry(item) for item in _lua_array(raw.get("items"), "list.items")
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def stat(self, remote_path: str) -> RemoteStat:
|
|
168
|
+
self.require_capability("fs.stat")
|
|
169
|
+
raw = _object(
|
|
170
|
+
self.transport.json(
|
|
171
|
+
"stat", query={"path": normalize_remote_path(remote_path)}
|
|
172
|
+
),
|
|
173
|
+
"stat",
|
|
174
|
+
)
|
|
175
|
+
return RemoteStat(
|
|
176
|
+
path=normalize_remote_path(_string(raw.get("path"), "stat.path")),
|
|
177
|
+
name=_string(raw.get("name"), "stat.name"),
|
|
178
|
+
parent=normalize_remote_path(_string(raw.get("parent"), "stat.parent")),
|
|
179
|
+
size=_integer(raw.get("size"), "stat.size"),
|
|
180
|
+
is_dir=_bool(raw.get("is_dir"), "stat.is_dir"),
|
|
181
|
+
ext=raw.get("ext") if isinstance(raw.get("ext"), str) else "",
|
|
182
|
+
mime=raw.get("mime")
|
|
183
|
+
if isinstance(raw.get("mime"), str)
|
|
184
|
+
else "application/octet-stream",
|
|
185
|
+
category=raw.get("category")
|
|
186
|
+
if isinstance(raw.get("category"), str)
|
|
187
|
+
else "other",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def stat_or_none(self, remote_path: str) -> RemoteStat | None:
|
|
191
|
+
try:
|
|
192
|
+
return self.stat(remote_path)
|
|
193
|
+
except CubicError as error:
|
|
194
|
+
if is_not_found(error):
|
|
195
|
+
return None
|
|
196
|
+
raise
|
|
197
|
+
|
|
198
|
+
def read(self, remote_path: str, offset: int, size: int) -> ReadChunk:
|
|
199
|
+
self.require_capability("fs.read")
|
|
200
|
+
response = self.transport.request(
|
|
201
|
+
"read",
|
|
202
|
+
query={
|
|
203
|
+
"path": normalize_remote_path(remote_path),
|
|
204
|
+
"offset": offset,
|
|
205
|
+
"size": size,
|
|
206
|
+
},
|
|
207
|
+
headers={"Accept": "application/octet-stream"},
|
|
208
|
+
)
|
|
209
|
+
lower_headers = {key.lower(): value for key, value in response.headers.items()}
|
|
210
|
+
try:
|
|
211
|
+
file_size = int(lower_headers["x-file-size"])
|
|
212
|
+
next_offset = int(lower_headers["x-next-offset"])
|
|
213
|
+
except (KeyError, ValueError) as error:
|
|
214
|
+
raise CubicError(
|
|
215
|
+
"Device response has invalid read headers.", code="INVALID_RESPONSE"
|
|
216
|
+
) from error
|
|
217
|
+
if file_size < 0 or next_offset < 0:
|
|
218
|
+
raise CubicError(
|
|
219
|
+
"Device response has invalid read headers.", code="INVALID_RESPONSE"
|
|
220
|
+
)
|
|
221
|
+
return ReadChunk(
|
|
222
|
+
data=response.body,
|
|
223
|
+
size=file_size,
|
|
224
|
+
next_offset=next_offset,
|
|
225
|
+
eof=lower_headers.get("x-eof") == "1",
|
|
226
|
+
name=lower_headers.get("x-file-name", ""),
|
|
227
|
+
mime=lower_headers.get("content-type", "application/octet-stream"),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def mkdir(self, remote_path: str) -> None:
|
|
231
|
+
self.require_capability("fs.mkdir")
|
|
232
|
+
self.transport.json(
|
|
233
|
+
"mkdir", method="POST", query={"path": normalize_remote_path(remote_path)}
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def rename(self, source: str, target: str) -> None:
|
|
237
|
+
self.require_capability("fs.rename")
|
|
238
|
+
self.transport.json(
|
|
239
|
+
"rename",
|
|
240
|
+
method="POST",
|
|
241
|
+
query={
|
|
242
|
+
"path": normalize_remote_path(source),
|
|
243
|
+
"new_path": normalize_remote_path(target),
|
|
244
|
+
},
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def upload(
|
|
248
|
+
self, remote_path: str, data: bytes, offset: int, total: int
|
|
249
|
+
) -> UploadResult:
|
|
250
|
+
self.require_capability("fs.write")
|
|
251
|
+
raw = _object(
|
|
252
|
+
self.transport.json(
|
|
253
|
+
"upload",
|
|
254
|
+
method="PUT",
|
|
255
|
+
query={
|
|
256
|
+
"path": normalize_remote_path(remote_path),
|
|
257
|
+
"offset": offset,
|
|
258
|
+
"total": total,
|
|
259
|
+
},
|
|
260
|
+
body=data,
|
|
261
|
+
headers={"Content-Type": "application/octet-stream"},
|
|
262
|
+
),
|
|
263
|
+
"upload",
|
|
264
|
+
)
|
|
265
|
+
next_offset = _integer(raw.get("next_offset"), "upload.next_offset")
|
|
266
|
+
return UploadResult(
|
|
267
|
+
path=normalize_remote_path(_string(raw.get("path"), "upload.path")),
|
|
268
|
+
next_offset=next_offset,
|
|
269
|
+
total=_integer(raw.get("total"), "upload.total"),
|
|
270
|
+
done=_bool(raw.get("done"), "upload.done"),
|
|
271
|
+
size=next_offset
|
|
272
|
+
if raw.get("size") is None
|
|
273
|
+
else _integer(raw.get("size"), "upload.size"),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def remove(self, remote_path: str) -> None:
|
|
277
|
+
self.require_capability("fs.remove")
|
|
278
|
+
self.transport.json(
|
|
279
|
+
"remove",
|
|
280
|
+
method="DELETE",
|
|
281
|
+
query={"path": normalize_remote_path(remote_path)},
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def rmdir(self, remote_path: str, recursive: bool = False) -> None:
|
|
285
|
+
self.require_capability("fs.rmdir")
|
|
286
|
+
self.transport.json(
|
|
287
|
+
"rmdir",
|
|
288
|
+
method="DELETE",
|
|
289
|
+
query={
|
|
290
|
+
"path": normalize_remote_path(remote_path),
|
|
291
|
+
"recursive": 1 if recursive else 0,
|
|
292
|
+
},
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
def apps(self) -> AppsResult:
|
|
296
|
+
self.require_capability("apps.list")
|
|
297
|
+
raw = _object(self.transport.json("apps"), "apps")
|
|
298
|
+
apps = tuple(
|
|
299
|
+
item
|
|
300
|
+
for item in _lua_array(raw.get("apps"), "apps.apps")
|
|
301
|
+
if isinstance(item, dict)
|
|
302
|
+
)
|
|
303
|
+
current = (
|
|
304
|
+
raw.get("current_app_id")
|
|
305
|
+
if isinstance(raw.get("current_app_id"), str)
|
|
306
|
+
else None
|
|
307
|
+
)
|
|
308
|
+
return AppsResult(
|
|
309
|
+
apps=apps,
|
|
310
|
+
current_app_id=current,
|
|
311
|
+
run_app_id=_string(raw.get("run_app_id"), "apps.run_app_id"),
|
|
312
|
+
run_app_main=normalize_remote_path(
|
|
313
|
+
_string(raw.get("run_app_main"), "apps.run_app_main")
|
|
314
|
+
),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
def read_devrun(self) -> str:
|
|
318
|
+
self.require_capability("devrun.read")
|
|
319
|
+
response = self.transport.request("code/read", headers={"Accept": "text/plain"})
|
|
320
|
+
try:
|
|
321
|
+
return response.body.decode("utf-8")
|
|
322
|
+
except UnicodeDecodeError as error:
|
|
323
|
+
raise CubicError(
|
|
324
|
+
"Device returned invalid UTF-8 DevRun source.", code="INVALID_RESPONSE"
|
|
325
|
+
) from error
|
|
326
|
+
|
|
327
|
+
def save_devrun(self, source: str, run: bool = False) -> DevRunResult:
|
|
328
|
+
self.require_capability("devrun.run" if run else "devrun.save")
|
|
329
|
+
encoded = source.encode("utf-8")
|
|
330
|
+
info = self.info()
|
|
331
|
+
if len(encoded) > info.max_code_bytes:
|
|
332
|
+
raise CubicError(
|
|
333
|
+
f"DevRun source is {len(encoded)} bytes; device limit is {info.max_code_bytes} bytes.",
|
|
334
|
+
code="FILE_TOO_LARGE",
|
|
335
|
+
)
|
|
336
|
+
raw = _object(
|
|
337
|
+
self.transport.json(
|
|
338
|
+
"code/run" if run else "code/save",
|
|
339
|
+
method="POST",
|
|
340
|
+
body=encoded,
|
|
341
|
+
headers={"Content-Type": "text/plain; charset=utf-8"},
|
|
342
|
+
),
|
|
343
|
+
"DevRun",
|
|
344
|
+
)
|
|
345
|
+
return DevRunResult(
|
|
346
|
+
id=_string(raw.get("id"), "DevRun.id"),
|
|
347
|
+
entry=normalize_remote_path(_string(raw.get("entry"), "DevRun.entry")),
|
|
348
|
+
bytes=_integer(raw.get("bytes"), "DevRun.bytes"),
|
|
349
|
+
launched=_bool(raw.get("launched"), "DevRun.launched"),
|
|
350
|
+
rescan_requested=_bool(
|
|
351
|
+
raw.get("rescan_requested"), "DevRun.rescan_requested"
|
|
352
|
+
),
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def public_info(info: DeviceInfo, url: str, name: str | None = None) -> dict[str, Any]:
|
|
357
|
+
return {
|
|
358
|
+
"name": name,
|
|
359
|
+
"url": url,
|
|
360
|
+
"version": info.version,
|
|
361
|
+
"api_version": info.api_version,
|
|
362
|
+
"route_base": info.route_base,
|
|
363
|
+
"root_path": info.root_path,
|
|
364
|
+
"chunk_size": info.chunk_size,
|
|
365
|
+
"max_file_size": info.max_file_size,
|
|
366
|
+
"max_code_bytes": info.max_code_bytes,
|
|
367
|
+
"run_app_id": info.run_app_id,
|
|
368
|
+
"run_app_main": info.run_app_main,
|
|
369
|
+
"capabilities": list(info.capabilities),
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def fetch_info(host: str, timeout_ms: int = 60_000) -> dict[str, Any]:
|
|
374
|
+
client = CubicClient(host, timeout_ms)
|
|
375
|
+
return public_info(client.info(force=True), client.transport.base_url)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Named device configuration with atomic JSON persistence."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import secrets
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
|
|
9
|
+
from .errors import CubicError, UsageError
|
|
10
|
+
from .url import normalize_device_url
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def default_config_path(
|
|
14
|
+
env: Mapping[str, str] | None = None, platform: str | None = None
|
|
15
|
+
) -> Path:
|
|
16
|
+
values = os.environ if env is None else env
|
|
17
|
+
if values.get("CUBIC_CONFIG"):
|
|
18
|
+
return Path(values["CUBIC_CONFIG"]).expanduser().resolve()
|
|
19
|
+
target_platform = platform or os.name
|
|
20
|
+
if target_platform in {"nt", "win32"}:
|
|
21
|
+
base = Path(values.get("APPDATA", Path.home() / "AppData" / "Roaming"))
|
|
22
|
+
else:
|
|
23
|
+
base = Path(values.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
24
|
+
return base / "cubic" / "config.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def validate_device_name(name: str) -> str:
|
|
28
|
+
trimmed = name.strip()
|
|
29
|
+
if (
|
|
30
|
+
not trimmed
|
|
31
|
+
or trimmed in {".", ".."}
|
|
32
|
+
or "/" in trimmed
|
|
33
|
+
or "\\" in trimmed
|
|
34
|
+
or any(ord(character) < 32 for character in trimmed)
|
|
35
|
+
):
|
|
36
|
+
raise UsageError(f"Invalid device name: {name}")
|
|
37
|
+
return trimmed
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ConfigStore:
|
|
41
|
+
def __init__(self, file_path: str | Path | None = None) -> None:
|
|
42
|
+
self.file_path = (
|
|
43
|
+
Path(file_path).resolve()
|
|
44
|
+
if file_path is not None
|
|
45
|
+
else default_config_path()
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def read(self) -> dict[str, Any]:
|
|
49
|
+
try:
|
|
50
|
+
parsed = json.loads(self.file_path.read_text(encoding="utf-8"))
|
|
51
|
+
except FileNotFoundError:
|
|
52
|
+
return {"version": 1, "current": None, "devices": {}}
|
|
53
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
54
|
+
raise CubicError(
|
|
55
|
+
f"Unable to read config {self.file_path}.", code="CONFIG_ERROR"
|
|
56
|
+
) from error
|
|
57
|
+
if (
|
|
58
|
+
not isinstance(parsed, dict)
|
|
59
|
+
or parsed.get("version") != 1
|
|
60
|
+
or not isinstance(parsed.get("devices"), dict)
|
|
61
|
+
):
|
|
62
|
+
raise CubicError(
|
|
63
|
+
f"Config {self.file_path} has an unsupported format.",
|
|
64
|
+
code="CONFIG_ERROR",
|
|
65
|
+
)
|
|
66
|
+
devices: dict[str, dict[str, str]] = {}
|
|
67
|
+
for name, raw_profile in parsed["devices"].items():
|
|
68
|
+
if (
|
|
69
|
+
not isinstance(name, str)
|
|
70
|
+
or not isinstance(raw_profile, dict)
|
|
71
|
+
or not isinstance(raw_profile.get("url"), str)
|
|
72
|
+
):
|
|
73
|
+
continue
|
|
74
|
+
profile = {"url": normalize_device_url(raw_profile["url"])}
|
|
75
|
+
if isinstance(raw_profile.get("version"), str):
|
|
76
|
+
profile["version"] = raw_profile["version"]
|
|
77
|
+
devices[name] = profile
|
|
78
|
+
current = (
|
|
79
|
+
parsed.get("current") if isinstance(parsed.get("current"), str) else None
|
|
80
|
+
)
|
|
81
|
+
if current not in devices:
|
|
82
|
+
current = None
|
|
83
|
+
return {"version": 1, "current": current, "devices": devices}
|
|
84
|
+
|
|
85
|
+
def write(self, config: dict[str, Any]) -> None:
|
|
86
|
+
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
temporary = self.file_path.with_name(
|
|
88
|
+
f"{self.file_path.name}.{os.getpid()}.{secrets.token_hex(4)}.tmp"
|
|
89
|
+
)
|
|
90
|
+
try:
|
|
91
|
+
temporary.write_text(
|
|
92
|
+
json.dumps(config, ensure_ascii=False, indent=2) + "\n",
|
|
93
|
+
encoding="utf-8",
|
|
94
|
+
)
|
|
95
|
+
try:
|
|
96
|
+
temporary.chmod(0o600)
|
|
97
|
+
except OSError:
|
|
98
|
+
pass
|
|
99
|
+
os.replace(temporary, self.file_path)
|
|
100
|
+
except OSError as error:
|
|
101
|
+
temporary.unlink(missing_ok=True)
|
|
102
|
+
raise CubicError(
|
|
103
|
+
f"Unable to write config {self.file_path}.", code="CONFIG_ERROR"
|
|
104
|
+
) from error
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def resolve_device(
|
|
108
|
+
store: ConfigStore,
|
|
109
|
+
option_host: str | None = None,
|
|
110
|
+
env: Mapping[str, str] | None = None,
|
|
111
|
+
) -> dict[str, str | None]:
|
|
112
|
+
values = os.environ if env is None else env
|
|
113
|
+
if option_host:
|
|
114
|
+
return {
|
|
115
|
+
"url": normalize_device_url(option_host),
|
|
116
|
+
"name": None,
|
|
117
|
+
"source": "option",
|
|
118
|
+
}
|
|
119
|
+
if values.get("CUBIC_HOST"):
|
|
120
|
+
return {
|
|
121
|
+
"url": normalize_device_url(values["CUBIC_HOST"]),
|
|
122
|
+
"name": None,
|
|
123
|
+
"source": "environment",
|
|
124
|
+
}
|
|
125
|
+
config = store.read()
|
|
126
|
+
current = config["current"]
|
|
127
|
+
profile = config["devices"].get(current) if current else None
|
|
128
|
+
if not current or not profile:
|
|
129
|
+
raise CubicError(
|
|
130
|
+
"No device selected. Run `cubic-py device add <name> <host>` or pass --host.",
|
|
131
|
+
code="NO_DEVICE",
|
|
132
|
+
)
|
|
133
|
+
return {"url": profile["url"], "name": current, "source": "config"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Stable error types shared by the Python client, transfers, and CLI."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CubicError(Exception):
|
|
5
|
+
def __init__(
|
|
6
|
+
self, message: str, *, code: str = "CUBIC_ERROR", exit_code: int = 1
|
|
7
|
+
) -> None:
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.code = code
|
|
10
|
+
self.exit_code = exit_code
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UsageError(CubicError, ValueError):
|
|
14
|
+
def __init__(self, message: str) -> None:
|
|
15
|
+
super().__init__(message, code="USAGE_ERROR", exit_code=2)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HttpError(CubicError):
|
|
19
|
+
def __init__(self, message: str, status: int, method: str, path: str) -> None:
|
|
20
|
+
super().__init__(message, code="NOT_FOUND" if status == 404 else "HTTP_ERROR")
|
|
21
|
+
self.status = status
|
|
22
|
+
self.method = method
|
|
23
|
+
self.path = path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_not_found(error: BaseException) -> bool:
|
|
27
|
+
return isinstance(error, HttpError) and error.status == 404
|