diff --git a/.circleci/config.yml b/.circleci/config.yml index 3b88e176092..08fb47490c2 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -249,6 +249,12 @@ jobs: cp junit-results.xml doc/_build/test-results/test-doc/junit.xml; cp coverage.xml doc/_build/test-results/test-doc/coverage.xml; fi; + # Build the development MNE wheel that the JupyterLite browser kernel + # will install, once, before Sphinx runs. Building it here rather than + # from conf.py keeps it out of the per-invocation docs build. + - run: + name: Build MNE wheel for JupyterLite + command: python doc/sphinxext/build_lite_wheel.py # Build docs - run: name: make html diff --git a/.gitignore b/.gitignore index 21275b21c0b..bf714cd5b15 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ mne/viz/_brain/tests/.ipynb_checkpoints dist/ doc/_build/ +doc/pypi/ doc/generated/ doc/auto_examples/ doc/auto_tutorials/ diff --git a/doc/changes/dev/14135.other.rst b/doc/changes/dev/14135.other.rst new file mode 100644 index 00000000000..2ca04fb6248 --- /dev/null +++ b/doc/changes/dev/14135.other.rst @@ -0,0 +1 @@ +Add a build script and CI step that produce the development MNE wheel the JupyterLite browser kernel installs, by `Natneal B`_. diff --git a/doc/sphinxext/build_lite_wheel.py b/doc/sphinxext/build_lite_wheel.py new file mode 100644 index 00000000000..817f7f99885 --- /dev/null +++ b/doc/sphinxext/build_lite_wheel.py @@ -0,0 +1,120 @@ +"""Build the development MNE wheel for the JupyterLite browser kernel. + +Run this once before building the docs, either in CI or locally:: + + python doc/sphinxext/build_lite_wheel.py + +The wheel is written to ``doc/pypi``, where the jupyterlite-pyodide-kernel +PipliteAddon discovers, copies and indexes it (adding it to ``pipliteUrls`` in +``jupyter-lite.json``), so the browser kernel installs the current development +MNE rather than the older release from PyPI. See +https://jupyterlite.readthedocs.io/en/latest/howto/pyodide/wheels.html + +Both functions are importable, so a docs build can reuse a wheel that is already +present rather than building one on every invocation:: + + from build_lite_wheel import build_wheel, find_wheels + + wheels = find_wheels() or build_wheel() +""" + +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +import json +import os +import shutil +import subprocess +import sys +import urllib.request +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +PYPI_WHEELS_DIR = REPO_ROOT / "doc" / "pypi" + + +def find_wheels(): + """Return the MNE wheels already present in ``doc/pypi``. + + Returns + ------- + wheels : list of pathlib.Path + Paths of the MNE wheels found, empty if there are none. + """ + return sorted(PYPI_WHEELS_DIR.glob("mne-*.whl")) + + +def _latest_pypi_version(): + """Return the newest MNE version on PyPI, or None if it cannot be reached. + + Returns + ------- + version : str | None + The version string, or None if PyPI could not be queried. + """ + # Broad on purpose: this only ever runs while raising, so a network problem + # here must not replace the real error with a less useful one. + try: + url = "https://pypi.org/pypi/mne/json" + with urllib.request.urlopen(url, timeout=10) as response: + return json.load(response)["info"]["version"] + except Exception: + return None + + +def build_wheel(): + """Build the development MNE wheel into ``doc/pypi``. + + Returns + ------- + wheels : list of pathlib.Path + Paths of the MNE wheels that were built. + """ + # The version below is pinned, so each build writes the same filename and + # wheels do not pile up. Clearing first is about determinism instead: this + # directory is the piplite index, so it should hold the wheel this build + # produced and nothing else, including anything left by a manual pip wheel. + shutil.rmtree(PYPI_WHEELS_DIR, ignore_errors=True) + PYPI_WHEELS_DIR.mkdir(parents=True, exist_ok=True) + + # The wheel is built from pyproject.toml as it stands: Pyodide 314 ships + # matplotlib 3.10.8, scipy 1.18.0 and numpy 2.4.3, all of which satisfy the + # minimums MNE declares, so none of them needs relaxing for the browser. + os.environ["SETUPTOOLS_SCM_PRETEND_VERSION"] = "9999.0.1" + # NB: build isolation is left ON (the default). MNE uses the hatchling build + # backend, so pip must create an isolated build env to install + # hatchling/hatch-vcs; --no-build-isolation fails with "Cannot import + # 'hatchling.build'" on CI, where those build deps are not in the base + # environment. + subprocess.run( + [ + sys.executable, + "-m", + "pip", + "wheel", + REPO_ROOT, + "--no-deps", + "-w", + PYPI_WHEELS_DIR, + ], + check=True, + ) + + # Fail loudly rather than silently letting the browser kernel fall back to + # the released MNE from PyPI. + wheels = find_wheels() + if not wheels: + latest = _latest_pypi_version() + fallback = f"MNE {latest}" if latest else "the latest MNE release" + raise RuntimeError( + f"JupyterLite: no MNE wheel was built into {PYPI_WHEELS_DIR}; the " + f"browser kernel would fall back to {fallback} from PyPI. Check the " + "'pip wheel' output above." + ) + return wheels + + +if __name__ == "__main__": + built = ", ".join(str(wheel) for wheel in build_wheel()) + print(f"[JupyterLite] Built MNE wheel(s) for the browser kernel: {built}") diff --git a/pyproject.toml b/pyproject.toml index f2d17cf1967..202ab5d6cbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,8 @@ doc = [ "graphviz", "intersphinx_registry >= 0.2405.27", "ipython != 8.7.0", # also in "full-no-qt" and "test" + "jupyterlite-pyodide-kernel", + "jupyterlite-sphinx", "memory_profiler >= 0.16", "mne-bids", "mne-connectivity", diff --git a/tools/circleci_uv_overrides.txt b/tools/circleci_uv_overrides.txt index e68be19682e..fc19663d27e 100644 --- a/tools/circleci_uv_overrides.txt +++ b/tools/circleci_uv_overrides.txt @@ -5,3 +5,17 @@ # so uv does not drop those dependencies (the override takes precedence over the # command line, including its extras). -e .[full-pyside6] + +# Floor for the browser kernel, so it stays on the Pyodide 314 line. Up to +# jupyterlite-sphinx 0.22.1 the jupyterlite-core cap was < 0.8, which held the +# kernel at Pyodide 0.29.3 and its matplotlib 3.8.4, one minor below the 3.9 +# MNE declares. 0.23.0 raised that cap to < 0.9, so the resolution now reaches +# Pyodide 314 unaided and these two lines change nothing today. They stay as a +# guard: anything that pulls jupyterlite-sphinx back below 0.23 would otherwise +# drop the browser to the old Pyodide and quietly stop meeting MNE's matplotlib +# bound. Pyodide 314 ships matplotlib 3.10.8, scipy 1.18.0 and numpy 2.4.3, all +# of which satisfy MNE, so the wheel build needs no version patching at all. +# The core line is the one that does the work; the kernel line pins the intent +# in case something else ever constrains the kernel directly. +jupyterlite-core>=0.8.1 +jupyterlite-pyodide-kernel>=0.8