colabapi 0.2.1__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {colabapi-0.2.1 → colabapi-0.2.2}/PKG-INFO +14 -5
  2. {colabapi-0.2.1 → colabapi-0.2.2}/README.md +12 -4
  3. {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/__init__.py +1 -1
  4. {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/cli.py +11 -0
  5. {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/colabcli.py +30 -0
  6. colabapi-0.2.2/colabapi/gauges.py +306 -0
  7. colabapi-0.2.2/colabapi/gui.py +642 -0
  8. colabapi-0.2.2/colabapi/localpty.py +190 -0
  9. {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/monitor.py +81 -2
  10. {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/terminal.py +72 -4
  11. colabapi-0.2.2/colabapi/termview.py +304 -0
  12. {colabapi-0.2.1 → colabapi-0.2.2}/colabapi.egg-info/PKG-INFO +14 -5
  13. {colabapi-0.2.1 → colabapi-0.2.2}/colabapi.egg-info/SOURCES.txt +3 -0
  14. {colabapi-0.2.1 → colabapi-0.2.2}/colabapi.egg-info/requires.txt +1 -0
  15. {colabapi-0.2.1 → colabapi-0.2.2}/pyproject.toml +5 -1
  16. colabapi-0.2.1/colabapi/gui.py +0 -299
  17. {colabapi-0.2.1 → colabapi-0.2.2}/LICENSE +0 -0
  18. {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/__main__.py +0 -0
  19. {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/_colab_shim.py +0 -0
  20. {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/_winshim.py +0 -0
  21. {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/assets/colabapi.ico +0 -0
  22. {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/assets/colabapi.png +0 -0
  23. {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/config.py +0 -0
  24. {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/keepalive.py +0 -0
  25. {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/persist.py +0 -0
  26. {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/platform.py +0 -0
  27. {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/procutil.py +0 -0
  28. {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/runtime.py +0 -0
  29. {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/service.py +0 -0
  30. {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/shellview.py +0 -0
  31. {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/timing.py +0 -0
  32. {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/ui.py +0 -0
  33. {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/winreg_install.py +0 -0
  34. {colabapi-0.2.1 → colabapi-0.2.2}/colabapi.egg-info/dependency_links.txt +0 -0
  35. {colabapi-0.2.1 → colabapi-0.2.2}/colabapi.egg-info/entry_points.txt +0 -0
  36. {colabapi-0.2.1 → colabapi-0.2.2}/colabapi.egg-info/top_level.txt +0 -0
  37. {colabapi-0.2.1 → colabapi-0.2.2}/setup.cfg +0 -0
  38. {colabapi-0.2.1 → colabapi-0.2.2}/tests/test_close_semantics.py +0 -0
  39. {colabapi-0.2.1 → colabapi-0.2.2}/tests/test_e2e_console.py +0 -0
  40. {colabapi-0.2.1 → colabapi-0.2.2}/tests/test_reconnect.py +0 -0
  41. {colabapi-0.2.1 → colabapi-0.2.2}/tests/test_windows_compat.py +0 -0
  42. {colabapi-0.2.1 → colabapi-0.2.2}/tests/test_windows_console.py +0 -0
  43. {colabapi-0.2.1 → colabapi-0.2.2}/tests/test_windows_encoding.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: colabapi
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Run and keep a Google Colab runtime alive, then reach its terminal from your own server, laptop, or Windows PC. A CLI for headless, persistent Colab sessions -- with Windows support the official CLI does not have.
5
5
  Author: lil-limbo
6
6
  License: MIT
@@ -27,6 +27,7 @@ License-File: LICENSE
27
27
  Requires-Dist: click>=8.1
28
28
  Requires-Dist: rich>=13.0
29
29
  Requires-Dist: websocket-client>=1.8
30
+ Requires-Dist: pyte>=0.8
30
31
  Requires-Dist: google-colab-cli>=0.6.0
31
32
  Dynamic: license-file
32
33
 
@@ -58,7 +59,7 @@ Google Colab is fantastic free (and paid) GPU/TPU compute, but it only lives ins
58
59
 
59
60
  ## Features
60
61
 
61
- * 🖥 **A desktop app, not just a CLI.** `colabapi ui` opens a clean graphical window (white, minimal, works on Linux and Windows) with one click access to Login, Run, Shell, Monitor, Sessions, Status, Doctor, and Logout. On Windows it opens straight from the Start menu and the app list.
62
+ * 🖥 **A desktop app, not just a CLI.** `colabapi ui` opens a clean graphical window (white, minimal, works on Linux and Windows) with live CPU / RAM / GPU / VRAM graphs, a session list, and a **real terminal built into the window** — the shell, sign-in, runtime allocation and everything else run *inside it*, not in a terminal it throws you out to. On Windows it opens straight from the Start menu and the app list.
62
63
  * 🪟 **Works on Windows** (PowerShell + CMD), which Google's own CLI does not. Registers as real installed software with its own logo.
63
64
  * 🔌 **Reconnects instead of dying.** WebSocket keepalive pings, exponential backoff, and your work keeps running on the VM across the drop.
64
65
  * 🔐 **Browser sign in, no password handling.** Authentication happens in Google's own login flow (including 2FA / device checks). `colabapi` never asks for, stores, or transmits your Google credentials. `colabapi logout` signs you out again in one command.
@@ -221,16 +222,24 @@ Press **Ctrl+C** to leave the monitor; type **`exit`** or press **Ctrl+D** to le
221
222
  colabapi ui
222
223
  ```
223
224
 
224
- A minimal white window with the same actions as the CLI: sign in, allocate a runtime, open the shell or the live monitor (each in its own terminal window), and check sessions, status, or doctor output right inside the window. The header shows whether you are signed in and how many sessions are active.
225
+ **Everything happens in the window. Nothing opens a second terminal.**
225
226
 
226
- * **Windows:** after `colabapi register`, "colabapi" in the Start menu and the app list opens this window directly.
227
+ * **Live graphs** across the top: CPU, RAM, GPU and VRAM, read from inside the selected runtime and plotted as they move.
228
+ * **A real terminal** in the window — a full VT emulator, not a log box. `Shell` drops you into the selected runtime (auto-reconnecting, and your work keeps running in tmux across a drop). `New runtime`, `Sign in`, `Monitor` and the rest run *in that same terminal*, on a pty, so their prompts prompt and you answer them right there.
229
+ * **Select a session, then act on it.** The list on the left is the selection `Shell`, `Stop` and `Monitor` operate on, and it is what the graphs follow. With nothing selected, those buttons are disabled rather than guessing.
230
+ * **Stop** releases the runtime, with a confirmation first.
231
+ * **Keyboard throughout:** Ctrl+N new runtime, Ctrl+S shell, Ctrl+K stop, Ctrl+M monitor, Ctrl+L sign in, F5 refresh. Enter on a session opens its shell. Every action is also a real, focusable button, and the menu bar carries the rest (REPL, status, runtimes, doctor, keep-alive service).
232
+
233
+ Sign-in is Google's own browser flow, exactly as on the command line — colabapi never sees your password.
234
+
235
+ * **Windows:** after `colabapi register`, "colabapi" in the Start menu and the app list opens this window directly. Windows has no pty, so there the interactive commands still open a console window; the shell, the graphs and everything else run in the window as normal.
227
236
  * **Linux:** the window uses Tkinter, which some distros package separately. If `colabapi ui` says Tkinter is missing, install it with `sudo apt install python3-tk` (Debian / Ubuntu / Kali) or `sudo dnf install python3-tkinter` (Fedora).
228
237
 
229
238
  ## Command reference
230
239
 
231
240
  | Command | What it does |
232
241
  |---|---|
233
- | `colabapi ui` | Open the colabapi desktop window (all of the below, with buttons). |
242
+ | `colabapi ui` | Open the colabapi window: live graphs, session list, and a built-in terminal that runs all of the below. |
234
243
  | `colabapi login` | Sign in via Google's browser flow (no password handled). |
235
244
  | `colabapi logout` | Sign out of Google and forget all sessions, for a clean start. |
236
245
  | `colabapi runtimes` | List runtime types and which need a paid plan. |
@@ -26,7 +26,7 @@ Google Colab is fantastic free (and paid) GPU/TPU compute, but it only lives ins
26
26
 
27
27
  ## Features
28
28
 
29
- * 🖥 **A desktop app, not just a CLI.** `colabapi ui` opens a clean graphical window (white, minimal, works on Linux and Windows) with one click access to Login, Run, Shell, Monitor, Sessions, Status, Doctor, and Logout. On Windows it opens straight from the Start menu and the app list.
29
+ * 🖥 **A desktop app, not just a CLI.** `colabapi ui` opens a clean graphical window (white, minimal, works on Linux and Windows) with live CPU / RAM / GPU / VRAM graphs, a session list, and a **real terminal built into the window** — the shell, sign-in, runtime allocation and everything else run *inside it*, not in a terminal it throws you out to. On Windows it opens straight from the Start menu and the app list.
30
30
  * 🪟 **Works on Windows** (PowerShell + CMD), which Google's own CLI does not. Registers as real installed software with its own logo.
31
31
  * 🔌 **Reconnects instead of dying.** WebSocket keepalive pings, exponential backoff, and your work keeps running on the VM across the drop.
32
32
  * 🔐 **Browser sign in, no password handling.** Authentication happens in Google's own login flow (including 2FA / device checks). `colabapi` never asks for, stores, or transmits your Google credentials. `colabapi logout` signs you out again in one command.
@@ -189,16 +189,24 @@ Press **Ctrl+C** to leave the monitor; type **`exit`** or press **Ctrl+D** to le
189
189
  colabapi ui
190
190
  ```
191
191
 
192
- A minimal white window with the same actions as the CLI: sign in, allocate a runtime, open the shell or the live monitor (each in its own terminal window), and check sessions, status, or doctor output right inside the window. The header shows whether you are signed in and how many sessions are active.
192
+ **Everything happens in the window. Nothing opens a second terminal.**
193
193
 
194
- * **Windows:** after `colabapi register`, "colabapi" in the Start menu and the app list opens this window directly.
194
+ * **Live graphs** across the top: CPU, RAM, GPU and VRAM, read from inside the selected runtime and plotted as they move.
195
+ * **A real terminal** in the window — a full VT emulator, not a log box. `Shell` drops you into the selected runtime (auto-reconnecting, and your work keeps running in tmux across a drop). `New runtime`, `Sign in`, `Monitor` and the rest run *in that same terminal*, on a pty, so their prompts prompt and you answer them right there.
196
+ * **Select a session, then act on it.** The list on the left is the selection `Shell`, `Stop` and `Monitor` operate on, and it is what the graphs follow. With nothing selected, those buttons are disabled rather than guessing.
197
+ * **Stop** releases the runtime, with a confirmation first.
198
+ * **Keyboard throughout:** Ctrl+N new runtime, Ctrl+S shell, Ctrl+K stop, Ctrl+M monitor, Ctrl+L sign in, F5 refresh. Enter on a session opens its shell. Every action is also a real, focusable button, and the menu bar carries the rest (REPL, status, runtimes, doctor, keep-alive service).
199
+
200
+ Sign-in is Google's own browser flow, exactly as on the command line — colabapi never sees your password.
201
+
202
+ * **Windows:** after `colabapi register`, "colabapi" in the Start menu and the app list opens this window directly. Windows has no pty, so there the interactive commands still open a console window; the shell, the graphs and everything else run in the window as normal.
195
203
  * **Linux:** the window uses Tkinter, which some distros package separately. If `colabapi ui` says Tkinter is missing, install it with `sudo apt install python3-tk` (Debian / Ubuntu / Kali) or `sudo dnf install python3-tkinter` (Fedora).
196
204
 
197
205
  ## Command reference
198
206
 
199
207
  | Command | What it does |
200
208
  |---|---|
201
- | `colabapi ui` | Open the colabapi desktop window (all of the below, with buttons). |
209
+ | `colabapi ui` | Open the colabapi window: live graphs, session list, and a built-in terminal that runs all of the below. |
202
210
  | `colabapi login` | Sign in via Google's browser flow (no password handled). |
203
211
  | `colabapi logout` | Sign out of Google and forget all sessions, for a clean start. |
204
212
  | `colabapi runtimes` | List runtime types and which need a paid plan. |
@@ -4,4 +4,4 @@ The tool never handles your Google password. You sign in to Colab in your own
4
4
  browser; colabapi connects to the running runtime over a secure tunnel.
5
5
  """
6
6
 
7
- __version__ = "0.2.1"
7
+ __version__ = "0.2.2"
@@ -231,6 +231,17 @@ def ui_cmd() -> None:
231
231
 
232
232
  def _tk_missing_help(detail: str = "") -> None:
233
233
  """Explain how to get Tkinter, the one piece pip cannot install."""
234
+ if "pyte" in detail:
235
+ # A different missing piece entirely, and pip *can* install this one.
236
+ # Printing the python3-tk advice here would send the user to fix
237
+ # something that is not broken.
238
+ console.print(Panel.fit(
239
+ "The window's embedded terminal needs [bold]pyte[/], which is missing.\n"
240
+ "It normally installs with colabapi:\n\n"
241
+ " [bold]pip install pyte[/]\n\n"
242
+ "or reinstall colabapi itself: [bold]pipx install --force colabapi[/]",
243
+ title="colabapi ui", border_style="yellow"))
244
+ return
234
245
  if "display" in detail:
235
246
  # Tkinter is fine; there is just no screen (headless box, plain ssh).
236
247
  console.print(Panel.fit(
@@ -258,6 +258,36 @@ class ColabCLI:
258
258
  """
259
259
  return self._run(["exec", *self._session_args()], timeout=timeout, input=code)
260
260
 
261
+ def exec_stream(self, code: str) -> subprocess.Popen:
262
+ """Run long-lived Python on the runtime and stream its stdout back.
263
+
264
+ `exec_code` above is one shot: it pays the connection cost (measured at
265
+ ~4s against a live T4) and then throws the connection away. Calling it in
266
+ a loop can therefore never sample faster than that, no matter what
267
+ interval is asked for -- which is why the window's graphs stream instead.
268
+ A program that prints on its own clock delivers each line as it is
269
+ produced (verified: ticks arrive 1.0s apart after the initial connect),
270
+ so one connection gives a genuinely live feed.
271
+
272
+ Returns the running process. The caller reads `.stdout` line by line and
273
+ must terminate it when done.
274
+ """
275
+ proc = subprocess.Popen(
276
+ self._command(["exec", *self._session_args()]),
277
+ stdin=subprocess.PIPE,
278
+ stdout=subprocess.PIPE,
279
+ stderr=subprocess.STDOUT, # errors belong in the same stream
280
+ text=True,
281
+ bufsize=1, # line buffered: a line out is a line in
282
+ env=self._child_env(),
283
+ )
284
+ # The code is the program, delivered on stdin and then EOF'd -- `colab
285
+ # exec` reads until EOF before it runs anything.
286
+ if proc.stdin is not None:
287
+ proc.stdin.write(code)
288
+ proc.stdin.close()
289
+ return proc
290
+
261
291
  def raw(self, args: Sequence[str]) -> int:
262
292
  """Passthrough escape hatch: `colabapi raw -- <args>` -> `colab <args>`."""
263
293
  return self._exec_tty(list(args))
@@ -0,0 +1,306 @@
1
+ """Live CPU / RAM / GPU / VRAM graphs for the selected session.
2
+
3
+ What the numbers are: they are read from *inside* the Colab runtime (a small
4
+ Python snippet run over the tunnel -- see `monitor.read_stats`), so they describe
5
+ the VM you are paying attention to, not the laptop the window is on.
6
+
7
+ Two pieces, kept apart on purpose:
8
+
9
+ * `Sampler` -- a background thread that polls one session and hands each
10
+ reading back on the UI thread. It owns the cadence and the failure handling,
11
+ and it can be pointed at a different session (or none) at any time.
12
+ * `Graphs` -- a dumb Tk widget. Given a reading it draws; given nothing it says
13
+ so. It never talks to Colab.
14
+
15
+ Sampling costs a `colab exec` round trip, so the cadence is seconds, not
16
+ milliseconds, and a sample is skipped entirely while the previous one is still in
17
+ flight -- a slow runtime must never queue up a backlog of probes.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import threading
23
+ import tkinter as tk
24
+ import tkinter.font as tkfont
25
+ from collections import deque
26
+ from typing import Callable, Optional
27
+
28
+ from . import monitor
29
+
30
+ # At one reading a second, this is the last three minutes -- enough to watch a
31
+ # training step spike and settle, without holding data nobody looks at.
32
+ HISTORY = 180
33
+
34
+ # The palette is shared with the rest of the window (gui.py) but redeclared as
35
+ # graph roles, so a colour change here cannot silently restyle a button.
36
+ INK = "#111111"
37
+ MUTED = "#6b6b6b"
38
+ GRID = "#ececec"
39
+ CARD = "#fafafa"
40
+ BORDER = "#e0e0e0"
41
+
42
+ CPU_COLOUR = "#3b82f6"
43
+ RAM_COLOUR = "#8b5cf6"
44
+ GPU_COLOUR = "#10b981"
45
+ VRAM_COLOUR = "#f59e0b"
46
+ DEAD = "#c9c9c9"
47
+
48
+
49
+ class Sampler:
50
+ """A live feed of one session's vitals.
51
+
52
+ Not a poller. Polling meant a fresh `colab exec` per reading, and connecting
53
+ costs ~4s against a real runtime -- so "every second" was never achievable
54
+ that way, however short the interval. Instead one long-lived exec runs
55
+ `monitor.STREAM_SNIPPET` on the VM and prints a block every second; this
56
+ reads those blocks as they arrive. The runtime's own clock sets the cadence,
57
+ so the graphs move once a second because the machine says so.
58
+
59
+ The stream is re-established if it dies (the runtime went away, the network
60
+ blinked), with a pause so a genuinely dead session is not hammered.
61
+ """
62
+
63
+ def __init__(self, open_stream: Callable[[str], object],
64
+ on_sample: Callable[[Optional[dict], str], None],
65
+ retry: float = 5.0):
66
+ # open_stream(session) -> a Popen whose stdout yields the blocks.
67
+ # Injected rather than built here so the GUI supplies the ColabCLI its
68
+ # buttons already drive, and tests can supply a fake.
69
+ self._open_stream = open_stream
70
+ self._on_sample = on_sample
71
+ self.retry = retry
72
+ self._session: Optional[str] = None
73
+ self._proc = None
74
+ self._lock = threading.Lock()
75
+ self._stop = threading.Event()
76
+ self._changed = threading.Event()
77
+ self._thread: Optional[threading.Thread] = None
78
+
79
+ def start(self) -> None:
80
+ if self._thread is None:
81
+ self._thread = threading.Thread(target=self._loop, name="colabapi-sampler",
82
+ daemon=True)
83
+ self._thread.start()
84
+
85
+ def watch(self, session: Optional[str]) -> None:
86
+ """Point the feed at a session (or None to idle). Takes effect now."""
87
+ with self._lock:
88
+ if session == self._session:
89
+ return
90
+ self._session = session
91
+ self._changed.set()
92
+ self._kill() # drop the old stream; the loop opens the new one
93
+
94
+ def stop(self) -> None:
95
+ self._stop.set()
96
+ self._changed.set()
97
+ self._kill()
98
+
99
+ def _kill(self) -> None:
100
+ proc, self._proc = self._proc, None
101
+ if proc is not None:
102
+ try:
103
+ proc.terminate()
104
+ except Exception: # noqa: BLE001
105
+ pass
106
+
107
+ def _loop(self) -> None:
108
+ while not self._stop.is_set():
109
+ with self._lock:
110
+ session = self._session
111
+ if session is None:
112
+ self._on_sample(None, "")
113
+ self._changed.wait(0.5)
114
+ self._changed.clear()
115
+ continue
116
+ try:
117
+ self._stream(session)
118
+ except Exception as exc: # noqa: BLE001 -- a dead runtime is news, not a crash
119
+ self._on_sample(None, _reason(exc))
120
+ if self._stop.is_set():
121
+ return
122
+ # The stream ended. If the user just switched sessions that is
123
+ # expected and we go straight round; otherwise wait, so a runtime
124
+ # that is really gone is not reconnected to on a tight loop.
125
+ if not self._changed.is_set():
126
+ self._changed.wait(self.retry)
127
+ self._changed.clear()
128
+
129
+ def _stream(self, session: str) -> None:
130
+ proc = self._open_stream(session)
131
+ self._proc = proc
132
+ block: list = []
133
+ errors: list = []
134
+ try:
135
+ for line in proc.stdout: # blocks until the VM speaks
136
+ if self._stop.is_set() or self._session != session:
137
+ return
138
+ line = line.rstrip("\n")
139
+ if line == "END":
140
+ self._on_sample(monitor.parse_block(block), "")
141
+ block = []
142
+ errors = []
143
+ elif line.startswith(("CPU ", "MEM ", "GPU ")):
144
+ block.append(line)
145
+ elif line.strip():
146
+ # Anything else is the CLI or the runtime complaining. Keep
147
+ # the last few lines: if the stream then dies, this is the
148
+ # only explanation the user will ever get.
149
+ errors.append(line.strip())
150
+ del errors[:-3]
151
+ finally:
152
+ self._kill()
153
+ if self._stop.is_set() or self._session != session:
154
+ return
155
+ code = proc.poll()
156
+ why = " · ".join(errors) if errors else (
157
+ f"the stats feed ended (exit {code})" if code else "the stats feed ended")
158
+ self._on_sample(None, why[:120])
159
+
160
+
161
+ def _reason(exc: Exception) -> str:
162
+ text = str(exc).strip().splitlines()
163
+ first = text[0] if text else exc.__class__.__name__
164
+ return first[:120]
165
+
166
+
167
+ class _Sparkline(tk.Canvas):
168
+ """One metric: a title, a current value, and its recent history."""
169
+
170
+ def __init__(self, parent, label: str, colour: str, height: int = 74, **kw):
171
+ super().__init__(parent, height=height, bg=CARD, highlightthickness=1,
172
+ highlightbackground=BORDER, bd=0, **kw)
173
+ self.label = label
174
+ self.colour = colour
175
+ self.history: deque = deque(maxlen=HISTORY)
176
+ self._value = ""
177
+ self._detail = ""
178
+ self._live = True
179
+ self._title_font = tkfont.Font(family=_ui_family(), size=8, weight="bold")
180
+ self._value_font = tkfont.Font(family=_ui_family(), size=13, weight="bold")
181
+ self._detail_font = tkfont.Font(family=_ui_family(), size=8)
182
+ self.bind("<Configure>", lambda _e: self._draw())
183
+
184
+ def push(self, pct: Optional[float], value: str, detail: str = "") -> None:
185
+ if pct is not None:
186
+ self.history.append(max(0.0, min(float(pct), 100.0)))
187
+ self._value, self._detail = value, detail
188
+ self._live = pct is not None
189
+ self._draw()
190
+
191
+ def clear(self, value: str = "—") -> None:
192
+ self.history.clear()
193
+ self._value, self._detail, self._live = value, "", False
194
+ self._draw()
195
+
196
+ def _draw(self) -> None:
197
+ self.delete("all")
198
+ w, h = self.winfo_width(), self.winfo_height()
199
+ if w <= 1 or h <= 1:
200
+ return
201
+ colour = self.colour if self._live else DEAD
202
+
203
+ self.create_text(10, 9, text=self.label.upper(), anchor="nw",
204
+ fill=MUTED, font=self._title_font)
205
+ self.create_text(10, 22, text=self._value or "—", anchor="nw",
206
+ fill=INK if self._live else MUTED, font=self._value_font)
207
+ if self._detail:
208
+ self.create_text(w - 10, 12, text=self._detail, anchor="ne",
209
+ fill=MUTED, font=self._detail_font)
210
+
211
+ # The plot occupies the lower band; the reading stays legible above it.
212
+ top, bottom = h * 0.55, h - 6
213
+ span = max(bottom - top, 1)
214
+ self.create_line(8, bottom, w - 8, bottom, fill=GRID)
215
+ n = len(self.history)
216
+ if n < 2:
217
+ if self._live:
218
+ self.create_text(w // 2, (top + bottom) / 2, text="collecting…",
219
+ fill=MUTED, font=self._detail_font)
220
+ return
221
+
222
+ # Always plot against a full 0-100 scale rather than autoscaling: a CPU
223
+ # idling at 2% must *look* idle, and an autoscaled axis would draw it as
224
+ # a dramatic mountain range.
225
+ left, right = 8, w - 8
226
+ step = (right - left) / max(n - 1, 1)
227
+ pts = [(left + i * step, bottom - (v / 100.0) * span)
228
+ for i, v in enumerate(self.history)]
229
+ area = [(left, bottom)] + pts + [(pts[-1][0], bottom)]
230
+ self.create_polygon([c for p in area for c in p],
231
+ fill=_tint(colour), outline="")
232
+ self.create_line([c for p in pts for c in p], fill=colour, width=2,
233
+ smooth=True, capstyle="round", joinstyle="round")
234
+ x, y = pts[-1]
235
+ self.create_oval(x - 2.5, y - 2.5, x + 2.5, y + 2.5, fill=colour, outline="")
236
+
237
+
238
+ def _tint(hex_colour: str) -> str:
239
+ """A pale wash of the line colour for the area under it. Tk has no alpha, so
240
+ the colour is mixed toward the card background instead."""
241
+ r, g, b = (int(hex_colour[i:i + 2], 16) for i in (1, 3, 5))
242
+ br, bg_, bb = (int(CARD[i:i + 2], 16) for i in (1, 3, 5))
243
+ mix = lambda c, base: int(base + (c - base) * 0.16) # noqa: E731
244
+ return f"#{mix(r, br):02x}{mix(g, bg_):02x}{mix(b, bb):02x}"
245
+
246
+
247
+ class Graphs(tk.Frame):
248
+ """The row of live graphs across the top of the window."""
249
+
250
+ def __init__(self, parent, bg: str = "#ffffff", **kw):
251
+ super().__init__(parent, bg=bg, **kw)
252
+ self.cpu = _Sparkline(self, "CPU", CPU_COLOUR)
253
+ self.ram = _Sparkline(self, "RAM", RAM_COLOUR)
254
+ self.gpu = _Sparkline(self, "GPU", GPU_COLOUR)
255
+ self.vram = _Sparkline(self, "VRAM", VRAM_COLOUR)
256
+ for i, card in enumerate((self.cpu, self.ram, self.gpu, self.vram)):
257
+ card.grid(row=0, column=i, sticky="nsew", padx=(0 if i == 0 else 6, 0))
258
+ self.columnconfigure(i, weight=1, uniform="g")
259
+
260
+ def update_from(self, stats: Optional[dict], reason: str = "") -> None:
261
+ """Paint one reading. `None` means there is nothing to read, and the
262
+ reason (if any) is shown rather than swallowed."""
263
+ if not stats:
264
+ for card in (self.cpu, self.ram, self.gpu, self.vram):
265
+ card.clear()
266
+ self.cpu._detail = reason[:40] if reason else ""
267
+ self.cpu._draw()
268
+ return
269
+
270
+ cpu = stats.get("cpu") or 0.0
271
+ self.cpu.push(cpu, f"{cpu:.0f}%")
272
+
273
+ used, total = stats.get("mem_used") or 0.0, stats.get("mem_total") or 0.0
274
+ pct = (used / total * 100) if total else 0.0
275
+ self.ram.push(pct, f"{pct:.0f}%", f"{used/1024:.1f} / {total/1024:.1f} GiB")
276
+
277
+ gpus = stats.get("gpus") or []
278
+ if not gpus:
279
+ # A CPU-only runtime is a normal thing to have, not a failure: say
280
+ # what it is instead of drawing two dead graphs.
281
+ self.gpu.clear("—")
282
+ self.gpu._detail = "no GPU"
283
+ self.gpu._draw()
284
+ self.vram.clear("—")
285
+ self.vram._detail = "CPU-only runtime"
286
+ self.vram._draw()
287
+ return
288
+
289
+ g = gpus[0]
290
+ self.gpu.push(g["util"], f"{g['util']:.0f}%",
291
+ f"{g['name']} {g['temp']:.0f}°C")
292
+ vused, vtotal = g["mem_used"], g["mem_total"]
293
+ vpct = (vused / vtotal * 100) if vtotal else 0.0
294
+ self.vram.push(vpct, f"{vpct:.0f}%",
295
+ f"{vused/1024:.1f} / {vtotal/1024:.1f} GiB")
296
+
297
+
298
+ def _ui_family() -> str:
299
+ try:
300
+ families = set(tkfont.families())
301
+ except tk.TclError:
302
+ return "Helvetica"
303
+ for name in ("Segoe UI", "Inter", "DejaVu Sans", "Cantarell", "Helvetica"):
304
+ if name in families:
305
+ return name
306
+ return "Helvetica"