Source code for dashboard

#!/usr/bin/env python3
"""Run every quality gate and report one green or red dashboard.

The dashboard is the single signal that every commit must keep green. It runs each gate
in its own subprocess, records the outcome, and prints a table. Its exit status is 0 only
when every gate is green, so the same command serves as a local habit, a pre-commit
check, and a CI step.

Command-line usage::

    python scripts/dashboard.py                        # table on the terminal
    python scripts/dashboard.py --markdown             # Markdown, for CI step summaries
    python scripts/dashboard.py --json runtime/dashboard.json

The seed gates generate a project and run its dashboard. They run only in a repository
that contains the project generator, ``scripts/new_project.py``.
"""

from __future__ import annotations

import argparse
import json
import subprocess
import sys
import time
from collections.abc import Sequence
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Literal

Status = Literal["green", "red"]

DEFAULT_ROOT: Path = Path(__file__).resolve().parent.parent
TAIL_LINES: int = 12
SEED_TARGET: str = "tmp/dashboard-seed"


[docs] @dataclass(frozen=True) class Gate: """A quality gate: a named command that must exit with status 0. Attributes ---------- name : str Short identifier shown on the dashboard. command : tuple of str Argument vector, run from the repository root. purpose : str One sentence that says what the gate protects. """ name: str command: tuple[str, ...] purpose: str
[docs] @dataclass(frozen=True) class GateResult: """The outcome of running one gate. Attributes ---------- name : str The gate's name. status : {"green", "red"} ``"green"`` when the command exited with status 0, and ``"red"`` otherwise. returncode : int The command's exit status. seconds : float Wall-clock seconds the command took, rounded to two decimals. tail : str The last lines of combined output, kept so that a red gate can be investigated. """ name: str status: Status returncode: int seconds: float tail: str
[docs] def default_gates(root: Path, python: str = sys.executable) -> tuple[Gate, ...]: """Build the gates that apply to a repository. Parameters ---------- root : Path Repository root. Whether ``scripts/new_project.py`` exists there decides whether the seed gates are included. python : str, optional Interpreter for every command. The default is the interpreter that runs the dashboard. Returns ------- tuple of Gate The lint, format, type, test, validator, and docs gates, followed by the seed gates when the project generator is present. """ gates = [ Gate("lint", (python, "-m", "ruff", "check", "."), "ruff finds no lint errors"), Gate("format", (python, "-m", "ruff", "format", "--check", "."), "code is formatted"), Gate("types", (python, "-m", "mypy"), "mypy --strict passes on the configured files"), Gate("tests", (python, "-m", "pytest", "-q"), "tests pass and the coverage gate holds"), Gate( "validator", (python, "scripts/check_workflow.py"), "the planning surfaces are consistent", ), Gate( "docs", (python, "-m", "sphinx", "-b", "html", "docs", "_build/html", "-W", "-q"), "the docs build with warnings as errors", ), ] if (root / "scripts" / "new_project.py").is_file(): seed_command = ( python, "scripts/new_project.py", "--name", "Dashboard Seed", "--slug", "dashboard-seed", "--dest", SEED_TARGET, "--force", ) gates.append( Gate("seed", seed_command, "the seed renders a project that passes the validator") ) gates.append( Gate( "seeded-dashboard", (python, f"{SEED_TARGET}/scripts/dashboard.py"), "the rendered project passes its own full dashboard", ) ) return tuple(gates)
[docs] def run_gate(gate: Gate, cwd: Path) -> GateResult: """Run one gate in a subprocess and record its outcome. Parameters ---------- gate : Gate The gate to run. cwd : Path Directory to run the command in, normally the repository root. Returns ------- GateResult The status, exit code, duration, and output tail of the run. """ started = time.perf_counter() completed = subprocess.run(gate.command, cwd=cwd, capture_output=True, text=True, check=False) seconds = round(time.perf_counter() - started, 2) lines = (completed.stdout + completed.stderr).rstrip().splitlines() status: Status = "green" if completed.returncode == 0 else "red" return GateResult( name=gate.name, status=status, returncode=completed.returncode, seconds=seconds, tail="\n".join(lines[-TAIL_LINES:]), )
[docs] def run_gates(gates: Sequence[Gate], cwd: Path) -> list[GateResult]: """Run every gate in order and collect the results. Parameters ---------- gates : sequence of Gate The gates to run, in the order they should appear. cwd : Path Directory to run every command in. Returns ------- list of GateResult One result per gate, in the same order as ``gates``. """ return [run_gate(gate, cwd) for gate in gates]
[docs] def overall_status(results: Sequence[GateResult]) -> Status: """Summarize a dashboard as one status. Parameters ---------- results : sequence of GateResult The results to summarize. Returns ------- {"green", "red"} ``"green"`` only when there is at least one result and every result is green. An empty dashboard is red, because nothing was checked. """ if results and all(result.status == "green" for result in results): return "green" return "red"
[docs] def render_text(results: Sequence[GateResult]) -> str: """Render the dashboard as a plain-text table. Parameters ---------- results : sequence of GateResult The results to render. Returns ------- str A table with one row per gate, an overall verdict, and the output tail of each red gate. """ rows = [f"{'GATE':<20}{'STATUS':<8}{'SECONDS':>8}"] rows.extend(f"{r.name:<20}{r.status.upper():<8}{r.seconds:>8.2f}" for r in results) rows.append("") if overall_status(results) == "green": rows.append("OVERALL: GREEN. Every gate passed.") else: rows.append("OVERALL: RED. Investigate and fix the red gates before continuing.") for result in results: if result.status == "red": rows.extend(["", f"--- {result.name} (exit {result.returncode}) ---", result.tail]) return "\n".join(rows)
[docs] def render_markdown(results: Sequence[GateResult]) -> str: """Render the dashboard as a Markdown table, for a CI step summary. Parameters ---------- results : sequence of GateResult The results to render. Returns ------- str A heading with the overall verdict, and a table with one row per gate. """ overall = overall_status(results).upper() lines = [ f"## Green dashboard: {overall}", "", "| Gate | Status | Seconds |", "| --- | --- | ---: |", ] lines.extend(f"| {r.name} | {r.status.upper()} | {r.seconds:.2f} |" for r in results) if overall == "RED": lines.extend(["", "Investigate and fix the red gates before continuing."]) return "\n".join(lines)
[docs] def record(results: Sequence[GateResult]) -> dict[str, object]: """Build the machine-readable record of a dashboard run. Parameters ---------- results : sequence of GateResult The results to record. Returns ------- dict of str to object The overall status and one entry per gate, ready for ``json.dumps``. """ return {"overall": overall_status(results), "gates": [asdict(r) for r in results]}
[docs] def main( argv: Sequence[str] | None = None, *, gates: Sequence[Gate] | None = None, root: Path = DEFAULT_ROOT, ) -> int: """Run the dashboard from the command line. Parameters ---------- argv : sequence of str or None, optional Arguments to parse. ``None`` reads ``sys.argv``. gates : sequence of Gate or None, optional Gates to run instead of the defaults. Tests use this to avoid running the full suite from inside itself. root : Path, optional Repository root in which the commands run. Returns ------- int ``0`` when every gate is green, and ``1`` otherwise. """ parser = argparse.ArgumentParser(description="Run every quality gate and report.") parser.add_argument("--markdown", action="store_true", help="print a Markdown table") parser.add_argument("--json", type=Path, metavar="PATH", help="also write a JSON record") args = parser.parse_args(argv) chosen = gates if gates is not None else default_gates(root) results = run_gates(chosen, root) print(render_markdown(results) if args.markdown else render_text(results)) if args.json is not None: args.json.parent.mkdir(parents=True, exist_ok=True) args.json.write_text(json.dumps(record(results), indent=2) + "\n", encoding="utf-8") return 0 if overall_status(results) == "green" else 1
if __name__ == "__main__": sys.exit(main())