Files
2026-07-13 13:26:28 +08:00

96 lines
3.2 KiB
Python

"""Strip machine-specific absolute paths from committed notebook outputs.
Notebooks executed on a contributor's machine bake absolute paths
(``/home/<user>/ml4t/code/...``) into committed cell outputs and into the
``papermill`` execution metadata. Readers should never see those. This tool
rewrites them in place:
* repo-internal paths -> repo-relative (matching ``utils.paths.display_path``)
* anything else under ``~/ml4t`` -> a ``~``-prefixed generic path
It edits the raw ``.ipynb`` text (no JSON reserialization) so the only diff is
the replaced substrings — formatting, key order and outputs are untouched.
Idempotent: running twice is a no-op. A companion test
(``tests/test_notebook_output_hygiene.py``) fails CI if any leak survives.
Usage:
uv run python scripts/sanitize_notebook_paths.py # rewrite in place
uv run python scripts/sanitize_notebook_paths.py --check # report only, exit 1 if dirty
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
# Applied in order; earlier (longer) rules win. The two repo-root prefixes map
# to "" so paths become repo-relative. `third_edition/code` is a stale former
# layout that still lingers in some papermill metadata. The ``/home/<user>/``
# prefix is matched generically (not just one username) so any contributor's
# or CI runner's path (e.g. /home/runner/...) is sanitized and CI-guarded.
REPLACEMENTS: list[tuple[re.Pattern[str], str]] = [
(re.compile(r"/home/[^/]+/ml4t/third_edition/code/"), ""),
(re.compile(r"/home/[^/]+/ml4t/code/"), ""),
# Docker container repo root: GPU notebooks (e.g. Ch12 02_gbm_comparison run
# in the ml4t-gpu image) bake the container working dir /app into outputs.
(re.compile(r"/app/"), ""),
(re.compile(r"/home/[^/]+/ml4t/"), "~/ml4t/"),
(re.compile(r"/home/[^/]+/"), "~/"),
]
SKIP_PARTS = {"_reference", ".venv", ".git"}
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_"):
continue
out.append(p)
return sorted(out)
def sanitize_text(text: str) -> tuple[str, int]:
n = 0
for pat, new in REPLACEMENTS:
text, k = pat.subn(new, text)
n += k
return text, n
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--check", action="store_true", help="report only; exit 1 if any leak found")
args = ap.parse_args()
dirty: list[tuple[Path, int]] = []
for nb in _iter_notebooks():
raw = nb.read_text(encoding="utf-8")
new, n = sanitize_text(raw)
if n:
dirty.append((nb.relative_to(REPO_ROOT), n))
if not args.check:
nb.write_text(new, encoding="utf-8")
if not dirty:
print("clean: no /home/<user> paths in any notebook")
return 0
verb = "would rewrite" if args.check else "rewrote"
total = sum(n for _, n in dirty)
print(f"{verb} {total} occurrence(s) across {len(dirty)} notebook(s):")
for rel, n in dirty:
print(f" {n:4d} {rel}")
return 1 if args.check else 0
if __name__ == "__main__":
sys.exit(main())