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 @@
{{ page.title }}
{%- 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