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.
Files changed (81) hide show
  1. zettcode/app/__init__.py +63 -0
  2. zettcode/app/agent/__init__.py +47 -0
  3. zettcode/app/agent/agent/__init__.py +84 -0
  4. zettcode/app/agent/agent/commands.py +269 -0
  5. zettcode/app/agent/agent/settings.py +132 -0
  6. zettcode/app/agent/conversation/__init__.py +7 -0
  7. zettcode/app/agent/conversation/changes.py +64 -0
  8. zettcode/app/agent/conversation/effort_gauge.py +126 -0
  9. zettcode/app/agent/conversation/projection.py +265 -0
  10. zettcode/app/agent/conversation/rendering.py +837 -0
  11. zettcode/app/agent/conversation/replay.py +198 -0
  12. zettcode/app/agent/conversation/shimmer.py +256 -0
  13. zettcode/app/agent/conversation/stops.py +57 -0
  14. zettcode/app/agent/conversation/tips.py +86 -0
  15. zettcode/app/agent/conversation/transcript/__init__.py +80 -0
  16. zettcode/app/agent/conversation/transcript/fold.py +57 -0
  17. zettcode/app/agent/conversation/transcript/log.py +182 -0
  18. zettcode/app/agent/conversation/transcript/oneoff.py +55 -0
  19. zettcode/app/agent/conversation/transcript/stream.py +187 -0
  20. zettcode/app/agent/conversation/transcript/turn.py +323 -0
  21. zettcode/app/commandline/__init__.py +47 -0
  22. zettcode/app/commandline/arguments.py +169 -0
  23. zettcode/app/commandline/completion.py +262 -0
  24. zettcode/app/commandline/history.py +305 -0
  25. zettcode/app/commandline/runner.py +365 -0
  26. zettcode/app/errors.py +12 -0
  27. zettcode/app/plugins/__init__.py +110 -0
  28. zettcode/app/plugins/builtins.py +33 -0
  29. zettcode/app/plugins/groups.py +503 -0
  30. zettcode/app/plugins/loader.py +268 -0
  31. zettcode/app/py.typed +0 -0
  32. zettcode/app/ui/__init__.py +63 -0
  33. zettcode/app/ui/app.py +1507 -0
  34. zettcode/app/ui/bars.py +290 -0
  35. zettcode/app/ui/boot.py +131 -0
  36. zettcode/app/ui/clipboard.py +340 -0
  37. zettcode/app/ui/commands.py +323 -0
  38. zettcode/app/ui/config_form.py +343 -0
  39. zettcode/app/ui/demo.py +180 -0
  40. zettcode/app/ui/followups.py +430 -0
  41. zettcode/app/ui/gc_policy.py +147 -0
  42. zettcode/app/ui/keys.py +462 -0
  43. zettcode/app/ui/messaging.py +260 -0
  44. zettcode/app/ui/notices.py +195 -0
  45. zettcode/app/ui/providers.py +626 -0
  46. zettcode/app/ui/sessions.py +422 -0
  47. zettcode/app/ui/settings.py +161 -0
  48. zettcode/app/ui/shell.py +361 -0
  49. zettcode/app/ui/turns.py +115 -0
  50. zettcode/app/ui/updating.py +204 -0
  51. zettcode/app/ui/widgets/__init__.py +161 -0
  52. zettcode/app/ui/widgets/approval_page.py +284 -0
  53. zettcode/app/ui/widgets/ask_page.py +386 -0
  54. zettcode/app/ui/widgets/bash_page.py +90 -0
  55. zettcode/app/ui/widgets/completer.py +100 -0
  56. zettcode/app/ui/widgets/composer.py +529 -0
  57. zettcode/app/ui/widgets/document_page.py +159 -0
  58. zettcode/app/ui/widgets/effort_page.py +294 -0
  59. zettcode/app/ui/widgets/follow_up.py +168 -0
  60. zettcode/app/ui/widgets/form_page.py +380 -0
  61. zettcode/app/ui/widgets/input_page.py +175 -0
  62. zettcode/app/ui/widgets/model_page.py +63 -0
  63. zettcode/app/ui/widgets/model_settings_page.py +653 -0
  64. zettcode/app/ui/widgets/pet.py +247 -0
  65. zettcode/app/ui/widgets/pet_page.py +347 -0
  66. zettcode/app/ui/widgets/plugins_page.py +442 -0
  67. zettcode/app/ui/widgets/root.py +68 -0
  68. zettcode/app/ui/widgets/self_compact_page.py +363 -0
  69. zettcode/app/ui/widgets/send_kind_page.py +193 -0
  70. zettcode/app/ui/widgets/sessions_page.py +511 -0
  71. zettcode/app/ui/widgets/statusline_page.py +427 -0
  72. zettcode/app/ui/widgets/steering.py +126 -0
  73. zettcode/app/ui/widgets/transcript.py +287 -0
  74. zettcode/app/ui/widgets/update_page.py +65 -0
  75. zettcode/app/ui/widgets/welcome.py +24 -0
  76. zettcode/collector.py +54 -0
  77. zettcode/update.py +519 -0
  78. zettcode_app-0.4.2.dist-info/METADATA +26 -0
  79. zettcode_app-0.4.2.dist-info/RECORD +81 -0
  80. zettcode_app-0.4.2.dist-info/WHEEL +4 -0
  81. 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