vmshot: make Pillow optional so --one works anywhere, and document the invocation that supplies it

This commit is contained in:
alex 2026-09-09 02:52:56 -04:00
parent a0caaa32fa
commit 96f2da8d2f

View file

@ -45,6 +45,22 @@ Usage
tools/vmshot.py --one 140 # full-size single guest, no sheet
tools/vmshot.py --cols 3 --width 900
**Run it as ``tools/vmshot.py``, not ``python3 tools/vmshot.py``.** The shebang is
``uv run --with pillow``, so invoking the file directly supplies Pillow even on a host
that has none; putting an interpreter in front of it throws that away. Lane BR hit
exactly this and concluded the tool was unusable from the WSL host. ``--one`` now works
either way -- it writes the guest's framebuffer bytes verbatim and needs no Pillow --
and only the contact sheet requires it, with the install line in the error.
For a single frame during a measurement run, going straight to the host is better still
and is what lane BR ended up doing::
ssh spicy "echo 'screendump /tmp/x.png -f png' | qm monitor 146"
That is the *live* framebuffer. The wall at http://192.168.3.201:8140/ serves a cached
frame up to a poll cycle (~5 s) old, which reads exactly like a swallowed click and has
cost two lanes wasted clicks.
Output lands in ``~/sots-re/dumps/vmshot/`` (``dumps/`` is gitignored).
A guest that is stopped, paused, or whose screendump fails gets a labelled
@ -68,7 +84,27 @@ import time
from datetime import datetime
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
# Pillow is needed only to composite the contact sheet. `--one` writes the guest's
# framebuffer bytes straight through, and that is the mode a measurement lane actually
# wants -- lane BR could not use this tool at all from the WSL host because the import
# was unconditional and Pillow is not installed there. Fail at the point of use, with
# the fix in the message, rather than at import.
try:
from PIL import Image, ImageDraw, ImageFont
_PIL_ERROR = None
except ImportError as e: # pragma: no cover - depends on the host
Image = ImageDraw = ImageFont = None # type: ignore[assignment]
_PIL_ERROR = e
def _require_pil() -> None:
if _PIL_ERROR is not None:
sys.exit(
f"vmshot: the contact sheet needs Pillow, which is not installed here ({_PIL_ERROR}).\n"
" `--one <id>` works without it and writes the guest's framebuffer verbatim.\n"
" To build sheets on this host: uv pip install pillow"
)
HOST = "spicy"
SERVICE = os.environ.get("VMWATCH_URL", "http://192.168.3.201:8140")
@ -204,7 +240,7 @@ def grab(ids: list[int], per_vm_timeout: int = 20) -> dict[int, dict]:
return out
def _font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
def _font(size: int, bold: bool = False):
try:
return ImageFont.truetype(FONT_BOLD if bold else FONT_PATH, size)
except OSError:
@ -223,7 +259,7 @@ def _clean_monitor_log(log: str) -> str:
return "; ".join(keep)[:180]
def make_tile(vmid: int, name: str, info: dict, width: int, stamp: str) -> Image.Image:
def make_tile(vmid: int, name: str, info: dict, width: int, stamp: str):
png = info.get("png")
status = info.get("status", "?")
err = _clean_monitor_log(info.get("log", ""))
@ -301,7 +337,8 @@ def make_tile(vmid: int, name: str, info: dict, width: int, stamp: str) -> Image
return tile
def contact_sheet(fleet, shots, tile_w: int, cols: int, stamp: str) -> Image.Image:
def contact_sheet(fleet, shots, tile_w: int, cols: int, stamp: str):
_require_pil()
tiles = [make_tile(vid, name, shots.get(vid, {}), tile_w, stamp) for vid, name, _ in fleet]
rows = (len(tiles) + cols - 1) // cols
row_h = [