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 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()