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/engine.py ADDED
@@ -0,0 +1,422 @@
1
+ """UI-independent sessions and streaming Python command pipelines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import posixpath
6
+ import shlex
7
+ from collections.abc import AsyncIterator, Awaitable, Callable
8
+ from dataclasses import dataclass, field
9
+ from inspect import BoundArguments, isawaitable
10
+ from io import StringIO
11
+ from typing import Any, Literal
12
+
13
+ import anyio
14
+ from cyclopts import App as ArgumentParser
15
+ from cyclopts import CycloptsError, Parameter
16
+ from rich.console import Console
17
+
18
+ from .filesystem import FileSystem, MemoryFileSystem, resolve_path
19
+
20
+
21
+ class ShellError(Exception):
22
+ """A command or syntax error suitable for display to the shell user."""
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Event:
27
+ kind: Literal["output", "error", "clear", "cwd", "status", "edit", "page"]
28
+ value: Any = None
29
+
30
+
31
+ @dataclass
32
+ class EditRequest:
33
+ """An interactive frontend acknowledges and handles a file-edit request."""
34
+
35
+ path: str
36
+ handled: bool = False
37
+
38
+
39
+ @dataclass
40
+ class PageRequest:
41
+ """A frontend displays streamed text and returns when its pager closes."""
42
+
43
+ title: str
44
+ lines: AsyncIterator[str]
45
+ searchable: bool = False
46
+ handled: bool = False
47
+
48
+
49
+ Emitter = Callable[[Event], Awaitable[None]]
50
+
51
+
52
+ @dataclass
53
+ class Context:
54
+ session: Session
55
+ emit: Emitter
56
+
57
+ @property
58
+ def fs(self) -> FileSystem:
59
+ return self.session.filesystem
60
+
61
+ def resolve(self, path: str) -> str:
62
+ return resolve_path(self.session.cwd, path)
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class Command:
67
+ """A typed Python command; ctx/context, stdin and stdout are injected by name.
68
+
69
+ Injected parameters must be keyword-only. Other parameters are parsed by
70
+ Cyclopts, including Annotated option aliases and validators.
71
+ """
72
+
73
+ name: str
74
+ description: str
75
+ run: Callable[..., Any]
76
+ standalone: bool = False
77
+ _parser: ArgumentParser = field(init=False, repr=False, compare=False)
78
+
79
+ def __post_init__(self) -> None:
80
+ parser = ArgumentParser(
81
+ name=self.name,
82
+ help=self.description,
83
+ version_flags=(),
84
+ default_parameter=Parameter(parse=r"^(?!(ctx|context|stdin|stdout)$)"),
85
+ )
86
+ parser.default(self.run)
87
+ object.__setattr__(self, "_parser", parser)
88
+
89
+ def prepare(self, arguments: list[str]) -> _Invocation:
90
+ output = StringIO()
91
+ console = Console(file=output, width=88, color_system=None)
92
+ try:
93
+ callback, bound, ignored = self._parser.parse_args(
94
+ arguments,
95
+ console=console,
96
+ error_console=console,
97
+ print_error=False,
98
+ exit_on_error=False,
99
+ help_on_error=False,
100
+ )
101
+ except CycloptsError as error:
102
+ raise ShellError(f"{self.name}: {error}") from error
103
+ unknown = set(ignored) - {"ctx", "context", "stdin", "stdout"}
104
+ if unknown:
105
+ raise ShellError(
106
+ f"unsupported injected parameters: {', '.join(sorted(unknown))}"
107
+ )
108
+ return _Invocation(
109
+ self.name,
110
+ callback,
111
+ bound,
112
+ ignored,
113
+ output,
114
+ callback == self._parser.help_print,
115
+ )
116
+
117
+ def option_names(self) -> list[str]:
118
+ names = set(self._parser.help_flags)
119
+ for argument in self._parser.assemble_argument_collection():
120
+ if argument.parse and argument.show:
121
+ names.update(argument.names)
122
+ return sorted(name for name in names if name.startswith("-"))
123
+
124
+ def help_text(self) -> str:
125
+ output = StringIO()
126
+ self._parser.help_print(
127
+ console=Console(file=output, width=88, color_system=None)
128
+ )
129
+ return output.getvalue().rstrip()
130
+
131
+
132
+ @dataclass
133
+ class _Invocation:
134
+ name: str
135
+ callback: Callable[..., Any]
136
+ bound: BoundArguments
137
+ ignored: dict[str, Any]
138
+ help_output: StringIO
139
+ is_help: bool
140
+
141
+ async def run(self, context: Context, stdin, stdout) -> None:
142
+ injected = {
143
+ "ctx": context,
144
+ "context": context,
145
+ "stdin": stdin,
146
+ "stdout": stdout,
147
+ }
148
+ self.bound.arguments.update({name: injected[name] for name in self.ignored})
149
+ result = self.callback(*self.bound.args, **self.bound.kwargs)
150
+ if isawaitable(result):
151
+ result = await result
152
+ if result is not None:
153
+ await stdout.send(result)
154
+ # Help uses a per-invocation console, never a global stdout redirect.
155
+ for line in self.help_output.getvalue().splitlines():
156
+ await stdout.send(line)
157
+
158
+
159
+ @dataclass(frozen=True)
160
+ class Result:
161
+ status: int
162
+ cancelled: bool = False
163
+
164
+
165
+ def parse_pipeline(source: str) -> list[list[str]]:
166
+ """Split unquoted pipes, then parse each stage with shell-style quoting."""
167
+ parts: list[str] = []
168
+ start = 0
169
+ quote = ""
170
+ escaped = False
171
+ for index, char in enumerate(source):
172
+ if escaped:
173
+ escaped = False
174
+ elif char == "\\" and quote != "'":
175
+ escaped = True
176
+ elif quote:
177
+ if char == quote:
178
+ quote = ""
179
+ elif char in "\"'":
180
+ quote = char
181
+ elif char == "|":
182
+ parts.append(source[start:index])
183
+ start = index + 1
184
+ parts.append(source[start:])
185
+ try:
186
+ stages = [shlex.split(part) for part in parts]
187
+ except ValueError as error:
188
+ raise ShellError(str(error)) from error
189
+ if len(stages) == 1 and not stages[0]:
190
+ return []
191
+ if any(not stage for stage in stages):
192
+ raise ShellError("empty pipeline stage")
193
+ return stages
194
+
195
+
196
+ def _scan_commands(
197
+ source: str, *, keep_empty_last: bool = False
198
+ ) -> tuple[list[str], bool]:
199
+ commands: list[str] = []
200
+ buffer: list[str] = []
201
+ quote = ""
202
+ pipe = False
203
+ incomplete = False
204
+ index = 0
205
+ while index < len(source):
206
+ char = source[index]
207
+ incomplete = False
208
+ if char == "\\" and quote != "'":
209
+ if index + 1 == len(source):
210
+ buffer.append(char)
211
+ incomplete = True
212
+ break
213
+ following = source[index + 1]
214
+ if following != "\n":
215
+ buffer.extend((char, following))
216
+ pipe = False
217
+ index += 2
218
+ continue
219
+ if quote:
220
+ buffer.append(char)
221
+ if char == quote:
222
+ quote = ""
223
+ elif char in "\"'":
224
+ quote = char
225
+ pipe = False
226
+ buffer.append(char)
227
+ elif char == "\n" and not pipe:
228
+ if "".join(buffer).strip():
229
+ commands.append("".join(buffer))
230
+ buffer.clear()
231
+ else:
232
+ buffer.append(char)
233
+ if not char.isspace():
234
+ pipe = char == "|"
235
+ index += 1
236
+ if "".join(buffer).strip() or keep_empty_last:
237
+ commands.append("".join(buffer))
238
+ return commands, bool(quote or pipe or incomplete)
239
+
240
+
241
+ def is_complete(source: str) -> bool:
242
+ """Whether Enter can submit, rather than continue an unfinished command."""
243
+ return not _scan_commands(source)[1]
244
+
245
+
246
+ def parse_commands(source: str) -> list[list[list[str]]]:
247
+ """Parse newline-separated pipelines, preserving quoted newlines."""
248
+ commands, incomplete = _scan_commands(source)
249
+ if incomplete:
250
+ raise ShellError(
251
+ "unfinished command: close quotes or complete the continuation"
252
+ )
253
+ return [parse_pipeline(command) for command in commands]
254
+
255
+
256
+ class Session:
257
+ """A shell session owns commands, history, working directory and cancellation.
258
+
259
+ Commands exchange Python values through bounded AnyIO streams. One command
260
+ line may run per session. Distinct sessions can run concurrently.
261
+ """
262
+
263
+ def __init__(self, filesystem: FileSystem | None = None) -> None:
264
+ self.filesystem = filesystem if filesystem is not None else MemoryFileSystem()
265
+ self.cwd = "/"
266
+ self.history: list[str] = []
267
+ self.commands: dict[str, Command] = {}
268
+ self._scope: anyio.CancelScope | None = None
269
+ self.last_status = 0
270
+ self._executing = False
271
+ from .commands import BUILTINS
272
+
273
+ for command in BUILTINS:
274
+ self.register(command)
275
+
276
+ @property
277
+ def running(self) -> bool:
278
+ return self._executing
279
+
280
+ def register(self, command: Command) -> None:
281
+ if not command.name or any(
282
+ char.isspace() or char == "|" for char in command.name
283
+ ):
284
+ raise ValueError("command names must be nonempty words")
285
+ self.commands[command.name] = command
286
+
287
+ def cancel(self) -> bool:
288
+ """Cancel the active execution from the same event loop."""
289
+ if self._scope is None:
290
+ return False
291
+ self._scope.cancel()
292
+ return True
293
+
294
+ async def execute(self, source: str, emit: Emitter) -> Result:
295
+ if self.running:
296
+ raise ShellError("this session is already running a command")
297
+ source = source.strip() if "\n" not in source else source
298
+ if not source.strip():
299
+ return Result(0)
300
+ self._executing = True
301
+ try:
302
+ return await self._execute(source, emit)
303
+ finally:
304
+ self._executing = False
305
+
306
+ async def _execute(self, source: str, emit: Emitter) -> Result:
307
+ if not self.history or self.history[-1] != source:
308
+ self.history.append(source)
309
+ status = 0
310
+ previous_cwd = self.cwd
311
+ with anyio.CancelScope() as scope:
312
+ self._scope = scope
313
+ try:
314
+ pipelines = []
315
+ for stages in parse_commands(source):
316
+ commands = []
317
+ for stage in stages:
318
+ if stage[0] not in self.commands:
319
+ raise ShellError(f"command not found: {stage[0]}")
320
+ command = self.commands[stage[0]]
321
+ invocation = command.prepare(stage[1:])
322
+ if (
323
+ len(stages) > 1
324
+ and command.standalone
325
+ and not invocation.is_help
326
+ ):
327
+ raise ShellError(
328
+ f"{command.name} must run outside a pipeline"
329
+ )
330
+ commands.append(invocation)
331
+ pipelines.append(commands)
332
+ for commands in pipelines:
333
+ status = await self._pipeline(commands, emit)
334
+ except (ShellError, OSError, ValueError) as error:
335
+ status = 1
336
+ await emit(Event("error", str(error)))
337
+ finally:
338
+ self._scope = None
339
+ cancelled = scope.cancel_called
340
+ self.last_status = 130 if cancelled else status
341
+ if self.cwd != previous_cwd:
342
+ await emit(Event("cwd", self.cwd))
343
+ await emit(Event("status", self.last_status))
344
+ return Result(self.last_status, cancelled)
345
+
346
+ async def _pipeline(self, commands, emit: Emitter) -> int:
347
+ streams = [
348
+ anyio.create_memory_object_stream[Any](16) for _ in range(len(commands) + 1)
349
+ ]
350
+ await streams[0][0].aclose() # First stage gets EOF on standard input.
351
+ statuses = [0] * len(commands)
352
+ context = Context(self, emit)
353
+
354
+ async def run_stage(index: int) -> None:
355
+ receive = streams[index][1]
356
+ send = streams[index + 1][0]
357
+ async with receive, send:
358
+ try:
359
+ await commands[index].run(context, receive, send)
360
+ except anyio.BrokenResourceError:
361
+ # A downstream command may deliberately stop consuming input.
362
+ pass
363
+ except Exception as error:
364
+ statuses[index] = 1
365
+ await emit(Event("error", f"{commands[index].name}: {error}"))
366
+
367
+ try:
368
+ async with anyio.create_task_group() as group:
369
+ for index in range(len(commands)):
370
+ group.start_soon(run_stage, index)
371
+ async with streams[-1][1] as output:
372
+ async for value in output:
373
+ await emit(Event("output", value))
374
+ finally:
375
+ for send, receive in streams:
376
+ await send.aclose()
377
+ await receive.aclose()
378
+ return int(any(statuses)) # A failure in any stage fails the pipeline.
379
+
380
+ async def complete(self, source: str) -> list[str]:
381
+ """Return whole-line replacements for command names and unquoted paths."""
382
+ await anyio.lowlevel.checkpoint()
383
+ commands, _ = _scan_commands(source, keep_empty_last=True)
384
+ segment = commands[-1]
385
+ segment = segment[segment.rfind("|") + 1 :]
386
+ words = segment.lstrip().split()
387
+ is_command = len(words) <= 1 and not segment.endswith(" ")
388
+ prefix = words[-1] if words and not segment.endswith(" ") else ""
389
+ start = len(source) - len(prefix)
390
+ if prefix and not source.endswith(prefix):
391
+ return [] # A word split by backslash-newline has no contiguous suffix.
392
+ if is_command:
393
+ return [
394
+ source[:start] + name
395
+ for name in sorted(self.commands)
396
+ if name.startswith(prefix)
397
+ ]
398
+ if prefix.startswith("-") and words and words[0] in self.commands:
399
+ return [
400
+ source[:start] + option
401
+ for option in self.commands[words[0]].option_names()
402
+ if option.startswith(prefix)
403
+ ]
404
+ directory, name = posixpath.split(prefix)
405
+ try:
406
+ rows = self.filesystem.listdir(resolve_path(self.cwd, directory or "."))
407
+ except OSError:
408
+ return []
409
+ return [
410
+ source[:start]
411
+ + shlex.quote(
412
+ posixpath.join(directory, row["name"])
413
+ + ("/" if row["type"] == "directory" else "")
414
+ )
415
+ for row in rows
416
+ if row["name"].startswith(name)
417
+ and (
418
+ not words
419
+ or words[0] not in {"cd", "rmdir"}
420
+ or row["type"] == "directory"
421
+ )
422
+ ]