signal-bot/tool/venv_bootstrap.py
Krzysztof kuhy Rudnicki a24d0d5905
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
feat: one page to manage the bot, and the tagger folded into it
./web.sh serves health, the configuration and the photo album on
127.0.0.1:8140 -- the port tool/tagger.py vacates. React 19, Vite 8,
TypeScript 6.0.3, ESLint 10 with strictTypeChecked + stylisticTypeChecked +
unicorn + sonarjs + perfectionist, Vitest 4 at 100% on all four metrics.

No coverage exclude list, including for main.tsx. This repository treats a
coverage omit as a suppression, and "the DOM bootstrap is awkward to assert"
is the same sentence as "this code is untested" -- so the bootstrap is a shim
thin enough to test in jsdom, and the album view holds its file list apart
from the album so the update path stopped carrying a "what if it is null"
branch that cannot happen. An untestable branch is an untested one.

TypeScript stays at 6.0.3: typescript-eslint 8.68.0 declares
typescript >=4.8.4 <6.1.0, and the fleet-wide allowlist entry says the
compiler waits for the linter. The freshness gate then caught two pins the
reference repo was carrying stale -- eslint-plugin-unicorn 73 -> 74 and a
floating node-version: "24" in CI -- both bumped rather than allowlisted.

Traps the gates found, all of them real:
- .gitignore has a bare lib/, which matches at any depth, so web/src/lib/
  would have been silently untracked. The source lives in src/api/.
- CI's shellcheck glob was `run.sh scripts/*.sh`, so tag.sh was unlinted in
  CI for its whole life and web.sh would have inherited that. Now ./*.sh.
- tsconfig.json is strict JSON, not JSONC: check-json is generic and
  tsconfig being commentable is a convention, not a requirement.
- The personal-data gate refused an invented phone number in a test, which
  is exactly what it is for.

The album's warning that names a command moved with the command, and every
path that pointed at tool/tagger.py now points at ./web.sh. The historical
mentions stay: they explain why the code is shaped as it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01416TegJ3sa8xL5qGGgrygP
2026-08-31 14:54:49 +02:00

156 lines
6.1 KiB
Python
Executable File

#!/usr/bin/env python3
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Make a script work with whatever interpreter you started it with.
``python tool/web.py`` failed with ``ModuleNotFoundError: fastapi`` because
the dependencies live in ``.venv`` and nothing said so. So instead of reporting
that, this fixes it: if the running interpreter cannot import what the script
needs, it creates ``.venv`` if there is none, installs the project into it, and
re-executes the same command line through it.
``web.sh`` and ``run.sh`` get there through ``scripts/ensure_venv.sh``. This
module is the same job for the case where somebody runs the ``.py`` directly,
which is what the file's own docstring used to tell them to do.
**Stdlib only, and it must stay that way.** It runs *before* the environment
holding the dependencies exists, so importing anything third-party here would
be the same bug one level down. That is also why it cannot use
``signal_bot.external``, the one module allowed to start a subprocess: it lives
inside the package being installed. Hence the second, scoped entry in
``per-file-ignores`` -- added deliberately, on kuhy's call of 2026-08-31, and
the reason it is a whole file for two subprocess calls is so that entry can
name something this small.
The sentinel environment variable stops a loop: if the re-executed interpreter
still cannot import what it needs, the second pass gives up and lets the real
ImportError through, so the error names the missing module rather than this.
"""
from __future__ import annotations
import importlib.util
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Final
REPO_ROOT: Final = Path(__file__).resolve().parent.parent
VENV_DIR: Final = REPO_ROOT / ".venv"
VENV_PYTHON: Final = VENV_DIR / "bin" / "python"
SENTINEL: Final = "SIGNAL_BOT_VENV_BOOTSTRAPPED"
# Enough to tell "wrong interpreter" from "project not installed". Not the
# whole dependency list -- pip is the authority on that, not this file.
REQUIRED: Final = ("fastapi", "uvicorn", "httpx", "signal_bot")
def missing(packages: tuple[str, ...] = REQUIRED) -> list[str]:
"""Which of ``packages`` this interpreter cannot import.
``find_spec`` rather than ``import``: it answers the same question without
paying for the module or running its side effects.
"""
absent = []
for name in packages:
try:
found = importlib.util.find_spec(name) is not None
except ImportError, ValueError:
found = False
if not found:
absent.append(name)
return absent
def ensure(packages: tuple[str, ...] = REQUIRED) -> None:
"""Re-execute through ``.venv`` if this interpreter is missing anything.
Does nothing in the normal case: an interpreter that can already import
everything is the right one, whether or not it is the venv's.
Args:
packages: Module names the caller is about to import.
"""
absent = missing(packages)
if not absent or os.environ.get(SENTINEL):
return
# Only build if the venv cannot already do the job. Testing the *current*
# interpreter is what decides to re-exec; testing the *venv's* is what
# decides to install. Conflating them reinstalls on every single launch,
# because the interpreter you typed never grows a fastapi.
if not ready(packages):
sys.stderr.write(f"== setting up .venv (missing: {', '.join(absent)})\n")
provision()
_reexec()
def ready(packages: tuple[str, ...] = REQUIRED) -> bool:
"""Whether the venv's interpreter can already import ``packages``."""
if not VENV_PYTHON.exists():
return False
return _run([str(VENV_PYTHON), "-c", f"import {', '.join(packages)}"], quiet=True)
def provision() -> None:
"""Create the virtualenv if it is not there, then install the project."""
if not VENV_PYTHON.exists() and not _run([*_venv_creator(), str(VENV_DIR)]):
_die(f"could not create a virtualenv at {VENV_DIR}")
if not _install():
_die("could not install the project into .venv")
def _venv_creator() -> list[str]:
"""The command that makes a virtualenv, preferring uv when it is here."""
uv = shutil.which("uv")
return [uv, "venv"] if uv is not None else [sys.executable, "-m", "venv"]
def _install() -> bool:
"""Install the project editable, so a later git pull reaches the page."""
uv = shutil.which("uv")
target = f"{REPO_ROOT}[dev]"
if uv is not None:
return _run(
[uv, "pip", "install", "-e", target], {**os.environ, "VIRTUAL_ENV": str(VENV_DIR)}
)
return _run([str(VENV_PYTHON), "-m", "pip", "install", "-e", target])
def _reexec() -> None:
"""Hand the same command line to the interpreter that can run it."""
if not VENV_PYTHON.exists():
_die(f"no interpreter at {VENV_PYTHON} after install")
os.execve(str(VENV_PYTHON), [str(VENV_PYTHON), *sys.argv], {**os.environ, SENTINEL: "1"})
def _run(command: list[str], env: dict[str, str] | None = None, *, quiet: bool = False) -> bool:
"""Run one setup command and say whether it worked.
Setup output is shown, because an install that takes a minute should look
like it is doing something. The probe is quiet: it fails on every normal
first run and its traceback would be the first thing anybody saw.
"""
output = subprocess.DEVNULL if quiet else None
try:
completed = subprocess.run(command, check=False, env=env, stdout=output, stderr=output)
except OSError:
return False
return completed.returncode == 0
def _die(message: str) -> None:
"""Stop with the command to run, not just what broke."""
sys.stderr.write(f"Error: {message}\n")
sys.stderr.write(f" Try: cd {REPO_ROOT} && uv venv .venv && uv pip install -e '.[dev]'\n")
raise SystemExit(1)
# Run on import, not from a call site. A caller that had to remember to call
# ensure() would have to do it *before* its own third-party imports, and code
# before imports is E402 -- so the fix would need a lint suppression in every
# file it protects. Importing this module is the whole interface: take a
# constant from it and .venv is guaranteed by the time the next import runs.
ensure()