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,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