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.
- {colabapi-0.2.1 → colabapi-0.2.2}/PKG-INFO +14 -5
- {colabapi-0.2.1 → colabapi-0.2.2}/README.md +12 -4
- {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/__init__.py +1 -1
- {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/cli.py +11 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/colabcli.py +30 -0
- colabapi-0.2.2/colabapi/gauges.py +306 -0
- colabapi-0.2.2/colabapi/gui.py +642 -0
- colabapi-0.2.2/colabapi/localpty.py +190 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/monitor.py +81 -2
- {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/terminal.py +72 -4
- colabapi-0.2.2/colabapi/termview.py +304 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/colabapi.egg-info/PKG-INFO +14 -5
- {colabapi-0.2.1 → colabapi-0.2.2}/colabapi.egg-info/SOURCES.txt +3 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/colabapi.egg-info/requires.txt +1 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/pyproject.toml +5 -1
- colabapi-0.2.1/colabapi/gui.py +0 -299
- {colabapi-0.2.1 → colabapi-0.2.2}/LICENSE +0 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/__main__.py +0 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/_colab_shim.py +0 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/_winshim.py +0 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/assets/colabapi.ico +0 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/assets/colabapi.png +0 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/config.py +0 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/keepalive.py +0 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/persist.py +0 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/platform.py +0 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/procutil.py +0 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/runtime.py +0 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/service.py +0 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/shellview.py +0 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/timing.py +0 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/ui.py +0 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/colabapi/winreg_install.py +0 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/colabapi.egg-info/dependency_links.txt +0 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/colabapi.egg-info/entry_points.txt +0 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/colabapi.egg-info/top_level.txt +0 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/setup.cfg +0 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/tests/test_close_semantics.py +0 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/tests/test_e2e_console.py +0 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/tests/test_reconnect.py +0 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/tests/test_windows_compat.py +0 -0
- {colabapi-0.2.1 → colabapi-0.2.2}/tests/test_windows_console.py +0 -0
- {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.
|
|
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
|
|
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
|
-
|
|
225
|
+
**Everything happens in the window. Nothing opens a second terminal.**
|
|
225
226
|
|
|
226
|
-
* **
|
|
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
|
|
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
|
|
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
|
-
|
|
192
|
+
**Everything happens in the window. Nothing opens a second terminal.**
|
|
193
193
|
|
|
194
|
-
* **
|
|
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
|
|
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. |
|
|
@@ -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"
|