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/_ls.py
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""Listing execution for the central ``ls`` callback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from functools import partial
|
|
10
|
+
from typing import TYPE_CHECKING, Generic, TypeAlias, TypeVar, cast
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from ._command import (
|
|
15
|
+
_collate,
|
|
16
|
+
_CommandFailureError,
|
|
17
|
+
_drain_current_operation,
|
|
18
|
+
_Failure,
|
|
19
|
+
_first_backend_error,
|
|
20
|
+
_MappedOperand,
|
|
21
|
+
_render_failure,
|
|
22
|
+
_render_output_failure,
|
|
23
|
+
_run_mapped_command,
|
|
24
|
+
)
|
|
25
|
+
from ._concurrent import _gather_bounded
|
|
26
|
+
from ._listing import ListingRow, emphasize, render_listing, to_listing
|
|
27
|
+
from ._metadata import valid_display_text
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from collections.abc import Awaitable, Callable
|
|
31
|
+
|
|
32
|
+
from fsspec.asyn import AsyncFileSystem
|
|
33
|
+
|
|
34
|
+
from ._app import AsyncFilesystemSource
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class _LsRequest:
|
|
39
|
+
include_almost_all: bool
|
|
40
|
+
long_listing: bool
|
|
41
|
+
human_readable: bool
|
|
42
|
+
operands: tuple[_MappedOperand, ...]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_PayloadT = TypeVar("_PayloadT")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class _FileResult(Generic[_PayloadT]):
|
|
50
|
+
operand: _MappedOperand
|
|
51
|
+
value: _PayloadT
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class _DirectoryResult(Generic[_PayloadT]):
|
|
56
|
+
operand: _MappedOperand
|
|
57
|
+
values: tuple[_PayloadT, ...]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class _PlainName:
|
|
62
|
+
text: str
|
|
63
|
+
directory: bool = False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_PlainResult: TypeAlias = _FileResult[_PlainName] | _DirectoryResult[_PlainName]
|
|
67
|
+
_LongResult: TypeAlias = _FileResult[ListingRow] | _DirectoryResult[ListingRow]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _emphasis_enabled() -> bool:
|
|
71
|
+
"""Decide once per invocation whether directory names may be bold."""
|
|
72
|
+
stdout = sys.stdout
|
|
73
|
+
try:
|
|
74
|
+
interactive = stdout is not None and stdout.isatty()
|
|
75
|
+
except (AttributeError, ValueError):
|
|
76
|
+
return False
|
|
77
|
+
return interactive and not os.environ.get("NO_COLOR") and os.environ.get("TERM") not in {"dumb", "unknown"}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def _run_ls(
|
|
81
|
+
command: str,
|
|
82
|
+
request: _LsRequest,
|
|
83
|
+
sources: Mapping[str, AsyncFilesystemSource],
|
|
84
|
+
) -> None:
|
|
85
|
+
emphasis = _emphasis_enabled()
|
|
86
|
+
|
|
87
|
+
async def execute(filesystems: Mapping[str, AsyncFileSystem]) -> None:
|
|
88
|
+
failures, format_output = await _trace_and_prepare(request, filesystems, emphasis=emphasis)
|
|
89
|
+
backend_error = _first_backend_error(failures)
|
|
90
|
+
output_error = None
|
|
91
|
+
try:
|
|
92
|
+
output = format_output()
|
|
93
|
+
for failure in failures:
|
|
94
|
+
_render_failure(command, failure)
|
|
95
|
+
if output:
|
|
96
|
+
try:
|
|
97
|
+
typer.echo(output, nl=False, color=True)
|
|
98
|
+
except BrokenPipeError as error:
|
|
99
|
+
output_error = error
|
|
100
|
+
except Exception as error: # noqa: BLE001 - output boundary.
|
|
101
|
+
output_error = error
|
|
102
|
+
_render_output_failure(command, error)
|
|
103
|
+
except Exception as error: # Preserve backend/output cause through cleanup.
|
|
104
|
+
command_error = backend_error if backend_error is not None else output_error
|
|
105
|
+
if command_error is None:
|
|
106
|
+
command_error = error
|
|
107
|
+
raise _CommandFailureError(
|
|
108
|
+
error=command_error,
|
|
109
|
+
render=False,
|
|
110
|
+
propagate=error,
|
|
111
|
+
) from error
|
|
112
|
+
if failures or output_error is not None:
|
|
113
|
+
raise _CommandFailureError(
|
|
114
|
+
error=backend_error if backend_error is not None else output_error,
|
|
115
|
+
render=False,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
await _run_mapped_command(
|
|
119
|
+
command,
|
|
120
|
+
request.operands,
|
|
121
|
+
sources,
|
|
122
|
+
execute,
|
|
123
|
+
broken_pipe_exit_code=1,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def _trace_and_prepare(
|
|
128
|
+
request: _LsRequest,
|
|
129
|
+
filesystems: Mapping[str, AsyncFileSystem],
|
|
130
|
+
*,
|
|
131
|
+
emphasis: bool,
|
|
132
|
+
) -> tuple[tuple[_Failure, ...], Callable[[], str]]:
|
|
133
|
+
# The reader/renderer pairing is fixed per mode; branching keeps each pair
|
|
134
|
+
# monomorphic while the deferred formatter preserves the caller's error
|
|
135
|
+
# flow (formatting still happens inside the caller's try block).
|
|
136
|
+
multiple_operands = len(request.operands) > 1
|
|
137
|
+
if request.long_listing:
|
|
138
|
+
|
|
139
|
+
def render_long(rows: tuple[ListingRow, ...]) -> str:
|
|
140
|
+
return render_listing(
|
|
141
|
+
rows,
|
|
142
|
+
human_readable=request.human_readable,
|
|
143
|
+
emphasize_directories=emphasis,
|
|
144
|
+
).removesuffix("\n")
|
|
145
|
+
|
|
146
|
+
long_successes, failures = await _trace_operands(request, filesystems, _read_long_operand)
|
|
147
|
+
return failures, partial(
|
|
148
|
+
_format_successes,
|
|
149
|
+
long_successes,
|
|
150
|
+
render_long,
|
|
151
|
+
multiple_operands=multiple_operands,
|
|
152
|
+
emphasis=emphasis,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def render_plain(names: tuple[_PlainName, ...]) -> str:
|
|
156
|
+
return "\n".join(emphasize(name.text) if emphasis and name.directory else name.text for name in names)
|
|
157
|
+
|
|
158
|
+
plain_successes, failures = await _trace_operands(request, filesystems, _read_plain_operand)
|
|
159
|
+
return failures, partial(
|
|
160
|
+
_format_successes,
|
|
161
|
+
plain_successes,
|
|
162
|
+
render_plain,
|
|
163
|
+
multiple_operands=multiple_operands,
|
|
164
|
+
emphasis=emphasis,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def _trace_operands(
|
|
169
|
+
request: _LsRequest,
|
|
170
|
+
filesystems: Mapping[str, AsyncFileSystem],
|
|
171
|
+
read: Callable[
|
|
172
|
+
...,
|
|
173
|
+
Awaitable[_FileResult[_PayloadT] | _DirectoryResult[_PayloadT] | _Failure],
|
|
174
|
+
],
|
|
175
|
+
) -> tuple[
|
|
176
|
+
tuple[_FileResult[_PayloadT] | _DirectoryResult[_PayloadT], ...],
|
|
177
|
+
tuple[_Failure, ...],
|
|
178
|
+
]:
|
|
179
|
+
# Operands are independent reads, so they overlap under the shared bound;
|
|
180
|
+
# results come back in operand order and nothing is written until all are
|
|
181
|
+
# in, so output and diagnostics are unchanged by completion order.
|
|
182
|
+
results = await _gather_bounded(
|
|
183
|
+
[
|
|
184
|
+
partial(
|
|
185
|
+
read,
|
|
186
|
+
operand,
|
|
187
|
+
filesystems[operand.name],
|
|
188
|
+
include_almost_all=request.include_almost_all,
|
|
189
|
+
)
|
|
190
|
+
for operand in request.operands
|
|
191
|
+
]
|
|
192
|
+
)
|
|
193
|
+
successes: list[_FileResult[_PayloadT] | _DirectoryResult[_PayloadT]] = []
|
|
194
|
+
failures = []
|
|
195
|
+
for result in results:
|
|
196
|
+
if isinstance(result, _Failure):
|
|
197
|
+
failures.append(result)
|
|
198
|
+
else:
|
|
199
|
+
successes.append(result)
|
|
200
|
+
return tuple(successes), tuple(failures)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
async def _classify_operand(
|
|
204
|
+
operand: _MappedOperand,
|
|
205
|
+
filesystem: AsyncFileSystem,
|
|
206
|
+
) -> Mapping[str, object] | _Failure:
|
|
207
|
+
# fsspec's native async API intentionally exposes underscore coroutines.
|
|
208
|
+
try:
|
|
209
|
+
info = await _drain_current_operation(filesystem._info(operand.path))
|
|
210
|
+
except Exception as error: # noqa: BLE001 - classify awaited backend failure.
|
|
211
|
+
return _Failure(operand, backend_error=error)
|
|
212
|
+
|
|
213
|
+
if not isinstance(info, Mapping):
|
|
214
|
+
return _Failure(operand)
|
|
215
|
+
kind = info.get("type")
|
|
216
|
+
if type(kind) is not str or (kind not in {"file", "directory"} and info.get("islink") is not True):
|
|
217
|
+
return _Failure(operand)
|
|
218
|
+
return cast("Mapping[str, object]", info)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _file_result_from_info(
|
|
222
|
+
operand: _MappedOperand,
|
|
223
|
+
info: Mapping[str, object],
|
|
224
|
+
*,
|
|
225
|
+
long_listing: bool,
|
|
226
|
+
) -> _PlainResult | _LongResult | _Failure:
|
|
227
|
+
if not long_listing:
|
|
228
|
+
return _FileResult(operand=operand, value=_PlainName(operand.spelling))
|
|
229
|
+
row = _listing_row(info)
|
|
230
|
+
if row is None or not row.name or "\0" in row.name or "\n" in row.name:
|
|
231
|
+
return _Failure(operand)
|
|
232
|
+
return _FileResult(operand=operand, value=row)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
async def _read_operand(
|
|
236
|
+
operand: _MappedOperand,
|
|
237
|
+
filesystem: AsyncFileSystem,
|
|
238
|
+
*,
|
|
239
|
+
include_almost_all: bool,
|
|
240
|
+
long_listing: bool,
|
|
241
|
+
) -> _PlainResult | _LongResult | _Failure:
|
|
242
|
+
"""Preserve operand identity before listing a directory's children."""
|
|
243
|
+
info = await _classify_operand(operand, filesystem)
|
|
244
|
+
if isinstance(info, _Failure):
|
|
245
|
+
return info
|
|
246
|
+
if info["type"] != "directory" or info.get("islink") is True:
|
|
247
|
+
return _file_result_from_info(operand, cast("Mapping[str, object]", info), long_listing=long_listing)
|
|
248
|
+
listing = await _list_directory(operand, filesystem, detail=True)
|
|
249
|
+
if isinstance(listing, list):
|
|
250
|
+
if long_listing:
|
|
251
|
+
rows = _directory_rows(operand.path, listing, include_almost_all=include_almost_all)
|
|
252
|
+
if rows is not None:
|
|
253
|
+
return _DirectoryResult(operand=operand, values=rows)
|
|
254
|
+
elif (children := _directory_lines(operand.path, listing, include_almost_all=include_almost_all)) is not None:
|
|
255
|
+
return _DirectoryResult(operand=operand, values=children)
|
|
256
|
+
|
|
257
|
+
if isinstance(listing, _Failure):
|
|
258
|
+
return listing
|
|
259
|
+
return _Failure(operand)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
async def _read_plain_operand(
|
|
263
|
+
operand: _MappedOperand,
|
|
264
|
+
filesystem: AsyncFileSystem,
|
|
265
|
+
*,
|
|
266
|
+
include_almost_all: bool,
|
|
267
|
+
) -> _PlainResult | _Failure:
|
|
268
|
+
result = await _read_operand(
|
|
269
|
+
operand,
|
|
270
|
+
filesystem,
|
|
271
|
+
include_almost_all=include_almost_all,
|
|
272
|
+
long_listing=False,
|
|
273
|
+
)
|
|
274
|
+
return cast("_PlainResult | _Failure", result)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
async def _read_long_operand(
|
|
278
|
+
operand: _MappedOperand,
|
|
279
|
+
filesystem: AsyncFileSystem,
|
|
280
|
+
*,
|
|
281
|
+
include_almost_all: bool,
|
|
282
|
+
) -> _LongResult | _Failure:
|
|
283
|
+
result = await _read_operand(
|
|
284
|
+
operand,
|
|
285
|
+
filesystem,
|
|
286
|
+
include_almost_all=include_almost_all,
|
|
287
|
+
long_listing=True,
|
|
288
|
+
)
|
|
289
|
+
return cast("_LongResult | _Failure", result)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _listing_row(info: Mapping[str, object]) -> ListingRow | None:
|
|
293
|
+
try:
|
|
294
|
+
return to_listing(info)
|
|
295
|
+
except (TypeError, ValueError):
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
async def _list_directory(
|
|
300
|
+
operand: _MappedOperand,
|
|
301
|
+
filesystem: AsyncFileSystem,
|
|
302
|
+
*,
|
|
303
|
+
detail: bool,
|
|
304
|
+
) -> object | _Failure:
|
|
305
|
+
try:
|
|
306
|
+
return await _drain_current_operation(
|
|
307
|
+
filesystem._ls(
|
|
308
|
+
operand.path,
|
|
309
|
+
detail=detail,
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
|
+
except Exception as error: # noqa: BLE001 - classify awaited backend failure.
|
|
313
|
+
return _Failure(operand, backend_error=error)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _sort_key(
|
|
317
|
+
result: _FileResult[_PayloadT] | _DirectoryResult[_PayloadT],
|
|
318
|
+
) -> tuple[str, str]:
|
|
319
|
+
return _collate(result.operand.spelling)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _format_successes(
|
|
323
|
+
successes: tuple[_FileResult[_PayloadT] | _DirectoryResult[_PayloadT], ...],
|
|
324
|
+
render: Callable[[tuple[_PayloadT, ...]], str],
|
|
325
|
+
*,
|
|
326
|
+
multiple_operands: bool,
|
|
327
|
+
emphasis: bool,
|
|
328
|
+
) -> str:
|
|
329
|
+
if not successes:
|
|
330
|
+
return ""
|
|
331
|
+
if not multiple_operands:
|
|
332
|
+
result = successes[0]
|
|
333
|
+
values = (result.value,) if isinstance(result, _FileResult) else result.values
|
|
334
|
+
rendered = render(values)
|
|
335
|
+
return f"{rendered}\n" if rendered else ""
|
|
336
|
+
|
|
337
|
+
files = sorted(
|
|
338
|
+
(result for result in successes if isinstance(result, _FileResult)),
|
|
339
|
+
key=_sort_key,
|
|
340
|
+
)
|
|
341
|
+
directories = sorted(
|
|
342
|
+
(result for result in successes if isinstance(result, _DirectoryResult)),
|
|
343
|
+
key=_sort_key,
|
|
344
|
+
)
|
|
345
|
+
blocks: list[str] = []
|
|
346
|
+
file_values = tuple(result.value for result in files)
|
|
347
|
+
if file_values:
|
|
348
|
+
blocks.append(render(file_values))
|
|
349
|
+
for result in directories:
|
|
350
|
+
rendered = render(result.values)
|
|
351
|
+
spelling = result.operand.spelling
|
|
352
|
+
header = f"{emphasize(spelling) if emphasis else spelling}:"
|
|
353
|
+
blocks.append(f"{header}\n{rendered}" if rendered else header)
|
|
354
|
+
return _join_blocks(blocks)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _join_blocks(blocks: list[str]) -> str:
|
|
358
|
+
return "\n\n".join(blocks) + "\n" if blocks else ""
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _directory_basename(path: str, name: object) -> str | None:
|
|
362
|
+
if not isinstance(name, str):
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
comparison_path = path.rstrip("/")
|
|
366
|
+
prefix = "/" if not comparison_path else f"{comparison_path}/"
|
|
367
|
+
if not name.startswith(prefix):
|
|
368
|
+
return None
|
|
369
|
+
basename = name[len(prefix) :]
|
|
370
|
+
if not basename or "/" in basename or not valid_display_text(basename):
|
|
371
|
+
return None
|
|
372
|
+
return basename
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _directory_lines(
|
|
376
|
+
path: str,
|
|
377
|
+
listing: object,
|
|
378
|
+
*,
|
|
379
|
+
include_almost_all: bool,
|
|
380
|
+
) -> tuple[_PlainName, ...] | None:
|
|
381
|
+
# Plain names print no metadata, so only name, type and islink are read;
|
|
382
|
+
# the rest of each record is deliberately left unvalidated.
|
|
383
|
+
if not isinstance(listing, list):
|
|
384
|
+
return None
|
|
385
|
+
|
|
386
|
+
names = []
|
|
387
|
+
for info in listing:
|
|
388
|
+
if not isinstance(info, Mapping):
|
|
389
|
+
return None
|
|
390
|
+
typed_info = cast("Mapping[str, object]", info)
|
|
391
|
+
basename = _directory_basename(path, typed_info.get("name"))
|
|
392
|
+
if basename is None:
|
|
393
|
+
return None
|
|
394
|
+
kind = typed_info.get("type")
|
|
395
|
+
directory = type(kind) is str and kind in {"directory", "dir"} and typed_info.get("islink") is not True
|
|
396
|
+
names.append(_PlainName(basename, directory=directory))
|
|
397
|
+
|
|
398
|
+
if include_almost_all:
|
|
399
|
+
selected = (name for name in names if name.text not in {".", ".."})
|
|
400
|
+
else:
|
|
401
|
+
selected = (name for name in names if not name.text.startswith("."))
|
|
402
|
+
return tuple(sorted(selected, key=lambda name: _collate(name.text)))
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _directory_rows(
|
|
406
|
+
path: str,
|
|
407
|
+
listing: object,
|
|
408
|
+
*,
|
|
409
|
+
include_almost_all: bool,
|
|
410
|
+
) -> tuple[ListingRow, ...] | None:
|
|
411
|
+
if not isinstance(listing, list):
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
entries: list[tuple[str, Mapping[str, object]]] = []
|
|
415
|
+
for info in listing:
|
|
416
|
+
if not isinstance(info, Mapping):
|
|
417
|
+
return None
|
|
418
|
+
typed_info = cast("Mapping[str, object]", info)
|
|
419
|
+
basename = _directory_basename(path, typed_info.get("name"))
|
|
420
|
+
if basename is None:
|
|
421
|
+
return None
|
|
422
|
+
entries.append((basename, typed_info))
|
|
423
|
+
|
|
424
|
+
if include_almost_all:
|
|
425
|
+
selected = (entry for entry in entries if entry[0] not in {".", ".."})
|
|
426
|
+
else:
|
|
427
|
+
selected = (entry for entry in entries if not entry[0].startswith("."))
|
|
428
|
+
sorted_entries = sorted(selected, key=lambda entry: _collate(entry[0]))
|
|
429
|
+
rows = []
|
|
430
|
+
for _basename, info in sorted_entries:
|
|
431
|
+
row = _listing_row(info)
|
|
432
|
+
if row is None:
|
|
433
|
+
return None
|
|
434
|
+
rows.append(row)
|
|
435
|
+
return tuple(rows)
|
fss_cli/_manifest.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Frozen, validated manifests built from a backend directory walk.
|
|
2
|
+
|
|
3
|
+
Recursive copy uses this module to validate and freeze a transfer plan before
|
|
4
|
+
writing any destination. Per
|
|
5
|
+
:doc:`ADR 0006 <../../../../docs/adr/0006-treat-backend-results-as-untrusted-input>`,
|
|
6
|
+
backend listings are untrusted. Invalid paths and duplicate children must fail
|
|
7
|
+
before copy can overwrite files at those paths.
|
|
8
|
+
|
|
9
|
+
The command reports incompatible results, unsupported entry types, and trees
|
|
10
|
+
above the entry limit separately.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import TYPE_CHECKING, TypeAlias, cast
|
|
18
|
+
|
|
19
|
+
from ._metadata import snapshot_mapping
|
|
20
|
+
from ._path import (
|
|
21
|
+
_has_dot_segment,
|
|
22
|
+
_lexical_join,
|
|
23
|
+
_lexical_relative,
|
|
24
|
+
_same_lexical_path,
|
|
25
|
+
)
|
|
26
|
+
from ._walk import _IncompatibleListingError, _ListedRow, _ListingLimitError, _walk
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from fsspec.asyn import AsyncFileSystem
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_MAX_ENTRIES = 10_000
|
|
33
|
+
_WALK_ROW_LENGTH = 3
|
|
34
|
+
_TOKEN_ALIASES = (
|
|
35
|
+
("etag", ("ETag", "etag")),
|
|
36
|
+
("md5", ("md5",)),
|
|
37
|
+
("content-md5", ("content-md5", "content_md5")),
|
|
38
|
+
("checksum", ("checksum",)),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _IncompatibleResultError(Exception):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _UnsupportedEntryError(Exception):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _EntryLimitError(Exception):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class _ManifestEntry:
|
|
56
|
+
relative: str
|
|
57
|
+
path: str
|
|
58
|
+
kind: str
|
|
59
|
+
size: int | None
|
|
60
|
+
tokens: tuple[tuple[str, str | bytes], ...]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_Manifest: TypeAlias = tuple[_ManifestEntry, ...]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class _WalkRow:
|
|
68
|
+
root: str
|
|
69
|
+
entries: tuple[_ManifestEntry, ...]
|
|
70
|
+
directory_paths: tuple[str, ...]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _shared_tokens_match(
|
|
74
|
+
source_tokens: tuple[tuple[str, object], ...],
|
|
75
|
+
destination_tokens: Mapping[str, object],
|
|
76
|
+
) -> bool:
|
|
77
|
+
"""Check that every verification token present on both sides agrees."""
|
|
78
|
+
return all(destination_tokens[name] == value for name, value in source_tokens if name in destination_tokens)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _tokens(info: Mapping[object, object]) -> tuple[tuple[str, str | bytes], ...]:
|
|
82
|
+
tokens: list[tuple[str, str | bytes]] = []
|
|
83
|
+
for normalized, aliases in _TOKEN_ALIASES:
|
|
84
|
+
present = [alias for alias in aliases if alias in info]
|
|
85
|
+
if len(present) > 1:
|
|
86
|
+
raise _IncompatibleResultError
|
|
87
|
+
if present:
|
|
88
|
+
value = info[present[0]]
|
|
89
|
+
if type(value) is not str and type(value) is not bytes:
|
|
90
|
+
raise _IncompatibleResultError
|
|
91
|
+
tokens.append((normalized, value))
|
|
92
|
+
return tuple(tokens)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _entry(
|
|
96
|
+
relative: str,
|
|
97
|
+
path: str,
|
|
98
|
+
info: object,
|
|
99
|
+
*,
|
|
100
|
+
expected_kind: str | None = None,
|
|
101
|
+
) -> _ManifestEntry:
|
|
102
|
+
if not isinstance(info, Mapping):
|
|
103
|
+
raise _IncompatibleResultError
|
|
104
|
+
name = info.get("name")
|
|
105
|
+
if not isinstance(name, str) or not _same_lexical_path(name, path):
|
|
106
|
+
raise _IncompatibleResultError
|
|
107
|
+
try:
|
|
108
|
+
typed_info = snapshot_mapping(cast("Mapping[object, object]", info))
|
|
109
|
+
except Exception as error:
|
|
110
|
+
raise _IncompatibleResultError from error
|
|
111
|
+
islink = typed_info.get("islink", False)
|
|
112
|
+
if type(islink) is not bool:
|
|
113
|
+
raise _IncompatibleResultError
|
|
114
|
+
kind = typed_info.get("type")
|
|
115
|
+
if not isinstance(kind, str):
|
|
116
|
+
raise _IncompatibleResultError
|
|
117
|
+
if islink or kind not in {"directory", "file"}:
|
|
118
|
+
raise _UnsupportedEntryError
|
|
119
|
+
if expected_kind is not None and kind != expected_kind:
|
|
120
|
+
raise _IncompatibleResultError
|
|
121
|
+
size = None
|
|
122
|
+
if kind == "file":
|
|
123
|
+
size = typed_info.get("size")
|
|
124
|
+
if type(size) is not int or size < 0:
|
|
125
|
+
raise _IncompatibleResultError
|
|
126
|
+
return _ManifestEntry(relative, path, kind, size, _tokens(typed_info))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _walk_row(
|
|
130
|
+
source_path: str,
|
|
131
|
+
value: object,
|
|
132
|
+
*,
|
|
133
|
+
entry_capacity: int,
|
|
134
|
+
) -> _WalkRow:
|
|
135
|
+
if type(value) is not tuple or len(value) != _WALK_ROW_LENGTH:
|
|
136
|
+
raise _IncompatibleResultError
|
|
137
|
+
root, directories, files = value
|
|
138
|
+
if (
|
|
139
|
+
type(root) is not str
|
|
140
|
+
or not root.startswith("/")
|
|
141
|
+
or "\0" in root
|
|
142
|
+
or "\n" in root
|
|
143
|
+
or "\r" in root
|
|
144
|
+
or _has_dot_segment(root)
|
|
145
|
+
or not isinstance(directories, Mapping)
|
|
146
|
+
or not isinstance(files, Mapping)
|
|
147
|
+
):
|
|
148
|
+
raise _IncompatibleResultError
|
|
149
|
+
|
|
150
|
+
entries: list[_ManifestEntry] = []
|
|
151
|
+
directory_paths: list[str] = []
|
|
152
|
+
child_names: set[str] = set()
|
|
153
|
+
for collection, kind in ((directories, "directory"), (files, "file")):
|
|
154
|
+
if len(collection) > entry_capacity - len(entries):
|
|
155
|
+
raise _EntryLimitError
|
|
156
|
+
try:
|
|
157
|
+
children = snapshot_mapping(collection)
|
|
158
|
+
except Exception as error:
|
|
159
|
+
raise _IncompatibleResultError from error
|
|
160
|
+
for name, info in children.items():
|
|
161
|
+
if (
|
|
162
|
+
type(name) is not str
|
|
163
|
+
or not name
|
|
164
|
+
or name in {".", ".."}
|
|
165
|
+
or "/" in name
|
|
166
|
+
or "\0" in name
|
|
167
|
+
or "\n" in name
|
|
168
|
+
or "\r" in name
|
|
169
|
+
or name in child_names
|
|
170
|
+
):
|
|
171
|
+
raise _IncompatibleResultError
|
|
172
|
+
child_names.add(name)
|
|
173
|
+
path = _lexical_join(root, name)
|
|
174
|
+
entry = _entry(
|
|
175
|
+
_relative_path(source_path, path),
|
|
176
|
+
path,
|
|
177
|
+
info,
|
|
178
|
+
expected_kind=kind,
|
|
179
|
+
)
|
|
180
|
+
entries.append(entry)
|
|
181
|
+
if len(entries) > entry_capacity:
|
|
182
|
+
raise _EntryLimitError
|
|
183
|
+
if kind == "directory":
|
|
184
|
+
directory_paths.append(path)
|
|
185
|
+
return _WalkRow(root, tuple(entries), tuple(directory_paths))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class _ManifestBuilder:
|
|
189
|
+
"""Accept each frozen row once and retain only the final entry index."""
|
|
190
|
+
|
|
191
|
+
def __init__(self, root: _ManifestEntry) -> None:
|
|
192
|
+
self.entries = {"": root}
|
|
193
|
+
self.roots: set[str] = set()
|
|
194
|
+
self.expected_roots = {root.path}
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def capacity(self) -> int:
|
|
198
|
+
return _MAX_ENTRIES - len(self.entries)
|
|
199
|
+
|
|
200
|
+
def accept(self, row: _WalkRow) -> None:
|
|
201
|
+
if (
|
|
202
|
+
row.root in self.roots
|
|
203
|
+
or row.root not in self.expected_roots
|
|
204
|
+
or any(entry.relative in self.entries for entry in row.entries)
|
|
205
|
+
):
|
|
206
|
+
raise _IncompatibleResultError
|
|
207
|
+
self.roots.add(row.root)
|
|
208
|
+
self.expected_roots.update(row.directory_paths)
|
|
209
|
+
self.entries.update((entry.relative, entry) for entry in row.entries)
|
|
210
|
+
|
|
211
|
+
def finish(self) -> _Manifest:
|
|
212
|
+
if self.roots != self.expected_roots:
|
|
213
|
+
raise _IncompatibleResultError
|
|
214
|
+
return tuple(sorted(self.entries.values(), key=lambda item: item.relative))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
async def _walk_rows(
|
|
218
|
+
filesystem: AsyncFileSystem,
|
|
219
|
+
root: _ManifestEntry,
|
|
220
|
+
) -> _Manifest:
|
|
221
|
+
builder = _ManifestBuilder(root)
|
|
222
|
+
|
|
223
|
+
def accept(row: _ListedRow) -> None:
|
|
224
|
+
builder.accept(
|
|
225
|
+
_walk_row(
|
|
226
|
+
root.path,
|
|
227
|
+
(row.root, row.directories, row.files),
|
|
228
|
+
entry_capacity=builder.capacity,
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
await _walk(
|
|
234
|
+
filesystem,
|
|
235
|
+
root.path,
|
|
236
|
+
accept=accept,
|
|
237
|
+
capacity=lambda: builder.capacity,
|
|
238
|
+
)
|
|
239
|
+
except _IncompatibleListingError as error:
|
|
240
|
+
raise _IncompatibleResultError from error
|
|
241
|
+
except _ListingLimitError as error:
|
|
242
|
+
raise _EntryLimitError from error
|
|
243
|
+
return builder.finish()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _relative_path(root: str, path: str) -> str:
|
|
247
|
+
relative = _lexical_relative(root, path)
|
|
248
|
+
if relative is None:
|
|
249
|
+
raise _IncompatibleResultError
|
|
250
|
+
return relative
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
async def _manifest(
|
|
254
|
+
filesystem: AsyncFileSystem,
|
|
255
|
+
path: str,
|
|
256
|
+
source_info: object,
|
|
257
|
+
) -> _Manifest:
|
|
258
|
+
if not isinstance(source_info, Mapping):
|
|
259
|
+
raise _IncompatibleResultError
|
|
260
|
+
reported_path = source_info.get("name")
|
|
261
|
+
if type(reported_path) is not str or not _same_lexical_path(reported_path, path):
|
|
262
|
+
raise _IncompatibleResultError
|
|
263
|
+
root_entry = _entry("", path, source_info, expected_kind="directory")
|
|
264
|
+
return await _walk_rows(filesystem, root_entry)
|