Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Pre-commit hook: keep notebook outputs clear and bootstrap cells in sync.

Activated once per machine via `git config core.hooksPath .githooks`
(see docs/notebooks/README.md for the full explanation). Fixes any affected
notebook in place and re-stages it, so the commit proceeds with the fix already
applied -- no interruption, no separate re-commit step.
"""

import subprocess
import sys
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent
NOTEBOOKS_DIR = REPO_ROOT / "docs" / "notebooks"


def staged_files(pattern: str) -> list[str]:
result = subprocess.run(
["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR", "--", pattern],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=True,
)
return [line for line in result.stdout.splitlines() if line]


def main() -> int:
if not NOTEBOOKS_DIR.exists():
return 0

staged_notebooks = staged_files("docs/notebooks/*.ipynb")
template_staged = bool(staged_files("docs/notebooks/_mvtb_nb_bootstrap.py"))

# Output-clearing only ever touches notebooks actually staged for this commit.
output_targets = staged_notebooks

# Bootstrap-cell regeneration also has to cover every notebook when the
# template itself changed -- otherwise the other notebooks go stale and CI
# fails on files this commit never touched.
if template_staged:
bootstrap_targets = sorted(
str(p.relative_to(REPO_ROOT)) for p in NOTEBOOKS_DIR.glob("*.ipynb")
)
else:
bootstrap_targets = staged_notebooks

if not output_targets and not bootstrap_targets:
return 0

changed_paths: set[str] = set()

try:
if output_targets:
paths = [str(REPO_ROOT / t) for t in output_targets]
subprocess.run(
[sys.executable, str(NOTEBOOKS_DIR / "clear_outputs.py"), *paths],
cwd=REPO_ROOT,
check=True,
)
changed_paths.update(paths)

if bootstrap_targets:
paths = [str(REPO_ROOT / t) for t in bootstrap_targets]
subprocess.run(
[sys.executable, str(NOTEBOOKS_DIR / "sync_bootstrap.py"), *paths],
cwd=REPO_ROOT,
check=True,
)
changed_paths.update(paths)
except subprocess.CalledProcessError:
print("pre-commit hook: notebook cleanup failed unexpectedly", file=sys.stderr)
return 1

if changed_paths:
subprocess.run(["git", "add", "--", *sorted(changed_paths)], cwd=REPO_ROOT, check=True)

return 0


if __name__ == "__main__":
sys.exit(main())
5 changes: 5 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ jobs:
with:
python-version: "3.12"

- name: Check notebook bootstrap cells and outputs are in sync
run: |
python docs/notebooks/sync_bootstrap.py --check
python docs/notebooks/clear_outputs.py --check

- name: Install system dependencies
run: sudo apt-get install -y graphviz

Expand Down
51 changes: 51 additions & 0 deletions docs/notebooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,55 @@ Moving beyond pixels to robust features and 3D reasoning.
<font size="2">Created by Peter Corke | QUT Centre for Robotics</font>
</p>

---

## Maintainer notes: how these notebooks install themselves

*(Written 2026-08 - only relevant if you're editing the install machinery itself.)*

Every notebook here has to work in three different environments, each of which needs the toolbox installed a different way:

- **Locally** (VS Code, `jupyter notebook`, `nbmake` in CI) — the toolbox is assumed already installed (dev env or `pip install`); nothing to do.
- **Google Colab** — Colab's "Open in Colab" link fetches *only that one `.ipynb` file* from GitHub, no sibling files, so the toolbox has to be `pip install`ed fresh into the Colab VM each session.
- **JupyterLite** (the in-browser "Try it Now" version published alongside the Sphinx docs) — runs on Pyodide/WASM, where packages install via `micropip` rather than `pip`, and the toolbox wheel is installed with `deps=False` (to stop micropip re-resolving compiled packages like numpy/scipy/opencv that Pyodide already provides its own WASM builds of) — which means every *other* runtime dependency (tqdm, requests, ...) has to be listed and installed explicitly.

**All three cases are handled by one file: `_mvtb_nb_bootstrap.py`.** It detects which environment it's in and does whatever that environment needs, ending with a one-line sanity check so a broken environment-detection is obvious at a glance rather than failing mysteriously three cells later:
```
Running locally using MVTB v2.2.0
Running on Colab using MVTB v2.2.0
Running in browser using MVTB v2.2.0
```

Every notebook's first code cell is a copy of that file's content, marked with a `# MVTB_BOOTSTRAP_CELL` comment. It's a *copy*, not an import — Colab can't see sibling files, so the cell has to be fully self-contained, the same way Colab notebooks in this space normally are (this is deliberate; a fancier design that fetched the shared code over the network at runtime was considered and rejected — it would have made every notebook depend on GitHub being reachable just to install itself, which is a worse failure mode than a bit of duplication).

**The copy is machine-generated, not hand-maintained**, which is the actual point: edit `_mvtb_nb_bootstrap.py` once, then either let the commit hook regenerate every notebook's marked cell for you automatically (silently, as part of `git commit`), or run it by hand:
```
python docs/notebooks/sync_bootstrap.py
```
A second hook also clears notebook outputs on the way in (`clear_notebook_outputs.sh`'s logic), so committed notebooks stay clean without having to remember that step either.

**Enforcement happens via a plain git hook, not the `pre-commit` framework.** We wanted here a fully mechanical, deterministic regeneration shouldn't need a manual re-commit every single time. So this repo uses a plain git hook instead, versioned at `.githooks/pre-commit`.

### One-time setup (per machine)

This is a *local* git setting — it doesn't come along when you clone the repo, and isn't shared by git config, so it needs doing once on every machine you commit from:
```
git config core.hooksPath .githooks
```
That's the only step — `.githooks/pre-commit` is already committed with its executable bit set (git tracks that), and it only needs the Python already on your `PATH` plus the stdlib (no extra `pip install`, no `pre-commit` package). To check it's active:
```
git config --get core.hooksPath # should print .githooks
```
Nothing is enforced on a machine where this hasn't been set — CI is the backstop for that case, not the primary mechanism.

The hook stays quiet when there's nothing to do, and only speaks up when it actually changes something:
```
docs/notebooks/gamma.ipynb: found output, clearing it
docs/notebooks/camera.ipynb: bootstrap cell out of date, regenerating
```
so an ordinary commit scrolls past without any noise, but one that got silently fixed for you still leaves a visible trace — a lightweight way to notice the safety net firing without it ever stopping you.

If a notebook's bootstrap cell falls out of sync anyway (hook not installed on this machine, notebook edited elsewhere), CI catches it on the PR — a job re-runs the generator in check-only mode across every notebook and fails if anything doesn't match.

**Why this exists at all:** before this, 13 of the 16 notebooks each had their own hand-pasted, slightly-drifted copy of the Colab-install snippet, and the JupyterLite demo notebook had its own bespoke version with a manually-maintained dependency list. That's exactly how machinevision-toolbox-python 2.2.0 shipped a broken "Try it Now" demo — `tqdm` was added as a real dependency but nobody updated the one notebook's hand-typed install list, and nothing tested it before release. One template, generated everywhere, checked by CI, closes that gap.

70 changes: 70 additions & 0 deletions docs/notebooks/_mvtb_nb_bootstrap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Environment bootstrap for machinevision-toolbox-python's Jupyter notebooks.

Installs the toolbox (and reports how) across the three environments a notebook in
this folder might run in: a local Jupyter/VS Code install, Google Colab, and
JupyterLite (Pyodide/WASM, in-browser).

This file is the single source of truth for that logic. Every notebook's own
bootstrap cell is a generated copy of this file's content, produced by
sync_bootstrap.py -- see docs/notebooks/README.md for the full explanation.
"""

import subprocess

Check notice on line 12 in docs/notebooks/_mvtb_nb_bootstrap.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

docs/notebooks/_mvtb_nb_bootstrap.py#L12

Consider possible security implications associated with the subprocess module.
import sys
from pathlib import Path


async def ensure_installed() -> bool:
"""Install machinevision-toolbox-python if needed, and report the environment.

:returns: True if running on Google Colab, False otherwise.
"""
if sys.platform == "emscripten":
import micropip

await micropip.install(
[
"opencv-python",
"spatialmath-python",
"pgraph-python",
"ansitable",
"mvtb-data",
"tqdm",
"requests",
]
)
import cv2 # noqa: F401 - force cv2 into module registry before toolbox import

wheels = sorted(Path("/pypi").glob("machinevision_toolbox_python-*.whl"))
if wheels:
# Prefer the wheel bundled with this JupyterLite site.
await micropip.install(wheels[-1].as_posix(), deps=False)
else:
# Fall back to PyPI when running outside the published site layout.
await micropip.install("machinevision-toolbox-python", deps=False)
where, colab = "in browser", False
else:
try:
import google.colab # noqa: F401
except ImportError:
where, colab = "locally", False
else:
print("Installing machinevision-toolbox-python...")
subprocess.run(

Check failure on line 53 in docs/notebooks/_mvtb_nb_bootstrap.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

docs/notebooks/_mvtb_nb_bootstrap.py#L53

Detected subprocess function 'run' without a static string.

Check warning on line 53 in docs/notebooks/_mvtb_nb_bootstrap.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

docs/notebooks/_mvtb_nb_bootstrap.py#L53

subprocess call - check for execution of untrusted input.
[
sys.executable,
"-m",
"pip",
"install",
"-q",
"machinevision-toolbox-python",
],
check=True,
)
where, colab = "on Colab", True

import machinevisiontoolbox

version = getattr(machinevisiontoolbox, "__version__", "unknown")
print(f"Running {where} using MVTB v{version}")
return colab
100 changes: 100 additions & 0 deletions docs/notebooks/clear_outputs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/env python
"""Clear cell outputs and execution counts from every notebook in this folder.

Pure stdlib (no dependency on jupyter/nbconvert being installed), so it can run as
a commit hook on any machine with just Python. Equivalent to
`jupyter nbconvert --ClearOutputPreprocessor.enabled=True --inplace`, which is what
clear_notebook_outputs.sh does for ad-hoc/manual use.

Usage:
python clear_outputs.py clear any notebook that has output
python clear_outputs.py --check report only, exit 1 if anything has output
python clear_outputs.py FILE ... only consider the given notebook(s)
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

HERE = Path(__file__).resolve().parent


def clear_cell(cell: dict) -> bool:
"""Clear one cell's outputs/execution count in place.

:returns: True if the cell was (or would be) changed.
"""
if cell.get("cell_type") != "code":
return False

changed = False
if cell.get("outputs"):
changed = True
cell["outputs"] = []
if cell.get("execution_count") is not None:
changed = True
cell["execution_count"] = None
if "execution" in cell.get("metadata", {}):
changed = True
del cell["metadata"]["execution"]

return changed


def process_notebook(path: Path, fix: bool) -> bool:
"""Check (and optionally clear) one notebook's outputs.

:returns: True if the notebook was (or would be) changed.
"""
with path.open("r", encoding="utf-8") as f:
nb = json.load(f)

changed = any(clear_cell(cell) for cell in nb.get("cells", []))

if changed and fix:
with path.open("w", encoding="utf-8") as f:
json.dump(nb, f, indent=1)
f.write("\n")

return changed


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--check",
action="store_true",
help="report notebooks with output without modifying them; exit 1 if any found",
)
parser.add_argument(
"files",
nargs="*",
type=Path,
help="only consider these notebooks (default: every *.ipynb in this folder)",
)
args = parser.parse_args()

fix = not args.check
notebooks = [p.resolve() for p in args.files] if args.files else sorted(HERE.glob("*.ipynb"))

any_changed = False
for nb_path in notebooks:
changed = process_notebook(nb_path, fix=fix)
if changed:
any_changed = True
rel = nb_path.relative_to(HERE.parent.parent)
if fix:
print(f"{rel}: found output, clearing it")
else:
print(f"{rel}: found output")

if args.check and any_changed:
return 1
return 0


if __name__ == "__main__":
sys.exit(main())
Loading