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,262 @@
|
|
|
1
|
+
"""Tab completion for a command line: the first word is a program, the rest depend on the program.
|
|
2
|
+
|
|
3
|
+
:class:`ShellCompleter` is the :class:`~zettcode.tui.Completer` the command panel hands its line. In the position of a
|
|
4
|
+
command it offers the programs on ``PATH`` (and the commands the reader has used before, first). Anywhere else it
|
|
5
|
+
puts together, in this order, what the program is known to take (``git`` takes subcommands and branches, see
|
|
6
|
+
:mod:`~zettcode.app.commandline.arguments`), the word the reader typed there the last time they ran the same
|
|
7
|
+
command (``git st`` finds ``status`` in the history), and what is in the directory the word points into. It needs no
|
|
8
|
+
help from the shell the commands run in, which is what lets it behave the same for bash, zsh, fish and PowerShell
|
|
9
|
+
alike.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from collections.abc import Callable, Sequence
|
|
16
|
+
from dataclasses import replace
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from zettcode.tui import Completer, CompletionItem
|
|
20
|
+
from zettcode.tui.widgets.console import ARGUMENT, COMMAND, DIRECTORY, EXECUTABLE, FILE, SYMLINK
|
|
21
|
+
|
|
22
|
+
from .arguments import ARGUMENTS, ArgumentCompleter
|
|
23
|
+
|
|
24
|
+
#: Candidates offered at most; a directory of ten thousand files must not become a menu of ten thousand.
|
|
25
|
+
LIMIT = 200
|
|
26
|
+
|
|
27
|
+
#: The words after which the next word is a command again (``sudo make``), besides the separators.
|
|
28
|
+
WRAPPERS = frozenset({"sudo", "time", "exec", "nohup", "xargs", "env", "command", "builtin", "then", "do", "else"})
|
|
29
|
+
|
|
30
|
+
#: The commands the panel itself understands, offered like programs.
|
|
31
|
+
INTERNAL = ("cd", "clear", "exit")
|
|
32
|
+
|
|
33
|
+
#: Programs after which only directories make sense.
|
|
34
|
+
DIRECTORY_ONLY = frozenset({"cd", "pushd", "rmdir"})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ShellCompleter(Completer):
|
|
38
|
+
"""Complete the word under the cursor of a shell command.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
cwd: The directory relative paths are read from; asked on every Tab because ``cd`` changes it.
|
|
42
|
+
history: Commands typed before, oldest first. Their first words are offered ahead of the programs on
|
|
43
|
+
``PATH``, since what the reader has run is likelier than what is installed.
|
|
44
|
+
path: ``PATH`` to search for programs; the environment's by default.
|
|
45
|
+
home: What ``~`` stands for; the real home directory by default.
|
|
46
|
+
arguments: The programs whose arguments are known; git by default.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
cwd: Callable[[], Path],
|
|
52
|
+
history: Sequence[str] = (),
|
|
53
|
+
*,
|
|
54
|
+
path: str | None = None,
|
|
55
|
+
home: Path | None = None,
|
|
56
|
+
arguments: Sequence[ArgumentCompleter] | None = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
self.cwd = cwd
|
|
59
|
+
self.history = tuple(history)
|
|
60
|
+
self.path = os.environ.get("PATH", "") if path is None else path
|
|
61
|
+
self.home = Path.home() if home is None else home
|
|
62
|
+
self.arguments = tuple(kind() for kind in ARGUMENTS) if arguments is None else tuple(arguments)
|
|
63
|
+
self._programs: list[str] | None = None
|
|
64
|
+
|
|
65
|
+
def __call__(self, text: str, position: int) -> list[CompletionItem]:
|
|
66
|
+
"""Return the candidates for the word that ends at ``position``."""
|
|
67
|
+
head = text[:position]
|
|
68
|
+
start = len(head)
|
|
69
|
+
while start > 0 and not head[start - 1].isspace():
|
|
70
|
+
start -= 1
|
|
71
|
+
word, before = head[start:], head[:start].rstrip()
|
|
72
|
+
# A subshell or a group opens with a bracket that is part of the word, and stays when the name is completed.
|
|
73
|
+
lead = word[: len(word) - len(word.lstrip("({"))]
|
|
74
|
+
bare = word[len(lead) :]
|
|
75
|
+
if self._is_command_position(before) and not bare.startswith(("~", ".", "/")) and "/" not in bare:
|
|
76
|
+
return (
|
|
77
|
+
[replace(item, value=lead + item.value, label=item.value) for item in self._programs_matching(bare)]
|
|
78
|
+
if bare
|
|
79
|
+
else []
|
|
80
|
+
)
|
|
81
|
+
words = self._words_of(before)
|
|
82
|
+
return self._arguments_matching(words, word)
|
|
83
|
+
|
|
84
|
+
# -- the first word -----------------------------------------------------
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _is_command_position(before: str) -> bool:
|
|
87
|
+
"""Return whether the word being typed is where a program name goes."""
|
|
88
|
+
if not before:
|
|
89
|
+
return True
|
|
90
|
+
if before.endswith(("|", ";", "&", "(", "{")):
|
|
91
|
+
return True
|
|
92
|
+
return before.split()[-1] in WRAPPERS
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def _words_of(before: str) -> list[str]:
|
|
96
|
+
"""Return the words of the command being typed, the program first: what follows the last separator and any ``sudo``."""
|
|
97
|
+
for separator in ("|", ";", "&", "("):
|
|
98
|
+
before = before.rpartition(separator)[2]
|
|
99
|
+
words = before.split()
|
|
100
|
+
while words and words[0] in WRAPPERS:
|
|
101
|
+
words.pop(0)
|
|
102
|
+
return words
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def _command_of(cls, before: str) -> str:
|
|
106
|
+
"""Return the program the words before the cursor belong to."""
|
|
107
|
+
words = cls._words_of(before)
|
|
108
|
+
return words[0] if words else ""
|
|
109
|
+
|
|
110
|
+
# -- the other words ----------------------------------------------------
|
|
111
|
+
def _arguments_matching(self, words: list[str], word: str) -> list[CompletionItem]:
|
|
112
|
+
"""Return what can follow ``words``: the program's own arguments, then the history's, then paths.
|
|
113
|
+
|
|
114
|
+
A word taken from the history stands in the order of use, and where the program's completer names the same
|
|
115
|
+
word it is that one that is offered (it knows what the word is), only earlier. A word with a slash in it is
|
|
116
|
+
a path and is not looked for in the history, which does not know where it was typed.
|
|
117
|
+
"""
|
|
118
|
+
program = words[0] if words else ""
|
|
119
|
+
known = next((c.suggest(words, word, self.cwd()) for c in self.arguments if program in c.commands), None)
|
|
120
|
+
specific = known.items if known else []
|
|
121
|
+
recalled = [] if program in DIRECTORY_ONLY or "/" in word else self._recalled(words, word)
|
|
122
|
+
if known and known.exclusive:
|
|
123
|
+
# The slot only takes what the program has: a typo that once ran is not a candidate.
|
|
124
|
+
names = {item.value for item in specific}
|
|
125
|
+
recalled = [value for value in recalled if value in names]
|
|
126
|
+
paths = (
|
|
127
|
+
[] if known and known.exclusive else self._paths_matching(word, directories_only=program in DIRECTORY_ONLY)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# A word from the history that is also something the program or the directory offers is that thing (a
|
|
131
|
+
# directory keeps its slash and its colour), only earlier.
|
|
132
|
+
known_by_name = {item.value.rstrip("/"): item for item in (*specific, *paths)}
|
|
133
|
+
rank = {value: index for index, value in enumerate(recalled)}
|
|
134
|
+
ordered = sorted(specific, key=lambda item: rank.get(item.value, len(rank)))
|
|
135
|
+
ordered += [known_by_name.get(value) or CompletionItem(value=value, type=ARGUMENT) for value in recalled]
|
|
136
|
+
ordered += paths
|
|
137
|
+
unique: dict[str, CompletionItem] = {}
|
|
138
|
+
for item in ordered:
|
|
139
|
+
unique.setdefault(item.value, item)
|
|
140
|
+
return list(unique.values())[:LIMIT]
|
|
141
|
+
|
|
142
|
+
def _recalled(self, words: list[str], prefix: str) -> list[str]:
|
|
143
|
+
"""Return the words that followed ``words`` in earlier commands and begin with ``prefix``, newest first."""
|
|
144
|
+
if not words:
|
|
145
|
+
return []
|
|
146
|
+
folded, depth = prefix.casefold(), len(words)
|
|
147
|
+
found: dict[str, None] = {}
|
|
148
|
+
for command in reversed(self.history):
|
|
149
|
+
parts = command.split()
|
|
150
|
+
if len(parts) > depth and parts[:depth] == words and parts[depth].casefold().startswith(folded):
|
|
151
|
+
found.setdefault(parts[depth])
|
|
152
|
+
return [name for name in found if name != prefix][:LIMIT]
|
|
153
|
+
|
|
154
|
+
def _programs_matching(self, prefix: str) -> list[CompletionItem]:
|
|
155
|
+
"""Return the programs starting with ``prefix``, whatever the case: the reader's own first, then what is installed.
|
|
156
|
+
|
|
157
|
+
A name that begins with the prefix exactly as typed comes ahead of one that matches only once case is ignored.
|
|
158
|
+
"""
|
|
159
|
+
folded = prefix.casefold()
|
|
160
|
+
names: dict[str, None] = {}
|
|
161
|
+
for command in reversed(self.history):
|
|
162
|
+
first = command.split(None, 1)[0] if command.strip() else ""
|
|
163
|
+
if first.casefold().startswith(folded) and "/" not in first:
|
|
164
|
+
names.setdefault(first)
|
|
165
|
+
for name in (*INTERNAL, *self._installed()):
|
|
166
|
+
if name.casefold().startswith(folded):
|
|
167
|
+
names.setdefault(name)
|
|
168
|
+
ordered = sorted(names, key=lambda name: not name.startswith(prefix))
|
|
169
|
+
return [CompletionItem(value=name, type=COMMAND) for name in ordered[:LIMIT]]
|
|
170
|
+
|
|
171
|
+
def _installed(self) -> list[str]:
|
|
172
|
+
"""Return the programs on ``PATH``, found once and kept."""
|
|
173
|
+
if self._programs is not None:
|
|
174
|
+
return self._programs
|
|
175
|
+
found: dict[str, None] = {}
|
|
176
|
+
extensions = (
|
|
177
|
+
[e.lower() for e in os.environ.get("PATHEXT", ".EXE;.CMD;.BAT").split(";")] if os.name == "nt" else []
|
|
178
|
+
)
|
|
179
|
+
for directory in self.path.split(os.pathsep):
|
|
180
|
+
try:
|
|
181
|
+
with os.scandir(directory or ".") as entries:
|
|
182
|
+
for entry in entries:
|
|
183
|
+
try:
|
|
184
|
+
if not entry.is_file() or not os.access(entry.path, os.X_OK):
|
|
185
|
+
continue
|
|
186
|
+
except OSError:
|
|
187
|
+
continue
|
|
188
|
+
name = entry.name
|
|
189
|
+
if extensions:
|
|
190
|
+
stem, dot, extension = name.rpartition(".")
|
|
191
|
+
if not dot or f".{extension.lower()}" not in extensions:
|
|
192
|
+
continue
|
|
193
|
+
name = stem
|
|
194
|
+
found.setdefault(name)
|
|
195
|
+
except OSError:
|
|
196
|
+
continue
|
|
197
|
+
self._programs = sorted(found)
|
|
198
|
+
return self._programs
|
|
199
|
+
|
|
200
|
+
# -- the other words ----------------------------------------------------
|
|
201
|
+
def _paths_matching(self, word: str, *, directories_only: bool) -> list[CompletionItem]:
|
|
202
|
+
"""Return the entries of the directory ``word`` points into whose names start with what follows its last slash.
|
|
203
|
+
|
|
204
|
+
The match ignores case, as a file browser's does (``read`` finds ``README.md``); directories are listed first,
|
|
205
|
+
and within each group the names that match exactly as typed come before the ones that only match once case is
|
|
206
|
+
ignored.
|
|
207
|
+
"""
|
|
208
|
+
cut = max(word.rfind("/"), word.rfind("\\") if os.name == "nt" else -1)
|
|
209
|
+
shown_dir, prefix = word[: cut + 1], word[cut + 1 :]
|
|
210
|
+
folded = prefix.casefold()
|
|
211
|
+
directory = self._resolve(shown_dir)
|
|
212
|
+
try:
|
|
213
|
+
with os.scandir(directory) as entries:
|
|
214
|
+
listing = [(entry.name, *self._kind(entry)) for entry in entries]
|
|
215
|
+
except OSError:
|
|
216
|
+
return []
|
|
217
|
+
|
|
218
|
+
def order(found: tuple[str, str, bool]) -> tuple[bool, bool, str]:
|
|
219
|
+
return (not found[2], not found[0].startswith(prefix), found[0].casefold())
|
|
220
|
+
|
|
221
|
+
items: list[CompletionItem] = []
|
|
222
|
+
for name, kind, is_dir in sorted(listing, key=order):
|
|
223
|
+
if not name.casefold().startswith(folded) or (name.startswith(".") and not prefix.startswith(".")):
|
|
224
|
+
continue
|
|
225
|
+
if directories_only and not is_dir:
|
|
226
|
+
continue
|
|
227
|
+
escaped = name.replace(" ", "\\ ")
|
|
228
|
+
suffix = "/" if is_dir else ""
|
|
229
|
+
items.append(CompletionItem(value=f"{shown_dir}{escaped}{suffix}", label=f"{name}{suffix}", type=kind))
|
|
230
|
+
if len(items) >= LIMIT:
|
|
231
|
+
break
|
|
232
|
+
return items
|
|
233
|
+
|
|
234
|
+
def _resolve(self, shown: str) -> Path:
|
|
235
|
+
"""Return the directory a typed prefix names: ``~`` is home, a relative one is under the working directory."""
|
|
236
|
+
if not shown:
|
|
237
|
+
return self.cwd()
|
|
238
|
+
if shown.startswith("~"):
|
|
239
|
+
shown = str(self.home) + shown[1:]
|
|
240
|
+
path = Path(shown)
|
|
241
|
+
return path if path.is_absolute() else self.cwd() / path
|
|
242
|
+
|
|
243
|
+
@staticmethod
|
|
244
|
+
def _kind(entry: os.DirEntry[str]) -> tuple[str, bool]:
|
|
245
|
+
"""Return what an entry is, as ``ls`` would colour it, and whether a path can go on through it.
|
|
246
|
+
|
|
247
|
+
A link is its own kind whatever it points at, but one to a directory still takes the slash, because
|
|
248
|
+
``cd link/`` and ``ls link/`` go through it.
|
|
249
|
+
"""
|
|
250
|
+
try:
|
|
251
|
+
is_dir = entry.is_dir()
|
|
252
|
+
if entry.is_symlink():
|
|
253
|
+
return SYMLINK, is_dir
|
|
254
|
+
if is_dir:
|
|
255
|
+
return DIRECTORY, True
|
|
256
|
+
if os.name == "nt":
|
|
257
|
+
runnable = entry.name.lower().endswith((".exe", ".cmd", ".bat", ".com", ".ps1"))
|
|
258
|
+
else:
|
|
259
|
+
runnable = os.access(entry.path, os.X_OK)
|
|
260
|
+
return (EXECUTABLE if runnable else FILE), False
|
|
261
|
+
except OSError:
|
|
262
|
+
return FILE, False
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""The commands the reader has typed before, read from the shells that kept them.
|
|
2
|
+
|
|
3
|
+
Each shell writes its history in a format of its own: bash a line per command, zsh a line per command with a time
|
|
4
|
+
stamp in front and a backslash to continue a long one, fish a small YAML-like list, PowerShell a line per command
|
|
5
|
+
in a file under the profile. :class:`HistorySource` is the one thing the rest of the program knows about them: a
|
|
6
|
+
source says where its file may be and how to read it, and a subclass is the answer for one shell. Adding another
|
|
7
|
+
shell is adding a class and a name in :data:`SOURCES`.
|
|
8
|
+
|
|
9
|
+
:class:`CommandHistory` puts them in order for one reader: the shell they use most, last, so that it is what Up
|
|
10
|
+
reaches first, and the commands typed in ZettCode's own panel after all of them, in a file of its own.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
from abc import ABC, abstractmethod
|
|
19
|
+
from collections.abc import Iterable, Iterator, Mapping, Sequence
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
#: Commands kept when everything is merged; the oldest are dropped.
|
|
23
|
+
LIMIT = 5000
|
|
24
|
+
|
|
25
|
+
#: The most of a history file that is read: its tail, which is where the commands that matter are. A history
|
|
26
|
+
#: that has grown to hundreds of megabytes must not make the panel wait.
|
|
27
|
+
TAIL_BYTES = 4 * 1024 * 1024
|
|
28
|
+
|
|
29
|
+
#: The time stamp zsh writes in front of a command when extended history is on: ``: 1690000000:0;``.
|
|
30
|
+
ZSH_STAMP = re.compile(r"^: \d+:\d+;")
|
|
31
|
+
|
|
32
|
+
#: The two escapes in the file ZettCode keeps for itself.
|
|
33
|
+
PLAIN_ESCAPE = re.compile(r"\\([\\n])")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HistorySource(ABC):
|
|
37
|
+
"""One shell's history: where it keeps it and how it is written.
|
|
38
|
+
|
|
39
|
+
Subclass it with the file names and the parser; the reading, the choice of file and the tolerance for
|
|
40
|
+
a file that is missing, unreadable or in another encoding are shared.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
name: The shell's name, which is also what ``$SHELL`` ends in.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
name = ""
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def paths(self, home: Path, env: Mapping[str, str]) -> Iterable[Path]:
|
|
50
|
+
"""Return the places the history may be, most likely first."""
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def parse(self, text: str) -> Iterator[str]:
|
|
54
|
+
"""Yield the commands in the file's text, oldest first."""
|
|
55
|
+
|
|
56
|
+
def decode(self, raw: bytes) -> str:
|
|
57
|
+
"""Return the file's bytes as text; a byte that is not UTF-8 is replaced, not refused.
|
|
58
|
+
|
|
59
|
+
A history written by a shell in another locale is still worth the commands that survive.
|
|
60
|
+
"""
|
|
61
|
+
return raw.decode("utf-8", errors="replace")
|
|
62
|
+
|
|
63
|
+
def load(self, home: Path | None = None, env: Mapping[str, str] | None = None) -> list[str]:
|
|
64
|
+
"""Return the commands in the first history file that can be read; empty when there is none."""
|
|
65
|
+
home = Path.home() if home is None else home
|
|
66
|
+
env = os.environ if env is None else env
|
|
67
|
+
for path in self.paths(home, env):
|
|
68
|
+
raw = read_tail(path)
|
|
69
|
+
if raw is not None:
|
|
70
|
+
return [command for command in self.parse(self.decode(raw)) if command.strip()]
|
|
71
|
+
return []
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def read_tail(path: Path) -> bytes | None:
|
|
75
|
+
"""Return the last :data:`TAIL_BYTES` of a file, or ``None`` when it cannot be read."""
|
|
76
|
+
try:
|
|
77
|
+
with path.open("rb") as handle:
|
|
78
|
+
size = handle.seek(0, os.SEEK_END)
|
|
79
|
+
cut = size > TAIL_BYTES
|
|
80
|
+
handle.seek(max(0, size - TAIL_BYTES))
|
|
81
|
+
raw = handle.read()
|
|
82
|
+
except OSError:
|
|
83
|
+
return None
|
|
84
|
+
if cut:
|
|
85
|
+
# The first line of a tail is usually half of one.
|
|
86
|
+
raw = raw.partition(b"\n")[2]
|
|
87
|
+
return raw
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class BashHistory(HistorySource):
|
|
91
|
+
"""bash: one command per line; a line starting with ``#`` and digits is a time stamp, not a command."""
|
|
92
|
+
|
|
93
|
+
name = "bash"
|
|
94
|
+
|
|
95
|
+
def paths(self, home: Path, env: Mapping[str, str]) -> Iterable[Path]:
|
|
96
|
+
"""Return ``$HISTFILE`` and ``~/.bash_history``."""
|
|
97
|
+
if env.get("HISTFILE"):
|
|
98
|
+
yield Path(env["HISTFILE"]).expanduser()
|
|
99
|
+
yield home / ".bash_history"
|
|
100
|
+
|
|
101
|
+
def parse(self, text: str) -> Iterator[str]:
|
|
102
|
+
"""Yield each line that is not a time stamp."""
|
|
103
|
+
for line in text.splitlines():
|
|
104
|
+
if line.startswith("#") and line[1:].isdigit():
|
|
105
|
+
continue
|
|
106
|
+
yield line.strip()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def unmetafy(raw: bytes) -> bytes:
|
|
110
|
+
"""Undo zsh's escaping: a byte of 0x83 means "the next byte, xor 0x20" (zsh marks its own control bytes so)."""
|
|
111
|
+
if b"\x83" not in raw:
|
|
112
|
+
return raw
|
|
113
|
+
out = bytearray()
|
|
114
|
+
following = iter(raw)
|
|
115
|
+
for byte in following:
|
|
116
|
+
out.append(next(following, 0) ^ 0x20 if byte == 0x83 else byte)
|
|
117
|
+
return bytes(out)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class ZshHistory(HistorySource):
|
|
121
|
+
"""zsh: ``: <time>:<seconds>;<command>`` with extended history on, a bare command otherwise.
|
|
122
|
+
|
|
123
|
+
A command that ran over several lines is written with a backslash at the end of each but the last, and
|
|
124
|
+
zsh escapes some bytes of non-ASCII text, which :func:`unmetafy` takes back.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
name = "zsh"
|
|
128
|
+
|
|
129
|
+
def paths(self, home: Path, env: Mapping[str, str]) -> Iterable[Path]:
|
|
130
|
+
"""Return ``$HISTFILE``, then ``.zsh_history`` in ``$ZDOTDIR`` and in the home directory."""
|
|
131
|
+
if env.get("HISTFILE"):
|
|
132
|
+
yield Path(env["HISTFILE"]).expanduser()
|
|
133
|
+
if env.get("ZDOTDIR"):
|
|
134
|
+
yield Path(env["ZDOTDIR"]).expanduser() / ".zsh_history"
|
|
135
|
+
yield home / ".zsh_history"
|
|
136
|
+
|
|
137
|
+
def decode(self, raw: bytes) -> str:
|
|
138
|
+
"""Take back zsh's escaping before reading the bytes as text."""
|
|
139
|
+
return super().decode(unmetafy(raw))
|
|
140
|
+
|
|
141
|
+
def parse(self, text: str) -> Iterator[str]:
|
|
142
|
+
"""Yield each command, joining the lines of one that continues."""
|
|
143
|
+
pending: list[str] = []
|
|
144
|
+
for line in text.splitlines():
|
|
145
|
+
if not pending:
|
|
146
|
+
line = ZSH_STAMP.sub("", line, count=1)
|
|
147
|
+
if line.endswith("\\"):
|
|
148
|
+
pending.append(line[:-1])
|
|
149
|
+
continue
|
|
150
|
+
pending.append(line)
|
|
151
|
+
yield "\n".join(pending).strip()
|
|
152
|
+
pending = []
|
|
153
|
+
if pending:
|
|
154
|
+
yield "\n".join(pending).strip()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class FishHistory(HistorySource):
|
|
158
|
+
"""fish: a list of ``- cmd: <command>`` entries, each followed by indented ``when:`` and ``paths:`` lines.
|
|
159
|
+
|
|
160
|
+
Inside a command fish writes a backslash as ``\\\\`` and a newline as ``\\n``.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
name = "fish"
|
|
164
|
+
|
|
165
|
+
def paths(self, home: Path, env: Mapping[str, str]) -> Iterable[Path]:
|
|
166
|
+
"""Return fish's history under ``$XDG_DATA_HOME``, then under ``~/.local/share``."""
|
|
167
|
+
if env.get("XDG_DATA_HOME"):
|
|
168
|
+
yield Path(env["XDG_DATA_HOME"]).expanduser() / "fish" / "fish_history"
|
|
169
|
+
yield home / ".local" / "share" / "fish" / "fish_history"
|
|
170
|
+
|
|
171
|
+
def parse(self, text: str) -> Iterator[str]:
|
|
172
|
+
"""Yield the ``cmd`` of every entry."""
|
|
173
|
+
for line in text.splitlines():
|
|
174
|
+
if line.startswith("- cmd: "):
|
|
175
|
+
yield self._unescape(line[len("- cmd: ") :]).strip()
|
|
176
|
+
|
|
177
|
+
@staticmethod
|
|
178
|
+
def _unescape(value: str) -> str:
|
|
179
|
+
out: list[str] = []
|
|
180
|
+
index = 0
|
|
181
|
+
while index < len(value):
|
|
182
|
+
character = value[index]
|
|
183
|
+
if character == "\\" and index + 1 < len(value):
|
|
184
|
+
following = value[index + 1]
|
|
185
|
+
if following in ("\\", "n"):
|
|
186
|
+
out.append("\n" if following == "n" else "\\")
|
|
187
|
+
index += 2
|
|
188
|
+
continue
|
|
189
|
+
out.append(character)
|
|
190
|
+
index += 1
|
|
191
|
+
return "".join(out)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class PowerShellHistory(HistorySource):
|
|
195
|
+
"""PowerShell: the PSReadLine file, one command per line (a backtick continues a long one)."""
|
|
196
|
+
|
|
197
|
+
name = "pwsh"
|
|
198
|
+
|
|
199
|
+
def paths(self, home: Path, env: Mapping[str, str]) -> Iterable[Path]:
|
|
200
|
+
"""Return the PSReadLine history under ``%APPDATA%`` on Windows and under the data directory elsewhere."""
|
|
201
|
+
if env.get("APPDATA"):
|
|
202
|
+
yield (
|
|
203
|
+
Path(env["APPDATA"]) / "Microsoft" / "Windows" / "PowerShell" / "PSReadLine" / "ConsoleHost_history.txt"
|
|
204
|
+
)
|
|
205
|
+
data = Path(env["XDG_DATA_HOME"]).expanduser() if env.get("XDG_DATA_HOME") else home / ".local" / "share"
|
|
206
|
+
yield data / "powershell" / "PSReadLine" / "ConsoleHost_history.txt"
|
|
207
|
+
|
|
208
|
+
def parse(self, text: str) -> Iterator[str]:
|
|
209
|
+
"""Yield each command."""
|
|
210
|
+
pending = ""
|
|
211
|
+
for line in text.splitlines():
|
|
212
|
+
if line.endswith("`"):
|
|
213
|
+
pending += line[:-1] + "\n"
|
|
214
|
+
continue
|
|
215
|
+
yield (pending + line).strip()
|
|
216
|
+
pending = ""
|
|
217
|
+
if pending:
|
|
218
|
+
yield pending.strip()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class PlainHistory(HistorySource):
|
|
222
|
+
"""The file ZettCode keeps for itself: one command per line, appended to as they are run."""
|
|
223
|
+
|
|
224
|
+
name = "zettcode"
|
|
225
|
+
|
|
226
|
+
def __init__(self, path: Path) -> None:
|
|
227
|
+
self.path = path
|
|
228
|
+
|
|
229
|
+
def paths(self, home: Path, env: Mapping[str, str]) -> Iterable[Path]:
|
|
230
|
+
"""Return the one file."""
|
|
231
|
+
yield self.path
|
|
232
|
+
|
|
233
|
+
def parse(self, text: str) -> Iterator[str]:
|
|
234
|
+
"""Yield each line; a command that had a newline in it was written with it escaped."""
|
|
235
|
+
for line in text.splitlines():
|
|
236
|
+
yield PLAIN_ESCAPE.sub(lambda found: "\n" if found.group(1) == "n" else "\\", line).strip()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
#: The shells ZettCode can read, in the order they are tried when ``$SHELL`` names none of them.
|
|
240
|
+
SOURCES: tuple[HistorySource, ...] = (BashHistory(), ZshHistory(), FishHistory(), PowerShellHistory())
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def preferred(env: Mapping[str, str], platform: str = sys.platform) -> str:
|
|
244
|
+
"""Return the name of the shell the reader most likely uses: what ``$SHELL`` ends in, else the platform's own."""
|
|
245
|
+
shell = Path(env.get("SHELL", "")).name.lower().removesuffix(".exe")
|
|
246
|
+
if shell == "pwsh" or shell == "powershell":
|
|
247
|
+
return PowerShellHistory.name
|
|
248
|
+
if shell in {source.name for source in SOURCES}:
|
|
249
|
+
return shell
|
|
250
|
+
return PowerShellHistory.name if platform == "win32" else BashHistory.name
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class CommandHistory:
|
|
254
|
+
"""Every command the reader has typed that is worth offering again, oldest first.
|
|
255
|
+
|
|
256
|
+
The histories of the shells are read and laid end to end with the one the reader uses *last*, then the
|
|
257
|
+
commands typed in ZettCode's panel; a command that appears twice is kept once, at its newest place, so Up
|
|
258
|
+
reaches each command once and the freshest first.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
own: The file the commands typed in the panel are appended to.
|
|
262
|
+
sources: The shells to read; every shell ZettCode knows by default.
|
|
263
|
+
home: The home directory; the real one by default.
|
|
264
|
+
env: The environment; the real one by default.
|
|
265
|
+
platform: ``sys.platform``, for choosing the reader's shell.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
def __init__(
|
|
269
|
+
self,
|
|
270
|
+
own: Path,
|
|
271
|
+
*,
|
|
272
|
+
sources: Sequence[HistorySource] = SOURCES,
|
|
273
|
+
home: Path | None = None,
|
|
274
|
+
env: Mapping[str, str] | None = None,
|
|
275
|
+
platform: str = sys.platform,
|
|
276
|
+
) -> None:
|
|
277
|
+
self.own = own
|
|
278
|
+
self.sources = tuple(sources)
|
|
279
|
+
self.home = Path.home() if home is None else home
|
|
280
|
+
self.env = os.environ if env is None else env
|
|
281
|
+
self.platform = platform
|
|
282
|
+
|
|
283
|
+
def load(self) -> list[str]:
|
|
284
|
+
"""Return the merged history, oldest first."""
|
|
285
|
+
favourite = preferred(self.env, self.platform)
|
|
286
|
+
ordered = sorted(self.sources, key=lambda source: source.name == favourite)
|
|
287
|
+
merged: list[str] = []
|
|
288
|
+
for source in ordered:
|
|
289
|
+
merged.extend(source.load(self.home, self.env))
|
|
290
|
+
merged.extend(PlainHistory(self.own).load(self.home, self.env))
|
|
291
|
+
kept: dict[str, None] = {}
|
|
292
|
+
for command in merged:
|
|
293
|
+
kept.pop(command, None)
|
|
294
|
+
kept[command] = None
|
|
295
|
+
return list(kept)[-LIMIT:]
|
|
296
|
+
|
|
297
|
+
def record(self, command: str) -> None:
|
|
298
|
+
"""Append a command to ZettCode's own file; a file that cannot be written is not an error."""
|
|
299
|
+
line = command.replace("\\", "\\\\").replace("\n", "\\n")
|
|
300
|
+
try:
|
|
301
|
+
self.own.parent.mkdir(parents=True, exist_ok=True)
|
|
302
|
+
with self.own.open("a", encoding="utf-8") as handle:
|
|
303
|
+
handle.write(line + "\n")
|
|
304
|
+
except OSError:
|
|
305
|
+
pass
|