fss-cli 0.11.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,908 @@
1
+ """Verified two-operand recursive ``cp``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from collections.abc import Mapping
8
+ from contextlib import suppress
9
+ from dataclasses import dataclass, replace
10
+ from functools import partial
11
+ from pathlib import Path
12
+ from types import MappingProxyType
13
+ from typing import TYPE_CHECKING, cast
14
+
15
+ from . import _copy_file
16
+ from ._command import (
17
+ _backend_category,
18
+ _call,
19
+ _CommandFailureError,
20
+ _MappedOperand,
21
+ _render_operand_diagnostic,
22
+ _usage_error,
23
+ )
24
+ from ._concurrent import _run_bounded
25
+ from ._content import checksum_relation, describe_checksums, matching_checksum, same_contents
26
+ from ._copy_file import copy_on_backend, create_staging
27
+ from ._diagnostics import _render_diagnostic_value
28
+ from ._logging import _location, _log_file_operation, _redact
29
+ from ._manifest import (
30
+ _MAX_ENTRIES,
31
+ _entry,
32
+ _EntryLimitError,
33
+ _IncompatibleResultError,
34
+ _Manifest,
35
+ _manifest,
36
+ _ManifestEntry,
37
+ _shared_tokens_match,
38
+ _UnsupportedEntryError,
39
+ )
40
+ from ._path import (
41
+ _has_dot_segment,
42
+ _is_root,
43
+ _is_same_or_descendant,
44
+ _lexical_basename,
45
+ _lexical_join,
46
+ _lexical_parent,
47
+ )
48
+ from ._post_copy import (
49
+ _DEFAULT_METADATA_WAIT_SECONDS,
50
+ _PreviousFile,
51
+ _report,
52
+ _resolved_operand,
53
+ _TransferProof,
54
+ report_verified,
55
+ )
56
+ from ._progress import _NO_DISPLAY, _TransferDisplay
57
+ from ._results import info_or_missing, infos_for_paths
58
+
59
+ if TYPE_CHECKING:
60
+ from fsspec.asyn import AsyncFileSystem
61
+
62
+ from ._concurrent import _Outcome
63
+ from ._progress import _Transfer
64
+
65
+ _LOGGER = logging.getLogger(__name__)
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class _RecursiveCpFailure:
70
+ operand: _MappedOperand
71
+ category: str | None = None
72
+ backend_error: Exception | None = None
73
+ residue: bool = False
74
+ rendered: bool = False
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class _DestinationPlan:
79
+ missing_directories: tuple[_ManifestEntry, ...]
80
+ files: Mapping[str, _ManifestEntry]
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class _TransferResult:
85
+ """One file transfer's observed outcome, rendered later in manifest order.
86
+
87
+ ``skipped`` holds the destination metadata proof of a verified skip;
88
+ ``cleanup_error`` records a staging cleanup failure alongside, or instead
89
+ of, the transfer ``failure``.
90
+ """
91
+
92
+ failure: _RecursiveCpFailure | None = None
93
+ cleanup_error: Exception | None = None
94
+ skipped: _ManifestEntry | None = None
95
+
96
+ @property
97
+ def failed(self) -> bool:
98
+ return self.failure is not None or self.cleanup_error is not None
99
+
100
+
101
+ def _canonical_operand(
102
+ command: str,
103
+ operand: _MappedOperand,
104
+ *,
105
+ source: bool,
106
+ ) -> _MappedOperand:
107
+ if _has_dot_segment(operand.path):
108
+ rendered = _render_diagnostic_value(operand.spelling)
109
+ _usage_error(command, f"{rendered}: dot segment unsupported")
110
+ if source and _is_root(operand.path):
111
+ rendered = _render_diagnostic_value(operand.spelling)
112
+ _usage_error(command, f"{rendered}: source root unsupported")
113
+ return operand
114
+
115
+
116
+ def _render_failure(command: str, failure: _RecursiveCpFailure) -> None:
117
+ if failure.rendered:
118
+ return
119
+ suffix = "; destination residue may remain" if failure.residue else ""
120
+ _render_operand_diagnostic(
121
+ command,
122
+ failure.operand,
123
+ f"{failure.category}{suffix}",
124
+ )
125
+
126
+
127
+ def _read_failure(operand: _MappedOperand, error: Exception) -> _RecursiveCpFailure:
128
+ return _RecursiveCpFailure(operand, _backend_category(error), backend_error=error)
129
+
130
+
131
+ def _staging_failure(source: _MappedOperand, error: Exception) -> _RecursiveCpFailure:
132
+ rendered_class = _render_diagnostic_value(type(error).__name__)
133
+ return _RecursiveCpFailure(
134
+ source,
135
+ f"staging failure ({rendered_class})",
136
+ backend_error=error,
137
+ residue=True,
138
+ )
139
+
140
+
141
+ def _needs_local_compare(
142
+ source_entry: _ManifestEntry,
143
+ existing: _ManifestEntry | None,
144
+ ) -> bool:
145
+ """A same-size pair with no MD5 still needs a staged SHA-256 compare."""
146
+ if existing is None or existing.size != source_entry.size:
147
+ return False
148
+ return checksum_relation(source_entry.tokens, existing.tokens) is None
149
+
150
+
151
+ def _classify_source_info(
152
+ operand: _MappedOperand,
153
+ info: object,
154
+ ) -> _RecursiveCpFailure | None:
155
+ entry = _classify_existing(operand, operand.path, info, require_name=False)
156
+ if isinstance(entry, _RecursiveCpFailure):
157
+ return entry
158
+ if entry.kind == "file":
159
+ return _RecursiveCpFailure(operand, "not a directory")
160
+ return None
161
+
162
+
163
+ def _classify_existing( # noqa: PLR0911 - stable metadata categories.
164
+ operand: _MappedOperand,
165
+ path: str,
166
+ info: object,
167
+ *,
168
+ require_name: bool = True,
169
+ ) -> _ManifestEntry | _RecursiveCpFailure:
170
+ if not require_name:
171
+ if not isinstance(info, Mapping):
172
+ return _RecursiveCpFailure(operand, "incompatible result")
173
+ typed_info = cast("Mapping[object, object]", info)
174
+ kind = typed_info.get("type")
175
+ if not isinstance(kind, str):
176
+ return _RecursiveCpFailure(operand, "incompatible result")
177
+ islink = typed_info.get("islink", False)
178
+ if type(islink) is not bool:
179
+ return _RecursiveCpFailure(operand, "incompatible result")
180
+ if islink or kind not in {"directory", "file"}:
181
+ return _RecursiveCpFailure(operand, "unsupported entry type")
182
+ return _ManifestEntry("", path, kind, None, ())
183
+ try:
184
+ entry = _entry("", path, info)
185
+ except _UnsupportedEntryError as error:
186
+ return _RecursiveCpFailure(operand, "unsupported entry type", backend_error=error)
187
+ except _IncompatibleResultError as error:
188
+ return _RecursiveCpFailure(operand, "incompatible result", backend_error=error)
189
+ return entry
190
+
191
+
192
+ def _destination_path(root: str, relative: str) -> str:
193
+ return root if not relative else _lexical_join(root, relative)
194
+
195
+
196
+ def _render_cleanup_failure(
197
+ command: str,
198
+ source: _MappedOperand,
199
+ error: Exception,
200
+ ) -> None:
201
+ rendered_class = _render_diagnostic_value(type(error).__name__)
202
+ _render_operand_diagnostic(
203
+ command,
204
+ source,
205
+ f"staging cleanup failure ({rendered_class}); host staging residue may remain; destination residue may remain",
206
+ )
207
+
208
+
209
+ def _cleanup_staging(
210
+ command: str,
211
+ source: _MappedOperand,
212
+ path: str,
213
+ ) -> Exception | None:
214
+ error = _copy_file.remove_staging(path)
215
+ if error is not None:
216
+ _render_cleanup_failure(command, source, error)
217
+ return error
218
+
219
+
220
+ def _frozen_tokens(entry: _ManifestEntry) -> dict[str, frozenset[str | bytes]]:
221
+ return {name: frozenset({value}) for name, value in entry.tokens}
222
+
223
+
224
+ def _directory_depth(entry: _ManifestEntry) -> int:
225
+ return 0 if not entry.relative else entry.relative.count("/") + 1
226
+
227
+
228
+ def _cleanup_under_control(command: str, source: _MappedOperand, path: str) -> None:
229
+ with suppress(BaseException): # Original control flow wins.
230
+ _cleanup_staging(command, source, path)
231
+
232
+
233
+ @dataclass(frozen=True)
234
+ class _RecursiveCopy:
235
+ command: str
236
+ source: _MappedOperand
237
+ destination: _MappedOperand
238
+ source_filesystem: AsyncFileSystem
239
+ destination_filesystem: AsyncFileSystem
240
+ display: _TransferDisplay = _NO_DISPLAY
241
+ metadata_wait: int = _DEFAULT_METADATA_WAIT_SECONDS
242
+
243
+ async def _resolve_target( # noqa: C901, PLR0911, PLR0912
244
+ self,
245
+ ) -> tuple[str, object | None, _RecursiveCpFailure | None]:
246
+ destination_info, error = await info_or_missing(
247
+ self.destination_filesystem,
248
+ self.destination.path,
249
+ )
250
+ if error is not None:
251
+ return self.destination.path, None, _read_failure(self.destination, error)
252
+
253
+ known_parent = None
254
+ resolved = self.destination.path
255
+ resolved_info = destination_info
256
+ if destination_info is not None:
257
+ entry = _classify_existing(
258
+ self.destination,
259
+ self.destination.path,
260
+ destination_info,
261
+ require_name=False,
262
+ )
263
+ if isinstance(entry, _RecursiveCpFailure):
264
+ return resolved, None, entry
265
+ if entry.kind == "directory":
266
+ known_parent = self.destination.path
267
+ resolved = _lexical_join(
268
+ self.destination.path,
269
+ _lexical_basename(self.source.path),
270
+ )
271
+ resolved_info, error = await info_or_missing(
272
+ self.destination_filesystem,
273
+ resolved,
274
+ )
275
+ if error is not None:
276
+ return resolved, None, _read_failure(self.destination, error)
277
+
278
+ parent = _lexical_parent(resolved)
279
+ if parent != known_parent:
280
+ parent_info, error = await info_or_missing(
281
+ self.destination_filesystem,
282
+ parent,
283
+ )
284
+ if error is not None:
285
+ return resolved, None, _read_failure(self.destination, error)
286
+ if parent_info is None:
287
+ return (
288
+ resolved,
289
+ None,
290
+ _RecursiveCpFailure(self.destination, "not found"),
291
+ )
292
+ parent_entry = _classify_existing(
293
+ self.destination,
294
+ parent,
295
+ parent_info,
296
+ require_name=False,
297
+ )
298
+ if isinstance(parent_entry, _RecursiveCpFailure):
299
+ if parent_entry.category == "unsupported entry type":
300
+ return (
301
+ resolved,
302
+ None,
303
+ _RecursiveCpFailure(self.destination, "not a directory"),
304
+ )
305
+ return resolved, None, parent_entry
306
+ if parent_entry.kind != "directory":
307
+ return (
308
+ resolved,
309
+ None,
310
+ _RecursiveCpFailure(self.destination, "not a directory"),
311
+ )
312
+
313
+ if resolved_info is not None:
314
+ root_entry = _classify_existing(
315
+ self.destination,
316
+ resolved,
317
+ resolved_info,
318
+ require_name=False,
319
+ )
320
+ if isinstance(root_entry, _RecursiveCpFailure):
321
+ return resolved, None, root_entry
322
+ if root_entry.kind == "file":
323
+ return (
324
+ resolved,
325
+ None,
326
+ _RecursiveCpFailure(
327
+ self.destination,
328
+ "destination type conflict",
329
+ ),
330
+ )
331
+
332
+ if self.source.name == self.destination.name and _is_same_or_descendant(
333
+ self.source.path,
334
+ resolved,
335
+ ):
336
+ return (
337
+ resolved,
338
+ None,
339
+ _RecursiveCpFailure(
340
+ self.destination,
341
+ "destination is inside source",
342
+ ),
343
+ )
344
+ return resolved, resolved_info, None
345
+
346
+ async def _preflight_destination(
347
+ self,
348
+ root: str,
349
+ manifest: _Manifest,
350
+ root_info: object | None,
351
+ ) -> tuple[_DestinationPlan, _RecursiveCpFailure | None]:
352
+ """Classify every destination path before any mutation.
353
+
354
+ A missing destination root makes every entry missing, so no per-entry
355
+ request is made. Otherwise the root's resolution metadata is reused
356
+ and every other entry is read concurrently, then classified in
357
+ manifest order.
358
+ """
359
+ if root_info is None:
360
+ missing = tuple(entry for entry in manifest if entry.kind == "directory")
361
+ return _DestinationPlan(missing, MappingProxyType({})), None
362
+ paths = [_destination_path(root, entry.relative) for entry in manifest]
363
+ found, error = await infos_for_paths(
364
+ self.destination_filesystem,
365
+ [path for path in paths if path != root],
366
+ missing_ok=True,
367
+ )
368
+ missing: list[_ManifestEntry] = []
369
+ files: dict[str, _ManifestEntry] = {}
370
+ empty = _DestinationPlan((), MappingProxyType({}))
371
+ if error is not None:
372
+ return empty, _read_failure(self.destination, error)
373
+ for entry, path in zip(manifest, paths, strict=True):
374
+ info = root_info if path == root else found[path]
375
+ if info is None:
376
+ if entry.kind == "directory":
377
+ missing.append(entry)
378
+ continue
379
+ existing = self._existing_destination(entry, path, info)
380
+ if isinstance(existing, _RecursiveCpFailure):
381
+ return empty, existing
382
+ if existing.kind == "file":
383
+ files[path] = existing
384
+ return _DestinationPlan(tuple(missing), MappingProxyType(files)), None
385
+
386
+ def _existing_destination(
387
+ self,
388
+ entry: _ManifestEntry,
389
+ path: str,
390
+ info: object,
391
+ ) -> _ManifestEntry | _RecursiveCpFailure:
392
+ existing = _classify_existing(self.destination, path, info)
393
+ if isinstance(existing, _RecursiveCpFailure):
394
+ return existing
395
+ if existing.kind != entry.kind:
396
+ return _RecursiveCpFailure(self.destination, "destination type conflict")
397
+ return existing
398
+
399
+ def _log_identity(
400
+ self,
401
+ source_entry: _ManifestEntry,
402
+ destination_path: str,
403
+ existing: _ManifestEntry,
404
+ ) -> None:
405
+ if not _LOGGER.isEnabledFor(logging.DEBUG):
406
+ return
407
+ if existing.size != source_entry.size:
408
+ basis = f"sizes differ ({source_entry.size} != {existing.size}); content comparison skipped"
409
+ else:
410
+ basis = describe_checksums(source_entry.tokens, existing.tokens)
411
+ _LOGGER.debug(
412
+ "content identity %s -> %s: source tokens [%s], destination tokens [%s]; %s",
413
+ _location(self.source.name, source_entry.path),
414
+ _location(self.destination.name, destination_path),
415
+ ", ".join(name for name, _ in source_entry.tokens),
416
+ ", ".join(name for name, _ in existing.tokens),
417
+ basis,
418
+ )
419
+
420
+ async def _tracked_transfer(
421
+ self,
422
+ source_entry: _ManifestEntry,
423
+ destination_path: str,
424
+ existing: _ManifestEntry | None,
425
+ ) -> _TransferResult:
426
+ with self.display.transfer() as transfer:
427
+ return await self._transfer(source_entry, destination_path, existing, transfer)
428
+
429
+ async def _transfer( # noqa: C901, PLR0912, PLR0915
430
+ self,
431
+ source_entry: _ManifestEntry,
432
+ destination_path: str,
433
+ existing: _ManifestEntry | None,
434
+ transfer: _Transfer,
435
+ ) -> _TransferResult:
436
+ started = time.monotonic()
437
+ source_location = _location(self.source.name, source_entry.path)
438
+ destination_location = _location(self.destination.name, destination_path)
439
+ if existing is not None:
440
+ self._log_identity(source_entry, destination_path, existing)
441
+ if (
442
+ existing is not None
443
+ and existing.size == source_entry.size
444
+ and matching_checksum(source_entry.tokens, existing.tokens)
445
+ ):
446
+ _log_file_operation(
447
+ _LOGGER,
448
+ "skipped",
449
+ source_location,
450
+ destination_location,
451
+ source_entry.size,
452
+ started,
453
+ )
454
+ return _TransferResult(skipped=existing)
455
+ if self.source.name == self.destination.name and not _needs_local_compare(source_entry, existing):
456
+ result = await self._copy_on_backend(
457
+ source_entry,
458
+ destination_path,
459
+ existing,
460
+ transfer,
461
+ started,
462
+ )
463
+ if result is not None:
464
+ return result
465
+ try:
466
+ temporary = create_staging("fss-cli-cp-recursive-")
467
+ except Exception as error: # noqa: BLE001 - staging creation boundary.
468
+ return _TransferResult(_staging_failure(self.source, error))
469
+ _LOGGER.debug("staging %s through %s", source_location, _redact(temporary))
470
+
471
+ failure = None
472
+ try:
473
+ try:
474
+ await _call(
475
+ self.source_filesystem,
476
+ "_get_file",
477
+ source_entry.path,
478
+ temporary,
479
+ callback=transfer.phase("download", f"{self.source.name}:{source_entry.path}"),
480
+ )
481
+ except Exception as error: # noqa: BLE001 - stable transfer category.
482
+ failure = _RecursiveCpFailure(
483
+ self.source,
484
+ "transfer failure",
485
+ backend_error=error,
486
+ residue=True,
487
+ )
488
+
489
+ if failure is None:
490
+ try:
491
+ staged_size = Path(temporary).stat().st_size # noqa: ASYNC240
492
+ except Exception as error: # noqa: BLE001 - staging stat boundary.
493
+ failure = _staging_failure(self.source, error)
494
+ else:
495
+ if staged_size != source_entry.size:
496
+ failure = _RecursiveCpFailure(
497
+ self.source,
498
+ "source changed",
499
+ residue=True,
500
+ )
501
+
502
+ identical = False
503
+ if failure is None and existing is not None and existing.size == source_entry.size:
504
+ relation = checksum_relation(source_entry.tokens, existing.tokens)
505
+ if relation is None:
506
+ try:
507
+ identical = await same_contents(
508
+ self.destination_filesystem,
509
+ destination_path,
510
+ temporary,
511
+ )
512
+ except Exception as error: # noqa: BLE001 - content verification boundary.
513
+ failure = _RecursiveCpFailure(
514
+ self.destination,
515
+ "verification failure",
516
+ backend_error=error,
517
+ residue=True,
518
+ )
519
+ else:
520
+ _LOGGER.debug(
521
+ "content identity %s -> %s: SHA-256 of staged contents %s",
522
+ source_location,
523
+ destination_location,
524
+ "equal" if identical else "differ",
525
+ )
526
+ else:
527
+ identical = relation
528
+
529
+ uploaded = False
530
+ if failure is None and not identical:
531
+ try:
532
+ await _call(
533
+ self.destination_filesystem,
534
+ "_put_file",
535
+ temporary,
536
+ destination_path,
537
+ mode="overwrite",
538
+ callback=transfer.phase("upload", f"{self.destination.name}:{destination_path}"),
539
+ )
540
+ except Exception as error: # noqa: BLE001 - stable mutation category.
541
+ failure = _RecursiveCpFailure(
542
+ self.destination,
543
+ "mutation failure",
544
+ backend_error=error,
545
+ residue=True,
546
+ )
547
+ else:
548
+ uploaded = True
549
+
550
+ transfer.finish()
551
+ if uploaded:
552
+ failure = await self._report_upload(source_entry, destination_path, existing)
553
+ except BaseException:
554
+ _cleanup_under_control(self.command, self.source, temporary)
555
+ raise
556
+
557
+ cleanup_error = _copy_file.remove_staging(temporary)
558
+ if failure is not None or cleanup_error is not None:
559
+ return _TransferResult(failure, cleanup_error)
560
+ _log_file_operation(
561
+ _LOGGER,
562
+ "skipped" if identical else "staged",
563
+ source_location,
564
+ destination_location,
565
+ source_entry.size,
566
+ started,
567
+ )
568
+ return _TransferResult(skipped=existing if identical else None)
569
+
570
+ async def _copy_on_backend(
571
+ self,
572
+ source_entry: _ManifestEntry,
573
+ destination_path: str,
574
+ existing: _ManifestEntry | None,
575
+ transfer: _Transfer,
576
+ started: float,
577
+ ) -> _TransferResult | None:
578
+ """Copy one file with ``_cp_file`` after a fresh size check."""
579
+ try:
580
+ info = await _call(self.source_filesystem, "_info", source_entry.path)
581
+ current = _entry(source_entry.relative, source_entry.path, info, expected_kind="file")
582
+ except Exception as error: # noqa: BLE001 - stable revalidation category.
583
+ return _TransferResult(
584
+ _RecursiveCpFailure(
585
+ self.source,
586
+ "source revalidation failure",
587
+ backend_error=error,
588
+ residue=True,
589
+ )
590
+ )
591
+ if current.size != source_entry.size:
592
+ return _TransferResult(_RecursiveCpFailure(self.source, "source changed", residue=True))
593
+ target = _resolved_operand(self.destination, destination_path)
594
+ try:
595
+ copied = await copy_on_backend(
596
+ self.source_filesystem,
597
+ source_entry.path,
598
+ destination_path,
599
+ partial(transfer.phase, "copy", target.spelling),
600
+ )
601
+ except Exception as error: # noqa: BLE001 - stable mutation category.
602
+ return _TransferResult(
603
+ _RecursiveCpFailure(
604
+ self.destination,
605
+ "mutation failure",
606
+ backend_error=error,
607
+ residue=True,
608
+ )
609
+ )
610
+ if not copied:
611
+ return None
612
+ transfer.finish()
613
+ failure = await self._report_upload(source_entry, destination_path, existing, verb="copied")
614
+ if failure is not None:
615
+ return _TransferResult(failure)
616
+ _log_file_operation(
617
+ _LOGGER,
618
+ "copied",
619
+ _location(self.source.name, source_entry.path),
620
+ _location(self.destination.name, destination_path),
621
+ source_entry.size,
622
+ started,
623
+ )
624
+ return _TransferResult()
625
+
626
+ async def _report_upload(
627
+ self,
628
+ source_entry: _ManifestEntry,
629
+ destination_path: str,
630
+ existing: _ManifestEntry | None,
631
+ *,
632
+ verb: str = "uploaded",
633
+ ) -> _RecursiveCpFailure | None:
634
+ """Print one file's two outcome lines once its destination ``_info`` settles."""
635
+ target = _resolved_operand(self.destination, destination_path)
636
+ size = source_entry.size or 0
637
+ unverified = await report_verified(
638
+ self.command,
639
+ self.destination_filesystem,
640
+ target,
641
+ _TransferProof(size, tuple(_frozen_tokens(source_entry).items())),
642
+ None if existing is None else _PreviousFile(existing.size, _frozen_tokens(existing)),
643
+ mutation="copy" if verb == "copied" else "upload",
644
+ wait=self.metadata_wait,
645
+ )
646
+ if unverified is None:
647
+ return None
648
+ _report(self.command, target, f"{unverified.text}; destination residue may remain")
649
+ return _RecursiveCpFailure(
650
+ target,
651
+ unverified.text,
652
+ backend_error=unverified.error,
653
+ residue=True,
654
+ rendered=True,
655
+ )
656
+
657
+ async def _create_directories(
658
+ self,
659
+ root: str,
660
+ missing: tuple[_ManifestEntry, ...],
661
+ ) -> _RecursiveCpFailure | None:
662
+ """Create missing directories one depth at a time, parents first."""
663
+ levels: dict[int, list[_ManifestEntry]] = {}
664
+ for entry in missing:
665
+ levels.setdefault(_directory_depth(entry), []).append(entry)
666
+ for depth in sorted(levels):
667
+ outcomes = await _run_bounded(
668
+ [
669
+ partial(
670
+ _call,
671
+ self.destination_filesystem,
672
+ "_mkdir",
673
+ _destination_path(root, entry.relative),
674
+ create_parents=False,
675
+ )
676
+ for entry in sorted(levels[depth], key=lambda item: item.relative)
677
+ ]
678
+ )
679
+ for outcome in outcomes:
680
+ if outcome is not None and outcome.error is not None:
681
+ return _RecursiveCpFailure(
682
+ self.destination,
683
+ "mutation failure",
684
+ backend_error=outcome.error,
685
+ residue=True,
686
+ )
687
+ return None
688
+
689
+ def _render_transfer_failure(
690
+ self,
691
+ result: _TransferResult,
692
+ ) -> _RecursiveCpFailure | None:
693
+ """Render one transfer's diagnostics in their original order."""
694
+ if result.failure is not None:
695
+ _render_failure(self.command, result.failure)
696
+ if result.cleanup_error is not None:
697
+ _render_cleanup_failure(self.command, self.source, result.cleanup_error)
698
+ if result.failure is not None:
699
+ return replace(result.failure, rendered=True)
700
+ if result.cleanup_error is not None:
701
+ return _RecursiveCpFailure(self.source, backend_error=result.cleanup_error, rendered=True)
702
+ return None
703
+
704
+ def _settle_transfers(
705
+ self,
706
+ files: list[_ManifestEntry],
707
+ outcomes: list[_Outcome[_TransferResult] | None],
708
+ ) -> dict[str, _ManifestEntry] | _RecursiveCpFailure:
709
+ """Render the first failed transfer in manifest order, or collect skips.
710
+
711
+ A later transfer that was already in flight when the first failure was
712
+ observed still reports its own host staging residue.
713
+ """
714
+ skipped: dict[str, _ManifestEntry] = {}
715
+ first: _RecursiveCpFailure | None = None
716
+ for entry, outcome in zip(files, outcomes, strict=True):
717
+ if outcome is None:
718
+ continue
719
+ if outcome.error is not None:
720
+ raise outcome.error
721
+ result = cast("_TransferResult", outcome.value)
722
+ if first is not None:
723
+ if result.cleanup_error is not None:
724
+ _render_cleanup_failure(self.command, self.source, result.cleanup_error)
725
+ continue
726
+ first = self._render_transfer_failure(result)
727
+ if first is None and result.skipped is not None:
728
+ skipped[entry.relative] = result.skipped
729
+ return first if first is not None else skipped
730
+
731
+ async def _mutate(
732
+ self,
733
+ root: str,
734
+ manifest: _Manifest,
735
+ plan: _DestinationPlan,
736
+ ) -> dict[str, _ManifestEntry] | _RecursiveCpFailure:
737
+ failure = await self._create_directories(root, plan.missing_directories)
738
+ if failure is not None:
739
+ return failure
740
+ files = [entry for entry in manifest if entry.kind == "file"]
741
+ outcomes = await _run_bounded(
742
+ [
743
+ partial(
744
+ self._tracked_transfer,
745
+ entry,
746
+ _destination_path(root, entry.relative),
747
+ plan.files.get(_destination_path(root, entry.relative)),
748
+ )
749
+ for entry in files
750
+ ],
751
+ stop=lambda result: result.failed,
752
+ )
753
+ return self._settle_transfers(files, outcomes)
754
+
755
+ async def _revalidate_source(self, frozen: _Manifest) -> _RecursiveCpFailure | None:
756
+ try:
757
+ current_info = await _call(
758
+ self.source_filesystem,
759
+ "_info",
760
+ self.source.path,
761
+ )
762
+ current = await _manifest(
763
+ self.source_filesystem,
764
+ self.source.path,
765
+ current_info,
766
+ )
767
+ except Exception as error: # noqa: BLE001 - stable revalidation category.
768
+ return _RecursiveCpFailure(
769
+ self.source,
770
+ "source revalidation failure",
771
+ backend_error=error,
772
+ residue=True,
773
+ )
774
+ if current != frozen:
775
+ return _RecursiveCpFailure(self.source, "source changed", residue=True)
776
+ return None
777
+
778
+ async def _verify_destination(
779
+ self,
780
+ root: str,
781
+ manifest: _Manifest,
782
+ skipped: Mapping[str, _ManifestEntry],
783
+ ) -> _RecursiveCpFailure | None:
784
+ paths = [_destination_path(root, entry.relative) for entry in manifest]
785
+ found, error = await infos_for_paths(self.destination_filesystem, paths, missing_ok=False)
786
+ if error is not None:
787
+ return _RecursiveCpFailure(
788
+ self.destination,
789
+ "verification failure",
790
+ backend_error=error,
791
+ residue=True,
792
+ )
793
+ try:
794
+ for source_entry, path in zip(manifest, paths, strict=True):
795
+ destination_entry = _entry(
796
+ source_entry.relative,
797
+ path,
798
+ found[path],
799
+ expected_kind=source_entry.kind,
800
+ )
801
+ proof = skipped.get(source_entry.relative)
802
+ tokens_match = (
803
+ destination_entry.tokens == proof.tokens
804
+ if proof is not None
805
+ else _shared_tokens_match(source_entry.tokens, dict(destination_entry.tokens))
806
+ )
807
+ if destination_entry.size != source_entry.size or not tokens_match:
808
+ return _RecursiveCpFailure(
809
+ self.destination,
810
+ "verification failure",
811
+ residue=True,
812
+ )
813
+ except Exception as error: # noqa: BLE001 - stable verification category.
814
+ return _RecursiveCpFailure(
815
+ self.destination,
816
+ "verification failure",
817
+ backend_error=error,
818
+ residue=True,
819
+ )
820
+ return None
821
+
822
+ async def run(self) -> _RecursiveCpFailure | None: # noqa: C901, PLR0911
823
+ started = time.monotonic()
824
+ try:
825
+ source_info = await _call(
826
+ self.source_filesystem,
827
+ "_info",
828
+ self.source.path,
829
+ )
830
+ except Exception as error: # noqa: BLE001 - classify read boundary.
831
+ return _read_failure(self.source, error)
832
+ failure = _classify_source_info(self.source, source_info)
833
+ if failure is not None:
834
+ return failure
835
+
836
+ root, root_info, failure = await self._resolve_target()
837
+ if failure is not None:
838
+ return failure
839
+
840
+ try:
841
+ manifest = await _manifest(
842
+ self.source_filesystem,
843
+ self.source.path,
844
+ source_info,
845
+ )
846
+ except _UnsupportedEntryError as error:
847
+ return _RecursiveCpFailure(self.source, "unsupported entry type", backend_error=error)
848
+ except _EntryLimitError as error:
849
+ return _RecursiveCpFailure(
850
+ self.source,
851
+ f"source tree exceeds {_MAX_ENTRIES} entries",
852
+ backend_error=error,
853
+ )
854
+ except _IncompatibleResultError as error:
855
+ return _RecursiveCpFailure(self.source, "incompatible result", backend_error=error)
856
+ except Exception as error: # noqa: BLE001 - classify walk boundary.
857
+ return _read_failure(self.source, error)
858
+
859
+ plan, failure = await self._preflight_destination(root, manifest, root_info)
860
+ if failure is not None:
861
+ return failure
862
+ skipped = await self._mutate(root, manifest, plan)
863
+ if isinstance(skipped, _RecursiveCpFailure):
864
+ return skipped
865
+ failure = await self._revalidate_source(manifest)
866
+ if failure is not None:
867
+ return failure
868
+ failure = await self._verify_destination(root, manifest, skipped)
869
+ if failure is None:
870
+ _log_file_operation(
871
+ _LOGGER,
872
+ "verified",
873
+ _location(self.source.name, self.source.path),
874
+ _location(self.destination.name, root),
875
+ sum(entry.size or 0 for entry in manifest),
876
+ started,
877
+ )
878
+ return failure
879
+
880
+
881
+ async def _run_recursive_cp( # noqa: PLR0913 - operands plus the shared display.
882
+ command: str,
883
+ source_operand: _MappedOperand,
884
+ destination_operand: _MappedOperand,
885
+ filesystems: Mapping[str, AsyncFileSystem],
886
+ *,
887
+ display: _TransferDisplay = _NO_DISPLAY,
888
+ metadata_wait: int = _DEFAULT_METADATA_WAIT_SECONDS,
889
+ ) -> None:
890
+ failure = await _RecursiveCopy(
891
+ command,
892
+ source_operand,
893
+ destination_operand,
894
+ filesystems[source_operand.name],
895
+ filesystems[destination_operand.name],
896
+ display,
897
+ metadata_wait,
898
+ ).run()
899
+ if failure is not None:
900
+ try:
901
+ _render_failure(command, failure)
902
+ except Exception as error:
903
+ raise _CommandFailureError(
904
+ error=failure.backend_error,
905
+ render=False,
906
+ propagate=error,
907
+ ) from error
908
+ raise _CommandFailureError(error=failure.backend_error, render=False)