fss-cli 0.11.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fss_cli/__init__.py +24 -0
- fss_cli/_accounts.py +29 -0
- fss_cli/_app.py +663 -0
- fss_cli/_cat.py +391 -0
- fss_cli/_command.py +368 -0
- fss_cli/_concurrent.py +181 -0
- fss_cli/_content.py +94 -0
- fss_cli/_copy_file.py +65 -0
- fss_cli/_cp.py +740 -0
- fss_cli/_diagnostics.py +12 -0
- fss_cli/_du.py +90 -0
- fss_cli/_find.py +109 -0
- fss_cli/_head_tail.py +128 -0
- fss_cli/_info.py +179 -0
- fss_cli/_listing.py +289 -0
- fss_cli/_logging.py +72 -0
- fss_cli/_ls.py +435 -0
- fss_cli/_manifest.py +264 -0
- fss_cli/_metadata.py +70 -0
- fss_cli/_mkdir.py +97 -0
- fss_cli/_mv.py +146 -0
- fss_cli/_path.py +62 -0
- fss_cli/_post_copy.py +191 -0
- fss_cli/_progress.py +132 -0
- fss_cli/_recursive_cp.py +908 -0
- fss_cli/_recursive_rm.py +335 -0
- fss_cli/_results.py +115 -0
- fss_cli/_rm.py +149 -0
- fss_cli/_rmdir.py +148 -0
- fss_cli/_size.py +95 -0
- fss_cli/_sources.py +178 -0
- fss_cli/_stat.py +196 -0
- fss_cli/_test.py +59 -0
- fss_cli/_tree.py +208 -0
- fss_cli/_unlink.py +90 -0
- fss_cli/_walk.py +141 -0
- fss_cli-0.11.0.dist-info/METADATA +144 -0
- fss_cli-0.11.0.dist-info/RECORD +40 -0
- fss_cli-0.11.0.dist-info/WHEEL +4 -0
- fss_cli-0.11.0.dist-info/licenses/LICENSE +661 -0
fss_cli/_recursive_rm.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Capability-gated, manifest-verified recursive ``rm``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from functools import partial
|
|
8
|
+
from typing import TYPE_CHECKING, cast
|
|
9
|
+
|
|
10
|
+
from ._command import (
|
|
11
|
+
_backend_category,
|
|
12
|
+
_call,
|
|
13
|
+
_render_operand_diagnostic,
|
|
14
|
+
)
|
|
15
|
+
from ._concurrent import _gather_bounded, _outcome_value, _run_bounded
|
|
16
|
+
from ._metadata import snapshot_mapping
|
|
17
|
+
from ._path import (
|
|
18
|
+
_has_dot_segment,
|
|
19
|
+
_is_same_or_descendant,
|
|
20
|
+
_lexical_basename,
|
|
21
|
+
_lexical_join,
|
|
22
|
+
_lexical_parent,
|
|
23
|
+
_same_lexical_path,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from fsspec.asyn import AsyncFileSystem
|
|
28
|
+
|
|
29
|
+
from ._command import _MappedOperand
|
|
30
|
+
|
|
31
|
+
_REQUIRED_HOOKS = ("_info", "_ls", "_rm_file", "_rmdir")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _IncompatibleManifestError(Exception):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _UnsupportedEntryError(Exception):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class _ManifestEntry:
|
|
44
|
+
path: str
|
|
45
|
+
kind: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class _Manifest:
|
|
50
|
+
root: str
|
|
51
|
+
entries: tuple[_ManifestEntry, ...]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class _RecursiveRmFailure:
|
|
56
|
+
operand: _MappedOperand
|
|
57
|
+
category: str
|
|
58
|
+
backend_error: Exception | None = None
|
|
59
|
+
root_missing: bool = False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _has_required_hooks(filesystem: AsyncFileSystem) -> bool:
|
|
63
|
+
return all(callable(getattr(filesystem, name, None)) for name in _REQUIRED_HOOKS)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _freeze_mapping(value: object) -> Mapping[object, object]:
|
|
67
|
+
if not isinstance(value, Mapping):
|
|
68
|
+
raise _IncompatibleManifestError
|
|
69
|
+
try:
|
|
70
|
+
return cast("Mapping[object, object]", snapshot_mapping(value))
|
|
71
|
+
except Exception as error:
|
|
72
|
+
raise _IncompatibleManifestError from error
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _entry_fields(value: object) -> tuple[str, str]:
|
|
76
|
+
info = _freeze_mapping(value)
|
|
77
|
+
name = info.get("name")
|
|
78
|
+
if type(name) is not str:
|
|
79
|
+
raise _IncompatibleManifestError
|
|
80
|
+
islink = info.get("islink", False)
|
|
81
|
+
if type(islink) is not bool:
|
|
82
|
+
raise _IncompatibleManifestError
|
|
83
|
+
if islink:
|
|
84
|
+
raise _UnsupportedEntryError
|
|
85
|
+
kind = info.get("type")
|
|
86
|
+
if type(kind) is not str:
|
|
87
|
+
raise _IncompatibleManifestError
|
|
88
|
+
return name, kind
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _root_entry(path: str, value: object) -> _ManifestEntry:
|
|
92
|
+
name, kind = _entry_fields(value)
|
|
93
|
+
if not _same_lexical_path(name, path):
|
|
94
|
+
raise _IncompatibleManifestError
|
|
95
|
+
if kind != "directory":
|
|
96
|
+
raise NotADirectoryError(path)
|
|
97
|
+
return _ManifestEntry(path, kind)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _listed_entry(parent: str, root: str, value: object) -> _ManifestEntry:
|
|
101
|
+
name, kind = _entry_fields(value)
|
|
102
|
+
if kind not in {"file", "directory"}:
|
|
103
|
+
raise _UnsupportedEntryError
|
|
104
|
+
if (
|
|
105
|
+
not name
|
|
106
|
+
or name.rstrip("/") != name
|
|
107
|
+
or "\0" in name
|
|
108
|
+
or "\n" in name
|
|
109
|
+
or "\r" in name
|
|
110
|
+
or _has_dot_segment(name)
|
|
111
|
+
or not _is_same_or_descendant(root, name)
|
|
112
|
+
or not _same_lexical_path(_lexical_parent(name), parent)
|
|
113
|
+
):
|
|
114
|
+
raise _IncompatibleManifestError
|
|
115
|
+
return _ManifestEntry(_lexical_join(parent, _lexical_basename(name)), kind)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _frozen_listing(
|
|
119
|
+
directory: str,
|
|
120
|
+
root: str,
|
|
121
|
+
result: object,
|
|
122
|
+
seen: set[str],
|
|
123
|
+
) -> list[_ManifestEntry]:
|
|
124
|
+
"""Validate one directory's ``_ls(detail=True)`` result into child entries."""
|
|
125
|
+
if not isinstance(result, list):
|
|
126
|
+
raise _IncompatibleManifestError
|
|
127
|
+
frozen: list[_ManifestEntry] = []
|
|
128
|
+
expected_length = len(result)
|
|
129
|
+
try:
|
|
130
|
+
for value in result:
|
|
131
|
+
entry = _listed_entry(directory, root, value)
|
|
132
|
+
if entry.path in seen:
|
|
133
|
+
raise _IncompatibleManifestError # noqa: TRY301
|
|
134
|
+
seen.add(entry.path)
|
|
135
|
+
frozen.append(entry)
|
|
136
|
+
except (_IncompatibleManifestError, _UnsupportedEntryError):
|
|
137
|
+
raise
|
|
138
|
+
except Exception as error:
|
|
139
|
+
raise _IncompatibleManifestError from error
|
|
140
|
+
if len(result) != expected_length or len(frozen) != expected_length:
|
|
141
|
+
raise _IncompatibleManifestError
|
|
142
|
+
return frozen
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def _manifest(
|
|
146
|
+
filesystem: AsyncFileSystem,
|
|
147
|
+
root: str,
|
|
148
|
+
root_info: object,
|
|
149
|
+
) -> _Manifest:
|
|
150
|
+
"""List the tree breadth-first, then freeze it in leaves-first order.
|
|
151
|
+
|
|
152
|
+
Every directory of one depth is listed concurrently under the shared
|
|
153
|
+
bound; a listing failure at any depth fails the plan. Entries are emitted
|
|
154
|
+
depth-first in path order with each directory after its children.
|
|
155
|
+
"""
|
|
156
|
+
root_entry = _root_entry(root, root_info)
|
|
157
|
+
seen = {root}
|
|
158
|
+
children: dict[str, list[_ManifestEntry]] = {}
|
|
159
|
+
level = [root_entry]
|
|
160
|
+
while level:
|
|
161
|
+
listings = await _gather_bounded(
|
|
162
|
+
[partial(_call, filesystem, "_ls", directory.path, detail=True) for directory in level]
|
|
163
|
+
)
|
|
164
|
+
next_level: list[_ManifestEntry] = []
|
|
165
|
+
for directory, result in zip(level, listings, strict=True):
|
|
166
|
+
frozen = _frozen_listing(directory.path, root, result, seen)
|
|
167
|
+
children[directory.path] = frozen
|
|
168
|
+
next_level.extend(entry for entry in frozen if entry.kind == "directory")
|
|
169
|
+
level = next_level
|
|
170
|
+
|
|
171
|
+
entries: list[_ManifestEntry] = []
|
|
172
|
+
stack = [(root_entry, False)]
|
|
173
|
+
while stack:
|
|
174
|
+
candidate, visited = stack.pop()
|
|
175
|
+
if candidate.kind == "file" or visited:
|
|
176
|
+
entries.append(candidate)
|
|
177
|
+
continue
|
|
178
|
+
stack.append((candidate, True))
|
|
179
|
+
stack.extend(
|
|
180
|
+
(entry, False)
|
|
181
|
+
for entry in sorted(
|
|
182
|
+
children[candidate.path],
|
|
183
|
+
key=lambda item: item.path,
|
|
184
|
+
reverse=True,
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
manifest = _Manifest(root, tuple(entries))
|
|
189
|
+
_revalidate_manifest(manifest)
|
|
190
|
+
return manifest
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _revalidate_manifest(manifest: _Manifest) -> None:
|
|
194
|
+
if not manifest.entries or manifest.entries[-1] != _ManifestEntry(manifest.root, "directory"):
|
|
195
|
+
raise _IncompatibleManifestError
|
|
196
|
+
seen: set[str] = set()
|
|
197
|
+
directories = {manifest.root}
|
|
198
|
+
for entry in manifest.entries:
|
|
199
|
+
if entry.path in seen or not _is_same_or_descendant(manifest.root, entry.path):
|
|
200
|
+
raise _IncompatibleManifestError
|
|
201
|
+
seen.add(entry.path)
|
|
202
|
+
if entry.kind == "directory":
|
|
203
|
+
directories.add(entry.path)
|
|
204
|
+
for entry in manifest.entries[:-1]:
|
|
205
|
+
if _lexical_parent(entry.path) not in directories:
|
|
206
|
+
raise _IncompatibleManifestError
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _read_failure(
|
|
210
|
+
operand: _MappedOperand,
|
|
211
|
+
error: Exception,
|
|
212
|
+
*,
|
|
213
|
+
root_missing: bool = False,
|
|
214
|
+
) -> _RecursiveRmFailure:
|
|
215
|
+
return _RecursiveRmFailure(
|
|
216
|
+
operand,
|
|
217
|
+
_backend_category(error),
|
|
218
|
+
backend_error=error,
|
|
219
|
+
root_missing=root_missing,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
async def _plan(
|
|
224
|
+
operand: _MappedOperand,
|
|
225
|
+
filesystem: AsyncFileSystem,
|
|
226
|
+
) -> _Manifest | _RecursiveRmFailure:
|
|
227
|
+
if not _has_required_hooks(filesystem):
|
|
228
|
+
return _RecursiveRmFailure(operand, "unsupported operation")
|
|
229
|
+
root = operand.path.rstrip("/")
|
|
230
|
+
try:
|
|
231
|
+
root_info = await _call(filesystem, "_info", root)
|
|
232
|
+
except Exception as error: # noqa: BLE001 - classify read boundary.
|
|
233
|
+
return _read_failure(
|
|
234
|
+
operand,
|
|
235
|
+
error,
|
|
236
|
+
root_missing=isinstance(error, FileNotFoundError),
|
|
237
|
+
)
|
|
238
|
+
try:
|
|
239
|
+
return await _manifest(filesystem, root, root_info)
|
|
240
|
+
except _UnsupportedEntryError:
|
|
241
|
+
return _RecursiveRmFailure(operand, "unsupported operation")
|
|
242
|
+
except _IncompatibleManifestError:
|
|
243
|
+
return _RecursiveRmFailure(operand, "incompatible result")
|
|
244
|
+
except Exception as error: # noqa: BLE001 - classify planning boundary.
|
|
245
|
+
return _read_failure(operand, error)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
async def _remove_entry(
|
|
249
|
+
operand: _MappedOperand,
|
|
250
|
+
filesystem: AsyncFileSystem,
|
|
251
|
+
manifest: _Manifest,
|
|
252
|
+
entry: _ManifestEntry,
|
|
253
|
+
) -> _RecursiveRmFailure | None:
|
|
254
|
+
"""Remove one entry, then prove its absence."""
|
|
255
|
+
if not _is_same_or_descendant(manifest.root, entry.path):
|
|
256
|
+
return _RecursiveRmFailure(operand, "incompatible result")
|
|
257
|
+
operation = "_rm_file" if entry.kind == "file" else "_rmdir"
|
|
258
|
+
try:
|
|
259
|
+
await _call(filesystem, operation, entry.path)
|
|
260
|
+
except Exception as error: # noqa: BLE001 - mutation may be partial.
|
|
261
|
+
return _RecursiveRmFailure(
|
|
262
|
+
operand,
|
|
263
|
+
"recursive removal incomplete; residue possible",
|
|
264
|
+
backend_error=error,
|
|
265
|
+
)
|
|
266
|
+
try:
|
|
267
|
+
await _call(filesystem, "_info", entry.path)
|
|
268
|
+
except FileNotFoundError:
|
|
269
|
+
return None
|
|
270
|
+
except Exception as error: # noqa: BLE001 - absence remains uncertain.
|
|
271
|
+
return _RecursiveRmFailure(
|
|
272
|
+
operand,
|
|
273
|
+
"recursive removal incomplete; residue possible",
|
|
274
|
+
backend_error=error,
|
|
275
|
+
)
|
|
276
|
+
return _RecursiveRmFailure(
|
|
277
|
+
operand,
|
|
278
|
+
"recursive removal incomplete; residue possible",
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _depth(manifest: _Manifest, entry: _ManifestEntry) -> int:
|
|
283
|
+
return entry.path[len(manifest.root) :].count("/")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
async def _mutate(
|
|
287
|
+
operand: _MappedOperand,
|
|
288
|
+
filesystem: AsyncFileSystem,
|
|
289
|
+
manifest: _Manifest,
|
|
290
|
+
) -> _RecursiveRmFailure | None:
|
|
291
|
+
"""Remove the manifest leaves-first, one depth at a time.
|
|
292
|
+
|
|
293
|
+
Every entry of the deepest remaining depth is removed and proven absent
|
|
294
|
+
concurrently under the shared bound, so a directory is removed only after
|
|
295
|
+
all of its children are confirmed gone. After the first failure no new
|
|
296
|
+
removal starts; in-flight ones finish and the first failure in manifest
|
|
297
|
+
order is reported.
|
|
298
|
+
"""
|
|
299
|
+
try:
|
|
300
|
+
_revalidate_manifest(manifest)
|
|
301
|
+
except _IncompatibleManifestError:
|
|
302
|
+
return _RecursiveRmFailure(operand, "incompatible result")
|
|
303
|
+
|
|
304
|
+
levels: dict[int, list[_ManifestEntry]] = {}
|
|
305
|
+
for entry in manifest.entries:
|
|
306
|
+
levels.setdefault(_depth(manifest, entry), []).append(entry)
|
|
307
|
+
for depth in sorted(levels, reverse=True):
|
|
308
|
+
outcomes = await _run_bounded(
|
|
309
|
+
[partial(_remove_entry, operand, filesystem, manifest, entry) for entry in levels[depth]],
|
|
310
|
+
stop=lambda failure: failure is not None,
|
|
311
|
+
)
|
|
312
|
+
for outcome in outcomes:
|
|
313
|
+
if outcome is None:
|
|
314
|
+
continue
|
|
315
|
+
failure = _outcome_value(outcome)
|
|
316
|
+
if failure is not None:
|
|
317
|
+
return failure
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
async def _remove_recursive(
|
|
322
|
+
operand: _MappedOperand,
|
|
323
|
+
filesystem: AsyncFileSystem,
|
|
324
|
+
) -> _RecursiveRmFailure | None:
|
|
325
|
+
plan = await _plan(operand, filesystem)
|
|
326
|
+
if isinstance(plan, _RecursiveRmFailure):
|
|
327
|
+
return plan
|
|
328
|
+
return await _mutate(operand, filesystem, plan)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _render_recursive_failure(
|
|
332
|
+
command: str,
|
|
333
|
+
failure: _RecursiveRmFailure,
|
|
334
|
+
) -> None:
|
|
335
|
+
_render_operand_diagnostic(command, failure.operand, failure.category)
|
fss_cli/_results.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Validate untrusted fsspec results once, at the hook boundary.
|
|
2
|
+
|
|
3
|
+
Integers stay exact (``bool`` is not an integer). ``str``, ``list`` and
|
|
4
|
+
``Mapping`` accept subclasses, because backends may return specialised
|
|
5
|
+
strings and lists. A mapping is snapshotted once.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from functools import partial
|
|
12
|
+
from typing import TYPE_CHECKING, cast
|
|
13
|
+
|
|
14
|
+
from ._command import _call
|
|
15
|
+
from ._concurrent import _gather_bounded
|
|
16
|
+
from ._metadata import snapshot_mapping, valid_display_text
|
|
17
|
+
from ._path import _has_dot_segment, _lexical_components, _lexical_parent, _same_lexical_path
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from fsspec.asyn import AsyncFileSystem
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _BadResultError(Exception):
|
|
24
|
+
"""A hook result does not have the shape the command can use."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def snapshot_fields(value: object) -> tuple[Mapping[str, object], str, str]:
|
|
28
|
+
"""Snapshot one metadata mapping and return it with its name and type.
|
|
29
|
+
|
|
30
|
+
``str`` subclasses are accepted for both fields. The size is not checked.
|
|
31
|
+
"""
|
|
32
|
+
if not isinstance(value, Mapping):
|
|
33
|
+
raise _BadResultError
|
|
34
|
+
try:
|
|
35
|
+
raw = snapshot_mapping(cast("Mapping[object, object]", value))
|
|
36
|
+
except Exception as error:
|
|
37
|
+
raise _BadResultError from error
|
|
38
|
+
name = raw.get("name")
|
|
39
|
+
kind = raw.get("type")
|
|
40
|
+
if not isinstance(name, str) or not isinstance(kind, str):
|
|
41
|
+
raise _BadResultError
|
|
42
|
+
return cast("Mapping[str, object]", raw), name, kind
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def snapshot_listing(root: str, listing: object) -> dict[tuple[str, ...], Mapping[str, object]]:
|
|
46
|
+
"""Index a complete detail listing, refusing invalid or duplicated paths."""
|
|
47
|
+
if not isinstance(listing, list):
|
|
48
|
+
raise _BadResultError
|
|
49
|
+
expected = len(listing)
|
|
50
|
+
indexed: dict[tuple[str, ...], Mapping[str, object]] = {}
|
|
51
|
+
for value in listing:
|
|
52
|
+
info, name, _kind = snapshot_fields(value)
|
|
53
|
+
path = name.rstrip("/")
|
|
54
|
+
identity = _lexical_components(path)
|
|
55
|
+
if (
|
|
56
|
+
not name
|
|
57
|
+
or not valid_display_text(name)
|
|
58
|
+
or _has_dot_segment(name)
|
|
59
|
+
or (not _same_lexical_path(path, root) and not _same_lexical_path(_lexical_parent(path), root))
|
|
60
|
+
or identity in indexed
|
|
61
|
+
):
|
|
62
|
+
raise _BadResultError
|
|
63
|
+
indexed[identity] = info
|
|
64
|
+
if len(listing) != expected or len(indexed) != expected:
|
|
65
|
+
raise _BadResultError
|
|
66
|
+
return indexed
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def _info(filesystem: AsyncFileSystem, path: str, *, missing_ok: bool) -> object | None:
|
|
70
|
+
try:
|
|
71
|
+
return await _call(filesystem, "_info", path)
|
|
72
|
+
except FileNotFoundError:
|
|
73
|
+
if not missing_ok:
|
|
74
|
+
raise
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def info_or_missing(filesystem: AsyncFileSystem, path: str) -> tuple[object | None, Exception | None]:
|
|
79
|
+
"""Read optional metadata while preserving the command's error classification."""
|
|
80
|
+
try:
|
|
81
|
+
return await _info(filesystem, path, missing_ok=True), None
|
|
82
|
+
except Exception as error: # noqa: BLE001 - metadata acquisition boundary.
|
|
83
|
+
return None, error
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def infos_for_paths(
|
|
87
|
+
filesystem: AsyncFileSystem, paths: list[str], *, missing_ok: bool
|
|
88
|
+
) -> tuple[dict[str, object | None], Exception | None]:
|
|
89
|
+
"""Use validated parent listings, then bounded direct lookup for every gap."""
|
|
90
|
+
parents = list(dict.fromkeys(_lexical_parent(path) for path in paths))
|
|
91
|
+
|
|
92
|
+
async def listing(parent: str) -> dict[tuple[str, ...], Mapping[str, object]]:
|
|
93
|
+
try:
|
|
94
|
+
result = await _call(filesystem, "_ls", parent, detail=True)
|
|
95
|
+
except (FileNotFoundError, PermissionError, NotImplementedError):
|
|
96
|
+
return {}
|
|
97
|
+
return snapshot_listing(parent, result)
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
listings = dict(
|
|
101
|
+
zip(parents, await _gather_bounded([partial(listing, parent) for parent in parents]), strict=True)
|
|
102
|
+
)
|
|
103
|
+
found: dict[str, object | None] = {}
|
|
104
|
+
gaps: list[str] = []
|
|
105
|
+
for path in paths:
|
|
106
|
+
row = listings[_lexical_parent(path)].get(_lexical_components(path))
|
|
107
|
+
if row is None or (row["type"] == "file" and row.get("size") is None):
|
|
108
|
+
gaps.append(path)
|
|
109
|
+
else:
|
|
110
|
+
found[path] = row
|
|
111
|
+
values = await _gather_bounded([partial(_info, filesystem, path, missing_ok=missing_ok) for path in gaps])
|
|
112
|
+
found.update(zip(gaps, values, strict=True))
|
|
113
|
+
except Exception as error: # noqa: BLE001 - command owns diagnostics and mutation policy.
|
|
114
|
+
return {}, error
|
|
115
|
+
return found, None
|
fss_cli/_rm.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Typed async execution for ``rm`` profiles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from ._command import (
|
|
9
|
+
_binary_stdout,
|
|
10
|
+
_CommandFailureError,
|
|
11
|
+
_first_backend_error,
|
|
12
|
+
_MappedOperand,
|
|
13
|
+
_render_output_failure,
|
|
14
|
+
_run_mapped_command,
|
|
15
|
+
_write_binary,
|
|
16
|
+
)
|
|
17
|
+
from ._recursive_rm import (
|
|
18
|
+
_RecursiveRmFailure,
|
|
19
|
+
_remove_recursive,
|
|
20
|
+
_render_recursive_failure,
|
|
21
|
+
)
|
|
22
|
+
from ._rmdir import _remove_empty_directory, _RmdirFailure
|
|
23
|
+
from ._rmdir import _render_failure as _render_rmdir_failure
|
|
24
|
+
from ._unlink import _confirmed_rm_file, _render_failure, _UnlinkFailure
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from collections.abc import Mapping
|
|
28
|
+
|
|
29
|
+
from fsspec.asyn import AsyncFileSystem
|
|
30
|
+
|
|
31
|
+
from ._app import AsyncFilesystemSource
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class _RmRequest:
|
|
36
|
+
force: bool
|
|
37
|
+
directory: bool
|
|
38
|
+
recursive: bool
|
|
39
|
+
verbose: bool
|
|
40
|
+
operands: tuple[_MappedOperand, ...]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _write_verbose_line(spelling: str) -> None:
|
|
44
|
+
chunk = f"{spelling}\n".encode()
|
|
45
|
+
stdout = _binary_stdout()
|
|
46
|
+
_write_binary(stdout, chunk)
|
|
47
|
+
stdout.flush()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _render_rm_failure(
|
|
51
|
+
command: str,
|
|
52
|
+
failure: _UnlinkFailure | _RmdirFailure | _RecursiveRmFailure,
|
|
53
|
+
) -> None:
|
|
54
|
+
if isinstance(failure, _RecursiveRmFailure):
|
|
55
|
+
_render_recursive_failure(command, failure)
|
|
56
|
+
elif isinstance(failure, _RmdirFailure):
|
|
57
|
+
_render_rmdir_failure(command, failure)
|
|
58
|
+
else:
|
|
59
|
+
_render_failure(command, failure)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _render_rm_failure_or_raise(
|
|
63
|
+
command: str,
|
|
64
|
+
failure: _UnlinkFailure | _RmdirFailure | _RecursiveRmFailure,
|
|
65
|
+
) -> None:
|
|
66
|
+
try:
|
|
67
|
+
_render_rm_failure(command, failure)
|
|
68
|
+
except Exception as error:
|
|
69
|
+
raise _CommandFailureError(
|
|
70
|
+
error=failure.backend_error,
|
|
71
|
+
render=False,
|
|
72
|
+
propagate=error,
|
|
73
|
+
) from error
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def _remove_directory_entry(
|
|
77
|
+
operand: _MappedOperand,
|
|
78
|
+
filesystem: AsyncFileSystem,
|
|
79
|
+
) -> _UnlinkFailure | _RmdirFailure | None:
|
|
80
|
+
failure = await _confirmed_rm_file(operand, filesystem)
|
|
81
|
+
if isinstance(failure, _UnlinkFailure) and failure.incompatible == "directory":
|
|
82
|
+
return await _remove_empty_directory(operand, filesystem)
|
|
83
|
+
return failure
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def _trace_operands(
|
|
87
|
+
command: str,
|
|
88
|
+
request: _RmRequest,
|
|
89
|
+
filesystems: Mapping[str, AsyncFileSystem],
|
|
90
|
+
failures: list[_UnlinkFailure | _RmdirFailure | _RecursiveRmFailure],
|
|
91
|
+
) -> Exception | None:
|
|
92
|
+
for operand in request.operands:
|
|
93
|
+
filesystem = filesystems[operand.name]
|
|
94
|
+
if request.recursive:
|
|
95
|
+
result = await _remove_recursive(operand, filesystem)
|
|
96
|
+
elif request.directory:
|
|
97
|
+
result = await _remove_directory_entry(operand, filesystem)
|
|
98
|
+
else:
|
|
99
|
+
result = await _confirmed_rm_file(operand, filesystem)
|
|
100
|
+
force_missing = request.force and (
|
|
101
|
+
(
|
|
102
|
+
isinstance(result, _UnlinkFailure)
|
|
103
|
+
and not result.uncertain
|
|
104
|
+
and isinstance(result.backend_error, FileNotFoundError)
|
|
105
|
+
)
|
|
106
|
+
or (isinstance(result, _RecursiveRmFailure) and result.root_missing)
|
|
107
|
+
)
|
|
108
|
+
if result is not None and not force_missing:
|
|
109
|
+
failures.append(result)
|
|
110
|
+
if request.verbose:
|
|
111
|
+
_render_rm_failure_or_raise(command, result)
|
|
112
|
+
continue
|
|
113
|
+
if request.verbose and result is None:
|
|
114
|
+
try:
|
|
115
|
+
_write_verbose_line(operand.spelling)
|
|
116
|
+
except BrokenPipeError as error:
|
|
117
|
+
return error
|
|
118
|
+
except Exception as error: # noqa: BLE001 - stdout boundary.
|
|
119
|
+
try:
|
|
120
|
+
_render_output_failure(command, error)
|
|
121
|
+
except Exception as render_error:
|
|
122
|
+
raise _CommandFailureError(
|
|
123
|
+
error=error,
|
|
124
|
+
render=False,
|
|
125
|
+
propagate=render_error,
|
|
126
|
+
) from render_error
|
|
127
|
+
return error
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def _run_rm(
|
|
132
|
+
command: str,
|
|
133
|
+
request: _RmRequest,
|
|
134
|
+
sources: Mapping[str, AsyncFilesystemSource],
|
|
135
|
+
) -> None:
|
|
136
|
+
async def operation(filesystems: Mapping[str, AsyncFileSystem]) -> None:
|
|
137
|
+
failures: list[_UnlinkFailure | _RmdirFailure | _RecursiveRmFailure] = []
|
|
138
|
+
output_error = await _trace_operands(command, request, filesystems, failures)
|
|
139
|
+
backend_error = _first_backend_error(failures)
|
|
140
|
+
if not request.verbose:
|
|
141
|
+
for failure in failures:
|
|
142
|
+
_render_rm_failure_or_raise(command, failure)
|
|
143
|
+
if failures or output_error is not None:
|
|
144
|
+
raise _CommandFailureError(
|
|
145
|
+
error=backend_error if backend_error is not None else output_error,
|
|
146
|
+
render=False,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
await _run_mapped_command(command, request.operands, sources, operation)
|