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/_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)