anyshell 0.1.0__tar.gz → 0.2.0__tar.gz

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.
Files changed (34) hide show
  1. {anyshell-0.1.0 → anyshell-0.2.0}/PKG-INFO +72 -7
  2. {anyshell-0.1.0 → anyshell-0.2.0}/README.md +71 -6
  3. {anyshell-0.1.0 → anyshell-0.2.0}/examples/browser/app.py +1 -1
  4. {anyshell-0.1.0 → anyshell-0.2.0}/examples/browser/browser.js +18 -4
  5. {anyshell-0.1.0 → anyshell-0.2.0}/examples/browser/index.html +5 -4
  6. {anyshell-0.1.0 → anyshell-0.2.0}/pyproject.toml +1 -1
  7. {anyshell-0.1.0 → anyshell-0.2.0}/pyproject.toml.orig +1 -1
  8. {anyshell-0.1.0 → anyshell-0.2.0}/src/anyshell/__init__.py +2 -0
  9. {anyshell-0.1.0 → anyshell-0.2.0}/src/anyshell/__main__.py +13 -1
  10. {anyshell-0.1.0 → anyshell-0.2.0}/src/anyshell/commands.py +81 -27
  11. {anyshell-0.1.0 → anyshell-0.2.0}/src/anyshell/editor.py +22 -7
  12. {anyshell-0.1.0 → anyshell-0.2.0}/src/anyshell/engine.py +141 -26
  13. {anyshell-0.1.0 → anyshell-0.2.0}/src/anyshell/filesystem.py +16 -6
  14. {anyshell-0.1.0 → anyshell-0.2.0}/src/anyshell/frontend.py +185 -13
  15. anyshell-0.2.0/src/anyshell/log.py +51 -0
  16. {anyshell-0.1.0 → anyshell-0.2.0}/src/anyshell/pager.py +88 -15
  17. {anyshell-0.1.0 → anyshell-0.2.0}/tests/browser/shell.test.cjs +113 -14
  18. {anyshell-0.1.0 → anyshell-0.2.0}/tests/test_editor.py +27 -0
  19. {anyshell-0.1.0 → anyshell-0.2.0}/tests/test_engine.py +50 -0
  20. anyshell-0.2.0/tests/test_frontend.py +593 -0
  21. anyshell-0.2.0/tests/test_jobs.py +194 -0
  22. {anyshell-0.1.0 → anyshell-0.2.0}/tests/test_multiline.py +65 -1
  23. {anyshell-0.1.0 → anyshell-0.2.0}/tests/test_options.py +34 -2
  24. {anyshell-0.1.0 → anyshell-0.2.0}/tests/test_pager.py +199 -10
  25. anyshell-0.1.0/tests/test_frontend.py +0 -297
  26. {anyshell-0.1.0 → anyshell-0.2.0}/LICENSE +0 -0
  27. {anyshell-0.1.0 → anyshell-0.2.0}/src/anyshell/presentation.py +0 -0
  28. {anyshell-0.1.0 → anyshell-0.2.0}/src/anyshell/py.typed +0 -0
  29. {anyshell-0.1.0 → anyshell-0.2.0}/tests/browser/package-lock.json +0 -0
  30. {anyshell-0.1.0 → anyshell-0.2.0}/tests/browser/package.json +0 -0
  31. {anyshell-0.1.0 → anyshell-0.2.0}/tests/conftest.py +0 -0
  32. {anyshell-0.1.0 → anyshell-0.2.0}/tests/test_find.py +0 -0
  33. {anyshell-0.1.0 → anyshell-0.2.0}/tests/test_transfers.py +0 -0
  34. {anyshell-0.1.0 → anyshell-0.2.0}/tools/build_browser.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anyshell
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: An async Python command shell with an optional Anytext frontend
5
5
  Author: David Brochart
6
6
  Author-email: David Brochart <david.brochart@gmail.com>
@@ -44,6 +44,14 @@ editor, run `uv run --extra anytext anyshell --prompt-position bottom` or constr
44
44
 
45
45
  The widget provides a scrollable transcript, Up/Down history, Tab completion for
46
46
  command names, options, and unquoted paths, Ctrl+C cancellation, and Ctrl+Q to quit the demo.
47
+ Drag across command output to select text. Ctrl+C copies selected output when no
48
+ foreground command is running; Ctrl+Shift+C copies it while a command is running.
49
+ Mouse selection and copying also work in the pager. Browser copying uses the
50
+ browser clipboard when permission is available.
51
+
52
+ The prompt is replaced by a blank cursor line while a foreground command runs
53
+ and returns when it finishes
54
+ or is cancelled with Ctrl+C. Background commands return the prompt immediately.
47
55
  Tab first extends the shared prefix when several completions match. If no more
48
56
  characters can be filled in, Tab opens a menu beside the cursor. Use Up/Down or
49
57
  Tab to select, Enter to insert, and Escape to dismiss; typing also dismisses it.
@@ -65,7 +73,8 @@ cat notes.txt |
65
73
  count
66
74
  ```
67
75
 
68
- Unquoted newlines separate commands, which execute in order. A backslash followed
76
+ Unquoted newlines and semicolons separate commands, which execute in order.
77
+ For example, `sleep 1; sleep 1` waits twice in sequence. A backslash followed
69
78
  by a newline joins lines without inserting a character. Newlines inside quotes
70
79
  remain part of the argument. All commands are parsed and validated before any
71
80
  runs; a runtime failure does not prevent later commands from running, and the
@@ -114,14 +123,58 @@ is `None`. `ls -a` (`--all`) includes hidden entries, and flags combine as `-la`
114
123
  `touch PATH...` creates missing files or updates modification times without
115
124
  changing existing contents. Existing directories can also be touched.
116
125
  `rm PATH...` removes files, continuing with remaining paths if one fails.
126
+ `rm -r DIRECTORY...` removes directory trees, including hidden entries.
127
+ `rm -f` ignores missing paths and accepts no operands; combine the flags as
128
+ `rm -rf DIRECTORY...`. Force does not suppress other errors. Recursive removal
129
+ does not follow symbolic links and cannot remove the workspace root.
117
130
  `rmdir DIRECTORY...` removes empty directories and rejects files and nonempty
118
131
  directories; its completion suggests only directories.
119
132
 
120
133
  This is an initial shell language: quoting, multiline commands, and pipes are supported. Bash scripts,
121
- redirection, environment expansion, glob expansion, process execution, job
122
- control, and persistent history are not implemented. Commands are trusted Python
134
+ redirection, environment expansion, glob expansion, process execution, foregrounding or suspending jobs, and persistent history
135
+ are not implemented. Commands are trusted Python
123
136
  code, not a sandbox. Browser filesystem changes are in-memory and reset on reload.
124
137
 
138
+ ## Background commands
139
+
140
+ Append `&` to run a command or pipeline in the background and return to the prompt:
141
+
142
+ ```sh
143
+ sleep 30 &
144
+ cat notes.txt | grep Python &
145
+ jobs
146
+ wait 1
147
+ kill 1
148
+ ```
149
+
150
+ `jobs` lists job IDs and states. `wait ID` waits for a job; `wait` waits for all
151
+ jobs still in the job list. `kill ID` cancels a job cooperatively. Ctrl+C cancels the
152
+ foreground command (including `wait`), leaving background jobs running. Job
153
+ output appears in the transcript, with launch and completion notifications.
154
+ Completed jobs are shown once by `jobs`, then removed from the job list.
155
+ Job IDs are not reused during the session.
156
+
157
+ An unquoted `&` also separates commands: `sleep 30 & echo ready`. Quote or escape
158
+ it to pass a literal ampersand. `&&` is not supported. Each job inherits the
159
+ working directory and command registry at launch and shares the filesystem;
160
+ changing directory in one job does not affect the prompt or other jobs.
161
+ Interactive commands such as `edit`, `more`, and `less` cannot run in the
162
+ background. Closing the shell cancels unfinished jobs. Headless `-c` execution
163
+ waits for background jobs before exiting; its exit status is the last submitted
164
+ foreground command's status (or zero for a background launch).
165
+
166
+ Engine callers provide a lifetime for background tasks explicitly:
167
+
168
+ ```python
169
+ async with session.background():
170
+ await session.execute("sleep 1 &", receive)
171
+ await session.wait_jobs()
172
+ ```
173
+
174
+ Leaving this context cancels unfinished jobs. `Session.jobs` maps IDs to `Job`
175
+ objects with `source`, `result`, and `done`; launch and completion events have
176
+ kind `job`. Background completion does not change `Session.last_status`.
177
+
125
178
  ## Find files
126
179
 
127
180
  ```sh
@@ -147,7 +200,8 @@ directory, or absolute when the starting path is absolute), and `type` (`file`,
147
200
  are listed without following them. Errors are reported while traversal continues
148
201
  with other entries and starting paths; any error makes the command fail.
149
202
  Custom filesystem implementations provide `entry_type()` without following the
150
- final symbolic link, alongside the existing directory-listing methods.
203
+ final symbolic link, alongside the existing directory-listing methods. Filesystems supporting recursive
204
+ removal also accept `remove(path, recursive=True)`.
151
205
 
152
206
  ## Move and copy files
153
207
 
@@ -184,6 +238,8 @@ and Home/End jumps to the beginning/end. `q`, Ctrl+Q, or Escape closes the pager
184
238
  Mouse scrolling also works. Shortcuts appear in the footer.
185
239
 
186
240
  The pager accepts files or pipeline input, formatting Python values for display.
241
+ File arguments are opened and checked before the pager appears; missing or
242
+ unreadable files report an error directly in the shell.
187
243
  It streams input into scrollback; closing it stops consuming the pipeline.
188
244
  Headless execution reports that an interactive frontend is required. Other
189
245
  frontends can handle the `page` event's `PageRequest`, consume its `lines`, mark
@@ -246,6 +302,9 @@ async def main():
246
302
  anyio.run(main)
247
303
  ```
248
304
 
305
+ Help uses the current transcript or terminal width. Engine callers can set
306
+ `Session.execute(..., output_width=100)` when supplying their own frontend.
307
+
249
308
  Commands use Cyclopts to parse typed Python parameters. `--help` (or `-h`)
250
309
  and `help COMMAND` write help to the transcript. Invalid arguments produce shell
251
310
  errors without exiting the app. All pipeline arguments are validated before any
@@ -288,6 +347,12 @@ session, so the engine and UI can evolve independently.
288
347
 
289
348
  ## Browser demo
290
349
 
350
+ The deployment workflow publishes the demo to https://davidbrochart.github.io/anyshell/.
351
+ GitHub Actions builds and deploys it from `main` using
352
+ `.github/workflows/pages.yml`; it can also be deployed manually from Actions.
353
+
354
+ To run it locally:
355
+
291
356
  ```sh
292
357
  uv run python tools/build_browser.py
293
358
  uv run python -m http.server --directory .cache/browser-site 8000
@@ -328,8 +393,8 @@ Set `project.version` in `pyproject.toml`, update the lockfile with `uv lock`, a
328
393
  commit the release changes. Push a matching version tag, without a `v` prefix:
329
394
 
330
395
  ```sh
331
- git tag 0.1.0
332
- git push origin 0.1.0
396
+ git tag 0.2.0
397
+ git push origin 0.2.0
333
398
  ```
334
399
 
335
400
  The `release.yml` workflow checks that the tag matches the package version,
@@ -27,6 +27,14 @@ editor, run `uv run --extra anytext anyshell --prompt-position bottom` or constr
27
27
 
28
28
  The widget provides a scrollable transcript, Up/Down history, Tab completion for
29
29
  command names, options, and unquoted paths, Ctrl+C cancellation, and Ctrl+Q to quit the demo.
30
+ Drag across command output to select text. Ctrl+C copies selected output when no
31
+ foreground command is running; Ctrl+Shift+C copies it while a command is running.
32
+ Mouse selection and copying also work in the pager. Browser copying uses the
33
+ browser clipboard when permission is available.
34
+
35
+ The prompt is replaced by a blank cursor line while a foreground command runs
36
+ and returns when it finishes
37
+ or is cancelled with Ctrl+C. Background commands return the prompt immediately.
30
38
  Tab first extends the shared prefix when several completions match. If no more
31
39
  characters can be filled in, Tab opens a menu beside the cursor. Use Up/Down or
32
40
  Tab to select, Enter to insert, and Escape to dismiss; typing also dismisses it.
@@ -48,7 +56,8 @@ cat notes.txt |
48
56
  count
49
57
  ```
50
58
 
51
- Unquoted newlines separate commands, which execute in order. A backslash followed
59
+ Unquoted newlines and semicolons separate commands, which execute in order.
60
+ For example, `sleep 1; sleep 1` waits twice in sequence. A backslash followed
52
61
  by a newline joins lines without inserting a character. Newlines inside quotes
53
62
  remain part of the argument. All commands are parsed and validated before any
54
63
  runs; a runtime failure does not prevent later commands from running, and the
@@ -97,14 +106,58 @@ is `None`. `ls -a` (`--all`) includes hidden entries, and flags combine as `-la`
97
106
  `touch PATH...` creates missing files or updates modification times without
98
107
  changing existing contents. Existing directories can also be touched.
99
108
  `rm PATH...` removes files, continuing with remaining paths if one fails.
109
+ `rm -r DIRECTORY...` removes directory trees, including hidden entries.
110
+ `rm -f` ignores missing paths and accepts no operands; combine the flags as
111
+ `rm -rf DIRECTORY...`. Force does not suppress other errors. Recursive removal
112
+ does not follow symbolic links and cannot remove the workspace root.
100
113
  `rmdir DIRECTORY...` removes empty directories and rejects files and nonempty
101
114
  directories; its completion suggests only directories.
102
115
 
103
116
  This is an initial shell language: quoting, multiline commands, and pipes are supported. Bash scripts,
104
- redirection, environment expansion, glob expansion, process execution, job
105
- control, and persistent history are not implemented. Commands are trusted Python
117
+ redirection, environment expansion, glob expansion, process execution, foregrounding or suspending jobs, and persistent history
118
+ are not implemented. Commands are trusted Python
106
119
  code, not a sandbox. Browser filesystem changes are in-memory and reset on reload.
107
120
 
121
+ ## Background commands
122
+
123
+ Append `&` to run a command or pipeline in the background and return to the prompt:
124
+
125
+ ```sh
126
+ sleep 30 &
127
+ cat notes.txt | grep Python &
128
+ jobs
129
+ wait 1
130
+ kill 1
131
+ ```
132
+
133
+ `jobs` lists job IDs and states. `wait ID` waits for a job; `wait` waits for all
134
+ jobs still in the job list. `kill ID` cancels a job cooperatively. Ctrl+C cancels the
135
+ foreground command (including `wait`), leaving background jobs running. Job
136
+ output appears in the transcript, with launch and completion notifications.
137
+ Completed jobs are shown once by `jobs`, then removed from the job list.
138
+ Job IDs are not reused during the session.
139
+
140
+ An unquoted `&` also separates commands: `sleep 30 & echo ready`. Quote or escape
141
+ it to pass a literal ampersand. `&&` is not supported. Each job inherits the
142
+ working directory and command registry at launch and shares the filesystem;
143
+ changing directory in one job does not affect the prompt or other jobs.
144
+ Interactive commands such as `edit`, `more`, and `less` cannot run in the
145
+ background. Closing the shell cancels unfinished jobs. Headless `-c` execution
146
+ waits for background jobs before exiting; its exit status is the last submitted
147
+ foreground command's status (or zero for a background launch).
148
+
149
+ Engine callers provide a lifetime for background tasks explicitly:
150
+
151
+ ```python
152
+ async with session.background():
153
+ await session.execute("sleep 1 &", receive)
154
+ await session.wait_jobs()
155
+ ```
156
+
157
+ Leaving this context cancels unfinished jobs. `Session.jobs` maps IDs to `Job`
158
+ objects with `source`, `result`, and `done`; launch and completion events have
159
+ kind `job`. Background completion does not change `Session.last_status`.
160
+
108
161
  ## Find files
109
162
 
110
163
  ```sh
@@ -130,7 +183,8 @@ directory, or absolute when the starting path is absolute), and `type` (`file`,
130
183
  are listed without following them. Errors are reported while traversal continues
131
184
  with other entries and starting paths; any error makes the command fail.
132
185
  Custom filesystem implementations provide `entry_type()` without following the
133
- final symbolic link, alongside the existing directory-listing methods.
186
+ final symbolic link, alongside the existing directory-listing methods. Filesystems supporting recursive
187
+ removal also accept `remove(path, recursive=True)`.
134
188
 
135
189
  ## Move and copy files
136
190
 
@@ -167,6 +221,8 @@ and Home/End jumps to the beginning/end. `q`, Ctrl+Q, or Escape closes the pager
167
221
  Mouse scrolling also works. Shortcuts appear in the footer.
168
222
 
169
223
  The pager accepts files or pipeline input, formatting Python values for display.
224
+ File arguments are opened and checked before the pager appears; missing or
225
+ unreadable files report an error directly in the shell.
170
226
  It streams input into scrollback; closing it stops consuming the pipeline.
171
227
  Headless execution reports that an interactive frontend is required. Other
172
228
  frontends can handle the `page` event's `PageRequest`, consume its `lines`, mark
@@ -229,6 +285,9 @@ async def main():
229
285
  anyio.run(main)
230
286
  ```
231
287
 
288
+ Help uses the current transcript or terminal width. Engine callers can set
289
+ `Session.execute(..., output_width=100)` when supplying their own frontend.
290
+
232
291
  Commands use Cyclopts to parse typed Python parameters. `--help` (or `-h`)
233
292
  and `help COMMAND` write help to the transcript. Invalid arguments produce shell
234
293
  errors without exiting the app. All pipeline arguments are validated before any
@@ -271,6 +330,12 @@ session, so the engine and UI can evolve independently.
271
330
 
272
331
  ## Browser demo
273
332
 
333
+ The deployment workflow publishes the demo to https://davidbrochart.github.io/anyshell/.
334
+ GitHub Actions builds and deploys it from `main` using
335
+ `.github/workflows/pages.yml`; it can also be deployed manually from Actions.
336
+
337
+ To run it locally:
338
+
274
339
  ```sh
275
340
  uv run python tools/build_browser.py
276
341
  uv run python -m http.server --directory .cache/browser-site 8000
@@ -311,8 +376,8 @@ Set `project.version` in `pyproject.toml`, update the lockfile with `uv lock`, a
311
376
  commit the release changes. Push a matching version tag, without a `v` prefix:
312
377
 
313
378
  ```sh
314
- git tag 0.1.0
315
- git push origin 0.1.0
379
+ git tag 0.2.0
380
+ git push origin 0.2.0
316
381
  ```
317
382
 
318
383
  The `release.yml` workflow checks that the tag matches the package version,
@@ -53,7 +53,7 @@ class BrowserShell(ShellApp):
53
53
  session = Session(
54
54
  MemoryFileSystem(
55
55
  {
56
- "readme.txt": "Welcome to Anyshell!\nEvery command runs in Python.\nTry help or ls | select name | sort.\n",
56
+ "readme.txt": "Welcome to anyshell!\n",
57
57
  "notes/fruits.txt": "pear\napple\nbanana\napricot\n",
58
58
  }
59
59
  )
@@ -16,15 +16,29 @@ const proxies = [];
16
16
  try {
17
17
  const terminal = new Terminal({ cursorBlink: true, fontSize: 15,
18
18
  fontFamily: "ui-monospace, monospace", allowProposedApi: true,
19
- theme: { background: "#0c0e14" } });
19
+ customGlyphs: true, scrollback: 0, theme: { background: "#0c0e14" } });
20
20
  terminal.loadAddon(new Unicode11Addon.Unicode11Addon());
21
21
  terminal.unicode.activeVersion = "11";
22
22
  const fit = new FitAddon.FitAddon();
23
23
  terminal.loadAddon(fit);
24
- terminal.open(document.querySelector("#terminal"));
24
+ // Fit against an unpadded inner element, not the bordered outer container.
25
+ const terminalScreen = document.querySelector("#terminal-screen");
26
+ terminal.open(terminalScreen);
27
+ // Draw block and box glyphs as shapes so font metrics cannot leave row seams.
28
+ terminal.loadAddon(new CanvasAddon.CanvasAddon());
29
+ // Forward the shell's terminal clipboard writes to the browser clipboard.
30
+ disposables.push(terminal.parser.registerOscHandler(52, (data) => {
31
+ const encoded = data.slice(data.indexOf(";") + 1);
32
+ if (encoded === "?" || !navigator.clipboard?.writeText) return false;
33
+ try {
34
+ const text = new TextDecoder().decode(Uint8Array.from(atob(encoded), c => c.charCodeAt(0)));
35
+ navigator.clipboard.writeText(text).catch(() => {});
36
+ return true;
37
+ } catch { return false; }
38
+ }));
25
39
  fit.fit();
26
40
  observer = new ResizeObserver(() => fit.fit());
27
- observer.observe(document.querySelector("#terminal"));
41
+ observer.observe(terminalScreen);
28
42
  const { loadPyodide } = await import("https://cdn.jsdelivr.net/pyodide/v314.0.7/full/pyodide.mjs");
29
43
  const pyodide = await loadPyodide();
30
44
  window.anyshellDemo = { pyodide, terminal };
@@ -43,7 +57,7 @@ try {
43
57
  pyodide.globals.set("browser_ready", () => {
44
58
  ready = true;
45
59
  resize(terminal.cols, terminal.rows);
46
- status.textContent = "Running · Python commands · No threads";
60
+ status.textContent = "Running";
47
61
  terminal.focus();
48
62
  });
49
63
  const source = await fetch("app.py", { cache: "no-store" });
@@ -15,13 +15,15 @@
15
15
  .controls { display: flex; align-items: center; gap: 8px; }
16
16
  button, select { color: inherit; background: #252b39; border: 1px solid #46516a; border-radius: 6px; padding: 8px 14px; cursor: pointer; }
17
17
  #status { color: #a9c6ff; min-height: 20px; }
18
- #terminal { flex: 1; min-height: 180px; padding: 12px; background: #0c0e14; border: 1px solid #343d50; border-radius: 8px; overflow: hidden; }
18
+ header, #status { flex-shrink: 0; }
19
+ #terminal { display: flex; flex: 1; min-height: 0; padding: 12px; background: #0c0e14; border: 1px solid #343d50; border-radius: 8px; overflow: hidden; }
20
+ #terminal-screen { flex: 1; min-width: 0; min-height: 0; }
19
21
  #error { white-space: pre-wrap; overflow: auto; color: #ffb4b4; margin: 0; max-height: 30vh; }
20
- footer { color: #a9b3c4; font-size: 13px; }
21
22
  </style>
22
23
  <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js"></script>
23
24
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js"></script>
24
25
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-unicode11@0.8.0/lib/addon-unicode11.js"></script>
26
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-canvas@0.7.0/lib/addon-canvas.js"></script>
25
27
  </head>
26
28
  <body>
27
29
  <main>
@@ -30,9 +32,8 @@
30
32
  <div class="controls"><label for="prompt-position">Prompt</label><select id="prompt-position"><option value="inline">Inline</option><option value="bottom">Bottom</option></select><button id="reload" type="button">Reload</button></div>
31
33
  </header>
32
34
  <div id="status" role="status">Loading Python…</div>
33
- <div id="terminal" aria-label="Anyshell terminal"></div>
35
+ <div id="terminal" aria-label="Anyshell terminal"><div id="terminal-screen"></div></div>
34
36
  <pre id="error" role="alert" hidden></pre>
35
- <footer>Try help, cat readme.txt, or ls | select name | sort. Tab completes; Up/Down recalls history. Ctrl+C cancels; Ctrl+Q quits. Files reset on reload.</footer>
36
37
  </main>
37
38
  <script type="module" src="browser.js"></script>
38
39
  </body>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "anyshell"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "An async Python command shell with an optional Anytext frontend"
5
5
  requires-python = ">=3.11"
6
6
  readme = "README.md"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "anyshell"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "An async Python command shell with an optional Anytext frontend"
5
5
  requires-python = ">=3.11"
6
6
  readme = "README.md"
@@ -5,6 +5,7 @@ from .engine import (
5
5
  Context,
6
6
  EditRequest,
7
7
  Event,
8
+ Job,
8
9
  PageRequest,
9
10
  Result,
10
11
  Session,
@@ -18,6 +19,7 @@ __all__ = [
18
19
  "EditRequest",
19
20
  "Event",
20
21
  "FileSystem",
22
+ "Job",
21
23
  "LocalFileSystem",
22
24
  "MemoryFileSystem",
23
25
  "PageRequest",
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import argparse
6
6
  import sys
7
7
  from pathlib import Path
8
+ from shutil import get_terminal_size
8
9
 
9
10
  import anyio
10
11
 
@@ -41,6 +42,12 @@ def main() -> None:
41
42
 
42
43
  async def emit(event: Event) -> None:
43
44
  nonlocal long_header
45
+ if event.kind == "job":
46
+ job = event.value
47
+ state = (
48
+ "running" if job.result is None else f"done ({job.result.status})"
49
+ )
50
+ print(f"[{job.id}] {state} {job.source}", file=sys.stderr)
44
51
  if event.kind in {"output", "error"}:
45
52
  if event.kind == "output" and is_long_file_record(event.value):
46
53
  if not long_header:
@@ -54,7 +61,12 @@ def main() -> None:
54
61
  )
55
62
 
56
63
  async def execute() -> int:
57
- return (await session.execute(args.command, emit)).status
64
+ async with session.background():
65
+ result = await session.execute(
66
+ args.command, emit, output_width=get_terminal_size((88, 24)).columns
67
+ )
68
+ await session.wait_jobs()
69
+ return result.status
58
70
 
59
71
  try:
60
72
  status = anyio.run(execute, backend=args.backend)
@@ -6,7 +6,7 @@ import json
6
6
  import math
7
7
  import posixpath
8
8
  import re
9
- from contextlib import aclosing
9
+ from contextlib import ExitStack, aclosing
10
10
  from fnmatch import fnmatchcase
11
11
  from typing import Annotated, Literal
12
12
 
@@ -40,7 +40,9 @@ async def help_command(command: str | None = None, *, ctx: Context, stdout):
40
40
  if command is not None:
41
41
  if command not in ctx.session.commands:
42
42
  raise ShellError(f"command not found: {command}")
43
- for line in ctx.session.commands[command].help_text().splitlines():
43
+ for line in (
44
+ ctx.session.commands[command].help_text(width=ctx.output_width).splitlines()
45
+ ):
44
46
  await stdout.send(line)
45
47
  else:
46
48
  for name, entry in sorted(ctx.session.commands.items()):
@@ -289,18 +291,35 @@ async def write(path: str, *text: str, ctx: Context):
289
291
  ctx.fs.write_text(ctx.resolve(path), " ".join(text) + "\n")
290
292
 
291
293
 
292
- @builtin("rm", "Remove files. Use rmdir for empty directories.", standalone=True)
293
- async def rm(path: str, *paths: str, ctx: Context):
294
+ @builtin("rm", "Remove files, or directory trees with -r.", standalone=True)
295
+ async def rm(
296
+ *paths: str,
297
+ recursive: Annotated[
298
+ bool,
299
+ Parameter(name=["--recursive", "-r", "-R"], help="Remove directory trees."),
300
+ ] = False,
301
+ force: Annotated[
302
+ bool,
303
+ Parameter(name=["--force", "-f"], help="Ignore missing paths and operands."),
304
+ ] = False,
305
+ ctx: Context,
306
+ ):
307
+ if not paths and not force:
308
+ raise ShellError("missing path")
294
309
  errors = []
295
- for name in (path, *paths):
310
+ for name in paths:
296
311
  try:
297
- ctx.fs.remove(ctx.resolve(name))
312
+ if recursive:
313
+ ctx.fs.remove(ctx.resolve(name), recursive=True)
314
+ else:
315
+ ctx.fs.remove(ctx.resolve(name))
298
316
  except IsADirectoryError:
299
317
  errors.append(
300
318
  f"{name!r} is a directory; use rmdir to remove an empty directory"
301
319
  )
302
320
  except FileNotFoundError:
303
- errors.append(f"{name!r}: no such file")
321
+ if not force:
322
+ errors.append(f"{name!r}: no such file")
304
323
  except OSError as error:
305
324
  errors.append(f"{name!r}: {error}")
306
325
  await anyio.lowlevel.checkpoint()
@@ -354,24 +373,36 @@ async def less(*files: str, ctx: Context, stdin):
354
373
  async def _page(files: tuple[str, ...], ctx: Context, stdin, *, searchable: bool):
355
374
  from .presentation import format_value
356
375
 
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")
376
+ with ExitStack() as stack:
377
+ sources = []
378
+ for name in files:
379
+ source = iter(ctx.fs.read_lines(ctx.resolve(name)))
380
+ if (close := getattr(source, "close", None)) is not None:
381
+ stack.callback(close)
382
+ # Opening and reading once catches missing paths, permissions and
383
+ # initial decoding errors before a frontend can display the pager.
384
+ sources.append((next(source, None), source))
385
+
386
+ async def lines():
387
+ if files:
388
+ for first, source in sources:
389
+ if first is not None:
390
+ yield first
391
+ for line in source:
392
+ yield line
393
+ else:
394
+ async for value in stdin:
395
+ yield format_value(value)
396
+
397
+ async with aclosing(lines()) as stream:
398
+ request = PageRequest(
399
+ " · ".join(files) if files else "Pipeline output",
400
+ stream,
401
+ searchable=searchable,
402
+ )
403
+ await ctx.emit(Event("page", request))
404
+ if not request.handled:
405
+ raise ShellError("an interactive frontend is required to page text")
375
406
 
376
407
 
377
408
  @builtin("edit", "Open a text file in the interactive editor.", standalone=True)
@@ -388,8 +419,31 @@ async def clear(*, ctx: Context):
388
419
 
389
420
 
390
421
  @builtin("sleep", "Wait asynchronously; Ctrl+C cancels.")
391
- async def sleep(seconds: float, *, stdout):
422
+ async def sleep(seconds: float):
392
423
  if not math.isfinite(seconds) or seconds < 0:
393
424
  raise ShellError("seconds must be finite and nonnegative")
394
425
  await anyio.sleep(seconds)
395
- await stdout.send(f"slept {seconds:g}s")
426
+
427
+
428
+ @builtin("jobs", "List background jobs.")
429
+ async def jobs(*, ctx: Context, stdout):
430
+ for job in list(ctx.session.jobs.values()):
431
+ completed = job.result is not None
432
+ state = f"done ({job.result.status})" if completed else "running"
433
+ await stdout.send(f"[{job.id}] {state} {job.source}")
434
+ if completed:
435
+ ctx.session.jobs.pop(job.id, None)
436
+
437
+
438
+ @builtin("wait", "Wait for one or all background jobs.", standalone=True)
439
+ async def wait(job: int | None = None, *, ctx: Context):
440
+ status = await ctx.session.wait_jobs(job)
441
+ if status:
442
+ raise ShellError(f"background job exited with status {status}")
443
+
444
+
445
+ @builtin("kill", "Cancel a background job.", standalone=True)
446
+ async def kill(job: int, *, ctx: Context):
447
+ entry = ctx.session.get_job(job)
448
+ if entry.scope is not None:
449
+ entry.scope.cancel()
@@ -1,7 +1,7 @@
1
1
  """Interactive file editor for the optional Anytext frontend."""
2
2
 
3
3
  import anyio
4
- from anytext import on
4
+ from anytext import events, on
5
5
  from anytext.app import ComposeResult
6
6
  from anytext.binding import Binding
7
7
  from anytext.containers import Vertical
@@ -12,15 +12,30 @@ from rich.text import Text
12
12
  from .filesystem import FileSystem
13
13
 
14
14
 
15
+ class FileTextArea(TextArea):
16
+ """Consume editor shortcuts in order with edits, before app-wide bindings."""
17
+
18
+ async def on_key(self, event: events.Key) -> None:
19
+ editor = self.query_ancestor(FileEditor)
20
+ for binding in editor.BINDINGS:
21
+ if event.key in binding.key.split(",") and editor.check_action(
22
+ binding.action, ()
23
+ ):
24
+ event.stop()
25
+ event.prevent_default()
26
+ await editor.run_action(binding.action)
27
+ return
28
+
29
+
15
30
  class FileEditor(Vertical):
16
31
  """Edit one UTF-8 file without starting a process or leaving the app."""
17
32
 
18
33
  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),
34
+ Binding("ctrl+s", "save", "Save"),
35
+ Binding("ctrl+q", "close_editor", "Close"),
36
+ Binding("ctrl+q", "confirm_discard", "Discard and close"),
37
+ Binding("escape", "keep_editing", "Keep editing"),
38
+ Binding("ctrl+d", "discard", show=False),
24
39
  ]
25
40
  _confirming_close = var(False, bindings=True)
26
41
 
@@ -52,7 +67,7 @@ class FileEditor(Vertical):
52
67
  def compose(self) -> ComposeResult:
53
68
  with Vertical(id="editor-window"):
54
69
  yield Static(Text(self.path), id="editor-title")
55
- yield TextArea(self._original, show_line_numbers=True, id="file-text")
70
+ yield FileTextArea(self._original, show_line_numbers=True, id="file-text")
56
71
 
57
72
  def on_mount(self) -> None:
58
73
  area = self.query_one(TextArea)