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/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
|
+
]
|