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/_cp.py ADDED
@@ -0,0 +1,740 @@
1
+ """Typed request planning and async execution for verified ``cp``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import time
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass
10
+ from functools import partial
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING
13
+
14
+ from . import _copy_file
15
+ from ._command import (
16
+ _CommandFailureError,
17
+ _drain_current_operation,
18
+ _Failure,
19
+ _MappedOperand,
20
+ _parse_mapped_operand,
21
+ _render_backend_failure,
22
+ _render_operand_diagnostic,
23
+ _run_mapped_command,
24
+ _usage_error,
25
+ )
26
+ from ._concurrent import _run_bounded
27
+ from ._copy_file import copy_on_backend, create_staging, discard_staging
28
+ from ._diagnostics import _render_diagnostic_value
29
+ from ._logging import _location, _log_file_operation, _redact
30
+ from ._manifest import _shared_tokens_match
31
+ from ._path import _lexical_basename, _lexical_join, _lexical_parent
32
+ from ._post_copy import (
33
+ _DEFAULT_METADATA_WAIT_SECONDS,
34
+ _PreviousFile,
35
+ _report_gate,
36
+ _report_ready,
37
+ _require_file_size,
38
+ _resolved_operand,
39
+ _TransferProof,
40
+ _verification_tokens,
41
+ report_verified,
42
+ )
43
+ from ._progress import _transfer_display
44
+ from ._recursive_cp import _canonical_operand, _run_recursive_cp
45
+
46
+ if TYPE_CHECKING:
47
+ from collections.abc import Collection
48
+
49
+ from fsspec.asyn import AsyncFileSystem
50
+ from fsspec.callbacks import Callback
51
+
52
+ from ._app import AsyncFilesystemSource
53
+ from ._progress import _Transfer, _TransferDisplay
54
+
55
+ _MIN_OPERAND_COUNT = 2
56
+ _LOGGER = logging.getLogger(__name__)
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class _CpRequest:
61
+ source: _MappedOperand
62
+ destination: _MappedOperand
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class _CpPlan:
67
+ requests: tuple[_CpRequest, ...]
68
+ require_directory: bool
69
+ recursive: bool
70
+ metadata_wait: int = _DEFAULT_METADATA_WAIT_SECONDS
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class _CpFailure(_Failure):
75
+ """A copy failure: the shared shape plus this command's transfer state.
76
+
77
+ ``category`` carries a copy-specific diagnostic that has no equivalent in
78
+ the shared vocabulary; ``residue`` marks a failure that may have left a
79
+ partial or unverified destination behind. ``verdict`` is a post-copy
80
+ verification outcome, rendered verbatim against the resolved destination.
81
+ """
82
+
83
+ category: str | None = None
84
+ residue: bool = False
85
+ verdict: str | None = None
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class _PreparedTransfer:
90
+ proof: _TransferProof
91
+ resolved: str
92
+ previous: _PreviousFile | None
93
+
94
+
95
+ def _cp_plan(
96
+ command: str,
97
+ operands: tuple[str, ...],
98
+ known_names: Collection[str],
99
+ *,
100
+ recursive: bool,
101
+ metadata_wait: int = _DEFAULT_METADATA_WAIT_SECONDS,
102
+ ) -> _CpPlan:
103
+ if len(operands) < _MIN_OPERAND_COUNT:
104
+ _usage_error(command, "missing mapped filesystem operand")
105
+ if recursive and len(operands) > _MIN_OPERAND_COUNT:
106
+ _usage_error(command, "extra operand")
107
+
108
+ mapped = tuple(_parse_mapped_operand(command, operand, known_names) for operand in operands)
109
+ if recursive:
110
+ mapped = (
111
+ _canonical_operand(command, mapped[0], source=True),
112
+ _canonical_operand(command, mapped[1], source=False),
113
+ )
114
+ destination = mapped[-1]
115
+ return _CpPlan(
116
+ requests=tuple(_CpRequest(source=source, destination=destination) for source in mapped[:-1]),
117
+ require_directory=len(mapped) > _MIN_OPERAND_COUNT,
118
+ recursive=recursive,
119
+ metadata_wait=metadata_wait,
120
+ )
121
+
122
+
123
+ async def _require_directory(
124
+ destination: _MappedOperand,
125
+ filesystem: AsyncFileSystem,
126
+ ) -> _CpFailure | None:
127
+ try:
128
+ info = await _drain_current_operation(filesystem._info(destination.path))
129
+ except Exception as error: # noqa: BLE001 - classify awaited backend failure.
130
+ return _CpFailure(destination, backend_error=error)
131
+ if not isinstance(info, Mapping) or not isinstance(info.get("type"), str):
132
+ return _CpFailure(destination, incompatible="result")
133
+ if info["type"] == "file":
134
+ return _CpFailure(destination, category="not a directory")
135
+ if info["type"] != "directory":
136
+ return _CpFailure(destination, incompatible="result")
137
+ return None
138
+
139
+
140
+ def _require_source_file_size(
141
+ source: _MappedOperand,
142
+ info: object,
143
+ ) -> int | _CpFailure:
144
+ if not isinstance(info, Mapping):
145
+ return _CpFailure(source, incompatible="result")
146
+ result_type = info.get("type")
147
+ if not isinstance(result_type, str):
148
+ return _CpFailure(source, incompatible="result")
149
+ if result_type == "directory":
150
+ return _CpFailure(source, incompatible="directory")
151
+ if result_type != "file":
152
+ return _CpFailure(source, incompatible="result")
153
+ size = _require_file_size(info)
154
+ if size is None:
155
+ return _CpFailure(source, incompatible="result")
156
+ return size
157
+
158
+
159
+ async def _prepare_transfer(
160
+ request: _CpRequest,
161
+ source_filesystem: AsyncFileSystem,
162
+ destination_filesystem: AsyncFileSystem,
163
+ ) -> _PreparedTransfer | _CpFailure:
164
+ """Read and validate the source, then resolve the destination path."""
165
+ try:
166
+ source_info = await _drain_current_operation(source_filesystem._info(request.source.path))
167
+ except Exception as error: # noqa: BLE001 - classify awaited backend failure.
168
+ return _CpFailure(request.source, backend_error=error)
169
+
170
+ expected_size = _require_source_file_size(request.source, source_info)
171
+ if isinstance(expected_size, _CpFailure):
172
+ return expected_size
173
+ proof = _freeze_transfer_proof(source_info, expected_size)
174
+
175
+ resolution = await _resolve_destination(
176
+ request.destination,
177
+ request.source.path,
178
+ destination_filesystem,
179
+ )
180
+ if isinstance(resolution, _CpFailure):
181
+ return resolution
182
+ resolved, previous_info = resolution
183
+ previous = (
184
+ None
185
+ if previous_info is None
186
+ else _PreviousFile(
187
+ size=_require_file_size(previous_info),
188
+ tokens=_verification_tokens(previous_info),
189
+ )
190
+ )
191
+ return _PreparedTransfer(proof, resolved, previous)
192
+
193
+
194
+ async def _resolve_destination( # noqa: C901, PLR0911, PLR0912 - explicit target branches.
195
+ destination: _MappedOperand,
196
+ source_path: str,
197
+ filesystem: AsyncFileSystem,
198
+ ) -> tuple[str, Mapping[object, object] | None] | _CpFailure:
199
+ """Resolve the target path and return the file ``_info`` it already holds, if any."""
200
+ known_directory: str | None = None
201
+ previous: Mapping[object, object] | None = None
202
+ try:
203
+ dest_info = await _drain_current_operation(filesystem._info(destination.path))
204
+ except FileNotFoundError:
205
+ resolved = destination.path
206
+ except Exception as error: # noqa: BLE001 - classify awaited backend failure.
207
+ return _CpFailure(destination, backend_error=error)
208
+ else:
209
+ if not isinstance(dest_info, Mapping) or not isinstance(dest_info.get("type"), str):
210
+ return _CpFailure(destination, incompatible="result")
211
+ dest_type = dest_info["type"]
212
+ if dest_type == "directory":
213
+ known_directory = destination.path
214
+ resolved = _lexical_join(
215
+ destination.path,
216
+ _lexical_basename(source_path),
217
+ )
218
+ elif dest_type == "file":
219
+ resolved = destination.path
220
+ previous = dest_info
221
+ else:
222
+ return _CpFailure(destination, incompatible="result")
223
+
224
+ if resolved != destination.path:
225
+ try:
226
+ collision = await _drain_current_operation(filesystem._info(resolved))
227
+ except FileNotFoundError:
228
+ collision = None
229
+ except Exception as error: # noqa: BLE001 - classify awaited backend failure.
230
+ return _CpFailure(destination, backend_error=error)
231
+ if collision is not None:
232
+ if not isinstance(collision, Mapping) or not isinstance(collision.get("type"), str):
233
+ return _CpFailure(destination, incompatible="result")
234
+ if collision["type"] == "directory":
235
+ return _CpFailure(destination, incompatible="result")
236
+ if collision["type"] != "file":
237
+ return _CpFailure(destination, incompatible="result")
238
+ previous = collision
239
+
240
+ parent = _lexical_parent(resolved)
241
+ if parent == known_directory:
242
+ return resolved, previous
243
+ try:
244
+ parent_info = await _drain_current_operation(filesystem._info(parent))
245
+ except Exception as error: # noqa: BLE001 - classify awaited backend failure.
246
+ return _CpFailure(destination, backend_error=error)
247
+
248
+ if not isinstance(parent_info, Mapping) or not isinstance(parent_info.get("type"), str):
249
+ return _CpFailure(destination, incompatible="result")
250
+ if parent_info["type"] != "directory":
251
+ category = "not a directory" if parent_info["type"] == "file" else "incompatible result"
252
+ return _CpFailure(destination, category=category)
253
+
254
+ return resolved, previous
255
+
256
+
257
+ async def _stage_remote(
258
+ filesystem: AsyncFileSystem,
259
+ remote: str,
260
+ prefix: str,
261
+ callback: Callback,
262
+ ) -> tuple[str | None, Exception | None]:
263
+ try:
264
+ temporary = create_staging(prefix)
265
+ except Exception as error: # noqa: BLE001 - local staging creation boundary.
266
+ return None, error
267
+ try:
268
+ await _drain_current_operation(filesystem._get_file(remote, temporary, callback=callback))
269
+ except Exception as error: # noqa: BLE001 - staging download boundary.
270
+ _copy_file.remove_staging(temporary)
271
+ return None, error
272
+ except BaseException:
273
+ discard_staging(temporary)
274
+ raise
275
+ return temporary, None
276
+
277
+
278
+ def _freeze_transfer_proof(
279
+ source_info: object,
280
+ expected_size: int,
281
+ ) -> _TransferProof:
282
+ return _TransferProof(
283
+ expected_size=expected_size,
284
+ tokens=tuple(_verification_tokens(source_info).items()),
285
+ )
286
+
287
+
288
+ async def _verify_transfer( # noqa: PLR0913 - one explicit transfer-proof boundary.
289
+ source_filesystem: AsyncFileSystem,
290
+ destination_filesystem: AsyncFileSystem,
291
+ source_path: str,
292
+ destination_path: str,
293
+ proof: _TransferProof,
294
+ destination: _MappedOperand,
295
+ *,
296
+ require_source_absent: bool,
297
+ ) -> _CpFailure | None:
298
+ try:
299
+ destination_info = await _drain_current_operation(destination_filesystem._info(destination_path))
300
+ except Exception as error: # noqa: BLE001 - post-copy verify is residue-bearing.
301
+ return _CpFailure(
302
+ destination,
303
+ backend_error=error,
304
+ residue=True,
305
+ category="verification failure",
306
+ )
307
+
308
+ verified_size = _require_file_size(destination_info)
309
+ if (
310
+ verified_size is None
311
+ or verified_size != proof.expected_size
312
+ or not _shared_tokens_match(proof.tokens, _verification_tokens(destination_info))
313
+ ):
314
+ return _CpFailure(
315
+ destination,
316
+ category="verification failure",
317
+ residue=True,
318
+ )
319
+
320
+ if require_source_absent:
321
+ try:
322
+ await _drain_current_operation(source_filesystem._info(source_path))
323
+ except FileNotFoundError:
324
+ return None
325
+ except Exception as error: # noqa: BLE001 - post-move absence proof.
326
+ return _CpFailure(
327
+ destination,
328
+ backend_error=error,
329
+ category="verification failure",
330
+ residue=True,
331
+ )
332
+ return _CpFailure(
333
+ destination,
334
+ category="verification failure",
335
+ residue=True,
336
+ )
337
+ return None
338
+
339
+
340
+ async def _await_verified_copy( # noqa: PLR0913 - maps the common verification result to command diagnostics.
341
+ command: str,
342
+ filesystem: AsyncFileSystem,
343
+ prepared: _PreparedTransfer,
344
+ destination: _MappedOperand,
345
+ *,
346
+ mutation: str,
347
+ wait: int,
348
+ ) -> _CpFailure | None:
349
+ unverified = await report_verified(
350
+ command,
351
+ filesystem,
352
+ destination,
353
+ prepared.proof,
354
+ prepared.previous,
355
+ mutation=mutation,
356
+ wait=wait,
357
+ )
358
+ if unverified is None:
359
+ return None
360
+ return _CpFailure(destination, backend_error=unverified.error, residue=True, verdict=unverified.text)
361
+
362
+
363
+ async def _confirmed_cross_source_cp_file( # noqa: C901, PLR0913 - explicit copy outcomes.
364
+ command: str,
365
+ request: _CpRequest,
366
+ source_filesystem: AsyncFileSystem,
367
+ destination_filesystem: AsyncFileSystem,
368
+ *,
369
+ transfer: _Transfer,
370
+ metadata_wait: int,
371
+ ) -> _CpFailure | None:
372
+ started = time.monotonic()
373
+ prepared = await _prepare_transfer(
374
+ request,
375
+ source_filesystem,
376
+ destination_filesystem,
377
+ )
378
+ if isinstance(prepared, _CpFailure):
379
+ return prepared
380
+ proof, resolved = prepared.proof, prepared.resolved
381
+ target = _resolved_operand(request.destination, resolved)
382
+
383
+ if source_filesystem is destination_filesystem and request.source.path == resolved:
384
+ return _CpFailure(request.source, incompatible="same_path")
385
+
386
+ temporary, staging_error = await _stage_remote(
387
+ source_filesystem,
388
+ request.source.path,
389
+ "fss-cli-cp-",
390
+ transfer.phase("download", request.source.spelling),
391
+ )
392
+ if staging_error is not None or temporary is None:
393
+ return _CpFailure(
394
+ request.source,
395
+ backend_error=staging_error,
396
+ category="staging failure",
397
+ )
398
+ _LOGGER.debug(
399
+ "staged %s through %s",
400
+ _location(request.source.name, request.source.path),
401
+ _redact(temporary),
402
+ )
403
+
404
+ primary_failure: _CpFailure | None = None
405
+ try:
406
+ try:
407
+ staged_size = Path(temporary).stat().st_size # noqa: ASYNC240
408
+ except Exception as error: # noqa: BLE001 - local staging boundary.
409
+ primary_failure = _CpFailure(
410
+ request.source,
411
+ backend_error=error,
412
+ category="staging failure",
413
+ )
414
+ else:
415
+ if staged_size != proof.expected_size:
416
+ primary_failure = _CpFailure(
417
+ request.source,
418
+ category="verification failure",
419
+ )
420
+
421
+ if primary_failure is None:
422
+ try:
423
+ await _drain_current_operation(
424
+ destination_filesystem._put_file(
425
+ temporary,
426
+ resolved,
427
+ mode="overwrite",
428
+ callback=transfer.phase("upload", target.spelling),
429
+ )
430
+ )
431
+ except Exception as error: # noqa: BLE001 - mutation may leave residue.
432
+ primary_failure = _CpFailure(
433
+ request.destination,
434
+ backend_error=error,
435
+ uncertain=True,
436
+ residue=True,
437
+ )
438
+
439
+ transfer.finish()
440
+ if primary_failure is None:
441
+ primary_failure = await _await_verified_copy(
442
+ command,
443
+ destination_filesystem,
444
+ prepared,
445
+ target,
446
+ mutation="upload",
447
+ wait=metadata_wait,
448
+ )
449
+ except BaseException:
450
+ discard_staging(temporary)
451
+ raise
452
+
453
+ cleanup_error = _copy_file.remove_staging(temporary)
454
+ if primary_failure is not None:
455
+ return primary_failure
456
+ if cleanup_error is not None:
457
+ return _CpFailure(
458
+ request.destination,
459
+ backend_error=cleanup_error,
460
+ category="staging failure",
461
+ residue=True,
462
+ )
463
+ _log_verified_transfer("staged", request, resolved, proof, started)
464
+ return None
465
+
466
+
467
+ async def _confirmed_cp_file(
468
+ command: str,
469
+ request: _CpRequest,
470
+ filesystem: AsyncFileSystem,
471
+ *,
472
+ transfer: _Transfer,
473
+ metadata_wait: int,
474
+ ) -> _CpFailure | None:
475
+ started = time.monotonic()
476
+ prepared = await _prepare_transfer(request, filesystem, filesystem)
477
+ if isinstance(prepared, _CpFailure):
478
+ return prepared
479
+ proof, resolved = prepared.proof, prepared.resolved
480
+ target = _resolved_operand(request.destination, resolved)
481
+
482
+ if request.source.path == resolved:
483
+ return _CpFailure(request.source, incompatible="same_path")
484
+
485
+ try:
486
+ copied = await copy_on_backend(
487
+ filesystem, request.source.path, resolved, partial(transfer.phase, "copy", target.spelling)
488
+ )
489
+ except Exception as error: # noqa: BLE001 - mutation may leave destination residue.
490
+ return _CpFailure(
491
+ request.destination,
492
+ backend_error=error,
493
+ uncertain=True,
494
+ residue=True,
495
+ )
496
+
497
+ if not copied:
498
+ return await _confirmed_cross_source_cp_file(
499
+ command,
500
+ request,
501
+ filesystem,
502
+ filesystem,
503
+ transfer=transfer,
504
+ metadata_wait=metadata_wait,
505
+ )
506
+ transfer.finish()
507
+ failure = await _await_verified_copy(
508
+ command,
509
+ filesystem,
510
+ prepared,
511
+ target,
512
+ mutation="copy",
513
+ wait=metadata_wait,
514
+ )
515
+ if failure is not None:
516
+ return failure
517
+ _log_verified_transfer("copied", request, resolved, proof, started)
518
+ return None
519
+
520
+
521
+ def _log_verified_transfer(
522
+ outcome: str,
523
+ request: _CpRequest,
524
+ resolved: str,
525
+ proof: _TransferProof,
526
+ started: float,
527
+ ) -> None:
528
+ """Record one verified file operation for an embedding host."""
529
+ _log_file_operation(
530
+ _LOGGER,
531
+ outcome,
532
+ _location(request.source.name, request.source.path),
533
+ _location(request.destination.name, resolved),
534
+ proof.expected_size,
535
+ started,
536
+ )
537
+
538
+
539
+ def _render_staging_category(error: Exception) -> str:
540
+ rendered_class = _render_diagnostic_value(type(error).__name__)
541
+ return f"staging failure ({rendered_class})"
542
+
543
+
544
+ def _render_failure( # noqa: C901 - stable diagnostic categories.
545
+ command: str, failure: _CpFailure
546
+ ) -> None:
547
+ suffix = "; destination residue may remain" if failure.residue else ""
548
+ if failure.uncertain:
549
+ _render_operand_diagnostic(
550
+ command,
551
+ failure.operand,
552
+ f"uncertain mutation state{suffix}",
553
+ )
554
+ elif failure.verdict is not None:
555
+ _render_operand_diagnostic(command, failure.operand, f"{failure.verdict}{suffix}")
556
+ elif failure.incompatible == "directory":
557
+ _render_operand_diagnostic(command, failure.operand, "is a directory")
558
+ elif failure.incompatible == "same_path":
559
+ _render_operand_diagnostic(command, failure.operand, "same path")
560
+ elif failure.incompatible == "result":
561
+ _render_operand_diagnostic(command, failure.operand, "incompatible result")
562
+ elif failure.category == "verification failure":
563
+ _render_operand_diagnostic(
564
+ command,
565
+ failure.operand,
566
+ f"verification failure{suffix}",
567
+ )
568
+ elif failure.category == "not a directory":
569
+ _render_operand_diagnostic(command, failure.operand, "not a directory")
570
+ elif failure.category == "staging failure" and failure.backend_error is not None:
571
+ _render_operand_diagnostic(
572
+ command,
573
+ failure.operand,
574
+ f"{_render_staging_category(failure.backend_error)}{suffix}",
575
+ )
576
+ elif isinstance(failure.backend_error, IsADirectoryError):
577
+ _render_operand_diagnostic(command, failure.operand, "is a directory")
578
+ elif isinstance(failure.backend_error, NotADirectoryError):
579
+ _render_operand_diagnostic(command, failure.operand, "not a directory")
580
+ elif failure.backend_error is None:
581
+ _render_operand_diagnostic(
582
+ command,
583
+ failure.operand,
584
+ failure.category or "incompatible result",
585
+ )
586
+ else:
587
+ _render_backend_failure(command, failure.operand, failure.backend_error)
588
+
589
+
590
+ async def _copy_one_request(
591
+ command: str,
592
+ request: _CpRequest,
593
+ filesystems: Mapping[str, AsyncFileSystem],
594
+ transfer: _Transfer,
595
+ metadata_wait: int,
596
+ ) -> _CpFailure | None:
597
+ """Copy one planned request on one backend or across two."""
598
+ if request.source.name == request.destination.name:
599
+ return await _confirmed_cp_file(
600
+ command,
601
+ request,
602
+ filesystems[request.source.name],
603
+ transfer=transfer,
604
+ metadata_wait=metadata_wait,
605
+ )
606
+ return await _confirmed_cross_source_cp_file(
607
+ command,
608
+ request,
609
+ filesystems[request.source.name],
610
+ filesystems[request.destination.name],
611
+ transfer=transfer,
612
+ metadata_wait=metadata_wait,
613
+ )
614
+
615
+
616
+ def _copy_destination_key(request: _CpRequest, *, into_directory: bool) -> tuple[str, str]:
617
+ """The file path two copies must not write at the same time."""
618
+ path = request.destination.path
619
+ if into_directory:
620
+ path = _lexical_join(path, _lexical_basename(request.source.path))
621
+ return request.destination.name, path
622
+
623
+
624
+ async def _copy_requests_concurrently(
625
+ command: str,
626
+ plan: _CpPlan,
627
+ filesystems: Mapping[str, AsyncFileSystem],
628
+ display: _TransferDisplay,
629
+ ) -> _CpFailure | None:
630
+ """Copy every planned file under the shared limit, printing in argv order.
631
+
632
+ Copies of the same destination path take a lock in submission order, so a
633
+ repeated basename still finishes as the last operand.
634
+ """
635
+ locks: dict[tuple[str, str], asyncio.Lock] = {}
636
+ keys: list[tuple[str, str]] = []
637
+ for request in plan.requests:
638
+ key = _copy_destination_key(request, into_directory=plan.require_directory)
639
+ keys.append(key)
640
+ locks.setdefault(key, asyncio.Lock())
641
+ # Copies of one destination path stay in argv order. Outcome lines are
642
+ # written as each copy produces them, so a failed write or an interrupt
643
+ # still lands inside that copy.
644
+ gates = [asyncio.Event() for _request in plan.requests]
645
+ if gates:
646
+ gates[0].set()
647
+
648
+ async def run_one(index: int, request: _CpRequest) -> _CpFailure | None:
649
+ gate_token = _report_gate.set(gates[index])
650
+ ready_token = _report_ready.set(False)
651
+ try:
652
+ async with locks[keys[index]]:
653
+ with display.transfer() as transfer:
654
+ result = await _copy_one_request(
655
+ command,
656
+ request,
657
+ filesystems,
658
+ transfer,
659
+ plan.metadata_wait,
660
+ )
661
+ await gates[index].wait()
662
+ return result
663
+ finally:
664
+ _report_gate.reset(gate_token)
665
+ _report_ready.reset(ready_token)
666
+ if index + 1 < len(gates):
667
+ gates[index + 1].set()
668
+
669
+ outcomes = await _run_bounded(
670
+ [partial(run_one, index, request) for index, request in enumerate(plan.requests)],
671
+ stop=lambda result: result is not None,
672
+ )
673
+ for outcome in outcomes:
674
+ if outcome is None:
675
+ break
676
+ if outcome.error is not None:
677
+ raise outcome.error
678
+ if outcome.value is not None:
679
+ return outcome.value
680
+ return None
681
+
682
+
683
+ async def _copy_requests(
684
+ command: str,
685
+ plan: _CpPlan,
686
+ filesystems: Mapping[str, AsyncFileSystem],
687
+ display: _TransferDisplay,
688
+ ) -> None:
689
+ if plan.recursive:
690
+ await _run_recursive_cp(
691
+ command,
692
+ plan.requests[0].source,
693
+ plan.requests[0].destination,
694
+ filesystems,
695
+ display=display,
696
+ metadata_wait=plan.metadata_wait,
697
+ )
698
+ return
699
+
700
+ failure = None
701
+ if plan.require_directory:
702
+ failure = await _require_directory(
703
+ plan.requests[0].destination,
704
+ filesystems[plan.requests[0].destination.name],
705
+ )
706
+ if failure is None and plan.requests:
707
+ failure = await _copy_requests_concurrently(command, plan, filesystems, display)
708
+ if failure is not None:
709
+ try:
710
+ _render_failure(command, failure)
711
+ except Exception as error:
712
+ raise _CommandFailureError(
713
+ error=failure.backend_error,
714
+ render=False,
715
+ propagate=error,
716
+ ) from error
717
+ raise _CommandFailureError(
718
+ error=failure.backend_error,
719
+ render=False,
720
+ )
721
+
722
+
723
+ async def _run_cp(
724
+ command: str,
725
+ plan: _CpPlan,
726
+ sources: Mapping[str, AsyncFilesystemSource],
727
+ ) -> None:
728
+ async def operation(filesystems: Mapping[str, AsyncFileSystem]) -> None:
729
+ with _transfer_display() as display:
730
+ await _copy_requests(command, plan, filesystems, display)
731
+
732
+ await _run_mapped_command(
733
+ command,
734
+ (
735
+ *(request.source for request in plan.requests),
736
+ plan.requests[0].destination,
737
+ ),
738
+ sources,
739
+ operation,
740
+ )