anyshell 0.1.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.
- anyshell/__init__.py +27 -0
- anyshell/__main__.py +74 -0
- anyshell/commands.py +395 -0
- anyshell/editor.py +122 -0
- anyshell/engine.py +422 -0
- anyshell/filesystem.py +349 -0
- anyshell/frontend.py +535 -0
- anyshell/pager.py +203 -0
- anyshell/presentation.py +55 -0
- anyshell/py.typed +0 -0
- anyshell-0.1.0.dist-info/METADATA +337 -0
- anyshell-0.1.0.dist-info/RECORD +15 -0
- anyshell-0.1.0.dist-info/WHEEL +4 -0
- anyshell-0.1.0.dist-info/entry_points.txt +3 -0
- anyshell-0.1.0.dist-info/licenses/LICENSE +21 -0
anyshell/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Portable Python commands and async shell sessions, independent of the UI."""
|
|
2
|
+
|
|
3
|
+
from .engine import (
|
|
4
|
+
Command,
|
|
5
|
+
Context,
|
|
6
|
+
EditRequest,
|
|
7
|
+
Event,
|
|
8
|
+
PageRequest,
|
|
9
|
+
Result,
|
|
10
|
+
Session,
|
|
11
|
+
ShellError,
|
|
12
|
+
)
|
|
13
|
+
from .filesystem import FileSystem, LocalFileSystem, MemoryFileSystem
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Command",
|
|
17
|
+
"Context",
|
|
18
|
+
"EditRequest",
|
|
19
|
+
"Event",
|
|
20
|
+
"FileSystem",
|
|
21
|
+
"LocalFileSystem",
|
|
22
|
+
"MemoryFileSystem",
|
|
23
|
+
"PageRequest",
|
|
24
|
+
"Result",
|
|
25
|
+
"Session",
|
|
26
|
+
"ShellError",
|
|
27
|
+
]
|
anyshell/__main__.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Desktop frontend and headless command-line entry points."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import anyio
|
|
10
|
+
|
|
11
|
+
from .engine import Event, Session
|
|
12
|
+
from .filesystem import LocalFileSystem
|
|
13
|
+
from .presentation import (
|
|
14
|
+
LONG_FILE_HEADERS,
|
|
15
|
+
format_long_line,
|
|
16
|
+
format_value,
|
|
17
|
+
is_long_file_record,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main() -> None:
|
|
22
|
+
parser = argparse.ArgumentParser(description="A portable Python command shell")
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--root", type=Path, default=Path.cwd(), help="Workspace directory mapped to /"
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument("--backend", choices=["asyncio", "trio"], default="asyncio")
|
|
27
|
+
parser.add_argument("-c", "--command", help="Execute one command line without a UI")
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--prompt-position",
|
|
30
|
+
choices=["inline", "bottom"],
|
|
31
|
+
default="inline",
|
|
32
|
+
help="Interactive prompt position (default: inline)",
|
|
33
|
+
)
|
|
34
|
+
args = parser.parse_args()
|
|
35
|
+
try:
|
|
36
|
+
session = Session(LocalFileSystem(args.root))
|
|
37
|
+
except OSError as error:
|
|
38
|
+
parser.error(str(error))
|
|
39
|
+
if args.command is not None:
|
|
40
|
+
long_header = False
|
|
41
|
+
|
|
42
|
+
async def emit(event: Event) -> None:
|
|
43
|
+
nonlocal long_header
|
|
44
|
+
if event.kind in {"output", "error"}:
|
|
45
|
+
if event.kind == "output" and is_long_file_record(event.value):
|
|
46
|
+
if not long_header:
|
|
47
|
+
print(format_long_line(LONG_FILE_HEADERS))
|
|
48
|
+
long_header = True
|
|
49
|
+
else:
|
|
50
|
+
long_header = False
|
|
51
|
+
print(
|
|
52
|
+
format_value(event.value),
|
|
53
|
+
file=sys.stderr if event.kind == "error" else sys.stdout,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
async def execute() -> int:
|
|
57
|
+
return (await session.execute(args.command, emit)).status
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
status = anyio.run(execute, backend=args.backend)
|
|
61
|
+
except KeyboardInterrupt:
|
|
62
|
+
status = 130
|
|
63
|
+
raise SystemExit(status)
|
|
64
|
+
try:
|
|
65
|
+
from .frontend import ShellApp
|
|
66
|
+
except ModuleNotFoundError as error:
|
|
67
|
+
if error.name != "anytext":
|
|
68
|
+
raise
|
|
69
|
+
parser.error("Install the frontend with: pip install 'anyshell[anytext]'")
|
|
70
|
+
ShellApp(session, prompt_position=args.prompt_position).run(backend=args.backend)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
main()
|
anyshell/commands.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""Typed Python commands with Cyclopts argument parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import posixpath
|
|
8
|
+
import re
|
|
9
|
+
from contextlib import aclosing
|
|
10
|
+
from fnmatch import fnmatchcase
|
|
11
|
+
from typing import Annotated, Literal
|
|
12
|
+
|
|
13
|
+
import anyio
|
|
14
|
+
from cyclopts import Parameter
|
|
15
|
+
|
|
16
|
+
from .engine import Command, Context, EditRequest, Event, PageRequest, ShellError
|
|
17
|
+
|
|
18
|
+
BUILTINS: list[Command] = []
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def builtin(name: str, description: str, *, standalone: bool = False):
|
|
22
|
+
def register(function):
|
|
23
|
+
BUILTINS.append(Command(name, description, function, standalone))
|
|
24
|
+
return function
|
|
25
|
+
|
|
26
|
+
return register
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def render(value) -> str:
|
|
30
|
+
if isinstance(value, str):
|
|
31
|
+
return value
|
|
32
|
+
try:
|
|
33
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True)
|
|
34
|
+
except (TypeError, ValueError):
|
|
35
|
+
return str(value)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@builtin("help", "List commands or show a command's options.")
|
|
39
|
+
async def help_command(command: str | None = None, *, ctx: Context, stdout):
|
|
40
|
+
if command is not None:
|
|
41
|
+
if command not in ctx.session.commands:
|
|
42
|
+
raise ShellError(f"command not found: {command}")
|
|
43
|
+
for line in ctx.session.commands[command].help_text().splitlines():
|
|
44
|
+
await stdout.send(line)
|
|
45
|
+
else:
|
|
46
|
+
for name, entry in sorted(ctx.session.commands.items()):
|
|
47
|
+
await stdout.send(f"{name:10} {entry.description}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@builtin("echo", "Output one text value.")
|
|
51
|
+
async def echo(*words: str, stdout):
|
|
52
|
+
await stdout.send(" ".join(words))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@builtin("pwd", "Print the session's logical working directory.")
|
|
56
|
+
async def pwd(*, ctx: Context, stdout):
|
|
57
|
+
await stdout.send(ctx.session.cwd)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@builtin("cd", "Change the working directory.", standalone=True)
|
|
61
|
+
async def cd(directory: str = "/", *, ctx: Context):
|
|
62
|
+
path = ctx.resolve(directory)
|
|
63
|
+
if not ctx.fs.is_dir(path):
|
|
64
|
+
raise ShellError(f"not a directory: {path}")
|
|
65
|
+
ctx.session.cwd = path
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@builtin("ls", "List files or directory entries as structured records.")
|
|
69
|
+
async def ls(
|
|
70
|
+
directory: str = ".",
|
|
71
|
+
*,
|
|
72
|
+
long: Annotated[
|
|
73
|
+
bool, Parameter(name=["--long", "-l"], help="Show size and modification time.")
|
|
74
|
+
] = False,
|
|
75
|
+
all: Annotated[
|
|
76
|
+
bool, Parameter(name=["--all", "-a"], help="Include hidden entries.")
|
|
77
|
+
] = False,
|
|
78
|
+
ctx: Context,
|
|
79
|
+
stdout,
|
|
80
|
+
):
|
|
81
|
+
path = ctx.resolve(directory)
|
|
82
|
+
if not ctx.fs.is_dir(path):
|
|
83
|
+
metadata = ctx.fs.stat(path) # Also validates existence for a plain listing.
|
|
84
|
+
row = {"name": posixpath.basename(path), "type": "file"}
|
|
85
|
+
if long:
|
|
86
|
+
row.update(metadata)
|
|
87
|
+
await stdout.send(row)
|
|
88
|
+
return
|
|
89
|
+
for row in ctx.fs.listdir(path):
|
|
90
|
+
if not all and row["name"].startswith("."):
|
|
91
|
+
continue
|
|
92
|
+
if long:
|
|
93
|
+
row = {**row, **ctx.fs.stat(ctx.resolve(directory + "/" + row["name"]))}
|
|
94
|
+
await stdout.send(row)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@builtin("find", "Walk directory trees and filter entries by name or type.")
|
|
98
|
+
async def find(
|
|
99
|
+
*paths: str,
|
|
100
|
+
name: Annotated[
|
|
101
|
+
str | None,
|
|
102
|
+
Parameter(
|
|
103
|
+
name=["-name", "--name"], help="Match the basename with a glob pattern."
|
|
104
|
+
),
|
|
105
|
+
] = None,
|
|
106
|
+
iname: Annotated[
|
|
107
|
+
str | None,
|
|
108
|
+
Parameter(
|
|
109
|
+
name=["-iname", "--iname"], help="Match the basename without letter case."
|
|
110
|
+
),
|
|
111
|
+
] = None,
|
|
112
|
+
type: Annotated[
|
|
113
|
+
Literal["f", "d", "l"] | None,
|
|
114
|
+
Parameter(
|
|
115
|
+
name=["-type", "--type"],
|
|
116
|
+
help="Select files (f), directories (d), or symbolic links (l).",
|
|
117
|
+
),
|
|
118
|
+
] = None,
|
|
119
|
+
max_depth: Annotated[
|
|
120
|
+
int | None,
|
|
121
|
+
Parameter(
|
|
122
|
+
name=["-maxdepth", "--max-depth"],
|
|
123
|
+
help="Descend at most this many levels; starting paths have depth zero.",
|
|
124
|
+
),
|
|
125
|
+
] = None,
|
|
126
|
+
ctx: Context,
|
|
127
|
+
stdout,
|
|
128
|
+
):
|
|
129
|
+
if max_depth is not None and max_depth < 0:
|
|
130
|
+
raise ShellError("maximum depth must be nonnegative")
|
|
131
|
+
kinds = {"f": "file", "d": "directory", "l": "symlink"}
|
|
132
|
+
errors = []
|
|
133
|
+
for root in paths or (".",):
|
|
134
|
+
stack = [(ctx.resolve(root), posixpath.normpath(root), 0)]
|
|
135
|
+
while stack:
|
|
136
|
+
await anyio.lowlevel.checkpoint()
|
|
137
|
+
path, display, depth = stack.pop()
|
|
138
|
+
try:
|
|
139
|
+
kind = ctx.fs.entry_type(path)
|
|
140
|
+
except FileNotFoundError:
|
|
141
|
+
errors.append(f"{display!r}: no such file or directory")
|
|
142
|
+
continue
|
|
143
|
+
except OSError as error:
|
|
144
|
+
errors.append(f"{display!r}: {error}")
|
|
145
|
+
continue
|
|
146
|
+
basename = posixpath.basename(display.rstrip("/")) or "/"
|
|
147
|
+
if (
|
|
148
|
+
(name is None or fnmatchcase(basename, name))
|
|
149
|
+
and (
|
|
150
|
+
iname is None or fnmatchcase(basename.casefold(), iname.casefold())
|
|
151
|
+
)
|
|
152
|
+
and (type is None or kind == kinds[type])
|
|
153
|
+
):
|
|
154
|
+
await stdout.send({"name": basename, "path": display, "type": kind})
|
|
155
|
+
if kind == "directory" and (max_depth is None or depth < max_depth):
|
|
156
|
+
try:
|
|
157
|
+
entries = ctx.fs.listdir(path)
|
|
158
|
+
except OSError as error:
|
|
159
|
+
errors.append(f"{display!r}: {error}")
|
|
160
|
+
continue
|
|
161
|
+
for entry in reversed(entries):
|
|
162
|
+
stack.append(
|
|
163
|
+
(
|
|
164
|
+
posixpath.join(path, entry["name"]),
|
|
165
|
+
posixpath.join(display, entry["name"]),
|
|
166
|
+
depth + 1,
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
if errors:
|
|
170
|
+
raise ShellError("; ".join(errors))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@builtin("cat", "Stream file lines, or pass input through.")
|
|
174
|
+
async def cat(*files: str, ctx: Context, stdin, stdout):
|
|
175
|
+
if not files:
|
|
176
|
+
async for value in stdin:
|
|
177
|
+
await stdout.send(value)
|
|
178
|
+
else:
|
|
179
|
+
for name in files:
|
|
180
|
+
for line in ctx.fs.read_lines(ctx.resolve(name)):
|
|
181
|
+
await stdout.send(line)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@builtin("grep", "Filter input or file lines using a Python regular expression.")
|
|
185
|
+
async def grep(
|
|
186
|
+
pattern: str,
|
|
187
|
+
*files: str,
|
|
188
|
+
ignore_case: Annotated[
|
|
189
|
+
bool, Parameter(name=["--ignore-case", "-i"], help="Ignore letter case.")
|
|
190
|
+
] = False,
|
|
191
|
+
ctx: Context,
|
|
192
|
+
stdin,
|
|
193
|
+
stdout,
|
|
194
|
+
):
|
|
195
|
+
expression = re.compile(pattern, re.IGNORECASE if ignore_case else 0)
|
|
196
|
+
if files:
|
|
197
|
+
for name in files:
|
|
198
|
+
for line in ctx.fs.read_lines(ctx.resolve(name)):
|
|
199
|
+
if expression.search(line):
|
|
200
|
+
await stdout.send(line)
|
|
201
|
+
else:
|
|
202
|
+
await anyio.lowlevel.checkpoint()
|
|
203
|
+
else:
|
|
204
|
+
async for value in stdin:
|
|
205
|
+
if expression.search(render(value)):
|
|
206
|
+
await stdout.send(value)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@builtin("where", "Filter records by a field's text value.")
|
|
210
|
+
async def where(field: str, value: str, *, stdin, stdout):
|
|
211
|
+
async for row in stdin:
|
|
212
|
+
if not isinstance(row, dict) or field not in row:
|
|
213
|
+
raise ShellError(f"input has no field {field!r}")
|
|
214
|
+
if str(row[field]) == value:
|
|
215
|
+
await stdout.send(row)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@builtin("select", "Extract one field from each input record.")
|
|
219
|
+
async def select(field: str, *, stdin, stdout):
|
|
220
|
+
async for row in stdin:
|
|
221
|
+
if not isinstance(row, dict) or field not in row:
|
|
222
|
+
raise ShellError(f"input has no field {field!r}")
|
|
223
|
+
await stdout.send(row[field])
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@builtin("sort", "Buffer and sort input values or a record field.")
|
|
227
|
+
async def sort(
|
|
228
|
+
field: str | None = None,
|
|
229
|
+
*,
|
|
230
|
+
reverse: Annotated[
|
|
231
|
+
bool, Parameter(name=["--reverse", "-r"], help="Reverse the sort order.")
|
|
232
|
+
] = False,
|
|
233
|
+
stdin,
|
|
234
|
+
stdout,
|
|
235
|
+
):
|
|
236
|
+
values = [value async for value in stdin]
|
|
237
|
+
|
|
238
|
+
def key(value):
|
|
239
|
+
if field is not None:
|
|
240
|
+
if not isinstance(value, dict) or field not in value:
|
|
241
|
+
raise ShellError(f"input has no field {field!r}")
|
|
242
|
+
return render(value[field])
|
|
243
|
+
return render(value)
|
|
244
|
+
|
|
245
|
+
for value in sorted(values, key=key, reverse=reverse):
|
|
246
|
+
await stdout.send(value)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@builtin("count", "Output the number of input values.")
|
|
250
|
+
async def count(*, stdin, stdout):
|
|
251
|
+
total = 0
|
|
252
|
+
async for _ in stdin:
|
|
253
|
+
total += 1
|
|
254
|
+
await stdout.send(total)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@builtin("mkdir", "Create one directory.", standalone=True)
|
|
258
|
+
async def mkdir(directory: str, *, ctx: Context):
|
|
259
|
+
path = ctx.resolve(directory)
|
|
260
|
+
try:
|
|
261
|
+
ctx.fs.mkdir(path)
|
|
262
|
+
except FileExistsError:
|
|
263
|
+
kind = "directory" if ctx.fs.is_dir(path) else "file"
|
|
264
|
+
raise ShellError(f"{directory!r}: {kind} already exists") from None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@builtin("rmdir", "Remove empty directories.", standalone=True)
|
|
268
|
+
async def rmdir(directory: str, *directories: str, ctx: Context):
|
|
269
|
+
for name in (directory, *directories):
|
|
270
|
+
ctx.fs.rmdir(ctx.resolve(name))
|
|
271
|
+
await anyio.lowlevel.checkpoint()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@builtin(
|
|
275
|
+
"touch",
|
|
276
|
+
"Create files or update modification times without changing contents.",
|
|
277
|
+
standalone=True,
|
|
278
|
+
)
|
|
279
|
+
async def touch(path: str, *paths: str, ctx: Context):
|
|
280
|
+
for name in (path, *paths):
|
|
281
|
+
ctx.fs.touch(ctx.resolve(name))
|
|
282
|
+
await anyio.lowlevel.checkpoint()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@builtin("write", "Create or replace a UTF-8 file.", standalone=True)
|
|
286
|
+
async def write(path: str, *text: str, ctx: Context):
|
|
287
|
+
if not text:
|
|
288
|
+
raise ShellError("write requires text after the file name")
|
|
289
|
+
ctx.fs.write_text(ctx.resolve(path), " ".join(text) + "\n")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@builtin("rm", "Remove files. Use rmdir for empty directories.", standalone=True)
|
|
293
|
+
async def rm(path: str, *paths: str, ctx: Context):
|
|
294
|
+
errors = []
|
|
295
|
+
for name in (path, *paths):
|
|
296
|
+
try:
|
|
297
|
+
ctx.fs.remove(ctx.resolve(name))
|
|
298
|
+
except IsADirectoryError:
|
|
299
|
+
errors.append(
|
|
300
|
+
f"{name!r} is a directory; use rmdir to remove an empty directory"
|
|
301
|
+
)
|
|
302
|
+
except FileNotFoundError:
|
|
303
|
+
errors.append(f"{name!r}: no such file")
|
|
304
|
+
except OSError as error:
|
|
305
|
+
errors.append(f"{name!r}: {error}")
|
|
306
|
+
await anyio.lowlevel.checkpoint()
|
|
307
|
+
if errors:
|
|
308
|
+
raise ShellError("; ".join(errors))
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _transfer_paths(ctx: Context, source: str, destination: str) -> tuple[str, str]:
|
|
312
|
+
original = ctx.resolve(source)
|
|
313
|
+
target = ctx.resolve(destination)
|
|
314
|
+
if ctx.fs.is_dir(target):
|
|
315
|
+
target = posixpath.join(target, posixpath.basename(original))
|
|
316
|
+
return original, target
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@builtin("mv", "Move or rename a file or directory.", standalone=True)
|
|
320
|
+
async def mv(source: str, destination: str, *, ctx: Context):
|
|
321
|
+
original, target = _transfer_paths(ctx, source, destination)
|
|
322
|
+
ctx.fs.move(original, target)
|
|
323
|
+
if ctx.session.cwd == original or ctx.session.cwd.startswith(original + "/"):
|
|
324
|
+
ctx.session.cwd = target + ctx.session.cwd[len(original) :]
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@builtin("cp", "Copy a file, or a directory with -r.", standalone=True)
|
|
328
|
+
async def cp(
|
|
329
|
+
source: str,
|
|
330
|
+
destination: str,
|
|
331
|
+
*,
|
|
332
|
+
recursive: Annotated[
|
|
333
|
+
bool,
|
|
334
|
+
Parameter(
|
|
335
|
+
name=["--recursive", "-r"], help="Copy directories and their contents."
|
|
336
|
+
),
|
|
337
|
+
] = False,
|
|
338
|
+
ctx: Context,
|
|
339
|
+
):
|
|
340
|
+
original, target = _transfer_paths(ctx, source, destination)
|
|
341
|
+
ctx.fs.copy(original, target, recursive=recursive)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@builtin("more", "Read files or pipeline output in the interactive pager.")
|
|
345
|
+
async def more(*files: str, ctx: Context, stdin):
|
|
346
|
+
await _page(files, ctx, stdin, searchable=False)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@builtin("less", "Read and search files or pipeline output in the interactive pager.")
|
|
350
|
+
async def less(*files: str, ctx: Context, stdin):
|
|
351
|
+
await _page(files, ctx, stdin, searchable=True)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
async def _page(files: tuple[str, ...], ctx: Context, stdin, *, searchable: bool):
|
|
355
|
+
from .presentation import format_value
|
|
356
|
+
|
|
357
|
+
async def lines():
|
|
358
|
+
if files:
|
|
359
|
+
for name in files:
|
|
360
|
+
for line in ctx.fs.read_lines(ctx.resolve(name)):
|
|
361
|
+
yield line
|
|
362
|
+
else:
|
|
363
|
+
async for value in stdin:
|
|
364
|
+
yield format_value(value)
|
|
365
|
+
|
|
366
|
+
async with aclosing(lines()) as stream:
|
|
367
|
+
request = PageRequest(
|
|
368
|
+
" · ".join(files) if files else "Pipeline output",
|
|
369
|
+
stream,
|
|
370
|
+
searchable=searchable,
|
|
371
|
+
)
|
|
372
|
+
await ctx.emit(Event("page", request))
|
|
373
|
+
if not request.handled:
|
|
374
|
+
raise ShellError("an interactive frontend is required to page text")
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
@builtin("edit", "Open a text file in the interactive editor.", standalone=True)
|
|
378
|
+
async def edit(path: str, *, ctx: Context):
|
|
379
|
+
request = EditRequest(ctx.resolve(path))
|
|
380
|
+
await ctx.emit(Event("edit", request))
|
|
381
|
+
if not request.handled:
|
|
382
|
+
raise ShellError("an interactive frontend is required to edit files")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@builtin("clear", "Clear the frontend transcript.", standalone=True)
|
|
386
|
+
async def clear(*, ctx: Context):
|
|
387
|
+
await ctx.emit(Event("clear"))
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@builtin("sleep", "Wait asynchronously; Ctrl+C cancels.")
|
|
391
|
+
async def sleep(seconds: float, *, stdout):
|
|
392
|
+
if not math.isfinite(seconds) or seconds < 0:
|
|
393
|
+
raise ShellError("seconds must be finite and nonnegative")
|
|
394
|
+
await anyio.sleep(seconds)
|
|
395
|
+
await stdout.send(f"slept {seconds:g}s")
|
anyshell/editor.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Interactive file editor for the optional Anytext frontend."""
|
|
2
|
+
|
|
3
|
+
import anyio
|
|
4
|
+
from anytext import on
|
|
5
|
+
from anytext.app import ComposeResult
|
|
6
|
+
from anytext.binding import Binding
|
|
7
|
+
from anytext.containers import Vertical
|
|
8
|
+
from anytext.reactive import var
|
|
9
|
+
from anytext.widgets import Static, TextArea
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from .filesystem import FileSystem
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FileEditor(Vertical):
|
|
16
|
+
"""Edit one UTF-8 file without starting a process or leaving the app."""
|
|
17
|
+
|
|
18
|
+
BINDINGS = [
|
|
19
|
+
Binding("ctrl+s", "save", "Save", priority=True),
|
|
20
|
+
Binding("ctrl+q", "close_editor", "Close", priority=True),
|
|
21
|
+
Binding("ctrl+q", "confirm_discard", "Discard and close", priority=True),
|
|
22
|
+
Binding("escape", "keep_editing", "Keep editing", priority=True),
|
|
23
|
+
Binding("ctrl+d", "discard", show=False, priority=True),
|
|
24
|
+
]
|
|
25
|
+
_confirming_close = var(False, bindings=True)
|
|
26
|
+
|
|
27
|
+
DEFAULT_CSS = """
|
|
28
|
+
FileEditor { width: 1fr; height: 1fr; background: $surface; }
|
|
29
|
+
FileEditor #editor-window {
|
|
30
|
+
width: 1fr; height: 1fr; background: $surface;
|
|
31
|
+
}
|
|
32
|
+
FileEditor #editor-title { height: 1; padding: 0 1; }
|
|
33
|
+
FileEditor TextArea { height: 1fr; }
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, filesystem: FileSystem, path: str) -> None:
|
|
37
|
+
super().__init__()
|
|
38
|
+
self.closed = anyio.Event()
|
|
39
|
+
self._confirming_close = False
|
|
40
|
+
self.filesystem = filesystem
|
|
41
|
+
self.path = path
|
|
42
|
+
if filesystem.is_dir(path):
|
|
43
|
+
raise IsADirectoryError(f"{path!r} is a directory")
|
|
44
|
+
try:
|
|
45
|
+
self._original = filesystem.read_text(path)
|
|
46
|
+
except FileNotFoundError:
|
|
47
|
+
self._original = ""
|
|
48
|
+
self._saved_text = self._original
|
|
49
|
+
self._observed_text = self._original
|
|
50
|
+
self._newline = "\r\n" if "\r\n" in self._original else "\n"
|
|
51
|
+
|
|
52
|
+
def compose(self) -> ComposeResult:
|
|
53
|
+
with Vertical(id="editor-window"):
|
|
54
|
+
yield Static(Text(self.path), id="editor-title")
|
|
55
|
+
yield TextArea(self._original, show_line_numbers=True, id="file-text")
|
|
56
|
+
|
|
57
|
+
def on_mount(self) -> None:
|
|
58
|
+
area = self.query_one(TextArea)
|
|
59
|
+
# Use the widget's normalized representation when checking for changes.
|
|
60
|
+
self._saved_text = area.text
|
|
61
|
+
self._observed_text = area.text
|
|
62
|
+
area.focus()
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def dirty(self) -> bool:
|
|
66
|
+
return self.query_one(TextArea).text != self._saved_text
|
|
67
|
+
|
|
68
|
+
@on(TextArea.Changed)
|
|
69
|
+
def text_changed(self) -> None:
|
|
70
|
+
self.query_one("#editor-title", Static).update(
|
|
71
|
+
Text(self.path + (" *" if self.dirty else ""))
|
|
72
|
+
)
|
|
73
|
+
text = self.query_one(TextArea).text
|
|
74
|
+
if text != self._observed_text:
|
|
75
|
+
self._observed_text = text
|
|
76
|
+
self._confirming_close = False
|
|
77
|
+
|
|
78
|
+
def action_save(self) -> None:
|
|
79
|
+
text = self.query_one(TextArea).text
|
|
80
|
+
self._observed_text = text
|
|
81
|
+
# Preserve the newline convention of an existing CRLF file.
|
|
82
|
+
content = text.replace("\r\n", "\n").replace("\n", self._newline)
|
|
83
|
+
try:
|
|
84
|
+
self.filesystem.write_text(self.path, content)
|
|
85
|
+
except (OSError, UnicodeError) as error:
|
|
86
|
+
self.app.notify(
|
|
87
|
+
str(error), title="Could not save", severity="error", markup=False
|
|
88
|
+
)
|
|
89
|
+
return
|
|
90
|
+
self._saved_text = text
|
|
91
|
+
self.query_one("#editor-title", Static).update(Text(self.path))
|
|
92
|
+
self._confirming_close = False
|
|
93
|
+
self.query_one(TextArea).focus()
|
|
94
|
+
|
|
95
|
+
def action_close_editor(self) -> None:
|
|
96
|
+
text = self.query_one(TextArea).text
|
|
97
|
+
if self._confirming_close and text == self._observed_text:
|
|
98
|
+
self.closed.set()
|
|
99
|
+
return
|
|
100
|
+
self._observed_text = text
|
|
101
|
+
if self.dirty:
|
|
102
|
+
self._confirming_close = True
|
|
103
|
+
else:
|
|
104
|
+
self.closed.set()
|
|
105
|
+
|
|
106
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
107
|
+
if action == "close_editor":
|
|
108
|
+
return not self._confirming_close
|
|
109
|
+
if action in {"discard", "confirm_discard", "keep_editing"}:
|
|
110
|
+
return self._confirming_close
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
def action_confirm_discard(self) -> None:
|
|
114
|
+
self.action_close_editor()
|
|
115
|
+
|
|
116
|
+
def action_discard(self) -> None:
|
|
117
|
+
if self._confirming_close:
|
|
118
|
+
self.closed.set()
|
|
119
|
+
|
|
120
|
+
def action_keep_editing(self) -> None:
|
|
121
|
+
self._confirming_close = False
|
|
122
|
+
self.query_one(TextArea).focus()
|