From a0524bd091873795184897e1541edca9fbabb8dd Mon Sep 17 00:00:00 2001
From: said
Date: Mon, 31 Aug 2026 07:05:23 +0100
Subject: [PATCH 1/3] add google colab example
---
.mkdocs/theme/breadcrumbs.html | 11 +
CHANGELOG.md | 6 +
README.md | 11 +-
docs/index.md | 9 +
docs/stylesheets/site.css | 89 ++++++-
docs/user/getting-started/index.md | 3 +
docs/user/tutorials/notebook-quickstart.md | 151 ++++++++++++
examples/notebooks/quickstart.ipynb | 256 +++++++++++++++++++++
mkdocs.yml | 1 +
pyproject.toml | 8 +
tests/docs/test_notebooks.py | 108 +++++++++
tests/docs/test_publication.py | 36 +++
tools/mkdocs_publication.py | 32 ++-
13 files changed, 718 insertions(+), 3 deletions(-)
create mode 100644 docs/user/tutorials/notebook-quickstart.md
create mode 100644 examples/notebooks/quickstart.ipynb
create mode 100644 tests/docs/test_notebooks.py
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..49a4f32c2
--- /dev/null
+++ b/docs/user/tutorials/notebook-quickstart.md
@@ -0,0 +1,151 @@
+---
+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
+import numpy as np
+
+geometry.circle_area(np.float64(2.0))
+```
+
+```text
+np.float64(12.566370614359172)
+```
+
+## 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)
+values
+```
+
+```text
+array([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:
+
+```python
+values = np.array([1.0, 2.0, 3.0])
+scale(values)
+values
+```
+
+```text
+array([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..cd3949858
--- /dev/null
+++ b/examples/notebooks/quickstart.ipynb
@@ -0,0 +1,256 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "8f031571",
+ "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. It builds real CPython extension modules,\n",
+ "so it needs a compiler; the setup cell installs one."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "8102a504",
+ "metadata": {},
+ "source": [
+ "## Setup\n",
+ "\n",
+ "Colab needs a Fortran compiler and PRIK itself."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "911ce631",
+ "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": "5ebd172b",
+ "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": "87aa276c",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "%load_ext prik.jupyter"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "a149e67e",
+ "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": "6df11b50",
+ "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": "5a54aeca",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import numpy as np\n",
+ "\n",
+ "geometry.circle_area(np.float64(2.0))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "60adb9ac",
+ "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": "b073b048",
+ "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": "67f1b4f5",
+ "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": "9acd1fb0",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "values = np.array([1.0, 2.0, 3.0])\n",
+ "scale(np.uintp(values.size), values)\n",
+ "values"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "d3575bd1",
+ "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": "178bd0cd",
+ "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": "9ff5ebd7",
+ "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": "c19d2d4d",
+ "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": "216aa94c",
+ "metadata": {},
+ "source": [
+ "Same C code, same compiler. Only the Python API changed:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "bc94f426",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "values = np.array([1.0, 2.0, 3.0])\n",
+ "scale(values)\n",
+ "values"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "80e84c86",
+ "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..30ea21a8f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -67,7 +67,10 @@ qa = [
"bandit[toml]==1.9.4",
"coverage[toml]>=7.10",
"hypothesis>=6.100",
+ "ipykernel>=6.29",
"ipython>=8.0",
+ "nbclient>=0.10",
+ "nbformat>=5.10",
"pytest>=8.0",
"pytest-randomly>=3.15",
"pyperf==2.10.0",
@@ -155,6 +158,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..57b45b4f8
--- /dev/null
+++ b/tests/docs/test_notebooks.py
@@ -0,0 +1,108 @@
+"""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)
+
+ results = [text for cell in notebook.cells for text in _cell_text(cell)]
+ # The notebook's whole point: the same C routine, reached through a
+ # hand-written count and then through a contract that derives it.
+ assert results.count("array([2., 4., 6.])") == 2
+ assert any("12.56637" in text for text in results)
+
+
+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..b55798a7e 100644
--- a/tests/docs/test_publication.py
+++ b/tests/docs/test_publication.py
@@ -111,3 +111,39 @@ 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")
+ files: list[object] = []
+
+ mkdocs_publication._publish_example_notebooks(
+ files,
+ {"docs_dir": str(docs_dir), "site_dir": str(tmp_path / "site")},
+ )
+
+ assert [file.src_uri for file in files] == ["quickstart.ipynb"]
+ assert Path(files[0].abs_dest_path).name == "quickstart.ipynb"
+
+
+def test_publishing_example_notebooks_tolerates_a_missing_directory(tmp_path: Path) -> None:
+ docs_dir = tmp_path / "docs"
+ docs_dir.mkdir()
+ files: list[object] = []
+
+ mkdocs_publication._publish_example_notebooks(
+ files,
+ {"docs_dir": str(docs_dir), "site_dir": str(tmp_path / "site")},
+ )
+
+ assert files == []
diff --git a/tools/mkdocs_publication.py b/tools/mkdocs_publication.py
index e7dadc692..e9b58274c 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,32 @@ def on_config(config, **_kwargs):
return config
+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
+
+ source_dir = Path(config["docs_dir"]).parent / _EXAMPLE_NOTEBOOK_DIR
+ if not source_dir.is_dir():
+ return
+ for notebook in sorted(source_dir.glob("*.ipynb")):
+ files.append(
+ File(
+ notebook.name,
+ str(source_dir),
+ 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
From d80f821285c9a36fc6716690d498d96d1cce9065 Mon Sep 17 00:00:00 2001
From: said
Date: Mon, 31 Aug 2026 08:43:52 +0100
Subject: [PATCH 2/3] improve the note-book example
---
docs/user/tutorials/notebook-quickstart.md | 23 ++++---
examples/notebooks/quickstart.ipynb | 78 ++++++++++++++--------
tests/docs/test_notebooks.py | 14 ++--
3 files changed, 74 insertions(+), 41 deletions(-)
diff --git a/docs/user/tutorials/notebook-quickstart.md b/docs/user/tutorials/notebook-quickstart.md
index 49a4f32c2..448106834 100644
--- a/docs/user/tutorials/notebook-quickstart.md
+++ b/docs/user/tutorials/notebook-quickstart.md
@@ -45,15 +45,18 @@ end module
```
```python
-import numpy as np
-
-geometry.circle_area(np.float64(2.0))
+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
-np.float64(12.566370614359172)
+✅ 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
@@ -77,11 +80,12 @@ though NumPy already knows it:
```python
values = np.array([1.0, 2.0, 3.0])
scale(np.uintp(values.size), values)
-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
-array([2., 4., 6.])
+✅ scale(count, values) doubled in place: [2. 4. 6.] (expected [2. 4. 6.])
```
## 4. Reshape the API with a contract
@@ -129,16 +133,17 @@ from prik.contracts import Arg, Float64, native_call
def scale(values: Float64[:]) -> None: ...
```
-Same C code, same compiler; only the Python API changed:
+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)
-values
+assert np.allclose(values, [2.0, 4.0, 6.0])
+print(f"✅ scale(values) doubled in place: {values} (expected [2. 4. 6.])")
```
```text
-array([2., 4., 6.])
+✅ scale(values) doubled in place: [2. 4. 6.] (expected [2. 4. 6.])
```
## Where to go next
diff --git a/examples/notebooks/quickstart.ipynb b/examples/notebooks/quickstart.ipynb
index cd3949858..a254743b6 100644
--- a/examples/notebooks/quickstart.ipynb
+++ b/examples/notebooks/quickstart.ipynb
@@ -2,20 +2,20 @@
"cells": [
{
"cell_type": "markdown",
- "id": "8f031571",
+ "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. It builds real CPython extension modules,\n",
- "so it needs a compiler; the setup cell installs one."
+ "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": "8102a504",
+ "id": "ea638956",
"metadata": {},
"source": [
"## Setup\n",
@@ -26,7 +26,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "911ce631",
+ "id": "a446408a",
"metadata": {
"tags": [
"prik-colab-setup"
@@ -40,7 +40,7 @@
},
{
"cell_type": "markdown",
- "id": "5ebd172b",
+ "id": "cf97f2e1",
"metadata": {},
"source": [
"If pip upgraded NumPy, Colab will ask you to restart the runtime. Do that,\n",
@@ -50,16 +50,26 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "87aa276c",
+ "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": "a149e67e",
+ "id": "b82f42f2",
"metadata": {},
"source": [
"## 1. Compile a Fortran cell\n",
@@ -71,7 +81,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "6df11b50",
+ "id": "205cf93b",
"metadata": {},
"outputs": [],
"source": [
@@ -88,18 +98,18 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "5a54aeca",
+ "id": "995ac839",
"metadata": {},
"outputs": [],
"source": [
- "import numpy as np\n",
- "\n",
- "geometry.circle_area(np.float64(2.0))"
+ "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": "60adb9ac",
+ "id": "134d3908",
"metadata": {},
"source": [
"## 2. Compile a C cell\n",
@@ -111,7 +121,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "b073b048",
+ "id": "b77b1866",
"metadata": {},
"outputs": [],
"source": [
@@ -127,7 +137,7 @@
},
{
"cell_type": "markdown",
- "id": "67f1b4f5",
+ "id": "7e60efc9",
"metadata": {},
"source": [
"PRIK gave `double *values` a *runtime-rank* contract, so it accepts a NumPy\n",
@@ -138,18 +148,19 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "9acd1fb0",
+ "id": "ddd15779",
"metadata": {},
"outputs": [],
"source": [
"values = np.array([1.0, 2.0, 3.0])\n",
"scale(np.uintp(values.size), values)\n",
- "values"
+ "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": "d3575bd1",
+ "id": "6c639d2a",
"metadata": {},
"source": [
"## 3. Reshape the API with a contract\n",
@@ -164,7 +175,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "178bd0cd",
+ "id": "cdb09a85",
"metadata": {},
"outputs": [],
"source": [
@@ -180,7 +191,7 @@
},
{
"cell_type": "markdown",
- "id": "9ff5ebd7",
+ "id": "818a7cfe",
"metadata": {},
"source": [
"The generated contract reads `def scale(count: UInt64, values: Float64[...])`.\n",
@@ -191,7 +202,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "c19d2d4d",
+ "id": "3955cb30",
"metadata": {},
"outputs": [],
"source": [
@@ -207,27 +218,40 @@
},
{
"cell_type": "markdown",
- "id": "216aa94c",
+ "id": "fbc473b2",
"metadata": {},
"source": [
- "Same C code, same compiler. Only the Python API changed:"
+ "Same C code, same compiler. Only the Python API changed — `count` is gone:"
]
},
{
"cell_type": "code",
"execution_count": null,
- "id": "bc94f426",
+ "id": "1fd12b49",
"metadata": {},
"outputs": [],
"source": [
"values = np.array([1.0, 2.0, 3.0])\n",
"scale(values)\n",
- "values"
+ "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": "80e84c86",
+ "id": "25f4b06f",
"metadata": {},
"source": [
"## Where to go next\n",
diff --git a/tests/docs/test_notebooks.py b/tests/docs/test_notebooks.py
index 57b45b4f8..ea2cf095a 100644
--- a/tests/docs/test_notebooks.py
+++ b/tests/docs/test_notebooks.py
@@ -91,11 +91,15 @@ def test_notebook_executes_and_publishes_its_documented_results(notebook_path: P
resources={"metadata": {"path": str(tmp_path)}},
).execute(env=environment)
- results = [text for cell in notebook.cells for text in _cell_text(cell)]
- # The notebook's whole point: the same C routine, reached through a
- # hand-written count and then through a contract that derives it.
- assert results.count("array([2., 4., 6.])") == 2
- assert any("12.56637" in text for text in results)
+ 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]:
From 9fbca2f830c3c20d972991371e517228da91b049 Mon Sep 17 00:00:00 2001
From: said
Date: Mon, 31 Aug 2026 09:18:59 +0100
Subject: [PATCH 3/3] fix ipython version issue and remove mkdocs importation
from tests
---
CHANGELOG.md | 7 +++++++
pyproject.toml | 6 ++++--
tests/docs/test_publication.py | 17 +++--------------
tools/mkdocs_publication.py | 15 ++++++++++-----
4 files changed, 24 insertions(+), 21 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1ea7caafe..aebcb7fc9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,13 @@ release tags add a leading `v` to the package version.
## Unreleased
+- The `jupyter` extra now accepts IPython 7.0 and newer instead of requiring
+ 8.0. The cell magics use only long-stable IPython APIs, and the higher floor
+ made `pip install prik[jupyter]` upgrade the IPython that hosted notebook
+ environments ship, which forced a runtime restart for no benefit. The `qa`
+ extra installs `prik[jupyter]` rather than repeating that requirement, so
+ the supported IPython range is stated once.
+
- Added a runnable `examples/notebooks/quickstart.ipynb` and its guided
tutorial, covering a Fortran cell, a C cell, and reshaping the generated API
by editing its semantic contract in the same session. The home page, Getting
diff --git a/pyproject.toml b/pyproject.toml
index 30ea21a8f..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,11 +64,13 @@ 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",
"ipykernel>=6.29",
- "ipython>=8.0",
"nbclient>=0.10",
"nbformat>=5.10",
"pytest>=8.0",
diff --git a/tests/docs/test_publication.py b/tests/docs/test_publication.py
index b55798a7e..4e2745c36 100644
--- a/tests/docs/test_publication.py
+++ b/tests/docs/test_publication.py
@@ -125,25 +125,14 @@ def test_example_notebooks_are_served_from_the_site(tmp_path: Path) -> None:
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")
- files: list[object] = []
- mkdocs_publication._publish_example_notebooks(
- files,
- {"docs_dir": str(docs_dir), "site_dir": str(tmp_path / "site")},
- )
+ published = mkdocs_publication._example_notebook_paths({"docs_dir": str(docs_dir)})
- assert [file.src_uri for file in files] == ["quickstart.ipynb"]
- assert Path(files[0].abs_dest_path).name == "quickstart.ipynb"
+ 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()
- files: list[object] = []
-
- mkdocs_publication._publish_example_notebooks(
- files,
- {"docs_dir": str(docs_dir), "site_dir": str(tmp_path / "site")},
- )
- assert files == []
+ assert mkdocs_publication._example_notebook_paths({"docs_dir": str(docs_dir)}) == []
diff --git a/tools/mkdocs_publication.py b/tools/mkdocs_publication.py
index e9b58274c..0562329e9 100644
--- a/tools/mkdocs_publication.py
+++ b/tools/mkdocs_publication.py
@@ -260,6 +260,14 @@ 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.
@@ -269,14 +277,11 @@ def _publish_example_notebooks(files, config) -> None:
"""
from mkdocs.structure.files import File
- source_dir = Path(config["docs_dir"]).parent / _EXAMPLE_NOTEBOOK_DIR
- if not source_dir.is_dir():
- return
- for notebook in sorted(source_dir.glob("*.ipynb")):
+ for notebook in _example_notebook_paths(config):
files.append(
File(
notebook.name,
- str(source_dir),
+ str(notebook.parent),
str(Path(config["site_dir"]) / _EXAMPLE_NOTEBOOK_DIR),
use_directory_urls=False,
)