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/_command.py ADDED
@@ -0,0 +1,368 @@
1
+ """Shared execution support for mapped-source command modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import locale
8
+ import sys
9
+ from contextlib import suppress
10
+ from dataclasses import dataclass
11
+ from typing import (
12
+ TYPE_CHECKING,
13
+ Literal,
14
+ NoReturn,
15
+ Protocol,
16
+ TypeAlias,
17
+ TypeGuard,
18
+ TypeVar,
19
+ cast,
20
+ )
21
+
22
+ import typer
23
+
24
+ from ._diagnostics import _render_diagnostic_prefix, _render_diagnostic_value
25
+ from ._sources import _SourceInvocation
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import (
29
+ Awaitable,
30
+ Callable,
31
+ Collection,
32
+ Iterable,
33
+ Mapping,
34
+ Sequence,
35
+ )
36
+
37
+ from fsspec.asyn import AsyncFileSystem
38
+
39
+ from ._app import AsyncFilesystemSource
40
+ from ._recursive_rm import _RecursiveRmFailure
41
+
42
+ # 128 + SIGPIPE (13): lets pipeline consumers distinguish a closed reader from
43
+ # an ordinary command failure when the broken pipe is the sole failure.
44
+ _BROKEN_PIPE_EXIT_CODE = 141
45
+ _ResultT = TypeVar("_ResultT")
46
+
47
+
48
+ def _usage_error(command: str, diagnostic: str) -> NoReturn:
49
+ """Emit one stable usage diagnostic on stderr and exit ``2``."""
50
+ prefix = _render_diagnostic_prefix(command)
51
+ typer.echo(f"{prefix} {diagnostic}", err=True, color=True)
52
+ raise typer.Exit(2)
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class _MappedOperand:
57
+ """A parsed ``name:/path`` operand selecting one mapped source."""
58
+
59
+ spelling: str
60
+ name: str
61
+ path: str
62
+
63
+
64
+ class _BinaryWriter(Protocol):
65
+ """The write/flush surface of a binary stdout stream."""
66
+
67
+ def write(self, data: bytes) -> int: ...
68
+
69
+ def flush(self) -> None: ...
70
+
71
+
72
+ def _binary_stdout() -> _BinaryWriter:
73
+ """Return the process binary stdout buffer, or raise if unavailable."""
74
+ buffer = getattr(sys.stdout, "buffer", None)
75
+ if buffer is None:
76
+ message = "stdout has no binary buffer"
77
+ raise OSError(message)
78
+ return buffer
79
+
80
+
81
+ def _write_binary(stdout: _BinaryWriter, payload: bytes) -> None:
82
+ """Write one complete byte payload or reject a short write."""
83
+ written = stdout.write(payload)
84
+ if written != len(payload):
85
+ message = "short write"
86
+ raise OSError(message)
87
+
88
+
89
+ # The result shapes a command can reject without a backend exception. Commands
90
+ # translate these into their own diagnostic vocabulary when rendering.
91
+ _Incompatible: TypeAlias = Literal["directory", "result", "same_path"]
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class _Failure:
96
+ """One operand-scoped command failure.
97
+
98
+ ``backend_error`` carries an exception raised by a filesystem hook;
99
+ ``incompatible`` marks a hook that returned successfully with a result the
100
+ command's contract rejects. ``uncertain`` marks a failure observed after a
101
+ mutation was dispatched, so the remote state is not known to be unchanged.
102
+ """
103
+
104
+ operand: _MappedOperand
105
+ backend_error: Exception | None = None
106
+ incompatible: _Incompatible | None = None
107
+ uncertain: bool = False
108
+
109
+
110
+ # Commands that need to tell apart the code path a failure came from subclass
111
+ # `_Failure` as a tag; the shared helpers stay usable through this bound.
112
+ _FailureT = TypeVar("_FailureT", bound=_Failure)
113
+
114
+
115
+ class _CommandFailureError(Exception):
116
+ """One expected command or output failure crossing the invocation boundary."""
117
+
118
+ def __init__(
119
+ self,
120
+ operand: _MappedOperand | None = None,
121
+ error: Exception | None = None,
122
+ *,
123
+ render: bool = True,
124
+ propagate: Exception | None = None,
125
+ ) -> None:
126
+ self.operand = operand
127
+ self.error = error
128
+ self.render = render
129
+ self.propagate = propagate
130
+
131
+
132
+ async def _drain_current_operation(operation: Awaitable[_ResultT]) -> _ResultT:
133
+ """Drain one started operation before propagating caller control flow."""
134
+
135
+ async def capture() -> tuple[BaseException | None, _ResultT | None]:
136
+ try:
137
+ return None, await operation
138
+ except BaseException as error: # noqa: BLE001 - preserve exact control flow.
139
+ return error, None
140
+
141
+ task = asyncio.create_task(capture())
142
+ try:
143
+ error, result = await asyncio.shield(task)
144
+ except BaseException:
145
+ while not task.done():
146
+ with suppress(BaseException):
147
+ await asyncio.shield(task)
148
+ with suppress(BaseException):
149
+ task.result()
150
+ raise
151
+ if error is not None:
152
+ raise error
153
+ return cast("_ResultT", result)
154
+
155
+
156
+ async def _call(
157
+ filesystem: AsyncFileSystem,
158
+ operation: str,
159
+ *args: object,
160
+ **kwargs: object,
161
+ ) -> object:
162
+ """Await one named async hook, refusing absent or synchronous forms."""
163
+ method = getattr(filesystem, operation, None)
164
+ if not callable(method):
165
+ raise NotImplementedError
166
+ result = method(*args, **kwargs)
167
+ if not inspect.isawaitable(result):
168
+ raise NotImplementedError
169
+ return await _drain_current_operation(result)
170
+
171
+
172
+ def _render_operand_diagnostic(
173
+ command: str,
174
+ operand: _MappedOperand,
175
+ category: str,
176
+ ) -> None:
177
+ prefix = _render_diagnostic_prefix(command)
178
+ rendered_operand = _render_diagnostic_value(operand.spelling)
179
+ typer.echo(f"{prefix} {rendered_operand}: {category}", err=True, color=True)
180
+
181
+
182
+ def _render_failure(command: str, failure: _Failure) -> None:
183
+ if failure.backend_error is None:
184
+ _render_operand_diagnostic(command, failure.operand, "incompatible result")
185
+ else:
186
+ _render_backend_failure(command, failure.operand, failure.backend_error)
187
+
188
+
189
+ def _render_backend_failure(
190
+ command: str,
191
+ operand: _MappedOperand,
192
+ error: Exception,
193
+ ) -> None:
194
+ _render_operand_diagnostic(command, operand, _backend_category(error))
195
+
196
+
197
+ def _backend_category(error: Exception) -> str:
198
+ for error_type, category in (
199
+ (FileNotFoundError, "not found"),
200
+ (FileExistsError, "file exists"),
201
+ (PermissionError, "permission denied"),
202
+ (IsADirectoryError, "is a directory"),
203
+ (NotADirectoryError, "not a directory"),
204
+ (NotImplementedError, "unsupported operation"),
205
+ ):
206
+ if isinstance(error, error_type):
207
+ return category
208
+ rendered_class = _render_diagnostic_value(type(error).__name__)
209
+ rendered_message = _render_diagnostic_value(str(error))
210
+ return f"backend failure ({rendered_class}): {rendered_message}"
211
+
212
+
213
+ def _first_backend_error(
214
+ failures: Iterable[_Failure | _RecursiveRmFailure],
215
+ ) -> Exception | None:
216
+ """Return the first backend exception among ``failures``, if any."""
217
+ return next(
218
+ (failure.backend_error for failure in failures if failure.backend_error is not None),
219
+ None,
220
+ )
221
+
222
+
223
+ def _raise_operand_failures(
224
+ command: str,
225
+ failures: Sequence[_FailureT],
226
+ render: Callable[[str, _FailureT], None],
227
+ ) -> NoReturn:
228
+ """Render every operand failure, then fail the invocation exactly once.
229
+
230
+ An exception escaping ``render`` (an output failure) propagates instead of
231
+ the command failure, but the invocation still reports the backend error it
232
+ had already observed.
233
+ """
234
+ backend_error = _first_backend_error(failures)
235
+ try:
236
+ for failure in failures:
237
+ render(command, failure)
238
+ except Exception as error:
239
+ raise _CommandFailureError(
240
+ error=backend_error,
241
+ render=False,
242
+ propagate=error,
243
+ ) from error
244
+ raise _CommandFailureError(error=backend_error, render=False)
245
+
246
+
247
+ def _render_output_failure(command: str, error: Exception) -> None:
248
+ prefix = _render_diagnostic_prefix(command)
249
+ rendered_class = _render_diagnostic_value(type(error).__name__)
250
+ rendered_message = _render_diagnostic_value(str(error))
251
+ typer.echo(
252
+ f"{prefix} output: output failure ({rendered_class}): {rendered_message}",
253
+ err=True,
254
+ color=True,
255
+ )
256
+
257
+
258
+ async def _run_mapped_command(
259
+ command: str,
260
+ operands: tuple[_MappedOperand, ...],
261
+ sources: Mapping[str, AsyncFilesystemSource],
262
+ operation: Callable[[Mapping[str, AsyncFileSystem]], Awaitable[None]],
263
+ *,
264
+ broken_pipe_exit_code: int = _BROKEN_PIPE_EXIT_CODE,
265
+ ) -> None:
266
+ """Acquire referenced sources, run one command, and own final status."""
267
+ invocation = _SourceInvocation(command, sources)
268
+ acquired = False
269
+ failure: _CommandFailureError | None = None
270
+ try:
271
+ filesystems = await invocation.acquire(tuple(dict.fromkeys(operand.name for operand in operands)))
272
+ acquired = filesystems is not None
273
+ if filesystems is not None:
274
+ await operation(filesystems)
275
+ except _CommandFailureError as error:
276
+ failure = error
277
+ if error.render and error.operand is not None:
278
+ _render_failure(command, _Failure(error.operand, error.error))
279
+ elif (
280
+ error.render
281
+ and error.error is not None
282
+ and not isinstance(
283
+ error.error,
284
+ BrokenPipeError,
285
+ )
286
+ ):
287
+ _render_output_failure(command, error.error)
288
+ finally:
289
+ cleanup_failed = await invocation.close_with_command_error(failure.error if failure is not None else None)
290
+
291
+ if failure is not None and failure.propagate is not None:
292
+ raise failure.propagate
293
+ if not acquired or failure is not None or cleanup_failed:
294
+ if failure is not None and isinstance(failure.error, BrokenPipeError) and not cleanup_failed:
295
+ raise typer.Exit(broken_pipe_exit_code)
296
+ raise typer.Exit(1)
297
+
298
+
299
+ async def _run_single_operand_text(
300
+ command: str,
301
+ operand: _MappedOperand,
302
+ sources: Mapping[str, AsyncFilesystemSource],
303
+ operation: Callable[[AsyncFileSystem], Awaitable[str | _Failure]],
304
+ ) -> None:
305
+ """Run one mapped async operation with buffered text output and cleanup."""
306
+
307
+ async def execute(filesystems: Mapping[str, AsyncFileSystem]) -> None:
308
+ result = await operation(filesystems[operand.name])
309
+ if isinstance(result, _Failure):
310
+ raise _CommandFailureError(operand, result.backend_error)
311
+ if not result:
312
+ return
313
+ try:
314
+ typer.echo(result, nl=False, color=True)
315
+ except BrokenPipeError as error:
316
+ raise _CommandFailureError(error=error, render=False) from error
317
+ except Exception as error:
318
+ raise _CommandFailureError(error=error) from error
319
+
320
+ await _run_mapped_command(
321
+ command,
322
+ (operand,),
323
+ sources,
324
+ execute,
325
+ broken_pipe_exit_code=1,
326
+ )
327
+
328
+
329
+ def _collate(value: str) -> tuple[str, str]:
330
+ """Return the locale-aware sort key used for every ordered CLI listing.
331
+
332
+ The raw value breaks ties so that strings the current locale considers
333
+ equal still order deterministically.
334
+ """
335
+ return locale.strxfrm(value), value
336
+
337
+
338
+ def _valid_size(value: object) -> TypeGuard[int]:
339
+ """Accept only an exact non-negative ``int`` byte count.
340
+
341
+ ``type(...) is int`` rather than ``isinstance``: ``bool`` subclasses
342
+ ``int``, so a backend returning ``True`` would otherwise pass as size 1.
343
+ """
344
+ return type(value) is int and value >= 0
345
+
346
+
347
+ def _parse_mapped_operand(
348
+ command: str,
349
+ argument: str,
350
+ known_names: Collection[str],
351
+ ) -> _MappedOperand:
352
+ """Parse and validate one ``name:/path`` operand against the known sources."""
353
+ name, separator, path = argument.partition(":")
354
+ if not name or not separator or not path.startswith("/") or "\0" in argument or "\n" in argument:
355
+ rendered = _render_diagnostic_value(argument)
356
+ _usage_error(command, f"{rendered}: invalid mapped filesystem operand")
357
+
358
+ if name not in known_names:
359
+ rendered_operand = _render_diagnostic_value(argument)
360
+ rendered_names = ", ".join(
361
+ _render_diagnostic_value(candidate) for candidate in sorted(known_names, key=_collate)
362
+ )
363
+ _usage_error(
364
+ command,
365
+ f"{rendered_operand}: unknown filesystem (known: {rendered_names})",
366
+ )
367
+
368
+ return _MappedOperand(spelling=argument, name=name, path=path)
fss_cli/_concurrent.py ADDED
@@ -0,0 +1,181 @@
1
+ """Bounded, order-preserving concurrency for independent backend operations.
2
+
3
+ Remote backends pay one round trip per hook call, so commands that issue many
4
+ independent calls run them concurrently under one fixed bound. Results are
5
+ always returned in submission order, so output and the reported failure stay
6
+ deterministic regardless of completion order.
7
+
8
+ After the first failure, no new operation starts. Every operation already in
9
+ flight finishes before control flow propagates.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ from contextlib import suppress
16
+ from dataclasses import dataclass
17
+ from typing import TYPE_CHECKING, Generic, TypeVar, cast
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Awaitable, Callable, Sequence
21
+
22
+ _CONCURRENCY = 16
23
+ _ResultT = TypeVar("_ResultT")
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class _Outcome(Generic[_ResultT]):
28
+ """The observed result of one started operation.
29
+
30
+ Exactly one of ``value`` or ``error`` is meaningful: ``error`` is set when
31
+ the operation raised an ordinary exception.
32
+ """
33
+
34
+ value: _ResultT | None = None
35
+ error: Exception | None = None
36
+
37
+
38
+ async def _capture(
39
+ operation: Callable[[], Awaitable[_ResultT]],
40
+ ) -> _Outcome[_ResultT] | BaseException:
41
+ """Run one operation, returning control-flow exceptions as values.
42
+
43
+ A task must never raise ``KeyboardInterrupt`` or ``SystemExit`` itself:
44
+ asyncio re-raises those out of the event loop, bypassing the drain below.
45
+ """
46
+ try:
47
+ return _Outcome(value=await operation())
48
+ except Exception as error: # noqa: BLE001 - reported in submission order.
49
+ return _Outcome(error=error)
50
+ except BaseException as error: # noqa: BLE001 - preserve exact control flow.
51
+ return error
52
+
53
+
54
+ async def _drain(tasks: Sequence[asyncio.Task[object]]) -> None:
55
+ """Wait for every task to finish, even while this waiter is cancelled."""
56
+ if not tasks:
57
+ return
58
+ aggregate = asyncio.gather(*tasks, return_exceptions=True)
59
+ while not aggregate.done():
60
+ with suppress(BaseException):
61
+ await asyncio.shield(aggregate)
62
+
63
+
64
+ class _BoundedRun(Generic[_ResultT]):
65
+ """Scheduler state for one :func:`_run_bounded` call."""
66
+
67
+ def __init__(
68
+ self,
69
+ operations: Sequence[Callable[[], Awaitable[_ResultT]]],
70
+ stop: Callable[[_ResultT], bool] | None,
71
+ limit: int,
72
+ ) -> None:
73
+ self.operations = operations
74
+ self.stop = stop
75
+ self.limit = limit
76
+ self.outcomes: list[_Outcome[_ResultT] | None] = [None] * len(operations)
77
+ self.pending: dict[asyncio.Task[_Outcome[_ResultT] | BaseException], int] = {}
78
+ self.next_index = 0
79
+ self.stopped = False
80
+ self.control: BaseException | None = None
81
+
82
+ def launch(self) -> None:
83
+ while not self.stopped and len(self.pending) < self.limit and self.next_index < len(self.operations):
84
+ operation = self.operations[self.next_index]
85
+ self.pending[asyncio.create_task(_capture(operation))] = self.next_index
86
+ self.next_index += 1
87
+
88
+ def interrupt(self) -> None:
89
+ self.stopped = True
90
+ for task in self.pending:
91
+ task.cancel()
92
+
93
+ def record(self, task: asyncio.Task[_Outcome[_ResultT] | BaseException]) -> None:
94
+ index = self.pending.pop(task)
95
+ result = asyncio.CancelledError() if task.cancelled() else task.result()
96
+ if isinstance(result, BaseException):
97
+ if self.control is None:
98
+ self.control = result
99
+ self.interrupt()
100
+ return
101
+ self.outcomes[index] = result
102
+ if result.error is not None or (self.stop is not None and self.stop(cast("_ResultT", result.value))):
103
+ self.stopped = True
104
+
105
+ async def run(self) -> list[_Outcome[_ResultT] | None]:
106
+ try:
107
+ self.launch()
108
+ while self.pending:
109
+ done, _ = await asyncio.wait(self.pending, return_when=asyncio.FIRST_COMPLETED)
110
+ for task in done:
111
+ self.record(task)
112
+ self.launch()
113
+ except BaseException:
114
+ self.interrupt()
115
+ await _drain(tuple(self.pending))
116
+ raise
117
+ if self.control is not None:
118
+ raise self.control
119
+ return self.outcomes
120
+
121
+
122
+ async def _run_bounded(
123
+ operations: Sequence[Callable[[], Awaitable[_ResultT]]],
124
+ *,
125
+ stop: Callable[[_ResultT], bool] | None = None,
126
+ limit: int = _CONCURRENCY,
127
+ ) -> list[_Outcome[_ResultT] | None]:
128
+ """Run operations with at most ``limit`` in flight, in submission order.
129
+
130
+ Operations start in submission order. Once one raises an ordinary
131
+ exception, or returns a value for which ``stop`` is true, no further
132
+ operation starts; every operation already started is awaited. The result
133
+ holds one outcome per started operation and ``None`` for each operation
134
+ that never started. Because operations start in order, every operation
135
+ before a stopping one has an outcome, so a caller scanning the result in
136
+ order meets the first failure in submission order before any ``None``.
137
+
138
+ A control-flow exception cancels the other started operations. This
139
+ includes cancellation and ``KeyboardInterrupt``, whether raised by an
140
+ operation or delivered to the caller. Each operation drains its in-flight
141
+ hook call through ``_drain_current_operation``. The exception propagates
142
+ only after every started operation has finished.
143
+
144
+ Args:
145
+ operations: Zero-argument callables, each starting one operation.
146
+ stop: Optional predicate marking a successful value as a failure that
147
+ stops further starts.
148
+ limit: Maximum number of operations in flight.
149
+
150
+ Returns:
151
+ Outcomes in submission order, ``None`` for unstarted operations.
152
+ """
153
+ return await _BoundedRun(operations, stop, limit).run()
154
+
155
+
156
+ def _outcome_value(outcome: _Outcome[_ResultT] | None) -> _ResultT:
157
+ """Return a started operation's value, re-raising its failure."""
158
+ if outcome is None:
159
+ message = "operation never started"
160
+ raise RuntimeError(message)
161
+ if outcome.error is not None:
162
+ raise outcome.error
163
+ return cast("_ResultT", outcome.value)
164
+
165
+
166
+ async def _gather_bounded(
167
+ operations: Sequence[Callable[[], Awaitable[_ResultT]]],
168
+ *,
169
+ limit: int = _CONCURRENCY,
170
+ ) -> list[_ResultT]:
171
+ """Run operations under the bound and return their values in order.
172
+
173
+ Raises:
174
+ Exception: The failure of the first failing operation in submission
175
+ order, after every started operation has finished.
176
+ """
177
+ outcomes = await _run_bounded(operations, limit=limit)
178
+ for outcome in outcomes:
179
+ if outcome is not None and outcome.error is not None:
180
+ raise outcome.error
181
+ return [_outcome_value(outcome) for outcome in outcomes]
fss_cli/_content.py ADDED
@@ -0,0 +1,94 @@
1
+ """Bounded content comparison for resumable recursive copies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ import binascii
8
+ import hashlib
9
+ import re
10
+ import tempfile
11
+ from contextlib import suppress
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING
14
+
15
+ from ._command import _call, _drain_current_operation
16
+
17
+ if TYPE_CHECKING:
18
+ from fsspec.asyn import AsyncFileSystem
19
+
20
+ _HASH_CHUNK = 1024 * 1024
21
+ _MD5_BYTES = 16
22
+
23
+
24
+ def _md5(tokens: tuple[tuple[str, object], ...]) -> bytes | None:
25
+ values = dict(tokens)
26
+ value = values.get("md5")
27
+ if isinstance(value, str) and re.fullmatch(r"[0-9a-fA-F]{32}", value):
28
+ return bytes.fromhex(value)
29
+ value = values.get("content-md5")
30
+ if isinstance(value, (str, bytes)):
31
+ try:
32
+ digest = base64.b64decode(value, validate=True)
33
+ except (ValueError, binascii.Error):
34
+ return None
35
+ if len(digest) == _MD5_BYTES:
36
+ return digest
37
+ return None
38
+
39
+
40
+ def checksum_relation(
41
+ source: tuple[tuple[str, object], ...],
42
+ destination: tuple[tuple[str, object], ...],
43
+ ) -> bool | None:
44
+ """Return whether two MD5s match.
45
+
46
+ ``None`` means at least one side has no usable MD5, so the caller must
47
+ compare bytes. ``False`` means both sides have an MD5 and they differ, so
48
+ a byte download would not change the decision.
49
+ """
50
+ left = _md5(source)
51
+ right = _md5(destination)
52
+ if left is None or right is None:
53
+ return None
54
+ return left == right
55
+
56
+
57
+ def matching_checksum(source: tuple[tuple[str, object], ...], destination: tuple[tuple[str, object], ...]) -> bool:
58
+ """Accept compatible explicit MD5 values, never opaque ETags or sizes."""
59
+ digest = _md5(source)
60
+ return digest is not None and digest == _md5(destination)
61
+
62
+
63
+ def describe_checksums(source: tuple[tuple[str, object], ...], destination: tuple[tuple[str, object], ...]) -> str:
64
+ """Describe the content-identity decision for a debug record, without values."""
65
+ source_digest = _md5(source)
66
+ destination_digest = _md5(destination)
67
+ if source_digest is None or destination_digest is None:
68
+ return "no compatible MD5 checksum on both sides; comparing SHA-256"
69
+ if source_digest == destination_digest:
70
+ return "MD5 checksums equal"
71
+ return "MD5 checksums differ; comparing SHA-256"
72
+
73
+
74
+ def _digest(path: str) -> bytes:
75
+ digest = hashlib.sha256()
76
+ with Path(path).open("rb") as stream:
77
+ while chunk := stream.read(_HASH_CHUNK):
78
+ digest.update(chunk)
79
+ return digest.digest()
80
+
81
+
82
+ async def same_contents(filesystem: AsyncFileSystem, destination: str, staged_source: str) -> bool:
83
+ """Compare staged bytes without keeping object contents in memory."""
84
+ directory = tempfile.TemporaryDirectory(prefix="fss-cli-compare-")
85
+ try:
86
+ local = str(Path(directory.name) / "destination")
87
+ await _call(filesystem, "_get_file", destination, local)
88
+ identical = await _drain_current_operation(asyncio.to_thread(lambda: _digest(staged_source) == _digest(local)))
89
+ except BaseException:
90
+ with suppress(BaseException):
91
+ directory.cleanup()
92
+ raise
93
+ directory.cleanup()
94
+ return identical
fss_cli/_copy_file.py ADDED
@@ -0,0 +1,65 @@
1
+ """Backend-neutral copy dispatch and owned staging shared by both copy commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import os
7
+ import tempfile
8
+ from contextlib import suppress
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ from ._command import _call
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Callable
16
+
17
+ from fsspec.asyn import AsyncFileSystem
18
+ from fsspec.callbacks import Callback
19
+
20
+
21
+ async def copy_on_backend(
22
+ filesystem: AsyncFileSystem, source: str, destination: str, callback: Callable[[], Callback]
23
+ ) -> bool:
24
+ """Try the copy hook; only an explicitly unsupported operation permits staging."""
25
+ try:
26
+ parameters = inspect.signature(filesystem._cp_file).parameters
27
+ except (TypeError, ValueError):
28
+ parameters = {}
29
+ kwargs = {"callback": callback()} if "callback" in parameters else {}
30
+ try:
31
+ await _call(filesystem, "_cp_file", source, destination, **kwargs)
32
+ except NotImplementedError:
33
+ return False
34
+ return True
35
+
36
+
37
+ def create_staging(prefix: str) -> str:
38
+ """Create an owned staging file, cleaning up if descriptor handoff fails."""
39
+ descriptor, path = tempfile.mkstemp(prefix=prefix)
40
+ try:
41
+ os.close(descriptor)
42
+ except Exception:
43
+ remove_staging(path)
44
+ raise
45
+ except BaseException:
46
+ discard_staging(path)
47
+ raise
48
+ return path
49
+
50
+
51
+ def remove_staging(path: str) -> Exception | None:
52
+ """Return an ordinary cleanup failure without masking the transfer result."""
53
+ try:
54
+ Path(path).unlink()
55
+ except FileNotFoundError:
56
+ return None
57
+ except Exception as error: # noqa: BLE001 - caller owns cleanup diagnostics.
58
+ return error
59
+ return None
60
+
61
+
62
+ def discard_staging(path: str) -> None:
63
+ """Clean up while preserving the caller's active control-flow exception."""
64
+ with suppress(BaseException):
65
+ remove_staging(path)