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/_rmdir.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Typed async execution for ``rmdir``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from ._command import (
|
|
11
|
+
_drain_current_operation,
|
|
12
|
+
_Failure,
|
|
13
|
+
_MappedOperand,
|
|
14
|
+
_raise_operand_failures,
|
|
15
|
+
_render_backend_failure,
|
|
16
|
+
_render_operand_diagnostic,
|
|
17
|
+
_run_mapped_command,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from fsspec.asyn import AsyncFileSystem
|
|
22
|
+
|
|
23
|
+
from ._app import AsyncFilesystemSource
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class _RmdirFailure(_Failure):
|
|
28
|
+
"""A failure raised on the empty-directory removal path.
|
|
29
|
+
|
|
30
|
+
Structurally identical to the base failure; the distinct type is how ``rm``
|
|
31
|
+
tells a directory-path failure from a file-path one, because the two render
|
|
32
|
+
different diagnostic vocabularies for the same condition.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def _require_directory(
|
|
37
|
+
operand: _MappedOperand,
|
|
38
|
+
filesystem: AsyncFileSystem,
|
|
39
|
+
) -> _RmdirFailure | None:
|
|
40
|
+
try:
|
|
41
|
+
info = await _drain_current_operation(filesystem._info(operand.path))
|
|
42
|
+
except Exception as error: # noqa: BLE001 - classify awaited backend failure.
|
|
43
|
+
return _RmdirFailure(operand, backend_error=error)
|
|
44
|
+
|
|
45
|
+
if not isinstance(info, Mapping) or not isinstance(info.get("type"), str):
|
|
46
|
+
return _RmdirFailure(operand, incompatible="result")
|
|
47
|
+
|
|
48
|
+
result_type = info["type"]
|
|
49
|
+
if result_type == "file":
|
|
50
|
+
return _RmdirFailure(operand, incompatible="directory")
|
|
51
|
+
if result_type != "directory":
|
|
52
|
+
return _RmdirFailure(operand, incompatible="result")
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def _observe_post_mutation(
|
|
57
|
+
operand: _MappedOperand,
|
|
58
|
+
filesystem: AsyncFileSystem,
|
|
59
|
+
mutation_error: Exception | None,
|
|
60
|
+
) -> _RmdirFailure | None:
|
|
61
|
+
try:
|
|
62
|
+
await _drain_current_operation(filesystem._info(operand.path))
|
|
63
|
+
except FileNotFoundError:
|
|
64
|
+
# Absence proves success, including after a mutation-call exception.
|
|
65
|
+
return None
|
|
66
|
+
except Exception as error: # noqa: BLE001 - never hide non-not-found errors.
|
|
67
|
+
if mutation_error is not None:
|
|
68
|
+
return _RmdirFailure(operand, backend_error=mutation_error, uncertain=True)
|
|
69
|
+
return _RmdirFailure(operand, backend_error=error, uncertain=True)
|
|
70
|
+
if mutation_error is not None:
|
|
71
|
+
# Presence after a mutation-call exception is confirmed failure.
|
|
72
|
+
return _RmdirFailure(operand, backend_error=mutation_error)
|
|
73
|
+
return _RmdirFailure(operand, incompatible="result")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def _remove_empty_directory(
|
|
77
|
+
operand: _MappedOperand,
|
|
78
|
+
filesystem: AsyncFileSystem,
|
|
79
|
+
) -> _RmdirFailure | None:
|
|
80
|
+
directory_failure = await _require_directory(operand, filesystem)
|
|
81
|
+
if directory_failure is not None:
|
|
82
|
+
return directory_failure
|
|
83
|
+
|
|
84
|
+
rmdir = getattr(filesystem, "_rmdir", None)
|
|
85
|
+
if not callable(rmdir):
|
|
86
|
+
return _RmdirFailure(
|
|
87
|
+
operand,
|
|
88
|
+
backend_error=NotImplementedError(f"{type(filesystem).__name__} lacks async _rmdir"),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
mutation_error: Exception | None = None
|
|
92
|
+
try:
|
|
93
|
+
await _drain_current_operation(rmdir(operand.path))
|
|
94
|
+
except Exception as error: # noqa: BLE001 - mutation may leave uncertain state.
|
|
95
|
+
mutation_error = error
|
|
96
|
+
|
|
97
|
+
return await _observe_post_mutation(operand, filesystem, mutation_error)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _render_rmdir_backend_failure(
|
|
101
|
+
command: str,
|
|
102
|
+
operand: _MappedOperand,
|
|
103
|
+
error: Exception,
|
|
104
|
+
) -> None:
|
|
105
|
+
if isinstance(error, OSError) and error.errno == errno.ENOTEMPTY:
|
|
106
|
+
_render_operand_diagnostic(command, operand, "directory not empty")
|
|
107
|
+
return
|
|
108
|
+
_render_backend_failure(command, operand, error)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _render_failure(command: str, failure: _RmdirFailure) -> None:
|
|
112
|
+
if failure.uncertain:
|
|
113
|
+
_render_operand_diagnostic(command, failure.operand, "uncertain state")
|
|
114
|
+
elif failure.incompatible == "directory":
|
|
115
|
+
_render_operand_diagnostic(command, failure.operand, "not a directory")
|
|
116
|
+
elif failure.incompatible == "result":
|
|
117
|
+
_render_operand_diagnostic(command, failure.operand, "incompatible result")
|
|
118
|
+
elif isinstance(failure.backend_error, NotADirectoryError):
|
|
119
|
+
_render_operand_diagnostic(command, failure.operand, "not a directory")
|
|
120
|
+
elif failure.backend_error is None:
|
|
121
|
+
_render_operand_diagnostic(command, failure.operand, "incompatible result")
|
|
122
|
+
else:
|
|
123
|
+
_render_rmdir_backend_failure(command, failure.operand, failure.backend_error)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def _trace_operands(
|
|
127
|
+
operands: tuple[_MappedOperand, ...],
|
|
128
|
+
filesystems: Mapping[str, AsyncFileSystem],
|
|
129
|
+
) -> tuple[_RmdirFailure, ...]:
|
|
130
|
+
failures = []
|
|
131
|
+
for operand in operands:
|
|
132
|
+
result = await _remove_empty_directory(operand, filesystems[operand.name])
|
|
133
|
+
if isinstance(result, _RmdirFailure):
|
|
134
|
+
failures.append(result)
|
|
135
|
+
return tuple(failures)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def _run_rmdir(
|
|
139
|
+
command: str,
|
|
140
|
+
operands: tuple[_MappedOperand, ...],
|
|
141
|
+
sources: Mapping[str, AsyncFilesystemSource],
|
|
142
|
+
) -> None:
|
|
143
|
+
async def operation(filesystems: Mapping[str, AsyncFileSystem]) -> None:
|
|
144
|
+
failures = await _trace_operands(operands, filesystems)
|
|
145
|
+
if failures:
|
|
146
|
+
_raise_operand_failures(command, failures, _render_failure)
|
|
147
|
+
|
|
148
|
+
await _run_mapped_command(command, operands, sources, operation)
|
fss_cli/_size.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Exact-size execution for typed ``size``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from ._command import (
|
|
10
|
+
_CommandFailureError,
|
|
11
|
+
_drain_current_operation,
|
|
12
|
+
_MappedOperand,
|
|
13
|
+
_run_mapped_command,
|
|
14
|
+
_valid_size,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Mapping
|
|
19
|
+
|
|
20
|
+
from fsspec.asyn import AsyncFileSystem
|
|
21
|
+
|
|
22
|
+
from ._app import AsyncFilesystemSource
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _group_operands(
|
|
26
|
+
operands: tuple[_MappedOperand, ...],
|
|
27
|
+
) -> dict[str, list[tuple[int, _MappedOperand]]]:
|
|
28
|
+
groups: dict[str, list[tuple[int, _MappedOperand]]] = {}
|
|
29
|
+
for index, operand in enumerate(operands):
|
|
30
|
+
groups.setdefault(operand.name, []).append((index, operand))
|
|
31
|
+
return groups
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def _measure(
|
|
35
|
+
operands: tuple[_MappedOperand, ...],
|
|
36
|
+
filesystems: Mapping[str, AsyncFileSystem],
|
|
37
|
+
) -> str:
|
|
38
|
+
if len(operands) == 1:
|
|
39
|
+
return await _measure_one(operands[0], filesystems)
|
|
40
|
+
return await _measure_many(operands, filesystems)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _measure_one(
|
|
44
|
+
operand: _MappedOperand,
|
|
45
|
+
filesystems: Mapping[str, AsyncFileSystem],
|
|
46
|
+
) -> str:
|
|
47
|
+
try:
|
|
48
|
+
result = await _drain_current_operation(filesystems[operand.name]._size(operand.path))
|
|
49
|
+
except Exception as error:
|
|
50
|
+
raise _CommandFailureError(operand, error) from error
|
|
51
|
+
if not _valid_size(result):
|
|
52
|
+
raise _CommandFailureError(operand)
|
|
53
|
+
return f"{result}\t{operand.spelling}\n"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def _measure_many(
|
|
57
|
+
operands: tuple[_MappedOperand, ...],
|
|
58
|
+
filesystems: Mapping[str, AsyncFileSystem],
|
|
59
|
+
) -> str:
|
|
60
|
+
sizes: dict[int, int] = {}
|
|
61
|
+
for name, group in _group_operands(operands).items():
|
|
62
|
+
paths = [operand.path for _, operand in group]
|
|
63
|
+
try:
|
|
64
|
+
result = await _drain_current_operation(filesystems[name]._sizes(paths))
|
|
65
|
+
except Exception as error:
|
|
66
|
+
raise _CommandFailureError(group[0][1], error) from error
|
|
67
|
+
if type(result) is not list or len(result) != len(group):
|
|
68
|
+
raise _CommandFailureError(group[0][1])
|
|
69
|
+
for (index, operand), size in zip(group, result, strict=True):
|
|
70
|
+
if not _valid_size(size):
|
|
71
|
+
raise _CommandFailureError(operand)
|
|
72
|
+
sizes[index] = size
|
|
73
|
+
|
|
74
|
+
return "".join(f"{sizes[index]}\t{operand.spelling}\n" for index, operand in enumerate(operands))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def _run_size(
|
|
78
|
+
command: str,
|
|
79
|
+
operands: tuple[_MappedOperand, ...],
|
|
80
|
+
sources: Mapping[str, AsyncFilesystemSource],
|
|
81
|
+
) -> None:
|
|
82
|
+
async def execute(filesystems: Mapping[str, AsyncFileSystem]) -> None:
|
|
83
|
+
result = await _measure(operands, filesystems)
|
|
84
|
+
try:
|
|
85
|
+
typer.echo(result, nl=False, color=True)
|
|
86
|
+
except Exception as error:
|
|
87
|
+
raise _CommandFailureError(error=error) from error
|
|
88
|
+
|
|
89
|
+
await _run_mapped_command(
|
|
90
|
+
command,
|
|
91
|
+
operands,
|
|
92
|
+
sources,
|
|
93
|
+
execute,
|
|
94
|
+
broken_pipe_exit_code=1,
|
|
95
|
+
)
|
fss_cli/_sources.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Invocation-owned async filesystem source lifecycle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from contextlib import AbstractAsyncContextManager
|
|
7
|
+
from types import TracebackType
|
|
8
|
+
from typing import TYPE_CHECKING, TypeAlias
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from fsspec.asyn import AsyncFileSystem
|
|
12
|
+
|
|
13
|
+
from ._diagnostics import _render_diagnostic_prefix, _render_diagnostic_value
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Iterable, Mapping
|
|
17
|
+
|
|
18
|
+
from fsspec import AbstractFileSystem
|
|
19
|
+
|
|
20
|
+
from ._app import AsyncFilesystemSource
|
|
21
|
+
|
|
22
|
+
_ExcInfo: TypeAlias = tuple[
|
|
23
|
+
type[BaseException] | None,
|
|
24
|
+
BaseException | None,
|
|
25
|
+
TracebackType | None,
|
|
26
|
+
]
|
|
27
|
+
_EMPTY_EXC_INFO: _ExcInfo = (None, None, None)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _source_exception(
|
|
31
|
+
command: str,
|
|
32
|
+
name: str,
|
|
33
|
+
stage: str,
|
|
34
|
+
error: Exception,
|
|
35
|
+
) -> None:
|
|
36
|
+
prefix = _render_diagnostic_prefix(command)
|
|
37
|
+
rendered_name = _render_diagnostic_value(name)
|
|
38
|
+
rendered_class = _render_diagnostic_value(type(error).__name__)
|
|
39
|
+
rendered_message = _render_diagnostic_value(str(error))
|
|
40
|
+
typer.echo(
|
|
41
|
+
f"{prefix} {rendered_name}: source {stage} failure ({rendered_class}): {rendered_message}",
|
|
42
|
+
err=True,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _render_exit_failures(
|
|
47
|
+
command: str,
|
|
48
|
+
failures: Iterable[tuple[str, Exception]],
|
|
49
|
+
*,
|
|
50
|
+
preserve_control: bool,
|
|
51
|
+
) -> BaseException | None:
|
|
52
|
+
render_control = None
|
|
53
|
+
for name, error in failures:
|
|
54
|
+
try:
|
|
55
|
+
_source_exception(command, name, "exit", error)
|
|
56
|
+
except Exception: # noqa: BLE001, PERF203, S110 - attempt every diagnostic.
|
|
57
|
+
pass
|
|
58
|
+
except BaseException as error: # noqa: BLE001 - preserve control flow.
|
|
59
|
+
if not preserve_control and render_control is None:
|
|
60
|
+
render_control = error
|
|
61
|
+
return render_control
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _SourceInvocation:
|
|
65
|
+
"""Acquire, expose, and release sources for one command invocation."""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
command: str,
|
|
70
|
+
sources: Mapping[str, AsyncFilesystemSource],
|
|
71
|
+
) -> None:
|
|
72
|
+
self._command = command
|
|
73
|
+
self._sources = sources
|
|
74
|
+
self._entered: list[tuple[str, AbstractAsyncContextManager[AbstractFileSystem]]] = []
|
|
75
|
+
self._failure_exc_info: _ExcInfo = _EMPTY_EXC_INFO
|
|
76
|
+
|
|
77
|
+
async def acquire(
|
|
78
|
+
self,
|
|
79
|
+
names: Iterable[str],
|
|
80
|
+
) -> Mapping[str, AsyncFileSystem] | None:
|
|
81
|
+
"""Acquire distinct mapped names sequentially in the given order."""
|
|
82
|
+
filesystems: dict[str, AsyncFileSystem] = {}
|
|
83
|
+
for name in names:
|
|
84
|
+
try:
|
|
85
|
+
manager = self._sources[name]()
|
|
86
|
+
except Exception as error: # noqa: BLE001 - classify source failures.
|
|
87
|
+
_source_exception(self._command, name, "factory", error)
|
|
88
|
+
self._remember_failure(error)
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
if not (
|
|
92
|
+
isinstance(manager, AbstractAsyncContextManager)
|
|
93
|
+
and callable(getattr(manager, "__aenter__", None))
|
|
94
|
+
and callable(getattr(manager, "__aexit__", None))
|
|
95
|
+
):
|
|
96
|
+
prefix = _render_diagnostic_prefix(self._command)
|
|
97
|
+
rendered_name = _render_diagnostic_value(name)
|
|
98
|
+
typer.echo(
|
|
99
|
+
f"{prefix} {rendered_name}: source factory returned incompatible async context manager",
|
|
100
|
+
err=True,
|
|
101
|
+
)
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
filesystem = await manager.__aenter__()
|
|
106
|
+
except Exception as error: # noqa: BLE001 - classify source failures.
|
|
107
|
+
_source_exception(self._command, name, "entry", error)
|
|
108
|
+
self._remember_failure(error)
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
self._entered.append((name, manager))
|
|
112
|
+
if not (
|
|
113
|
+
isinstance(filesystem, AsyncFileSystem)
|
|
114
|
+
and filesystem.async_impl is True
|
|
115
|
+
and filesystem.asynchronous is True
|
|
116
|
+
):
|
|
117
|
+
prefix = _render_diagnostic_prefix(self._command)
|
|
118
|
+
rendered_name = _render_diagnostic_value(name)
|
|
119
|
+
typer.echo(
|
|
120
|
+
f"{prefix} {rendered_name}: source yielded incompatible async filesystem",
|
|
121
|
+
err=True,
|
|
122
|
+
)
|
|
123
|
+
return None
|
|
124
|
+
filesystems[name] = filesystem
|
|
125
|
+
return filesystems
|
|
126
|
+
|
|
127
|
+
def _remember_failure(self, error: Exception) -> None:
|
|
128
|
+
self._failure_exc_info = (type(error), error, error.__traceback__)
|
|
129
|
+
|
|
130
|
+
async def close(self, active_exc_info: _ExcInfo) -> bool:
|
|
131
|
+
"""Exit every entered source and preserve control-flow precedence."""
|
|
132
|
+
exit_exc_info = active_exc_info if active_exc_info[1] is not None else self._failure_exc_info
|
|
133
|
+
primary_control = (
|
|
134
|
+
active_exc_info[1]
|
|
135
|
+
if active_exc_info[1] is not None and not isinstance(active_exc_info[1], Exception)
|
|
136
|
+
else None
|
|
137
|
+
)
|
|
138
|
+
cleanup_control = None
|
|
139
|
+
exit_failures: list[tuple[str, Exception]] = []
|
|
140
|
+
entered, self._entered = self._entered, []
|
|
141
|
+
|
|
142
|
+
for name, manager in reversed(entered):
|
|
143
|
+
try:
|
|
144
|
+
await manager.__aexit__(*exit_exc_info)
|
|
145
|
+
except Exception as error: # noqa: BLE001, PERF203 - every exit runs.
|
|
146
|
+
exit_failures.append((name, error))
|
|
147
|
+
except BaseException as error: # noqa: BLE001 - preserve control flow.
|
|
148
|
+
if primary_control is None and cleanup_control is None:
|
|
149
|
+
cleanup_control = error
|
|
150
|
+
|
|
151
|
+
render_control = _render_exit_failures(
|
|
152
|
+
self._command,
|
|
153
|
+
exit_failures,
|
|
154
|
+
preserve_control=(primary_control is not None or cleanup_control is not None),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if primary_control is None:
|
|
158
|
+
if cleanup_control is not None:
|
|
159
|
+
raise cleanup_control
|
|
160
|
+
if render_control is not None:
|
|
161
|
+
raise render_control
|
|
162
|
+
return bool(exit_failures)
|
|
163
|
+
|
|
164
|
+
async def close_with_command_error(self, command_error: Exception | None) -> bool:
|
|
165
|
+
"""Close after the command, letting a captured command error take precedence.
|
|
166
|
+
|
|
167
|
+
The command error is captured, not raised, so it never flows through
|
|
168
|
+
``__aexit__``. Fold it into the exit ``exc_info`` only when no live
|
|
169
|
+
exception is propagating, or the live one is an ordinary ``Exception``.
|
|
170
|
+
"""
|
|
171
|
+
active_exc_info: _ExcInfo = sys.exc_info()
|
|
172
|
+
if command_error is not None and (active_exc_info[1] is None or isinstance(active_exc_info[1], Exception)):
|
|
173
|
+
active_exc_info = (
|
|
174
|
+
type(command_error),
|
|
175
|
+
command_error,
|
|
176
|
+
command_error.__traceback__,
|
|
177
|
+
)
|
|
178
|
+
return await self.close(active_exc_info)
|
fss_cli/_stat.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Reduced BSD/macOS ``stat`` execution for typed callbacks.
|
|
2
|
+
|
|
3
|
+
Owner and group names resolve through the local ``pwd``/``grp`` account
|
|
4
|
+
databases on a best-effort basis: they describe the local namespace, not the
|
|
5
|
+
remote source's, and fall back to the numeric id when a name is unknown or when
|
|
6
|
+
the host lacks these POSIX-only modules.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import stat as stat_module
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from functools import partial
|
|
16
|
+
from typing import TYPE_CHECKING, cast
|
|
17
|
+
|
|
18
|
+
from ._accounts import group_name as _group_name
|
|
19
|
+
from ._accounts import owner_name as _owner_name
|
|
20
|
+
from ._command import (
|
|
21
|
+
_binary_stdout,
|
|
22
|
+
_CommandFailureError,
|
|
23
|
+
_drain_current_operation,
|
|
24
|
+
_Failure,
|
|
25
|
+
_first_backend_error,
|
|
26
|
+
_MappedOperand,
|
|
27
|
+
_render_failure,
|
|
28
|
+
_run_mapped_command,
|
|
29
|
+
_write_binary,
|
|
30
|
+
)
|
|
31
|
+
from ._concurrent import _run_bounded
|
|
32
|
+
from ._metadata import normalize_time
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from fsspec.asyn import AsyncFileSystem
|
|
36
|
+
|
|
37
|
+
from ._app import AsyncFilesystemSource
|
|
38
|
+
|
|
39
|
+
_MONTHS = (
|
|
40
|
+
"Jan",
|
|
41
|
+
"Feb",
|
|
42
|
+
"Mar",
|
|
43
|
+
"Apr",
|
|
44
|
+
"May",
|
|
45
|
+
"Jun",
|
|
46
|
+
"Jul",
|
|
47
|
+
"Aug",
|
|
48
|
+
"Sep",
|
|
49
|
+
"Oct",
|
|
50
|
+
"Nov",
|
|
51
|
+
"Dec",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class _StatSuccess:
|
|
57
|
+
operand: _MappedOperand
|
|
58
|
+
line: bytes
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _format_mtime(mtime: float) -> str:
|
|
62
|
+
local = time.localtime(mtime)
|
|
63
|
+
month = _MONTHS[local.tm_mon - 1]
|
|
64
|
+
return f"{month} {local.tm_mday:2d} {local.tm_hour:02d}:{local.tm_min:02d}:{local.tm_sec:02d} {local.tm_year}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _validate_info( # noqa: C901, PLR0911 - locked Local-rich shape checks.
|
|
68
|
+
info: object,
|
|
69
|
+
) -> Mapping[str, object] | None:
|
|
70
|
+
if not isinstance(info, Mapping):
|
|
71
|
+
return None
|
|
72
|
+
mapping = cast("Mapping[str, object]", info)
|
|
73
|
+
result_type = mapping.get("type")
|
|
74
|
+
if result_type not in {"file", "directory"} or type(result_type) is not str:
|
|
75
|
+
return None
|
|
76
|
+
if "islink" in mapping:
|
|
77
|
+
islink = mapping["islink"]
|
|
78
|
+
if type(islink) is not bool or islink:
|
|
79
|
+
return None
|
|
80
|
+
name = mapping.get("name")
|
|
81
|
+
if type(name) is not str:
|
|
82
|
+
return None
|
|
83
|
+
size = mapping.get("size")
|
|
84
|
+
if type(size) is not int or size < 0:
|
|
85
|
+
return None
|
|
86
|
+
mode = mapping.get("mode")
|
|
87
|
+
if type(mode) is not int:
|
|
88
|
+
return None
|
|
89
|
+
nlink = mapping.get("nlink")
|
|
90
|
+
if type(nlink) is not int or nlink < 1:
|
|
91
|
+
return None
|
|
92
|
+
uid = mapping.get("uid")
|
|
93
|
+
if type(uid) is not int or uid < 0:
|
|
94
|
+
return None
|
|
95
|
+
gid = mapping.get("gid")
|
|
96
|
+
if type(gid) is not int or gid < 0:
|
|
97
|
+
return None
|
|
98
|
+
checked_mtime = normalize_time(mapping.get("mtime"))
|
|
99
|
+
if checked_mtime is None:
|
|
100
|
+
return None
|
|
101
|
+
try:
|
|
102
|
+
time.localtime(checked_mtime)
|
|
103
|
+
except (OverflowError, OSError, ValueError):
|
|
104
|
+
return None
|
|
105
|
+
return mapping
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _render_line(operand: _MappedOperand, info: Mapping[str, object]) -> bytes:
|
|
109
|
+
mode = cast("int", info["mode"])
|
|
110
|
+
nlink = cast("int", info["nlink"])
|
|
111
|
+
uid = cast("int", info["uid"])
|
|
112
|
+
gid = cast("int", info["gid"])
|
|
113
|
+
size = cast("int", info["size"])
|
|
114
|
+
mtime = cast("float", normalize_time(info["mtime"]))
|
|
115
|
+
line = (
|
|
116
|
+
f"{stat_module.filemode(mode)} {nlink} {_owner_name(uid)} {_group_name(gid)} "
|
|
117
|
+
f'{size} "{_format_mtime(mtime)}" {operand.path}\n'
|
|
118
|
+
)
|
|
119
|
+
return line.encode()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _write_line(line: bytes) -> None:
|
|
123
|
+
stdout = _binary_stdout()
|
|
124
|
+
_write_binary(stdout, line)
|
|
125
|
+
stdout.flush()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def _read_operand(
|
|
129
|
+
operand: _MappedOperand,
|
|
130
|
+
filesystem: AsyncFileSystem,
|
|
131
|
+
) -> _StatSuccess | _Failure:
|
|
132
|
+
try:
|
|
133
|
+
info = await _drain_current_operation(filesystem._info(operand.path))
|
|
134
|
+
except Exception as error: # noqa: BLE001 - classify awaited backend failure.
|
|
135
|
+
return _Failure(operand, backend_error=error)
|
|
136
|
+
|
|
137
|
+
validated = _validate_info(info)
|
|
138
|
+
if validated is None:
|
|
139
|
+
return _Failure(operand, incompatible="result")
|
|
140
|
+
return _StatSuccess(operand, _render_line(operand, validated))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def _trace_operands(
|
|
144
|
+
command: str,
|
|
145
|
+
operands: tuple[_MappedOperand, ...],
|
|
146
|
+
filesystems: Mapping[str, AsyncFileSystem],
|
|
147
|
+
) -> None:
|
|
148
|
+
# Reads overlap under the shared bound; lines and diagnostics are still
|
|
149
|
+
# emitted strictly in operand order, and an operand whose read raised
|
|
150
|
+
# propagates at its own position, after every earlier operand's output.
|
|
151
|
+
outcomes = await _run_bounded([partial(_read_operand, operand, filesystems[operand.name]) for operand in operands])
|
|
152
|
+
failures: list[_Failure] = []
|
|
153
|
+
for outcome in outcomes:
|
|
154
|
+
if outcome is None: # pragma: no cover - only after a raised read.
|
|
155
|
+
break
|
|
156
|
+
if outcome.error is not None:
|
|
157
|
+
raise outcome.error
|
|
158
|
+
result = cast("_StatSuccess | _Failure", outcome.value)
|
|
159
|
+
if isinstance(result, _Failure):
|
|
160
|
+
failures.append(result)
|
|
161
|
+
try:
|
|
162
|
+
_render_failure(command, result)
|
|
163
|
+
except Exception as error:
|
|
164
|
+
raise _CommandFailureError(
|
|
165
|
+
error=result.backend_error,
|
|
166
|
+
render=False,
|
|
167
|
+
propagate=error,
|
|
168
|
+
) from error
|
|
169
|
+
continue
|
|
170
|
+
try:
|
|
171
|
+
_write_line(result.line)
|
|
172
|
+
except Exception as error:
|
|
173
|
+
raise _CommandFailureError(error=error) from error
|
|
174
|
+
|
|
175
|
+
if failures:
|
|
176
|
+
raise _CommandFailureError(
|
|
177
|
+
error=_first_backend_error(failures),
|
|
178
|
+
render=False,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def _run_stat(
|
|
183
|
+
command: str,
|
|
184
|
+
operands: tuple[_MappedOperand, ...],
|
|
185
|
+
sources: Mapping[str, AsyncFilesystemSource],
|
|
186
|
+
) -> None:
|
|
187
|
+
async def execute(filesystems: Mapping[str, AsyncFileSystem]) -> None:
|
|
188
|
+
await _trace_operands(command, operands, filesystems)
|
|
189
|
+
|
|
190
|
+
await _run_mapped_command(
|
|
191
|
+
command,
|
|
192
|
+
operands,
|
|
193
|
+
sources,
|
|
194
|
+
execute,
|
|
195
|
+
broken_pipe_exit_code=1,
|
|
196
|
+
)
|
fss_cli/_test.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Predicate execution for typed ``test``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Literal
|
|
6
|
+
|
|
7
|
+
from ._command import (
|
|
8
|
+
_CommandFailureError,
|
|
9
|
+
_drain_current_operation,
|
|
10
|
+
_MappedOperand,
|
|
11
|
+
_run_mapped_command,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
|
|
17
|
+
from fsspec.asyn import AsyncFileSystem
|
|
18
|
+
|
|
19
|
+
from ._app import AsyncFilesystemSource
|
|
20
|
+
|
|
21
|
+
_Predicate = Literal["e", "d", "f"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def _evaluate(
|
|
25
|
+
predicate: _Predicate,
|
|
26
|
+
operand: _MappedOperand,
|
|
27
|
+
filesystem: AsyncFileSystem,
|
|
28
|
+
) -> object:
|
|
29
|
+
path = operand.path
|
|
30
|
+
if predicate == "e":
|
|
31
|
+
result = await _drain_current_operation(filesystem._exists(path))
|
|
32
|
+
elif predicate == "d":
|
|
33
|
+
result = await _drain_current_operation(filesystem._isdir(path))
|
|
34
|
+
else:
|
|
35
|
+
result = await _drain_current_operation(filesystem._isfile(path))
|
|
36
|
+
return result
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def _run_test(
|
|
40
|
+
command: str,
|
|
41
|
+
predicate: _Predicate,
|
|
42
|
+
operand: _MappedOperand,
|
|
43
|
+
sources: Mapping[str, AsyncFilesystemSource],
|
|
44
|
+
) -> None:
|
|
45
|
+
async def execute(filesystems: Mapping[str, AsyncFileSystem]) -> None:
|
|
46
|
+
try:
|
|
47
|
+
result = await _evaluate(
|
|
48
|
+
predicate,
|
|
49
|
+
operand,
|
|
50
|
+
filesystems[operand.name],
|
|
51
|
+
)
|
|
52
|
+
except Exception as error:
|
|
53
|
+
raise _CommandFailureError(operand, error) from error
|
|
54
|
+
if type(result) is not bool:
|
|
55
|
+
raise _CommandFailureError(operand)
|
|
56
|
+
if not result:
|
|
57
|
+
raise _CommandFailureError(render=False)
|
|
58
|
+
|
|
59
|
+
await _run_mapped_command(command, (operand,), sources, execute)
|