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.
@@ -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