Run notebooks in CI

This commit is contained in:
Andras Schmelczer 2026-10-28 12:51:24 +00:00
commit 7c049388f6
8 changed files with 243 additions and 11 deletions

View file

@ -27,8 +27,8 @@ jobs:
run: uv python install 3.13 run: uv python install 3.13
- name: Install dependencies - name: Install dependencies
# --seed gives the venv its own pip, which scripts/check-python.sh shells # --seed gives the venv its own pip for mypy's automatic type-stub
# out to for its linters; '.[dev]' provides the rest (mypy resolves the # installation; '.[dev]' provides the rest (mypy resolves the
# package's own imports against the installed tree). # package's own imports against the installed tree).
run: | run: |
uv venv --seed --python 3.13 uv venv --seed --python 3.13
@ -39,6 +39,35 @@ jobs:
. .venv/bin/activate . .venv/bin/activate
scripts/check-python.sh great_ai tests scripts/check-python.sh great_ai tests
notebooks:
name: Notebooks on Python ${{ matrix.python-version }}
runs-on: docker
strategy:
fail-fast: false
matrix:
python-version: ['3.12', '3.13']
steps:
- uses: actions/checkout@v4
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}
- name: Install notebook dependencies
run: |
uv venv --python ${{ matrix.python-version }}
uv pip install '.[notebooks]'
- name: Execute every notebook in a fresh workspace
env:
HF_HUB_OFFLINE: '1'
HF_DATASETS_OFFLINE: '1'
run: .venv/bin/python scripts/run-notebooks.py
test: test:
name: Test on Python ${{ matrix.python-version }} name: Test on Python ${{ matrix.python-version }}
runs-on: docker runs-on: docker

View file

@ -1,6 +1,8 @@
# syntax=docker/dockerfile:1.4 # syntax=docker/dockerfile:1.4
FROM python:3.13-slim-trixie FROM python:3.13-slim-trixie
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
LABEL org.opencontainers.image.title="GreatAI package wrapper image" LABEL org.opencontainers.image.title="GreatAI package wrapper image"
LABEL org.opencontainers.image.vendor="ScoutinScience B.V." LABEL org.opencontainers.image.vendor="ScoutinScience B.V."
LABEL org.opencontainers.image.authors="andras@schmelczer.dev" LABEL org.opencontainers.image.authors="andras@schmelczer.dev"
@ -18,8 +20,7 @@ RUN DEBIAN_FRONTEND=noninteractive apt update &&\
WORKDIR /dependencies WORKDIR /dependencies
COPY . great_ai COPY . great_ai
RUN python3 -m pip --no-cache-dir install --upgrade pip &&\ RUN uv pip install --system --no-cache ./great_ai &&\
pip install --no-cache-dir ./great_ai &&\
rm -rf great_ai rm -rf great_ai
HEALTHCHECK \ HEALTHCHECK \

View file

@ -13,8 +13,12 @@ Applying AI is becoming increasingly more accessible, but many case studies have
## Example ## Example
With [uv](https://docs.astral.sh/uv/getting-started/installation/) installed, create and activate a Python environment:
```sh ```sh
pip install great-ai uv venv --python 3.13
source .venv/bin/activate
uv pip install great-ai
``` ```
Create a new file called `demo.py` Create a new file called `demo.py`
@ -62,9 +66,10 @@ However, [research indicates](https://great-ai.scoutinscience.com) that professi
## Find `great-ai` on [PyPI](https://pypi.org/project/great-ai/) ## Find `great-ai` on [PyPI](https://pypi.org/project/great-ai/)
Requires Python 3.12 or later, including Python 3.13. Requires Python 3.12 or later, including Python 3.13.
With uv installed and your virtual environment active:
```sh ```sh
pip install great-ai uv pip install great-ai
``` ```
## Find `great-ai` on [DockerHub](https://hub.docker.com/repository/docker/schmelczera/great-ai) ## Find `great-ai` on [DockerHub](https://hub.docker.com/repository/docker/schmelczera/great-ai)
@ -80,10 +85,9 @@ Contributions are welcome.
### Install for development ### Install for development
```sh ```sh
python3 -m venv --copies .env uv venv --seed --python 3.13 .env
source .env/bin/activate source .env/bin/activate
pip install --upgrade pip uv pip install --editable '.[dev]'
pip install --editable '.[dev]'
``` ```
### Develop ### Develop
@ -105,3 +109,15 @@ mkdocs serve
``` ```
> Serve documentation. > Serve documentation.
### Release
With `git`, `python3`, and `uv` installed, run from a clean `main` checkout:
```sh
scripts/release.sh 0.2.0
```
This bumps the version, builds, commits, tags, and pushes `main` and the tag
atomically. The tag triggers the PyPI and DockerHub workflows, which require
`PYPI_TOKEN`, `DOCKER_USERNAME`, and `DOCKER_PASSWORD` in Forgejo repository secrets.

61
docs/notebook_support.py Normal file
View file

@ -0,0 +1,61 @@
"""Shared local paths and small datasets for the documentation notebooks."""
import json
import os
from collections import Counter
from pathlib import Path
from typing import List, Optional
def setup_notebook(root: Path, workflow: str) -> Path:
"""Keep related notebooks' models and traces together, outside source files."""
from great_ai import ParallelTinyDbDriver, configure
from great_ai.large_file import LargeFileLocal
base = Path(os.environ.get("GREAT_AI_NOTEBOOK_WORKDIR", root / ".cache/notebooks"))
suffix = "-full" if os.environ.get("GREAT_AI_NOTEBOOK_FULL") == "1" else ""
work_dir = (base / (workflow + suffix)).resolve()
work_dir.mkdir(parents=True, exist_ok=True)
LargeFileLocal.cache_path = work_dir / "models-cache"
ParallelTinyDbDriver.path_to_db = work_dir / "tracing_database.json"
configure(
large_file_implementation=LargeFileLocal,
tracing_database_factory=ParallelTinyDbDriver,
disable_se4ml_banner=True,
seed=42,
)
print(f"Notebook artifacts: {work_dir}")
return work_dir
def mag_lines(path: Path, per_label: Optional[int] = None) -> List[str]:
"""Read the bundled MAG data, optionally limiting each class for a quick run."""
counts: Counter = Counter()
lines = []
with path.open(encoding="utf-8") as stream:
for line in stream:
label = json.loads(line)["label"]
if per_label is None or counts[label] < per_label:
counts[label] += 1
lines.append(line)
return lines
def demo_annotations() -> List[dict]:
"""Synthetic annotations for exercising the pipeline, not measuring quality."""
annotations = [{}, {}]
for index, topic in enumerate(
["protein", "cancer", "solar", "quantum", "neural", "genome"]
):
for sample in range(10):
positive = sample % 2
sentence = (
f"Our {topic} method improves prediction accuracy in experiment {sample}."
if positive
else f"The {topic} dataset contains observations from experiment {sample}."
)
annotations[0][sentence] = positive
annotations[1][sentence] = (
1 - positive if (index * 10 + sample) % 11 == 0 else positive
)
return annotations

View file

@ -35,7 +35,7 @@
# #
# - LGTM looks good: https://lgtm.com/projects/g/phfaist/pylatexenc/ # - LGTM looks good: https://lgtm.com/projects/g/phfaist/pylatexenc/
# #
# - python package creation works: (python setup.py sdist, pip install # - python package creation works: (python setup.py sdist, uv pip install
# dist/pylatexenc-xxx.tar.gz) # dist/pylatexenc-xxx.tar.gz)
# #
# 2) update change log (doc/changes.rst) # 2) update change log (doc/changes.rst)

View file

@ -53,6 +53,17 @@ dependencies = [
] ]
[project.optional-dependencies] [project.optional-dependencies]
notebooks = [
"jupyterlab >= 4, < 5",
"ipykernel >= 6, < 8",
"ipywidgets >= 8, < 9",
"nbclient >= 0.10, < 1",
"transformers >= 5.21, < 6",
"datasets >= 5.1, < 6",
"torch >= 2.6, < 3",
"accelerate >= 1, < 2",
"pingouin >= 0.5, < 1",
]
dev = [ dev = [
"flit >= 4.1.0, < 5", "flit >= 4.1.0, < 5",
"mkdocs >= 1.6.1, < 2", "mkdocs >= 1.6.1, < 2",

View file

@ -3,7 +3,7 @@
set -e set -e
echo "Installing dependencies if necessary" echo "Installing dependencies if necessary"
python3 -m pip install autoflake isort black[jupyter] mypy flake8 uv pip install --python "$(command -v python3)" autoflake isort 'black[jupyter]' mypy flake8
for dir in "$@"; do for dir in "$@"; do
echo "Checking $dir" echo "Checking $dir"

114
scripts/run-notebooks.py Normal file
View file

@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Execute documentation notebooks in order, with the current Python interpreter."""
import argparse
import os
import subprocess
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
import nbformat
from jupyter_client import KernelManager
from nbclient import NotebookClient
ROOT = Path(__file__).resolve().parents[1]
NOTEBOOKS = [
"docs/tutorial/train.ipynb",
"docs/tutorial/deploy.ipynb",
"docs/examples/simple/data.ipynb",
"docs/examples/simple/train.ipynb",
"docs/examples/simple/deploy.ipynb",
"docs/examples/simple/stacked-bars.ipynb",
"docs/examples/simple-mag/train.ipynb",
"docs/examples/scibert/data.ipynb",
"docs/examples/scibert/train.ipynb",
"docs/examples/scibert/deploy.ipynb",
"docs/surveys/surveys.ipynb",
]
CHECK_DEPLOYMENT = """
import sys
from importlib import import_module
from pathlib import Path
from fastapi.testclient import TestClient
from great_ai.__main__ import find_app, get_script_name, get_script_name_of_notebook
notebook = sys.argv[1]
try:
module_name = get_script_name(notebook)
app_name = find_app(module_name)
assert app_name is not None, "The CLI could not import the deployment notebook"
module = import_module(module_name)
function_name = app_name.split(":")[1].split(".")[0]
service = getattr(module, function_name)
argument = "text" if sys.argv[2] == "simple" else "sentence"
with TestClient(service.app) as client:
response = client.post("/predict", json={argument: "Neural networks learn useful features."})
response.raise_for_status()
assert response.json()["output"] is not None
finally:
Path(get_script_name_of_notebook(notebook)).unlink(missing_ok=True)
"""
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("notebooks", nargs="*", help="Paths relative to the repository")
parser.add_argument("--timeout", type=int, default=600, help="Seconds per cell")
args = parser.parse_args()
output_dir = (
ROOT
/ ".cache/executed-notebooks"
/ f"python{sys.version_info.major}.{sys.version_info.minor}"
)
output_dir.mkdir(parents=True, exist_ok=True)
# A fresh workspace detects missing dependencies between notebook stages and
# avoids silently passing because an old model or database happens to exist.
with TemporaryDirectory(prefix="great-ai-notebooks-") as workspace:
env = dict(os.environ, GREAT_AI_NOTEBOOK_WORKDIR=workspace)
for name in args.notebooks or NOTEBOOKS:
path = (ROOT / name).resolve()
relative = path.relative_to(ROOT)
print(f"Running {relative}", flush=True)
notebook = nbformat.read(path, as_version=4)
nbformat.validate(notebook)
manager = KernelManager(kernel_name="python3")
# Do not depend on a user-installed kernelspec pointing at this venv.
manager.kernel_spec.argv = [
sys.executable,
"-m",
"ipykernel_launcher",
"-f",
"{connection_file}",
]
client = NotebookClient(notebook, km=manager, timeout=args.timeout)
try:
client.execute(cwd=str(path.parent), env=env, cleanup_kc=True)
finally:
destination = output_dir / relative
destination.parent.mkdir(parents=True, exist_ok=True)
nbformat.write(notebook, destination)
if path.stem == "deploy":
subprocess.run(
[
sys.executable,
"-c",
CHECK_DEPLOYMENT,
str(destination),
path.parent.name,
],
cwd=ROOT,
env=env,
check=True,
timeout=args.timeout,
stdout=subprocess.DEVNULL,
)
print(f"Passed {relative}", flush=True)
print(f"Executed notebooks saved to {output_dir}", flush=True)
if __name__ == "__main__":
main()