Run notebooks in CI
This commit is contained in:
parent
37b4c17f59
commit
7c049388f6
8 changed files with 243 additions and 11 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
26
README.md
26
README.md
|
|
@ -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
61
docs/notebook_support.py
Normal 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
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
114
scripts/run-notebooks.py
Normal 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()
|
||||||
Loading…
Reference in a new issue