diff --git a/.binder/environment.yml b/.binder/environment.yml index 46e5eb2..29310f3 100644 --- a/.binder/environment.yml +++ b/.binder/environment.yml @@ -8,6 +8,7 @@ dependencies: - xarray - scipy - pandas + - polars - matplotlib - cartopy - cmocean diff --git a/.binder/patch_notebooks.py b/.binder/patch_notebooks.py new file mode 100644 index 0000000..54dfe0c --- /dev/null +++ b/.binder/patch_notebooks.py @@ -0,0 +1,29 @@ +import json +import re +import sys + +# `html_str = pio.to_html(fig, ...)` followed by `display(HTML(html_str))` +PATTERN = re.compile(r"(\w+) = pio\.to_html\((\w+).*\)\ndisplay\(HTML\(\1\)\)") + + +def patch_notebook(nb: dict) -> int: + """Patch a notebook dict in place, returning the number of replacements.""" + n_total = 0 + for cell in nb["cells"]: + if cell["cell_type"] == "code": + source, n = PATTERN.subn(r"\2.show()", "".join(cell["source"])) + if n: + cell["source"] = source.splitlines(keepends=True) + cell["outputs"] = [] # stale docs output + n_total += n + return n_total + + +if __name__ == "__main__": + for path in sys.argv[1:]: + with open(path, encoding="utf-8") as f: + nb = json.load(f) + if patch_notebook(nb): + with open(path, "w", encoding="utf-8") as f: + json.dump(nb, f, indent=1, ensure_ascii=False) + print(f"Patched plotly figures -> fig.show() in {path}") diff --git a/.binder/postBuild b/.binder/postBuild index 1651f95..2376544 100644 --- a/.binder/postBuild +++ b/.binder/postBuild @@ -42,6 +42,11 @@ while IFS= read -r file_path || [ -n "$file_path" ]; do fi done < "$LIST_FILE" +# swap docs-only plotly HTML embedding for fig.show(), so figures render in Jupyter on Binder +shopt -s nullglob +python .binder/patch_notebooks.py "$TARGET_DIR"/*.ipynb +shopt -u nullglob + # landing page: copy to the workspace root cp .binder/welcome.md welcome.md diff --git a/.github/workflows/check-binder-files.yml b/.github/workflows/check-binder-files.yml index 4d4362d..a930f82 100644 --- a/.github/workflows/check-binder-files.yml +++ b/.github/workflows/check-binder-files.yml @@ -6,6 +6,8 @@ on: paths: - '.binder/files_to_fetch.txt' - '.binder/config.yaml' + - '.binder/patch_notebooks.py' + - 'tests/**' pull_request: types: [ opened, synchronize, reopened ] branches: [ main ] @@ -31,6 +33,6 @@ jobs: python -m pip install --upgrade pip pip install pytest pyyaml - - name: Run notebook URL unit tests + - name: Run notebook URL and patch drift unit tests run: | - pytest tests/test_binder_files.py -v \ No newline at end of file + pytest tests/ -v \ No newline at end of file diff --git a/tests/test_binder_patch_notebooks.py b/tests/test_binder_patch_notebooks.py new file mode 100644 index 0000000..d7a9168 --- /dev/null +++ b/tests/test_binder_patch_notebooks.py @@ -0,0 +1,54 @@ +import importlib.util +import json +import urllib.request +from pathlib import Path + +import pytest + +from test_binder_files import CONFIG, get_target_files + +_spec = importlib.util.spec_from_file_location( + "patch_notebooks", Path(__file__).parent.parent / ".binder" / "patch_notebooks.py" +) +patch_notebooks = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(patch_notebooks) + + +def _code(nb): + return "\n".join( + "".join(c["source"]) for c in nb["cells"] if c["cell_type"] == "code" + ) + + +def test_patch_notebook(): + nb = { + "cells": [ + { + "cell_type": "code", + "source": [ + 'html_str = pio.to_html(fig, include_plotlyjs="cdn")\n', + "display(HTML(html_str))", + ], + "outputs": [{"output_type": "display_data"}], + } + ] + } + assert patch_notebooks.patch_notebook(nb) == 1 + assert nb["cells"][0]["source"] == ["fig.show()"] + assert nb["cells"][0]["outputs"] == [] + + +@pytest.mark.parametrize("file_path", get_target_files()) +def test_upstream_notebook_fully_patched(file_path): + """Catch drift: any plotly HTML embedding upstream must be rewritten by the patch.""" + url = ( + f"https://raw.githubusercontent.com/{CONFIG['repo_owner']}/" + f"{CONFIG['repo_name']}/{CONFIG['branch']}/{file_path}" + ) + with urllib.request.urlopen(url) as response: + nb = json.load(response) + patch_notebooks.patch_notebook(nb) + assert "to_html(" not in _code(nb), ( + f"{file_path} embeds plotly HTML in a way .binder/patch_notebooks.py doesn't " + "rewrite to `fig.show()`, so it won't render on Binder. Update PATTERN." + )