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/_cat.py ADDED
@@ -0,0 +1,391 @@
1
+ """Binary execution for typed mapped-file ``cat``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ import tempfile
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, BinaryIO
12
+
13
+ import typer
14
+
15
+ from . import _copy_file
16
+ from ._command import (
17
+ _binary_stdout,
18
+ _BinaryWriter,
19
+ _CommandFailureError,
20
+ _drain_current_operation,
21
+ _Failure,
22
+ _MappedOperand,
23
+ _render_failure,
24
+ _render_output_failure,
25
+ _run_mapped_command,
26
+ _write_binary,
27
+ )
28
+ from ._diagnostics import _render_diagnostic_prefix, _render_diagnostic_value
29
+
30
+ if TYPE_CHECKING:
31
+ from fsspec.asyn import AsyncFileSystem
32
+
33
+ from ._app import AsyncFilesystemSource
34
+
35
+ _OUTPUT_CHUNK = 1 << 16
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class _StdinOperand:
40
+ spelling: str = "-"
41
+
42
+
43
+ _CatOperand = _MappedOperand | _StdinOperand
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class _StagingFailure:
48
+ operand: _CatOperand
49
+ error: Exception
50
+
51
+
52
+ def _binary_stdin() -> BinaryIO:
53
+ buffer = getattr(sys.stdin, "buffer", None)
54
+ if buffer is None:
55
+ message = "stdin has no binary buffer"
56
+ raise OSError(message)
57
+ return buffer
58
+
59
+
60
+ def _render_staging_failure(
61
+ command: str,
62
+ operand: _CatOperand,
63
+ error: Exception,
64
+ ) -> None:
65
+ prefix = _render_diagnostic_prefix(command)
66
+ rendered_operand = _render_diagnostic_value(operand.spelling)
67
+ rendered_class = _render_diagnostic_value(type(error).__name__)
68
+ rendered_message = _render_diagnostic_value(str(error))
69
+ typer.echo(
70
+ f"{prefix} {rendered_operand}: staging failure ({rendered_class}): {rendered_message}",
71
+ err=True,
72
+ color=True,
73
+ )
74
+
75
+
76
+ def _render_operand_failure(command: str, failure: _Failure | _StagingFailure) -> None:
77
+ if isinstance(failure, _StagingFailure):
78
+ _render_staging_failure(command, failure.operand, failure.error)
79
+ return
80
+ _render_failure(command, failure)
81
+
82
+
83
+ @dataclass
84
+ class _CatOwnership:
85
+ pending_temporaries: set[str]
86
+ temporary_operands: dict[str, _MappedOperand]
87
+ pending_descriptors: dict[int, str]
88
+
89
+ def add(self, descriptor: int, temporary: str, operand: _MappedOperand) -> None:
90
+ self.pending_temporaries.add(temporary)
91
+ self.temporary_operands[temporary] = operand
92
+ self.pending_descriptors[descriptor] = temporary
93
+
94
+
95
+ def _close_descriptor(
96
+ ownership: _CatOwnership,
97
+ descriptor: int,
98
+ ) -> Exception | None:
99
+ try:
100
+ os.close(descriptor)
101
+ except Exception as error: # noqa: BLE001 - descriptor ownership boundary.
102
+ return error
103
+ ownership.pending_descriptors.pop(descriptor)
104
+ return None
105
+
106
+
107
+ def _remove_owned_temporary(
108
+ ownership: _CatOwnership,
109
+ temporary: str,
110
+ ) -> Exception | None:
111
+ if temporary not in ownership.pending_temporaries:
112
+ return None
113
+ if temporary in ownership.pending_descriptors.values():
114
+ message = "temporary descriptor remains open"
115
+ return OSError(message)
116
+ cleanup_error = _copy_file.remove_staging(temporary)
117
+ if cleanup_error is None:
118
+ ownership.pending_temporaries.remove(temporary)
119
+ ownership.temporary_operands.pop(temporary)
120
+ return cleanup_error
121
+
122
+
123
+ def _sweep_ownership(ownership: _CatOwnership) -> dict[str, Exception]:
124
+ errors: dict[str, Exception] = {}
125
+ for descriptor, temporary in tuple(ownership.pending_descriptors.items()):
126
+ close_error = _close_descriptor(ownership, descriptor)
127
+ if close_error is not None:
128
+ errors[temporary] = close_error
129
+ for temporary in tuple(ownership.pending_temporaries):
130
+ cleanup_error = _remove_owned_temporary(ownership, temporary)
131
+ if cleanup_error is not None:
132
+ errors.setdefault(temporary, cleanup_error)
133
+ else:
134
+ errors.pop(temporary, None)
135
+ return errors
136
+
137
+
138
+ def _failure_after_temporary(
139
+ ownership: _CatOwnership,
140
+ operand: _MappedOperand,
141
+ temporary: str,
142
+ error: Exception,
143
+ *,
144
+ staging: bool,
145
+ ) -> _StagingFailure | _Failure:
146
+ cleanup_error = _remove_owned_temporary(ownership, temporary)
147
+ if cleanup_error is not None:
148
+ if staging and temporary in ownership.pending_descriptors.values():
149
+ return _StagingFailure(operand, error)
150
+ return _StagingFailure(operand, cleanup_error)
151
+ if staging:
152
+ return _StagingFailure(operand, error)
153
+ return _Failure(operand, backend_error=error)
154
+
155
+
156
+ async def _stage_operand(
157
+ operand: _MappedOperand,
158
+ filesystem: AsyncFileSystem,
159
+ ownership: _CatOwnership,
160
+ ) -> str | _Failure | _StagingFailure:
161
+ # fsspec's native async API intentionally exposes underscore coroutines.
162
+ try:
163
+ info = await _drain_current_operation(filesystem._info(operand.path))
164
+ except Exception as error: # noqa: BLE001 - classify awaited backend failure.
165
+ return _Failure(operand, backend_error=error)
166
+
167
+ if not isinstance(info, Mapping) or info.get("type") != "file":
168
+ return _Failure(operand)
169
+
170
+ try:
171
+ descriptor, temporary = tempfile.mkstemp(prefix="fss-cli-cat-")
172
+ except Exception as error: # noqa: BLE001 - local staging creation boundary.
173
+ return _StagingFailure(operand, error)
174
+
175
+ ownership.add(descriptor, temporary, operand)
176
+ close_error = _close_descriptor(ownership, descriptor)
177
+ if close_error is not None:
178
+ _sweep_ownership(ownership)
179
+ return _failure_after_temporary(
180
+ ownership,
181
+ operand,
182
+ temporary,
183
+ close_error,
184
+ staging=True,
185
+ )
186
+
187
+ try:
188
+ await _drain_current_operation(filesystem._get_file(operand.path, temporary))
189
+ except Exception as error: # noqa: BLE001 - classify awaited backend failure.
190
+ return _failure_after_temporary(
191
+ ownership,
192
+ operand,
193
+ temporary,
194
+ error,
195
+ staging=False,
196
+ )
197
+ except BaseException:
198
+ _remove_owned_temporary(ownership, temporary)
199
+ raise
200
+
201
+ return temporary
202
+
203
+
204
+ @dataclass(frozen=True)
205
+ class _ForwardResult:
206
+ staging_error: Exception | None = None
207
+ output_error: Exception | None = None
208
+ wrote_output: bool = False
209
+
210
+
211
+ def _emit_handle(handle: BinaryIO, *, read_is_output: bool) -> _ForwardResult:
212
+ emitted = False
213
+ stdout: _BinaryWriter | None = None
214
+ while True:
215
+ try:
216
+ chunk = handle.read(_OUTPUT_CHUNK)
217
+ except Exception as error: # noqa: BLE001 - local staging/stdin read boundary.
218
+ if read_is_output and emitted:
219
+ return _ForwardResult(output_error=error, wrote_output=True)
220
+ return _ForwardResult(staging_error=error, wrote_output=emitted)
221
+ if not chunk:
222
+ break
223
+ if stdout is None:
224
+ try:
225
+ stdout = _binary_stdout()
226
+ except Exception as error: # noqa: BLE001 - stdout boundary.
227
+ return _ForwardResult(output_error=error, wrote_output=emitted)
228
+ try:
229
+ _write_binary(stdout, chunk)
230
+ except Exception as error: # noqa: BLE001 - stdout boundary.
231
+ return _ForwardResult(output_error=error, wrote_output=emitted)
232
+ emitted = True
233
+ if stdout is not None:
234
+ try:
235
+ stdout.flush()
236
+ except Exception as error: # noqa: BLE001 - stdout boundary.
237
+ return _ForwardResult(output_error=error, wrote_output=emitted)
238
+ return _ForwardResult(wrote_output=emitted)
239
+
240
+
241
+ def _forward_stdin() -> _ForwardResult:
242
+ try:
243
+ handle = _binary_stdin()
244
+ except Exception as error: # noqa: BLE001 - stdin open boundary.
245
+ return _ForwardResult(staging_error=error)
246
+ return _emit_handle(handle, read_is_output=False)
247
+
248
+
249
+ def _forward_temporary(temporary: str) -> _ForwardResult:
250
+ """Stream one staging handle straight to stdout."""
251
+ try:
252
+ handle = Path(temporary).open("rb") # noqa: SIM115
253
+ except Exception as error: # noqa: BLE001 - local staging open boundary.
254
+ return _ForwardResult(staging_error=error)
255
+
256
+ forwarded = _ForwardResult()
257
+ try:
258
+ forwarded = _emit_handle(handle, read_is_output=True)
259
+ finally:
260
+ try:
261
+ handle.close()
262
+ except Exception as error: # noqa: BLE001 - local staging close boundary.
263
+ if forwarded.staging_error is None and forwarded.output_error is None:
264
+ if forwarded.wrote_output:
265
+ forwarded = _ForwardResult(
266
+ output_error=error,
267
+ wrote_output=True,
268
+ )
269
+ else:
270
+ forwarded = _ForwardResult(staging_error=error)
271
+
272
+ return forwarded
273
+
274
+
275
+ @dataclass
276
+ class _CatProgress:
277
+ failures: list[_Failure | _StagingFailure]
278
+ output_error: Exception | None = None
279
+ staging_cleanup_error: Exception | None = None
280
+
281
+ @property
282
+ def succeeded(self) -> bool:
283
+ return not self.failures and self.output_error is None and self.staging_cleanup_error is None
284
+
285
+ def command_error(self) -> Exception | None:
286
+ for failure in self.failures:
287
+ if isinstance(failure, _StagingFailure):
288
+ return failure.error
289
+ if failure.backend_error is not None:
290
+ return failure.backend_error
291
+ if self.output_error is not None:
292
+ return self.output_error
293
+ return self.staging_cleanup_error
294
+
295
+
296
+ def _apply_forward_result(
297
+ command: str,
298
+ operand: _CatOperand,
299
+ forwarded: _ForwardResult,
300
+ progress: _CatProgress,
301
+ ) -> None:
302
+ if forwarded.staging_error is not None:
303
+ failure = _StagingFailure(operand, forwarded.staging_error)
304
+ _render_operand_failure(command, failure)
305
+ progress.failures.append(failure)
306
+ return
307
+ if forwarded.output_error is not None:
308
+ progress.output_error = forwarded.output_error
309
+ if not isinstance(forwarded.output_error, BrokenPipeError):
310
+ _render_output_failure(command, forwarded.output_error)
311
+
312
+
313
+ async def _emit_mapped_operand(
314
+ command: str,
315
+ operand: _MappedOperand,
316
+ filesystem: AsyncFileSystem,
317
+ progress: _CatProgress,
318
+ ownership: _CatOwnership,
319
+ ) -> None:
320
+ staged = await _stage_operand(operand, filesystem, ownership)
321
+ if not isinstance(staged, str):
322
+ _render_operand_failure(command, staged)
323
+ progress.failures.append(staged)
324
+ return
325
+ temporary = staged
326
+ try:
327
+ _apply_forward_result(
328
+ command,
329
+ operand,
330
+ _forward_temporary(temporary),
331
+ progress,
332
+ )
333
+ finally:
334
+ cleanup_error = _remove_owned_temporary(ownership, temporary)
335
+ if cleanup_error is not None:
336
+ progress.staging_cleanup_error = cleanup_error
337
+ _render_staging_failure(command, operand, cleanup_error)
338
+
339
+
340
+ async def _emit_operands(
341
+ command: str,
342
+ operands: tuple[_CatOperand, ...],
343
+ filesystems: Mapping[str, AsyncFileSystem],
344
+ progress: _CatProgress,
345
+ ownership: _CatOwnership,
346
+ ) -> None:
347
+ for operand in operands:
348
+ if progress.output_error is not None:
349
+ return
350
+ if isinstance(operand, _StdinOperand):
351
+ _apply_forward_result(command, operand, _forward_stdin(), progress)
352
+ continue
353
+ await _emit_mapped_operand(
354
+ command,
355
+ operand,
356
+ filesystems[operand.name],
357
+ progress,
358
+ ownership,
359
+ )
360
+
361
+
362
+ async def _run_cat(
363
+ command: str,
364
+ operands: tuple[_CatOperand, ...],
365
+ sources: Mapping[str, AsyncFilesystemSource],
366
+ ) -> None:
367
+ mapped = tuple(operand for operand in operands if isinstance(operand, _MappedOperand))
368
+
369
+ async def execute(filesystems: Mapping[str, AsyncFileSystem]) -> None:
370
+ progress = _CatProgress(failures=[])
371
+ ownership = _CatOwnership(set(), {}, {})
372
+ try:
373
+ await _emit_operands(
374
+ command,
375
+ operands,
376
+ filesystems,
377
+ progress,
378
+ ownership,
379
+ )
380
+ finally:
381
+ for temporary, error in _sweep_ownership(ownership).items():
382
+ operand = ownership.temporary_operands[temporary]
383
+ _render_staging_failure(command, operand, error)
384
+ progress.staging_cleanup_error = error
385
+ if not progress.succeeded:
386
+ raise _CommandFailureError(
387
+ error=progress.command_error(),
388
+ render=False,
389
+ )
390
+
391
+ await _run_mapped_command(command, mapped, sources, execute)