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.
- 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 +543 -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.0a1.dist-info/METADATA +67 -0
- holocubic_cli_python-0.1.0a1.dist-info/RECORD +17 -0
- holocubic_cli_python-0.1.0a1.dist-info/WHEEL +4 -0
- holocubic_cli_python-0.1.0a1.dist-info/entry_points.txt +2 -0
- holocubic_cli_python-0.1.0a1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Typed public models for the HoloCubic DevTools API."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
LEGACY_V1_CAPABILITIES = (
|
|
8
|
+
"fs.list",
|
|
9
|
+
"fs.stat",
|
|
10
|
+
"fs.read",
|
|
11
|
+
"fs.write",
|
|
12
|
+
"fs.mkdir",
|
|
13
|
+
"fs.rename",
|
|
14
|
+
"fs.remove",
|
|
15
|
+
"fs.rmdir",
|
|
16
|
+
"apps.list",
|
|
17
|
+
"devrun.read",
|
|
18
|
+
"devrun.save",
|
|
19
|
+
"devrun.run",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class DeviceInfo:
|
|
25
|
+
api_version: int
|
|
26
|
+
version: str | None
|
|
27
|
+
route_base: str
|
|
28
|
+
root_path: str
|
|
29
|
+
chunk_size: int
|
|
30
|
+
max_file_size: int
|
|
31
|
+
max_code_bytes: int
|
|
32
|
+
run_app_id: str
|
|
33
|
+
run_app_main: str
|
|
34
|
+
capabilities: tuple[str, ...]
|
|
35
|
+
raw: dict[str, Any] = field(repr=False)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class RemoteEntry:
|
|
40
|
+
name: str
|
|
41
|
+
path: str
|
|
42
|
+
size: int
|
|
43
|
+
is_dir: bool
|
|
44
|
+
ext: str
|
|
45
|
+
mime: str
|
|
46
|
+
category: str
|
|
47
|
+
|
|
48
|
+
def public(self) -> dict[str, Any]:
|
|
49
|
+
return {
|
|
50
|
+
"name": self.name,
|
|
51
|
+
"path": self.path,
|
|
52
|
+
"size": self.size,
|
|
53
|
+
"isDir": self.is_dir,
|
|
54
|
+
"ext": self.ext,
|
|
55
|
+
"mime": self.mime,
|
|
56
|
+
"category": self.category,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class ListResult:
|
|
62
|
+
path: str
|
|
63
|
+
parent: str
|
|
64
|
+
dir_count: int
|
|
65
|
+
file_count: int
|
|
66
|
+
total_bytes: int
|
|
67
|
+
items: tuple[RemoteEntry, ...]
|
|
68
|
+
|
|
69
|
+
def public(self) -> dict[str, Any]:
|
|
70
|
+
return {
|
|
71
|
+
"path": self.path,
|
|
72
|
+
"parent": self.parent,
|
|
73
|
+
"dirCount": self.dir_count,
|
|
74
|
+
"fileCount": self.file_count,
|
|
75
|
+
"totalBytes": self.total_bytes,
|
|
76
|
+
"items": [item.public() for item in self.items],
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class RemoteStat:
|
|
82
|
+
path: str
|
|
83
|
+
name: str
|
|
84
|
+
parent: str
|
|
85
|
+
size: int
|
|
86
|
+
is_dir: bool
|
|
87
|
+
ext: str
|
|
88
|
+
mime: str
|
|
89
|
+
category: str
|
|
90
|
+
|
|
91
|
+
def public(self) -> dict[str, Any]:
|
|
92
|
+
return {
|
|
93
|
+
"path": self.path,
|
|
94
|
+
"name": self.name,
|
|
95
|
+
"parent": self.parent,
|
|
96
|
+
"size": self.size,
|
|
97
|
+
"isDir": self.is_dir,
|
|
98
|
+
"ext": self.ext,
|
|
99
|
+
"mime": self.mime,
|
|
100
|
+
"category": self.category,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class ReadChunk:
|
|
106
|
+
data: bytes
|
|
107
|
+
size: int
|
|
108
|
+
next_offset: int
|
|
109
|
+
eof: bool
|
|
110
|
+
name: str
|
|
111
|
+
mime: str
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class UploadResult:
|
|
116
|
+
path: str
|
|
117
|
+
next_offset: int
|
|
118
|
+
total: int
|
|
119
|
+
done: bool
|
|
120
|
+
size: int
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True)
|
|
124
|
+
class AppsResult:
|
|
125
|
+
apps: tuple[dict[str, Any], ...]
|
|
126
|
+
current_app_id: str | None
|
|
127
|
+
run_app_id: str
|
|
128
|
+
run_app_main: str
|
|
129
|
+
|
|
130
|
+
def public(self) -> dict[str, Any]:
|
|
131
|
+
return {
|
|
132
|
+
"apps": list(self.apps),
|
|
133
|
+
"currentAppId": self.current_app_id,
|
|
134
|
+
"runAppId": self.run_app_id,
|
|
135
|
+
"runAppMain": self.run_app_main,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True)
|
|
140
|
+
class DevRunResult:
|
|
141
|
+
id: str
|
|
142
|
+
entry: str
|
|
143
|
+
bytes: int
|
|
144
|
+
launched: bool
|
|
145
|
+
rescan_requested: bool
|
|
146
|
+
|
|
147
|
+
def public(self) -> dict[str, Any]:
|
|
148
|
+
return {
|
|
149
|
+
"id": self.id,
|
|
150
|
+
"entry": self.entry,
|
|
151
|
+
"bytes": self.bytes,
|
|
152
|
+
"launched": self.launched,
|
|
153
|
+
"rescanRequested": self.rescan_requested,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True)
|
|
158
|
+
class TransferLimits:
|
|
159
|
+
max_depth: int = 32
|
|
160
|
+
max_entries: int = 4096
|
|
161
|
+
max_download_bytes: int = 128 * 1024 * 1024
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(frozen=True)
|
|
165
|
+
class TransferSummary:
|
|
166
|
+
source: str
|
|
167
|
+
destination: str
|
|
168
|
+
files: int
|
|
169
|
+
directories: int
|
|
170
|
+
bytes: int
|
|
171
|
+
|
|
172
|
+
def public(self) -> dict[str, Any]:
|
|
173
|
+
return {
|
|
174
|
+
"source": self.source,
|
|
175
|
+
"destination": self.destination,
|
|
176
|
+
"files": self.files,
|
|
177
|
+
"directories": self.directories,
|
|
178
|
+
"bytes": self.bytes,
|
|
179
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Remote and local path confinement helpers."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import posixpath
|
|
5
|
+
from pathlib import Path, PurePosixPath
|
|
6
|
+
|
|
7
|
+
from .errors import UsageError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
REMOTE_ROOT = "/sd"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _reject_nul(value: str) -> None:
|
|
14
|
+
if "\0" in value:
|
|
15
|
+
raise UsageError("Remote path contains an invalid NUL character.")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def normalize_remote_path(value: str | None, root: str = REMOTE_ROOT) -> str:
|
|
19
|
+
candidate_value = (value or "").strip()
|
|
20
|
+
_reject_nul(candidate_value)
|
|
21
|
+
unix = candidate_value.replace("\\", "/")
|
|
22
|
+
candidate = unix if unix.startswith("/") else posixpath.join(root, unix or ".")
|
|
23
|
+
normalized = posixpath.normpath(candidate)
|
|
24
|
+
if normalized != root and not normalized.startswith(f"{root}/"):
|
|
25
|
+
raise UsageError(f"Remote path must stay below {root}: {value or ''}")
|
|
26
|
+
return normalized
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def remote_join(parent: str, name: str) -> str:
|
|
30
|
+
_reject_nul(name)
|
|
31
|
+
if not name or name in {".", ".."} or "/" in name or "\\" in name:
|
|
32
|
+
raise UsageError(f"Invalid remote entry name: {name}")
|
|
33
|
+
return normalize_remote_path(posixpath.join(normalize_remote_path(parent), name))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def remote_basename(remote_path: str) -> str:
|
|
37
|
+
normalized = normalize_remote_path(remote_path)
|
|
38
|
+
return "sd" if normalized == REMOTE_ROOT else posixpath.basename(normalized)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def assert_can_delete_remote(remote_path: str) -> None:
|
|
42
|
+
if normalize_remote_path(remote_path) == REMOTE_ROOT:
|
|
43
|
+
raise UsageError(f"Refusing to delete {REMOTE_ROOT}.")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def safe_local_destination(root: str | Path, relative_path: str) -> Path:
|
|
47
|
+
_reject_nul(relative_path)
|
|
48
|
+
normalized = relative_path.replace("\\", "/")
|
|
49
|
+
pure = PurePosixPath(normalized)
|
|
50
|
+
if pure.is_absolute() or ".." in pure.parts:
|
|
51
|
+
raise UsageError(f"Download entry escapes destination: {relative_path}")
|
|
52
|
+
resolved_root = Path(root).resolve()
|
|
53
|
+
resolved = (resolved_root / Path(*pure.parts)).resolve()
|
|
54
|
+
try:
|
|
55
|
+
common = os.path.commonpath((resolved_root, resolved))
|
|
56
|
+
except ValueError as error:
|
|
57
|
+
raise UsageError(
|
|
58
|
+
f"Download entry escapes destination: {relative_path}"
|
|
59
|
+
) from error
|
|
60
|
+
if Path(common) != resolved_root:
|
|
61
|
+
raise UsageError(f"Download entry escapes destination: {relative_path}")
|
|
62
|
+
return resolved
|