From 6e79a7844bb3687c328c3e8f4c483e50587ffe6e Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Wed, 21 Oct 2026 23:57:19 -0400 Subject: [PATCH 1/2] Add projects to processing --- AGENTS.md | 18 ++++- README.md | 3 +- js/README.md | 1 + js/src/scrolly/plugin.mjs | 6 +- js/src/shared/config.mjs | 7 +- js/src/shared/paths.mjs | 32 +++++--- js/src/solutions/plugin.mjs | 6 +- python/README.md | 5 +- python/src/nma_kit/cli.py | 6 +- python/src/nma_kit/config.py | 45 ++++++++--- python/src/nma_kit/convert.py | 55 +++++++------ python/src/nma_kit/scrolly.py | 33 ++++---- python/src/nma_kit/solutions.py | 13 ++-- tests/example-course/myst.yml | 3 + .../projects/Neuroscience/Project1.md | 29 +++++++ tests/fixtures/config/settings.json | 77 +++++++++++++++---- tests/js/config.test.mjs | 8 +- tests/js/shared.test.mjs | 4 +- tests/python/test_cli.py | 57 +++++++++++++- tests/python/test_config.py | 13 +++- tests/python/test_convert.py | 4 +- tests/python/test_scrolly.py | 9 ++- tests/python/test_solutions.py | 14 ++-- 23 files changed, 331 insertions(+), 117 deletions(-) create mode 100644 tests/example-course/projects/Neuroscience/Project1.md diff --git a/AGENTS.md b/AGENTS.md index 6cb3204..46623de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,22 @@ templates/wheels.yml # optional caller for course-wheels.yml (editable cells replace JupyterLite's power button) is in the kit's `site.css`; courses no longer carry it. +## Content roots (internals) + +- Pages live in **content roots**: `tutorials` (`nma.paths.tutorials`) and + `projects` (`nma.paths.projects`). `Settings.content_roots` / + `contentRoots()` list `(name, directory)`; the *name* is fixed, the + directory is configurable. A missing directory is just empty. +- Everything generated is scoped by the root name: + `notebooks///.ipynb` (+ `instructor/`, `solutions/`), + `_build/scrolly///.json`. `page_location` / + `pageLocation` return the root too, and `PageLocation.notebook_dir` / + `notebookDir()` give the folder under `notebooks/`. The deepest matching + directory wins when roots are nested. +- `nma-kit notebooks | prepare-site | render-scrolly | check` all go through + `process_content` / the roots, so a feature added to tutorials works in projects. + The twins are tested with `tests/fixtures/config/settings.json`. + ## Site branding (internals) - **Source of truth**: `python/src/nma_kit/assets/` (`site.css`, logos, favicon, @@ -164,7 +180,7 @@ templates/wheels.yml # optional caller for course-wheels.yml footer links; regression test in `tests/js/scrolly.test.mjs`). - `nma-kit render-scrolly` runs each scrolly page's instructor notebook (exercise cells removed, `# @param` at defaults, cwd = page dir) and writes - `_build/scrolly//.json` = `{cell_key: {stdout, images, error}}`. + `_build/scrolly///.json` = `{cell_key: {stdout, images, error}}`. A raising cell fails CI with `file:line`. ## Commands diff --git a/README.md b/README.md index ab6fd26..30fb7cd 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,8 @@ Optional settings, in the `nma:` block of `myst.yml` (defaults shown, except ```yaml nma: paths: - tutorials: tutorials # where the .md pages live + tutorials: tutorials # where the tutorial .md pages live + projects: projects # where the project .md pages live notebooks: branch: notebooks-branch # where generated notebooks are published feedback: # needed if any page uses {nma-feedback} diff --git a/js/README.md b/js/README.md index 41caae7..57977b7 100644 --- a/js/README.md +++ b/js/README.md @@ -54,6 +54,7 @@ Settings live in the `nma:` block of `myst.yml` (all optional): nma: paths: tutorials: tutorials + projects: projects notebooks: branch: notebooks-branch feedback: { url: https://..., project: ..., user_key: ... } diff --git a/js/src/scrolly/plugin.mjs b/js/src/scrolly/plugin.mjs index ed0340c..ffaae86 100644 --- a/js/src/scrolly/plugin.mjs +++ b/js/src/scrolly/plugin.mjs @@ -420,12 +420,12 @@ function scrollyConfig() { return { pyodideUrl: courseSettings().nma.scrolly?.pyodide_url || DEFAULT_PYODIDE_URL }; } -// tutorials/W2D1_Examples/W2D1_Tutorial2.md -> _build/scrolly/W2D1_Examples/W2D1_Tutorial2.json -// (same as render_path in nma_kit.scrolly; the tutorials dir comes from myst.yml) +// tutorials/W2D1_Examples/W2D1_Tutorial2.md -> _build/scrolly/tutorials/W2D1_Examples/W2D1_Tutorial2.json +// (same as render_path in nma_kit.scrolly; the content dirs come from myst.yml) function renderFile(filePath) { const location = pageLocation(filePath); const parts = location - ? [...(location.relDir ? location.relDir.split('/') : []), location.stem] + ? [location.root, ...(location.relDir ? location.relDir.split('/') : []), location.stem] : relative(process.cwd(), filePath).split(sep).join('/').replace(/\.[^.]+$/, '').split('/'); const stem = parts.pop(); return join(process.cwd(), '_build', 'scrolly', ...parts, `${stem}.json`); diff --git a/js/src/shared/config.mjs b/js/src/shared/config.mjs index b55da8b..fdd61de 100644 --- a/js/src/shared/config.mjs +++ b/js/src/shared/config.mjs @@ -4,13 +4,15 @@ // // nma: // paths: -// tutorials: tutorials # where the course's .md pages live +// tutorials: tutorials # where the course's tutorial .md pages live +// projects: projects # where the course's project .md pages live // notebooks: // branch: notebooks-branch # where generated notebooks are published import { parse } from 'yaml'; export const DEFAULT_TUTORIALS_DIR = 'tutorials'; +export const DEFAULT_PROJECTS_DIR = 'projects'; export const DEFAULT_NOTEBOOKS_BRANCH = 'notebooks-branch'; const GITHUB_RE = /github\.com\/([^\s'"]+?)\/?$/; @@ -36,7 +38,7 @@ function setting(nma, section, key, fallback) { return typeof value === 'string' && value.trim() ? value : fallback; } -/** {githubRepo, tutorialsDir, notebooksBranch, nma} from myst.yml text. */ +/** {githubRepo, tutorialsDir, projectsDir, notebooksBranch, nma} from myst.yml text. */ export function loadSettings(text) { const data = parseYaml(text); const nma = readNmaConfig(text); @@ -45,6 +47,7 @@ export function loadSettings(text) { return { githubRepo: match ? match[1] : null, tutorialsDir: setting(nma, 'paths', 'tutorials', DEFAULT_TUTORIALS_DIR).replace(/^\/+|\/+$/g, ''), + projectsDir: setting(nma, 'paths', 'projects', DEFAULT_PROJECTS_DIR).replace(/^\/+|\/+$/g, ''), notebooksBranch: setting(nma, 'notebooks', 'branch', DEFAULT_NOTEBOOKS_BRANCH), nma, }; diff --git a/js/src/shared/paths.mjs b/js/src/shared/paths.mjs index dafd53f..00249d1 100644 --- a/js/src/shared/paths.mjs +++ b/js/src/shared/paths.mjs @@ -28,14 +28,28 @@ export function courseSettings() { export const githubRepo = () => courseSettings().githubRepo; -// tutorials/W2D1_Examples/W2D1_Tutorial1.md -> { relDir: 'W2D1_Examples', stem: 'W2D1_Tutorial1' } +/** The (name, directory) of each place pages live; twin of Settings.content_roots. */ +export function contentRoots() { + const { tutorialsDir, projectsDir } = courseSettings(); + return [['tutorials', tutorialsDir], ['projects', projectsDir]]; +} + +/** Folder under notebooks/ holding a page's generated files. */ +export const notebookDir = ({ root, relDir }) => (relDir ? `${root}/${relDir}` : root); + +// tutorials/W2D1_Examples/W2D1_Tutorial1.md -> { root: 'tutorials', relDir: 'W2D1_Examples', stem: 'W2D1_Tutorial1' } +// The deepest matching content root wins (twin of page_location). export function pageLocation(filePath) { - const prefix = `${courseSettings().tutorialsDir}/`; const rel = relative(process.cwd(), filePath).split(sep).join('/'); - if (!rel.startsWith(prefix)) return null; - const parts = rel.slice(prefix.length).split('/'); - const stem = parts.pop().replace(/\.[^.]+$/, ''); - return { relDir: parts.join('/'), stem }; + const roots = contentRoots().sort((a, b) => b[1].length - a[1].length); + for (const [root, dir] of roots) { + const prefix = `${dir}/`; + if (!rel.startsWith(prefix)) continue; + const parts = rel.slice(prefix.length).split('/'); + const stem = parts.pop().replace(/\.[^.]+$/, ''); + return { root, relDir: parts.join('/'), stem }; + } + return null; } /** Colab and Kaggle URLs of a page's generated notebook (same as make_badge_html). */ @@ -43,7 +57,7 @@ export function notebookUrls(filePath) { const { githubRepo: repo, notebooksBranch: branch } = courseSettings(); const location = pageLocation(filePath); if (!repo || !location) return { colabUrl: 'https://colab.research.google.com/', kaggleUrl: 'https://www.kaggle.com/' }; - const path = ['notebooks', location.relDir, `${location.stem}.ipynb`].filter(Boolean).join('/'); + const path = ['notebooks', notebookDir(location), `${location.stem}.ipynb`].filter(Boolean).join('/'); return { colabUrl: `https://colab.research.google.com/github/${repo}/blob/${branch}/${path}`, kaggleUrl: `https://kaggle.com/kernels/welcome?src=https://raw.githubusercontent.com/${repo}/${branch}/${path}`, @@ -51,7 +65,7 @@ export function notebookUrls(filePath) { } /** GitHub URL of a solution/explanation file on the notebooks branch. */ -export function solutionUrl(repo, relDir, filename, branch) { - const path = ['notebooks', relDir, 'solutions', filename].filter(Boolean).join('/'); +export function solutionUrl(repo, nbDir, filename, branch) { + const path = ['notebooks', nbDir, 'solutions', filename].filter(Boolean).join('/'); return `https://github.com/${repo}/blob/${branch}/${path}`; } diff --git a/js/src/solutions/plugin.mjs b/js/src/solutions/plugin.mjs index 1823bc9..792d92a 100644 --- a/js/src/solutions/plugin.mjs +++ b/js/src/solutions/plugin.mjs @@ -6,7 +6,7 @@ // placeholders in document order and builds the links. The file naming and URL // rules must stay in sync with nma_kit.solutions. -import { courseSettings, pageLocation, solutionUrl } from '../shared/paths.mjs'; +import { contentRoots, notebookDir, pageLocation, solutionUrl } from '../shared/paths.mjs'; import { findPlaceholders, replaceNode, text } from '../shared/tree.mjs'; const SOLUTION_KINDS = { @@ -56,12 +56,12 @@ const solutionLinks = { continue; } if (!location) { - fatal(`${kind} is only supported in pages under ${courseSettings().tutorialsDir}/`); + fatal(`${kind} is only supported in pages under ${contentRoots().map(([, dir]) => `${dir}/`).join(' or ')}`); continue; } counters[kind] = (counters[kind] ?? 0) + 1; const { label, file } = SOLUTION_KINDS[kind]; - const url = solutionUrl(repo, location.relDir, file(location.stem, counters[kind]), branch); + const url = solutionUrl(repo, notebookDir(location), file(location.stem, counters[kind]), branch); replaceWith( [ { diff --git a/python/README.md b/python/README.md index c512103..8522a51 100644 --- a/python/README.md +++ b/python/README.md @@ -24,7 +24,7 @@ folder with `myst.yml`). | Command | What it does | |---|---| | `nma-kit check` | Validate the content rules and the `nma:` block of `myst.yml` | -| `nma-kit notebooks [--dry-run]` | Generate student and instructor notebooks and the solution files into `notebooks/` | +| `nma-kit notebooks [--dry-run]` | Generate student and instructor notebooks and the solution files into `notebooks//...` | | `nma-kit sync-assets` | Write the site branding (stylesheet, logos, favicon, Rubik font) to `_nma/`, which the course's `myst.yml` points at | | `nma-kit render-scrolly` | Run each scrolly page's instructor notebook and save outputs to `_build/scrolly/` | | `nma-kit prepare-site [--dry-run] [--kit-version V]` | Add badges and fallback cells to the pages, pin the kit URL in `myst.yml` and run `sync-assets`. **CI only: it edits files in place, never commit the result** | @@ -41,7 +41,8 @@ top-level `nma:` block holds the rest. ```yaml nma: paths: - tutorials: tutorials # where the .md pages live + tutorials: tutorials # where the tutorial .md pages live + projects: projects # where the project .md pages live notebooks: branch: notebooks-branch # where generated notebooks are published feedback: # required if any page uses {nma-feedback} diff --git a/python/src/nma_kit/cli.py b/python/src/nma_kit/cli.py index ccb6120..9636c40 100644 --- a/python/src/nma_kit/cli.py +++ b/python/src/nma_kit/cli.py @@ -58,13 +58,13 @@ def cmd_notebooks(args) -> int: settings = _settings() if not _validate(check_injected=True): return 1 - count = convert.process_tutorials(settings, args.dry_run, prepare_site=False) + count = convert.process_content(settings, args.dry_run, prepare_site=False) print(f"Done. Processed {count} file(s).") return 0 def cmd_render_scrolly(args) -> int: - return scrolly.render_all(read_settings().tutorials_dir) + return scrolly.render_all(read_settings()) def cmd_sync_assets(args) -> int: @@ -78,7 +78,7 @@ def cmd_prepare_site(args) -> int: settings = _settings() if not _validate(check_injected=False): return 1 - count = convert.process_tutorials(settings, args.dry_run, prepare_site=True) + count = convert.process_content(settings, args.dry_run, prepare_site=True) if not args.dry_run: branding.sync_assets(settings) text = MYST_CONFIG.read_text(encoding="utf-8") diff --git a/python/src/nma_kit/config.py b/python/src/nma_kit/config.py index e1e3e18..9aefac7 100644 --- a/python/src/nma_kit/config.py +++ b/python/src/nma_kit/config.py @@ -11,18 +11,21 @@ nma: paths: - tutorials: tutorials # where the course's .md pages live + tutorials: tutorials # where the course's tutorial .md pages live + projects: projects # where the course's project .md pages live notebooks: branch: notebooks-branch # where generated notebooks are published """ import re from dataclasses import dataclass, field +from typing import NamedTuple from pathlib import Path, PurePosixPath import yaml DEFAULT_TUTORIALS_DIR = "tutorials" +DEFAULT_PROJECTS_DIR = "projects" DEFAULT_NOTEBOOKS_BRANCH = "notebooks-branch" NOTEBOOKS_DIR = "notebooks" MYST_CONFIG = Path("myst.yml") @@ -44,6 +47,13 @@ class Settings: tutorials_dir: str = DEFAULT_TUTORIALS_DIR notebooks_branch: str = DEFAULT_NOTEBOOKS_BRANCH nma: dict = field(default_factory=dict) + projects_dir: str = DEFAULT_PROJECTS_DIR + + @property + def content_roots(self) -> tuple[tuple[str, str], ...]: + """(name, directory) of each place pages live. The name is fixed (it is + the first folder under notebooks/); the directory is configurable.""" + return (("tutorials", self.tutorials_dir), ("projects", self.projects_dir)) def _yaml(text: str) -> dict: @@ -80,6 +90,7 @@ def load_settings(myst_text: str) -> Settings: tutorials_dir=_setting(nma, "paths", "tutorials", DEFAULT_TUTORIALS_DIR).strip("/"), notebooks_branch=_setting(nma, "notebooks", "branch", DEFAULT_NOTEBOOKS_BRANCH), nma=nma, + projects_dir=_setting(nma, "paths", "projects", DEFAULT_PROJECTS_DIR).strip("/"), ) @@ -90,13 +101,27 @@ def read_settings(path: Path = MYST_CONFIG) -> Settings: return load_settings(path.read_text(encoding="utf-8")) -def page_location(settings: Settings, page: str | Path) -> tuple[str, str] | None: - """(rel_dir, stem) of a page path relative to the course root, or None if - it is outside the tutorials dir. 'tutorials/W1/T1.md' -> ('W1', 'T1').""" +class PageLocation(NamedTuple): + root: str # content root name: 'tutorials' or 'projects' + rel_dir: str # folder under the content root ('' at the top) + stem: str + + @property + def notebook_dir(self) -> str: + """Folder under notebooks/ holding the page's generated files.""" + return f"{self.root}/{self.rel_dir}" if self.rel_dir else self.root + + +def page_location(settings: Settings, page: str | Path) -> PageLocation | None: + """Where a page (path relative to the course root) lives, or None if it is + outside every content root. 'tutorials/W1/T1.md' -> PageLocation('tutorials', 'W1', 'T1'). + The deepest matching root wins.""" path = PurePosixPath(Path(page).as_posix()) - try: - rel = path.relative_to(settings.tutorials_dir) - except ValueError: - return None - rel_dir = rel.parent.as_posix() - return ("" if rel_dir == "." else rel_dir), rel.stem + for name, directory in sorted(settings.content_roots, key=lambda r: -len(r[1])): + try: + rel = path.relative_to(directory) + except ValueError: + continue + rel_dir = rel.parent.as_posix() + return PageLocation(name, "" if rel_dir == "." else rel_dir, rel.stem) + return None diff --git a/python/src/nma_kit/convert.py b/python/src/nma_kit/convert.py index 99ef147..4b95e78 100644 --- a/python/src/nma_kit/convert.py +++ b/python/src/nma_kit/convert.py @@ -1,11 +1,12 @@ """ -Convert MyST Markdown tutorials to Jupyter notebooks and inject Colab/Kaggle badges. +Convert MyST Markdown pages (tutorials and projects) to Jupyter notebooks and inject Colab/Kaggle badges. Usage: nma-kit notebooks [--dry-run] nma-kit prepare-site [--dry-run] -`nma-kit notebooks` (CI: course-publish workflow). For each .md file in the tutorials dir +`nma-kit notebooks` (CI: course-publish workflow). For each .md file in a content dir +(nma.paths.tutorials, nma.paths.projects) that has a kernelspec frontmatter: 1. Convert to .ipynb via jupytext. Scrolly pages (`layout: scrolly`) are flattened first: {step} markers removed, {stage} blocks unwrapped, and @@ -18,8 +19,9 @@ into a student notebook (link cells) and an instructor notebook (solution code cells, explanation markdown), and write the solution files 5. Inject a Colab + Kaggle badge markdown cell at position 0 of each notebook - 6. Write notebooks//.ipynb (student), - notebooks//instructor/.ipynb and notebooks//solutions/* + 6. Write notebooks///.ipynb (student), + notebooks///instructor/.ipynb and notebooks///solutions/*, + where is `tutorials` or `projects` (the content root the page is in) The .md sources are never modified in this mode. `nma-kit prepare-site` (CI, right before `myst build`): edit each kernelspec .md file in place, @@ -32,7 +34,7 @@ Only run it on a throwaway checkout (CI). Never commit the result. Both modes first validate the content rules and exit non-zero on any violation: - - No .ipynb files in tutorials/ (all content is authored as MyST .md) + - No .ipynb files in the content dirs (all content is authored as MyST .md) - Any .md with a {code-cell} directive must declare a kernelspec - {nma-video}/{nma-slides} directives must be valid (ids present, known options only, closed, no body) @@ -172,19 +174,21 @@ def validate_content(check_injected: bool = True) -> list[str]: """ errors = [] config_text = MYST_CONFIG.read_text(encoding="utf-8") if MYST_CONFIG.exists() else "" - tutorials_dir = Path(load_settings(config_text).tutorials_dir) + settings = load_settings(config_text) + content_dirs = [Path(directory) for _, directory in settings.content_roots] config_problems = nma_feedback.config_errors(config_text) errors += config_problems errors += nma_branding.brand_errors(read_nma_block(config_text)) has_config = not config_problems and nma_feedback.load_config(config_text) is not None - for path in sorted(tutorials_dir.rglob("*.ipynb")): - if ".ipynb_checkpoints" in path.parts: - continue - errors.append( - f"{path}: .ipynb files are not allowed in {tutorials_dir.as_posix()}/. Convert it with " - f"`jupytext --to md:myst` and commit only the .md." - ) - for path in sorted(tutorials_dir.rglob("*.md")): + for content_dir in content_dirs: + for path in sorted(content_dir.rglob("*.ipynb")): + if ".ipynb_checkpoints" in path.parts: + continue + errors.append( + f"{path}: .ipynb files are not allowed in {content_dir.as_posix()}/. Convert it with " + f"`jupytext --to md:myst` and commit only the .md." + ) + for path in sorted(p for d in content_dirs for p in d.rglob("*.md")): if has_code_cells(path) and not has_kernelspec(path): errors.append( f"{path}: contains {{code-cell}} blocks but no `kernelspec` in its " @@ -378,7 +382,7 @@ def build_notebooks(source_file: Path, out_path: Path, github_repo: str, dry_run and add badges. """ settings = settings or Settings(github_repo) - rel_dir, _ = page_location(settings, source_file) + location = page_location(settings, source_file) branch = settings.notebooks_branch instructor_path = out_path.parent / "instructor" / out_path.name solutions_dir = out_path.parent / "solutions" @@ -405,7 +409,7 @@ def build_notebooks(source_file: Path, out_path: Path, github_repo: str, dry_run print(f" Expanded {count} feedback directive(s) (prefix {prefix})") student, instructor, files = nma_solutions.split_notebook( - nb, source_file.stem, rel_dir, github_repo, branch) + nb, source_file.stem, location.notebook_dir, github_repo, branch) for name, content in files.items(): solutions_dir.mkdir(parents=True, exist_ok=True) (solutions_dir / name).write_text(content, encoding="utf-8") @@ -418,20 +422,25 @@ def build_notebooks(source_file: Path, out_path: Path, github_repo: str, dry_run print(f" Wrote notebook with badges: {path}") -def process_tutorials(settings: Settings, dry_run: bool, prepare_site: bool) -> int: - """Walk the tutorials dir and process all executable .md pages. Returns count processed.""" +def process_content(settings: Settings, dry_run: bool, prepare_site: bool) -> int: + """Walk every content dir (tutorials, projects) and process all executable + .md pages. Returns count processed.""" count = 0 github_repo = require_github_repo(settings) feedback_config = nma_feedback.load_config(MYST_CONFIG.read_text(encoding="utf-8")) - tutorials_dir = Path(settings.tutorials_dir) + pages = sorted( + source_file + for _, directory in settings.content_roots + for source_file in Path(directory).rglob("*.md") + ) - for source_file in sorted(tutorials_dir.rglob("*.md")): + for source_file in pages: if not has_kernelspec(source_file): continue # static page — skip - # Determine output path under notebooks/ - relative_to_tutorials = source_file.relative_to(tutorials_dir) - out_path = NOTEBOOKS_DIR / relative_to_tutorials.with_suffix(".ipynb") + # Output path: notebooks///.ipynb + location = page_location(settings, source_file) + out_path = NOTEBOOKS_DIR / location.notebook_dir / f"{location.stem}.ipynb" if prepare_site: prepare_site_md(source_file, out_path.as_posix(), github_repo, dry_run, diff --git a/python/src/nma_kit/scrolly.py b/python/src/nma_kit/scrolly.py index 76cdda7..37865ac 100644 --- a/python/src/nma_kit/scrolly.py +++ b/python/src/nma_kit/scrolly.py @@ -30,7 +30,7 @@ - is_scrolly() <-> isScrolly() - cell_key() <-> cellKey() (fixture tests/fixtures/scrolly/cell_keys.json) - parse_params() <-> parseParams() (fixture tests/fixtures/scrolly/params.json) - - render file path _build/scrolly//.json + - render file path _build/scrolly///.json """ import hashlib @@ -364,18 +364,18 @@ def rewrite_notebook_params(nb: dict) -> int: # # `nma-kit render-scrolly` runs every scrolly page with a # kernelspec headless and writes its outputs to -# _build/scrolly//.json = {cell_key: {stdout, images, error}}. +# _build/scrolly///.json = {cell_key: {stdout, images, error}}. # js/src/scrolly/plugin.mjs embeds them so readers see real figures before # starting Python. -from nma_kit.config import DEFAULT_TUTORIALS_DIR as TUTORIALS_DIR # noqa: E402 RENDER_DIR = "_build/scrolly" RENDER_TIMEOUT = 300 -def render_path(rel_dir: str, stem: str): +def render_path(notebook_dir: str, stem: str): + """notebook_dir is PageLocation.notebook_dir, e.g. 'tutorials/W1D1'.""" from pathlib import Path - return Path(RENDER_DIR, *([rel_dir] if rel_dir else []), f"{stem}.json") + return Path(RENDER_DIR, *([notebook_dir] if notebook_dir else []), f"{stem}.json") def _source_cells(text: str) -> dict: @@ -399,7 +399,7 @@ def _outputs(cell: dict) -> dict: return {"stdout": stdout, "images": images, "error": None} -def execution_notebook(page, rel_dir: str, repo: str = "org/repo") -> dict: +def execution_notebook(page, notebook_dir: str, repo: str = "org/repo") -> dict: """ The page's instructor notebook, ready to run: flattened, solutions filled in, `# @param` cells left as plain assignments (so they run with their @@ -419,7 +419,7 @@ def execution_notebook(page, rel_dir: str, repo: str = "org/repo") -> dict: if result.returncode != 0: raise RuntimeError(f"jupytext failed:\n{result.stderr}") nb = json.loads(out.read_text(encoding="utf-8")) - _, instructor, _ = nma_solutions.split_notebook(nb, page.stem, rel_dir, repo) + _, instructor, _ = nma_solutions.split_notebook(nb, page.stem, notebook_dir, repo) cells = [] for cell in instructor["cells"]: if cell.get("cell_type") != "code": @@ -431,7 +431,7 @@ def execution_notebook(page, rel_dir: str, repo: str = "org/repo") -> dict: return {**instructor, "cells": cells} -def render_page(page, rel_dir: str) -> tuple[dict | None, str | None]: +def render_page(page, notebook_dir: str) -> tuple[dict | None, str | None]: """Run one page. Returns (render data, None) or (None, error message).""" import nbformat from nbclient import NotebookClient @@ -439,7 +439,7 @@ def render_page(page, rel_dir: str) -> tuple[dict | None, str | None]: text = page.read_text(encoding="utf-8") lines_by_key = _source_cells(text) - nb = nbformat.from_dict(execution_notebook(page, rel_dir)) + nb = nbformat.from_dict(execution_notebook(page, notebook_dir)) client = NotebookClient(nb, timeout=RENDER_TIMEOUT, kernel_name="python3", resources={"metadata": {"path": str(page.parent)}}) try: @@ -463,27 +463,28 @@ def render_page(page, rel_dir: str) -> tuple[dict | None, str | None]: return data, None -def render_all(root: str = TUTORIALS_DIR) -> int: - """Render every scrolly page with a kernelspec under `root`. Returns an exit code.""" +def render_all(settings) -> int: + """Render every scrolly page with a kernelspec under the content dirs. Returns an exit code.""" import json from pathlib import Path from nma_kit import convert as convert_to_notebooks + from nma_kit.config import page_location failed = False - for page in sorted(Path(root).rglob("*.md")): + pages = sorted(p for _, directory in settings.content_roots for p in Path(directory).rglob("*.md")) + for page in pages: text = page.read_text(encoding="utf-8") if not (is_scrolly(text) and convert_to_notebooks.has_kernelspec(page)): continue - rel = page.relative_to(root) - rel_dir = rel.parent.as_posix() if rel.parent != Path(".") else "" + location = page_location(settings, page) print(f"Rendering {page}") - data, error = render_page(page, rel_dir) + data, error = render_page(page, location.notebook_dir) if error: print(f" ERROR {error}", file=sys.stderr) failed = True continue - out = render_path(rel_dir, page.stem) + out = render_path(location.notebook_dir, page.stem) out.parent.mkdir(parents=True, exist_ok=True) out.write_text(json.dumps(data), encoding="utf-8") print(f" Wrote {out} ({len(data)} cell(s))") diff --git a/python/src/nma_kit/solutions.py b/python/src/nma_kit/solutions.py index ab829da..5f8acfd 100644 --- a/python/src/nma_kit/solutions.py +++ b/python/src/nma_kit/solutions.py @@ -24,7 +24,7 @@ def f(x): One source becomes: - student notebook: "Click for solution/explanation" link cells - instructor notebook: the solution as a code cell, the explanation as markdown - - solution files, written to notebooks//solutions/ on notebooks-branch + - solution files, written to notebooks///solutions/ on notebooks-branch - a hidden setup cell for the site that runs the reference solution when an unfinished exercise is executed, so "Run all" doesn't stop @@ -230,9 +230,10 @@ def solution_filename(stem: str, kind: str, n: int) -> str: return FILE_PATTERNS[kind].format(stem=stem, n=n) -def solution_url(repo: str, rel_dir: str, filename: str, branch: str = NOTEBOOKS_BRANCH) -> str: - """GitHub URL of a solution file. rel_dir is the page's folder under the tutorials dir.""" - path = "/".join(p for p in ("notebooks", rel_dir, "solutions", filename) if p) +def solution_url(repo: str, notebook_dir: str, filename: str, branch: str = NOTEBOOKS_BRANCH) -> str: + """GitHub URL of a solution file. notebook_dir is the page's folder under + notebooks/ (PageLocation.notebook_dir, e.g. 'tutorials/W1D1').""" + path = "/".join(p for p in ("notebooks", notebook_dir, "solutions", filename) if p) return f"https://github.com/{repo}/blob/{branch}/{path}" @@ -303,7 +304,7 @@ def _instructor_cell(item: Item, cell_id: str) -> dict: } -def split_notebook(nb: dict, stem: str, rel_dir: str, repo: str, +def split_notebook(nb: dict, stem: str, notebook_dir: str, repo: str, branch: str = NOTEBOOKS_BRANCH) -> tuple[dict, dict, dict]: """ Build the student and instructor versions of a jupytext-generated notebook. @@ -334,7 +335,7 @@ def split_notebook(nb: dict, stem: str, rel_dir: str, repo: str, fragment = _markdown(prose, f"{base}-m{k}") student.append(fragment) instructor.append(fragment) - url = solution_url(repo, rel_dir, item.filename, branch) + url = solution_url(repo, notebook_dir, item.filename, branch) student.append(_student_cell(item, url, f"{base}-s{k}")) instructor.append(_instructor_cell(item, f"{base}-i{k}")) files[item.filename] = item.content diff --git a/tests/example-course/myst.yml b/tests/example-course/myst.yml index 2c126f7..ae0b6ae 100644 --- a/tests/example-course/myst.yml +++ b/tests/example-course/myst.yml @@ -20,6 +20,9 @@ project: - file: tutorials/W2D1_Examples/W2D1_Tutorial1.md - file: tutorials/W2D1_Examples/W2D1_Tutorial2.md - file: tutorials/W2D1_Examples/W2D1_Tutorial3.md + - title: Projects + children: + - file: projects/Neuroscience/Project1.md site: template: book-theme options: diff --git a/tests/example-course/projects/Neuroscience/Project1.md b/tests/example-course/projects/Neuroscience/Project1.md new file mode 100644 index 0000000..705f568 --- /dev/null +++ b/tests/example-course/projects/Neuroscience/Project1.md @@ -0,0 +1,29 @@ +--- +title: "Project 1: Fitting a Line" +kernelspec: + name: python3 + display_name: Python 3 +--- + +# Project 1: Fitting a Line + +Projects are authored like tutorials and get the same generated notebooks, +Colab and Kaggle badges and solution links, under `notebooks/projects/`. + +## Exercise 1 + +Complete the function so it returns the slope of the line through two points. + +```{code-cell} python +def slope(x0, y0, x1, y1): + raise NotImplementedError("Student exercise: compute the slope") + +print(slope(0, 0, 2, 4)) +``` + +::::{nma-solution} +```python +def slope(x0, y0, x1, y1): + return (y1 - y0) / (x1 - x0) +``` +:::: diff --git a/tests/fixtures/config/settings.json b/tests/fixtures/config/settings.json index 5892c09..c3a924c 100644 --- a/tests/fixtures/config/settings.json +++ b/tests/fixtures/config/settings.json @@ -1,5 +1,5 @@ { - "_doc": "Shared by tests/python/test_config.py and tests/js/config.test.mjs. Each case: a course myst.yml, a page path relative to the course root, and what both sides must compute for it (null = page is outside the tutorials dir).", + "_doc": "Shared by tests/python/test_config.py and tests/js/config.test.mjs. Each case: a course myst.yml, a page path relative to the course root, and what both sides must compute for it (null = page is outside every content dir). `notebookDir` is the folder under notebooks/.", "cases": [ { "name": "defaults without an nma block", @@ -8,10 +8,12 @@ "expect": { "relDir": "W2D1_Examples", "stem": "W2D1_Tutorial1", - "notebook": "notebooks/W2D1_Examples/W2D1_Tutorial1.ipynb", - "colab": "https://colab.research.google.com/github/neuromatch/course-template/blob/notebooks-branch/notebooks/W2D1_Examples/W2D1_Tutorial1.ipynb", - "kaggle": "https://kaggle.com/kernels/welcome?src=https://raw.githubusercontent.com/neuromatch/course-template/notebooks-branch/notebooks/W2D1_Examples/W2D1_Tutorial1.ipynb", - "solution1": "https://github.com/neuromatch/course-template/blob/notebooks-branch/notebooks/W2D1_Examples/solutions/W2D1_Tutorial1_Solution_1.py" + "notebook": "notebooks/tutorials/W2D1_Examples/W2D1_Tutorial1.ipynb", + "colab": "https://colab.research.google.com/github/neuromatch/course-template/blob/notebooks-branch/notebooks/tutorials/W2D1_Examples/W2D1_Tutorial1.ipynb", + "kaggle": "https://kaggle.com/kernels/welcome?src=https://raw.githubusercontent.com/neuromatch/course-template/notebooks-branch/notebooks/tutorials/W2D1_Examples/W2D1_Tutorial1.ipynb", + "solution1": "https://github.com/neuromatch/course-template/blob/notebooks-branch/notebooks/tutorials/W2D1_Examples/solutions/W2D1_Tutorial1_Solution_1.py", + "root": "tutorials", + "notebookDir": "tutorials/W2D1_Examples" } }, { @@ -21,10 +23,12 @@ "expect": { "relDir": "Day1", "stem": "T1", - "notebook": "notebooks/Day1/T1.ipynb", - "colab": "https://colab.research.google.com/github/org/my-course/blob/nb/notebooks/Day1/T1.ipynb", - "kaggle": "https://kaggle.com/kernels/welcome?src=https://raw.githubusercontent.com/org/my-course/nb/notebooks/Day1/T1.ipynb", - "solution1": "https://github.com/org/my-course/blob/nb/notebooks/Day1/solutions/T1_Solution_1.py" + "notebook": "notebooks/tutorials/Day1/T1.ipynb", + "colab": "https://colab.research.google.com/github/org/my-course/blob/nb/notebooks/tutorials/Day1/T1.ipynb", + "kaggle": "https://kaggle.com/kernels/welcome?src=https://raw.githubusercontent.com/org/my-course/nb/notebooks/tutorials/Day1/T1.ipynb", + "solution1": "https://github.com/org/my-course/blob/nb/notebooks/tutorials/Day1/solutions/T1_Solution_1.py", + "root": "tutorials", + "notebookDir": "tutorials/Day1" } }, { @@ -34,10 +38,12 @@ "expect": { "relDir": "", "stem": "intro", - "notebook": "notebooks/intro.ipynb", - "colab": "https://colab.research.google.com/github/o/r/blob/notebooks-branch/notebooks/intro.ipynb", - "kaggle": "https://kaggle.com/kernels/welcome?src=https://raw.githubusercontent.com/o/r/notebooks-branch/notebooks/intro.ipynb", - "solution1": "https://github.com/o/r/blob/notebooks-branch/notebooks/solutions/intro_Solution_1.py" + "notebook": "notebooks/tutorials/intro.ipynb", + "colab": "https://colab.research.google.com/github/o/r/blob/notebooks-branch/notebooks/tutorials/intro.ipynb", + "kaggle": "https://kaggle.com/kernels/welcome?src=https://raw.githubusercontent.com/o/r/notebooks-branch/notebooks/tutorials/intro.ipynb", + "solution1": "https://github.com/o/r/blob/notebooks-branch/notebooks/tutorials/solutions/intro_Solution_1.py", + "root": "tutorials", + "notebookDir": "tutorials" } }, { @@ -45,6 +51,51 @@ "myst": "version: 1\nproject:\n github: https://github.com/o/r\nnma:\n paths:\n tutorials: content\n", "page": "tutorials/W1D1/T1.md", "expect": null + }, + { + "name": "project page", + "myst": "version: 1\nproject:\n github: https://github.com/o/r\n", + "page": "projects/Neuroscience/ProjectA.md", + "expect": { + "root": "projects", + "relDir": "Neuroscience", + "notebookDir": "projects/Neuroscience", + "stem": "ProjectA", + "notebook": "notebooks/projects/Neuroscience/ProjectA.ipynb", + "colab": "https://colab.research.google.com/github/o/r/blob/notebooks-branch/notebooks/projects/Neuroscience/ProjectA.ipynb", + "kaggle": "https://kaggle.com/kernels/welcome?src=https://raw.githubusercontent.com/o/r/notebooks-branch/notebooks/projects/Neuroscience/ProjectA.ipynb", + "solution1": "https://github.com/o/r/blob/notebooks-branch/notebooks/projects/Neuroscience/solutions/ProjectA_Solution_1.py" + } + }, + { + "name": "custom projects dir keeps the projects root name", + "myst": "version: 1\nproject:\n github: https://github.com/o/r\nnma:\n paths:\n projects: capstone\n", + "page": "capstone/P1.md", + "expect": { + "root": "projects", + "relDir": "", + "notebookDir": "projects", + "stem": "P1", + "notebook": "notebooks/projects/P1.ipynb", + "colab": "https://colab.research.google.com/github/o/r/blob/notebooks-branch/notebooks/projects/P1.ipynb", + "kaggle": "https://kaggle.com/kernels/welcome?src=https://raw.githubusercontent.com/o/r/notebooks-branch/notebooks/projects/P1.ipynb", + "solution1": "https://github.com/o/r/blob/notebooks-branch/notebooks/projects/solutions/P1_Solution_1.py" + } + }, + { + "name": "deepest content root wins", + "myst": "version: 1\nproject:\n github: https://github.com/o/r\nnma:\n paths:\n tutorials: course\n projects: course/projects\n", + "page": "course/projects/P1.md", + "expect": { + "root": "projects", + "relDir": "", + "notebookDir": "projects", + "stem": "P1", + "notebook": "notebooks/projects/P1.ipynb", + "colab": "https://colab.research.google.com/github/o/r/blob/notebooks-branch/notebooks/projects/P1.ipynb", + "kaggle": "https://kaggle.com/kernels/welcome?src=https://raw.githubusercontent.com/o/r/notebooks-branch/notebooks/projects/P1.ipynb", + "solution1": "https://github.com/o/r/blob/notebooks-branch/notebooks/projects/solutions/P1_Solution_1.py" + } } ] } diff --git a/tests/js/config.test.mjs b/tests/js/config.test.mjs index 035325b..d6f9a43 100644 --- a/tests/js/config.test.mjs +++ b/tests/js/config.test.mjs @@ -8,7 +8,7 @@ import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadSettings, readNmaConfig } from '../../js/src/shared/config.mjs'; -import { pageLocation, notebookUrls, solutionUrl, courseSettings } from '../../js/src/shared/paths.mjs'; +import { pageLocation, notebookDir, notebookUrls, solutionUrl, courseSettings } from '../../js/src/shared/paths.mjs'; const here = dirname(fileURLToPath(import.meta.url)); const { cases } = JSON.parse(readFileSync(join(here, '..', 'fixtures', 'config', 'settings.json'), 'utf8')); @@ -35,10 +35,11 @@ for (const c of cases) { assert.equal(location, null); return; } - assert.deepEqual(location, { relDir: c.expect.relDir, stem: c.expect.stem }); + assert.deepEqual(location, { root: c.expect.root, relDir: c.expect.relDir, stem: c.expect.stem }); + assert.equal(notebookDir(location), c.expect.notebookDir); assert.deepEqual(notebookUrls(page), { colabUrl: c.expect.colab, kaggleUrl: c.expect.kaggle }); const s = courseSettings(); - assert.equal(solutionUrl(s.githubRepo, location.relDir, `${location.stem}_Solution_1.py`, s.notebooksBranch), c.expect.solution1); + assert.equal(solutionUrl(s.githubRepo, notebookDir(location), `${location.stem}_Solution_1.py`, s.notebooksBranch), c.expect.solution1); }); }); } @@ -47,6 +48,7 @@ test('defaults without an nma block', () => { assert.deepEqual(loadSettings('project:\n github: https://github.com/o/r\n'), { githubRepo: 'o/r', tutorialsDir: 'tutorials', + projectsDir: 'projects', notebooksBranch: 'notebooks-branch', nma: {}, }); diff --git a/tests/js/shared.test.mjs b/tests/js/shared.test.mjs index 48258a4..62891c2 100644 --- a/tests/js/shared.test.mjs +++ b/tests/js/shared.test.mjs @@ -39,9 +39,9 @@ test('notebookUrls matches make_badge_html', () => { const { colabUrl, kaggleUrl } = notebookUrls(join(course, 'tutorials/W2D1_Examples/W2D1_Tutorial1.md')); assert.equal( colabUrl, - 'https://colab.research.google.com/github/neuromatch/course-kit/blob/notebooks-branch/notebooks/W2D1_Examples/W2D1_Tutorial1.ipynb', + 'https://colab.research.google.com/github/neuromatch/course-kit/blob/notebooks-branch/notebooks/tutorials/W2D1_Examples/W2D1_Tutorial1.ipynb', ); assert.ok( - kaggleUrl.endsWith('raw.githubusercontent.com/neuromatch/course-kit/notebooks-branch/notebooks/W2D1_Examples/W2D1_Tutorial1.ipynb'), + kaggleUrl.endsWith('raw.githubusercontent.com/neuromatch/course-kit/notebooks-branch/notebooks/tutorials/W2D1_Examples/W2D1_Tutorial1.ipynb'), ); }); diff --git a/tests/python/test_cli.py b/tests/python/test_cli.py index 19b9958..f4b3efe 100644 --- a/tests/python/test_cli.py +++ b/tests/python/test_cli.py @@ -61,7 +61,7 @@ def test_prepare_site_pins_kit_url_and_adds_badges(tmp_path, monkeypatch): assert main(["prepare-site", "--kit-version", "9.9.9"]) == 0 assert f"{KIT}@9.9.9/dist/nma.yml" in (tmp_path / "myst.yml").read_text() page = (tmp_path / "tutorials" / "D" / "T.md").read_text() - assert "colab.research.google.com/github/org/repo/blob/notebooks-branch/notebooks/D/T.ipynb" in page + assert "colab.research.google.com/github/org/repo/blob/notebooks-branch/notebooks/tutorials/D/T.ipynb" in page def test_prepare_site_defaults_to_own_version(tmp_path, monkeypatch): @@ -81,10 +81,59 @@ def test_notebooks_dry_run_writes_nothing(tmp_path, monkeypatch): def test_notebooks_writes_student_and_instructor(tmp_path, monkeypatch): monkeypatch.chdir(course(tmp_path)) assert main(["notebooks"]) == 0 - student = json.loads((tmp_path / "notebooks" / "D" / "T.ipynb").read_text()) + student = json.loads((tmp_path / "notebooks" / "tutorials" / "D" / "T.ipynb").read_text()) assert "colab" in "".join(student["cells"][0]["source"]) - assert (tmp_path / "notebooks" / "D" / "instructor" / "T.ipynb").exists() - assert (tmp_path / "notebooks" / "D" / "solutions" / "T_Solution_1.py").exists() + assert (tmp_path / "notebooks" / "tutorials" / "D" / "instructor" / "T.ipynb").exists() + assert (tmp_path / "notebooks" / "tutorials" / "D" / "solutions" / "T_Solution_1.py").exists() + + +def with_project(root: Path, page: str = EXERCISE + SOLUTION) -> Path: + (root / "projects" / "Neuro").mkdir(parents=True) + (root / "projects" / "Neuro" / "P.md").write_text(page) + return root + + +def test_notebooks_covers_projects_under_their_own_root(tmp_path, monkeypatch): + monkeypatch.chdir(with_project(course(tmp_path))) + assert main(["notebooks"]) == 0 + base = tmp_path / "notebooks" + assert (base / "tutorials" / "D" / "T.ipynb").exists() + project = json.loads((base / "projects" / "Neuro" / "P.ipynb").read_text()) + assert "blob/notebooks-branch/notebooks/projects/Neuro/P.ipynb" in "".join(project["cells"][0]["source"]) + assert (base / "projects" / "Neuro" / "instructor" / "P.ipynb").exists() + solution_link = "".join(project["cells"][2]["source"]) + assert "notebooks/projects/Neuro/solutions/P_Solution_1.py" in solution_link + + +def test_prepare_site_adds_badges_to_projects(tmp_path, monkeypatch): + monkeypatch.chdir(with_project(course(tmp_path))) + assert main(["prepare-site"]) == 0 + page = (tmp_path / "projects" / "Neuro" / "P.md").read_text() + assert "notebooks-branch/notebooks/projects/Neuro/P.ipynb" in page + + +def test_check_validates_projects(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(with_project(course(tmp_path), EXERCISE)) + assert main(["check"]) == 1 + assert "P.md" in capsys.readouterr().err + + +def test_check_rejects_ipynb_in_projects(tmp_path, monkeypatch, capsys): + root = with_project(course(tmp_path)) + (root / "projects" / "Neuro" / "x.ipynb").write_text("{}") + monkeypatch.chdir(root) + assert main(["check"]) == 1 + assert "projects/Neuro/x.ipynb" in capsys.readouterr().err + + +def test_custom_projects_dir(tmp_path, monkeypatch): + root = course(tmp_path) + (root / "myst.yml").write_text((root / "myst.yml").read_text() + "nma:\n paths:\n projects: capstone\n") + (root / "capstone").mkdir() + (root / "capstone" / "P.md").write_text(EXERCISE + SOLUTION) + monkeypatch.chdir(root) + assert main(["notebooks"]) == 0 + assert (root / "notebooks" / "projects" / "P.ipynb").exists() # root name, not the directory def test_render_scrolly_without_scrolly_pages(tmp_path, monkeypatch): diff --git a/tests/python/test_config.py b/tests/python/test_config.py index 17802e8..a124b18 100644 --- a/tests/python/test_config.py +++ b/tests/python/test_config.py @@ -15,6 +15,8 @@ def test_defaults_without_nma_block(): s = load_settings("project:\n github: https://github.com/o/r\n") assert s.tutorials_dir == "tutorials" + assert s.projects_dir == "projects" + assert s.content_roots == (("tutorials", "tutorials"), ("projects", "projects")) assert s.notebooks_branch == "notebooks-branch" assert s.github_repo == "o/r" @@ -25,6 +27,11 @@ def test_custom_paths(): assert s.github_repo is None +def test_custom_projects_dir(): + s = load_settings("nma:\n paths:\n projects: capstone/\n") + assert s.content_roots == (("tutorials", "tutorials"), ("projects", "capstone")) + + def test_nma_block_is_exposed(): s = load_settings("nma:\n feedback:\n url: https://x\n") assert s.nma == {"feedback": {"url": "https://x"}} @@ -38,12 +45,12 @@ def test_shared_fixture_cases(case): if expect is None: assert location is None return - rel_dir, stem = location - assert (rel_dir, stem) == (expect["relDir"], expect["stem"]) + assert (location.root, location.rel_dir, location.stem) == (expect["root"], expect["relDir"], expect["stem"]) + assert location.notebook_dir == expect["notebookDir"] html = make_badge_html(expect["notebook"], s.github_repo, s.notebooks_branch) assert f'href="{expect["colab"]}"' in html assert f'href="{expect["kaggle"]}"' in html - assert solution_url(s.github_repo, rel_dir, f"{stem}_Solution_1.py", s.notebooks_branch) == expect["solution1"] + assert solution_url(s.github_repo, location.notebook_dir, f"{location.stem}_Solution_1.py", s.notebooks_branch) == expect["solution1"] def test_number_value_rejected(): diff --git a/tests/python/test_convert.py b/tests/python/test_convert.py index 1543136..673981a 100644 --- a/tests/python/test_convert.py +++ b/tests/python/test_convert.py @@ -114,7 +114,7 @@ def test_build_notebooks_expands_feedback(tmp_path, monkeypatch): page = tmp_path / "tutorials" / "D" / "T.md" page.parent.mkdir(parents=True) page.write_text(FEEDBACK_PAGE) - out = tmp_path / "notebooks" / "D" / "T.ipynb" + out = tmp_path / "notebooks" / "tutorials" / "D" / "T.ipynb" config = nma_feedback.FeedbackConfig("https://e.org", "proj", "key") convert_md_to_notebook(page.relative_to(tmp_path), out, dry_run=False) @@ -180,7 +180,7 @@ def test_build_notebooks_flattens_scrolly_pages(tmp_path, monkeypatch): page = tmp_path / "tutorials" / "D" / "T.md" page.parent.mkdir(parents=True) page.write_text(SCROLLY_PAGE) - out = tmp_path / "notebooks" / "D" / "T.ipynb" + out = tmp_path / "notebooks" / "tutorials" / "D" / "T.ipynb" convert_md_to_notebook(page.relative_to(tmp_path), out, dry_run=False) build_notebooks(page.relative_to(tmp_path), out.relative_to(tmp_path), "org/repo", False, None) diff --git a/tests/python/test_scrolly.py b/tests/python/test_scrolly.py index 70158a6..3c8eec9 100644 --- a/tests/python/test_scrolly.py +++ b/tests/python/test_scrolly.py @@ -4,6 +4,7 @@ import pytest from nma_kit import scrolly as nma_scrolly +from nma_kit.config import Settings from nma_kit.scrolly import cell_key, find_markers, is_scrolly, parse_params, validate FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "scrolly" @@ -273,8 +274,8 @@ def _render_tree(tmp_path, page_text): def test_render_page_writes_outputs(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) _render_tree(tmp_path, RENDER_PAGE) - assert nma_scrolly.render_all() == 0 - data = json.loads((tmp_path / "_build" / "scrolly" / "Day" / "T.json").read_text()) + assert nma_scrolly.render_all(Settings(None)) == 0 + data = json.loads((tmp_path / "_build" / "scrolly" / "tutorials" / "Day" / "T.json").read_text()) hello = data[cell_key('print("hello from", here)')] assert hello == {"stdout": "hello from Day\n", "images": [], "error": None} # cwd = page dir @@ -289,7 +290,7 @@ def test_render_page_writes_outputs(tmp_path, monkeypatch): def test_render_failure_names_page_and_line(tmp_path, monkeypatch, capsys): monkeypatch.chdir(tmp_path) _render_tree(tmp_path, FRONT + "\n:::{step}\n:::\n\n```{code-cell} python\nx = 1\n```\n\n```{code-cell} python\n1 / 0\n```\n") - assert nma_scrolly.render_all() == 1 + assert nma_scrolly.render_all(Settings(None)) == 1 err = capsys.readouterr().err assert "tutorials/Day/T.md:16:" in err and "ZeroDivisionError" in err @@ -297,7 +298,7 @@ def test_render_failure_names_page_and_line(tmp_path, monkeypatch, capsys): def test_render_skips_non_scrolly_pages(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) _render_tree(tmp_path, PLAIN_FRONT + "\n```{code-cell} python\n1 / 0\n```\n") - assert nma_scrolly.render_all() == 0 + assert nma_scrolly.render_all(Settings(None)) == 0 assert not (tmp_path / "_build" / "scrolly").exists() diff --git a/tests/python/test_solutions.py b/tests/python/test_solutions.py index 1bc2b7f..d7658a2 100644 --- a/tests/python/test_solutions.py +++ b/tests/python/test_solutions.py @@ -247,11 +247,11 @@ def text_of(cell): code("plot()", "d"), md(f"{SOLUTION}", "e"), ) -URL = "https://github.com/org/repo/blob/notebooks-branch/notebooks/Day/solutions/" +URL = "https://github.com/org/repo/blob/notebooks-branch/notebooks/tutorials/Day/solutions/" def test_student_notebook_has_links_and_no_solution_code(): - student, _, _ = split_notebook(NB, "T", "Day", "org/repo") + student, _, _ = split_notebook(NB, "T", "tutorials/Day", "org/repo") texts = [(c["cell_type"], text_of(c)) for c in student["cells"]] assert texts == [ ("markdown", "# T"), @@ -267,7 +267,7 @@ def test_student_notebook_has_links_and_no_solution_code(): def test_instructor_notebook_has_solutions_and_explanations(): - _, instructor, _ = split_notebook(NB, "T", "Day", "org/repo") + _, instructor, _ = split_notebook(NB, "T", "tutorials/Day", "org/repo") cells = instructor["cells"] assert [c["cell_type"] for c in cells] == [ "markdown", "code", "code", "markdown", "markdown", "code", "code"] @@ -279,7 +279,7 @@ def test_instructor_notebook_has_solutions_and_explanations(): def test_files_are_returned(): - _, _, files = split_notebook(NB, "T", "Day", "org/repo") + _, _, files = split_notebook(NB, "T", "tutorials/Day", "org/repo") assert files == { "T_Solution_1.py": "def f(x):\n return 2 * x\n", "T_Explanation_1.md": "For $a<0$ the solution **decays**.\n", @@ -288,7 +288,7 @@ def test_files_are_returned(): def test_cell_ids_are_unique(): - student, instructor, _ = split_notebook(NB, "T", "Day", "org/repo") + student, instructor, _ = split_notebook(NB, "T", "tutorials/Day", "org/repo") for nb in (student, instructor): ids = [c["id"] for c in nb["cells"]] assert len(ids) == len(set(ids)) @@ -296,13 +296,13 @@ def test_cell_ids_are_unique(): def test_notebook_without_directives_is_unchanged(): nb = notebook(md("# T", "a"), code("x = 1", "b")) - student, instructor, files = split_notebook(nb, "T", "Day", "org/repo") + student, instructor, files = split_notebook(nb, "T", "tutorials/Day", "org/repo") assert student == nb and instructor == nb and files == {} def test_input_notebook_is_not_mutated(): before = str(NB) - split_notebook(NB, "T", "Day", "org/repo") + split_notebook(NB, "T", "tutorials/Day", "org/repo") assert str(NB) == before From d8874ae8946006a3a748266904ec82dabe10995b Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Thu, 22 Oct 2026 00:31:23 -0400 Subject: [PATCH 2/2] Fix bug in projects --- AGENTS.md | 2 +- js/src/solutions/plugin.mjs | 2 +- tests/js/solutions.test.mjs | 44 ++++++++++++++++++++++++++++++++++ tests/scripts/build-example.sh | 2 +- 4 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 tests/js/solutions.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 46623de..461b5cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -135,7 +135,7 @@ templates/wheels.yml # optional caller for course-wheels.yml change), `--nma-cell-*` and `--nma-tok-*` (read by `cells/assets/cells.css`). Syntax-token colours are fixed on purpose (chosen for contrast). - Settings are validated by `branding.brand_errors` inside `check`. -- `tests/scripts/build-example.sh` ends with `git checkout -- tutorials myst.yml` +- `tests/scripts/build-example.sh` ends with `git checkout -- tutorials projects myst.yml` to undo `prepare-site`: stage (`git add`) edits to the example course's `myst.yml` before running it, or they are reverted too. diff --git a/js/src/solutions/plugin.mjs b/js/src/solutions/plugin.mjs index 792d92a..2d51b32 100644 --- a/js/src/solutions/plugin.mjs +++ b/js/src/solutions/plugin.mjs @@ -6,7 +6,7 @@ // placeholders in document order and builds the links. The file naming and URL // rules must stay in sync with nma_kit.solutions. -import { contentRoots, notebookDir, pageLocation, solutionUrl } from '../shared/paths.mjs'; +import { contentRoots, courseSettings, notebookDir, pageLocation, solutionUrl } from '../shared/paths.mjs'; import { findPlaceholders, replaceNode, text } from '../shared/tree.mjs'; const SOLUTION_KINDS = { diff --git a/tests/js/solutions.test.mjs b/tests/js/solutions.test.mjs new file mode 100644 index 0000000..980fc35 --- /dev/null +++ b/tests/js/solutions.test.mjs @@ -0,0 +1,44 @@ +// The {nma-solution} link transform, run on a hand-built tree. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import plugin from '../../js/src/solutions/plugin.mjs'; + +const start = process.cwd(); +const MYST = 'project:\n github: https://github.com/o/r\n'; + +function run(page) { + const dir = mkdtempSync(join(tmpdir(), 'nma-course-')); + writeFileSync(join(dir, 'myst.yml'), MYST); + process.chdir(dir); + try { + const tree = { type: 'root', children: [{ type: 'nmaSolutionRef', kind: 'nma-solution' }] }; + const messages = []; + const vfile = { path: join(dir, page), message: (m) => { const x = { m }; messages.push(x); return x; } }; + plugin.transforms[0].plugin()(tree, vfile); + return { tree, messages }; + } finally { + process.chdir(start); + } +} + +test('links to the solution under the page content root', () => { + for (const [page, dir] of [ + ['tutorials/D/T.md', 'tutorials/D'], + ['projects/Neuro/P.md', 'projects/Neuro'], + ]) { + const { tree, messages } = run(page); + assert.equal(messages.length, 0); + const link = JSON.stringify(tree); + assert.ok(link.includes(`notebooks/${dir}/solutions/${page.split('/').pop().replace('.md', '')}_Solution_1.py`), link); + } +}); + +test('pages outside every content dir get a fatal message naming them', () => { + const { messages } = run('other/X.md'); + assert.equal(messages.length, 1); + assert.match(messages[0].m, /tutorials\/ or projects\//); +}); diff --git a/tests/scripts/build-example.sh b/tests/scripts/build-example.sh index 78ddcd3..203d654 100755 --- a/tests/scripts/build-example.sh +++ b/tests/scripts/build-example.sh @@ -13,7 +13,7 @@ fi cd "$KIT/tests/example-course" # prepare-site edits the pages in place; never leave that behind. -trap 'git checkout -- tutorials myst.yml' EXIT +trap 'git checkout -- tutorials projects myst.yml' EXIT nma-kit check nma-kit render-scrolly nma-kit prepare-site