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
zettcode/update.py ADDED
@@ -0,0 +1,519 @@
1
+ """Notice a newer ZettCode on PyPI, and install it when the reader asks.
2
+
3
+ The check itself is never on the startup path: a background task asks PyPI once a day and leaves what it found in
4
+ ``~/.zettcode/update.json``. The next start reads that file — no network, nothing to wait for — and offers the version
5
+ it names, unless the reader has already skipped that one. The upgrade that follows a "yes" does block, on purpose:
6
+ the reader asked for it, so it asks PyPI what is newest at that moment, installs from PyPI's own index, and the window
7
+ takes no input until it is done.
8
+
9
+ An upgrade is only believed once it has been checked. A package manager that exits 0 has
10
+ not necessarily installed anything (``uv tool upgrade`` answers "Nothing to upgrade" with 0, and so
11
+ does a pip whose index has not listed the release yet), so after it runs the installed version is
12
+ read back from the interpreter that was upgraded and written to the same file. What the file says
13
+ is what the next start trusts: an upgrade that did not take is reported as that, not as done, and
14
+ one that did is not offered again.
15
+
16
+ Every failure means "no news". A machine with no network, a proxy that eats the
17
+ request, an index that answers with something unexpected: none of them may
18
+ become the reason a session fails to open, so the check swallows what it cannot
19
+ use and leaves the stored state alone.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import json
26
+ import os
27
+ import ssl
28
+ import subprocess
29
+ import sys
30
+ import tempfile
31
+ import threading
32
+ import urllib.error
33
+ import urllib.request
34
+ from collections.abc import Callable, Sequence
35
+ from dataclasses import dataclass, replace
36
+ from datetime import datetime, timedelta, timezone
37
+ from pathlib import Path
38
+ from shutil import which
39
+ from tempfile import NamedTemporaryFile
40
+ from typing import Any, TypeVar
41
+
42
+ from zettcode.paths import DEFAULT_UPDATE_FILE
43
+ from zettcode.version import __version__
44
+
45
+ Result = TypeVar("Result")
46
+
47
+ #: PyPI's JSON API for this project; ``info.version`` is the newest release. Named for the API it is, beside the
48
+ #: simple index below, so the two cannot be mistaken for one another.
49
+ JSON_API_URL = "https://pypi.org/pypi/zettcode/json"
50
+
51
+ #: PyPI's package index, which the upgrade is pointed at explicitly. The metadata above comes from PyPI, so the
52
+ #: download has to as well: a mirror configured on this machine can lag behind it by hours, and the package manager
53
+ #: then reports "already satisfied" for a release that has existed for a while. A reader who wants the mirror can
54
+ #: still install by hand.
55
+ SIMPLE_INDEX = "https://pypi.org/simple"
56
+
57
+ #: Seconds the request may take before it is abandoned. Generous on purpose: a
58
+ #: corporate proxy is routine here, and the check runs in the background, so a
59
+ #: slow answer costs a thread rather than a frame. A start never notices either way.
60
+ TIMEOUT = 10.0
61
+
62
+ #: How long one answer is trusted: a start inside the window reads the file only.
63
+ CHECK_INTERVAL = timedelta(hours=24)
64
+
65
+
66
+ def version_key(value: str) -> tuple[int, ...]:
67
+ """Return a comparable key for a version such as ``0.1.2``.
68
+
69
+ Only the leading digits of each dot-separated part are read, so a release
70
+ candidate (``0.2.0rc1``) keys the same as the release it leads to and is
71
+ never offered over it.
72
+ """
73
+ parts: list[int] = []
74
+ for piece in value.strip().lstrip("vV").split("."):
75
+ leading = ""
76
+ for character in piece:
77
+ if not character.isdigit():
78
+ break
79
+ leading += character
80
+ parts.append(int(leading) if leading else 0)
81
+ return tuple(parts)
82
+
83
+
84
+ def is_newer(candidate: str, current: str) -> bool:
85
+ """Return whether ``candidate`` is a later release than ``current``."""
86
+ return version_key(candidate) > version_key(current)
87
+
88
+
89
+ def _text(value: object) -> str | None:
90
+ """Return a stored string, or ``None`` for anything else."""
91
+ if not isinstance(value, str):
92
+ return None
93
+ return value.strip() or None
94
+
95
+
96
+ def _moment(value: object) -> datetime | None:
97
+ """Return a stored timestamp as an aware datetime, or ``None``."""
98
+ if not isinstance(value, str):
99
+ return None
100
+ try:
101
+ moment = datetime.fromisoformat(value)
102
+ except ValueError:
103
+ return None
104
+ return moment if moment.tzinfo is not None else moment.replace(tzinfo=timezone.utc)
105
+
106
+
107
+ @dataclass(frozen=True, slots=True)
108
+ class UpdateState:
109
+ """What the last check found, what the reader has dismissed, and what an upgrade installed.
110
+
111
+ Attributes:
112
+ checked_at: When the index was last asked, aware and in UTC; ``None``
113
+ before the first successful check.
114
+ latest: Newest release the index named, if any.
115
+ skipped: Version the reader said not to ask about again.
116
+ installed: Version an upgrade was *confirmed* to have installed, read back from the
117
+ environment afterwards; ``None`` until one has been.
118
+ installed_by: The interpreter that upgrade ran in. A record is only about that interpreter.
119
+ noted: Version whose mismatch with the running program has already been explained, so the
120
+ explanation is given once and not at every start.
121
+ """
122
+
123
+ checked_at: datetime | None = None
124
+ latest: str | None = None
125
+ skipped: str | None = None
126
+ installed: str | None = None
127
+ installed_by: str | None = None
128
+ noted: str | None = None
129
+
130
+ @property
131
+ def stale(self) -> bool:
132
+ """Return whether the stored answer is old enough to ask the index again."""
133
+ if self.checked_at is None:
134
+ return True
135
+ return datetime.now(timezone.utc) - self.checked_at >= CHECK_INTERVAL
136
+
137
+ def shadowed_by(self, current: str = __version__, executable: str | None = None) -> str | None:
138
+ """Return the version an upgrade installed here that this process is not running, if any.
139
+
140
+ A confirmed install newer than the running program, in the interpreter that is running it,
141
+ means the upgrade worked and something else is supplying the code — an editable checkout, a
142
+ ``PYTHONPATH`` — so offering the same upgrade again cannot change what runs. A record made
143
+ by another interpreter says nothing about this one: that copy really is old.
144
+
145
+ Args:
146
+ current: Version of the running program.
147
+ executable: Interpreter running it; defaults to this process's.
148
+ """
149
+ if self.installed is None or self.installed_by != (executable or sys.executable):
150
+ return None
151
+ return self.installed if is_newer(self.installed, current) else None
152
+
153
+ def offer(self, current: str = __version__, executable: str | None = None) -> str | None:
154
+ """Return the version worth offering, or ``None`` when there is nothing to say."""
155
+ if self.latest is None or self.skipped == self.latest:
156
+ return None
157
+ if self.shadowed_by(current, executable) is not None:
158
+ return None
159
+ return self.latest if is_newer(self.latest, current) else None
160
+
161
+
162
+ async def in_background(work: Callable[[], Result]) -> Result:
163
+ """Run one blocking call on a daemon thread and return what it produced.
164
+
165
+ Not :func:`asyncio.to_thread`: that uses the loop's own executor, which is
166
+ joined when the process exits, so a request still waiting on a slow proxy
167
+ would hold the terminal open for as long as its timeout. This thread is left
168
+ behind instead, because a check nobody is waiting for cannot matter once the
169
+ session is gone.
170
+ """
171
+ loop = asyncio.get_running_loop()
172
+ future: asyncio.Future[Result] = loop.create_future()
173
+
174
+ def deliver(value: Result) -> None:
175
+ """Resolve the awaiting coroutine, unless it already went away."""
176
+ if not future.done():
177
+ future.set_result(value)
178
+
179
+ def fail(error: BaseException) -> None:
180
+ """Report why the call produced nothing, for the same reason."""
181
+ if not future.done():
182
+ future.set_exception(error)
183
+
184
+ def hand_over(callback: Callable[[Any], None], argument: Any) -> None:
185
+ """Pass the outcome to the loop, unless the loop is gone.
186
+
187
+ The thread is left running on purpose when the session ends, so its answer can
188
+ arrive after the loop has been closed — and ``call_soon_threadsafe`` then raises.
189
+ A daemon thread that raises prints a traceback, and one that does so while the
190
+ interpreter is shutting down can take the whole process down with it: the
191
+ standard error stream's lock cannot be taken, and Python aborts. Nobody is
192
+ waiting for this answer any more, so dropping it is the whole of the handling.
193
+ """
194
+ try:
195
+ loop.call_soon_threadsafe(callback, argument)
196
+ except RuntimeError:
197
+ pass
198
+
199
+ def run() -> None:
200
+ try:
201
+ value = work()
202
+ except BaseException as error: # noqa: BLE001 - handed to the awaiting caller
203
+ hand_over(fail, error)
204
+ else:
205
+ hand_over(deliver, value)
206
+
207
+ threading.Thread(target=run, name="zettcode-background", daemon=True).start()
208
+ return await future
209
+
210
+
211
+ def read_state(path: Path) -> UpdateState:
212
+ """Return what the last check left, or an empty state when nothing is readable."""
213
+ try:
214
+ raw = json.loads(path.read_text(encoding="utf-8"))
215
+ except (OSError, ValueError):
216
+ return UpdateState()
217
+ if not isinstance(raw, dict):
218
+ return UpdateState()
219
+ return UpdateState(
220
+ checked_at=_moment(raw.get("checked_at")),
221
+ latest=_text(raw.get("latest")),
222
+ skipped=_text(raw.get("skipped")),
223
+ installed=_text(raw.get("installed")),
224
+ installed_by=_text(raw.get("installed_by")),
225
+ noted=_text(raw.get("noted")),
226
+ )
227
+
228
+
229
+ def write_state(state: UpdateState, path: Path) -> None:
230
+ """Write the state in one replace, so a crash cannot leave half a file."""
231
+ payload = {
232
+ "checked_at": state.checked_at.isoformat() if state.checked_at is not None else None,
233
+ "latest": state.latest,
234
+ "skipped": state.skipped,
235
+ "installed": state.installed,
236
+ "installed_by": state.installed_by,
237
+ "noted": state.noted,
238
+ }
239
+ path.parent.mkdir(parents=True, exist_ok=True)
240
+ with NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
241
+ json.dump(payload, handle, indent=2)
242
+ handle.write("\n")
243
+ temporary = Path(handle.name)
244
+ os.replace(temporary, path)
245
+
246
+
247
+ def fetch_latest(url: str = JSON_API_URL, *, timeout: float = TIMEOUT) -> str:
248
+ """Return the newest release the index reports, raising when it cannot."""
249
+ request = urllib.request.Request(url, headers={"User-Agent": f"zettcode/{__version__}"})
250
+ try:
251
+ with urllib.request.urlopen( # noqa: S310 - the URL is ours
252
+ request, timeout=timeout, context=ssl_context()
253
+ ) as response:
254
+ payload = json.loads(response.read().decode("utf-8"))
255
+ except (urllib.error.URLError, OSError, ValueError) as error:
256
+ raise RuntimeError(f"could not read {url}: {error}") from error
257
+ latest = payload.get("info", {}).get("version") if isinstance(payload, dict) else None
258
+ if not isinstance(latest, str) or not latest.strip():
259
+ raise RuntimeError(f"{url} answered without a version")
260
+ return latest.strip()
261
+
262
+
263
+ def ssl_context() -> ssl.SSLContext:
264
+ """Return the trust store to verify the index against.
265
+
266
+ The platform's own, through ``truststore``, rather than the roots bundled
267
+ with Python: a machine behind a corporate proxy — which is what an internal
268
+ gateway usually means — cannot verify anything against the bundled set, and
269
+ the system store is what every other program on it already trusts. The
270
+ import is local because the check runs in a worker thread, and a client that
271
+ never asks about updates should not pay for it at import time.
272
+ """
273
+ try:
274
+ import truststore
275
+ except ImportError: # pragma: no cover - truststore ships with the runtime deps
276
+ return ssl.create_default_context()
277
+ return truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
278
+
279
+
280
+ async def check_for_update(
281
+ path: Path,
282
+ *,
283
+ fetch: Callable[[], str] = fetch_latest,
284
+ now: Callable[[], datetime] | None = None,
285
+ ) -> UpdateState | None:
286
+ """Ask the index in a thread and store what it said.
287
+
288
+ Args:
289
+ path: File the answer is written to.
290
+ fetch: Supplies the newest version; injectable so tests stay offline.
291
+ now: Clock for the check timestamp; defaults to UTC now.
292
+
293
+ Returns:
294
+ The state that was written, or ``None`` when the stored answer was still
295
+ fresh, or the index could not be read.
296
+ """
297
+ state = read_state(path)
298
+ if not state.stale:
299
+ return None
300
+ try:
301
+ latest = await in_background(fetch)
302
+ except Exception:
303
+ # Offline, a proxy in the way, an index that answered oddly: the next
304
+ # start tries again, and this one says nothing about it.
305
+ return None
306
+ moment = (now or (lambda: datetime.now(timezone.utc)))()
307
+ written = replace(state, checked_at=moment, latest=latest)
308
+ write_state(written, path)
309
+ return written
310
+
311
+
312
+ def installed_by_uv_tool() -> bool:
313
+ """Return whether this interpreter lives in a ``uv tool`` environment.
314
+
315
+ ``uv`` leaves a ``uv-receipt.toml`` at the root of every tool environment, wherever
316
+ ``UV_TOOL_DIR`` put it, which is the one thing that is not a guess. The path is the fallback,
317
+ and it is read as given: a tool environment's ``python`` is a link to the interpreter it was
318
+ built from, and resolving it names that interpreter's directory instead, which has no ``tools``
319
+ in it. That is how a real ``uv tool`` install used to be taken for a pip one, and handed a
320
+ ``pip install --upgrade`` in an environment that has no pip.
321
+ """
322
+ if (Path(sys.prefix) / "uv-receipt.toml").is_file():
323
+ return True
324
+ parts = Path(sys.executable).parts
325
+ return "uv" in parts and "tools" in parts
326
+
327
+
328
+ def upgrade_command() -> tuple[str, ...]:
329
+ """Return the command that replaces this installation with the newest release.
330
+
331
+ ``uv tool upgrade`` when uv installed ZettCode that way, ``pip`` for the
332
+ interpreter running this process otherwise. Both are pointed at PyPI's own index: the newest version was read
333
+ from PyPI, and installing through whatever mirror the machine has configured would ask for a release that index
334
+ may not list yet. The panel shows the command before it runs, so a wrong guess is visible rather than
335
+ surprising.
336
+ """
337
+ if installed_by_uv_tool() and which("uv") is not None:
338
+ # `--index-url` rather than the newer `--default-index`: current uv still takes the old spelling, and an
339
+ # older one only has that, so an upgrade that worked before this flag keeps working.
340
+ return ("uv", "tool", "upgrade", "zettcode", "--index-url", SIMPLE_INDEX)
341
+ return (sys.executable, "-m", "pip", "install", "--upgrade", "--index-url", SIMPLE_INDEX, "zettcode")
342
+
343
+
344
+ async def run_upgrade(command: Sequence[str] | None = None, *, timeout: float = 600.0) -> tuple[bool, str]:
345
+ """Run the upgrade in a thread and return whether it worked and what it said.
346
+
347
+ The output is captured rather than painted: this runs while the terminal
348
+ belongs to the frame, and a package manager's progress bars would corrupt it.
349
+ """
350
+ argv = tuple(command) if command is not None else upgrade_command()
351
+ try:
352
+ result = await in_background(
353
+ lambda: subprocess.run(
354
+ argv,
355
+ capture_output=True,
356
+ text=True,
357
+ stdin=subprocess.DEVNULL,
358
+ timeout=timeout,
359
+ check=False,
360
+ )
361
+ )
362
+ except (OSError, subprocess.SubprocessError) as error:
363
+ return False, str(error)
364
+ output = f"{result.stdout or ''}{result.stderr or ''}".strip()
365
+ return result.returncode == 0, output
366
+
367
+
368
+ #: What a fresh interpreter is asked to print: the version of the installed distribution, which is
369
+ #: what the next start will run, whatever this process was started with.
370
+ INSTALLED_PROBE = "from importlib.metadata import version; print(version('zettcode'))"
371
+
372
+
373
+ async def installed_version(executable: str | None = None, *, timeout: float = 60.0) -> str | None:
374
+ """Return the version of ZettCode installed for an interpreter, read from a fresh process.
375
+
376
+ The running process cannot answer: it imported the code it started with, which is the version
377
+ that is being replaced. A new process reads what is on disk now, which is what the next start
378
+ will run. It is started from a neutral directory, because a start is not made from the workspace
379
+ and a stray ``zettcode`` there must not answer for the install.
380
+
381
+ Args:
382
+ executable: The interpreter the upgrade ran in; defaults to this process's.
383
+ timeout: Seconds before giving up.
384
+
385
+ Returns:
386
+ The version, or ``None`` when it could not be read — which is "unconfirmed", never "unchanged".
387
+ """
388
+ argv = (executable or sys.executable, "-c", INSTALLED_PROBE)
389
+ try:
390
+ result = await in_background(
391
+ lambda: subprocess.run(
392
+ argv,
393
+ capture_output=True,
394
+ text=True,
395
+ stdin=subprocess.DEVNULL,
396
+ timeout=timeout,
397
+ check=False,
398
+ cwd=tempfile.gettempdir(),
399
+ )
400
+ )
401
+ except (OSError, subprocess.SubprocessError):
402
+ return None
403
+ if result.returncode != 0:
404
+ return None
405
+ lines = [line.strip() for line in (result.stdout or "").splitlines() if line.strip()]
406
+ return lines[-1] if lines else None
407
+
408
+
409
+ def record_install(path: Path, version: str, executable: str | None = None) -> None:
410
+ """Write down that an upgrade installed ``version``, keeping everything else the file says.
411
+
412
+ Args:
413
+ path: The state file.
414
+ version: What :func:`installed_version` confirmed.
415
+ executable: The interpreter it was confirmed for; defaults to this process's.
416
+ """
417
+ write_state(
418
+ replace(read_state(path), installed=version, installed_by=executable or sys.executable, noted=None), path
419
+ )
420
+
421
+
422
+ def running_from() -> str:
423
+ """Return the directory the running program's code was loaded from."""
424
+ return str(Path(__file__).resolve().parent)
425
+
426
+
427
+ def display_command(command: Sequence[str]) -> str:
428
+ """Return the command the way a reader would type it.
429
+
430
+ The pip fallback has to name an interpreter to be unambiguous
431
+ (``/path/to/python -m pip ...``), and that path is both long enough to be
432
+ clipped in a panel row and noise beside the ``pip install`` it stands for.
433
+ Everything else is shown as it runs, including the index: the panel promises the command can be read before it
434
+ runs, and which index it installs from is exactly what a reader deciding to accept it would want to see.
435
+ """
436
+ argv = list(command)
437
+ if len(argv) > 2 and argv[1] == "-m" and Path(argv[0]).name.lower().startswith(("python", "pypy")):
438
+ return " ".join(argv[2:])
439
+ return " ".join(argv)
440
+
441
+
442
+ def _run_in_terminal(command: Sequence[str]) -> int:
443
+ """Run the package manager with the reader's own terminal, so its progress is what they see."""
444
+ try:
445
+ return subprocess.run(list(command), check=False).returncode
446
+ except OSError as error:
447
+ print(f"could not run {display_command(command)}: {error}", file=sys.stderr)
448
+ return 127
449
+
450
+
451
+ def _installed_now() -> str | None:
452
+ """Read the installed version back from a fresh interpreter (see :func:`installed_version`)."""
453
+ return asyncio.run(installed_version())
454
+
455
+
456
+ def update_program(
457
+ *,
458
+ current: str = __version__,
459
+ path: Path | None = None,
460
+ fetch: Callable[[], str] = fetch_latest,
461
+ run: Callable[[Sequence[str]], int] = _run_in_terminal,
462
+ read_installed: Callable[[], str | None] = _installed_now,
463
+ say: Callable[[str], None] = print,
464
+ ) -> int:
465
+ """Install the newest release now, from the command line: what ``zettcode update`` does.
466
+
467
+ The panel inside the program offers the same upgrade; this is for the reader who would rather ask for it
468
+ than wait to be offered. It asks the index first, so a program that is already the newest says so and
469
+ runs nothing; if the index cannot be reached it goes on regardless, because the package manager knows
470
+ better than a failed request whether there is anything to install. The package manager's own output is
471
+ shown, and its exit status is not believed on its own (see the module notes): the version is read back
472
+ from a fresh interpreter, and only that is reported and recorded.
473
+
474
+ Args:
475
+ current: The version running now.
476
+ path: The state file the confirmed install is written to; defaults to ``~/.zettcode/update.json``.
477
+ fetch: Supplies the newest version; injectable so tests stay offline.
478
+ run: Runs the upgrade command and returns its exit status.
479
+ read_installed: Reads the version installed now, from outside this process.
480
+ say: Where the report goes.
481
+
482
+ Returns:
483
+ The exit status: ``0`` when ZettCode is the newest release or was updated (or the update ran and the
484
+ result could not be read back), ``1`` when the upgrade failed or left the old version.
485
+ """
486
+ say(f"ZettCode {current}")
487
+ latest: str | None
488
+ try:
489
+ latest = fetch()
490
+ except Exception: # noqa: BLE001 - offline, a proxy, an odd answer: let the package manager decide
491
+ latest = None
492
+ if latest is not None and not is_newer(latest, current):
493
+ say(f"already the newest release ({current})")
494
+ return 0
495
+ command = upgrade_command()
496
+ say(f"updating{f' to {latest}' if latest else ''}: {display_command(command)}")
497
+ status = run(command)
498
+ if status != 0:
499
+ say(f"the update failed (exit status {status}); run it yourself: {display_command(command)}")
500
+ return 1
501
+ installed = read_installed()
502
+ if installed is None:
503
+ say("the update ran, but the installed version could not be read back — check with `zettcode --version`")
504
+ return 0
505
+ if not is_newer(installed, current):
506
+ if latest is not None:
507
+ say(
508
+ f"ZettCode is still {installed}, not {latest}: the release may be yanked, or the installer may have "
509
+ f"been pinned to {installed}; try `uv tool install zettcode@latest`, or {display_command(command)}"
510
+ )
511
+ else:
512
+ say(f"ZettCode is {installed}: there was nothing newer to install")
513
+ return 1 if latest is not None else 0
514
+ try:
515
+ record_install(path if path is not None else DEFAULT_UPDATE_FILE, installed)
516
+ except OSError:
517
+ pass # the install happened; failing to write it down must not make it look as if it had not
518
+ say(f"updated {current} → {installed}; restart ZettCode to use it")
519
+ return 0
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.5
2
+ Name: zettcode-app
3
+ Version: 0.4.2
4
+ Summary: The terminal application of ZettCode: the conversation model, the shell and its views
5
+ Project-URL: Repository, https://github.com/Chang-LeHung/zettcode
6
+ Author: Chang-LeHung
7
+ License-Expression: MIT
8
+ Keywords: agent,terminal,tui,zettcode
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Programming Language :: Python :: 3.15
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: truststore<1,>=0.10
19
+ Requires-Dist: zett-agent<0.3,>=0.2.10
20
+ Requires-Dist: zettcode-broker
21
+ Requires-Dist: zettcode-engine
22
+ Requires-Dist: zettcode-messaging
23
+ Requires-Dist: zettcode-plugin-api
24
+ Requires-Dist: zettcode-presenters
25
+ Requires-Dist: zettcode-sdk
26
+ Requires-Dist: zettcode-tui
@@ -0,0 +1,81 @@
1
+ zettcode/collector.py,sha256=bYx20re5LX4VrJ8G7X-0oSrutqg6Bb9D2wlHXK_lwTM,2026
2
+ zettcode/update.py,sha256=HSAI219P5T_JphU79axKoUT7gSDHLT7t-EssV8ElQW4,22902
3
+ zettcode/app/__init__.py,sha256=VkkjVsS5BFrHELyB202476EoB217pZEo3IQkQpYG52I,2021
4
+ zettcode/app/errors.py,sha256=n9DzRnItv96c_8pWtYnGsJ4snxD5b_MVr-ZW4krZspU,581
5
+ zettcode/app/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ zettcode/app/agent/__init__.py,sha256=oud8tPXFmVpDcsMd0klnSaq0ViNENaxJiWy61oIQlNw,1642
7
+ zettcode/app/agent/agent/__init__.py,sha256=8McGcz87u0-KPD9HpcOXWHYtoLn_JRfDXQqSmQ_f5Tk,3681
8
+ zettcode/app/agent/agent/commands.py,sha256=s81xUuEg1e_Wtpjp0bF0it_n9DFs6DiBUVGaYTJBBfc,12780
9
+ zettcode/app/agent/agent/settings.py,sha256=BLrb7WKQJFpbZDncnEOWc0-r7aq7q8Jmf9_m1rYt_yM,6076
10
+ zettcode/app/agent/conversation/__init__.py,sha256=NMuYfXmNpVlyd7yvX0d8RtNCPSar5pXVn0PygEg_SUU,431
11
+ zettcode/app/agent/conversation/changes.py,sha256=pBy80toI-CfcRF6UAUZrapUCi27lA5Jgt4z66CTG4_Y,2834
12
+ zettcode/app/agent/conversation/effort_gauge.py,sha256=FJIQhaA5hOWYyU5Dq4UbydYK0Ha8yVkkDAuWy578Uhc,5550
13
+ zettcode/app/agent/conversation/projection.py,sha256=s2ZCUk7f9wyepN1BIKX0vbYwqKUOhSXxvOsEvJktl_U,13523
14
+ zettcode/app/agent/conversation/rendering.py,sha256=vSAhA2R5B7wElcNTN5eYCIiV3NNfgWxNbwkDq7SPU8M,36075
15
+ zettcode/app/agent/conversation/replay.py,sha256=YblcKg3nE_nWLW1j1vygZwL03jpQpYB5guY37ySp6Tk,9301
16
+ zettcode/app/agent/conversation/shimmer.py,sha256=H13nyLU2il01zdjOyo8x6kESBx4gkVTkRuRhRBYWtzo,12582
17
+ zettcode/app/agent/conversation/stops.py,sha256=GsYwBHy0kJfr2uWHUbltDs4-fXPBkDmB55vYXyw8R9M,3027
18
+ zettcode/app/agent/conversation/tips.py,sha256=MWRHxFUDbZxPitCu-fXLFipsCnWoccG8j1W988_JuAg,5418
19
+ zettcode/app/agent/conversation/transcript/__init__.py,sha256=GuX7BAfB3v2qVIeoEa5-x-u6cppDd3HMs2tfMfb_Vos,3761
20
+ zettcode/app/agent/conversation/transcript/fold.py,sha256=7EmpZXmlBCYA3Nxi7NbP82RbhfVx_Mq8LJF9YYSzmCc,2011
21
+ zettcode/app/agent/conversation/transcript/log.py,sha256=p2Ap75ICPVpz4wrlHp7BscAmnZlqjD1kyL-Xm60O9UQ,8241
22
+ zettcode/app/agent/conversation/transcript/oneoff.py,sha256=RMYevSfmSk5KwjWB6jGl2MktU--mDzeyChf7-GaK0QE,2506
23
+ zettcode/app/agent/conversation/transcript/stream.py,sha256=-vIBurmykFXRVzAS6YbcewTNxbuLVnmhMQ19LPhIXIw,8431
24
+ zettcode/app/agent/conversation/transcript/turn.py,sha256=lFF7Kuv3DRG95yxM3YeYag8R2TmTuPBQfm5EknsXsuc,15455
25
+ zettcode/app/commandline/__init__.py,sha256=1LfVb4_I3bZf_-AkmPyviimM1wUxTMTyG-8PNNNhEjU,1016
26
+ zettcode/app/commandline/arguments.py,sha256=DUtpNMTYH1dY6U1uKHTs_FEy7qCXen3uP7xQFBRXmes,6501
27
+ zettcode/app/commandline/completion.py,sha256=I1nerxI3gyHvvjfUktuOnCUn0dlv47WLe5HqSJR5L08,12788
28
+ zettcode/app/commandline/history.py,sha256=BoSBmFmwy0jXarvH3rBR2WQp0o5CtrTzmPJ1scmevOY,12156
29
+ zettcode/app/commandline/runner.py,sha256=mPpCX6JNgu3thj3HXPdZtqVo-54ht47A-D2a_2lK9sY,15481
30
+ zettcode/app/plugins/__init__.py,sha256=9fLhiGQHNhH2SuEOegxyWAdNKM0ISp19o_rXIIE_yNw,3531
31
+ zettcode/app/plugins/builtins.py,sha256=1AvNNi4U5mbSTBhhGEv5dlu3KKIdFwl0r8aD1w3-hCA,1419
32
+ zettcode/app/plugins/groups.py,sha256=v6CdaEtti9FRvEWRYGLCIN-MGTvaY_X-XdK4HLvtgdQ,23049
33
+ zettcode/app/plugins/loader.py,sha256=bXvN2vuwUQHFvJ_c5ltNDGTCeAdEsPkgXatgJzBS5NA,13143
34
+ zettcode/app/ui/__init__.py,sha256=X4psJrk-0LbVawDHHwdKIRFSnYZXyX2L8YK1vCjhBgo,1720
35
+ zettcode/app/ui/app.py,sha256=iq8XcMrhR5Oc7IOLLAndeqzp9j9jtX_5uwpVMiihhIo,74748
36
+ zettcode/app/ui/bars.py,sha256=HXcA7JFiuKpzh2jlQaB-Zpy0t5NAWQCBcyLOUX7evCg,14148
37
+ zettcode/app/ui/boot.py,sha256=v_6-ts3lQX8ZgWuj77QNl06138OsWRj6eMv_KutHJgQ,6135
38
+ zettcode/app/ui/clipboard.py,sha256=jjENYb9HgdS99IxMZgwM-4ZfSkNspBbSjrtoH_biY2Q,14670
39
+ zettcode/app/ui/commands.py,sha256=Z5Ug0Es6bYA5JkDtzbIf_xGPLo07UB2lbveJUQrCFrk,16529
40
+ zettcode/app/ui/config_form.py,sha256=xKHCcGhcDx0oQS-5lMETPhfIfRk-qcRpm_qf8tS19xY,13733
41
+ zettcode/app/ui/demo.py,sha256=ykt5oJtNgwgulLSJLmInRKOZh7fwNskE_yhKBpY5fvY,6306
42
+ zettcode/app/ui/followups.py,sha256=0t4Ff0mKMho6Iub0kvvHGJveKqUreY-8gKx5cU8HTq4,20368
43
+ zettcode/app/ui/gc_policy.py,sha256=vHNYaikNcsOCiGlwRdEgmybapW6zS1_-DKHeHzGNBk8,6341
44
+ zettcode/app/ui/keys.py,sha256=2wKeBqGzDtUN18AMOIBT2bn07894FR-WidMMtdKNTMw,24216
45
+ zettcode/app/ui/messaging.py,sha256=rZYLW45QVPVQhYhYAqnmIJK3akQmaypi2KqOlHU4h8g,13332
46
+ zettcode/app/ui/notices.py,sha256=vx9Mgaa8A21M6zDxDzOE_4Gc6kyMB_C74yPJAXqoTeU,8608
47
+ zettcode/app/ui/providers.py,sha256=8GobiWrbOjxBQM_jOcsKR8q2xD9SzjLGWRi69fqGT4k,28397
48
+ zettcode/app/ui/sessions.py,sha256=EraEvh3-2cgpClnTn67l4yfsHoLmN8aM9qdWNqsi-3c,20719
49
+ zettcode/app/ui/settings.py,sha256=s5SCRg22m3H0aa-q97W_WoR1s_vxBQscrEAZF4m93HA,6734
50
+ zettcode/app/ui/shell.py,sha256=CatRQgMttX995RGDsXsYn1eSFcfW0nITaJhqTECavZs,16710
51
+ zettcode/app/ui/turns.py,sha256=wCNepTbDLu1htFJgm9deZvlUwx5uURy7i8K0VTjSRVE,4960
52
+ zettcode/app/ui/updating.py,sha256=LtB3vGq6OUo2XlnLXav1PuIz0WMdAn9ZLHZzr_YfFrI,9754
53
+ zettcode/app/ui/widgets/__init__.py,sha256=4ifL1V3x42BY89ni-_8x7TISgJY-miLA63R1mD0-7lA,5176
54
+ zettcode/app/ui/widgets/approval_page.py,sha256=zT9oR-YNC67azEXFxjIF2nsoms4zJQMzyLB2VgLO6ck,12155
55
+ zettcode/app/ui/widgets/ask_page.py,sha256=7lW8mQau6kHAUnRG0W5sXA5fJdozH6m3YErLI8cGsf8,16498
56
+ zettcode/app/ui/widgets/bash_page.py,sha256=-0sVK89HIRrhOz6n2YDenRFMXXo1GIenRWy68WoccLE,3667
57
+ zettcode/app/ui/widgets/completer.py,sha256=1o1wWdwXxyjfkGHWC7wGXl3g8HNpcaJmn-5gu6gCoVE,4761
58
+ zettcode/app/ui/widgets/composer.py,sha256=urxL_Bnpkg4P8C6-C3LdfZduA_ae9EtHsmGZd01ef_k,23674
59
+ zettcode/app/ui/widgets/document_page.py,sha256=xEmWjiWjT7AJraFLu33S7rsVORr8YJWKVvogzY3gXuk,6038
60
+ zettcode/app/ui/widgets/effort_page.py,sha256=Gs1UpOpev9sruL61iA7TSP928JYAc0_20Cmx4GL4w08,13632
61
+ zettcode/app/ui/widgets/follow_up.py,sha256=kpX4YfetJERWGsu8K5vNccmMcWnXtgKcmnAs8hQV-TI,8310
62
+ zettcode/app/ui/widgets/form_page.py,sha256=INiEDQkrLliJ1Ot6uwMZypu_TwhAFxvTgg8WOO5PNTQ,16303
63
+ zettcode/app/ui/widgets/input_page.py,sha256=PRVFHzizVhbp7YxcmIASIpyTEcAqdYVI5_qlVbuk19s,6543
64
+ zettcode/app/ui/widgets/model_page.py,sha256=9S9k1NbyffsIiLAwltRbKwMq8lD8W9PPNV-AqGEHF5M,2745
65
+ zettcode/app/ui/widgets/model_settings_page.py,sha256=4rIi53PEoSYZqA3HjHE3cWygkXpIeSZgffVvC8vcHuY,26382
66
+ zettcode/app/ui/widgets/pet.py,sha256=dsVLO_BvverF-0Iec8l6KBu1716Qi56mwnAizrcohHc,12108
67
+ zettcode/app/ui/widgets/pet_page.py,sha256=aXEmgPF8U3l9Q_X7dTL0ldN7A8hl1WrptIKevmjU8yo,15840
68
+ zettcode/app/ui/widgets/plugins_page.py,sha256=gHpkdCU_8JKdkRlZSHj-CWmc_jVpzUl90x-ttszezHI,20095
69
+ zettcode/app/ui/widgets/root.py,sha256=qVgAhWwZ9dK6CQJ5dgI7k36qFam12q5oU3ltXJuFj1M,2714
70
+ zettcode/app/ui/widgets/self_compact_page.py,sha256=0g0Ozu_ru6MwbtYFDEg_XR2TYYSYRqe-T6VGUQ1gkAk,16999
71
+ zettcode/app/ui/widgets/send_kind_page.py,sha256=_0knUlYNl9_9esaQ6WfSP5RH38PYSboacDodW_y7mGM,7437
72
+ zettcode/app/ui/widgets/sessions_page.py,sha256=wsoH4fjXg4K-Xg9WsqyMFiMZk4XE0sRVo74UxMqKfdk,24825
73
+ zettcode/app/ui/widgets/statusline_page.py,sha256=Jc535A3Yt1ahcP0Hx7uEA0_NcBHQjmijcTDTw9_4Yi4,19821
74
+ zettcode/app/ui/widgets/steering.py,sha256=go1zaSYYjXCSJJnaXrGsbYiZNp2jaASnP3DVm_1eEDk,5216
75
+ zettcode/app/ui/widgets/transcript.py,sha256=0_WQhR3YUZ8zxZftLcQemgehoC4a4jKZ2I8oLQ2v-fU,12155
76
+ zettcode/app/ui/widgets/update_page.py,sha256=FBgXU9P1A7FAq0_apa-XKc7IcKian9O2hwx0RxwBpIo,2588
77
+ zettcode/app/ui/widgets/welcome.py,sha256=9jbcTUzvWQxWjLeG4ZQCLm64H12QKXn6Vc_auSR2fhA,919
78
+ zettcode_app-0.4.2.dist-info/METADATA,sha256=ZkIYj685g6jSmsYWccCQWbQvtmStSSKaRTUdMlP_VJc,1010
79
+ zettcode_app-0.4.2.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
80
+ zettcode_app-0.4.2.dist-info/entry_points.txt,sha256=r2wjH9DbirtzmkUoBErNV1u2sOco10jBRGBCyEEQYDE,71
81
+ zettcode_app-0.4.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [zettcode.plugin]
2
+ themes = zettcode.app.plugins.builtins:BuiltinThemes