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.
@@ -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)