holocubic-cli-python 0.1.0a1__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,3 @@
1
+ """Python implementation of the HoloCubic CLI."""
2
+
3
+ __version__ = "0.1.0a1"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,85 @@
1
+ """Local HoloCubic app validation."""
2
+
3
+ import os
4
+ import re
5
+ from dataclasses import dataclass
6
+ from pathlib import Path, PurePosixPath
7
+
8
+ from .errors import CubicError, UsageError
9
+ from .remote_path import remote_join
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ValidatedApp:
14
+ source: Path
15
+ id: str
16
+ destination: str
17
+ entry: str
18
+
19
+ def public(self) -> dict[str, str]:
20
+ return {
21
+ "source": str(self.source),
22
+ "id": self.id,
23
+ "destination": self.destination,
24
+ "entry": self.entry,
25
+ }
26
+
27
+
28
+ def validate_app_id(value: str) -> str:
29
+ app_id = value.strip()
30
+ if (
31
+ not app_id
32
+ or app_id in {".", ".."}
33
+ or app_id.startswith(".cubic-")
34
+ or "/" in app_id
35
+ or "\\" in app_id
36
+ or any(ord(character) < 32 for character in app_id)
37
+ ):
38
+ raise UsageError(f"Invalid app id: {value}")
39
+ remote_join("/sd/apps", app_id)
40
+ return app_id
41
+
42
+
43
+ def _require_file(file_path: Path, label: str) -> None:
44
+ try:
45
+ if file_path.is_symlink() or not file_path.is_file():
46
+ raise OSError("not a regular file")
47
+ except OSError as error:
48
+ raise CubicError(
49
+ f"App {label} is missing or is not a regular file: {file_path}",
50
+ code="INVALID_APP",
51
+ ) from error
52
+
53
+
54
+ def validate_app_directory(
55
+ directory: str | Path, requested_id: str | None = None
56
+ ) -> ValidatedApp:
57
+ source = Path(os.path.abspath(os.fspath(directory)))
58
+ if source.is_symlink() or not source.is_dir():
59
+ raise CubicError(
60
+ f"App source is not a regular directory: {source}", code="INVALID_APP"
61
+ )
62
+ info_path = source / "app.info"
63
+ _require_file(info_path, "metadata")
64
+ try:
65
+ info = info_path.read_text(encoding="utf-8")
66
+ except (OSError, UnicodeDecodeError) as error:
67
+ raise CubicError(
68
+ f"Unable to read app metadata: {info_path}", code="INVALID_APP"
69
+ ) from error
70
+ match = re.search(r"^\s*entry\s*=\s*(.+?)\s*$", info, flags=re.MULTILINE)
71
+ entry = match.group(1).strip() if match else "main.lua"
72
+ pure_entry = PurePosixPath(entry)
73
+ if (
74
+ not entry
75
+ or pure_entry.is_absolute()
76
+ or ".." in pure_entry.parts
77
+ or "\\" in entry
78
+ ):
79
+ raise CubicError(
80
+ f"app.info declares an unsafe entry: {entry}", code="INVALID_APP"
81
+ )
82
+ _require_file(source.joinpath(*pure_entry.parts), "entry")
83
+ _require_file(source / "main.lua", "main.lua")
84
+ app_id = validate_app_id(requested_id if requested_id is not None else source.name)
85
+ return ValidatedApp(source, app_id, remote_join("/sd/apps", app_id), entry)
@@ -0,0 +1,543 @@
1
+ """Command-line interface matching the Node reference implementation."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ import time
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from . import __version__
12
+ from .app import validate_app_directory, validate_app_id
13
+ from .client import CubicClient, public_info
14
+ from .config import (
15
+ ConfigStore,
16
+ default_config_path,
17
+ resolve_device,
18
+ validate_device_name,
19
+ )
20
+ from .errors import CubicError, UsageError
21
+ from .models import TransferLimits
22
+ from .remote_path import assert_can_delete_remote, normalize_remote_path, remote_join
23
+ from .transfer import (
24
+ TransferProgress,
25
+ download_path,
26
+ ensure_remote_directory,
27
+ upload_path,
28
+ )
29
+ from .url import normalize_device_url
30
+
31
+
32
+ def _positive_integer(value: str) -> int:
33
+ try:
34
+ parsed = int(value)
35
+ except ValueError as error:
36
+ raise argparse.ArgumentTypeError("must be a positive integer") from error
37
+ if parsed <= 0:
38
+ raise argparse.ArgumentTypeError("must be a positive integer")
39
+ return parsed
40
+
41
+
42
+ def _add_transfer_options(
43
+ parser: argparse.ArgumentParser, *, download: bool = False
44
+ ) -> None:
45
+ parser.add_argument(
46
+ "-f", "--force", action="store_true", help="replace an existing target"
47
+ )
48
+ parser.add_argument(
49
+ "--retries",
50
+ type=_positive_integer,
51
+ default=2,
52
+ help="retry transient chunk failures",
53
+ )
54
+ parser.add_argument(
55
+ "--max-depth", type=_positive_integer, default=32, help="recursive depth limit"
56
+ )
57
+ parser.add_argument(
58
+ "--max-entries",
59
+ type=_positive_integer,
60
+ default=4096,
61
+ help="recursive entry limit",
62
+ )
63
+ if download:
64
+ parser.add_argument(
65
+ "--max-bytes",
66
+ type=_positive_integer,
67
+ default=128 * 1024 * 1024,
68
+ help="aggregate directory download limit",
69
+ )
70
+
71
+
72
+ def build_parser() -> argparse.ArgumentParser:
73
+ parser = argparse.ArgumentParser(
74
+ prog="cubic-py",
75
+ description="Manage HoloCubic DevTools devices and SD-card files",
76
+ )
77
+ parser.add_argument(
78
+ "--version", action="version", version=f"%(prog)s {__version__}"
79
+ )
80
+ parser.add_argument(
81
+ "-H", "--host", help="use a device without changing saved configuration"
82
+ )
83
+ parser.add_argument(
84
+ "--timeout", type=_positive_integer, default=60_000, metavar="MILLISECONDS"
85
+ )
86
+ parser.add_argument(
87
+ "--json",
88
+ action="store_true",
89
+ dest="as_json",
90
+ help="write stable JSON to stdout",
91
+ )
92
+ parser.add_argument(
93
+ "--quiet", action="store_true", help="suppress progress and success messages"
94
+ )
95
+ parser.add_argument("--config", help="override the configuration file")
96
+ commands = parser.add_subparsers(dest="command", required=True)
97
+
98
+ device = commands.add_parser("device", help="manage saved devices")
99
+ device_commands = device.add_subparsers(dest="device_command", required=True)
100
+ device_add = device_commands.add_parser("add", help="verify and save a device")
101
+ device_add.add_argument("name")
102
+ device_add.add_argument("device_host")
103
+ device_add.add_argument("--no-use", action="store_false", dest="use", default=True)
104
+ device_commands.add_parser("list", help="list saved devices")
105
+ device_use = device_commands.add_parser("use", help="select a saved device")
106
+ device_use.add_argument("name")
107
+ device_remove = device_commands.add_parser("remove", help="remove a saved device")
108
+ device_remove.add_argument("name")
109
+
110
+ commands.add_parser("ping", help="test the selected device")
111
+ commands.add_parser("info", help="show device capabilities and transfer limits")
112
+ list_parser = commands.add_parser("ls", help="list a remote directory")
113
+ list_parser.add_argument("remote", nargs="?")
114
+ stat_parser = commands.add_parser(
115
+ "stat", help="show remote file or directory metadata"
116
+ )
117
+ stat_parser.add_argument("remote")
118
+ cat_parser = commands.add_parser("cat", help="write a remote file to stdout")
119
+ cat_parser.add_argument("remote")
120
+ mkdir_parser = commands.add_parser(
121
+ "mkdir", help="create a remote directory and missing parents"
122
+ )
123
+ mkdir_parser.add_argument("remote")
124
+ move_parser = commands.add_parser("mv", help="rename or move a remote path")
125
+ move_parser.add_argument("source")
126
+ move_parser.add_argument("target")
127
+ remove_parser = commands.add_parser("rm", help="remove a remote file or directory")
128
+ remove_parser.add_argument("remote")
129
+ remove_parser.add_argument("-r", "--recursive", action="store_true")
130
+ remove_parser.add_argument("-y", "--yes", action="store_true")
131
+
132
+ push_parser = commands.add_parser(
133
+ "push", aliases=["upload"], help="upload a file or directory recursively"
134
+ )
135
+ push_parser.add_argument("local")
136
+ push_parser.add_argument("remote", nargs="?")
137
+ _add_transfer_options(push_parser)
138
+ pull_parser = commands.add_parser(
139
+ "pull", aliases=["download"], help="download a file or directory recursively"
140
+ )
141
+ pull_parser.add_argument("remote")
142
+ pull_parser.add_argument("local", nargs="?")
143
+ _add_transfer_options(pull_parser, download=True)
144
+
145
+ devrun = commands.add_parser("devrun", help="read, save, or run DevRun source")
146
+ devrun_commands = devrun.add_subparsers(dest="devrun_command", required=True)
147
+ devrun_read = devrun_commands.add_parser("read", help="read DevRun source")
148
+ devrun_read.add_argument("output", nargs="?")
149
+ devrun_read.add_argument("-f", "--force", action="store_true")
150
+ for name in ("save", "run"):
151
+ child = devrun_commands.add_parser(
152
+ name, help=f"{'save and run' if name == 'run' else 'save'} DevRun source"
153
+ )
154
+ child.add_argument("file")
155
+
156
+ app = commands.add_parser("app", help="list and install SD-card apps")
157
+ app_commands = app.add_subparsers(dest="app_command", required=True)
158
+ app_commands.add_parser("list", help="list installed apps")
159
+ app_install = app_commands.add_parser(
160
+ "install", help="validate and upload an app directory"
161
+ )
162
+ app_install.add_argument("directory")
163
+ app_install.add_argument("--id")
164
+ app_install.add_argument("-f", "--force", action="store_true")
165
+ app_remove = app_commands.add_parser(
166
+ "remove", help="remove an installed app directory"
167
+ )
168
+ app_remove.add_argument("id")
169
+ app_remove.add_argument("-y", "--yes", action="store_true", required=True)
170
+ return parser
171
+
172
+
173
+ def _configure_stdio() -> None:
174
+ for stream in (sys.stdout, sys.stderr):
175
+ reconfigure = getattr(stream, "reconfigure", None)
176
+ if callable(reconfigure):
177
+ reconfigure(encoding="utf-8", errors="backslashreplace")
178
+
179
+
180
+ def _format_bytes(value: int) -> str:
181
+ if value < 1024:
182
+ return f"{value} B"
183
+ if value < 1024**2:
184
+ return f"{value / 1024:.1f} KiB"
185
+ return f"{value / 1024**2:.1f} MiB"
186
+
187
+
188
+ class _Runtime:
189
+ def __init__(self, args: argparse.Namespace) -> None:
190
+ self.args = args
191
+ config_path = (
192
+ Path(args.config).resolve() if args.config else default_config_path()
193
+ )
194
+ self.store = ConfigStore(config_path)
195
+
196
+ def target(self) -> tuple[CubicClient, str | None, str]:
197
+ resolved = resolve_device(self.store, self.args.host)
198
+ url = str(resolved["url"])
199
+ return CubicClient(url, self.args.timeout), resolved["name"], url
200
+
201
+ def output(self, value: Any, human: Sequence[str] | str | None = None) -> None:
202
+ if self.args.as_json:
203
+ print(json.dumps(value, ensure_ascii=False, separators=(",", ":")))
204
+ elif not self.args.quiet and human is not None:
205
+ if isinstance(human, str):
206
+ print(human)
207
+ else:
208
+ for line in human:
209
+ print(line)
210
+
211
+ def progress(self, event: TransferProgress) -> None:
212
+ if self.args.as_json or self.args.quiet:
213
+ return
214
+ if event.phase == "scan":
215
+ print(f"Scanning {event.path} ...", file=sys.stderr)
216
+ return
217
+ total = f" / {_format_bytes(event.total_bytes)}" if event.total_bytes else ""
218
+ line = f"{event.phase:<8} {_format_bytes(event.transferred_bytes)}{total} {event.path}"
219
+ if sys.stderr.isatty():
220
+ print(
221
+ f"\r{line:<90}",
222
+ end="\n" if event.phase == "commit" else "",
223
+ file=sys.stderr,
224
+ )
225
+ elif event.phase == "commit" or event.transferred_bytes == event.total_bytes:
226
+ print(line, file=sys.stderr)
227
+
228
+ def execute(self) -> None:
229
+ args = self.args
230
+ if args.command == "device":
231
+ self._device()
232
+ elif args.command == "ping":
233
+ client, name, url = self.target()
234
+ started = time.perf_counter()
235
+ info = client.info(force=True)
236
+ latency = round((time.perf_counter() - started) * 1000)
237
+ self.output(
238
+ {
239
+ "ok": True,
240
+ "name": name,
241
+ "url": url,
242
+ "latency_ms": latency,
243
+ "version": info.version,
244
+ },
245
+ f"Connected to {name or url} in {latency} ms{f' ({info.version})' if info.version else ''}.",
246
+ )
247
+ elif args.command == "info":
248
+ client, name, url = self.target()
249
+ info = client.info(force=True)
250
+ self.output(
251
+ public_info(info, url, name),
252
+ [
253
+ f"Device: {name or '(temporary)'}",
254
+ f"URL: {url}",
255
+ f"Version: {info.version or 'unknown'}",
256
+ f"API: v{info.api_version}",
257
+ f"Root: {info.root_path}",
258
+ f"Chunk size: {_format_bytes(info.chunk_size)}",
259
+ f"Max file: {_format_bytes(info.max_file_size)}",
260
+ f"Capabilities: {', '.join(info.capabilities)}",
261
+ ],
262
+ )
263
+ elif args.command == "ls":
264
+ client, _, _ = self.target()
265
+ result = client.list(normalize_remote_path(args.remote))
266
+ self.output(
267
+ result.public(),
268
+ [
269
+ f"{'d' if item.is_dir else '-'} {'' if item.is_dir else str(item.size):>10} {item.name}{'/' if item.is_dir else ''}"
270
+ for item in result.items
271
+ ],
272
+ )
273
+ elif args.command == "stat":
274
+ client, _, _ = self.target()
275
+ result = client.stat(args.remote)
276
+ self.output(
277
+ result.public(),
278
+ [
279
+ f"Path: {result.path}",
280
+ f"Type: {'directory' if result.is_dir else 'file'}",
281
+ f"Size: {result.size} bytes",
282
+ f"MIME: {result.mime}",
283
+ ],
284
+ )
285
+ elif args.command == "cat":
286
+ if args.as_json:
287
+ raise UsageError("`cat` cannot be combined with --json.")
288
+ client, _, _ = self.target()
289
+ remote = normalize_remote_path(args.remote)
290
+ item = client.stat(remote)
291
+ if item.is_dir:
292
+ raise CubicError(
293
+ f"Remote source is a directory: {remote}", code="NOT_A_FILE"
294
+ )
295
+ info = client.info()
296
+ offset = 0
297
+ writer = getattr(sys.stdout, "buffer", sys.stdout)
298
+ while offset < item.size:
299
+ chunk = client.read(
300
+ remote, offset, min(info.chunk_size, item.size - offset)
301
+ )
302
+ if chunk.next_offset != offset + len(chunk.data) or not chunk.data:
303
+ raise CubicError(
304
+ f"Device returned an invalid read offset for {remote}.",
305
+ code="INVALID_RESPONSE",
306
+ )
307
+ writer.write(chunk.data)
308
+ offset = chunk.next_offset
309
+ elif args.command == "mkdir":
310
+ client, _, _ = self.target()
311
+ target = normalize_remote_path(args.remote)
312
+ ensure_remote_directory(client, target)
313
+ self.output({"path": target}, f"Created {target}")
314
+ elif args.command == "mv":
315
+ client, _, _ = self.target()
316
+ source = normalize_remote_path(args.source)
317
+ target = normalize_remote_path(args.target)
318
+ client.rename(source, target)
319
+ self.output(
320
+ {"source": source, "target": target}, f"Moved {source} -> {target}"
321
+ )
322
+ elif args.command == "rm":
323
+ client, _, _ = self.target()
324
+ target = normalize_remote_path(args.remote)
325
+ assert_can_delete_remote(target)
326
+ item = client.stat(target)
327
+ if item.is_dir:
328
+ if not args.recursive:
329
+ raise UsageError(
330
+ f"Remote path is a directory; use --recursive: {target}"
331
+ )
332
+ if not args.yes:
333
+ raise UsageError("Recursive deletion requires --yes.")
334
+ client.rmdir(target, True)
335
+ else:
336
+ client.remove(target)
337
+ self.output(
338
+ {"removed": target, "recursive": item.is_dir}, f"Removed {target}"
339
+ )
340
+ elif args.command in {"push", "upload"}:
341
+ client, _, _ = self.target()
342
+ result = upload_path(
343
+ client,
344
+ Path(args.local),
345
+ args.remote,
346
+ force=args.force,
347
+ retries=args.retries,
348
+ limits=TransferLimits(args.max_depth, args.max_entries),
349
+ on_progress=self.progress,
350
+ )
351
+ self.output(
352
+ result.public(),
353
+ f"Uploaded {result.files} file(s), {_format_bytes(result.bytes)} -> {result.destination}",
354
+ )
355
+ elif args.command in {"pull", "download"}:
356
+ client, _, _ = self.target()
357
+ result = download_path(
358
+ client,
359
+ args.remote,
360
+ Path(args.local) if args.local else None,
361
+ force=args.force,
362
+ retries=args.retries,
363
+ limits=TransferLimits(args.max_depth, args.max_entries, args.max_bytes),
364
+ on_progress=self.progress,
365
+ )
366
+ self.output(
367
+ result.public(),
368
+ f"Downloaded {result.files} file(s), {_format_bytes(result.bytes)} -> {result.destination}",
369
+ )
370
+ elif args.command == "devrun":
371
+ self._devrun()
372
+ elif args.command == "app":
373
+ self._app()
374
+ else:
375
+ raise UsageError(f"Unknown command: {args.command}")
376
+
377
+ def _device(self) -> None:
378
+ args = self.args
379
+ if args.device_command == "add":
380
+ name = validate_device_name(args.name)
381
+ url = normalize_device_url(args.device_host)
382
+ info = CubicClient(url, args.timeout).info(force=True)
383
+ config = self.store.read()
384
+ profile = {"url": url}
385
+ if info.version:
386
+ profile["version"] = info.version
387
+ config["devices"][name] = profile
388
+ if args.use:
389
+ config["current"] = name
390
+ self.store.write(config)
391
+ self.output(
392
+ {
393
+ "name": name,
394
+ "url": url,
395
+ "selected": config["current"] == name,
396
+ "version": info.version,
397
+ },
398
+ [f"Added {name}: {url}"]
399
+ + ([f"Selected device: {name}"] if config["current"] == name else []),
400
+ )
401
+ elif args.device_command == "list":
402
+ config = self.store.read()
403
+ rows = [
404
+ {
405
+ "name": name,
406
+ "url": profile["url"],
407
+ "version": profile.get("version"),
408
+ "selected": name == config["current"],
409
+ }
410
+ for name, profile in sorted(config["devices"].items())
411
+ ]
412
+ lines: list[str] = []
413
+ for row in rows:
414
+ suffix = f" {row['version']}" if row["version"] else ""
415
+ lines.append(
416
+ f"{'*' if row['selected'] else ' '} {row['name']:<16} {row['url']}{suffix}"
417
+ )
418
+ if not lines:
419
+ lines = ["No saved devices."]
420
+ self.output({"current": config["current"], "devices": rows}, lines)
421
+ elif args.device_command in {"use", "remove"}:
422
+ name = validate_device_name(args.name)
423
+ config = self.store.read()
424
+ if name not in config["devices"]:
425
+ raise CubicError(f"Unknown device: {name}", code="NO_DEVICE")
426
+ if args.device_command == "use":
427
+ config["current"] = name
428
+ self.store.write(config)
429
+ self.output({"current": name}, f"Selected device: {name}")
430
+ else:
431
+ del config["devices"][name]
432
+ if config["current"] == name:
433
+ config["current"] = None
434
+ self.store.write(config)
435
+ self.output(
436
+ {"removed": name, "current": config["current"]},
437
+ f"Removed device: {name}",
438
+ )
439
+
440
+ def _devrun(self) -> None:
441
+ args = self.args
442
+ client, _, _ = self.target()
443
+ if args.devrun_command == "read":
444
+ source = client.read_devrun()
445
+ if not args.output:
446
+ if args.as_json:
447
+ self.output({"source": source})
448
+ else:
449
+ print(source, end="")
450
+ return
451
+ output_path = Path(args.output).resolve()
452
+ mode = "w" if args.force else "x"
453
+ try:
454
+ with output_path.open(mode, encoding="utf-8", newline="") as handle:
455
+ handle.write(source)
456
+ except FileExistsError as error:
457
+ raise CubicError(
458
+ f"Local target already exists: {output_path}. Use --force to replace it.",
459
+ code="TARGET_EXISTS",
460
+ ) from error
461
+ self.output(
462
+ {"path": str(output_path), "bytes": len(source.encode("utf-8"))},
463
+ f"Saved DevRun source to {output_path}",
464
+ )
465
+ else:
466
+ source = Path(args.file).resolve().read_text(encoding="utf-8")
467
+ run = args.devrun_command == "run"
468
+ result = client.save_devrun(source, run)
469
+ self.output(
470
+ result.public(),
471
+ f"{'Ran' if run else 'Saved'} {result.entry} ({result.bytes} bytes)",
472
+ )
473
+
474
+ def _app(self) -> None:
475
+ args = self.args
476
+ client, _, _ = self.target()
477
+ if args.app_command == "list":
478
+ result = client.apps()
479
+ self.output(
480
+ result.public(),
481
+ [
482
+ f"{item.get('id', '')}{' *' if item.get('id') == result.current_app_id else ''}"
483
+ for item in result.apps
484
+ ],
485
+ )
486
+ elif args.app_command == "install":
487
+ validated = validate_app_directory(Path(args.directory), args.id)
488
+ apps = client.apps()
489
+ if validated.id == apps.run_app_id:
490
+ raise UsageError(
491
+ f"Refusing to replace {apps.run_app_id}; use the dedicated devrun commands."
492
+ )
493
+ if validated.id == apps.current_app_id:
494
+ raise UsageError(
495
+ f"Refusing to replace the currently running app {validated.id}; switch apps first."
496
+ )
497
+ transfer = upload_path(
498
+ client,
499
+ validated.source,
500
+ validated.destination,
501
+ force=args.force,
502
+ on_progress=self.progress,
503
+ )
504
+ result = {
505
+ **validated.public(),
506
+ "transfer": transfer.public(),
507
+ "rescanRequired": True,
508
+ }
509
+ self.output(
510
+ result,
511
+ [
512
+ f"Installed {validated.id} -> {validated.destination}",
513
+ "Rescan apps on the device before first launch.",
514
+ ],
515
+ )
516
+ elif args.app_command == "remove":
517
+ app_id = validate_app_id(args.id)
518
+ target = remote_join("/sd/apps", app_id)
519
+ apps = client.apps()
520
+ if app_id == apps.run_app_id:
521
+ raise UsageError(
522
+ f"Refusing to remove {apps.run_app_id}; it is managed by the dedicated devrun commands."
523
+ )
524
+ if app_id == apps.current_app_id:
525
+ raise UsageError(
526
+ f"Refusing to remove the currently running app {app_id}; switch apps first."
527
+ )
528
+ client.rmdir(target, True)
529
+ self.output({"removed": app_id, "path": target}, f"Removed app {app_id}")
530
+
531
+
532
+ def main(argv: Sequence[str] | None = None) -> int:
533
+ _configure_stdio()
534
+ args = build_parser().parse_args(argv)
535
+ try:
536
+ _Runtime(args).execute()
537
+ return 0
538
+ except CubicError as error:
539
+ print(f"cubic-py: {error}", file=sys.stderr)
540
+ return error.exit_code
541
+ except (OSError, UnicodeError) as error:
542
+ print(f"cubic-py: {error}", file=sys.stderr)
543
+ return 1