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/_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)
|