Skip to content
Open
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
47 changes: 47 additions & 0 deletions .github/workflows/notebooks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Notebook Tests

on:
push:
branches: [main]
paths:
- "docs/examples/**"
- "xarray_subset_grid/**"
- "tests/test_notebooks.py"
- "scripts/run_notebooks.py"
- ".github/workflows/notebooks.yml"
pull_request:
branches: [main]

jobs:
run-notebooks:
name: Run example notebooks (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12"]

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install package and notebook dependencies
run: |
python -m pip install --upgrade pip
pip install -e .
pip install pytest pytest-cov nbconvert jupyter matplotlib ipykernel
pip install fsspec s3fs h5netcdf zarr
python -m ipykernel install --user --name xsg-venv

- name: Run offline notebook tests via pytest
run: |
pytest tests/test_notebooks.py -v -k "offline"

- name: Run offline notebooks via standalone script
run: |
python scripts/run_notebooks.py
74 changes: 74 additions & 0 deletions docs/examples/local_subset_example.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "915b3101",
"metadata": {},
"source": [
"# Local Subset Example\n",
"\n",
"Demonstrates subsetting using a bundled local NetCDF file.\n",
"No network access required."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "ab62940a",
"metadata": {},
"outputs": [],
"source": [
"import xarray as xr\n",
"import numpy as np\n",
"from pathlib import Path\n",
"import xarray_subset_grid # noqa: F401\n",
"\n",
"data_path = Path('../../tests/example_data') / 'AMSEAS-subset.nc'\n",
"ds = xr.open_dataset(data_path)\n",
"print('Dataset loaded:', ds)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "9cd58ce9",
"metadata": {},
"outputs": [],
"source": [
"print('Grid type:', ds.xsg.grid)\n",
"print('Grid vars:', ds.xsg.grid_vars)\n",
"print('Data vars:', ds.xsg.data_vars)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "6c79afb7",
"metadata": {},
"outputs": [],
"source": [
"lats = ds.cf['latitude'].values\n",
"lons = ds.cf['longitude'].values\n",
"lat_mid = (float(lats.min()) + float(lats.max())) / 2\n",
"lon_mid = (float(lons.min()) + float(lons.max())) / 2\n",
"bbox = (float(lons.min()), float(lats.min()), lon_mid, lat_mid)\n",
"ds_sub = ds.xsg.subset_bbox(bbox)\n",
"print('Subsetted dataset:', ds_sub)\n",
"print('Done.')"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11.0"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ dependencies = [
]

optional-dependencies.dev = [
"ipykernel",
"jupyter",
"nbconvert",
"pre-commit",
"pytest",
"pytest-cov",
Expand Down
51 changes: 51 additions & 0 deletions scripts/create_notebook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import nbformat as nbf

nb = nbf.v4.new_notebook()

# Proper kernel metadata so nbconvert knows to write .py not .txt
nb.metadata = {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3",
},
"language_info": {
"name": "python",
"version": "3.11.0",
},
}

nb.cells = [
nbf.v4.new_markdown_cell(
"# Local Subset Example\n\n"
"Demonstrates subsetting using a bundled local NetCDF file.\n"
"No network access required."
),
nbf.v4.new_code_cell(
"import xarray as xr\n"
"import numpy as np\n"
"from pathlib import Path\n"
"import xarray_subset_grid # noqa: F401\n\n"
"data_path = Path('../../tests/example_data') / 'AMSEAS-subset.nc'\n"
"ds = xr.open_dataset(data_path)\n"
"print('Dataset loaded:', ds)"
),
nbf.v4.new_code_cell(
"print('Grid type:', ds.xsg.grid)\n"
"print('Grid vars:', ds.xsg.grid_vars)\n"
"print('Data vars:', ds.xsg.data_vars)"
),
nbf.v4.new_code_cell(
"lats = ds.cf['latitude'].values\n"
"lons = ds.cf['longitude'].values\n"
"lat_mid = (float(lats.min()) + float(lats.max())) / 2\n"
"lon_mid = (float(lons.min()) + float(lons.max())) / 2\n"
"bbox = (float(lons.min()), float(lats.min()), lon_mid, lat_mid)\n"
"ds_sub = ds.xsg.subset_bbox(bbox)\n"
"print('Subsetted dataset:', ds_sub)\n"
"print('Done.')"
),
]

nbf.write(nb, "docs/examples/local_subset_example.ipynb")
print("Notebook created successfully.")
182 changes: 182 additions & 0 deletions scripts/run_notebooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
#!/usr/bin/env python
"""
Custom runner script for xarray-subset-grid example notebooks.

Finds all example notebooks in docs/examples/, converts each to a Python
script using nbconvert (as suggested in issue #106), runs the script in a
subprocess, and produces a pass/fail summary report.

Usage
-----
Run offline notebooks only (default):
python scripts/run_notebooks.py

Run all notebooks including those that need network access:
python scripts/run_notebooks.py --online
"""
import argparse
import os
import subprocess
import sys
import tempfile
import time
from pathlib import Path

EXAMPLES_DIR = Path(__file__).parent.parent / "docs" / "examples"

# Notebooks that use only local data from docs/examples/example_data/
# These can run without any network access.
OFFLINE_NOTEBOOKS = [
"local_subset_example.ipynb",
]

# Notebooks that require network access (OPeNDAP, THREDDS, S3, AWS).
# Only run these when --online is passed.
ONLINE_NOTEBOOKS = [
"regular_grid_2d.ipynb",
"RegularGridTHREDDS.ipynb",
"fvcom.ipynb",
"fvcom_3d.ipynb",
"gfs_opendap.ipynb",
"nam_opendap.ipynb",
"roms.ipynb",
"roms_3d.ipynb",
"roms-compare.ipynb",
"rtofs.ipynb",
"selfe.ipynb",
"sscofs.ipynb",
"stofs_2d.ipynb",
"stofs_3d.ipynb",
"subset_from_ncfile.ipynb",
]


def run_notebook(nb_path: Path) -> tuple[bool, float, str]:
"""
Convert a notebook to a Python script using nbconvert, then run
the script in a subprocess.

Parameters
----------
nb_path : Path
Absolute path to the .ipynb file.

Returns
-------
success : bool
elapsed : float seconds taken
output : str combined stdout + stderr from the run
"""
start = time.time()

with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)

# Step 1 — Convert notebook → Python script
convert_result = subprocess.run(
[
sys.executable, "-m", "jupyter", "nbconvert",
"--to", "script",
str(nb_path),
"--output-dir", str(tmp_path),
],
capture_output=True,
text=True,
)

if convert_result.returncode != 0:
elapsed = time.time() - start
return False, elapsed, (
convert_result.stdout + "\n" + convert_result.stderr
)

# Step 2 — Locate the generated .py file
scripts = list(tmp_path.glob("*.py")) or list(tmp_path.glob("*.txt"))
if not scripts:
return 1, "nbconvert produced no output file (.py or .txt)"

script_path = scripts[0]

# Use a non-interactive matplotlib backend so plots don't block
env = os.environ.copy()
env["MPLBACKEND"] = "Agg"

# Step 3 — Run the script; cwd = notebook's own directory so that
# relative paths to example_data work correctly
run_result = subprocess.run(
[sys.executable, str(script_path)],
capture_output=True,
text=True,
cwd=nb_path.parent,
env=env,
)

elapsed = time.time() - start
output = run_result.stdout
if run_result.stderr:
output += "\nSTDERR:\n" + run_result.stderr

return run_result.returncode == 0, elapsed, output


def main() -> None:
parser = argparse.ArgumentParser(
description="Run xarray-subset-grid example notebooks via nbconvert."
)
parser.add_argument(
"--online",
action="store_true",
help="Also run notebooks that need network access (OPeNDAP / S3 / THREDDS).",
)
args = parser.parse_args()

to_run = list(OFFLINE_NOTEBOOKS)
if args.online:
to_run += ONLINE_NOTEBOOKS

results: list[tuple[str, str, float, str]] = []

print(f"\nRunning {'all' if args.online else 'offline'} example notebooks")
print("=" * 60)

for nb_name in to_run:
nb_path = EXAMPLES_DIR / nb_name
tag = "[online] " if nb_name in ONLINE_NOTEBOOKS else "[offline]"

if not nb_path.exists():
print(f" SKIP {tag} {nb_name} (file not found)")
results.append((nb_name, "SKIP", 0.0, ""))
continue

print(f" RUN {tag} {nb_name} ...", end="", flush=True)
success, elapsed, output = run_notebook(nb_path)
status = "PASS" if success else "FAIL"
print(f" {status} ({elapsed:.1f}s)")

if not success:
# Indent the output so it's easy to read in the report
indented = "\n".join(" " + line for line in output.splitlines())
print(indented)

results.append((nb_name, status, elapsed, output))

# ── Summary report ──────────────────────────────────────────────────────
passed = sum(1 for _, s, _, _ in results if s == "PASS")
failed = sum(1 for _, s, _, _ in results if s == "FAIL")
skipped = sum(1 for _, s, _, _ in results if s == "SKIP")

print("\n" + "=" * 60)
print(f"Results: {passed} passed | {failed} failed | {skipped} skipped")

if failed:
print("\nFailed notebooks:")
for name, status, _, _ in results:
if status == "FAIL":
print(f" ✗ {name}")
sys.exit(1)
else:
print("All notebooks ran successfully! ✓")


if __name__ == "__main__":
main()
2 changes: 1 addition & 1 deletion tests/test_grids/test_sgrid.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def test_grid_topology_location_parse():


@pytest.mark.skipif(
zarr__version__ >= 3, reason="zarr3.0.8 doesn't support FSpec AWS (it might soon)"
int(zarr.__version__.split(".")[0]) >= 3, reason="zarr3.0.8 doesn't support FSpec AWS (it might soon)"
)
@pytest.mark.online
def test_polygon_subset():
Expand Down
Loading