diff --git a/.github/workflows/notebooks.yml b/.github/workflows/notebooks.yml new file mode 100644 index 0000000..c01295c --- /dev/null +++ b/.github/workflows/notebooks.yml @@ -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 \ No newline at end of file diff --git a/docs/examples/local_subset_example.ipynb b/docs/examples/local_subset_example.ipynb new file mode 100644 index 0000000..2708468 --- /dev/null +++ b/docs/examples/local_subset_example.ipynb @@ -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 +} diff --git a/pyproject.toml b/pyproject.toml index 444f409..f47ee32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,9 @@ dependencies = [ ] optional-dependencies.dev = [ + "ipykernel", + "jupyter", + "nbconvert", "pre-commit", "pytest", "pytest-cov", diff --git a/scripts/create_notebook.py b/scripts/create_notebook.py new file mode 100644 index 0000000..5cf6dc0 --- /dev/null +++ b/scripts/create_notebook.py @@ -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.") \ No newline at end of file diff --git a/scripts/run_notebooks.py b/scripts/run_notebooks.py new file mode 100644 index 0000000..cb3a752 --- /dev/null +++ b/scripts/run_notebooks.py @@ -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() \ No newline at end of file diff --git a/tests/test_grids/test_sgrid.py b/tests/test_grids/test_sgrid.py index dce7379..cb4a69c 100644 --- a/tests/test_grids/test_sgrid.py +++ b/tests/test_grids/test_sgrid.py @@ -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(): diff --git a/tests/test_notebooks.py b/tests/test_notebooks.py new file mode 100644 index 0000000..b1964ce --- /dev/null +++ b/tests/test_notebooks.py @@ -0,0 +1,141 @@ +""" +Pytest integration for auto-running example notebooks (issue #106). + +Each notebook is converted to a Python script via nbconvert and then +executed in a subprocess, mirroring the approach described in the issue. + +Offline notebooks (local data only) run unconditionally. +Network notebooks are decorated with @pytest.mark.online and are skipped +unless pytest is invoked with --online (matching the existing convention +in conftest.py). + +To run offline notebook tests: + pytest tests/test_notebooks.py -v + +To run all notebook tests (needs internet): + pytest tests/test_notebooks.py -v --online +""" +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +EXAMPLES_DIR = Path(__file__).parent.parent / "docs" / "examples" + +# ── Offline: use only local files in docs/examples/example_data/ ──────────── +OFFLINE_NOTEBOOKS = [ + "local_subset_example.ipynb", +] + +# ── Online: require OPeNDAP / THREDDS / S3 / AWS access ───────────────────── +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", +] + + +# ── Helper ─────────────────────────────────────────────────────────────────── + +def _run_notebook(nb_path: Path) -> tuple[int, str]: + """ + Convert *nb_path* to a Python script with nbconvert, execute the + script in a subprocess, and return (returncode, combined_output). + + The notebook's own directory is used as the working directory so that + relative paths to example_data resolve correctly. + A non-interactive matplotlib backend is set via MPLBACKEND=Agg so + that plt.show() calls do not open windows or hang the process. + """ + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + + # Step 1: notebook → Python script + convert = subprocess.run( + [ + sys.executable, "-m", "jupyter", "nbconvert", + "--to", "script", + str(nb_path), + "--output-dir", str(tmp_path), + ], + capture_output=True, + text=True, + ) + if convert.returncode != 0: + return convert.returncode, convert.stdout + "\n" + convert.stderr + + 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)" + # Step 2: execute the script + env = os.environ.copy() + env["MPLBACKEND"] = "Agg" # non-interactive backend + + run = subprocess.run( + [sys.executable, str(scripts[0])], + capture_output=True, + text=True, + cwd=nb_path.parent, # resolve relative data paths + env=env, + ) + + output = run.stdout + if run.stderr: + output += "\nSTDERR:\n" + run.stderr + return run.returncode, output + + +# ── Tests ──────────────────────────────────────────────────────────────────── + +@pytest.mark.parametrize("notebook", OFFLINE_NOTEBOOKS) +def test_offline_notebook(notebook): + """ + Notebooks that require only local data. + These always run as part of the normal test suite. + """ + nb_path = EXAMPLES_DIR / notebook + if not nb_path.exists(): + pytest.skip(f"Notebook not found: {nb_path}") + + returncode, output = _run_notebook(nb_path) + + if returncode != 0: + pytest.fail( + f"Notebook '{notebook}' failed (exit code {returncode}).\n" + f"Output:\n{output}" + ) + + +@pytest.mark.online +@pytest.mark.parametrize("notebook", ONLINE_NOTEBOOKS) +def test_online_notebook(notebook): + """ + Notebooks that require network access (OPeNDAP, THREDDS, S3, AWS). + Skipped unless pytest is run with --online. + """ + nb_path = EXAMPLES_DIR / notebook + if not nb_path.exists(): + pytest.skip(f"Notebook not found: {nb_path}") + + returncode, output = _run_notebook(nb_path) + + if returncode != 0: + pytest.fail( + f"Notebook '{notebook}' failed (exit code {returncode}).\n" + f"Output:\n{output}" + ) \ No newline at end of file