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,661 @@
|
|
|
1
|
+
"""Safe chunked file and recursive directory transfers."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import posixpath
|
|
5
|
+
import secrets
|
|
6
|
+
import shutil
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .client import CubicClient
|
|
13
|
+
from .errors import CubicError, HttpError
|
|
14
|
+
from .models import RemoteStat, TransferLimits, TransferSummary
|
|
15
|
+
from .remote_path import (
|
|
16
|
+
normalize_remote_path,
|
|
17
|
+
remote_basename,
|
|
18
|
+
remote_join,
|
|
19
|
+
safe_local_destination,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class TransferProgress:
|
|
25
|
+
phase: str
|
|
26
|
+
path: str
|
|
27
|
+
transferred_bytes: int
|
|
28
|
+
total_bytes: int
|
|
29
|
+
completed_entries: int
|
|
30
|
+
total_entries: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
ProgressCallback = Callable[[TransferProgress], None]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class _LocalFile:
|
|
38
|
+
absolute: Path
|
|
39
|
+
relative: str
|
|
40
|
+
size: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class _RemoteFile:
|
|
45
|
+
remote: str
|
|
46
|
+
relative: str
|
|
47
|
+
size: int
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _suffix() -> str:
|
|
51
|
+
return f"{os.getpid()}-{secrets.token_hex(5)}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _absolute_path(value: str | Path) -> Path:
|
|
55
|
+
"""Make a path absolute without following its final symbolic link."""
|
|
56
|
+
return Path(os.path.abspath(os.fspath(value)))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _transient(error: BaseException) -> bool:
|
|
60
|
+
return (
|
|
61
|
+
isinstance(error, HttpError)
|
|
62
|
+
and error.status >= 500
|
|
63
|
+
or isinstance(error, CubicError)
|
|
64
|
+
and error.code in {"TIMEOUT", "CONNECTION_ERROR"}
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _with_retry(operation: Callable[[], object], retries: int) -> object:
|
|
69
|
+
for attempt in range(max(0, retries) + 1):
|
|
70
|
+
try:
|
|
71
|
+
return operation()
|
|
72
|
+
except CubicError as error:
|
|
73
|
+
if not _transient(error) or attempt >= retries:
|
|
74
|
+
raise
|
|
75
|
+
time.sleep(0.1 * (2**attempt))
|
|
76
|
+
raise AssertionError("unreachable retry state")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def ensure_remote_directory(client: CubicClient, remote_directory: str) -> None:
|
|
80
|
+
normalized = normalize_remote_path(remote_directory)
|
|
81
|
+
if normalized == "/sd":
|
|
82
|
+
return
|
|
83
|
+
current = "/sd"
|
|
84
|
+
for segment in normalized[4:].split("/"):
|
|
85
|
+
current = remote_join(current, segment)
|
|
86
|
+
current_stat = client.stat_or_none(current)
|
|
87
|
+
if current_stat is None:
|
|
88
|
+
client.mkdir(current)
|
|
89
|
+
elif not current_stat.is_dir:
|
|
90
|
+
raise CubicError(
|
|
91
|
+
f"Remote parent is a file: {current}", code="PATH_CONFLICT"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _remove_remote_path(client: CubicClient, remote_path: str) -> None:
|
|
96
|
+
target = client.stat_or_none(remote_path)
|
|
97
|
+
if target is None:
|
|
98
|
+
return
|
|
99
|
+
if target.is_dir:
|
|
100
|
+
client.rmdir(remote_path, True)
|
|
101
|
+
else:
|
|
102
|
+
client.remove(remote_path)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _commit_remote(
|
|
106
|
+
client: CubicClient,
|
|
107
|
+
temporary: str,
|
|
108
|
+
target: str,
|
|
109
|
+
current_target: RemoteStat | None,
|
|
110
|
+
force: bool,
|
|
111
|
+
) -> None:
|
|
112
|
+
if current_target is not None and not force:
|
|
113
|
+
raise CubicError(
|
|
114
|
+
f"Remote target already exists: {target}. Use --force to replace it.",
|
|
115
|
+
code="TARGET_EXISTS",
|
|
116
|
+
)
|
|
117
|
+
backup = f"{target}.cubic-backup-{_suffix()}"
|
|
118
|
+
backed_up = False
|
|
119
|
+
if current_target is not None:
|
|
120
|
+
client.rename(target, backup)
|
|
121
|
+
backed_up = True
|
|
122
|
+
try:
|
|
123
|
+
client.rename(temporary, target)
|
|
124
|
+
except CubicError as error:
|
|
125
|
+
if backed_up:
|
|
126
|
+
try:
|
|
127
|
+
client.rename(backup, target)
|
|
128
|
+
except CubicError:
|
|
129
|
+
pass
|
|
130
|
+
raise CubicError(
|
|
131
|
+
f"Unable to commit remote target {target}: {error}", code="COMMIT_FAILED"
|
|
132
|
+
) from error
|
|
133
|
+
if backed_up:
|
|
134
|
+
_remove_remote_path(client, backup)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _scan_local_tree(
|
|
138
|
+
root: Path, limits: TransferLimits
|
|
139
|
+
) -> tuple[list[str], list[_LocalFile], int]:
|
|
140
|
+
directories: list[str] = []
|
|
141
|
+
files: list[_LocalFile] = []
|
|
142
|
+
entries = 0
|
|
143
|
+
total_bytes = 0
|
|
144
|
+
|
|
145
|
+
def visit(absolute: Path, relative: str, depth: int) -> None:
|
|
146
|
+
nonlocal entries, total_bytes
|
|
147
|
+
if depth > limits.max_depth:
|
|
148
|
+
raise CubicError(
|
|
149
|
+
f"Local directory exceeds maximum depth {limits.max_depth}: {absolute}",
|
|
150
|
+
code="TREE_LIMIT",
|
|
151
|
+
)
|
|
152
|
+
for child in sorted(absolute.iterdir(), key=lambda item: item.name):
|
|
153
|
+
entries += 1
|
|
154
|
+
if entries > limits.max_entries:
|
|
155
|
+
raise CubicError(
|
|
156
|
+
f"Local directory exceeds maximum entries {limits.max_entries}.",
|
|
157
|
+
code="TREE_LIMIT",
|
|
158
|
+
)
|
|
159
|
+
child_relative = (
|
|
160
|
+
posixpath.join(relative, child.name) if relative else child.name
|
|
161
|
+
)
|
|
162
|
+
if child.is_symlink():
|
|
163
|
+
raise CubicError(
|
|
164
|
+
f"Symbolic links are not followed: {child}", code="SYMLINK_REJECTED"
|
|
165
|
+
)
|
|
166
|
+
if child.is_dir():
|
|
167
|
+
directories.append(child_relative)
|
|
168
|
+
visit(child, child_relative, depth + 1)
|
|
169
|
+
elif child.is_file():
|
|
170
|
+
size = child.stat().st_size
|
|
171
|
+
files.append(_LocalFile(child, child_relative, size))
|
|
172
|
+
total_bytes += size
|
|
173
|
+
else:
|
|
174
|
+
raise CubicError(
|
|
175
|
+
f"Unsupported local filesystem entry: {child}",
|
|
176
|
+
code="UNSUPPORTED_ENTRY",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
visit(root, "", 0)
|
|
180
|
+
return directories, files, total_bytes
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _scan_remote_tree(
|
|
184
|
+
client: CubicClient, root: str, limits: TransferLimits
|
|
185
|
+
) -> tuple[list[str], list[_RemoteFile], int]:
|
|
186
|
+
directories: list[str] = []
|
|
187
|
+
files: list[_RemoteFile] = []
|
|
188
|
+
seen: set[str] = set()
|
|
189
|
+
entries = 0
|
|
190
|
+
total_bytes = 0
|
|
191
|
+
|
|
192
|
+
def visit(current: str, relative: str, depth: int) -> None:
|
|
193
|
+
nonlocal entries, total_bytes
|
|
194
|
+
if depth > limits.max_depth:
|
|
195
|
+
raise CubicError(
|
|
196
|
+
f"Remote directory exceeds maximum depth {limits.max_depth}: {current}",
|
|
197
|
+
code="TREE_LIMIT",
|
|
198
|
+
)
|
|
199
|
+
result = client.list(current)
|
|
200
|
+
for item in result.items:
|
|
201
|
+
entries += 1
|
|
202
|
+
if entries > limits.max_entries:
|
|
203
|
+
raise CubicError(
|
|
204
|
+
f"Remote directory exceeds maximum entries {limits.max_entries}.",
|
|
205
|
+
code="TREE_LIMIT",
|
|
206
|
+
)
|
|
207
|
+
expected = remote_join(current, item.name)
|
|
208
|
+
if item.path != expected:
|
|
209
|
+
raise CubicError(
|
|
210
|
+
f"Unsafe directory entry path returned by device: {item.path}",
|
|
211
|
+
code="UNSAFE_REMOTE_ENTRY",
|
|
212
|
+
)
|
|
213
|
+
item_relative = (
|
|
214
|
+
posixpath.join(relative, item.name) if relative else item.name
|
|
215
|
+
)
|
|
216
|
+
if item_relative in seen:
|
|
217
|
+
raise CubicError(
|
|
218
|
+
f"Duplicate remote directory entry: {item_relative}",
|
|
219
|
+
code="INVALID_RESPONSE",
|
|
220
|
+
)
|
|
221
|
+
seen.add(item_relative)
|
|
222
|
+
if item.is_dir:
|
|
223
|
+
directories.append(item_relative)
|
|
224
|
+
visit(item.path, item_relative, depth + 1)
|
|
225
|
+
else:
|
|
226
|
+
files.append(_RemoteFile(item.path, item_relative, item.size))
|
|
227
|
+
total_bytes += item.size
|
|
228
|
+
if total_bytes > limits.max_download_bytes:
|
|
229
|
+
raise CubicError(
|
|
230
|
+
f"Remote directory exceeds download limit {limits.max_download_bytes} bytes.",
|
|
231
|
+
code="TREE_LIMIT",
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
visit(root, "", 0)
|
|
235
|
+
return directories, files, total_bytes
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def upload_file(
|
|
239
|
+
client: CubicClient,
|
|
240
|
+
local_file: str | Path,
|
|
241
|
+
remote_file: str,
|
|
242
|
+
*,
|
|
243
|
+
force: bool = False,
|
|
244
|
+
retries: int = 2,
|
|
245
|
+
on_progress: ProgressCallback | None = None,
|
|
246
|
+
) -> TransferSummary:
|
|
247
|
+
source = _absolute_path(local_file)
|
|
248
|
+
if source.is_symlink():
|
|
249
|
+
raise CubicError(
|
|
250
|
+
f"Symbolic links are not followed: {source}", code="SYMLINK_REJECTED"
|
|
251
|
+
)
|
|
252
|
+
if not source.is_file():
|
|
253
|
+
raise CubicError(f"Local source is not a file: {source}", code="NOT_A_FILE")
|
|
254
|
+
size = source.stat().st_size
|
|
255
|
+
target = normalize_remote_path(remote_file)
|
|
256
|
+
info = client.info()
|
|
257
|
+
if size > info.max_file_size:
|
|
258
|
+
raise CubicError(
|
|
259
|
+
f"Local file is {size} bytes; device limit is {info.max_file_size} bytes.",
|
|
260
|
+
code="FILE_TOO_LARGE",
|
|
261
|
+
)
|
|
262
|
+
current_target = client.stat_or_none(target)
|
|
263
|
+
if current_target is not None and current_target.is_dir:
|
|
264
|
+
raise CubicError(
|
|
265
|
+
f"Remote target is a directory: {target}", code="PATH_CONFLICT"
|
|
266
|
+
)
|
|
267
|
+
if current_target is not None and not force:
|
|
268
|
+
raise CubicError(
|
|
269
|
+
f"Remote target already exists: {target}. Use --force to replace it.",
|
|
270
|
+
code="TARGET_EXISTS",
|
|
271
|
+
)
|
|
272
|
+
ensure_remote_directory(client, posixpath.dirname(target))
|
|
273
|
+
temporary = f"{target}.cubic-upload-{_suffix()}"
|
|
274
|
+
offset = 0
|
|
275
|
+
try:
|
|
276
|
+
if size == 0:
|
|
277
|
+
result = _with_retry(lambda: client.upload(temporary, b"", 0, 0), retries)
|
|
278
|
+
if not getattr(result, "done") or getattr(result, "next_offset") != 0:
|
|
279
|
+
raise CubicError(
|
|
280
|
+
"Device did not complete the empty upload.", code="INVALID_RESPONSE"
|
|
281
|
+
)
|
|
282
|
+
else:
|
|
283
|
+
with source.open("rb") as handle:
|
|
284
|
+
while offset < size:
|
|
285
|
+
data = handle.read(min(info.chunk_size, size - offset))
|
|
286
|
+
if not data:
|
|
287
|
+
raise CubicError(
|
|
288
|
+
f"Unexpected end of local file: {source}",
|
|
289
|
+
code="LOCAL_READ_ERROR",
|
|
290
|
+
)
|
|
291
|
+
result = _with_retry(
|
|
292
|
+
lambda data=data, offset=offset: client.upload(
|
|
293
|
+
temporary, data, offset, size
|
|
294
|
+
),
|
|
295
|
+
retries,
|
|
296
|
+
)
|
|
297
|
+
if (
|
|
298
|
+
getattr(result, "next_offset") != offset + len(data)
|
|
299
|
+
or getattr(result, "total") != size
|
|
300
|
+
):
|
|
301
|
+
raise CubicError(
|
|
302
|
+
f"Device returned an invalid upload offset for {target}.",
|
|
303
|
+
code="INVALID_RESPONSE",
|
|
304
|
+
)
|
|
305
|
+
offset = getattr(result, "next_offset")
|
|
306
|
+
if on_progress:
|
|
307
|
+
on_progress(
|
|
308
|
+
TransferProgress(
|
|
309
|
+
"upload",
|
|
310
|
+
target,
|
|
311
|
+
offset,
|
|
312
|
+
size,
|
|
313
|
+
1 if offset == size else 0,
|
|
314
|
+
1,
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
uploaded = client.stat(temporary)
|
|
318
|
+
if uploaded.is_dir or uploaded.size != size:
|
|
319
|
+
raise CubicError(
|
|
320
|
+
f"Remote upload verification failed for {target}.", code="VERIFY_FAILED"
|
|
321
|
+
)
|
|
322
|
+
if on_progress:
|
|
323
|
+
on_progress(TransferProgress("commit", target, size, size, 1, 1))
|
|
324
|
+
_commit_remote(client, temporary, target, current_target, force)
|
|
325
|
+
return TransferSummary(str(source), target, 1, 0, size)
|
|
326
|
+
except Exception:
|
|
327
|
+
try:
|
|
328
|
+
_remove_remote_path(client, temporary)
|
|
329
|
+
except CubicError:
|
|
330
|
+
pass
|
|
331
|
+
raise
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def upload_path(
|
|
335
|
+
client: CubicClient,
|
|
336
|
+
local_source: str | Path,
|
|
337
|
+
remote_destination: str | None = None,
|
|
338
|
+
*,
|
|
339
|
+
force: bool = False,
|
|
340
|
+
retries: int = 2,
|
|
341
|
+
limits: TransferLimits | None = None,
|
|
342
|
+
on_progress: ProgressCallback | None = None,
|
|
343
|
+
) -> TransferSummary:
|
|
344
|
+
source = _absolute_path(local_source)
|
|
345
|
+
if source.is_symlink():
|
|
346
|
+
raise CubicError(
|
|
347
|
+
f"Symbolic links are not followed: {source}", code="SYMLINK_REJECTED"
|
|
348
|
+
)
|
|
349
|
+
target = normalize_remote_path(
|
|
350
|
+
remote_destination or remote_join("/sd", source.name)
|
|
351
|
+
)
|
|
352
|
+
if source.is_file():
|
|
353
|
+
return upload_file(
|
|
354
|
+
client,
|
|
355
|
+
source,
|
|
356
|
+
target,
|
|
357
|
+
force=force,
|
|
358
|
+
retries=retries,
|
|
359
|
+
on_progress=on_progress,
|
|
360
|
+
)
|
|
361
|
+
if not source.is_dir():
|
|
362
|
+
raise CubicError(
|
|
363
|
+
f"Unsupported local source: {source}", code="UNSUPPORTED_ENTRY"
|
|
364
|
+
)
|
|
365
|
+
transfer_limits = limits or TransferLimits()
|
|
366
|
+
if on_progress:
|
|
367
|
+
on_progress(TransferProgress("scan", str(source), 0, 0, 0, 0))
|
|
368
|
+
directories, files, total_bytes = _scan_local_tree(source, transfer_limits)
|
|
369
|
+
info = client.info()
|
|
370
|
+
oversized = next((item for item in files if item.size > info.max_file_size), None)
|
|
371
|
+
if oversized:
|
|
372
|
+
raise CubicError(
|
|
373
|
+
f"Local file is {oversized.size} bytes; device limit is {info.max_file_size}: {oversized.absolute}",
|
|
374
|
+
code="FILE_TOO_LARGE",
|
|
375
|
+
)
|
|
376
|
+
current_target = client.stat_or_none(target)
|
|
377
|
+
if current_target is not None and not force:
|
|
378
|
+
raise CubicError(
|
|
379
|
+
f"Remote target already exists: {target}. Use --force to replace it.",
|
|
380
|
+
code="TARGET_EXISTS",
|
|
381
|
+
)
|
|
382
|
+
ensure_remote_directory(client, posixpath.dirname(target))
|
|
383
|
+
temporary = f"{target}.cubic-upload-{_suffix()}"
|
|
384
|
+
transferred = 0
|
|
385
|
+
completed = 0
|
|
386
|
+
total_entries = len(directories) + len(files)
|
|
387
|
+
try:
|
|
388
|
+
client.mkdir(temporary)
|
|
389
|
+
for directory in directories:
|
|
390
|
+
ensure_remote_directory(client, posixpath.join(temporary, directory))
|
|
391
|
+
completed += 1
|
|
392
|
+
for local in files:
|
|
393
|
+
before = transferred
|
|
394
|
+
|
|
395
|
+
def child_progress(
|
|
396
|
+
event: TransferProgress, relative: str = local.relative
|
|
397
|
+
) -> None:
|
|
398
|
+
if on_progress:
|
|
399
|
+
on_progress(
|
|
400
|
+
TransferProgress(
|
|
401
|
+
event.phase,
|
|
402
|
+
relative,
|
|
403
|
+
before + event.transferred_bytes,
|
|
404
|
+
total_bytes,
|
|
405
|
+
completed,
|
|
406
|
+
total_entries,
|
|
407
|
+
)
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
upload_file(
|
|
411
|
+
client,
|
|
412
|
+
local.absolute,
|
|
413
|
+
posixpath.join(temporary, local.relative),
|
|
414
|
+
retries=retries,
|
|
415
|
+
on_progress=child_progress,
|
|
416
|
+
)
|
|
417
|
+
transferred += local.size
|
|
418
|
+
completed += 1
|
|
419
|
+
if on_progress:
|
|
420
|
+
on_progress(
|
|
421
|
+
TransferProgress(
|
|
422
|
+
"commit", target, total_bytes, total_bytes, completed, total_entries
|
|
423
|
+
)
|
|
424
|
+
)
|
|
425
|
+
_commit_remote(client, temporary, target, current_target, force)
|
|
426
|
+
return TransferSummary(
|
|
427
|
+
str(source), target, len(files), len(directories) + 1, total_bytes
|
|
428
|
+
)
|
|
429
|
+
except Exception:
|
|
430
|
+
try:
|
|
431
|
+
_remove_remote_path(client, temporary)
|
|
432
|
+
except CubicError:
|
|
433
|
+
pass
|
|
434
|
+
raise
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _existing_local(target: Path) -> os.stat_result | None:
|
|
438
|
+
try:
|
|
439
|
+
return target.lstat()
|
|
440
|
+
except FileNotFoundError:
|
|
441
|
+
return None
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _remove_local(target: Path) -> None:
|
|
445
|
+
if target.is_dir() and not target.is_symlink():
|
|
446
|
+
shutil.rmtree(target)
|
|
447
|
+
else:
|
|
448
|
+
target.unlink(missing_ok=True)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _commit_local(
|
|
452
|
+
temporary: Path, target: Path, current_target: os.stat_result | None, force: bool
|
|
453
|
+
) -> None:
|
|
454
|
+
if current_target is not None and not force:
|
|
455
|
+
raise CubicError(
|
|
456
|
+
f"Local target already exists: {target}. Use --force to replace it.",
|
|
457
|
+
code="TARGET_EXISTS",
|
|
458
|
+
)
|
|
459
|
+
backup = target.with_name(f"{target.name}.cubic-backup-{_suffix()}")
|
|
460
|
+
backed_up = False
|
|
461
|
+
if current_target is not None:
|
|
462
|
+
target.rename(backup)
|
|
463
|
+
backed_up = True
|
|
464
|
+
try:
|
|
465
|
+
temporary.rename(target)
|
|
466
|
+
except OSError as error:
|
|
467
|
+
if backed_up:
|
|
468
|
+
try:
|
|
469
|
+
backup.rename(target)
|
|
470
|
+
except OSError:
|
|
471
|
+
pass
|
|
472
|
+
raise CubicError(
|
|
473
|
+
f"Unable to commit local target {target}: {error}", code="COMMIT_FAILED"
|
|
474
|
+
) from error
|
|
475
|
+
if backed_up:
|
|
476
|
+
_remove_local(backup)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def download_file(
|
|
480
|
+
client: CubicClient,
|
|
481
|
+
remote_file: str,
|
|
482
|
+
local_file: str | Path,
|
|
483
|
+
*,
|
|
484
|
+
force: bool = False,
|
|
485
|
+
retries: int = 2,
|
|
486
|
+
limits: TransferLimits | None = None,
|
|
487
|
+
on_progress: ProgressCallback | None = None,
|
|
488
|
+
) -> TransferSummary:
|
|
489
|
+
source = normalize_remote_path(remote_file)
|
|
490
|
+
source_stat = client.stat(source)
|
|
491
|
+
if source_stat.is_dir:
|
|
492
|
+
raise CubicError(f"Remote source is a directory: {source}", code="NOT_A_FILE")
|
|
493
|
+
transfer_limits = limits or TransferLimits()
|
|
494
|
+
if source_stat.size > transfer_limits.max_download_bytes:
|
|
495
|
+
raise CubicError(
|
|
496
|
+
f"Remote file exceeds download limit {transfer_limits.max_download_bytes} bytes: {source}",
|
|
497
|
+
code="TREE_LIMIT",
|
|
498
|
+
)
|
|
499
|
+
target = _absolute_path(local_file)
|
|
500
|
+
current_target = _existing_local(target)
|
|
501
|
+
if current_target is not None and target.is_dir():
|
|
502
|
+
raise CubicError(f"Local target is a directory: {target}", code="PATH_CONFLICT")
|
|
503
|
+
if current_target is not None and not force:
|
|
504
|
+
raise CubicError(
|
|
505
|
+
f"Local target already exists: {target}. Use --force to replace it.",
|
|
506
|
+
code="TARGET_EXISTS",
|
|
507
|
+
)
|
|
508
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
509
|
+
temporary = target.with_name(f"{target.name}.cubic-download-{_suffix()}")
|
|
510
|
+
info = client.info()
|
|
511
|
+
offset = 0
|
|
512
|
+
try:
|
|
513
|
+
with temporary.open("xb") as handle:
|
|
514
|
+
while offset < source_stat.size:
|
|
515
|
+
chunk = _with_retry(
|
|
516
|
+
lambda offset=offset: client.read(
|
|
517
|
+
source, offset, min(info.chunk_size, source_stat.size - offset)
|
|
518
|
+
),
|
|
519
|
+
retries,
|
|
520
|
+
)
|
|
521
|
+
data = getattr(chunk, "data")
|
|
522
|
+
if (
|
|
523
|
+
getattr(chunk, "size") != source_stat.size
|
|
524
|
+
or getattr(chunk, "next_offset") != offset + len(data)
|
|
525
|
+
or not data
|
|
526
|
+
):
|
|
527
|
+
raise CubicError(
|
|
528
|
+
f"Device returned an invalid read offset for {source}.",
|
|
529
|
+
code="INVALID_RESPONSE",
|
|
530
|
+
)
|
|
531
|
+
handle.write(data)
|
|
532
|
+
offset = getattr(chunk, "next_offset")
|
|
533
|
+
if getattr(chunk, "eof") and offset != source_stat.size:
|
|
534
|
+
raise CubicError(
|
|
535
|
+
f"Device ended the download early for {source}.",
|
|
536
|
+
code="INVALID_RESPONSE",
|
|
537
|
+
)
|
|
538
|
+
if on_progress:
|
|
539
|
+
on_progress(
|
|
540
|
+
TransferProgress(
|
|
541
|
+
"download",
|
|
542
|
+
source,
|
|
543
|
+
offset,
|
|
544
|
+
source_stat.size,
|
|
545
|
+
1 if offset == source_stat.size else 0,
|
|
546
|
+
1,
|
|
547
|
+
)
|
|
548
|
+
)
|
|
549
|
+
if temporary.stat().st_size != source_stat.size:
|
|
550
|
+
raise CubicError(
|
|
551
|
+
f"Local download verification failed for {source}.",
|
|
552
|
+
code="VERIFY_FAILED",
|
|
553
|
+
)
|
|
554
|
+
if on_progress:
|
|
555
|
+
on_progress(
|
|
556
|
+
TransferProgress(
|
|
557
|
+
"commit", str(target), source_stat.size, source_stat.size, 1, 1
|
|
558
|
+
)
|
|
559
|
+
)
|
|
560
|
+
_commit_local(temporary, target, current_target, force)
|
|
561
|
+
return TransferSummary(source, str(target), 1, 0, source_stat.size)
|
|
562
|
+
except Exception:
|
|
563
|
+
temporary.unlink(missing_ok=True)
|
|
564
|
+
raise
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def download_path(
|
|
568
|
+
client: CubicClient,
|
|
569
|
+
remote_source: str,
|
|
570
|
+
local_destination: str | Path | None = None,
|
|
571
|
+
*,
|
|
572
|
+
force: bool = False,
|
|
573
|
+
retries: int = 2,
|
|
574
|
+
limits: TransferLimits | None = None,
|
|
575
|
+
on_progress: ProgressCallback | None = None,
|
|
576
|
+
) -> TransferSummary:
|
|
577
|
+
source = normalize_remote_path(remote_source)
|
|
578
|
+
source_stat = client.stat(source)
|
|
579
|
+
target = _absolute_path(local_destination or remote_basename(source))
|
|
580
|
+
if not source_stat.is_dir:
|
|
581
|
+
return download_file(
|
|
582
|
+
client,
|
|
583
|
+
source,
|
|
584
|
+
target,
|
|
585
|
+
force=force,
|
|
586
|
+
retries=retries,
|
|
587
|
+
limits=limits,
|
|
588
|
+
on_progress=on_progress,
|
|
589
|
+
)
|
|
590
|
+
transfer_limits = limits or TransferLimits()
|
|
591
|
+
if on_progress:
|
|
592
|
+
on_progress(TransferProgress("scan", source, 0, 0, 0, 0))
|
|
593
|
+
directories, files, total_bytes = _scan_remote_tree(client, source, transfer_limits)
|
|
594
|
+
current_target = _existing_local(target)
|
|
595
|
+
if current_target is not None and not force:
|
|
596
|
+
raise CubicError(
|
|
597
|
+
f"Local target already exists: {target}. Use --force to replace it.",
|
|
598
|
+
code="TARGET_EXISTS",
|
|
599
|
+
)
|
|
600
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
601
|
+
temporary = target.with_name(f"{target.name}.cubic-download-{_suffix()}")
|
|
602
|
+
transferred = 0
|
|
603
|
+
completed = 0
|
|
604
|
+
total_entries = len(directories) + len(files)
|
|
605
|
+
try:
|
|
606
|
+
temporary.mkdir()
|
|
607
|
+
for directory in directories:
|
|
608
|
+
safe_local_destination(temporary, directory).mkdir(
|
|
609
|
+
parents=True, exist_ok=True
|
|
610
|
+
)
|
|
611
|
+
completed += 1
|
|
612
|
+
for remote in files:
|
|
613
|
+
destination = safe_local_destination(temporary, remote.relative)
|
|
614
|
+
before = transferred
|
|
615
|
+
|
|
616
|
+
def child_progress(
|
|
617
|
+
event: TransferProgress, relative: str = remote.relative
|
|
618
|
+
) -> None:
|
|
619
|
+
if on_progress:
|
|
620
|
+
on_progress(
|
|
621
|
+
TransferProgress(
|
|
622
|
+
event.phase,
|
|
623
|
+
relative,
|
|
624
|
+
before + event.transferred_bytes,
|
|
625
|
+
total_bytes,
|
|
626
|
+
completed,
|
|
627
|
+
total_entries,
|
|
628
|
+
)
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
download_file(
|
|
632
|
+
client,
|
|
633
|
+
remote.remote,
|
|
634
|
+
destination,
|
|
635
|
+
retries=retries,
|
|
636
|
+
limits=TransferLimits(
|
|
637
|
+
transfer_limits.max_depth, transfer_limits.max_entries, remote.size
|
|
638
|
+
),
|
|
639
|
+
on_progress=child_progress,
|
|
640
|
+
)
|
|
641
|
+
transferred += remote.size
|
|
642
|
+
completed += 1
|
|
643
|
+
if on_progress:
|
|
644
|
+
on_progress(
|
|
645
|
+
TransferProgress(
|
|
646
|
+
"commit",
|
|
647
|
+
str(target),
|
|
648
|
+
total_bytes,
|
|
649
|
+
total_bytes,
|
|
650
|
+
completed,
|
|
651
|
+
total_entries,
|
|
652
|
+
)
|
|
653
|
+
)
|
|
654
|
+
_commit_local(temporary, target, current_target, force)
|
|
655
|
+
return TransferSummary(
|
|
656
|
+
source, str(target), len(files), len(directories) + 1, total_bytes
|
|
657
|
+
)
|
|
658
|
+
except Exception:
|
|
659
|
+
if temporary.exists():
|
|
660
|
+
shutil.rmtree(temporary, ignore_errors=True)
|
|
661
|
+
raise
|