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

416 lines
14 KiB
Python

"""Papermill-based notebook execution helpers.
Provides:
- run_notebook(): Execute a .py notebook via Papermill with parameter injection
- get_overrides(): Load per-notebook overrides from tests/overrides.yaml
- collect_chapter_notebooks(): Discover notebooks in chapter directories
- get_tier() / current_test_tier(): Test-tier routing (per-commit / weekly / on-demand)
- get_record_mode(): VCR cassette mode (consumed by Step 5)
NOTE: Notebooks live directly in chapter directories
(e.g., 05_synthetic_data/01_timegan.py), NOT in code/ subdirs.
overrides.yaml schema (per-notebook, all optional):
timeout: int (seconds, default 300)
parameters: dict (papermill -p overrides)
skip: bool — hard skip in uv-native run (Docker tests ignore)
skip_reason: str
requires_import: str | list[str]
gpu: bool
long_running: bool
docker_env: str — informational (e.g., "benchmark")
tier: "per_commit" | "weekly" | "on_demand" — default "per_commit"
Per-commit runs the Tests workflow on every PR/push.
Weekly runs the weekly-external scheduled workflow (Step 2).
On_demand runs only on manual dispatch (GPU-only NBs).
reruns: int — flaky-retry count (consumed once pytest-rerunfailures lands,
Step 2). Default 0.
record_mode: "replay" | "rewrite" — VCR cassette mode (consumed by Step 5).
Default "replay".
"""
import os
import subprocess
import sys
import tempfile
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).parent.parent
OVERRIDES_PATH = REPO_ROOT / "tests" / "overrides.yaml"
# Cache loaded overrides
_overrides_cache: dict | None = None
# ---------------------------------------------------------------------------
# Test tier — controls when a notebook runs in CI.
# Per-commit (default): every PR / push triggers the Tests workflow.
# Weekly: only the scheduled weekly-external workflow (Mon 06:00 UTC).
# On-demand: only manual workflow_dispatch (e.g., GPU-only Tier 3).
# ---------------------------------------------------------------------------
TIER_PER_COMMIT = "per_commit"
TIER_WEEKLY = "weekly"
TIER_ON_DEMAND = "on_demand"
VALID_TIERS = frozenset({TIER_PER_COMMIT, TIER_WEEKLY, TIER_ON_DEMAND})
# VCR cassette modes (Step 5: pytest-recording).
RECORD_REPLAY = "replay"
RECORD_REWRITE = "rewrite"
VALID_RECORD_MODES = frozenset({RECORD_REPLAY, RECORD_REWRITE})
def get_tier(overrides: dict) -> str:
"""Return the test tier declared by overrides (default: per_commit)."""
tier = overrides.get("tier") or TIER_PER_COMMIT
if tier not in VALID_TIERS:
raise ValueError(
f"Invalid tier {tier!r} in overrides — must be one of {sorted(VALID_TIERS)}"
)
return tier
def current_test_tier() -> str:
"""Return the tier the current pytest run is targeting.
Read from ML4T_TEST_TIER env var; defaults to per_commit so existing
workflows that don't set it keep their current behavior (only NBs
without a tier key — i.e., tier=per_commit — execute).
"""
tier = os.environ.get("ML4T_TEST_TIER") or TIER_PER_COMMIT
if tier not in VALID_TIERS:
raise ValueError(f"Invalid ML4T_TEST_TIER={tier!r} — must be one of {sorted(VALID_TIERS)}")
return tier
def get_reruns(overrides: dict) -> int:
"""Return per-notebook flaky retry count (default 0).
Consumed by Step 2 (pytest-rerunfailures dep + collection hook adds
@pytest.mark.flaky(reruns=N) when N > 0). Until that lands the value
is parsed but no retries happen.
"""
val = overrides.get("reruns", 0)
if not isinstance(val, int) or val < 0:
raise ValueError(f"Invalid reruns={val!r} — must be non-negative int")
return val
def get_record_mode(overrides: dict) -> str:
"""Return VCR cassette mode for the notebook (default: replay)."""
mode = overrides.get("record_mode") or RECORD_REPLAY
if mode not in VALID_RECORD_MODES:
raise ValueError(
f"Invalid record_mode {mode!r} — must be one of {sorted(VALID_RECORD_MODES)}"
)
return mode
def get_overrides(notebook_key: str) -> dict:
"""Get parameter overrides for a notebook from tests/overrides.yaml.
Args:
notebook_key: Notebook path relative to repo root, no extension.
e.g., "05_synthetic_data/02_tailgan_tail_risk"
Returns:
Dict with optional keys: timeout, gpu, parameters
"""
global _overrides_cache
if _overrides_cache is None:
if OVERRIDES_PATH.exists():
with open(OVERRIDES_PATH) as f:
_overrides_cache = yaml.safe_load(f) or {}
else:
_overrides_cache = {}
return _overrides_cache.get(notebook_key) or {}
def sync_notebook(py_path: Path) -> Path:
"""Sync a .py notebook to a temporary .ipynb via Jupytext.
Writes to a temp file so the real .ipynb (which may contain pre-executed
outputs) is never overwritten.
Args:
py_path: Path to the .py source file
Returns:
Path to a temporary .ipynb file (caller must clean up)
"""
# Write to a temp file — never touch the real .ipynb
tmp_fd, tmp_path_str = tempfile.mkstemp(suffix=".ipynb", prefix=f"_pm_{py_path.stem}_")
os.close(tmp_fd)
tmp_ipynb = Path(tmp_path_str)
result = subprocess.run(
[
sys.executable,
"-m",
"jupytext",
"--to",
"notebook",
"--set-kernel",
"python3",
"--output",
str(tmp_ipynb),
str(py_path),
],
capture_output=True,
text=True,
timeout=60,
cwd=str(REPO_ROOT),
)
if result.returncode != 0:
tmp_ipynb.unlink(missing_ok=True)
raise RuntimeError(f"Jupytext sync failed for {py_path}: {result.stderr}")
if not tmp_ipynb.exists():
raise FileNotFoundError(f"Expected temp .ipynb not found after sync: {tmp_ipynb}")
return tmp_ipynb
def run_notebook(
py_path: Path,
parameters: dict | None = None,
timeout: int = 300,
output_dir: Path | None = None,
data_dir: Path | None = None,
extra_env: dict[str, str] | None = None,
log_path: Path | None = None,
cwd: Path | None = None,
) -> dict:
"""Execute a notebook via Papermill with parameter injection.
This is the core test helper. It:
1. Syncs .py -> .ipynb via Jupytext
2. Executes via Papermill with parameter overrides
3. Logs per-cell progress to log_path (if provided)
4. Returns status and error info
Args:
py_path: Path to the .py notebook source
parameters: Dict of parameters to inject (overrides defaults in parameters cell)
timeout: Per-cell timeout in seconds
output_dir: Directory for ML4T_OUTPUT_DIR (redirects saves to temp)
data_dir: Directory for ML4T_DATA_PATH (test data location)
extra_env: Additional environment variables for notebook execution
log_path: Path to progress log file (appended to)
Returns:
Dict with keys: status ("ok" or "error"), error (str if failed),
duration_s (float), n_cells (int)
"""
import time
import papermill as pm
start = time.time()
nb_name = py_path.stem
def _log(msg: str) -> None:
if log_path:
with open(log_path, "a") as f:
f.write(f"[{time.strftime('%H:%M:%S')}] {msg}\n")
f.flush()
_log(f"START {nb_name} (timeout={timeout}s)")
# Sync to a temp .ipynb (never overwrites the real .ipynb)
tmp_ipynb: Path | None = None
try:
tmp_ipynb = sync_notebook(py_path)
except (RuntimeError, FileNotFoundError) as e:
_log(f"SYNC_FAIL {nb_name}: {e}")
return {
"status": "error",
"error": f"Jupytext sync failed: {e}",
"duration_s": time.time() - start,
"n_cells": 0,
}
ipynb_path = tmp_ipynb
# Executed notebook output path
executed_path = py_path.parent / f"_executed_{py_path.stem}.ipynb"
# Setup environment - Papermill's execute_notebook inherits os.environ,
# so we temporarily set env vars and restore them after execution.
saved_env = {}
env_vars = {
"MPLBACKEND": "Agg",
"PLOTLY_RENDERER": "json",
"DISABLE_HPO": "1",
}
if output_dir:
output_dir.mkdir(parents=True, exist_ok=True)
env_vars["ML4T_OUTPUT_DIR"] = str(output_dir)
if data_dir:
env_vars["ML4T_DATA_PATH"] = str(data_dir)
if extra_env:
env_vars.update({key: str(value) for key, value in extra_env.items()})
# PYTHONPATH includes repo root for utils imports + notebook dir for sibling imports
existing = os.environ.get("PYTHONPATH", "")
nb_dir = str(py_path.parent.resolve())
env_vars["PYTHONPATH"] = (
f"{REPO_ROOT}:{nb_dir}:{existing}" if existing else f"{REPO_ROOT}:{nb_dir}"
)
# Ensure torch's bundled CUDA libraries are found before system ones.
# The system libcudart.so.12 may be outdated and missing symbols like
# cudaGetDriverEntryPointByVersion that torch's bundled version provides.
try:
import torch
torch_lib = str(Path(torch.__file__).parent / "lib")
nvidia_libs = list((Path(torch.__file__).parent.parent / "nvidia").glob("*/lib"))
cuda_paths = [torch_lib] + [str(p) for p in nvidia_libs]
existing_ld = os.environ.get("LD_LIBRARY_PATH", "")
env_vars["LD_LIBRARY_PATH"] = ":".join(cuda_paths + [existing_ld])
except ImportError:
pass
remove_vars = ["TEST", "QUICK_TEST"]
if extra_env:
for key in extra_env:
if key in remove_vars:
remove_vars.remove(key)
# Apply environment changes
for key, value in env_vars.items():
saved_env[key] = os.environ.get(key)
os.environ[key] = value
for key in remove_vars:
saved_env[key] = os.environ.pop(key, None)
# Cell-level progress — always log to /tmp/ml4t-pm-{name}.log for visibility.
# Since request_save_on_cell_execute=True, the executed notebook is updated
# after each cell. Monitor it with: watch -n5 'python -c "import json; ..."'
progress_log = Path(f"/tmp/ml4t-pm-{nb_name}.log")
n_cells = 0
try:
with open(progress_log, "w") as pf:
pf.write(f"START {nb_name} timeout={timeout}s params={parameters}\n")
pm.execute_notebook(
str(ipynb_path),
str(executed_path),
parameters=parameters or {},
cwd=str(cwd or REPO_ROOT),
kernel_name="python3",
execution_timeout=timeout,
request_save_on_cell_execute=True,
progress_bar=False,
log_output=True,
)
# Count cells in executed notebook
try:
import nbformat
nb = nbformat.read(str(executed_path), as_version=4)
n_cells = len([c for c in nb.cells if c.cell_type == "code"])
except Exception:
pass
elapsed = time.time() - start
msg = f"OK {nb_name} ({elapsed:.1f}s, {n_cells} cells)"
_log(msg)
with open(progress_log, "a") as pf:
pf.write(f"{msg}\n")
return {"status": "ok", "error": None, "duration_s": elapsed, "n_cells": n_cells}
except pm.PapermillExecutionError as e:
elapsed = time.time() - start
msg = f"FAIL {nb_name} cell {e.cell_index} ({e.ename}): {e.evalue} ({elapsed:.1f}s)"
_log(msg)
with open(progress_log, "a") as pf:
pf.write(f"{msg}\n")
return {
"status": "error",
"error": f"Cell {e.cell_index} ({e.ename}): {e.evalue}",
"duration_s": elapsed,
"n_cells": e.cell_index,
}
except Exception as e:
elapsed = time.time() - start
_log(f"FAIL {nb_name}: {e} ({elapsed:.1f}s)")
return {"status": "error", "error": str(e), "duration_s": elapsed, "n_cells": 0}
finally:
# Restore environment
for key, value in saved_env.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
# Clean up temp input notebook
if tmp_ipynb is not None and tmp_ipynb.exists():
tmp_ipynb.unlink()
# Clean up executed notebook
if executed_path.exists():
executed_path.unlink()
def collect_chapter_notebooks(repo_root: Path, chapter_range: range) -> list[Path]:
"""Collect all teaching notebooks from chapter directories.
NOTE: Review repo has flat layout — notebooks live directly in
chapter directories (e.g., 05_synthetic_data/01_timegan.py),
NOT in code/ subdirectories.
Args:
repo_root: Repository root path
chapter_range: Range of chapter numbers to include
Returns:
Sorted list of .py notebook paths
"""
notebooks = []
for chapter_dir in sorted(repo_root.glob("[0-9][0-9]_*")):
if not chapter_dir.is_dir():
continue
# Extract chapter number from directory name
try:
ch_num = int(chapter_dir.name[:2])
except ValueError:
continue
if ch_num not in chapter_range:
continue
# Review repo: notebooks are directly in the chapter directory
for notebook in sorted(chapter_dir.glob("*.py")):
# Skip non-notebook files — use startswith to avoid false positives
# (e.g., "test_" must not match "backtest_")
if any(
notebook.name.startswith(prefix)
for prefix in [
"test_",
"conftest",
"extract_book_figures",
"export_figures",
"batch_",
]
) or any(x in notebook.name for x in ["__pycache__", "__init__"]):
continue
# Skip archived/draft/reserved directories
if any(
x in str(notebook)
for x in ["_archive", "archived", "drafts", "inventory", "_reserved"]
):
continue
# Skip helper/utility files (start with _)
if notebook.name.startswith("_"):
continue
if not notebook.name[0].isdigit() and not notebook.with_suffix(".ipynb").exists():
continue
notebooks.append(notebook)
return notebooks