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.
- fss_cli/__init__.py +24 -0
- fss_cli/_accounts.py +29 -0
- fss_cli/_app.py +663 -0
- fss_cli/_cat.py +391 -0
- fss_cli/_command.py +368 -0
- fss_cli/_concurrent.py +181 -0
- fss_cli/_content.py +94 -0
- fss_cli/_copy_file.py +65 -0
- fss_cli/_cp.py +740 -0
- fss_cli/_diagnostics.py +12 -0
- fss_cli/_du.py +90 -0
- fss_cli/_find.py +109 -0
- fss_cli/_head_tail.py +128 -0
- fss_cli/_info.py +179 -0
- fss_cli/_listing.py +289 -0
- fss_cli/_logging.py +72 -0
- fss_cli/_ls.py +435 -0
- fss_cli/_manifest.py +264 -0
- fss_cli/_metadata.py +70 -0
- fss_cli/_mkdir.py +97 -0
- fss_cli/_mv.py +146 -0
- fss_cli/_path.py +62 -0
- fss_cli/_post_copy.py +191 -0
- fss_cli/_progress.py +132 -0
- fss_cli/_recursive_cp.py +908 -0
- fss_cli/_recursive_rm.py +335 -0
- fss_cli/_results.py +115 -0
- fss_cli/_rm.py +149 -0
- fss_cli/_rmdir.py +148 -0
- fss_cli/_size.py +95 -0
- fss_cli/_sources.py +178 -0
- fss_cli/_stat.py +196 -0
- fss_cli/_test.py +59 -0
- fss_cli/_tree.py +208 -0
- fss_cli/_unlink.py +90 -0
- fss_cli/_walk.py +141 -0
- fss_cli-0.11.0.dist-info/METADATA +144 -0
- fss_cli-0.11.0.dist-info/RECORD +40 -0
- fss_cli-0.11.0.dist-info/WHEEL +4 -0
- fss_cli-0.11.0.dist-info/licenses/LICENSE +661 -0
fss_cli/_recursive_cp.py
ADDED
|
@@ -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)
|