scripts/new_notebook.py
4.0 KB| 1 | from __future__ import annotations |
| 2 | |
| 3 | import argparse |
| 4 | import json |
| 5 | import re |
| 6 | from pathlib import Path |
| 7 | from typing import Any |
| 8 | |
| 9 | |
| 10 | def slugify(text: str) -> str: |
| 11 | lowered = text.strip().lower() |
| 12 | cleaned = re.sub(r"[^a-z0-9]+", "-", lowered) |
| 13 | collapsed = re.sub(r"-+", "-", cleaned).strip("-") |
| 14 | return collapsed or "notebook" |
| 15 | |
| 16 | |
| 17 | def find_repo_root(start: Path) -> Path: |
| 18 | for candidate in (start, *start.parents): |
| 19 | if (candidate / ".git").exists(): |
| 20 | return candidate |
| 21 | return start |
| 22 | |
| 23 | |
| 24 | def load_template(skill_dir: Path, kind: str) -> dict[str, Any]: |
| 25 | asset_name = "experiment-template.ipynb" if kind == "experiment" else "tutorial-template.ipynb" |
| 26 | template_path = skill_dir / "assets" / asset_name |
| 27 | if not template_path.exists(): |
| 28 | raise SystemExit(f"Missing template: {template_path}") |
| 29 | with template_path.open("r", encoding="utf-8") as f: |
| 30 | data = json.load(f) |
| 31 | if not isinstance(data, dict): |
| 32 | raise SystemExit(f"Unexpected template shape: {template_path}") |
| 33 | return data |
| 34 | |
| 35 | |
| 36 | def update_title(notebook: dict[str, Any], kind: str, title: str) -> None: |
| 37 | prefix = "Experiment" if kind == "experiment" else "Tutorial" |
| 38 | expected = f"# {prefix}: {title}\n" |
| 39 | |
| 40 | cells = notebook.get("cells") |
| 41 | if not isinstance(cells, list) or not cells: |
| 42 | raise SystemExit("Template notebook has no cells") |
| 43 | |
| 44 | first_cell = cells[0] |
| 45 | if not isinstance(first_cell, dict) or first_cell.get("cell_type") != "markdown": |
| 46 | raise SystemExit("Template notebook must start with a markdown title cell") |
| 47 | |
| 48 | source = first_cell.get("source", []) |
| 49 | if isinstance(source, str): |
| 50 | source_lines = [source] |
| 51 | elif isinstance(source, list): |
| 52 | source_lines = [str(line) for line in source] |
| 53 | else: |
| 54 | source_lines = [] |
| 55 | |
| 56 | if source_lines: |
| 57 | source_lines[0] = expected |
| 58 | else: |
| 59 | source_lines = [expected] |
| 60 | |
| 61 | first_cell["source"] = source_lines |
| 62 | |
| 63 | metadata = notebook.setdefault("metadata", {}) |
| 64 | if not isinstance(metadata, dict): |
| 65 | raise SystemExit("Notebook metadata must be a mapping") |
| 66 | |
| 67 | language_info = metadata.setdefault("language_info", {}) |
| 68 | if isinstance(language_info, dict): |
| 69 | language_info.setdefault("name", "python") |
| 70 | language_info.setdefault("version", "3.12") |
| 71 | |
| 72 | |
| 73 | def default_output(repo_root: Path, title: str) -> Path: |
| 74 | filename = f"{slugify(title)}.ipynb" |
| 75 | return repo_root / "output" / "jupyter-notebook" / filename |
| 76 | |
| 77 | |
| 78 | def parse_args() -> argparse.Namespace: |
| 79 | parser = argparse.ArgumentParser(description="Scaffold a Jupyter notebook for experiments or tutorials.") |
| 80 | parser.add_argument( |
| 81 | "--kind", |
| 82 | choices=["experiment", "tutorial"], |
| 83 | default="experiment", |
| 84 | help="Notebook style to scaffold (default: experiment).", |
| 85 | ) |
| 86 | parser.add_argument( |
| 87 | "--title", |
| 88 | required=True, |
| 89 | help="Human-readable notebook title used in the first markdown cell.", |
| 90 | ) |
| 91 | parser.add_argument( |
| 92 | "--out", |
| 93 | type=Path, |
| 94 | default=None, |
| 95 | help="Output path for the notebook. Defaults to output/jupyter-notebook/<slug>.ipynb.", |
| 96 | ) |
| 97 | parser.add_argument( |
| 98 | "--force", |
| 99 | action="store_true", |
| 100 | help="Overwrite the output file if it already exists.", |
| 101 | ) |
| 102 | return parser.parse_args() |
| 103 | |
| 104 | |
| 105 | def main() -> None: |
| 106 | args = parse_args() |
| 107 | |
| 108 | script_path = Path(__file__).resolve() |
| 109 | skill_dir = script_path.parents[1] |
| 110 | repo_root = find_repo_root(skill_dir) |
| 111 | |
| 112 | notebook = load_template(skill_dir, args.kind) |
| 113 | update_title(notebook, args.kind, args.title) |
| 114 | |
| 115 | out_path = args.out or default_output(repo_root, args.title) |
| 116 | out_path = out_path.resolve() |
| 117 | |
| 118 | if out_path.exists() and not args.force: |
| 119 | raise SystemExit(f"Refusing to overwrite existing file without --force: {out_path}") |
| 120 | |
| 121 | out_path.parent.mkdir(parents=True, exist_ok=True) |
| 122 | with out_path.open("w", encoding="utf-8") as f: |
| 123 | json.dump(notebook, f, indent=2) |
| 124 | f.write("\n") |
| 125 | |
| 126 | print(f"Wrote {out_path} using kind={args.kind}.") |
| 127 | |
| 128 | |
| 129 | if __name__ == "__main__": |
| 130 | main() |
文件内容是平台检查时读出的文本,只供查看。平台不会运行包里的任何脚本或程序。

