|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +Inspect all notebook outputs under static/ and report failures. |
| 4 | +
|
| 5 | +Behaviour: |
| 6 | +- Writes a Markdown summary table to $GITHUB_STEP_SUMMARY (if set) listing |
| 7 | + every failed notebook per package/version with its truncated error. |
| 8 | +- Emits ::warning:: log lines so GitHub annotates each failed notebook |
| 9 | + visibly on the Actions run page. |
| 10 | +- Exits with code 1 iff the *latest* version of any package has at least one |
| 11 | + failed notebook output. Latest is determined from the package manifest's |
| 12 | + `latestTag`. Historical versions can fail without blocking deployment. |
| 13 | +
|
| 14 | +Usage (called from CI): |
| 15 | + python scripts/check-notebook-health.py |
| 16 | +""" |
| 17 | + |
| 18 | +from __future__ import annotations |
| 19 | + |
| 20 | +import json |
| 21 | +import os |
| 22 | +import sys |
| 23 | +from pathlib import Path |
| 24 | + |
| 25 | +STATIC_DIR = Path(__file__).resolve().parent.parent / "static" |
| 26 | + |
| 27 | +# Skip directories under static/ that aren't packages. |
| 28 | +NON_PACKAGE_DIRS = {"pyodide"} |
| 29 | + |
| 30 | + |
| 31 | +def _load_json(path: Path) -> dict | None: |
| 32 | + try: |
| 33 | + with open(path, "r", encoding="utf-8") as f: |
| 34 | + return json.load(f) |
| 35 | + except (json.JSONDecodeError, OSError): |
| 36 | + return None |
| 37 | + |
| 38 | + |
| 39 | +def _strip_ansi(s: str) -> str: |
| 40 | + """Remove ANSI escape sequences from a string.""" |
| 41 | + out = [] |
| 42 | + i = 0 |
| 43 | + while i < len(s): |
| 44 | + if s[i] == "\x1b" and i + 1 < len(s) and s[i + 1] == "[": |
| 45 | + j = i + 2 |
| 46 | + while j < len(s) and not (0x40 <= ord(s[j]) <= 0x7E): |
| 47 | + j += 1 |
| 48 | + i = j + 1 |
| 49 | + else: |
| 50 | + out.append(s[i]) |
| 51 | + i += 1 |
| 52 | + return "".join(out) |
| 53 | + |
| 54 | + |
| 55 | +def collect_failures() -> dict[tuple[str, str], list[tuple[str, str]]]: |
| 56 | + """Return {(package, tag): [(notebook_stem, error_message), ...]} for failed outputs.""" |
| 57 | + failures: dict[tuple[str, str], list[tuple[str, str]]] = {} |
| 58 | + |
| 59 | + for pkg_dir in sorted(p for p in STATIC_DIR.iterdir() if p.is_dir()): |
| 60 | + if pkg_dir.name in NON_PACKAGE_DIRS: |
| 61 | + continue |
| 62 | + for version_dir in sorted(p for p in pkg_dir.iterdir() if p.is_dir()): |
| 63 | + if not version_dir.name.startswith("v"): |
| 64 | + continue |
| 65 | + outputs_dir = version_dir / "outputs" |
| 66 | + if not outputs_dir.exists(): |
| 67 | + continue |
| 68 | + for output_file in sorted(outputs_dir.glob("*.json")): |
| 69 | + data = _load_json(output_file) |
| 70 | + if data is None or data.get("success") is not False: |
| 71 | + continue |
| 72 | + error = _strip_ansi(str(data.get("error", "unknown error"))).strip() |
| 73 | + if len(error) > 200: |
| 74 | + error = error[:197] + "..." |
| 75 | + failures.setdefault((pkg_dir.name, version_dir.name), []).append( |
| 76 | + (output_file.stem, error) |
| 77 | + ) |
| 78 | + return failures |
| 79 | + |
| 80 | + |
| 81 | +def latest_tags() -> dict[str, str]: |
| 82 | + """Return {package_id: latest_tag} from each package's top-level manifest.""" |
| 83 | + result: dict[str, str] = {} |
| 84 | + for pkg_dir in STATIC_DIR.iterdir(): |
| 85 | + if not pkg_dir.is_dir() or pkg_dir.name in NON_PACKAGE_DIRS: |
| 86 | + continue |
| 87 | + manifest = _load_json(pkg_dir / "manifest.json") |
| 88 | + if manifest and "latestTag" in manifest: |
| 89 | + result[pkg_dir.name] = manifest["latestTag"] |
| 90 | + return result |
| 91 | + |
| 92 | + |
| 93 | +def main() -> int: |
| 94 | + failures = collect_failures() |
| 95 | + latest = latest_tags() |
| 96 | + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") |
| 97 | + |
| 98 | + if not failures: |
| 99 | + msg = "All notebook outputs report success.\n" |
| 100 | + print(msg, end="") |
| 101 | + if summary_path: |
| 102 | + Path(summary_path).write_text("### Notebook health\n\n" + msg) |
| 103 | + return 0 |
| 104 | + |
| 105 | + # GitHub annotations — appear inline on the Actions run page. |
| 106 | + for (pkg, tag), items in failures.items(): |
| 107 | + for stem, err in items: |
| 108 | + print(f"::warning title=Notebook failure::{pkg}/{tag}/{stem}: {err}") |
| 109 | + |
| 110 | + # Step summary. |
| 111 | + lines = ["### Notebook health\n", f"\n{sum(len(v) for v in failures.values())} failed notebook output(s) across {len(failures)} version(s).\n"] |
| 112 | + lines.append("\n| Package | Version | Latest? | Notebook | Error |\n") |
| 113 | + lines.append("|---|---|---|---|---|\n") |
| 114 | + for (pkg, tag), items in sorted(failures.items()): |
| 115 | + is_latest = "**yes**" if latest.get(pkg) == tag else "" |
| 116 | + for stem, err in items: |
| 117 | + # Escape pipe characters for Markdown tables. |
| 118 | + err_md = err.replace("|", "\\|").replace("\n", " ") |
| 119 | + lines.append(f"| {pkg} | {tag} | {is_latest} | `{stem}` | {err_md} |\n") |
| 120 | + |
| 121 | + if summary_path: |
| 122 | + Path(summary_path).write_text("".join(lines)) |
| 123 | + else: |
| 124 | + sys.stdout.write("".join(lines)) |
| 125 | + |
| 126 | + # Hard-fail iff latest version of any package has failures. |
| 127 | + blocking = sorted({pkg for (pkg, tag) in failures if latest.get(pkg) == tag}) |
| 128 | + if blocking: |
| 129 | + print( |
| 130 | + f"\nBlocking: latest version of {', '.join(blocking)} has failed notebooks.", |
| 131 | + file=sys.stderr, |
| 132 | + ) |
| 133 | + return 1 |
| 134 | + |
| 135 | + print("\nFailures only in historical versions — not blocking deployment.") |
| 136 | + return 0 |
| 137 | + |
| 138 | + |
| 139 | +if __name__ == "__main__": |
| 140 | + sys.exit(main()) |
0 commit comments