diff --git a/.circleci/config.yml b/.circleci/config.yml index a8943cc0094..8ce7b306d01 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..4f23d98f3c9 --- /dev/null +++ b/doc/sphinxext/build_lite_wheel.py @@ -0,0 +1,97 @@ +"""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 glob +import os +import shutil +import subprocess +import sys + +REPO_ROOT = os.path.abspath( + os.path.join(os.path.dirname(__file__), os.pardir, os.pardir) +) +PYPI_WHEELS_DIR = os.path.join(REPO_ROOT, "doc", "pypi") + + +def find_wheels(): + """Return the MNE wheels already present in ``doc/pypi``. + + Returns + ------- + wheels : list of str + Paths of the MNE wheels found, empty if there are none. + """ + return glob.glob(os.path.join(PYPI_WHEELS_DIR, "mne-*.whl")) + + +def build_wheel(): + """Build the development MNE wheel into ``doc/pypi``. + + Returns + ------- + wheels : list of str + Paths of the MNE wheels that were built. + """ + # Clean first so stale wheels from previous runs do not accumulate and + # pollute the piplite all.json index. + shutil.rmtree(PYPI_WHEELS_DIR, ignore_errors=True) + os.makedirs(PYPI_WHEELS_DIR, exist_ok=True) + + # The wheel is built from pyproject.toml as it stands: Pyodide 314 ships + # matplotlib 3.10.8, scipy 1.17.1 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 older released MNE from PyPI. + wheels = find_wheels() + if not wheels: + raise RuntimeError( + f"JupyterLite: no MNE wheel was built into {PYPI_WHEELS_DIR!r}; the " + "browser kernel would fall back to the released PyPI version. Check " + "the 'pip wheel' output above." + ) + return wheels + + +if __name__ == "__main__": + print(f"[JupyterLite] Built MNE wheel(s) for the browser kernel: {build_wheel()}") diff --git a/pyproject.toml b/pyproject.toml index 56402be5563..425ebcf2f1b 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..d0d4c8f869c 100644 --- a/tools/circleci_uv_overrides.txt +++ b/tools/circleci_uv_overrides.txt @@ -5,3 +5,14 @@ # so uv does not drop those dependencies (the override takes precedence over the # command line, including its extras). -e .[full-pyside6] + +# jupyterlite-sphinx 0.22.1, its newest release, caps jupyterlite-core at < 0.8, +# which would hold the browser kernel at Pyodide 0.29.3 and its matplotlib 3.8.4, +# one minor below the 3.9 MNE declares. The cap is declared rather than real: +# 0.22.1 imports and builds fine against core 0.8.1. Overriding it puts the +# browser on Pyodide 314, whose matplotlib 3.10.8, scipy 1.17.1 and numpy 2.4.3 +# all satisfy MNE, so the wheel build needs no version patching at all. Both +# lines are needed: overriding core alone also lifts the old kernel's own cap, +# which would leave the old kernel and its old Pyodide in place. +jupyterlite-core>=0.8.1 +jupyterlite-pyodide-kernel>=0.8