diff --git a/.mkdocs/theme/breadcrumbs.html b/.mkdocs/theme/breadcrumbs.html index e71562274..cbbfd1c55 100644 --- a/.mkdocs/theme/breadcrumbs.html +++ b/.mkdocs/theme/breadcrumbs.html @@ -12,6 +12,17 @@ {%- endif %}
  • + + + Run it in Colab + +▶  Run it in Colab +⬇  Download the notebook +

    + ## Why PRIK - **Natural Python APIs:** Fortran modules become namespaces and derived types diff --git a/docs/stylesheets/site.css b/docs/stylesheets/site.css index c6524d1f1..ad9c56ae1 100644 --- a/docs/stylesheets/site.css +++ b/docs/stylesheets/site.css @@ -56,6 +56,49 @@ display: none; } +.wy-breadcrumbs-aside { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.prik-notebook-link { + display: inline-flex; + align-items: center; + gap: 0.4rem; + min-height: 2.15rem; + padding: 0.4rem 0.8rem; + border: 1px solid var(--prik-primary); + border-radius: 0.35rem; + background: #fff; + color: var(--prik-primary); + font-size: 0.82rem; + font-weight: 600; + line-height: 1; + text-decoration: none; + white-space: nowrap; + transition: + background-color 120ms ease, + border-color 120ms ease, + color 120ms ease; +} + +.prik-notebook-link:visited { + color: var(--prik-primary); +} + +.prik-notebook-link:hover, +.prik-notebook-link:focus { + background: var(--prik-primary-bg); + border-color: var(--prik-primary-dark); + color: var(--prik-primary-dark); +} + +.prik-notebook-link:focus-visible { + outline: 2px solid #f5b041; + outline-offset: 2px; +} + .prik-repository-link { display: inline-flex; align-items: center; @@ -170,6 +213,49 @@ outline-offset: 2px; } +.prik-notebook-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 1.25rem 0 1.5rem; +} + +.prik-secondary-cta { + display: inline-flex; + align-items: center; + min-height: 2.6rem; + padding: 0.65rem 1rem; + border: 1px solid var(--prik-primary); + border-radius: 0.35rem; + background: #fff; + color: var(--prik-primary); + font-weight: 700; + text-decoration: none; + transition: + background-color 120ms ease, + border-color 120ms ease, + box-shadow 120ms ease, + transform 120ms ease; +} + +.prik-secondary-cta:visited { + color: var(--prik-primary); +} + +.prik-secondary-cta:hover, +.prik-secondary-cta:focus { + background: var(--prik-primary-bg); + border-color: var(--prik-primary-dark); + box-shadow: 0 4px 9px rgb(0 0 0 / 12%); + color: var(--prik-primary-dark); + transform: translateY(-1px); +} + +.prik-secondary-cta:focus-visible { + outline: 2px solid #f5b041; + outline-offset: 2px; +} + .prik-faq-item { max-width: 56rem; margin: 0.8rem 0; @@ -442,7 +528,8 @@ @media screen and (max-width: 768px) { .wy-breadcrumbs-aside { - display: block; + display: flex; + flex-wrap: wrap; float: none; margin-top: 0.75rem; } diff --git a/docs/user/getting-started/index.md b/docs/user/getting-started/index.md index c6bdf3ca7..8fd47fea1 100644 --- a/docs/user/getting-started/index.md +++ b/docs/user/getting-started/index.md @@ -25,6 +25,9 @@ Follow these pages in order: 3. **[Your First Function](first-wrapped-function.md)** — Build the same scalar function from Fortran or C. 4. **[Development Workflow](beginner-workflow.md)** — Repeat the edit → review → build → test loop. +The [quickstart notebook](https://colab.research.google.com/github/PyNumLab/prik/blob/main/examples/notebooks/quickstart.ipynb) +runs this same loop in Colab, with nothing to install. + --- ## What You Will Build diff --git a/docs/user/tutorials/notebook-quickstart.md b/docs/user/tutorials/notebook-quickstart.md new file mode 100644 index 000000000..448106834 --- /dev/null +++ b/docs/user/tutorials/notebook-quickstart.md @@ -0,0 +1,156 @@ +--- +title: Run PRIK in a Notebook +description: Compile Fortran and C cells and reshape the generated API without leaving the notebook +audience: users +prerequisites: installation, IPython and Jupyter notebooks +related: ../guide/notebooks.md, ../guide/c/pointers-arrays-and-strings.md, pythonic-blas.md +status: maintained +publication: reviewed +--- + +# Run PRIK in a Notebook + +This tutorial compiles Fortran and C in notebook cells, calls them from Python, +and then reshapes the generated API by editing its semantic contract — all in +one session. + +

    +▶  Run it in Colab +⬇  Download the notebook +

    + +The notebook runs top to bottom and builds real extension modules, so it needs a +compiler. In Colab the first cell installs one. + +## 1. Load the extension + +```ipython +%load_ext prik.jupyter +``` + +## 2. Compile a Fortran cell + +`%%fortran` compiles the cell and publishes what it declares. A Fortran module +becomes a notebook name: + +```ipython +%%fortran +module geometry +contains + real(8) function circle_area(radius) + real(8), intent(in) :: radius + circle_area = 3.141592653589793d0 * radius**2 + end function +end module +``` + +```python +area = geometry.circle_area(np.float64(2.0)) +assert np.isclose(area, np.pi * 4) +print(f"✅ circle_area(2.0) = {area} (expected {np.pi * 4})") +``` + +```text +✅ circle_area(2.0) = 12.566370614359172 (expected 12.566370614359172) +``` + +Every result the notebook claims is asserted, so a ✅ means the cell really did +that rather than the page saying so. + +## 3. Compile a C cell + +`%%c` publishes C functions directly. This one doubles an array in place and +takes the element count the way C usually does: + +```ipython +%%c +#include + +void scale(size_t count, double *values) { + for (size_t index = 0; index < count; ++index) { + values[index] *= 2.0; + } +} +``` + +`double *values` becomes runtime-rank storage, so it accepts a NumPy array of +any rank and writes through it. The count still has to be passed by hand, +though NumPy already knows it: + +```python +values = np.array([1.0, 2.0, 3.0]) +scale(np.uintp(values.size), values) +assert np.allclose(values, [2.0, 4.0, 6.0]) +print(f"✅ scale(count, values) doubled in place: {values} (expected [2. 4. 6.])") +``` + +```text +✅ scale(count, values) doubled in place: [2. 4. 6.] (expected [2. 4. 6.]) +``` + +## 4. Reshape the API with a contract + +`--pyi` compiles nothing. It keeps the source and hands back the semantic +contract it derived, as an editable cell: + +```ipython +%%c --pyi +#include + +void scale(size_t count, double *values) { + for (size_t index = 0; index < count; ++index) { + values[index] *= 2.0; + } +} +``` + +Jupyter and Colab insert the contract below the cell you just ran: + +```ipython +%%pyi + +# prik: source-sha256= + +from prik.contracts import Float64, UInt64 + +def scale( + count: UInt64, + values: Float64[...] +) -> None: ... +``` + +Edit it so the count comes from the array. `Arg(0).size` supplies it, and +`Float64[:]` pins the rank to one. Keep the `# prik:` line, then run the cell: + +```ipython +%%pyi + +# prik: source-sha256= + +from prik.contracts import Arg, Float64, native_call + +@native_call([Arg(0).size, Arg(0)]) +def scale(values: Float64[:]) -> None: ... +``` + +Same C code, same compiler; only the Python API changed — `count` is gone: + +```python +values = np.array([1.0, 2.0, 3.0]) +scale(values) +assert np.allclose(values, [2.0, 4.0, 6.0]) +print(f"✅ scale(values) doubled in place: {values} (expected [2. 4. 6.])") +``` + +```text +✅ scale(values) doubled in place: [2. 4. 6.] (expected [2. 4. 6.]) +``` + +## Where to go next + +- [IPython and Jupyter Notebooks](../guide/notebooks.md) covers every magic, + its options, and the cell cache. +- [C Pointers, Arrays, and Strings](../guide/c/pointers-arrays-and-strings.md) + explains what `Float64[...]` accepts and how to narrow it. +- [Design a Pythonic BLAS API](pythonic-blas.md) applies the same contract + editing to a real library. diff --git a/examples/notebooks/quickstart.ipynb b/examples/notebooks/quickstart.ipynb new file mode 100644 index 000000000..a254743b6 --- /dev/null +++ b/examples/notebooks/quickstart.ipynb @@ -0,0 +1,280 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f69700b9", + "metadata": {}, + "source": [ + "# PRIK quickstart\n", + "\n", + "Compile Fortran and C in notebook cells and call them from Python.\n", + "\n", + "This notebook runs top to bottom. Every result is checked against what\n", + "the text says it should be, so a ✅ means the cell really did that." + ] + }, + { + "cell_type": "markdown", + "id": "ea638956", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "Colab needs a Fortran compiler and PRIK itself." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a446408a", + "metadata": { + "tags": [ + "prik-colab-setup" + ] + }, + "outputs": [], + "source": [ + "!apt-get -qq install -y gfortran > /dev/null\n", + "!pip install -q \"prik[jupyter] @ git+https://github.com/PyNumLab/prik.git\"" + ] + }, + { + "cell_type": "markdown", + "id": "cf97f2e1", + "metadata": {}, + "source": [ + "If pip upgraded NumPy, Colab will ask you to restart the runtime. Do that,\n", + "then continue from the next cell." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a68d174c", + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext prik.jupyter" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d9b016ec", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np" + ] + }, + { + "cell_type": "markdown", + "id": "b82f42f2", + "metadata": {}, + "source": [ + "## 1. Compile a Fortran cell\n", + "\n", + "`%%fortran` compiles the cell and publishes what it declares. A Fortran\n", + "module becomes a notebook name." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "205cf93b", + "metadata": {}, + "outputs": [], + "source": [ + "%%fortran\n", + "module geometry\n", + "contains\n", + " real(8) function circle_area(radius)\n", + " real(8), intent(in) :: radius\n", + " circle_area = 3.141592653589793d0 * radius**2\n", + " end function\n", + "end module\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "995ac839", + "metadata": {}, + "outputs": [], + "source": [ + "area = geometry.circle_area(np.float64(2.0))\n", + "assert np.isclose(area, np.pi * 4)\n", + "print(f\"✅ circle_area(2.0) = {area} (expected {np.pi * 4})\")" + ] + }, + { + "cell_type": "markdown", + "id": "134d3908", + "metadata": {}, + "source": [ + "## 2. Compile a C cell\n", + "\n", + "`%%c` publishes C functions directly. This one doubles an array in place,\n", + "and takes the element count the way C usually does." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b77b1866", + "metadata": {}, + "outputs": [], + "source": [ + "%%c\n", + "#include \n", + "\n", + "void scale(size_t count, double *values) {\n", + " for (size_t index = 0; index < count; ++index) {\n", + " values[index] *= 2.0;\n", + " }\n", + "}\n" + ] + }, + { + "cell_type": "markdown", + "id": "7e60efc9", + "metadata": {}, + "source": [ + "PRIK gave `double *values` a *runtime-rank* contract, so it accepts a NumPy\n", + "array of any rank and writes through it. The count still has to be passed\n", + "by hand, even though NumPy already knows it:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ddd15779", + "metadata": {}, + "outputs": [], + "source": [ + "values = np.array([1.0, 2.0, 3.0])\n", + "scale(np.uintp(values.size), values)\n", + "assert np.allclose(values, [2.0, 4.0, 6.0])\n", + "print(f\"✅ scale(count, values) doubled in place: {values} (expected [2. 4. 6.])\")" + ] + }, + { + "cell_type": "markdown", + "id": "6c639d2a", + "metadata": {}, + "source": [ + "## 3. Reshape the API with a contract\n", + "\n", + "`--pyi` compiles nothing. It keeps the source and hands back the semantic\n", + "contract it derived, as an editable cell.\n", + "\n", + "In Jupyter and Colab the cell appears below this one automatically. It is\n", + "already written out for you here so the notebook runs end to end." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cdb09a85", + "metadata": {}, + "outputs": [], + "source": [ + "%%c --pyi\n", + "#include \n", + "\n", + "void scale(size_t count, double *values) {\n", + " for (size_t index = 0; index < count; ++index) {\n", + " values[index] *= 2.0;\n", + " }\n", + "}\n" + ] + }, + { + "cell_type": "markdown", + "id": "818a7cfe", + "metadata": {}, + "source": [ + "The generated contract reads `def scale(count: UInt64, values: Float64[...])`.\n", + "Edit it so `count` comes from the array instead: `Arg(0).size` supplies it,\n", + "and `Float64[:]` pins the rank to one." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3955cb30", + "metadata": {}, + "outputs": [], + "source": [ + "%%pyi\n", + "\n", + "# prik: source-sha256=cbb931db2a66b26b4c8f1796b7fe1f93ec5c9af2e76fdacfb91c191a14055def\n", + "\n", + "from prik.contracts import Arg, Float64, native_call\n", + "\n", + "@native_call([Arg(0).size, Arg(0)])\n", + "def scale(values: Float64[:]) -> None: ...\n" + ] + }, + { + "cell_type": "markdown", + "id": "fbc473b2", + "metadata": {}, + "source": [ + "Same C code, same compiler. Only the Python API changed — `count` is gone:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1fd12b49", + "metadata": {}, + "outputs": [], + "source": [ + "values = np.array([1.0, 2.0, 3.0])\n", + "scale(values)\n", + "assert np.allclose(values, [2.0, 4.0, 6.0])\n", + "print(f\"✅ scale(values) doubled in place: {values} (expected [2. 4. 6.])\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "57814aab", + "metadata": {}, + "outputs": [], + "source": [ + "print(\"🎉 All checks passed.\")\n", + "print(\" Fortran and C both compiled in this notebook,\")\n", + "print(\" and the C API was reshaped by editing its contract.\")" + ] + }, + { + "cell_type": "markdown", + "id": "25f4b06f", + "metadata": {}, + "source": [ + "## Where to go next\n", + "\n", + "- [IPython and Jupyter Notebooks](https://pynumlab.github.io/prik/user/guide/notebooks/)\n", + " — every magic, option, and the cell cache\n", + "- [C Pointers, Arrays, and Strings](https://pynumlab.github.io/prik/user/guide/c/pointers-arrays-and-strings/)\n", + " — what `Float64[...]` accepts and how to narrow it\n", + "- [Design a Pythonic BLAS API](https://pynumlab.github.io/prik/user/tutorials/pythonic-blas/)\n", + " — the same contract editing, applied to a real library" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/mkdocs.yml b/mkdocs.yml index d693c45e9..886c6d550 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -73,6 +73,7 @@ nav: - Building the Shared Library: user/guide/building-shared-library.md - IPython and Jupyter Notebooks: user/guide/notebooks.md - Tutorials: + - Run PRIK in a Notebook: user/tutorials/notebook-quickstart.md - Design a Pythonic BLAS API: user/tutorials/pythonic-blas.md - Examples: - Overview: user/examples/index.md diff --git a/pyproject.toml b/pyproject.toml index 5fadbcd30..26900d727 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ dependencies = [ [project.optional-dependencies] jupyter = [ - "ipython>=8.0", + "ipython>=7.0", ] pretty = [ "rich>=13.7", @@ -64,10 +64,15 @@ docs = [ "pyperf==2.10.0", ] qa = [ + # The suite exercises the notebook magics, so it needs whatever the + # published extra installs. Naming it here keeps one IPython floor. + "prik[jupyter]", "bandit[toml]==1.9.4", "coverage[toml]>=7.10", "hypothesis>=6.100", - "ipython>=8.0", + "ipykernel>=6.29", + "nbclient>=0.10", + "nbformat>=5.10", "pytest>=8.0", "pytest-randomly>=3.15", "pyperf==2.10.0", @@ -155,6 +160,11 @@ select = [ fixable = ["ALL"] unfixable = [] +[tool.ruff.lint.per-file-ignores] +# A cell magic publishes its compiled names straight into the notebook +# namespace, so the cells that call them have no binding ruff can see. +"examples/notebooks/*.ipynb" = ["F821"] + [tool.ruff.lint.isort] known-first-party = ["prik"] diff --git a/tests/docs/test_notebooks.py b/tests/docs/test_notebooks.py new file mode 100644 index 000000000..ea2cf095a --- /dev/null +++ b/tests/docs/test_notebooks.py @@ -0,0 +1,112 @@ +"""Shipped example notebooks execute and keep the results they advertise. + +A notebook is the one documented artifact a reader runs unedited, and its +cells embed a digest of the exact source they were generated from. Executing +it here keeps both honest: a magic that stops working, or a source cell that +drifts away from its contract cell, fails the suite instead of the reader. +""" + +from __future__ import annotations + +import hashlib +import os +import shutil +from pathlib import Path + +import pytest + + +nbformat = pytest.importorskip("nbformat") +nbclient = pytest.importorskip("nbclient") + +NOTEBOOK_DIR = Path(__file__).parents[2] / "examples/notebooks" +# The Colab setup cell installs a toolchain and PRIK itself, which the suite +# already has; every other cell runs. +SETUP_TAG = "prik-colab-setup" + + +def _notebooks() -> list[Path]: + return sorted(NOTEBOOK_DIR.glob("*.ipynb")) + + +def test_example_notebooks_are_present(): + assert _notebooks(), f"No example notebooks found in {NOTEBOOK_DIR}" + + +@pytest.mark.parametrize("notebook_path", _notebooks(), ids=lambda path: path.stem) +def test_notebook_is_shipped_without_stored_output(notebook_path: Path): + """Stored output would go stale silently; the test is what proves the cells.""" + notebook = nbformat.read(notebook_path, as_version=4) + + stored = [index for index, cell in enumerate(notebook.cells) if cell.get("outputs")] + + assert not stored, f"{notebook_path.name} ships stored output in cells {stored}" + + +@pytest.mark.parametrize("notebook_path", _notebooks(), ids=lambda path: path.stem) +def test_editable_contract_cells_match_their_source_cell(notebook_path: Path): + """A `# prik:` digest must name a source cell that is still in the notebook.""" + notebook = nbformat.read(notebook_path, as_version=4) + available = { + hashlib.sha256(f"{language}{_magic_body(cell.source)}".encode()).hexdigest() + for cell in notebook.cells + for language in ("fortran", "c") + if cell.cell_type == "code" and cell.source.startswith(f"%%{language}") + } + + for index, cell in enumerate(notebook.cells): + for line in cell.source.splitlines(): + if not line.strip().startswith("# prik:"): + continue + digest = next( + (field.split("=", 1)[1] for field in line.split() if field.startswith("source-sha256=")), + None, + ) + assert digest in available, ( + f"{notebook_path.name} cell {index} names source-sha256={digest}, " + "which no source cell in the notebook produces" + ) + + +def _magic_body(source: str) -> str: + """Return the cell body a magic receives, without its own `%%` line.""" + _line, _, body = source.partition("\n") + return body + + +@pytest.mark.skipif(shutil.which("gfortran") is None, reason="requires gfortran") +@pytest.mark.skipif(shutil.which("cc") is None, reason="requires a C compiler") +@pytest.mark.parametrize("notebook_path", _notebooks(), ids=lambda path: path.stem) +def test_notebook_executes_and_publishes_its_documented_results(notebook_path: Path, tmp_path: Path): + notebook = nbformat.read(notebook_path, as_version=4) + notebook.cells = [cell for cell in notebook.cells if SETUP_TAG not in cell.get("metadata", {}).get("tags", [])] + + # An isolated cache makes the run prove a real compile rather than reuse + # whatever this machine built earlier. + environment = dict(os.environ, PRIK_CACHE_DIR=str(tmp_path / "cache")) + nbclient.NotebookClient( + notebook, + timeout=600, + kernel_name="python3", + resources={"metadata": {"path": str(tmp_path)}}, + ).execute(env=environment) + + printed = "\n".join(text for cell in notebook.cells for text in _cell_text(cell)) + + # Every documented result is asserted inside the notebook, so a passing run + # shows one tick per claim. + assert printed.count("\u2705") == 3, printed + assert "\U0001f389 All checks passed." in printed + # The same C routine, reached through a hand-written count and then through + # a contract that derives it. + assert printed.count("(expected [2. 4. 6.])") == 2, printed + + +def _cell_text(cell) -> list[str]: + texts = [] + for output in cell.get("outputs", []): + assert output.get("output_type") != "error", f"cell raised {output.get('ename')}: {output.get('evalue')}" + text = output.get("text") or (output.get("data") or {}).get("text/plain") + if text: + texts.append(text.strip()) + return texts diff --git a/tests/docs/test_publication.py b/tests/docs/test_publication.py index 83ce3c975..4e2745c36 100644 --- a/tests/docs/test_publication.py +++ b/tests/docs/test_publication.py @@ -111,3 +111,28 @@ def test_package_command_rejects_a_module_without_one_main_example(tmp_path: Pat "```bash\npython3 prik/component.py\n```", "developer/packages/component.md", ) + + +def test_example_notebooks_are_served_from_the_site(tmp_path: Path) -> None: + """A download button needs the notebook same-origin, not on GitHub. + + The browser honours ``download`` only for a same-origin file, so the site + serves a copy while the repository keeps the single source of truth. + """ + docs_dir = tmp_path / "docs" + notebook_dir = tmp_path / mkdocs_publication._EXAMPLE_NOTEBOOK_DIR + docs_dir.mkdir() + notebook_dir.mkdir(parents=True) + (notebook_dir / "quickstart.ipynb").write_text("{}", encoding="utf-8") + (notebook_dir / "notes.txt").write_text("not a notebook", encoding="utf-8") + + published = mkdocs_publication._example_notebook_paths({"docs_dir": str(docs_dir)}) + + assert [path.name for path in published] == ["quickstart.ipynb"] + + +def test_publishing_example_notebooks_tolerates_a_missing_directory(tmp_path: Path) -> None: + docs_dir = tmp_path / "docs" + docs_dir.mkdir() + + assert mkdocs_publication._example_notebook_paths({"docs_dir": str(docs_dir)}) == [] diff --git a/tools/mkdocs_publication.py b/tools/mkdocs_publication.py index e7dadc692..0562329e9 100644 --- a/tools/mkdocs_publication.py +++ b/tools/mkdocs_publication.py @@ -22,6 +22,10 @@ _PACKAGE_MAIN_COMMAND = re.compile(r"(?m)^```bash\npython3 (?Pprik/(?:[A-Za-z0-9_]+/)*[A-Za-z0-9_]+\.py)\n```$") _include_drafts = False +_config = None +# Runnable notebooks live with the other examples; the site serves a copy so a +# documentation page can hand the reader the file itself. +_EXAMPLE_NOTEBOOK_DIR = "examples/notebooks" _known_document_paths: set[str] = set() _published_paths: set[str] = set() _docs_dir = Path() @@ -241,7 +245,9 @@ def replace_command(match: re.Match[str]) -> str: def on_config(config, **_kwargs): """Load publication state and filter production navigation.""" - global _docs_dir, _include_drafts, _known_document_paths, _published_paths, _repository_url + global _config, _docs_dir, _include_drafts, _known_document_paths, _published_paths, _repository_url + + _config = config _include_drafts = os.getenv("PRIK_DOCS_INCLUDE_DRAFTS", "").strip().lower() in _TRUE_VALUES _docs_dir = Path(config["docs_dir"]) @@ -254,8 +260,37 @@ def on_config(config, **_kwargs): return config +def _example_notebook_paths(config) -> list[Path]: + """Return the runnable notebooks the site should serve alongside the pages.""" + source_dir = Path(config["docs_dir"]).parent / _EXAMPLE_NOTEBOOK_DIR + if not source_dir.is_dir(): + return [] + return sorted(source_dir.glob("*.ipynb")) + + +def _publish_example_notebooks(files, config) -> None: + """Serve the runnable example notebooks from the site itself. + + A documentation page can then offer the notebook as a download rather than + a view of its JSON: the browser honours ``download`` only for a same-origin + file, and the repository copy stays the single source of truth. + """ + from mkdocs.structure.files import File + + for notebook in _example_notebook_paths(config): + files.append( + File( + notebook.name, + str(notebook.parent), + str(Path(config["site_dir"]) / _EXAMPLE_NOTEBOOK_DIR), + use_directory_urls=False, + ) + ) + + def on_files(files, **_kwargs): """Remove unpublished Markdown files from production output and search.""" + _publish_example_notebooks(files, _config) if _include_drafts: return files