"""Unified runner for the pytest invocations CI exercises (issue #156). Reads `scripts/_ci_pytest_manifest.toml` and executes `python -m pytest -v` for each entry. Each invocation is wrapped in `::group::` / `::endgroup::` annotations so GitHub Actions collapses one log block per entry. Failure semantics: the runner CONTINUES through every entry instead of failing fast on the first non-zero exit, so a single PR sees every test suite that broke (not just the first one). The aggregate exit code is non-zero if any entry failed. The runner does NOT install pip deps — that happens once at the workflow level via `pip install -r requirements-dev.txt pytest`. Local use: python scripts/run_ci_pytest_manifest.py # run everything python scripts/run_ci_pytest_manifest.py --id # run one entry The drift guard at scripts/check_ci_pytest_manifest.py validates the manifest shape; this runner trusts the lint and does the minimum schema checks needed to fail safely if the manifest is malformed. """ from __future__ import annotations import argparse import subprocess import sys import tomllib from pathlib import Path def _load_manifest(path: Path) -> list[dict]: if not path.exists(): print(f"ERROR: manifest not found: {path}", file=sys.stderr) sys.exit(1) try: data = tomllib.loads(path.read_text(encoding="utf-8")) except tomllib.TOMLDecodeError as exc: print(f"ERROR: manifest parse error: {exc}", file=sys.stderr) sys.exit(1) entries = data.get("pytest", []) if not isinstance(entries, list): print("ERROR: manifest [[pytest]] must be an array of tables", file=sys.stderr) sys.exit(1) return entries def _run_entry(entry: dict, root: Path) -> int: entry_id = entry.get("id", "") entry_path = entry.get("path", "") entry_args = list(entry.get("args", [])) cmd = [sys.executable, "-m", "pytest", entry_path, *entry_args, "-v"] print(f"::group::{entry_id}") print(f"# {' '.join(cmd)}") sys.stdout.flush() result = subprocess.run(cmd, cwd=str(root)) print("::endgroup::") sys.stdout.flush() return result.returncode def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--manifest", default="scripts/_ci_pytest_manifest.toml", help="Path to the manifest TOML file (default: scripts/_ci_pytest_manifest.toml)", ) parser.add_argument( "--root", default=".", help="Repo root pytest should run from (default: cwd)", ) parser.add_argument( "--id", dest="only_id", default=None, help="Run a single named entry instead of the whole manifest (for local debug)", ) args = parser.parse_args() manifest_path = Path(args.manifest) root = Path(args.root).resolve() entries = _load_manifest(manifest_path) if args.only_id is not None: matches = [e for e in entries if e.get("id") == args.only_id] if not matches: print( f"ERROR: --id {args.only_id!r} not found in manifest", file=sys.stderr, ) return 1 entries = matches failed: list[str] = [] for entry in entries: rc = _run_entry(entry, root) if rc != 0: failed.append(str(entry.get("id", ""))) if failed: print( f"\nCI pytest manifest run FAILED on {len(failed)} entr(y/ies): " + ", ".join(failed), file=sys.stderr, ) return 1 print(f"\nCI pytest manifest run ok ({len(entries)} entr(y/ies)).") return 0 if __name__ == "__main__": sys.exit(main())