zettcode-app 0.4.2__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.
- zettcode/app/__init__.py +63 -0
- zettcode/app/agent/__init__.py +47 -0
- zettcode/app/agent/agent/__init__.py +84 -0
- zettcode/app/agent/agent/commands.py +269 -0
- zettcode/app/agent/agent/settings.py +132 -0
- zettcode/app/agent/conversation/__init__.py +7 -0
- zettcode/app/agent/conversation/changes.py +64 -0
- zettcode/app/agent/conversation/effort_gauge.py +126 -0
- zettcode/app/agent/conversation/projection.py +265 -0
- zettcode/app/agent/conversation/rendering.py +837 -0
- zettcode/app/agent/conversation/replay.py +198 -0
- zettcode/app/agent/conversation/shimmer.py +256 -0
- zettcode/app/agent/conversation/stops.py +57 -0
- zettcode/app/agent/conversation/tips.py +86 -0
- zettcode/app/agent/conversation/transcript/__init__.py +80 -0
- zettcode/app/agent/conversation/transcript/fold.py +57 -0
- zettcode/app/agent/conversation/transcript/log.py +182 -0
- zettcode/app/agent/conversation/transcript/oneoff.py +55 -0
- zettcode/app/agent/conversation/transcript/stream.py +187 -0
- zettcode/app/agent/conversation/transcript/turn.py +323 -0
- zettcode/app/commandline/__init__.py +47 -0
- zettcode/app/commandline/arguments.py +169 -0
- zettcode/app/commandline/completion.py +262 -0
- zettcode/app/commandline/history.py +305 -0
- zettcode/app/commandline/runner.py +365 -0
- zettcode/app/errors.py +12 -0
- zettcode/app/plugins/__init__.py +110 -0
- zettcode/app/plugins/builtins.py +33 -0
- zettcode/app/plugins/groups.py +503 -0
- zettcode/app/plugins/loader.py +268 -0
- zettcode/app/py.typed +0 -0
- zettcode/app/ui/__init__.py +63 -0
- zettcode/app/ui/app.py +1507 -0
- zettcode/app/ui/bars.py +290 -0
- zettcode/app/ui/boot.py +131 -0
- zettcode/app/ui/clipboard.py +340 -0
- zettcode/app/ui/commands.py +323 -0
- zettcode/app/ui/config_form.py +343 -0
- zettcode/app/ui/demo.py +180 -0
- zettcode/app/ui/followups.py +430 -0
- zettcode/app/ui/gc_policy.py +147 -0
- zettcode/app/ui/keys.py +462 -0
- zettcode/app/ui/messaging.py +260 -0
- zettcode/app/ui/notices.py +195 -0
- zettcode/app/ui/providers.py +626 -0
- zettcode/app/ui/sessions.py +422 -0
- zettcode/app/ui/settings.py +161 -0
- zettcode/app/ui/shell.py +361 -0
- zettcode/app/ui/turns.py +115 -0
- zettcode/app/ui/updating.py +204 -0
- zettcode/app/ui/widgets/__init__.py +161 -0
- zettcode/app/ui/widgets/approval_page.py +284 -0
- zettcode/app/ui/widgets/ask_page.py +386 -0
- zettcode/app/ui/widgets/bash_page.py +90 -0
- zettcode/app/ui/widgets/completer.py +100 -0
- zettcode/app/ui/widgets/composer.py +529 -0
- zettcode/app/ui/widgets/document_page.py +159 -0
- zettcode/app/ui/widgets/effort_page.py +294 -0
- zettcode/app/ui/widgets/follow_up.py +168 -0
- zettcode/app/ui/widgets/form_page.py +380 -0
- zettcode/app/ui/widgets/input_page.py +175 -0
- zettcode/app/ui/widgets/model_page.py +63 -0
- zettcode/app/ui/widgets/model_settings_page.py +653 -0
- zettcode/app/ui/widgets/pet.py +247 -0
- zettcode/app/ui/widgets/pet_page.py +347 -0
- zettcode/app/ui/widgets/plugins_page.py +442 -0
- zettcode/app/ui/widgets/root.py +68 -0
- zettcode/app/ui/widgets/self_compact_page.py +363 -0
- zettcode/app/ui/widgets/send_kind_page.py +193 -0
- zettcode/app/ui/widgets/sessions_page.py +511 -0
- zettcode/app/ui/widgets/statusline_page.py +427 -0
- zettcode/app/ui/widgets/steering.py +126 -0
- zettcode/app/ui/widgets/transcript.py +287 -0
- zettcode/app/ui/widgets/update_page.py +65 -0
- zettcode/app/ui/widgets/welcome.py +24 -0
- zettcode/collector.py +54 -0
- zettcode/update.py +519 -0
- zettcode_app-0.4.2.dist-info/METADATA +26 -0
- zettcode_app-0.4.2.dist-info/RECORD +81 -0
- zettcode_app-0.4.2.dist-info/WHEEL +4 -0
- zettcode_app-0.4.2.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Run one command at a time in the reader's shell and stream what it prints.
|
|
2
|
+
|
|
3
|
+
A command is run by the reader's own shell (``$SHELL -c``, PowerShell on Windows), in the directory the panel is in,
|
|
4
|
+
with no input — a program that asks a question gets the end of its input, and says so, instead of waiting for a
|
|
5
|
+
key nobody can press. Each command is a process of its own; the environment does not carry from one to the next,
|
|
6
|
+
except where the directory does: a bare ``cd`` is the panel's to do, because a ``cd`` in a process that is about to
|
|
7
|
+
exit changes nothing.
|
|
8
|
+
|
|
9
|
+
:class:`ShellLauncher` is how a shell is asked to run a string, and there is one per family: the strategy that
|
|
10
|
+
differs between a Unix shell, PowerShell and ``cmd`` is the arguments, and nothing else here knows which is in use.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import codecs
|
|
17
|
+
import contextlib
|
|
18
|
+
import os
|
|
19
|
+
import shlex
|
|
20
|
+
import shutil
|
|
21
|
+
import signal
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
from abc import ABC, abstractmethod
|
|
26
|
+
from collections.abc import Callable, Mapping
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path, PureWindowsPath
|
|
29
|
+
|
|
30
|
+
from zettcode.tui import SEPARATOR
|
|
31
|
+
|
|
32
|
+
#: Seconds a command is given to end after Ctrl-C before it is killed.
|
|
33
|
+
KILL_AFTER = 3.0
|
|
34
|
+
|
|
35
|
+
#: Bytes read from the command at once, and the pause after a read that filled it. A program that prints without
|
|
36
|
+
#: end is slowed by a full pipe rather than by the interface: the panel keeps answering keys.
|
|
37
|
+
CHUNK = 16384
|
|
38
|
+
PAUSE = 0.01
|
|
39
|
+
|
|
40
|
+
#: Shells that take ``-c <command>`` and are worth running the reader's commands with.
|
|
41
|
+
POSIX_SHELLS = frozenset({"bash", "zsh", "fish", "sh", "dash", "ksh", "ash"})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ShellLauncher(ABC):
|
|
45
|
+
"""How one family of shells is asked to run a string.
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
name: What the shell is called, shown in the panel.
|
|
49
|
+
prompt: The glyph before the line the commands are typed on.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
name = ""
|
|
53
|
+
prompt = "$"
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def argv(self, command: str) -> list[str]:
|
|
57
|
+
"""Return the program and arguments that run ``command``."""
|
|
58
|
+
|
|
59
|
+
def environment(self, base: Mapping[str, str]) -> dict[str, str]:
|
|
60
|
+
"""Return the environment to run in: the reader's, plus what makes programs print the way a terminal shows.
|
|
61
|
+
|
|
62
|
+
Programs colour their output only when it goes to a terminal, and a pipe is not one, so they are told to:
|
|
63
|
+
``FORCE_COLOR`` and ``CLICOLOR_FORCE`` for the many that read them, ``CLICOLOR`` for BSD and macOS ``ls``,
|
|
64
|
+
``PY_COLORS`` for pytest and ``CARGO_TERM_COLOR`` for cargo. git reads none of those, so it is given
|
|
65
|
+
``color.ui=always`` through ``GIT_CONFIG_*``, which takes effect for the one process and leaves the reader's
|
|
66
|
+
gitconfig alone (and, being ahead of it in git's order, still loses to a ``color.status = false`` of theirs).
|
|
67
|
+
All of it is skipped when ``NO_COLOR`` is set, and a variable the reader already set keeps their value.
|
|
68
|
+
Pager defaults assume a person at a terminal; the panel is a screen, but not one a pager can be driven from.
|
|
69
|
+
"""
|
|
70
|
+
env = dict(base)
|
|
71
|
+
env.update({"TERM": "xterm-256color", "PYTHONUNBUFFERED": "1"})
|
|
72
|
+
env.setdefault("PAGER", "cat")
|
|
73
|
+
env.setdefault("GIT_PAGER", "cat")
|
|
74
|
+
if "NO_COLOR" in env:
|
|
75
|
+
return env
|
|
76
|
+
for name, value in (
|
|
77
|
+
("FORCE_COLOR", "1"),
|
|
78
|
+
("CLICOLOR", "1"),
|
|
79
|
+
("CLICOLOR_FORCE", "1"),
|
|
80
|
+
("PY_COLORS", "1"),
|
|
81
|
+
("CARGO_TERM_COLOR", "always"),
|
|
82
|
+
):
|
|
83
|
+
env.setdefault(name, value)
|
|
84
|
+
# git's own settings can already be coming in this way; this one goes after them.
|
|
85
|
+
raw = env.get("GIT_CONFIG_COUNT", "0")
|
|
86
|
+
count = int(raw) if raw.isdigit() else 0
|
|
87
|
+
env[f"GIT_CONFIG_KEY_{count}"] = "color.ui"
|
|
88
|
+
env[f"GIT_CONFIG_VALUE_{count}"] = "always"
|
|
89
|
+
env["GIT_CONFIG_COUNT"] = str(count + 1)
|
|
90
|
+
return env
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class PosixLauncher(ShellLauncher):
|
|
94
|
+
"""bash, zsh, fish, sh and the like: ``<shell> -c <command>``.
|
|
95
|
+
|
|
96
|
+
fish is started without its configuration. ``fish -c`` reads ``config.fish`` every time, and a configuration with
|
|
97
|
+
a version manager or a prompt in it takes half a second to a second — for every command, measured at 0.6-0.9 s
|
|
98
|
+
against 4 ms without. bash, zsh and sh do not read their rc files for ``-c``, so they are not slow and are not
|
|
99
|
+
changed. What this costs is a fish user's own aliases and functions; the environment, ``PATH`` included, is
|
|
100
|
+
inherited from the shell that started the program and is not affected.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(self, shell: str) -> None:
|
|
104
|
+
self.shell = shell
|
|
105
|
+
self.name = Path(shell).name
|
|
106
|
+
|
|
107
|
+
def argv(self, command: str) -> list[str]:
|
|
108
|
+
"""Return ``[shell, "-c", command]``, with fish's ``--no-config`` before it."""
|
|
109
|
+
if self.name == "fish":
|
|
110
|
+
return [self.shell, "--no-config", "-c", command]
|
|
111
|
+
return [self.shell, "-c", command]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class PowerShellLauncher(ShellLauncher):
|
|
115
|
+
"""Windows PowerShell and PowerShell 7: output as UTF-8, no profile, no prompts."""
|
|
116
|
+
|
|
117
|
+
prompt = ">"
|
|
118
|
+
|
|
119
|
+
def __init__(self, executable: str) -> None:
|
|
120
|
+
self.executable = executable
|
|
121
|
+
self.name = PureWindowsPath(executable).stem
|
|
122
|
+
|
|
123
|
+
def argv(self, command: str) -> list[str]:
|
|
124
|
+
"""Return the arguments that run ``command`` with its output encoded as UTF-8."""
|
|
125
|
+
script = "[Console]::OutputEncoding=[Text.Encoding]::UTF8;$OutputEncoding=[Console]::OutputEncoding;" + command
|
|
126
|
+
return [self.executable, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class CommandPromptLauncher(ShellLauncher):
|
|
130
|
+
"""``cmd.exe``, for a machine with no PowerShell."""
|
|
131
|
+
|
|
132
|
+
name = "cmd"
|
|
133
|
+
prompt = ">"
|
|
134
|
+
|
|
135
|
+
def __init__(self, executable: str = "cmd.exe") -> None:
|
|
136
|
+
self.executable = executable
|
|
137
|
+
|
|
138
|
+
def argv(self, command: str) -> list[str]:
|
|
139
|
+
"""Return ``cmd /d /c <command>``."""
|
|
140
|
+
return [self.executable, "/d", "/c", command]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def detect_launcher(env: Mapping[str, str] | None = None, platform: str = sys.platform) -> ShellLauncher:
|
|
144
|
+
"""Return the launcher for the shell this reader has.
|
|
145
|
+
|
|
146
|
+
On Unix that is the shell in ``$SHELL`` when it is one that takes ``-c``, so a command is read the way the
|
|
147
|
+
reader writes it (a fish user's ``set -x`` is not a syntax error), otherwise bash, otherwise ``/bin/sh``. On
|
|
148
|
+
Windows it is PowerShell 7, Windows PowerShell, or ``cmd``, whichever is found first.
|
|
149
|
+
"""
|
|
150
|
+
env = os.environ if env is None else env
|
|
151
|
+
if platform == "win32":
|
|
152
|
+
for candidate in ("pwsh", "powershell"):
|
|
153
|
+
found = shutil.which(candidate)
|
|
154
|
+
if found:
|
|
155
|
+
return PowerShellLauncher(found)
|
|
156
|
+
return CommandPromptLauncher(env.get("COMSPEC", "cmd.exe"))
|
|
157
|
+
shell = env.get("SHELL", "")
|
|
158
|
+
if shell and Path(shell).name in POSIX_SHELLS and os.access(shell, os.X_OK):
|
|
159
|
+
return PosixLauncher(shell)
|
|
160
|
+
return PosixLauncher(shutil.which("bash") or "/bin/sh")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass(frozen=True, slots=True)
|
|
164
|
+
class RunResult:
|
|
165
|
+
"""How a command ended.
|
|
166
|
+
|
|
167
|
+
Attributes:
|
|
168
|
+
code: The exit status, or ``None`` when the command could not be started.
|
|
169
|
+
seconds: How long it ran.
|
|
170
|
+
stopped: It ended because the reader stopped it.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
code: int | None
|
|
174
|
+
seconds: float
|
|
175
|
+
stopped: bool = False
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def text(self) -> str:
|
|
179
|
+
"""Return the outcome in a few words, for the panel's heading."""
|
|
180
|
+
if self.code is None:
|
|
181
|
+
return "not started"
|
|
182
|
+
how = "stopped" if self.stopped else f"exit {self.code}"
|
|
183
|
+
return f"{how} {SEPARATOR} {self.seconds:.1f}s"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class CommandRunner:
|
|
187
|
+
"""Run commands one after another in a directory, and tell the panel what each prints.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
cwd: The directory commands run in; ``cd`` changes it.
|
|
191
|
+
launcher: How the shell is asked to run a string; the reader's own shell by default.
|
|
192
|
+
env: The environment commands inherit; the real one by default.
|
|
193
|
+
home: What ``cd`` with no argument goes to, and ``~`` stands for.
|
|
194
|
+
kill_after: Seconds a command has to end after being stopped before it is killed.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
def __init__(
|
|
198
|
+
self,
|
|
199
|
+
cwd: Path,
|
|
200
|
+
launcher: ShellLauncher | None = None,
|
|
201
|
+
*,
|
|
202
|
+
env: Mapping[str, str] | None = None,
|
|
203
|
+
home: Path | None = None,
|
|
204
|
+
kill_after: float = KILL_AFTER,
|
|
205
|
+
) -> None:
|
|
206
|
+
self.cwd = cwd
|
|
207
|
+
self.previous = cwd
|
|
208
|
+
self.env = os.environ if env is None else env
|
|
209
|
+
self.launcher = launcher or detect_launcher(self.env)
|
|
210
|
+
self.home = Path.home() if home is None else home
|
|
211
|
+
self.kill_after = kill_after
|
|
212
|
+
self._process: asyncio.subprocess.Process | None = None
|
|
213
|
+
self._stops = 0
|
|
214
|
+
self._starting = False
|
|
215
|
+
self._early_stop = False
|
|
216
|
+
self._killer: asyncio.TimerHandle | None = None
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def running(self) -> bool:
|
|
220
|
+
"""Return whether a command is running now."""
|
|
221
|
+
return self._process is not None and self._process.returncode is None
|
|
222
|
+
|
|
223
|
+
# -- cd -----------------------------------------------------------------
|
|
224
|
+
def change_directory(self, argument: str) -> str | None:
|
|
225
|
+
"""Move to a directory as ``cd`` does; return the complaint, or ``None`` when it worked."""
|
|
226
|
+
try:
|
|
227
|
+
words = shlex.split(argument, posix=os.name != "nt")
|
|
228
|
+
except ValueError as error:
|
|
229
|
+
return f"cd: {error}"
|
|
230
|
+
if os.name == "nt":
|
|
231
|
+
# Outside POSIX mode shlex keeps the quotes round a word; a shell takes them off.
|
|
232
|
+
words = [
|
|
233
|
+
word[1:-1] if len(word) >= 2 and word[0] == word[-1] and word[0] in "\"'" else word for word in words
|
|
234
|
+
]
|
|
235
|
+
if words[:1] == ["/d"]:
|
|
236
|
+
words = words[1:]
|
|
237
|
+
if len(words) > 1:
|
|
238
|
+
return "cd: too many arguments"
|
|
239
|
+
target = words[0] if words else "~"
|
|
240
|
+
if target == "-":
|
|
241
|
+
destination = self.previous
|
|
242
|
+
else:
|
|
243
|
+
if target == "~" or target.startswith(("~/", "~\\")):
|
|
244
|
+
target = str(self.home) + target[1:]
|
|
245
|
+
destination = Path(target)
|
|
246
|
+
if not destination.is_absolute():
|
|
247
|
+
destination = self.cwd / destination
|
|
248
|
+
try:
|
|
249
|
+
destination = destination.resolve()
|
|
250
|
+
except OSError as error:
|
|
251
|
+
return f"cd: {target}: {error.strerror or error}"
|
|
252
|
+
if not destination.is_dir():
|
|
253
|
+
return f"cd: no such directory: {target}"
|
|
254
|
+
self.previous, self.cwd = self.cwd, destination
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
# -- running ------------------------------------------------------------
|
|
258
|
+
async def run(self, command: str, write: Callable[[str], None]) -> RunResult:
|
|
259
|
+
"""Run ``command`` to its end, calling ``write`` with what it prints as it prints it."""
|
|
260
|
+
started = time.monotonic()
|
|
261
|
+
stripped = command.strip()
|
|
262
|
+
if stripped == "cd" or stripped.startswith(("cd ", "cd\t")):
|
|
263
|
+
error = self.change_directory(stripped[2:])
|
|
264
|
+
if error is not None:
|
|
265
|
+
write(error + "\n")
|
|
266
|
+
return RunResult(1 if error else 0, time.monotonic() - started)
|
|
267
|
+
self._stops = 0
|
|
268
|
+
self._starting, self._early_stop = True, False
|
|
269
|
+
try:
|
|
270
|
+
process = self._process = await asyncio.create_subprocess_exec(
|
|
271
|
+
*self.launcher.argv(command),
|
|
272
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
273
|
+
stdout=asyncio.subprocess.PIPE,
|
|
274
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
275
|
+
cwd=self.cwd,
|
|
276
|
+
env=self.launcher.environment(self.env),
|
|
277
|
+
creationflags=self._flags(),
|
|
278
|
+
start_new_session=os.name != "nt",
|
|
279
|
+
)
|
|
280
|
+
except OSError as error:
|
|
281
|
+
self._starting = False
|
|
282
|
+
write(f"{self.launcher.name}: {error.strerror or error}\n")
|
|
283
|
+
return RunResult(None, time.monotonic() - started)
|
|
284
|
+
self._starting = False
|
|
285
|
+
if self._early_stop:
|
|
286
|
+
# Stopped in the moment between Enter and the process existing: it is interrupted as soon as it does.
|
|
287
|
+
self.stop()
|
|
288
|
+
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
289
|
+
assert process.stdout is not None
|
|
290
|
+
try:
|
|
291
|
+
while True:
|
|
292
|
+
data = await process.stdout.read(CHUNK)
|
|
293
|
+
if not data:
|
|
294
|
+
break
|
|
295
|
+
write(decoder.decode(data))
|
|
296
|
+
await asyncio.sleep(PAUSE if len(data) >= CHUNK // 2 else 0)
|
|
297
|
+
tail = decoder.decode(b"", final=True)
|
|
298
|
+
if tail:
|
|
299
|
+
write(tail)
|
|
300
|
+
code = await process.wait()
|
|
301
|
+
finally:
|
|
302
|
+
await self._reap(process)
|
|
303
|
+
if self._killer is not None:
|
|
304
|
+
self._killer.cancel()
|
|
305
|
+
self._killer = None
|
|
306
|
+
return RunResult(code, time.monotonic() - started, stopped=self._stops > 0)
|
|
307
|
+
|
|
308
|
+
@staticmethod
|
|
309
|
+
def _flags() -> int:
|
|
310
|
+
"""Return the Windows creation flags that put the command in a process group of its own (zero elsewhere).
|
|
311
|
+
|
|
312
|
+
Elsewhere ``start_new_session`` does it; either way Ctrl-C can reach the command and all it started.
|
|
313
|
+
"""
|
|
314
|
+
return getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if os.name == "nt" else 0
|
|
315
|
+
|
|
316
|
+
# -- stopping -----------------------------------------------------------
|
|
317
|
+
def stop(self) -> bool:
|
|
318
|
+
"""Interrupt the running command; a second call, or a command that ignores the first, kills it.
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
Whether there was a command to stop.
|
|
322
|
+
"""
|
|
323
|
+
process = self._process
|
|
324
|
+
if process is None and self._starting:
|
|
325
|
+
self._early_stop = True
|
|
326
|
+
return True
|
|
327
|
+
if process is None or process.returncode is not None:
|
|
328
|
+
return False
|
|
329
|
+
self._stops += 1
|
|
330
|
+
if self._stops == 1:
|
|
331
|
+
self._send(process, interrupt=True)
|
|
332
|
+
with contextlib.suppress(RuntimeError):
|
|
333
|
+
self._killer = asyncio.get_running_loop().call_later(self.kill_after, self._kill, process)
|
|
334
|
+
else:
|
|
335
|
+
self._kill(process)
|
|
336
|
+
return True
|
|
337
|
+
|
|
338
|
+
def _kill(self, process: asyncio.subprocess.Process) -> None:
|
|
339
|
+
if process.returncode is None:
|
|
340
|
+
self._send(process, interrupt=False)
|
|
341
|
+
|
|
342
|
+
@staticmethod
|
|
343
|
+
def _send(process: asyncio.subprocess.Process, *, interrupt: bool) -> None:
|
|
344
|
+
"""Signal the command's whole process group: SIGINT to ask, SIGKILL to insist."""
|
|
345
|
+
try:
|
|
346
|
+
if os.name == "nt":
|
|
347
|
+
if interrupt:
|
|
348
|
+
process.send_signal(signal.CTRL_BREAK_EVENT) # type: ignore[attr-defined,unused-ignore]
|
|
349
|
+
else:
|
|
350
|
+
subprocess.run(
|
|
351
|
+
["taskkill", "/T", "/F", "/PID", str(process.pid)], capture_output=True, check=False, timeout=10
|
|
352
|
+
)
|
|
353
|
+
else:
|
|
354
|
+
os.killpg(process.pid, signal.SIGINT if interrupt else signal.SIGKILL)
|
|
355
|
+
except (OSError, subprocess.SubprocessError):
|
|
356
|
+
with contextlib.suppress(OSError):
|
|
357
|
+
process.kill()
|
|
358
|
+
|
|
359
|
+
async def _reap(self, process: asyncio.subprocess.Process) -> None:
|
|
360
|
+
"""Make sure the command is gone when :meth:`run` leaves, however it left (the panel closing, say)."""
|
|
361
|
+
if process.returncode is None:
|
|
362
|
+
self._send(process, interrupt=False)
|
|
363
|
+
with contextlib.suppress(Exception):
|
|
364
|
+
await asyncio.wait_for(process.wait(), 5)
|
|
365
|
+
self._process = None
|
zettcode/app/errors.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""What the shell catches from a command, and tells the reader in words."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from zettcode.sdk.errors import ZettCodeError
|
|
6
|
+
|
|
7
|
+
__all__ = ["COMMAND_ERRORS"]
|
|
8
|
+
|
|
9
|
+
#: What a command may fail with and have the reader told in words: a bad argument (``ValueError``), or the SDK saying
|
|
10
|
+
#: the session is locked, busy or missing. Anything else is a bug and is reported as one. This is the shell's catch
|
|
11
|
+
#: clause and not part of what a plugin imports: a command raises, and the shell decides what a reader is told.
|
|
12
|
+
COMMAND_ERRORS = (ValueError, ZettCodeError)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""The terminal's plugins: the row hooks, the groups a shell plugin registers into, and the bundle it produces.
|
|
2
|
+
|
|
3
|
+
A plugin subclasses :class:`~zettcode.engine.plugins.Plugin` (or :class:`TuiPlugin` to override a row hook) and
|
|
4
|
+
registers through ``ctx``::
|
|
5
|
+
|
|
6
|
+
from zettcode.app.plugins import TuiPlugin
|
|
7
|
+
from zettcode.engine.plugins import PluginContext
|
|
8
|
+
|
|
9
|
+
class Greeter(TuiPlugin):
|
|
10
|
+
name = "greeter"
|
|
11
|
+
|
|
12
|
+
def activate(self, ctx: PluginContext) -> None:
|
|
13
|
+
ctx.tui.register_command("greet", "say hello", self.greet)
|
|
14
|
+
|
|
15
|
+
:class:`TuiPlugin` is the engine's base class with :class:`PluginUiMixin`; the shell's commands, palettes, pets,
|
|
16
|
+
presenters and rows live in :class:`~zettcode.app.plugins.groups.TuiGroups`, which is ``ctx.tui`` — ``None`` in a
|
|
17
|
+
process without a terminal, which is why a shell-only plugin starts ``activate`` with ``if ctx.tui is None: return``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import importlib
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
|
+
|
|
25
|
+
#: Where each public name lives; read by :func:`__getattr__` on first use.
|
|
26
|
+
_EXPORTS = {
|
|
27
|
+
"Activity": "zettcode.plugin_api.state",
|
|
28
|
+
"ActivityState": "zettcode.plugin_api.state",
|
|
29
|
+
"DisplayState": "zettcode.plugin_api.state",
|
|
30
|
+
"ModelState": "zettcode.plugin_api.state",
|
|
31
|
+
"SessionState": "zettcode.plugin_api.state",
|
|
32
|
+
"ShellContext": "zettcode.plugin_api.state",
|
|
33
|
+
"UiBuilder": "zettcode.plugin_api.state",
|
|
34
|
+
"UiRegion": "zettcode.plugin_api.state",
|
|
35
|
+
"UiRow": "zettcode.plugin_api.state",
|
|
36
|
+
"UiSegment": "zettcode.plugin_api.state",
|
|
37
|
+
"UiSide": "zettcode.plugin_api.state",
|
|
38
|
+
"PluginUiMixin": "zettcode.plugin_api.mixins",
|
|
39
|
+
"PluginContext": "zettcode.engine.plugins.context",
|
|
40
|
+
"UiSlots": ".groups",
|
|
41
|
+
"TuiGroups": ".groups",
|
|
42
|
+
"TuiPlugin": "zettcode.plugin_api.plugin",
|
|
43
|
+
"TuiPlugins": "zettcode.plugin_api.plugin",
|
|
44
|
+
"TerminalLoader": ".loader",
|
|
45
|
+
"load_terminal_plugins": ".loader",
|
|
46
|
+
"HOST_LINK": "zettcode.plugin_api.host",
|
|
47
|
+
"HostLink": "zettcode.plugin_api.host",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if TYPE_CHECKING: # pragma: no cover - for type checkers, not the runtime
|
|
52
|
+
from zettcode.engine.plugins.context import PluginContext
|
|
53
|
+
from zettcode.plugin_api.host import HOST_LINK, HostLink
|
|
54
|
+
from zettcode.plugin_api.mixins import PluginUiMixin
|
|
55
|
+
from zettcode.plugin_api.plugin import TuiPlugin, TuiPlugins
|
|
56
|
+
from zettcode.plugin_api.state import (
|
|
57
|
+
Activity,
|
|
58
|
+
ActivityState,
|
|
59
|
+
DisplayState,
|
|
60
|
+
ModelState,
|
|
61
|
+
SessionState,
|
|
62
|
+
ShellContext,
|
|
63
|
+
UiBuilder,
|
|
64
|
+
UiRegion,
|
|
65
|
+
UiRow,
|
|
66
|
+
UiSegment,
|
|
67
|
+
UiSide,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
from .groups import UiSlots
|
|
71
|
+
from .loader import TerminalLoader, load_terminal_plugins
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def __getattr__(name: str) -> object:
|
|
75
|
+
"""Import the module a public name lives in, the first time it is asked for."""
|
|
76
|
+
try:
|
|
77
|
+
module = _EXPORTS[name]
|
|
78
|
+
except KeyError:
|
|
79
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
|
|
80
|
+
return getattr(importlib.import_module(module, __name__), name)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def __dir__() -> list[str]:
|
|
84
|
+
"""Return the names this package publishes."""
|
|
85
|
+
return sorted(__all__)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
__all__ = [
|
|
89
|
+
"HOST_LINK",
|
|
90
|
+
"Activity",
|
|
91
|
+
"ActivityState",
|
|
92
|
+
"DisplayState",
|
|
93
|
+
"HostLink",
|
|
94
|
+
"ModelState",
|
|
95
|
+
"PluginUiMixin",
|
|
96
|
+
"TuiGroups",
|
|
97
|
+
"SessionState",
|
|
98
|
+
"ShellContext",
|
|
99
|
+
"PluginContext",
|
|
100
|
+
"TerminalLoader",
|
|
101
|
+
"TuiPlugin",
|
|
102
|
+
"TuiPlugins",
|
|
103
|
+
"UiBuilder",
|
|
104
|
+
"UiRegion",
|
|
105
|
+
"UiRow",
|
|
106
|
+
"UiSegment",
|
|
107
|
+
"UiSide",
|
|
108
|
+
"UiSlots",
|
|
109
|
+
"load_terminal_plugins",
|
|
110
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""The palettes ZettCode ships with, written as an ordinary plugin.
|
|
2
|
+
|
|
3
|
+
The default header, status and turn-line rows moved to ``zettcode-presenters`` (``rows.py``), so this module holds
|
|
4
|
+
only the bundled palettes; the app has no built-in plugins, and the loader finds every terminal part through its
|
|
5
|
+
entry point.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from zettcode.engine.plugins.context import PluginContext
|
|
11
|
+
from zettcode.plugin_api.plugin import TuiPlugin
|
|
12
|
+
from zettcode.tui import bundled_themes
|
|
13
|
+
|
|
14
|
+
from .groups import require_tui
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BuiltinThemes(TuiPlugin):
|
|
18
|
+
"""The palettes that ship with the program, offered the way any plugin offers one.
|
|
19
|
+
|
|
20
|
+
They are TOML files beside the theme loader, so adding a built-in palette is adding a file and a name to
|
|
21
|
+
``BUNDLED``; nothing here knows what a palette looks like. They are registered first, so they are listed first
|
|
22
|
+
in the ``/theme`` menu, and a plugin cannot register a name they hold.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
name = "themes"
|
|
26
|
+
|
|
27
|
+
def activate(self, ctx: PluginContext) -> None:
|
|
28
|
+
"""Offer each palette file by name. Nothing is read until one is asked for."""
|
|
29
|
+
if ctx.tui is None:
|
|
30
|
+
return # loaded by a process with no screen (the server): there is nothing to offer a palette to
|
|
31
|
+
tui = require_tui(ctx)
|
|
32
|
+
for source in bundled_themes():
|
|
33
|
+
tui.register_theme(source.name, source.load, description=source.description)
|