From a48b2cdee48ae48c2e51d9e45cf5395c8423e3e6 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Tue, 4 Aug 2026 16:29:18 -0400 Subject: [PATCH 1/4] ENH: build the dev MNE wheel for the JupyterLite browser kernel Adds a script that builds MNE as 9999.0.1 into doc/pypi, where the jupyterlite-pyodide-kernel piplite addon picks it up, and the CircleCI step that runs it once before Sphinx. --- .circleci/config.yml | 6 ++ .gitignore | 1 + doc/sphinxext/build_lite_wheel.py | 115 ++++++++++++++++++++++++++++++ pyproject.toml | 2 + 4 files changed, 124 insertions(+) create mode 100644 doc/sphinxext/build_lite_wheel.py 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/sphinxext/build_lite_wheel.py b/doc/sphinxext/build_lite_wheel.py new file mode 100644 index 00000000000..a6dbf0b50bc --- /dev/null +++ b/doc/sphinxext/build_lite_wheel.py @@ -0,0 +1,115 @@ +"""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 re +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") +PYPROJECT_PATH = os.path.join(REPO_ROOT, "pyproject.toml") + + +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) + + with open(PYPROJECT_PATH, encoding="utf-8") as f: + orig_pyproject = f.read() + + # Pyodide bundles scipy and matplotlib versions that can be older than the + # minimums MNE declares, so build against relaxed bounds to keep the wheel + # installable there. This is a safety net rather than a requirement, since + # piplite installs past unsatisfied bounds anyway when the caller passes + # keep_going=True. numpy is deliberately left alone: MNE requires >= 2.0 and + # Pyodide ships 2.x, so there is nothing to relax, and claiming 1.x support + # would put a version MNE no longer runs on into the wheel metadata. + patched = re.sub(r'"scipy\s*>=\s*1\.1[0-9]"', '"scipy >= 1.7"', orig_pyproject) + patched = re.sub(r'"matplotlib\s*>=\s*3\.[5-9]"', '"matplotlib >= 3.5"', patched) + os.environ["SETUPTOOLS_SCM_PRETEND_VERSION"] = "9999.0.1" + try: + with open(PYPROJECT_PATH, "w", encoding="utf-8") as f: + f.write(patched) + # 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. Isolation also builds from a fresh copy that reads the + # patched pyproject.toml above, so the relaxed bounds are picked up. + subprocess.run( + [ + sys.executable, + "-m", + "pip", + "wheel", + REPO_ROOT, + "--no-deps", + "-w", + PYPI_WHEELS_DIR, + ], + check=True, + ) + finally: + with open(PYPROJECT_PATH, "w", encoding="utf-8") as f: + f.write(orig_pyproject) + + # 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", From abd1bdadd547d5fad20f3659a7aa353fb02e66c2 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Tue, 4 Aug 2026 16:30:37 -0400 Subject: [PATCH 2/4] DOC: add the changelog entry for the JupyterLite wheel build --- doc/changes/dev/14135.other.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changes/dev/14135.other.rst 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`_. From c355342f8dc232f4b6b9525d305e5f52a6241d18 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Fri, 7 Aug 2026 10:31:55 -0400 Subject: [PATCH 3/4] MAINT: only relax matplotlib for the browser wheel Pyodide 0.29.3 already ships scipy 1.14.1 and numpy 2.2.5, which satisfy MNE. Only matplotlib 3.8.4 falls short, so relax that bound and leave the rest. --- doc/sphinxext/build_lite_wheel.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/doc/sphinxext/build_lite_wheel.py b/doc/sphinxext/build_lite_wheel.py index a6dbf0b50bc..67a1a80c06a 100644 --- a/doc/sphinxext/build_lite_wheel.py +++ b/doc/sphinxext/build_lite_wheel.py @@ -63,15 +63,17 @@ def build_wheel(): with open(PYPROJECT_PATH, encoding="utf-8") as f: orig_pyproject = f.read() - # Pyodide bundles scipy and matplotlib versions that can be older than the - # minimums MNE declares, so build against relaxed bounds to keep the wheel - # installable there. This is a safety net rather than a requirement, since - # piplite installs past unsatisfied bounds anyway when the caller passes - # keep_going=True. numpy is deliberately left alone: MNE requires >= 2.0 and - # Pyodide ships 2.x, so there is nothing to relax, and claiming 1.x support - # would put a version MNE no longer runs on into the wheel metadata. - patched = re.sub(r'"scipy\s*>=\s*1\.1[0-9]"', '"scipy >= 1.7"', orig_pyproject) - patched = re.sub(r'"matplotlib\s*>=\s*3\.[5-9]"', '"matplotlib >= 3.5"', patched) + # Pyodide 0.29.3, which jupyterlite-pyodide-kernel 0.7.2 pins, bundles + # matplotlib 3.8.4, one minor below the 3.9 MNE declares. Relax that one + # bound so the wheel stays installable in the browser. scipy (1.14.1) and + # numpy (2.2.5) already satisfy MNE there, so they are left alone rather + # than advertising versions MNE no longer runs on. Even matplotlib is a + # safety net: the notebook setup cell passes keep_going=True to piplite, + # which installs past unsatisfied bounds. Once the stack can move to a + # Pyodide that ships matplotlib >= 3.9, this whole patch can go away. + patched = re.sub( + r'"matplotlib\s*>=\s*3\.[5-9]"', '"matplotlib >= 3.5"', orig_pyproject + ) os.environ["SETUPTOOLS_SCM_PRETEND_VERSION"] = "9999.0.1" try: with open(PYPROJECT_PATH, "w", encoding="utf-8") as f: From 24efce3c0a56e880710ab4e81cd80b8e4f8ca8e4 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sat, 8 Aug 2026 15:06:35 -0400 Subject: [PATCH 4/4] MAINT: move the browser build to Pyodide 314 Overrides jupyterlite-sphinx's jupyterlite-core cap so the docs build picks up pyodide-kernel 0.8.2. Its matplotlib, scipy and numpy all clear MNE's minimums, so the wheel build no longer patches pyproject.toml at all. --- doc/sphinxext/build_lite_wheel.py | 62 +++++++++++-------------------- tools/circleci_uv_overrides.txt | 11 ++++++ 2 files changed, 32 insertions(+), 41 deletions(-) diff --git a/doc/sphinxext/build_lite_wheel.py b/doc/sphinxext/build_lite_wheel.py index 67a1a80c06a..4f23d98f3c9 100644 --- a/doc/sphinxext/build_lite_wheel.py +++ b/doc/sphinxext/build_lite_wheel.py @@ -24,7 +24,6 @@ import glob import os -import re import shutil import subprocess import sys @@ -33,7 +32,6 @@ os.path.join(os.path.dirname(__file__), os.pardir, os.pardir) ) PYPI_WHEELS_DIR = os.path.join(REPO_ROOT, "doc", "pypi") -PYPROJECT_PATH = os.path.join(REPO_ROOT, "pyproject.toml") def find_wheels(): @@ -60,46 +58,28 @@ def build_wheel(): shutil.rmtree(PYPI_WHEELS_DIR, ignore_errors=True) os.makedirs(PYPI_WHEELS_DIR, exist_ok=True) - with open(PYPROJECT_PATH, encoding="utf-8") as f: - orig_pyproject = f.read() - - # Pyodide 0.29.3, which jupyterlite-pyodide-kernel 0.7.2 pins, bundles - # matplotlib 3.8.4, one minor below the 3.9 MNE declares. Relax that one - # bound so the wheel stays installable in the browser. scipy (1.14.1) and - # numpy (2.2.5) already satisfy MNE there, so they are left alone rather - # than advertising versions MNE no longer runs on. Even matplotlib is a - # safety net: the notebook setup cell passes keep_going=True to piplite, - # which installs past unsatisfied bounds. Once the stack can move to a - # Pyodide that ships matplotlib >= 3.9, this whole patch can go away. - patched = re.sub( - r'"matplotlib\s*>=\s*3\.[5-9]"', '"matplotlib >= 3.5"', orig_pyproject - ) + # 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" - try: - with open(PYPROJECT_PATH, "w", encoding="utf-8") as f: - f.write(patched) - # 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. Isolation also builds from a fresh copy that reads the - # patched pyproject.toml above, so the relaxed bounds are picked up. - subprocess.run( - [ - sys.executable, - "-m", - "pip", - "wheel", - REPO_ROOT, - "--no-deps", - "-w", - PYPI_WHEELS_DIR, - ], - check=True, - ) - finally: - with open(PYPROJECT_PATH, "w", encoding="utf-8") as f: - f.write(orig_pyproject) + # 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. 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