187 lines
6.9 KiB
Python
187 lines
6.9 KiB
Python
"""Provenance stamp + sync gate for paired ``.py``/``.ipynb`` notebooks.
|
|
|
|
A committed ``.ipynb`` should be the *current* ``.py`` executed in a real
|
|
environment with production parameters — not a stale render, not a TEST-mode run,
|
|
not a run in an environment missing a dependency (e.g. CUDA-LightGBM). This module
|
|
stamps that fact into the notebook and provides a gate that rejects violations, so
|
|
"edited the ``.py``, ran TEST or the wrong env, committed a stale ``.ipynb``" is
|
|
caught mechanically instead of by review.
|
|
|
|
The stamp lives in ``nb.metadata["ml4t_provenance"]``::
|
|
|
|
source_py_blob : git blob hash of the paired .py at execution time
|
|
executed_at : ISO-8601 timestamp
|
|
executor : environment label (e.g. "ml4t-gpu", "local-uv")
|
|
production : bool — True iff no papermill parameter overrides were injected
|
|
parameters : the papermill parameter overrides ({} for a production run)
|
|
notes : optional free text (e.g. "GPU libs: xgboost,lightgbm,catboost")
|
|
|
|
Gate (``check``): for every tracked ``.ipynb`` that HAS a stamp,
|
|
|
|
* ``source_py_blob`` must equal ``git hash-object`` of the current paired ``.py``
|
|
(else the ``.py`` changed since the notebook was executed — STALE), and
|
|
* ``production`` must be True (else a TEST-mode run was committed).
|
|
|
|
Notebooks WITHOUT a stamp are reported as "unverified" but do not fail unless
|
|
``--strict`` is passed. This is deliberate: adoption is gradual — stamp notebooks
|
|
as they are re-run through the canonical path, and the gate enforces only where
|
|
provenance exists. Flip to ``--strict`` once the backfill is complete.
|
|
|
|
Usage::
|
|
|
|
uv run python scripts/notebook_provenance.py stamp <nb.ipynb> --executor ml4t-gpu
|
|
uv run python scripts/notebook_provenance.py stamp <nb.ipynb> --executor ml4t-gpu --notes "..."
|
|
uv run python scripts/notebook_provenance.py check # gate (stamped-only)
|
|
uv run python scripts/notebook_provenance.py check --strict # also fail on unverified
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from datetime import UTC, datetime, timezone
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
SKIP_PARTS = {"_reference", ".venv", ".git", ".ipynb_checkpoints"}
|
|
STAMP_KEY = "ml4t_provenance"
|
|
|
|
|
|
def iter_notebooks() -> list[Path]:
|
|
out = []
|
|
for p in REPO_ROOT.rglob("*.ipynb"):
|
|
if SKIP_PARTS & set(p.parts):
|
|
continue
|
|
if p.name.startswith("_executed_") or p.name.startswith("_lock_"):
|
|
continue
|
|
out.append(p)
|
|
return sorted(out)
|
|
|
|
|
|
def paired_py(nb_path: Path) -> Path | None:
|
|
"""The .py jupytext-paired to this notebook (same dir + stem). None if absent."""
|
|
cand = nb_path.with_suffix(".py")
|
|
return cand if cand.exists() else None
|
|
|
|
|
|
def git_blob(path: Path) -> str:
|
|
"""git blob SHA-1 of the file's current content (working tree)."""
|
|
return subprocess.run(
|
|
["git", "hash-object", str(path)],
|
|
cwd=REPO_ROOT,
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
).stdout.strip()
|
|
|
|
|
|
def stamp_notebook(nb_path: Path, executor: str, notes: str | None = None) -> dict:
|
|
py = paired_py(nb_path)
|
|
if py is None:
|
|
raise SystemExit(f"no paired .py for {nb_path.relative_to(REPO_ROOT)} — cannot stamp")
|
|
nb = json.loads(nb_path.read_text(encoding="utf-8"))
|
|
params = nb.get("metadata", {}).get("papermill", {}).get("parameters", {}) or {}
|
|
stamp = {
|
|
"source_py_blob": git_blob(py),
|
|
"executed_at": datetime.now(UTC).isoformat(),
|
|
"executor": executor,
|
|
"production": not params,
|
|
"parameters": params,
|
|
}
|
|
if notes:
|
|
stamp["notes"] = notes
|
|
nb.setdefault("metadata", {})[STAMP_KEY] = stamp
|
|
nb_path.write_text(json.dumps(nb, indent=1, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
return stamp
|
|
|
|
|
|
def check_all(strict: bool = False) -> tuple[list[str], list[str], list[str]]:
|
|
"""Return (stale, testmode, unverified) lists of repo-relative offenders."""
|
|
stale: list[str] = []
|
|
testmode: list[str] = []
|
|
unverified: list[str] = []
|
|
for nb_path in iter_notebooks():
|
|
rel = str(nb_path.relative_to(REPO_ROOT))
|
|
py = paired_py(nb_path)
|
|
if py is None:
|
|
continue # un-paired notebooks have no .py to drift from
|
|
try:
|
|
nb = json.loads(nb_path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
continue
|
|
stamp = nb.get("metadata", {}).get(STAMP_KEY)
|
|
if not stamp:
|
|
unverified.append(rel)
|
|
continue
|
|
if stamp.get("source_py_blob") != git_blob(py):
|
|
stale.append(rel)
|
|
if not stamp.get("production", False):
|
|
testmode.append(f"{rel} (params={stamp.get('parameters')})")
|
|
return stale, testmode, unverified
|
|
|
|
|
|
def _cmd_stamp(args: argparse.Namespace) -> int:
|
|
s = stamp_notebook(Path(args.notebook).resolve(), args.executor, args.notes)
|
|
print(
|
|
f"stamped {args.notebook}: source_py_blob={s['source_py_blob'][:12]} "
|
|
f"executor={s['executor']} production={s['production']}"
|
|
)
|
|
return 0
|
|
|
|
|
|
def _cmd_check(args: argparse.Namespace) -> int:
|
|
stale, testmode, unverified = check_all(strict=args.strict)
|
|
fail = bool(stale or testmode) or (args.strict and bool(unverified))
|
|
if stale:
|
|
print(
|
|
"STALE (paired .py changed since the notebook was executed — re-run in the canonical env):"
|
|
)
|
|
for r in stale:
|
|
print(f" {r}")
|
|
if testmode:
|
|
print(
|
|
"TEST-MODE (committed a run with papermill parameter overrides — must be production):"
|
|
)
|
|
for r in testmode:
|
|
print(f" {r}")
|
|
if unverified:
|
|
verb = "UNVERIFIED (no provenance stamp"
|
|
verb += (
|
|
" — FAILING under --strict):" if args.strict else " — advisory, backfill over time):"
|
|
)
|
|
print(verb)
|
|
for r in unverified:
|
|
print(f" {r}")
|
|
if not fail:
|
|
print(
|
|
f"notebook sync OK: {len(stale)} stale, {len(testmode)} test-mode, "
|
|
f"{len(unverified)} unverified (advisory)"
|
|
)
|
|
return 1 if fail else 0
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(
|
|
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
|
)
|
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
|
|
sp = sub.add_parser("stamp", help="write a provenance stamp into a notebook")
|
|
sp.add_argument("notebook")
|
|
sp.add_argument("--executor", required=True, help="environment label, e.g. ml4t-gpu / local-uv")
|
|
sp.add_argument("--notes", default=None)
|
|
sp.set_defaults(func=_cmd_stamp)
|
|
|
|
cp = sub.add_parser("check", help="gate: fail on stale or test-mode stamped notebooks")
|
|
cp.add_argument("--strict", action="store_true", help="also fail on unstamped notebooks")
|
|
cp.set_defaults(func=_cmd_check)
|
|
|
|
args = ap.parse_args()
|
|
return args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|