From 6183f2448d2de79f48413d2a280ea61e8330b5a6 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Tue, 11 Aug 2026 09:26:12 -0400 Subject: [PATCH 01/11] ENH: add the JupyterLite notebook setup cell Installs MNE into the browser kernel with piplite and patches what Pyodide does not provide: HTTP data fetching, the readers that expect a file on disk, and a few things that need OS threads. --- doc/sphinxext/jupyterlite_setup_cell.py | 896 ++++++++++++++++++++++++ 1 file changed, 896 insertions(+) create mode 100644 doc/sphinxext/jupyterlite_setup_cell.py diff --git a/doc/sphinxext/jupyterlite_setup_cell.py b/doc/sphinxext/jupyterlite_setup_cell.py new file mode 100644 index 00000000000..10729a27cb8 --- /dev/null +++ b/doc/sphinxext/jupyterlite_setup_cell.py @@ -0,0 +1,896 @@ +"""The setup cell prepended to every JupyterLite notebook. + +This installs MNE into the browser kernel and patches the bits of the +environment Pyodide does not provide: data fetching over HTTP, the readers +that expect files already on disk, and the 3D renderer. + +The docs build prepends it only to the notebooks copied into the JupyterLite +contents. It deliberately does NOT go through ``first_notebook_cell``: that is +applied when the notebook is generated, so it would also land in the ``.ipynb`` +offered for download, where ``piplite`` does not exist and the notebook would +fail on its first cell. +""" + +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +from jupyterlite_lite_renderer import LITE_RENDERER_CELL + +LITE_SETUP_CELL = ( + "# 💡 This cell is automatically added to the start of each notebook.\n" + "# It installs MNE and patches the browser environment for Pyodide.\n" + "import piplite\n" + "# Use piplite (not micropip) so the locally-built development MNE wheel\n" + "# bundled into the JupyterLite build is preferred over the older PyPI\n" + "# release;\n" + "# piplite checks the local index first and falls back to PyPI for deps.\n" + "# keep_going=True lets it install even if Pyodide's bundled\n" + "# matplotlib/scipy/numpy are older than MNE's declared minimums.\n" + "await piplite.install(\n" + " ['mne', 'scikit-learn', 'joblib', 'pandas', 'seaborn', " + "'mne-connectivity', 'nibabel', 'pyvista-js', 'pyxdf', 'mffpy', " + "'python-picard'],\n" + " keep_going=True,\n" + ")\n" + "\n" + "import sys\n" + "import os\n" + "import io\n" + "\n" + "# lzma: try real stdlib first (Pyodide ships it); only mock if absent\n" + "try:\n" + " import lzma\n" + "except ImportError:\n" + " class _LZMAFile:\n" + " def __init__(self, *a, **kw): pass\n" + " def __enter__(self): return self\n" + " def __exit__(self, *a): pass\n" + " def write(self, d): pass\n" + " def read(self, n=-1): return b''\n" + " def close(self): pass\n" + " class _MockLZMA:\n" + " LZMAError = Exception\n" + " LZMAFile = _LZMAFile\n" + " FORMAT_XZ = 1\n" + " FORMAT_ALONE = 2\n" + " def __getattr__(self, name): return object\n" + " import sys as _sys\n" + " _sys.modules['lzma'] = _MockLZMA()\n" + "\n" + "# Mock multiprocessing — missing in Pyodide but imported by joblib\n" + "from unittest.mock import MagicMock\n" + "if 'multiprocessing' not in sys.modules:\n" + " m = MagicMock()\n" + " m.cpu_count.return_value = 1\n" + " sys.modules['multiprocessing'] = m\n" + " sys.modules['multiprocessing.util'] = m.util\n" + " sys.modules['multiprocessing.pool'] = m.pool\n" + "\n" + "# Patch requests so pooch can fetch files already on /drive/mne_data.\n" + "# open_url works for both text and binary in Pyodide >= 0.21.\n" + "import requests\n" + "import pyodide\n" + "orig_send = requests.Session.send\n" + "def pyodide_send(self, request, **kwargs):\n" + " try:\n" + " buf = pyodide.http.open_url(request.url)\n" + " content = buf.getvalue() if hasattr(buf, 'getvalue') else buf.read()\n" + " if isinstance(content, str):\n" + " content = content.encode('utf-8')\n" + " except Exception as e:\n" + " print(f'open_url failed for {request.url}: {e}')\n" + " return orig_send(self, request, **kwargs)\n" + " response = requests.Response()\n" + " response.status_code = 200\n" + " response.url = request.url\n" + " response.raw = io.BytesIO(content)\n" + " return response\n" + "requests.Session.send = pyodide_send\n" + "\n" + "# /drive/ in Pyodide requires Cross-Origin-Isolation headers\n" + "# (COOP/COEP) which many static servers (e.g. CircleCI artifacts)\n" + "# do not send. Fetch the data over HTTP into /tmp/mne_data instead\n" + "# — same-origin, no CORS. The data is served at the docs root\n" + "# (/mne_data/...) via Sphinx html_extra_path.\n" + "# Pyodide may run in a web worker (no `window`); `location` exists\n" + "# in both the main thread and workers, so use it to find the docs\n" + "# root by splitting on '/lite/'.\n" + "import pyodide.http as _phttp\n" + "import js as _js\n" + "try:\n" + " _page = str(_js.location.href)\n" + "except Exception:\n" + " _page = str(_js.window.location.href)\n" + "_base = _page.split('/lite/')[0] + '/mne_data/'\n" + "mne_data_path = '/tmp/mne_data'\n" + "_sample_dir = mne_data_path + '/MNE-sample-data'\n" + "# Eager 'core': small, commonly-used sample files fetched once at\n" + "# notebook start. The heavy files (raw / filt raw / ernoise / fwd /\n" + "# inv / src, ~360 MB total) are intentionally omitted here -- they are\n" + "# fetched lazily on first read via the reader shims below, so each\n" + "# notebook only downloads the sample files it actually uses.\n" + "_sample_files = [\n" + " 'version.txt',\n" + " 'MEG/sample/sample_audvis_raw-eve.fif',\n" + " 'MEG/sample/sample_audvis_filt-0-40_raw-eve.fif',\n" + " 'MEG/sample/sample_audvis_ecg-proj.fif',\n" + " 'MEG/sample/sample_audvis-cov.fif',\n" + " 'MEG/sample/sample_audvis-ave.fif',\n" + " 'MEG/sample/sample_audvis-no-filter-ave.fif',\n" + " 'MEG/sample/sample_audvis_raw-trans.fif',\n" + " 'MEG/sample/sample_audvis-shrunk-cov.fif',\n" + " 'MEG/sample/sample_audvis-meg-lh.stc',\n" + " 'MEG/sample/sample_audvis-meg-rh.stc',\n" + " 'subjects/sample/mri/T1.mgz',\n" + " 'subjects/sample/surf/rh.pial',\n" + " 'subjects/sample/surf/lh.pial',\n" + " 'subjects/sample/surf/rh.white',\n" + " 'subjects/sample/surf/lh.white',\n" + " 'subjects/sample/label/lh.aparc.annot',\n" + " 'subjects/sample/label/rh.aparc.annot',\n" + " 'SSS/sss_cal_mgh.dat',\n" + " 'SSS/ct_sparse_mgh.fif',\n" + "]\n" + "print('Fetching MNE sample data (once per session)...')\n" + "for _f in _sample_files:\n" + " _dst = _sample_dir + '/' + _f\n" + " if os.path.exists(_dst):\n" + " continue\n" + " _url = _base + 'MNE-sample-data/' + _f\n" + " try:\n" + " _r = await _phttp.pyfetch(_url)\n" + " if _r.status != 200:\n" + " print(f' HTTP {_r.status} for {_url}')\n" + " continue\n" + " _d = await _r.bytes()\n" + " if _d[:4] == b'=0)\n" + " _fc = _cv[_tris].mean(1)\n" + " for _cm, _col in (\n" + " (_fc < 0, (0.68, 0.68, 0.68)),\n" + " (_fc >= 0, (0.38, 0.38, 0.38))):\n" + " _s = _sub(_pts, _tris, _cm)\n" + " if _s is not None:\n" + " _plotter.add_mesh(\n" + " _pv.PolyData(points=_s[0], faces=_flat(_s[1])),\n" + " color=_col, smooth_shading=True)\n" + " # activation as a smooth hot gradient in N value bands,\n" + " # each lifted 2% off the surface to avoid z-fighting\n" + " _fv = _scal[_tris].mean(1)\n" + " _p90 = _np.percentile(_scal, 90.0)\n" + " _fmax = float(_scal.max())\n" + " # keep the background gray: for sparse point sources the\n" + " # 90th pct is ~0 (most of the brain is zero), which would\n" + " # paint everything, so fall back to a fraction of the max.\n" + " _fmin = _p90 if _p90 > _fmax * 0.05 else _fmax * 0.4\n" + " if _fmax > _fmin:\n" + " _edges = _np.linspace(_fmin, _fmax, _N + 1)\n" + " for _i in range(_N):\n" + " if _i < _N - 1:\n" + " _m = (_fv >= _edges[_i]) & (_fv < _edges[_i + 1])\n" + " else:\n" + " _m = _fv >= _edges[_i]\n" + " if int(_m.sum()) == 0:\n" + " continue\n" + " _rgb = _hot(0.25 + 0.41 * (_i / (_N - 1)))\n" + " _col = (float(_rgb[0]), float(_rgb[1]),\n" + " float(_rgb[2]))\n" + " _s = _sub(_pts, _tris, _m, 0.02, _cen)\n" + " if _s is not None:\n" + " _plotter.add_mesh(\n" + " _pv.PolyData(points=_s[0],\n" + " faces=_flat(_s[1])),\n" + " color=_col, smooth_shading=True)\n" + " # Open on the lateral profile (camera along the medial-lateral\n" + " # X axis, superior up), like native MNE, instead of vtk.js's\n" + " # default anterior/face-on view. Guarded so a missing\n" + " # view_vector never costs us the render.\n" + " try:\n" + " _plotter.view_vector((-1.0, 0.0, 0.0),\n" + " viewup=(0.0, 0.0, 1.0))\n" + " except Exception:\n" + " pass\n" + " _plotter.show()\n" + " except Exception as _e:\n" + " print('[JupyterLite] pyvista-js 3D render unavailable: '\n" + " + repr(_e))\n" + " return _LiteBrain()\n" + "mne.SourceEstimate.plot = _lite_stc_plot\n" + "\n" + "# Pyodide/WASM has no OS threads, so MNE's ProgressBar background\n" + "# updater thread (used by the ProgressBar context manager, e.g. in\n" + "# permutation cluster tests) crashes with 'can't start new thread'.\n" + "# That thread only animates a cosmetic bar — the computation runs on\n" + "# the main thread and __exit__ writes the final state — so no-op its\n" + "# start/join. Only affects notebooks that use it; results are unchanged.\n" + "try:\n" + " from mne.utils import progressbar as _mpb\n" + " _mpb._UpdateThread.start = lambda self: None\n" + " _mpb._UpdateThread.join = lambda self, *_a, **_kw: None\n" + "except Exception:\n" + " pass\n" + "# tqdm also spawns its own monitor thread, which likewise can't start in\n" + "# WASM and emits a TqdmMonitorWarning. Setting monitor_interval=0 before\n" + "# any bar is created skips that thread entirely (bars still display).\n" + "try:\n" + " import tqdm as _tqdm\n" + " _tqdm.tqdm.monitor_interval = 0\n" + "except Exception:\n" + " pass\n" + "\n" + "# Switch matplotlib to inline so figures render in the notebook.\n" + "import IPython\n" + "IPython.get_ipython().run_line_magic('matplotlib', 'inline')\n" + "import matplotlib.pyplot as plt\n" + "# Silence the spurious 'FigureCanvasAgg is non-interactive' warning\n" + "# at its source. MNE's plt_show calls fig.show() (the inline backend\n" + "# isn't detected as 'agg'), and the inline Agg canvas warns. Patching\n" + "# viz.utils.plt_show is not enough: other modules did\n" + "# `from .utils import plt_show` and hold their own reference. Every\n" + "# path resolves fig.show on the class at call time, so a no-op here\n" + "# silences it everywhere. Figures still render via the inline backend.\n" + "import matplotlib.figure as _mfig\n" + "_mfig.Figure.show = lambda self, *a, **k: None\n" + "import importlib\n" + "viz_utils = importlib.import_module('mne.viz.utils')\n" + "# Also display+close via IPython for paths that call plt_show\n" + "# directly, so figures render exactly once.\n" + "def pyodide_plt_show(show=True, fig=None, **kwargs):\n" + " if not show:\n" + " return\n" + " import IPython.display\n" + " _f = fig if fig is not None else plt.gcf()\n" + " IPython.display.display(_f)\n" + " plt.close(_f)\n" + "viz_utils.plt_show = pyodide_plt_show\n" + "\n" + "# EXPERIMENTAL 3D: plot_sparse_source_estimates builds its 3D renderer\n" + "# BEFORE the time-course figure, so in WASM the whole call dies and the\n" + "# notebook loses both halves. Rebuild it here: the same glass brain from\n" + "# the source space and a marker per active dipole via pyvista-js, plus\n" + "# the matplotlib time courses (which are the quantitative half). Same\n" + "# approach as the SourceEstimate.plot shim above.\n" + "def _lite_plot_sparse_source_estimates(\n" + " src, stcs, colors=None, linewidth=2, fontsize=18,\n" + " bgcolor=(0.05, 0, 0.1), opacity=0.2, brain_color=(0.7,) * 3,\n" + " show=True, high_resolution=False, fig_name=None,\n" + " fig_number=None, labels=None, modes=('cone', 'sphere'),\n" + " scale_factors=(1, 0.6), **kwargs):\n" + " import numpy as _np\n" + " from itertools import cycle as _cycle\n" + " from matplotlib.colors import to_rgb as _to_rgb\n" + " if not isinstance(stcs, list):\n" + " stcs = [stcs]\n" + " _lhp = src[0]['rr']\n" + " _pts = _np.r_[_lhp, src[1]['rr']] * 170\n" + " _nrm = _np.r_[src[0]['nn'], src[1]['nn']]\n" + " # use_tris is the decimated mesh and can be None on some source\n" + " # spaces; fall back to the full tris in that case.\n" + " _lt = src[0]['tris'] if high_resolution else src[0]['use_tris']\n" + " _rt = src[1]['tris'] if high_resolution else src[1]['use_tris']\n" + " if _lt is None or _rt is None:\n" + " _lt, _rt = src[0]['tris'], src[1]['tris']\n" + " _faces = _np.r_[_lt, len(_lhp) + _rt]\n" + " _vertnos = [_np.r_[_s.lh_vertno, len(_lhp) + _s.rh_vertno]\n" + " for _s in stcs]\n" + " _uniq = _np.unique(_np.concatenate(_vertnos).ravel())\n" + " # --- time courses -------------------------------------------------\n" + " _fig = plt.figure(fig_number, layout='constrained')\n" + " _fig.clf()\n" + " _ax = _fig.add_subplot(111)\n" + " _cyc = _cycle(colors if colors is not None else\n" + " plt.rcParams['axes.prop_cycle'].by_key()['color'])\n" + " _marks = []\n" + " for _v in _uniq:\n" + " _ind = [_k for _k, _vn in enumerate(_vertnos) if _v in _vn]\n" + " _c = next(_cyc)\n" + " _marks.append((int(_v), _to_rgb(_c), len(_ind) > 1))\n" + " for _k in _ind:\n" + " _m = _vertnos[_k] == _v\n" + " _ax.plot(1e3 * stcs[_k].times,\n" + " 1e9 * stcs[_k].data[_m].ravel(),\n" + " c=_c, linewidth=linewidth)\n" + " _ax.set_xlabel('Time (ms)', fontsize=fontsize)\n" + " _ax.set_ylabel('Source amplitude (nAm)', fontsize=fontsize)\n" + " if fig_name is not None:\n" + " _ax.set_title(fig_name)\n" + " pyodide_plt_show(show)\n" + " # --- glass brain + dipole markers ---------------------------------\n" + " try:\n" + " import pyvista_js as _pv\n" + " _plotter = _pv.Plotter()\n" + " _plotter.background_color = tuple(\n" + " float(min(max(_x, 0.0), 1.0)) for _x in bgcolor)\n" + " for _lp in ((1, 0, 0), (-1, 0, 0), (0, 1, 0),\n" + " (0, -1, 0), (0, 0, 1), (0, 0, -1)):\n" + " _plotter.add_light(_pv.Light(\n" + " position=(300.0 * _lp[0], 300.0 * _lp[1],\n" + " 300.0 * _lp[2]),\n" + " focal_point=(0.0, 0.0, 0.0), intensity=0.4))\n" + " _flat_faces = _np.hstack([\n" + " _np.full((len(_faces), 1), 3, dtype=_np.int32),\n" + " _faces.astype(_np.int32)]).ravel()\n" + " _plotter.add_mesh(\n" + " _pv.PolyData(points=_pts.astype(_np.float32),\n" + " faces=_flat_faces),\n" + " color=tuple(float(_x) for _x in brain_color),\n" + " opacity=float(opacity), smooth_shading=True)\n" + " for _v, _col, _common in _marks:\n" + " _sf = float(scale_factors[1] if _common\n" + " else scale_factors[0])\n" + " _mode = modes[1] if _common else modes[0]\n" + " _xyz = tuple(float(_q) for _q in _pts[_v])\n" + " if _mode == 'sphere':\n" + " _glyph = _pv.Sphere(radius=_sf, center=_xyz)\n" + " else:\n" + " _glyph = _pv.Cone(\n" + " center=_xyz,\n" + " direction=tuple(float(_q) for _q in _nrm[_v]),\n" + " height=2.0 * _sf, radius=_sf)\n" + " _plotter.add_mesh(_glyph, color=_col, smooth_shading=True)\n" + " try:\n" + " _plotter.view_vector((-1.0, 0.0, 0.0),\n" + " viewup=(0.0, 0.0, 1.0))\n" + " except Exception:\n" + " pass\n" + " _plotter.show()\n" + " except Exception as _e:\n" + " print('[JupyterLite] pyvista-js glass brain unavailable: '\n" + " + repr(_e))\n" + "mne.viz.plot_sparse_source_estimates = _lite_plot_sparse_source_estimates\n" + "\n" + "# Each MNE plot is rendered once by pyodide_plt_show above (display()).\n" + "# When a plot call is also a cell's last expression, the method returns\n" + "# the Figure, which Jupyter echoes a SECOND time as the Out[] result\n" + "# (the duplicate seen below inline plots). Drop that redundant echo for\n" + "# Figures (and pure lists of Figures, e.g. ica.plot_properties) so each\n" + "# plot appears exactly once. Non-figure results (numbers, DataFrames,\n" + "# reprs) are untouched, and raw matplotlib figures never shown still\n" + "# render via the inline backend's end-of-cell flush, so nothing hides.\n" + "# Wrapped in try/except (like the patches below): if anything about\n" + "# the displayhook is unexpected, silently keep the current behavior\n" + "# (harmless double render) rather than breaking the setup cell.\n" + "try:\n" + " _lite_dh = type(IPython.get_ipython().displayhook)\n" + " if not getattr(_lite_dh, '_lite_no_fig_echo', False):\n" + " _lite_dh_call = _lite_dh.__call__\n" + " def _lite_displayhook(self, result=None):\n" + " if isinstance(result, _mfig.Figure):\n" + " result = None\n" + " elif (isinstance(result, (list, tuple)) and result\n" + " and all(isinstance(_x, _mfig.Figure) for _x in result)):\n" + " result = None\n" + " return _lite_dh_call(self, result)\n" + " _lite_dh.__call__ = _lite_displayhook\n" + " _lite_dh._lite_no_fig_echo = True\n" + "except Exception:\n" + " pass\n" + "\n" + "# Real fix (not a warnings filter) for the threadpoolctl Pyodide\n" + "# RuntimeWarning seen via mne.sys_info(): threadpoolctl 3.6.0 (latest\n" + "# release) still calls the deprecated Pyodide JsProxy.as_object_map().\n" + "# Pyodide's own message says to use as_py_json() instead; both yield the\n" + "# same library filepaths, so we swap the call at its source. This removes\n" + "# the deprecated API usage entirely, so the warning is never emitted.\n" + "# The upstream fix is already merged (joblib/threadpoolctl#201) but\n" + "# unreleased; Pyodide bundles the released 3.6.0 wheel. DROP THIS PATCH\n" + "# once threadpoolctl 3.7.0 is released and Pyodide bundles it.\n" + "try:\n" + " import os as _os\n" + " import threadpoolctl as _tpc\n" + " def _find_libraries_pyodide(self):\n" + " from pyodide_js._module import LDSO\n" + " for _fp in LDSO.loadedLibsByName.as_py_json():\n" + " if _os.path.exists(_fp):\n" + " self._make_controller_from_path(_fp)\n" + " _tpc.ThreadpoolController._find_libraries_pyodide = (\n" + " _find_libraries_pyodide\n" + " )\n" + "except Exception:\n" + " pass\n" + LITE_RENDERER_CELL + # Draw MNE's 3D figures with pyvista-js. Appended last so MNE is + # already imported; see doc/sphinxext/jupyterlite_lite_renderer.py. +) From 1e6e216f567d7dcd7ee6073769a87f32203bfa1c Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Tue, 11 Aug 2026 09:27:04 -0400 Subject: [PATCH 02/11] DOC: add the changelog entry for the setup cell --- doc/changes/dev/14150.other.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changes/dev/14150.other.rst diff --git a/doc/changes/dev/14150.other.rst b/doc/changes/dev/14150.other.rst new file mode 100644 index 00000000000..948bb812fb1 --- /dev/null +++ b/doc/changes/dev/14150.other.rst @@ -0,0 +1 @@ +Add the setup cell that installs MNE into the browser kernel for the JupyterLite documentation, by `Natneal B`_. From 6e49df16978498bd7128199f6666ceeefeefb120 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Fri, 14 Aug 2026 09:32:12 -0400 Subject: [PATCH 03/11] DOC: correct what keep_going does in the setup cell It controls whether a dependency with no pure-Python wheel aborts the install or is reported at the end. It was never about version bounds, and since the move to Pyodide 314 there are none left to clear anyway. --- doc/sphinxext/jupyterlite_setup_cell.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/sphinxext/jupyterlite_setup_cell.py b/doc/sphinxext/jupyterlite_setup_cell.py index 10729a27cb8..5204ec7713c 100644 --- a/doc/sphinxext/jupyterlite_setup_cell.py +++ b/doc/sphinxext/jupyterlite_setup_cell.py @@ -25,8 +25,8 @@ "# bundled into the JupyterLite build is preferred over the older PyPI\n" "# release;\n" "# piplite checks the local index first and falls back to PyPI for deps.\n" - "# keep_going=True lets it install even if Pyodide's bundled\n" - "# matplotlib/scipy/numpy are older than MNE's declared minimums.\n" + "# keep_going=True so a dependency with no pure-Python wheel is reported\n" + "# at the end rather than aborting the whole install on the first one.\n" "await piplite.install(\n" " ['mne', 'scikit-learn', 'joblib', 'pandas', 'seaborn', " "'mne-connectivity', 'nibabel', 'pyvista-js', 'pyxdf', 'mffpy', " From 9045bfd614f48d6191398479afab89d4d3901a47 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 24 Aug 2026 11:25:26 -0400 Subject: [PATCH 04/11] MAINT: move the JupyterLite setup cell into a real source file --- doc/sphinxext/_lite_setup_cell.py | 1092 +++++++++++++++++++++++ doc/sphinxext/jupyterlite_setup_cell.py | 901 +------------------ 2 files changed, 1113 insertions(+), 880 deletions(-) create mode 100644 doc/sphinxext/_lite_setup_cell.py diff --git a/doc/sphinxext/_lite_setup_cell.py b/doc/sphinxext/_lite_setup_cell.py new file mode 100644 index 00000000000..ac80e878748 --- /dev/null +++ b/doc/sphinxext/_lite_setup_cell.py @@ -0,0 +1,1092 @@ +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +# This file is notebook source rather than a module: it installs packages with +# a top-level ``await`` and imports them only afterwards, so the rules about +# import position, await position and import order do not apply to it. Ruff +# still lints and formats everything else here, which is the point of keeping +# it as a real file instead of a string. +# ruff: noqa: E402, F704, I001 + +# --- JupyterLite setup cell ------------------------------------------------- +# 💡 This cell is automatically added to the start of each notebook. +# It installs MNE and patches the browser environment for Pyodide. +import piplite + +# Use piplite (not micropip) so the locally-built development MNE wheel +# bundled into the JupyterLite build is preferred over the older PyPI +# release; +# piplite checks the local index first and falls back to PyPI for deps. +# keep_going=True so a dependency with no pure-Python wheel is reported +# at the end rather than aborting the whole install on the first one. +await piplite.install( + [ + "mne", + "scikit-learn", + "joblib", + "pandas", + "seaborn", + "mne-connectivity", + "nibabel", + "pyvista-js", + "pyxdf", + "mffpy", + "python-picard", + ], + keep_going=True, +) + +import sys +import os +import io + +# lzma: try real stdlib first (Pyodide ships it); only mock if absent. The +# import has to be attempted rather than probed with find_spec, because the +# mock below is only installed when it actually fails. +try: + import lzma # noqa: F401 +except ImportError: + + class _LZMAFile: + def __init__(self, *a, **kw): + pass + + def __enter__(self): + return self + + def __exit__(self, *a): + pass + + def write(self, d): + pass + + def read(self, n=-1): + return b"" + + def close(self): + pass + + class _MockLZMA: + LZMAError = Exception + LZMAFile = _LZMAFile + FORMAT_XZ = 1 + FORMAT_ALONE = 2 + + def __getattr__(self, name): + return object + + import sys as _sys + + _sys.modules["lzma"] = _MockLZMA() + +# Mock multiprocessing — missing in Pyodide but imported by joblib +from unittest.mock import MagicMock + +if "multiprocessing" not in sys.modules: + m = MagicMock() + m.cpu_count.return_value = 1 + sys.modules["multiprocessing"] = m + sys.modules["multiprocessing.util"] = m.util + sys.modules["multiprocessing.pool"] = m.pool + +# Patch requests so pooch can fetch files already on /drive/mne_data. +# open_url works for both text and binary in Pyodide >= 0.21. +import requests +import pyodide + +orig_send = requests.Session.send + + +def pyodide_send(self, request, **kwargs): + try: + buf = pyodide.http.open_url(request.url) + content = buf.getvalue() if hasattr(buf, "getvalue") else buf.read() + if isinstance(content, str): + content = content.encode("utf-8") + except Exception as e: + print(f"open_url failed for {request.url}: {e}") + return orig_send(self, request, **kwargs) + response = requests.Response() + response.status_code = 200 + response.url = request.url + response.raw = io.BytesIO(content) + return response + + +requests.Session.send = pyodide_send + +# /drive/ in Pyodide requires Cross-Origin-Isolation headers +# (COOP/COEP) which many static servers (e.g. CircleCI artifacts) +# do not send. Fetch the data over HTTP into /tmp/mne_data instead +# — same-origin, no CORS. The data is served at the docs root +# (/mne_data/...) via Sphinx html_extra_path. +# Pyodide may run in a web worker (no `window`); `location` exists +# in both the main thread and workers, so use it to find the docs +# root by splitting on '/lite/'. +import pyodide.http as _phttp +import js as _js + +try: + _page = str(_js.location.href) +except Exception: + _page = str(_js.window.location.href) +_base = _page.split("/lite/")[0] + "/mne_data/" +mne_data_path = "/tmp/mne_data" +_sample_dir = mne_data_path + "/MNE-sample-data" +# Eager 'core': small, commonly-used sample files fetched once at +# notebook start. The heavy files (raw / filt raw / ernoise / fwd / +# inv / src, ~360 MB total) are intentionally omitted here -- they are +# fetched lazily on first read via the reader shims below, so each +# notebook only downloads the sample files it actually uses. +_sample_files = [ + "version.txt", + "MEG/sample/sample_audvis_raw-eve.fif", + "MEG/sample/sample_audvis_filt-0-40_raw-eve.fif", + "MEG/sample/sample_audvis_ecg-proj.fif", + "MEG/sample/sample_audvis-cov.fif", + "MEG/sample/sample_audvis-ave.fif", + "MEG/sample/sample_audvis-no-filter-ave.fif", + "MEG/sample/sample_audvis_raw-trans.fif", + "MEG/sample/sample_audvis-shrunk-cov.fif", + "MEG/sample/sample_audvis-meg-lh.stc", + "MEG/sample/sample_audvis-meg-rh.stc", + "subjects/sample/mri/T1.mgz", + "subjects/sample/surf/rh.pial", + "subjects/sample/surf/lh.pial", + "subjects/sample/surf/rh.white", + "subjects/sample/surf/lh.white", + "subjects/sample/label/lh.aparc.annot", + "subjects/sample/label/rh.aparc.annot", + "SSS/sss_cal_mgh.dat", + "SSS/ct_sparse_mgh.fif", +] +print("Fetching MNE sample data (once per session)...") +for _f in _sample_files: + _dst = _sample_dir + "/" + _f + if os.path.exists(_dst): + continue + _url = _base + "MNE-sample-data/" + _f + try: + _r = await _phttp.pyfetch(_url) + if _r.status != 200: + print(f" HTTP {_r.status} for {_url}") + continue + _d = await _r.bytes() + if _d[:4] == b"=0) + _fc = _cv[_tris].mean(1) + for _cm, _col in ( + (_fc < 0, (0.68, 0.68, 0.68)), + (_fc >= 0, (0.38, 0.38, 0.38)), + ): + _s = _sub(_pts, _tris, _cm) + if _s is not None: + _plotter.add_mesh( + _pv.PolyData(points=_s[0], faces=_flat(_s[1])), + color=_col, + smooth_shading=True, + ) + # activation as a smooth hot gradient in N value bands, + # each lifted 2% off the surface to avoid z-fighting + _fv = _scal[_tris].mean(1) + _p90 = _np.percentile(_scal, 90.0) + _fmax = float(_scal.max()) + # keep the background gray: for sparse point sources the + # 90th pct is ~0 (most of the brain is zero), which would + # paint everything, so fall back to a fraction of the max. + _fmin = _p90 if _p90 > _fmax * 0.05 else _fmax * 0.4 + if _fmax > _fmin: + _edges = _np.linspace(_fmin, _fmax, _N + 1) + for _i in range(_N): + if _i < _N - 1: + _m = (_fv >= _edges[_i]) & (_fv < _edges[_i + 1]) + else: + _m = _fv >= _edges[_i] + if int(_m.sum()) == 0: + continue + _rgb = _hot(0.25 + 0.41 * (_i / (_N - 1))) + _col = (float(_rgb[0]), float(_rgb[1]), float(_rgb[2])) + _s = _sub(_pts, _tris, _m, 0.02, _cen) + if _s is not None: + _plotter.add_mesh( + _pv.PolyData(points=_s[0], faces=_flat(_s[1])), + color=_col, + smooth_shading=True, + ) + # Open on the lateral profile (camera along the medial-lateral + # X axis, superior up), like native MNE, instead of vtk.js's + # default anterior/face-on view. Guarded so a missing + # view_vector never costs us the render. + try: + _plotter.view_vector((-1.0, 0.0, 0.0), viewup=(0.0, 0.0, 1.0)) + except Exception: + pass + _plotter.show() + except Exception as _e: + print("[JupyterLite] pyvista-js 3D render unavailable: " + repr(_e)) + return _LiteBrain() + + +mne.SourceEstimate.plot = _lite_stc_plot + +# Pyodide/WASM has no OS threads, so MNE's ProgressBar background +# updater thread (used by the ProgressBar context manager, e.g. in +# permutation cluster tests) crashes with 'can't start new thread'. +# That thread only animates a cosmetic bar — the computation runs on +# the main thread and __exit__ writes the final state — so no-op its +# start/join. Only affects notebooks that use it; results are unchanged. +try: + from mne.utils import progressbar as _mpb + + _mpb._UpdateThread.start = lambda self: None + _mpb._UpdateThread.join = lambda self, *_a, **_kw: None +except Exception: + pass +# tqdm also spawns its own monitor thread, which likewise can't start in +# WASM and emits a TqdmMonitorWarning. Setting monitor_interval=0 before +# any bar is created skips that thread entirely (bars still display). +try: + import tqdm as _tqdm + + _tqdm.tqdm.monitor_interval = 0 +except Exception: + pass + +# Switch matplotlib to inline so figures render in the notebook. +import IPython + +IPython.get_ipython().run_line_magic("matplotlib", "inline") +import matplotlib.pyplot as plt + +# Silence the spurious 'FigureCanvasAgg is non-interactive' warning +# at its source. MNE's plt_show calls fig.show() (the inline backend +# isn't detected as 'agg'), and the inline Agg canvas warns. Patching +# viz.utils.plt_show is not enough: other modules did +# `from .utils import plt_show` and hold their own reference. Every +# path resolves fig.show on the class at call time, so a no-op here +# silences it everywhere. Figures still render via the inline backend. +import matplotlib.figure as _mfig + +_mfig.Figure.show = lambda self, *a, **k: None +import importlib + +viz_utils = importlib.import_module("mne.viz.utils") + + +# Also display+close via IPython for paths that call plt_show +# directly, so figures render exactly once. +def pyodide_plt_show(show=True, fig=None, **kwargs): + if not show: + return + import IPython.display + + _f = fig if fig is not None else plt.gcf() + IPython.display.display(_f) + plt.close(_f) + + +viz_utils.plt_show = pyodide_plt_show + + +# EXPERIMENTAL 3D: plot_sparse_source_estimates builds its 3D renderer +# BEFORE the time-course figure, so in WASM the whole call dies and the +# notebook loses both halves. Rebuild it here: the same glass brain from +# the source space and a marker per active dipole via pyvista-js, plus +# the matplotlib time courses (which are the quantitative half). Same +# approach as the SourceEstimate.plot shim above. +def _lite_plot_sparse_source_estimates( + src, + stcs, + colors=None, + linewidth=2, + fontsize=18, + bgcolor=(0.05, 0, 0.1), + opacity=0.2, + brain_color=(0.7,) * 3, + show=True, + high_resolution=False, + fig_name=None, + fig_number=None, + labels=None, + modes=("cone", "sphere"), + scale_factors=(1, 0.6), + **kwargs, +): + import numpy as _np + from itertools import cycle as _cycle + from matplotlib.colors import to_rgb as _to_rgb + + if not isinstance(stcs, list): + stcs = [stcs] + _lhp = src[0]["rr"] + _pts = _np.r_[_lhp, src[1]["rr"]] * 170 + _nrm = _np.r_[src[0]["nn"], src[1]["nn"]] + # use_tris is the decimated mesh and can be None on some source + # spaces; fall back to the full tris in that case. + _lt = src[0]["tris"] if high_resolution else src[0]["use_tris"] + _rt = src[1]["tris"] if high_resolution else src[1]["use_tris"] + if _lt is None or _rt is None: + _lt, _rt = src[0]["tris"], src[1]["tris"] + _faces = _np.r_[_lt, len(_lhp) + _rt] + _vertnos = [_np.r_[_s.lh_vertno, len(_lhp) + _s.rh_vertno] for _s in stcs] + _uniq = _np.unique(_np.concatenate(_vertnos).ravel()) + # --- time courses ------------------------------------------------- + _fig = plt.figure(fig_number, layout="constrained") + _fig.clf() + _ax = _fig.add_subplot(111) + _cyc = _cycle( + colors + if colors is not None + else plt.rcParams["axes.prop_cycle"].by_key()["color"] + ) + _marks = [] + for _v in _uniq: + _ind = [_k for _k, _vn in enumerate(_vertnos) if _v in _vn] + _c = next(_cyc) + _marks.append((int(_v), _to_rgb(_c), len(_ind) > 1)) + for _k in _ind: + _m = _vertnos[_k] == _v + _ax.plot( + 1e3 * stcs[_k].times, + 1e9 * stcs[_k].data[_m].ravel(), + c=_c, + linewidth=linewidth, + ) + _ax.set_xlabel("Time (ms)", fontsize=fontsize) + _ax.set_ylabel("Source amplitude (nAm)", fontsize=fontsize) + if fig_name is not None: + _ax.set_title(fig_name) + pyodide_plt_show(show) + # --- glass brain + dipole markers --------------------------------- + try: + import pyvista_js as _pv + + _plotter = _pv.Plotter() + _plotter.background_color = tuple( + float(min(max(_x, 0.0), 1.0)) for _x in bgcolor + ) + for _lp in ( + (1, 0, 0), + (-1, 0, 0), + (0, 1, 0), + (0, -1, 0), + (0, 0, 1), + (0, 0, -1), + ): + _plotter.add_light( + _pv.Light( + position=(300.0 * _lp[0], 300.0 * _lp[1], 300.0 * _lp[2]), + focal_point=(0.0, 0.0, 0.0), + intensity=0.4, + ) + ) + _flat_faces = _np.hstack( + [_np.full((len(_faces), 1), 3, dtype=_np.int32), _faces.astype(_np.int32)] + ).ravel() + _plotter.add_mesh( + _pv.PolyData(points=_pts.astype(_np.float32), faces=_flat_faces), + color=tuple(float(_x) for _x in brain_color), + opacity=float(opacity), + smooth_shading=True, + ) + for _v, _col, _common in _marks: + _sf = float(scale_factors[1] if _common else scale_factors[0]) + _mode = modes[1] if _common else modes[0] + _xyz = tuple(float(_q) for _q in _pts[_v]) + if _mode == "sphere": + _glyph = _pv.Sphere(radius=_sf, center=_xyz) + else: + _glyph = _pv.Cone( + center=_xyz, + direction=tuple(float(_q) for _q in _nrm[_v]), + height=2.0 * _sf, + radius=_sf, + ) + _plotter.add_mesh(_glyph, color=_col, smooth_shading=True) + try: + _plotter.view_vector((-1.0, 0.0, 0.0), viewup=(0.0, 0.0, 1.0)) + except Exception: + pass + _plotter.show() + except Exception as _e: + print("[JupyterLite] pyvista-js glass brain unavailable: " + repr(_e)) + + +mne.viz.plot_sparse_source_estimates = _lite_plot_sparse_source_estimates + +# Each MNE plot is rendered once by pyodide_plt_show above (display()). +# When a plot call is also a cell's last expression, the method returns +# the Figure, which Jupyter echoes a SECOND time as the Out[] result +# (the duplicate seen below inline plots). Drop that redundant echo for +# Figures (and pure lists of Figures, e.g. ica.plot_properties) so each +# plot appears exactly once. Non-figure results (numbers, DataFrames, +# reprs) are untouched, and raw matplotlib figures never shown still +# render via the inline backend's end-of-cell flush, so nothing hides. +# Wrapped in try/except (like the patches below): if anything about +# the displayhook is unexpected, silently keep the current behavior +# (harmless double render) rather than breaking the setup cell. +try: + _lite_dh = type(IPython.get_ipython().displayhook) + if not getattr(_lite_dh, "_lite_no_fig_echo", False): + _lite_dh_call = _lite_dh.__call__ + + def _lite_displayhook(self, result=None): + if isinstance(result, _mfig.Figure): + result = None + elif ( + isinstance(result, (list, tuple)) + and result + and all(isinstance(_x, _mfig.Figure) for _x in result) + ): + result = None + return _lite_dh_call(self, result) + + _lite_dh.__call__ = _lite_displayhook + _lite_dh._lite_no_fig_echo = True +except Exception: + pass + +# Real fix (not a warnings filter) for the threadpoolctl Pyodide +# RuntimeWarning seen via mne.sys_info(): threadpoolctl 3.6.0 (latest +# release) still calls the deprecated Pyodide JsProxy.as_object_map(). +# Pyodide's own message says to use as_py_json() instead; both yield the +# same library filepaths, so we swap the call at its source. This removes +# the deprecated API usage entirely, so the warning is never emitted. +# The upstream fix is already merged (joblib/threadpoolctl#201) but +# unreleased; Pyodide bundles the released 3.6.0 wheel. DROP THIS PATCH +# once threadpoolctl 3.7.0 is released and Pyodide bundles it. +try: + import os as _os + import threadpoolctl as _tpc + + def _find_libraries_pyodide(self): + from pyodide_js._module import LDSO + + for _fp in LDSO.loadedLibsByName.as_py_json(): + if _os.path.exists(_fp): + self._make_controller_from_path(_fp) + + _tpc.ThreadpoolController._find_libraries_pyodide = _find_libraries_pyodide +except Exception: + pass diff --git a/doc/sphinxext/jupyterlite_setup_cell.py b/doc/sphinxext/jupyterlite_setup_cell.py index 5204ec7713c..33cfa89ecae 100644 --- a/doc/sphinxext/jupyterlite_setup_cell.py +++ b/doc/sphinxext/jupyterlite_setup_cell.py @@ -1,8 +1,10 @@ """The setup cell prepended to every JupyterLite notebook. -This installs MNE into the browser kernel and patches the bits of the -environment Pyodide does not provide: data fetching over HTTP, the readers -that expect files already on disk, and the 3D renderer. +It installs MNE into the browser kernel and patches what Pyodide does not +provide: data fetching over HTTP, the readers that expect files already on +disk, and the 3D renderer. The cell itself lives in ``_lite_setup_cell.py`` as +ordinary Python, so ruff lints and formats it; this module only reads that file +and exposes it as the string the browser kernel needs. The docs build prepends it only to the notebooks copied into the JupyterLite contents. It deliberately does NOT go through ``first_notebook_cell``: that is @@ -15,882 +17,21 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. +from pathlib import Path + from jupyterlite_lite_renderer import LITE_RENDERER_CELL -LITE_SETUP_CELL = ( - "# 💡 This cell is automatically added to the start of each notebook.\n" - "# It installs MNE and patches the browser environment for Pyodide.\n" - "import piplite\n" - "# Use piplite (not micropip) so the locally-built development MNE wheel\n" - "# bundled into the JupyterLite build is preferred over the older PyPI\n" - "# release;\n" - "# piplite checks the local index first and falls back to PyPI for deps.\n" - "# keep_going=True so a dependency with no pure-Python wheel is reported\n" - "# at the end rather than aborting the whole install on the first one.\n" - "await piplite.install(\n" - " ['mne', 'scikit-learn', 'joblib', 'pandas', 'seaborn', " - "'mne-connectivity', 'nibabel', 'pyvista-js', 'pyxdf', 'mffpy', " - "'python-picard'],\n" - " keep_going=True,\n" - ")\n" - "\n" - "import sys\n" - "import os\n" - "import io\n" - "\n" - "# lzma: try real stdlib first (Pyodide ships it); only mock if absent\n" - "try:\n" - " import lzma\n" - "except ImportError:\n" - " class _LZMAFile:\n" - " def __init__(self, *a, **kw): pass\n" - " def __enter__(self): return self\n" - " def __exit__(self, *a): pass\n" - " def write(self, d): pass\n" - " def read(self, n=-1): return b''\n" - " def close(self): pass\n" - " class _MockLZMA:\n" - " LZMAError = Exception\n" - " LZMAFile = _LZMAFile\n" - " FORMAT_XZ = 1\n" - " FORMAT_ALONE = 2\n" - " def __getattr__(self, name): return object\n" - " import sys as _sys\n" - " _sys.modules['lzma'] = _MockLZMA()\n" - "\n" - "# Mock multiprocessing — missing in Pyodide but imported by joblib\n" - "from unittest.mock import MagicMock\n" - "if 'multiprocessing' not in sys.modules:\n" - " m = MagicMock()\n" - " m.cpu_count.return_value = 1\n" - " sys.modules['multiprocessing'] = m\n" - " sys.modules['multiprocessing.util'] = m.util\n" - " sys.modules['multiprocessing.pool'] = m.pool\n" - "\n" - "# Patch requests so pooch can fetch files already on /drive/mne_data.\n" - "# open_url works for both text and binary in Pyodide >= 0.21.\n" - "import requests\n" - "import pyodide\n" - "orig_send = requests.Session.send\n" - "def pyodide_send(self, request, **kwargs):\n" - " try:\n" - " buf = pyodide.http.open_url(request.url)\n" - " content = buf.getvalue() if hasattr(buf, 'getvalue') else buf.read()\n" - " if isinstance(content, str):\n" - " content = content.encode('utf-8')\n" - " except Exception as e:\n" - " print(f'open_url failed for {request.url}: {e}')\n" - " return orig_send(self, request, **kwargs)\n" - " response = requests.Response()\n" - " response.status_code = 200\n" - " response.url = request.url\n" - " response.raw = io.BytesIO(content)\n" - " return response\n" - "requests.Session.send = pyodide_send\n" - "\n" - "# /drive/ in Pyodide requires Cross-Origin-Isolation headers\n" - "# (COOP/COEP) which many static servers (e.g. CircleCI artifacts)\n" - "# do not send. Fetch the data over HTTP into /tmp/mne_data instead\n" - "# — same-origin, no CORS. The data is served at the docs root\n" - "# (/mne_data/...) via Sphinx html_extra_path.\n" - "# Pyodide may run in a web worker (no `window`); `location` exists\n" - "# in both the main thread and workers, so use it to find the docs\n" - "# root by splitting on '/lite/'.\n" - "import pyodide.http as _phttp\n" - "import js as _js\n" - "try:\n" - " _page = str(_js.location.href)\n" - "except Exception:\n" - " _page = str(_js.window.location.href)\n" - "_base = _page.split('/lite/')[0] + '/mne_data/'\n" - "mne_data_path = '/tmp/mne_data'\n" - "_sample_dir = mne_data_path + '/MNE-sample-data'\n" - "# Eager 'core': small, commonly-used sample files fetched once at\n" - "# notebook start. The heavy files (raw / filt raw / ernoise / fwd /\n" - "# inv / src, ~360 MB total) are intentionally omitted here -- they are\n" - "# fetched lazily on first read via the reader shims below, so each\n" - "# notebook only downloads the sample files it actually uses.\n" - "_sample_files = [\n" - " 'version.txt',\n" - " 'MEG/sample/sample_audvis_raw-eve.fif',\n" - " 'MEG/sample/sample_audvis_filt-0-40_raw-eve.fif',\n" - " 'MEG/sample/sample_audvis_ecg-proj.fif',\n" - " 'MEG/sample/sample_audvis-cov.fif',\n" - " 'MEG/sample/sample_audvis-ave.fif',\n" - " 'MEG/sample/sample_audvis-no-filter-ave.fif',\n" - " 'MEG/sample/sample_audvis_raw-trans.fif',\n" - " 'MEG/sample/sample_audvis-shrunk-cov.fif',\n" - " 'MEG/sample/sample_audvis-meg-lh.stc',\n" - " 'MEG/sample/sample_audvis-meg-rh.stc',\n" - " 'subjects/sample/mri/T1.mgz',\n" - " 'subjects/sample/surf/rh.pial',\n" - " 'subjects/sample/surf/lh.pial',\n" - " 'subjects/sample/surf/rh.white',\n" - " 'subjects/sample/surf/lh.white',\n" - " 'subjects/sample/label/lh.aparc.annot',\n" - " 'subjects/sample/label/rh.aparc.annot',\n" - " 'SSS/sss_cal_mgh.dat',\n" - " 'SSS/ct_sparse_mgh.fif',\n" - "]\n" - "print('Fetching MNE sample data (once per session)...')\n" - "for _f in _sample_files:\n" - " _dst = _sample_dir + '/' + _f\n" - " if os.path.exists(_dst):\n" - " continue\n" - " _url = _base + 'MNE-sample-data/' + _f\n" - " try:\n" - " _r = await _phttp.pyfetch(_url)\n" - " if _r.status != 200:\n" - " print(f' HTTP {_r.status} for {_url}')\n" - " continue\n" - " _d = await _r.bytes()\n" - " if _d[:4] == b'=0)\n" - " _fc = _cv[_tris].mean(1)\n" - " for _cm, _col in (\n" - " (_fc < 0, (0.68, 0.68, 0.68)),\n" - " (_fc >= 0, (0.38, 0.38, 0.38))):\n" - " _s = _sub(_pts, _tris, _cm)\n" - " if _s is not None:\n" - " _plotter.add_mesh(\n" - " _pv.PolyData(points=_s[0], faces=_flat(_s[1])),\n" - " color=_col, smooth_shading=True)\n" - " # activation as a smooth hot gradient in N value bands,\n" - " # each lifted 2% off the surface to avoid z-fighting\n" - " _fv = _scal[_tris].mean(1)\n" - " _p90 = _np.percentile(_scal, 90.0)\n" - " _fmax = float(_scal.max())\n" - " # keep the background gray: for sparse point sources the\n" - " # 90th pct is ~0 (most of the brain is zero), which would\n" - " # paint everything, so fall back to a fraction of the max.\n" - " _fmin = _p90 if _p90 > _fmax * 0.05 else _fmax * 0.4\n" - " if _fmax > _fmin:\n" - " _edges = _np.linspace(_fmin, _fmax, _N + 1)\n" - " for _i in range(_N):\n" - " if _i < _N - 1:\n" - " _m = (_fv >= _edges[_i]) & (_fv < _edges[_i + 1])\n" - " else:\n" - " _m = _fv >= _edges[_i]\n" - " if int(_m.sum()) == 0:\n" - " continue\n" - " _rgb = _hot(0.25 + 0.41 * (_i / (_N - 1)))\n" - " _col = (float(_rgb[0]), float(_rgb[1]),\n" - " float(_rgb[2]))\n" - " _s = _sub(_pts, _tris, _m, 0.02, _cen)\n" - " if _s is not None:\n" - " _plotter.add_mesh(\n" - " _pv.PolyData(points=_s[0],\n" - " faces=_flat(_s[1])),\n" - " color=_col, smooth_shading=True)\n" - " # Open on the lateral profile (camera along the medial-lateral\n" - " # X axis, superior up), like native MNE, instead of vtk.js's\n" - " # default anterior/face-on view. Guarded so a missing\n" - " # view_vector never costs us the render.\n" - " try:\n" - " _plotter.view_vector((-1.0, 0.0, 0.0),\n" - " viewup=(0.0, 0.0, 1.0))\n" - " except Exception:\n" - " pass\n" - " _plotter.show()\n" - " except Exception as _e:\n" - " print('[JupyterLite] pyvista-js 3D render unavailable: '\n" - " + repr(_e))\n" - " return _LiteBrain()\n" - "mne.SourceEstimate.plot = _lite_stc_plot\n" - "\n" - "# Pyodide/WASM has no OS threads, so MNE's ProgressBar background\n" - "# updater thread (used by the ProgressBar context manager, e.g. in\n" - "# permutation cluster tests) crashes with 'can't start new thread'.\n" - "# That thread only animates a cosmetic bar — the computation runs on\n" - "# the main thread and __exit__ writes the final state — so no-op its\n" - "# start/join. Only affects notebooks that use it; results are unchanged.\n" - "try:\n" - " from mne.utils import progressbar as _mpb\n" - " _mpb._UpdateThread.start = lambda self: None\n" - " _mpb._UpdateThread.join = lambda self, *_a, **_kw: None\n" - "except Exception:\n" - " pass\n" - "# tqdm also spawns its own monitor thread, which likewise can't start in\n" - "# WASM and emits a TqdmMonitorWarning. Setting monitor_interval=0 before\n" - "# any bar is created skips that thread entirely (bars still display).\n" - "try:\n" - " import tqdm as _tqdm\n" - " _tqdm.tqdm.monitor_interval = 0\n" - "except Exception:\n" - " pass\n" - "\n" - "# Switch matplotlib to inline so figures render in the notebook.\n" - "import IPython\n" - "IPython.get_ipython().run_line_magic('matplotlib', 'inline')\n" - "import matplotlib.pyplot as plt\n" - "# Silence the spurious 'FigureCanvasAgg is non-interactive' warning\n" - "# at its source. MNE's plt_show calls fig.show() (the inline backend\n" - "# isn't detected as 'agg'), and the inline Agg canvas warns. Patching\n" - "# viz.utils.plt_show is not enough: other modules did\n" - "# `from .utils import plt_show` and hold their own reference. Every\n" - "# path resolves fig.show on the class at call time, so a no-op here\n" - "# silences it everywhere. Figures still render via the inline backend.\n" - "import matplotlib.figure as _mfig\n" - "_mfig.Figure.show = lambda self, *a, **k: None\n" - "import importlib\n" - "viz_utils = importlib.import_module('mne.viz.utils')\n" - "# Also display+close via IPython for paths that call plt_show\n" - "# directly, so figures render exactly once.\n" - "def pyodide_plt_show(show=True, fig=None, **kwargs):\n" - " if not show:\n" - " return\n" - " import IPython.display\n" - " _f = fig if fig is not None else plt.gcf()\n" - " IPython.display.display(_f)\n" - " plt.close(_f)\n" - "viz_utils.plt_show = pyodide_plt_show\n" - "\n" - "# EXPERIMENTAL 3D: plot_sparse_source_estimates builds its 3D renderer\n" - "# BEFORE the time-course figure, so in WASM the whole call dies and the\n" - "# notebook loses both halves. Rebuild it here: the same glass brain from\n" - "# the source space and a marker per active dipole via pyvista-js, plus\n" - "# the matplotlib time courses (which are the quantitative half). Same\n" - "# approach as the SourceEstimate.plot shim above.\n" - "def _lite_plot_sparse_source_estimates(\n" - " src, stcs, colors=None, linewidth=2, fontsize=18,\n" - " bgcolor=(0.05, 0, 0.1), opacity=0.2, brain_color=(0.7,) * 3,\n" - " show=True, high_resolution=False, fig_name=None,\n" - " fig_number=None, labels=None, modes=('cone', 'sphere'),\n" - " scale_factors=(1, 0.6), **kwargs):\n" - " import numpy as _np\n" - " from itertools import cycle as _cycle\n" - " from matplotlib.colors import to_rgb as _to_rgb\n" - " if not isinstance(stcs, list):\n" - " stcs = [stcs]\n" - " _lhp = src[0]['rr']\n" - " _pts = _np.r_[_lhp, src[1]['rr']] * 170\n" - " _nrm = _np.r_[src[0]['nn'], src[1]['nn']]\n" - " # use_tris is the decimated mesh and can be None on some source\n" - " # spaces; fall back to the full tris in that case.\n" - " _lt = src[0]['tris'] if high_resolution else src[0]['use_tris']\n" - " _rt = src[1]['tris'] if high_resolution else src[1]['use_tris']\n" - " if _lt is None or _rt is None:\n" - " _lt, _rt = src[0]['tris'], src[1]['tris']\n" - " _faces = _np.r_[_lt, len(_lhp) + _rt]\n" - " _vertnos = [_np.r_[_s.lh_vertno, len(_lhp) + _s.rh_vertno]\n" - " for _s in stcs]\n" - " _uniq = _np.unique(_np.concatenate(_vertnos).ravel())\n" - " # --- time courses -------------------------------------------------\n" - " _fig = plt.figure(fig_number, layout='constrained')\n" - " _fig.clf()\n" - " _ax = _fig.add_subplot(111)\n" - " _cyc = _cycle(colors if colors is not None else\n" - " plt.rcParams['axes.prop_cycle'].by_key()['color'])\n" - " _marks = []\n" - " for _v in _uniq:\n" - " _ind = [_k for _k, _vn in enumerate(_vertnos) if _v in _vn]\n" - " _c = next(_cyc)\n" - " _marks.append((int(_v), _to_rgb(_c), len(_ind) > 1))\n" - " for _k in _ind:\n" - " _m = _vertnos[_k] == _v\n" - " _ax.plot(1e3 * stcs[_k].times,\n" - " 1e9 * stcs[_k].data[_m].ravel(),\n" - " c=_c, linewidth=linewidth)\n" - " _ax.set_xlabel('Time (ms)', fontsize=fontsize)\n" - " _ax.set_ylabel('Source amplitude (nAm)', fontsize=fontsize)\n" - " if fig_name is not None:\n" - " _ax.set_title(fig_name)\n" - " pyodide_plt_show(show)\n" - " # --- glass brain + dipole markers ---------------------------------\n" - " try:\n" - " import pyvista_js as _pv\n" - " _plotter = _pv.Plotter()\n" - " _plotter.background_color = tuple(\n" - " float(min(max(_x, 0.0), 1.0)) for _x in bgcolor)\n" - " for _lp in ((1, 0, 0), (-1, 0, 0), (0, 1, 0),\n" - " (0, -1, 0), (0, 0, 1), (0, 0, -1)):\n" - " _plotter.add_light(_pv.Light(\n" - " position=(300.0 * _lp[0], 300.0 * _lp[1],\n" - " 300.0 * _lp[2]),\n" - " focal_point=(0.0, 0.0, 0.0), intensity=0.4))\n" - " _flat_faces = _np.hstack([\n" - " _np.full((len(_faces), 1), 3, dtype=_np.int32),\n" - " _faces.astype(_np.int32)]).ravel()\n" - " _plotter.add_mesh(\n" - " _pv.PolyData(points=_pts.astype(_np.float32),\n" - " faces=_flat_faces),\n" - " color=tuple(float(_x) for _x in brain_color),\n" - " opacity=float(opacity), smooth_shading=True)\n" - " for _v, _col, _common in _marks:\n" - " _sf = float(scale_factors[1] if _common\n" - " else scale_factors[0])\n" - " _mode = modes[1] if _common else modes[0]\n" - " _xyz = tuple(float(_q) for _q in _pts[_v])\n" - " if _mode == 'sphere':\n" - " _glyph = _pv.Sphere(radius=_sf, center=_xyz)\n" - " else:\n" - " _glyph = _pv.Cone(\n" - " center=_xyz,\n" - " direction=tuple(float(_q) for _q in _nrm[_v]),\n" - " height=2.0 * _sf, radius=_sf)\n" - " _plotter.add_mesh(_glyph, color=_col, smooth_shading=True)\n" - " try:\n" - " _plotter.view_vector((-1.0, 0.0, 0.0),\n" - " viewup=(0.0, 0.0, 1.0))\n" - " except Exception:\n" - " pass\n" - " _plotter.show()\n" - " except Exception as _e:\n" - " print('[JupyterLite] pyvista-js glass brain unavailable: '\n" - " + repr(_e))\n" - "mne.viz.plot_sparse_source_estimates = _lite_plot_sparse_source_estimates\n" - "\n" - "# Each MNE plot is rendered once by pyodide_plt_show above (display()).\n" - "# When a plot call is also a cell's last expression, the method returns\n" - "# the Figure, which Jupyter echoes a SECOND time as the Out[] result\n" - "# (the duplicate seen below inline plots). Drop that redundant echo for\n" - "# Figures (and pure lists of Figures, e.g. ica.plot_properties) so each\n" - "# plot appears exactly once. Non-figure results (numbers, DataFrames,\n" - "# reprs) are untouched, and raw matplotlib figures never shown still\n" - "# render via the inline backend's end-of-cell flush, so nothing hides.\n" - "# Wrapped in try/except (like the patches below): if anything about\n" - "# the displayhook is unexpected, silently keep the current behavior\n" - "# (harmless double render) rather than breaking the setup cell.\n" - "try:\n" - " _lite_dh = type(IPython.get_ipython().displayhook)\n" - " if not getattr(_lite_dh, '_lite_no_fig_echo', False):\n" - " _lite_dh_call = _lite_dh.__call__\n" - " def _lite_displayhook(self, result=None):\n" - " if isinstance(result, _mfig.Figure):\n" - " result = None\n" - " elif (isinstance(result, (list, tuple)) and result\n" - " and all(isinstance(_x, _mfig.Figure) for _x in result)):\n" - " result = None\n" - " return _lite_dh_call(self, result)\n" - " _lite_dh.__call__ = _lite_displayhook\n" - " _lite_dh._lite_no_fig_echo = True\n" - "except Exception:\n" - " pass\n" - "\n" - "# Real fix (not a warnings filter) for the threadpoolctl Pyodide\n" - "# RuntimeWarning seen via mne.sys_info(): threadpoolctl 3.6.0 (latest\n" - "# release) still calls the deprecated Pyodide JsProxy.as_object_map().\n" - "# Pyodide's own message says to use as_py_json() instead; both yield the\n" - "# same library filepaths, so we swap the call at its source. This removes\n" - "# the deprecated API usage entirely, so the warning is never emitted.\n" - "# The upstream fix is already merged (joblib/threadpoolctl#201) but\n" - "# unreleased; Pyodide bundles the released 3.6.0 wheel. DROP THIS PATCH\n" - "# once threadpoolctl 3.7.0 is released and Pyodide bundles it.\n" - "try:\n" - " import os as _os\n" - " import threadpoolctl as _tpc\n" - " def _find_libraries_pyodide(self):\n" - " from pyodide_js._module import LDSO\n" - " for _fp in LDSO.loadedLibsByName.as_py_json():\n" - " if _os.path.exists(_fp):\n" - " self._make_controller_from_path(_fp)\n" - " _tpc.ThreadpoolController._find_libraries_pyodide = (\n" - " _find_libraries_pyodide\n" - " )\n" - "except Exception:\n" - " pass\n" + LITE_RENDERER_CELL - # Draw MNE's 3D figures with pyvista-js. Appended last so MNE is - # already imported; see doc/sphinxext/jupyterlite_lite_renderer.py. -) +_SOURCE = Path(__file__).parent / "_lite_setup_cell.py" +# Everything after the banner is what the notebook runs. The license header and +# the ruff directives above it belong to the file, not to the cell. +_BANNER = "# --- JupyterLite setup cell" + +_text = _SOURCE.read_text() +if _BANNER not in _text: + raise RuntimeError(f"{_SOURCE.name} is missing the {_BANNER!r} banner") +_body = _text[_text.index(_BANNER) :] +_body = _body[_body.index("\n") + 1 :] + +# The renderer goes last, so MNE is already imported by the time it runs; see +# jupyterlite_lite_renderer.py. +LITE_SETUP_CELL = _body + LITE_RENDERER_CELL From 40e1626ee4408d436b3a4be07fd43f6824002d3f Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 24 Aug 2026 11:31:42 -0400 Subject: [PATCH 05/11] FIX: match the OSF host rather than searching the whole URL --- doc/sphinxext/_lite_setup_cell.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/doc/sphinxext/_lite_setup_cell.py b/doc/sphinxext/_lite_setup_cell.py index ac80e878748..879126bcd77 100644 --- a/doc/sphinxext/_lite_setup_cell.py +++ b/doc/sphinxext/_lite_setup_cell.py @@ -188,13 +188,18 @@ def pyodide_send(self, request, **kwargs): # Block pooch from attempting large OSF downloads in the browser. # The required files are either pre-injected or unavailable. import pooch +from urllib.parse import urlparse orig_pooch_fetch = pooch.Pooch.fetch def pyodide_pooch_fetch(self, fname, processor=None, downloader=None): url = self.get_url(fname) - if "osf.io" in url or "files.osf.io" in url: + # Compare the host rather than searching the whole URL: "osf.io" can turn + # up legitimately elsewhere in one (a query string, a path), and a + # substring test would refuse those downloads too. + host = urlparse(url).hostname or "" + if host == "osf.io" or host.endswith(".osf.io"): raise RuntimeError( f"Cannot download {fname!r} from OSF in JupyterLite: " "browser CORS policy and memory limits prevent large " From 0bb107a89594e91d21d079f439358538bc2fba52 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 24 Aug 2026 19:24:07 -0400 Subject: [PATCH 06/11] MAINT: address review on the JupyterLite setup cell --- doc/sphinxext/_lite_setup_cell.py | 494 ++++-------------------- doc/sphinxext/_lite_setup_cell_3d.py | 369 ++++++++++++++++++ doc/sphinxext/jupyterlite_setup_cell.py | 36 +- 3 files changed, 465 insertions(+), 434 deletions(-) create mode 100644 doc/sphinxext/_lite_setup_cell_3d.py diff --git a/doc/sphinxext/_lite_setup_cell.py b/doc/sphinxext/_lite_setup_cell.py index 879126bcd77..4aa4a063278 100644 --- a/doc/sphinxext/_lite_setup_cell.py +++ b/doc/sphinxext/_lite_setup_cell.py @@ -12,6 +12,9 @@ # --- JupyterLite setup cell ------------------------------------------------- # 💡 This cell is automatically added to the start of each notebook. # It installs MNE and patches the browser environment for Pyodide. +# Downloading this notebook to run it locally? Delete this cell first: +# piplite exists only inside JupyterLite, and a local MNE needs none of +# the patches below. import piplite # Use piplite (not micropip) so the locally-built development MNE wheel @@ -41,45 +44,6 @@ import os import io -# lzma: try real stdlib first (Pyodide ships it); only mock if absent. The -# import has to be attempted rather than probed with find_spec, because the -# mock below is only installed when it actually fails. -try: - import lzma # noqa: F401 -except ImportError: - - class _LZMAFile: - def __init__(self, *a, **kw): - pass - - def __enter__(self): - return self - - def __exit__(self, *a): - pass - - def write(self, d): - pass - - def read(self, n=-1): - return b"" - - def close(self): - pass - - class _MockLZMA: - LZMAError = Exception - LZMAFile = _LZMAFile - FORMAT_XZ = 1 - FORMAT_ALONE = 2 - - def __getattr__(self, name): - return object - - import sys as _sys - - _sys.modules["lzma"] = _MockLZMA() - # Mock multiprocessing — missing in Pyodide but imported by joblib from unittest.mock import MagicMock @@ -230,6 +194,26 @@ def pyodide_pooch_fetch(self, fname, processor=None, downloader=None): # (not a str) since tutorials use the / operator on the result. from pathlib import Path as _Path +_mne_data_root = _Path(mne_data_path) + + +def _lite_data_path(_rel): + """Return ``_rel`` resolved under the data root, as a POSIX string.""" + return (_mne_data_root / _rel).as_posix() + + +def _lite_rel_to_data(fname): + """Return ``fname`` relative to the data root, or None if it sits outside. + + Replaces the ``startswith(mne_data_path + "/")`` plus manual slicing this + file used to repeat at every reader shim. + """ + _p = _Path(str(fname)) + if _p == _mne_data_root or not _p.is_relative_to(_mne_data_root): + return None + return _p.relative_to(_mne_data_root).as_posix() + + _sample_path = _Path(_sample_dir) @@ -248,7 +232,7 @@ def _lite_sample_data_path(*_a, **_kw): # synchronous XHR may set responseType='arraybuffer', letting a sync # data_path() read binary. def _lite_fetch_rel(_rel): - _dst = mne_data_path + "/" + _rel + _dst = _lite_data_path(_rel) if not os.path.exists(_dst): from js import XMLHttpRequest @@ -266,7 +250,7 @@ def _lite_fetch_rel(_rel): def _lite_lazy_fetch(_folder, _fname): _lite_fetch_rel(_folder + "/" + _fname) - return _Path(mne_data_path + "/" + _folder) + return _Path(_lite_data_path(_folder)) def _lite_kiloword_data_path(*_a, **_kw): @@ -296,7 +280,7 @@ def _lite_mtrf_data_path(*_a, **_kw): # individual files, so a notebook that wants the EEGLAB recording does # not also drag down the 39 MB movement raw. def _lite_testing_data_path(*_a, **_kw): - return _Path(mne_data_path + "/MNE-testing-data") + return _Path(_lite_data_path("MNE-testing-data")) mne.datasets.testing.data_path = _lite_testing_data_path @@ -307,7 +291,7 @@ def _lite_testing_data_path(*_a, **_kw): # pull them individually. def _lite_folder_data_path(_folder): def _data_path(*_a, **_kw): - return _Path(mne_data_path + "/" + _folder) + return _Path(_lite_data_path(_folder)) return _data_path @@ -349,15 +333,30 @@ def _lite_eegbci_load_data(subject, runs, *_a, **_kw): # read_inverse_operator is asked to open it. def _lite_fetch_if_under_mne_data(fname): _p = str(fname) - if _p.startswith(mne_data_path + "/"): - _lite_fetch_rel(_p[len(mne_data_path) + 1 :]) + if _lite_rel_to_data(_p) is not None: + _lite_fetch_rel(_lite_rel_to_data(_p)) return fname -# Most readers just need their file pulled down before MNE opens it. -# One wrapper, driven by the table further below; readers that need -# more than this (a sibling file, a chain of candidates) keep their -# own shim. +# Reader overrides, tier one. +# +# Nothing is on disk here. The data is served over HTTP next to the docs, +# and MNE readers validate their filename through _check_fname(must_exist= +# True) before opening it, so the file has to be in the virtual filesystem +# by the time the real reader is called. Each reader is therefore wrapped: +# fetch first at the path the caller asked for, then hand straight over to +# the original. +# +# It has to be per reader rather than one hook on mne.io.read_raw, because +# the tutorials call the specific readers (read_raw_fif, read_epochs, +# read_forward_solution, ...) directly and never go through the generic one. +# +# Most of them only need that fetch, so they are driven by the table further +# below: _mods is where the name is bound (some are exported twice, publicly +# and on a private alias), _name is the function and _arg is the keyword its +# filename arrives under when it is not passed positionally. Tier two, the +# readers needing more than a single fetch -- a .stc stem that means two +# files, a directory, a sibling -- keep their own hand-written shim below. def _lite_wrap_reader(_mods, _name, _arg): _orig = getattr(_mods[0], _name) @@ -412,10 +411,10 @@ def _lite_check_fname(fname, overwrite=False, must_exist=False, *_a, **_kw): def _lite_read_raw_eeglab(input_fname, *_a, **_kw): _p = str(input_fname) - if _p.startswith(mne_data_path + "/"): + if _lite_rel_to_data(_p) is not None: for _cand in (_p, _p[:-4] + ".fdt"): try: - _lite_fetch_rel(_cand[len(mne_data_path) + 1 :]) + _lite_fetch_rel(_lite_rel_to_data(_cand)) except Exception: pass return _orig_read_raw_eeglab(input_fname, *_a, **_kw) @@ -437,15 +436,15 @@ def _lite_fetch_dir(_rel): _lite_fetch_rel(_rel + "/" + _name) except Exception as _e: print("[JupyterLite] skipped " + _name + ": " + repr(_e)) - return mne_data_path + "/" + _rel + return _lite_data_path(_rel) def _lite_dir_reader(_orig): def _read(fname, *_a, **_kw): _p = str(fname) - if _p.startswith(mne_data_path + "/"): + if _lite_rel_to_data(_p) is not None: try: - _lite_fetch_dir(_p[len(mne_data_path) + 1 :]) + _lite_fetch_dir(_lite_rel_to_data(_p)) except Exception as _e: print("[JupyterLite] could not fetch " + _p + ": " + repr(_e)) return _orig(fname, *_a, **_kw) @@ -482,11 +481,11 @@ def _lite_read_raw_kit(input_fname, *_a, **_kw): def _lite_read_raw_brainvision(vhdr_fname, *_a, **_kw): _p = str(vhdr_fname) - if _p.startswith(mne_data_path + "/"): + if _lite_rel_to_data(_p) is not None: _stem = _p[:-5] if _p.endswith(".vhdr") else _p for _cand in (_p, _stem + ".eeg", _stem + ".vmrk"): try: - _lite_fetch_rel(_cand[len(mne_data_path) + 1 :]) + _lite_fetch_rel(_lite_rel_to_data(_cand)) except Exception: pass return _orig_read_raw_brainvision(vhdr_fname, *_a, **_kw) @@ -518,8 +517,13 @@ def _lite_load_xdf(fname, *_a, **_kw): _pyxdf.load_xdf = _lite_load_xdf except Exception: pass -# The readers that only need the fetch. Two of them are bound on a -# private alias as well as the public one, so both are listed. +# The tier-one table (see "Reader overrides" above for why this exists). +# Each row is one reader that needs nothing but its file fetched first: +# _mods where the name is bound, as a tuple because a couple of them +# are exported both publicly and on a private alias +# _name the function to wrap on each of those modules +# _arg the keyword its filename arrives under, for calls that pass it +# by name rather than positionally import mne.minimum_norm as _mne_minv import mne.chpi as _mne_chpi @@ -545,10 +549,10 @@ def _lite_load_xdf(fname, *_a, **_kw): def _lite_read_source_estimate(fname, *_a, **_kw): _p = str(fname) - if _p.startswith(mne_data_path + "/"): + if _lite_rel_to_data(_p) is not None: for _suf in ("", "-lh.stc", "-rh.stc"): try: - _lite_fetch_rel(_p[len(mne_data_path) + 1 :] + _suf) + _lite_fetch_rel(_lite_rel_to_data(_p) + _suf) except Exception: pass return _orig_read_source_estimate(fname, *_a, **_kw) @@ -567,8 +571,8 @@ def _lite_read_source_estimate(fname, *_a, **_kw): def _lite_get_head_surface(surf, subject, subjects_dir, bem=None, verbose=None): _sd = str(subjects_dir) if subjects_dir is not None else "" - if subject and _sd.startswith(mne_data_path + "/"): - _rel = _sd[len(mne_data_path) + 1 :] + "/" + str(subject) + if subject and _lite_rel_to_data(_sd) is not None: + _rel = _lite_rel_to_data(_sd) + "/" + str(subject) if surf in ("head-dense", "seghead"): _cands = ["bem/" + str(subject) + "-head-dense.fif", "surf/lh.seghead"] else: @@ -601,10 +605,10 @@ def _lite_get_head_surface(surf, subject, subjects_dir, bem=None, verbose=None): def _lite_get_skull_surface(surf, subject, subjects_dir, bem=None, verbose=None): _sd = str(subjects_dir) if subjects_dir is not None else "" - if subject and _sd.startswith(mne_data_path + "/"): + if subject and _lite_rel_to_data(_sd) is not None: try: _lite_fetch_rel( - _sd[len(mne_data_path) + 1 :] + _lite_rel_to_data(_sd) + "/" + str(subject) + "/bem/" @@ -638,8 +642,8 @@ def _lite_surface_head_surface( subject, source, subjects_dir, on_defects, raise_error=True ): _sd = str(subjects_dir) if subjects_dir is not None else "" - if subject and _sd.startswith(mne_data_path + "/"): - _rel = _sd[len(mne_data_path) + 1 :] + "/" + str(subject) + if subject and _lite_rel_to_data(_sd) is not None: + _rel = _lite_rel_to_data(_sd) + "/" + str(subject) _srcs = [source] if isinstance(source, str) else list(source) for _s in _srcs: try: @@ -660,8 +664,8 @@ def _lite_surface_head_surface( def _lite_plot_bem(subject=None, subjects_dir=None, *_a, **_kw): _sd = str(subjects_dir) if subjects_dir is not None else "" - if subject and _sd.startswith(mne_data_path + "/"): - _rel = _sd[len(mne_data_path) + 1 :] + "/" + str(subject) + if subject and _lite_rel_to_data(_sd) is not None: + _rel = _lite_rel_to_data(_sd) + "/" + str(subject) _want = [ "bem/inner_skull.surf", "bem/outer_skull.surf", @@ -684,177 +688,6 @@ def _lite_plot_bem(subject=None, subjects_dir=None, *_a, **_kw): mne.viz.plot_bem = _lite_plot_bem -# EXPERIMENTAL 3D: MNE's normal Brain/VTK stack can't load in WASM, so -# route SourceEstimate.plot() through pyvista-js (vtk.js) instead. -# pyvista-js (0.15) has no scalar colormap in its renderer, so we -# approximate MNE's Brain look with solid-colored meshes: a two-tone -# curvature base (light gyri + dark sulci) plus many thin 'hot' bands -# for the activation, on a black background with even scene lighting. -# Static, one time point, no time slider yet. Fully guarded — any -# failure prints a message so the notebook completes. Returns a stub -# 'brain' whose methods (add_foci/add_text/show_view/...) are safe -# no-ops, so tutorials that call brain.add_foci(...) after plot() work. -class _LiteBrain: - def screenshot(self, *_a, **_kw): - import numpy as _np - - return _np.zeros((2, 2, 3), dtype="uint8") - - def __getattr__(self, _name): - return lambda *_a, **_kw: None - - -def _lite_stc_plot(self, *_a, **_kw): - try: - import numpy as _np - import nibabel as _nib - from scipy.spatial import cKDTree as _KDTree - from matplotlib import colormaps as _cmaps - import pyvista_js as _pv - - _subj = ( - _kw.get("subject") - or (_a[0] if _a and isinstance(_a[0], str) else None) - or "sample" - ) - _sdir = _kw.get("subjects_dir") - _sdir = ( - str(_sdir) - if _sdir is not None - else mne_data_path + "/MNE-sample-data/subjects" - ) - # surfaces are fetched relative to the served mne_data root, so - # derive that from subjects_dir rather than assuming sample -- - # a dataset may keep its FreeSurfer subjects under its own folder. - _rel_sdir = ( - _sdir[len(mne_data_path) + 1 :] - if _sdir.startswith(mne_data_path + "/") - else "MNE-sample-data/subjects" - ) - _init = _kw.get("initial_time", None) - if _init is None: - _ti = int(_np.argmax(_np.abs(self.data).mean(0))) - else: - _ti = int(_np.argmin(_np.abs(self.times - _init))) - _hot = _cmaps["hot"] - _N = 10 - - def _flat(_t): - return _np.hstack( - [_np.full((len(_t), 1), 3, dtype=_np.int64), _t.astype(_np.int64)] - ).ravel() - - def _sub(_pts, _tris, _mask, _lift=0.0, _cen=None): - _sel = _tris[_mask] - if len(_sel) == 0: - return None - _u, _iv = _np.unique(_sel, return_inverse=True) - _p = _pts[_u] - if _lift and _cen is not None: - _p = _cen + (_p - _cen) * (1.0 + _lift) - return _p, _iv.reshape(-1, 3) - - _plotter = _pv.Plotter() - _plotter.background_color = "black" - # even lighting so the surface isn't black when rotated - for _lp in ( - (1, 0, 0), - (-1, 0, 0), - (0, 1, 0), - (0, -1, 0), - (0, 0, 1), - (0, 0, -1), - ): - _plotter.add_light( - _pv.Light( - position=(300.0 * _lp[0], 300.0 * _lp[1], 300.0 * _lp[2]), - focal_point=(0.0, 0.0, 0.0), - intensity=0.4, - ) - ) - _nlh = len(self.vertices[0]) - _hemis = (("lh", 0, self.vertices[0]), ("rh", 1, self.vertices[1])) - for _h, _hi, _vno in _hemis: - if len(_vno) == 0: - continue - _pre = _rel_sdir + "/" + _subj + "/surf/" + _h - _lite_fetch_rel(_pre + ".inflated") - _lite_fetch_rel(_pre + ".curv") - _bpath = _sdir + "/" + _subj + "/surf/" + _h - _rr, _tris = mne.read_surface(_bpath + ".inflated") - _cv = _nib.freesurfer.read_morph_data(_bpath + ".curv") - _hdata = self.data[:_nlh] if _hi == 0 else self.data[_nlh:] - # color each surface vertex from the nearest ACTIVE source - # within a small radius, so single-vertex (point) sources - # show as visible blobs and dense sources fill in as usual - _sv = _hdata[:, _ti].astype(float) - _act = _sv != 0 - _scal = _np.zeros(len(_rr)) - if _act.any(): - _atree = _KDTree(_rr[_vno][_act]) - _ad, _ai = _atree.query(_rr) - _scal = _np.where(_ad <= 12.0, _sv[_act][_ai], 0.0) - # offset hemispheres along x so they do not overlap - _off = -60.0 if _h == "lh" else 60.0 - _pts = _np.round(_rr, 2) - _pts[:, 0] = _pts[:, 0] + _off - _cen = _pts.mean(0) - # curvature base: light gyri (curv<0) + dark sulci (curv>=0) - _fc = _cv[_tris].mean(1) - for _cm, _col in ( - (_fc < 0, (0.68, 0.68, 0.68)), - (_fc >= 0, (0.38, 0.38, 0.38)), - ): - _s = _sub(_pts, _tris, _cm) - if _s is not None: - _plotter.add_mesh( - _pv.PolyData(points=_s[0], faces=_flat(_s[1])), - color=_col, - smooth_shading=True, - ) - # activation as a smooth hot gradient in N value bands, - # each lifted 2% off the surface to avoid z-fighting - _fv = _scal[_tris].mean(1) - _p90 = _np.percentile(_scal, 90.0) - _fmax = float(_scal.max()) - # keep the background gray: for sparse point sources the - # 90th pct is ~0 (most of the brain is zero), which would - # paint everything, so fall back to a fraction of the max. - _fmin = _p90 if _p90 > _fmax * 0.05 else _fmax * 0.4 - if _fmax > _fmin: - _edges = _np.linspace(_fmin, _fmax, _N + 1) - for _i in range(_N): - if _i < _N - 1: - _m = (_fv >= _edges[_i]) & (_fv < _edges[_i + 1]) - else: - _m = _fv >= _edges[_i] - if int(_m.sum()) == 0: - continue - _rgb = _hot(0.25 + 0.41 * (_i / (_N - 1))) - _col = (float(_rgb[0]), float(_rgb[1]), float(_rgb[2])) - _s = _sub(_pts, _tris, _m, 0.02, _cen) - if _s is not None: - _plotter.add_mesh( - _pv.PolyData(points=_s[0], faces=_flat(_s[1])), - color=_col, - smooth_shading=True, - ) - # Open on the lateral profile (camera along the medial-lateral - # X axis, superior up), like native MNE, instead of vtk.js's - # default anterior/face-on view. Guarded so a missing - # view_vector never costs us the render. - try: - _plotter.view_vector((-1.0, 0.0, 0.0), viewup=(0.0, 0.0, 1.0)) - except Exception: - pass - _plotter.show() - except Exception as _e: - print("[JupyterLite] pyvista-js 3D render unavailable: " + repr(_e)) - return _LiteBrain() - - -mne.SourceEstimate.plot = _lite_stc_plot - # Pyodide/WASM has no OS threads, so MNE's ProgressBar background # updater thread (used by the ProgressBar context manager, e.g. in # permutation cluster tests) crashes with 'can't start new thread'. @@ -912,186 +745,3 @@ def pyodide_plt_show(show=True, fig=None, **kwargs): viz_utils.plt_show = pyodide_plt_show - - -# EXPERIMENTAL 3D: plot_sparse_source_estimates builds its 3D renderer -# BEFORE the time-course figure, so in WASM the whole call dies and the -# notebook loses both halves. Rebuild it here: the same glass brain from -# the source space and a marker per active dipole via pyvista-js, plus -# the matplotlib time courses (which are the quantitative half). Same -# approach as the SourceEstimate.plot shim above. -def _lite_plot_sparse_source_estimates( - src, - stcs, - colors=None, - linewidth=2, - fontsize=18, - bgcolor=(0.05, 0, 0.1), - opacity=0.2, - brain_color=(0.7,) * 3, - show=True, - high_resolution=False, - fig_name=None, - fig_number=None, - labels=None, - modes=("cone", "sphere"), - scale_factors=(1, 0.6), - **kwargs, -): - import numpy as _np - from itertools import cycle as _cycle - from matplotlib.colors import to_rgb as _to_rgb - - if not isinstance(stcs, list): - stcs = [stcs] - _lhp = src[0]["rr"] - _pts = _np.r_[_lhp, src[1]["rr"]] * 170 - _nrm = _np.r_[src[0]["nn"], src[1]["nn"]] - # use_tris is the decimated mesh and can be None on some source - # spaces; fall back to the full tris in that case. - _lt = src[0]["tris"] if high_resolution else src[0]["use_tris"] - _rt = src[1]["tris"] if high_resolution else src[1]["use_tris"] - if _lt is None or _rt is None: - _lt, _rt = src[0]["tris"], src[1]["tris"] - _faces = _np.r_[_lt, len(_lhp) + _rt] - _vertnos = [_np.r_[_s.lh_vertno, len(_lhp) + _s.rh_vertno] for _s in stcs] - _uniq = _np.unique(_np.concatenate(_vertnos).ravel()) - # --- time courses ------------------------------------------------- - _fig = plt.figure(fig_number, layout="constrained") - _fig.clf() - _ax = _fig.add_subplot(111) - _cyc = _cycle( - colors - if colors is not None - else plt.rcParams["axes.prop_cycle"].by_key()["color"] - ) - _marks = [] - for _v in _uniq: - _ind = [_k for _k, _vn in enumerate(_vertnos) if _v in _vn] - _c = next(_cyc) - _marks.append((int(_v), _to_rgb(_c), len(_ind) > 1)) - for _k in _ind: - _m = _vertnos[_k] == _v - _ax.plot( - 1e3 * stcs[_k].times, - 1e9 * stcs[_k].data[_m].ravel(), - c=_c, - linewidth=linewidth, - ) - _ax.set_xlabel("Time (ms)", fontsize=fontsize) - _ax.set_ylabel("Source amplitude (nAm)", fontsize=fontsize) - if fig_name is not None: - _ax.set_title(fig_name) - pyodide_plt_show(show) - # --- glass brain + dipole markers --------------------------------- - try: - import pyvista_js as _pv - - _plotter = _pv.Plotter() - _plotter.background_color = tuple( - float(min(max(_x, 0.0), 1.0)) for _x in bgcolor - ) - for _lp in ( - (1, 0, 0), - (-1, 0, 0), - (0, 1, 0), - (0, -1, 0), - (0, 0, 1), - (0, 0, -1), - ): - _plotter.add_light( - _pv.Light( - position=(300.0 * _lp[0], 300.0 * _lp[1], 300.0 * _lp[2]), - focal_point=(0.0, 0.0, 0.0), - intensity=0.4, - ) - ) - _flat_faces = _np.hstack( - [_np.full((len(_faces), 1), 3, dtype=_np.int32), _faces.astype(_np.int32)] - ).ravel() - _plotter.add_mesh( - _pv.PolyData(points=_pts.astype(_np.float32), faces=_flat_faces), - color=tuple(float(_x) for _x in brain_color), - opacity=float(opacity), - smooth_shading=True, - ) - for _v, _col, _common in _marks: - _sf = float(scale_factors[1] if _common else scale_factors[0]) - _mode = modes[1] if _common else modes[0] - _xyz = tuple(float(_q) for _q in _pts[_v]) - if _mode == "sphere": - _glyph = _pv.Sphere(radius=_sf, center=_xyz) - else: - _glyph = _pv.Cone( - center=_xyz, - direction=tuple(float(_q) for _q in _nrm[_v]), - height=2.0 * _sf, - radius=_sf, - ) - _plotter.add_mesh(_glyph, color=_col, smooth_shading=True) - try: - _plotter.view_vector((-1.0, 0.0, 0.0), viewup=(0.0, 0.0, 1.0)) - except Exception: - pass - _plotter.show() - except Exception as _e: - print("[JupyterLite] pyvista-js glass brain unavailable: " + repr(_e)) - - -mne.viz.plot_sparse_source_estimates = _lite_plot_sparse_source_estimates - -# Each MNE plot is rendered once by pyodide_plt_show above (display()). -# When a plot call is also a cell's last expression, the method returns -# the Figure, which Jupyter echoes a SECOND time as the Out[] result -# (the duplicate seen below inline plots). Drop that redundant echo for -# Figures (and pure lists of Figures, e.g. ica.plot_properties) so each -# plot appears exactly once. Non-figure results (numbers, DataFrames, -# reprs) are untouched, and raw matplotlib figures never shown still -# render via the inline backend's end-of-cell flush, so nothing hides. -# Wrapped in try/except (like the patches below): if anything about -# the displayhook is unexpected, silently keep the current behavior -# (harmless double render) rather than breaking the setup cell. -try: - _lite_dh = type(IPython.get_ipython().displayhook) - if not getattr(_lite_dh, "_lite_no_fig_echo", False): - _lite_dh_call = _lite_dh.__call__ - - def _lite_displayhook(self, result=None): - if isinstance(result, _mfig.Figure): - result = None - elif ( - isinstance(result, (list, tuple)) - and result - and all(isinstance(_x, _mfig.Figure) for _x in result) - ): - result = None - return _lite_dh_call(self, result) - - _lite_dh.__call__ = _lite_displayhook - _lite_dh._lite_no_fig_echo = True -except Exception: - pass - -# Real fix (not a warnings filter) for the threadpoolctl Pyodide -# RuntimeWarning seen via mne.sys_info(): threadpoolctl 3.6.0 (latest -# release) still calls the deprecated Pyodide JsProxy.as_object_map(). -# Pyodide's own message says to use as_py_json() instead; both yield the -# same library filepaths, so we swap the call at its source. This removes -# the deprecated API usage entirely, so the warning is never emitted. -# The upstream fix is already merged (joblib/threadpoolctl#201) but -# unreleased; Pyodide bundles the released 3.6.0 wheel. DROP THIS PATCH -# once threadpoolctl 3.7.0 is released and Pyodide bundles it. -try: - import os as _os - import threadpoolctl as _tpc - - def _find_libraries_pyodide(self): - from pyodide_js._module import LDSO - - for _fp in LDSO.loadedLibsByName.as_py_json(): - if _os.path.exists(_fp): - self._make_controller_from_path(_fp) - - _tpc.ThreadpoolController._find_libraries_pyodide = _find_libraries_pyodide -except Exception: - pass diff --git a/doc/sphinxext/_lite_setup_cell_3d.py b/doc/sphinxext/_lite_setup_cell_3d.py new file mode 100644 index 00000000000..a10f3b71192 --- /dev/null +++ b/doc/sphinxext/_lite_setup_cell_3d.py @@ -0,0 +1,369 @@ +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +# The experimental part of the browser setup, kept apart from the rest so the +# solid ground and the shifting ground are easy to tell apart. Everything here +# stands in for MNE's Brain/VTK stack, which has no WebAssembly build, and is +# the part most likely to be dropped as pyvista-js gains features upstream. +# Appended after the base cell, which it depends on: the second block below +# uses the matplotlib-inline shim that cell installs. +# This runs as a continuation of the base cell, in the same namespace, so it +# reads names that cell defined (mne, plt, the fetch helpers) rather than +# importing them again; F821 is off for that reason, not to hide typos. +# ruff: noqa: E402, F704, F821, I001 + +# --- JupyterLite setup cell, 3D ----------------------------------------------- +# EXPERIMENTAL 3D: MNE's normal Brain/VTK stack can't load in WASM, so +# route SourceEstimate.plot() through pyvista-js (vtk.js) instead. +# pyvista-js (0.15) has no scalar colormap in its renderer, so we +# approximate MNE's Brain look with solid-colored meshes: a two-tone +# curvature base (light gyri + dark sulci) plus many thin 'hot' bands +# for the activation, on a black background with even scene lighting. +# Static, one time point, no time slider yet. Fully guarded — any +# failure prints a message so the notebook completes. Returns a stub +# 'brain' whose methods (add_foci/add_text/show_view/...) are safe +# no-ops, so tutorials that call brain.add_foci(...) after plot() work. +class _LiteBrain: + def screenshot(self, *_a, **_kw): + import numpy as _np + + return _np.zeros((2, 2, 3), dtype="uint8") + + def __getattr__(self, _name): + return lambda *_a, **_kw: None + + +def _lite_stc_plot(self, *_a, **_kw): + try: + import numpy as _np + import nibabel as _nib + from scipy.spatial import cKDTree as _KDTree + from matplotlib import colormaps as _cmaps + import pyvista_js as _pv + + _subj = ( + _kw.get("subject") + or (_a[0] if _a and isinstance(_a[0], str) else None) + or "sample" + ) + _sdir = _kw.get("subjects_dir") + _sdir = ( + str(_sdir) + if _sdir is not None + else _lite_data_path("MNE-sample-data/subjects") + ) + # surfaces are fetched relative to the served mne_data root, so + # derive that from subjects_dir rather than assuming sample -- + # a dataset may keep its FreeSurfer subjects under its own folder. + _rel_sdir = ( + _lite_rel_to_data(_sdir) + if _lite_rel_to_data(_sdir) is not None + else "MNE-sample-data/subjects" + ) + _init = _kw.get("initial_time", None) + if _init is None: + _ti = int(_np.argmax(_np.abs(self.data).mean(0))) + else: + _ti = int(_np.argmin(_np.abs(self.times - _init))) + _hot = _cmaps["hot"] + _N = 10 + + def _flat(_t): + return _np.hstack( + [_np.full((len(_t), 1), 3, dtype=_np.int64), _t.astype(_np.int64)] + ).ravel() + + def _sub(_pts, _tris, _mask, _lift=0.0, _cen=None): + _sel = _tris[_mask] + if len(_sel) == 0: + return None + _u, _iv = _np.unique(_sel, return_inverse=True) + _p = _pts[_u] + if _lift and _cen is not None: + _p = _cen + (_p - _cen) * (1.0 + _lift) + return _p, _iv.reshape(-1, 3) + + _plotter = _pv.Plotter() + _plotter.background_color = "black" + # even lighting so the surface isn't black when rotated + for _lp in ( + (1, 0, 0), + (-1, 0, 0), + (0, 1, 0), + (0, -1, 0), + (0, 0, 1), + (0, 0, -1), + ): + _plotter.add_light( + _pv.Light( + position=(300.0 * _lp[0], 300.0 * _lp[1], 300.0 * _lp[2]), + focal_point=(0.0, 0.0, 0.0), + intensity=0.4, + ) + ) + _nlh = len(self.vertices[0]) + _hemis = (("lh", 0, self.vertices[0]), ("rh", 1, self.vertices[1])) + for _h, _hi, _vno in _hemis: + if len(_vno) == 0: + continue + _pre = _rel_sdir + "/" + _subj + "/surf/" + _h + _lite_fetch_rel(_pre + ".inflated") + _lite_fetch_rel(_pre + ".curv") + _bpath = _sdir + "/" + _subj + "/surf/" + _h + _rr, _tris = mne.read_surface(_bpath + ".inflated") + _cv = _nib.freesurfer.read_morph_data(_bpath + ".curv") + _hdata = self.data[:_nlh] if _hi == 0 else self.data[_nlh:] + # color each surface vertex from the nearest ACTIVE source + # within a small radius, so single-vertex (point) sources + # show as visible blobs and dense sources fill in as usual + _sv = _hdata[:, _ti].astype(float) + _act = _sv != 0 + _scal = _np.zeros(len(_rr)) + if _act.any(): + _atree = _KDTree(_rr[_vno][_act]) + _ad, _ai = _atree.query(_rr) + _scal = _np.where(_ad <= 12.0, _sv[_act][_ai], 0.0) + # offset hemispheres along x so they do not overlap + _off = -60.0 if _h == "lh" else 60.0 + _pts = _np.round(_rr, 2) + _pts[:, 0] = _pts[:, 0] + _off + _cen = _pts.mean(0) + # curvature base: light gyri (curv<0) + dark sulci (curv>=0) + _fc = _cv[_tris].mean(1) + for _cm, _col in ( + (_fc < 0, (0.68, 0.68, 0.68)), + (_fc >= 0, (0.38, 0.38, 0.38)), + ): + _s = _sub(_pts, _tris, _cm) + if _s is not None: + _plotter.add_mesh( + _pv.PolyData(points=_s[0], faces=_flat(_s[1])), + color=_col, + smooth_shading=True, + ) + # activation as a smooth hot gradient in N value bands, + # each lifted 2% off the surface to avoid z-fighting + _fv = _scal[_tris].mean(1) + _p90 = _np.percentile(_scal, 90.0) + _fmax = float(_scal.max()) + # keep the background gray: for sparse point sources the + # 90th pct is ~0 (most of the brain is zero), which would + # paint everything, so fall back to a fraction of the max. + _fmin = _p90 if _p90 > _fmax * 0.05 else _fmax * 0.4 + if _fmax > _fmin: + _edges = _np.linspace(_fmin, _fmax, _N + 1) + for _i in range(_N): + if _i < _N - 1: + _m = (_fv >= _edges[_i]) & (_fv < _edges[_i + 1]) + else: + _m = _fv >= _edges[_i] + if int(_m.sum()) == 0: + continue + _rgb = _hot(0.25 + 0.41 * (_i / (_N - 1))) + _col = (float(_rgb[0]), float(_rgb[1]), float(_rgb[2])) + _s = _sub(_pts, _tris, _m, 0.02, _cen) + if _s is not None: + _plotter.add_mesh( + _pv.PolyData(points=_s[0], faces=_flat(_s[1])), + color=_col, + smooth_shading=True, + ) + # Open on the lateral profile (camera along the medial-lateral + # X axis, superior up), like native MNE, instead of vtk.js's + # default anterior/face-on view. Guarded so a missing + # view_vector never costs us the render. + try: + _plotter.view_vector((-1.0, 0.0, 0.0), viewup=(0.0, 0.0, 1.0)) + except Exception: + pass + _plotter.show() + except Exception as _e: + print("[JupyterLite] pyvista-js 3D render unavailable: " + repr(_e)) + return _LiteBrain() + + +mne.SourceEstimate.plot = _lite_stc_plot + + +# EXPERIMENTAL 3D: plot_sparse_source_estimates builds its 3D renderer +# BEFORE the time-course figure, so in WASM the whole call dies and the +# notebook loses both halves. Rebuild it here: the same glass brain from +# the source space and a marker per active dipole via pyvista-js, plus +# the matplotlib time courses (which are the quantitative half). Same +# approach as the SourceEstimate.plot shim above. +def _lite_plot_sparse_source_estimates( + src, + stcs, + colors=None, + linewidth=2, + fontsize=18, + bgcolor=(0.05, 0, 0.1), + opacity=0.2, + brain_color=(0.7,) * 3, + show=True, + high_resolution=False, + fig_name=None, + fig_number=None, + labels=None, + modes=("cone", "sphere"), + scale_factors=(1, 0.6), + **kwargs, +): + import numpy as _np + from itertools import cycle as _cycle + from matplotlib.colors import to_rgb as _to_rgb + + if not isinstance(stcs, list): + stcs = [stcs] + _lhp = src[0]["rr"] + _pts = _np.r_[_lhp, src[1]["rr"]] * 170 + _nrm = _np.r_[src[0]["nn"], src[1]["nn"]] + # use_tris is the decimated mesh and can be None on some source + # spaces; fall back to the full tris in that case. + _lt = src[0]["tris"] if high_resolution else src[0]["use_tris"] + _rt = src[1]["tris"] if high_resolution else src[1]["use_tris"] + if _lt is None or _rt is None: + _lt, _rt = src[0]["tris"], src[1]["tris"] + _faces = _np.r_[_lt, len(_lhp) + _rt] + _vertnos = [_np.r_[_s.lh_vertno, len(_lhp) + _s.rh_vertno] for _s in stcs] + _uniq = _np.unique(_np.concatenate(_vertnos).ravel()) + # --- time courses ------------------------------------------------- + _fig = plt.figure(fig_number, layout="constrained") + _fig.clf() + _ax = _fig.add_subplot(111) + _cyc = _cycle( + colors + if colors is not None + else plt.rcParams["axes.prop_cycle"].by_key()["color"] + ) + _marks = [] + for _v in _uniq: + _ind = [_k for _k, _vn in enumerate(_vertnos) if _v in _vn] + _c = next(_cyc) + _marks.append((int(_v), _to_rgb(_c), len(_ind) > 1)) + for _k in _ind: + _m = _vertnos[_k] == _v + _ax.plot( + 1e3 * stcs[_k].times, + 1e9 * stcs[_k].data[_m].ravel(), + c=_c, + linewidth=linewidth, + ) + _ax.set_xlabel("Time (ms)", fontsize=fontsize) + _ax.set_ylabel("Source amplitude (nAm)", fontsize=fontsize) + if fig_name is not None: + _ax.set_title(fig_name) + pyodide_plt_show(show) + # --- glass brain + dipole markers --------------------------------- + try: + import pyvista_js as _pv + + _plotter = _pv.Plotter() + _plotter.background_color = tuple( + float(min(max(_x, 0.0), 1.0)) for _x in bgcolor + ) + for _lp in ( + (1, 0, 0), + (-1, 0, 0), + (0, 1, 0), + (0, -1, 0), + (0, 0, 1), + (0, 0, -1), + ): + _plotter.add_light( + _pv.Light( + position=(300.0 * _lp[0], 300.0 * _lp[1], 300.0 * _lp[2]), + focal_point=(0.0, 0.0, 0.0), + intensity=0.4, + ) + ) + _flat_faces = _np.hstack( + [_np.full((len(_faces), 1), 3, dtype=_np.int32), _faces.astype(_np.int32)] + ).ravel() + _plotter.add_mesh( + _pv.PolyData(points=_pts.astype(_np.float32), faces=_flat_faces), + color=tuple(float(_x) for _x in brain_color), + opacity=float(opacity), + smooth_shading=True, + ) + for _v, _col, _common in _marks: + _sf = float(scale_factors[1] if _common else scale_factors[0]) + _mode = modes[1] if _common else modes[0] + _xyz = tuple(float(_q) for _q in _pts[_v]) + if _mode == "sphere": + _glyph = _pv.Sphere(radius=_sf, center=_xyz) + else: + _glyph = _pv.Cone( + center=_xyz, + direction=tuple(float(_q) for _q in _nrm[_v]), + height=2.0 * _sf, + radius=_sf, + ) + _plotter.add_mesh(_glyph, color=_col, smooth_shading=True) + try: + _plotter.view_vector((-1.0, 0.0, 0.0), viewup=(0.0, 0.0, 1.0)) + except Exception: + pass + _plotter.show() + except Exception as _e: + print("[JupyterLite] pyvista-js glass brain unavailable: " + repr(_e)) + + +mne.viz.plot_sparse_source_estimates = _lite_plot_sparse_source_estimates + +# Each MNE plot is rendered once by pyodide_plt_show above (display()). +# When a plot call is also a cell's last expression, the method returns +# the Figure, which Jupyter echoes a SECOND time as the Out[] result +# (the duplicate seen below inline plots). Drop that redundant echo for +# Figures (and pure lists of Figures, e.g. ica.plot_properties) so each +# plot appears exactly once. Non-figure results (numbers, DataFrames, +# reprs) are untouched, and raw matplotlib figures never shown still +# render via the inline backend's end-of-cell flush, so nothing hides. +# Wrapped in try/except (like the patches below): if anything about +# the displayhook is unexpected, silently keep the current behavior +# (harmless double render) rather than breaking the setup cell. +try: + _lite_dh = type(IPython.get_ipython().displayhook) + if not getattr(_lite_dh, "_lite_no_fig_echo", False): + _lite_dh_call = _lite_dh.__call__ + + def _lite_displayhook(self, result=None): + if isinstance(result, _mfig.Figure): + result = None + elif ( + isinstance(result, (list, tuple)) + and result + and all(isinstance(_x, _mfig.Figure) for _x in result) + ): + result = None + return _lite_dh_call(self, result) + + _lite_dh.__call__ = _lite_displayhook + _lite_dh._lite_no_fig_echo = True +except Exception: + pass + +# Real fix (not a warnings filter) for the threadpoolctl Pyodide +# RuntimeWarning seen via mne.sys_info(): threadpoolctl 3.6.0 (latest +# release) still calls the deprecated Pyodide JsProxy.as_object_map(). +# Pyodide's own message says to use as_py_json() instead; both yield the +# same library filepaths, so we swap the call at its source. This removes +# the deprecated API usage entirely, so the warning is never emitted. +# The upstream fix is already merged (joblib/threadpoolctl#201) but +# unreleased; Pyodide bundles the released 3.6.0 wheel. DROP THIS PATCH +# once threadpoolctl 3.7.0 is released and Pyodide bundles it. +try: + import os as _os + import threadpoolctl as _tpc + + def _find_libraries_pyodide(self): + from pyodide_js._module import LDSO + + for _fp in LDSO.loadedLibsByName.as_py_json(): + if _os.path.exists(_fp): + self._make_controller_from_path(_fp) + + _tpc.ThreadpoolController._find_libraries_pyodide = _find_libraries_pyodide +except Exception: + pass diff --git a/doc/sphinxext/jupyterlite_setup_cell.py b/doc/sphinxext/jupyterlite_setup_cell.py index 33cfa89ecae..df51882e052 100644 --- a/doc/sphinxext/jupyterlite_setup_cell.py +++ b/doc/sphinxext/jupyterlite_setup_cell.py @@ -2,15 +2,22 @@ It installs MNE into the browser kernel and patches what Pyodide does not provide: data fetching over HTTP, the readers that expect files already on -disk, and the 3D renderer. The cell itself lives in ``_lite_setup_cell.py`` as -ordinary Python, so ruff lints and formats it; this module only reads that file -and exposes it as the string the browser kernel needs. +disk, and the 3D renderer. The cell lives in ``_lite_setup_cell.py`` and +``_lite_setup_cell_3d.py`` as ordinary Python, so ruff lints and formats it; +this module only reads those files and joins them into the string the browser +kernel needs. The 3D half is kept separate because it stands in for MNE's +Brain/VTK stack and is the part most likely to change as pyvista-js gains +features upstream. The docs build prepends it only to the notebooks copied into the JupyterLite contents. It deliberately does NOT go through ``first_notebook_cell``: that is applied when the notebook is generated, so it would also land in the ``.ipynb`` offered for download, where ``piplite`` does not exist and the notebook would fail on its first cell. + +The other direction is covered in the cell itself: a notebook downloaded from +inside JupyterLite does carry the cell, and it says to delete it before running +locally, for the same reason. """ # Authors: The MNE-Python contributors. @@ -21,17 +28,22 @@ from jupyterlite_lite_renderer import LITE_RENDERER_CELL -_SOURCE = Path(__file__).parent / "_lite_setup_cell.py" # Everything after the banner is what the notebook runs. The license header and # the ruff directives above it belong to the file, not to the cell. _BANNER = "# --- JupyterLite setup cell" -_text = _SOURCE.read_text() -if _BANNER not in _text: - raise RuntimeError(f"{_SOURCE.name} is missing the {_BANNER!r} banner") -_body = _text[_text.index(_BANNER) :] -_body = _body[_body.index("\n") + 1 :] -# The renderer goes last, so MNE is already imported by the time it runs; see -# jupyterlite_lite_renderer.py. -LITE_SETUP_CELL = _body + LITE_RENDERER_CELL +def _read(name): + _source = Path(__file__).parent / name + _text = _source.read_text() + if _BANNER not in _text: + raise RuntimeError(f"{_source.name} is missing the {_BANNER!r} banner") + _body = _text[_text.index(_BANNER) :] + return _body[_body.index("\n") + 1 :] + + +# Order matters: the 3D half reads the matplotlib shim the base half installs, +# and the renderer goes last so MNE is already imported by the time it runs. +LITE_SETUP_CELL = ( + _read("_lite_setup_cell.py") + _read("_lite_setup_cell_3d.py") + LITE_RENDERER_CELL +) From 2231fd1108dbc5bf6d69276a8ba5232eaa3cee09 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Wed, 26 Aug 2026 06:49:55 -0400 Subject: [PATCH 07/11] MAINT: address review on the JupyterLite setup cell Drops the underscore from the module aliases, uses args/kwargs and pathlib, and collapses two reader-table entries that were setting the attribute on the same module twice. --- doc/sphinxext/_lite_setup_cell.py | 293 ++++++++++++++------------- doc/sphinxext/_lite_setup_cell_3d.py | 25 +-- 2 files changed, 166 insertions(+), 152 deletions(-) diff --git a/doc/sphinxext/_lite_setup_cell.py b/doc/sphinxext/_lite_setup_cell.py index 4aa4a063278..c6d0abb94f2 100644 --- a/doc/sphinxext/_lite_setup_cell.py +++ b/doc/sphinxext/_lite_setup_cell.py @@ -9,6 +9,12 @@ # it as a real file instead of a string. # ruff: noqa: E402, F704, I001 +# Naming: everything this cell defines lands in the notebook's own namespace, +# so anything it invents is _-prefixed and cannot shadow a variable the +# tutorial goes on to use. Module imports are left plain: a tutorial importing +# the same module binds the same object, so there is nothing to protect. +# `mne_data_path` is the deliberate exception, since a reader may want it. + # --- JupyterLite setup cell ------------------------------------------------- # 💡 This cell is automatically added to the start of each notebook. # It installs MNE and patches the browser environment for Pyodide. @@ -54,15 +60,18 @@ sys.modules["multiprocessing.util"] = m.util sys.modules["multiprocessing.pool"] = m.pool -# Patch requests so pooch can fetch files already on /drive/mne_data. -# open_url works for both text and binary in Pyodide >= 0.21. +# Route requests through pyodide.http so the downloads that still go through +# pooch work in the browser. The one that matters is fetch_infant_template +# (25_automated_coreg), which reaches pooch.retrieve -> pooch.HTTPDownloader +# -> requests, and whose files live on github.com rather than OSF. open_url +# handles both text and binary in Pyodide >= 0.21. import requests import pyodide -orig_send = requests.Session.send +_orig_send = requests.Session.send -def pyodide_send(self, request, **kwargs): +def _pyodide_send(self, request, **kwargs): try: buf = pyodide.http.open_url(request.url) content = buf.getvalue() if hasattr(buf, "getvalue") else buf.read() @@ -70,7 +79,7 @@ def pyodide_send(self, request, **kwargs): content = content.encode("utf-8") except Exception as e: print(f"open_url failed for {request.url}: {e}") - return orig_send(self, request, **kwargs) + return _orig_send(self, request, **kwargs) response = requests.Response() response.status_code = 200 response.url = request.url @@ -78,7 +87,7 @@ def pyodide_send(self, request, **kwargs): return response -requests.Session.send = pyodide_send +requests.Session.send = _pyodide_send # /drive/ in Pyodide requires Cross-Origin-Isolation headers # (COOP/COEP) which many static servers (e.g. CircleCI artifacts) @@ -88,13 +97,13 @@ def pyodide_send(self, request, **kwargs): # Pyodide may run in a web worker (no `window`); `location` exists # in both the main thread and workers, so use it to find the docs # root by splitting on '/lite/'. -import pyodide.http as _phttp -import js as _js +import pyodide.http +import js try: - _page = str(_js.location.href) + _page = str(js.location.href) except Exception: - _page = str(_js.window.location.href) + _page = str(js.window.location.href) _base = _page.split("/lite/")[0] + "/mne_data/" mne_data_path = "/tmp/mne_data" _sample_dir = mne_data_path + "/MNE-sample-data" @@ -132,7 +141,7 @@ def pyodide_send(self, request, **kwargs): continue _url = _base + "MNE-sample-data/" + _f try: - _r = await _phttp.pyfetch(_url) + _r = await pyodide.http.pyfetch(_url) if _r.status != 200: print(f" HTTP {_r.status} for {_url}") continue @@ -149,15 +158,17 @@ def pyodide_send(self, request, **kwargs): os.environ["MNE_DATA"] = mne_data_path os.environ["MNE_DATASETS_SAMPLE_PATH"] = mne_data_path -# Block pooch from attempting large OSF downloads in the browser. -# The required files are either pre-injected or unavailable. +# Turn an OSF download into a readable error rather than an opaque CORS or +# out-of-memory failure. This covers Pooch.fetch, which is the path +# mne/datasets/_fetch.py uses for every packaged dataset; the handful of +# callers that use pooch.retrieve directly all point at other hosts. import pooch from urllib.parse import urlparse -orig_pooch_fetch = pooch.Pooch.fetch +_orig_pooch_fetch = pooch.Pooch.fetch -def pyodide_pooch_fetch(self, fname, processor=None, downloader=None): +def _pyodide_pooch_fetch(self, fname, processor=None, downloader=None): url = self.get_url(fname) # Compare the host rather than searching the whole URL: "osf.io" can turn # up legitimately elsewhere in one (a query string, a path), and a @@ -170,10 +181,10 @@ def pyodide_pooch_fetch(self, fname, processor=None, downloader=None): "dataset downloads. Open this notebook from mne.tools " "where sample data is pre-bundled, or run it locally." ) - return orig_pooch_fetch(self, fname, processor=processor, downloader=downloader) + return _orig_pooch_fetch(self, fname, processor=processor, downloader=downloader) -pooch.Pooch.fetch = pyodide_pooch_fetch +pooch.Pooch.fetch = _pyodide_pooch_fetch # Import MNE and finalize setup. import mne @@ -185,21 +196,22 @@ def pyodide_pooch_fetch(self, fname, processor=None, downloader=None): with open(_cfg, "w") as _f: _f.write("{}") mne.set_config("MNE_DATA", mne_data_path) -for ds in ["SAMPLE", "TESTING", "SSVEP", "EEGBCI", "SOMATO", "BRAINSTORM"]: - mne.set_config(f"MNE_DATASETS_{ds}_PATH", mne_data_path) +for _ds in ["SAMPLE", "TESTING", "SSVEP", "EEGBCI", "SOMATO", "BRAINSTORM"]: + mne.set_config(f"MNE_DATASETS_{_ds}_PATH", mne_data_path) +del _ds # Bypass pooch's archive check: data_path() normally looks for the # .tar.gz archive, not just the extracted folder. Return the folder # directly so pooch never tries to download from OSF. Return a Path # (not a str) since tutorials use the / operator on the result. -from pathlib import Path as _Path +from pathlib import Path -_mne_data_root = _Path(mne_data_path) +_mne_data_root = Path(mne_data_path) -def _lite_data_path(_rel): - """Return ``_rel`` resolved under the data root, as a POSIX string.""" - return (_mne_data_root / _rel).as_posix() +def _lite_data_path(rel): + """Return ``rel`` resolved under the data root.""" + return _mne_data_root / rel def _lite_rel_to_data(fname): @@ -208,16 +220,16 @@ def _lite_rel_to_data(fname): Replaces the ``startswith(mne_data_path + "/")`` plus manual slicing this file used to repeat at every reader shim. """ - _p = _Path(str(fname)) + _p = Path(str(fname)) if _p == _mne_data_root or not _p.is_relative_to(_mne_data_root): return None return _p.relative_to(_mne_data_root).as_posix() -_sample_path = _Path(_sample_dir) +_sample_path = Path(_sample_dir) -def _lite_sample_data_path(*_a, **_kw): +def _lite_sample_data_path(*args, **kwargs): return _sample_path @@ -231,36 +243,35 @@ def _lite_sample_data_path(*_a, **_kw): # notebook's setup. Pyodide runs in a web worker here, where a # synchronous XHR may set responseType='arraybuffer', letting a sync # data_path() read binary. -def _lite_fetch_rel(_rel): - _dst = _lite_data_path(_rel) - if not os.path.exists(_dst): +def _lite_fetch_rel(rel): + _dst = _lite_data_path(rel) + if not _dst.exists(): from js import XMLHttpRequest _xhr = XMLHttpRequest.new() - _xhr.open("GET", _base + _rel, False) + _xhr.open("GET", _base + rel, False) _xhr.responseType = "arraybuffer" _xhr.send() if _xhr.status != 200: - raise FileNotFoundError(f"Could not fetch {_rel} (HTTP {_xhr.status})") - os.makedirs(os.path.dirname(_dst), exist_ok=True) - with open(_dst, "wb") as _fh: - _fh.write(bytes(_xhr.response.to_py())) + raise FileNotFoundError(f"Could not fetch {rel} (HTTP {_xhr.status})") + _dst.parent.mkdir(parents=True, exist_ok=True) + _dst.write_bytes(bytes(_xhr.response.to_py())) return _dst def _lite_lazy_fetch(_folder, _fname): _lite_fetch_rel(_folder + "/" + _fname) - return _Path(_lite_data_path(_folder)) + return _lite_data_path(_folder) -def _lite_kiloword_data_path(*_a, **_kw): +def _lite_kiloword_data_path(*args, **kwargs): return _lite_lazy_fetch("MNE-kiloword-data", "kword_metadata-epo.fif") mne.datasets.kiloword.data_path = _lite_kiloword_data_path -def _lite_erp_core_data_path(*_a, **_kw): +def _lite_erp_core_data_path(*args, **kwargs): return _lite_lazy_fetch( "MNE-ERP-CORE-data", "ERP-CORE_Subject-001_Task-Flankers_eeg.fif" ) @@ -269,7 +280,7 @@ def _lite_erp_core_data_path(*_a, **_kw): mne.datasets.erp_core.data_path = _lite_erp_core_data_path -def _lite_mtrf_data_path(*_a, **_kw): +def _lite_mtrf_data_path(*args, **kwargs): return _lite_lazy_fetch("mTRF_1.5", "speech_data.mat") @@ -279,8 +290,8 @@ def _lite_mtrf_data_path(*_a, **_kw): # testing hands back the folder and lets the shimmed readers pull # individual files, so a notebook that wants the EEGLAB recording does # not also drag down the 39 MB movement raw. -def _lite_testing_data_path(*_a, **_kw): - return _Path(_lite_data_path("MNE-testing-data")) +def _lite_testing_data_path(*args, **kwargs): + return _lite_data_path("MNE-testing-data") mne.datasets.testing.data_path = _lite_testing_data_path @@ -290,8 +301,8 @@ def _lite_testing_data_path(*_a, **_kw): # files those examples read are served, and the shimmed readers below # pull them individually. def _lite_folder_data_path(_folder): - def _data_path(*_a, **_kw): - return _Path(_lite_data_path(_folder)) + def _data_path(*args, **kwargs): + return _lite_data_path(_folder) return _data_path @@ -308,7 +319,7 @@ def _data_path(*_a, **_kw): getattr(mne.datasets, _ds).data_path = _lite_folder_data_path(_folder) -def _lite_eegbci_load_data(subject, runs, *_a, **_kw): +def _lite_eegbci_load_data(subject, runs, *args, **kwargs): _runs = [runs] if isinstance(runs, (int, float)) else list(runs) _subjects = list(subject) if isinstance(subject, (list, tuple)) else [subject] _out = [] @@ -318,7 +329,7 @@ def _lite_eegbci_load_data(subject, runs, *_a, **_kw): "MNE-eegbci-data/files/eegmmidb/1.0.0/" f"S{int(_s):03d}/S{int(_s):03d}R{int(_r):02d}.edf" ) - _out.append(_Path(_lite_fetch_rel(_rel))) + _out.append(_lite_fetch_rel(_rel)) return _out @@ -338,38 +349,44 @@ def _lite_fetch_if_under_mne_data(fname): return fname -# Reader overrides, tier one. +# Reader overrides. +# +# Nothing is on disk here. The data is served over HTTP next to the docs, so +# a file has to be in the virtual filesystem by the time a reader opens it. # -# Nothing is on disk here. The data is served over HTTP next to the docs, -# and MNE readers validate their filename through _check_fname(must_exist= -# True) before opening it, so the file has to be in the virtual filesystem -# by the time the real reader is called. Each reader is therefore wrapped: -# fetch first at the path the caller asked for, then hand straight over to -# the original. +# There IS one general hook: nearly every MNE reader validates its filename +# through _check_fname(must_exist=True) first, so patching that one function +# (further down) covers read_info, read_evokeds, read_cov, read_label and the +# rest with no wrapper each. Three kinds of caller escape it, and those are +# what the wrappers below are for: # -# It has to be per reader rather than one hook on mne.io.read_raw, because -# the tutorials call the specific readers (read_raw_fif, read_epochs, -# read_forward_solution, ...) directly and never go through the generic one. +# 1. one filename that means several files. read_raw_brainvision is handed +# only the .vhdr, opens it, reads the names of its .eeg and .vmrk out of +# it, and opens those -- by which point we are inside the reader and it +# is too late to fetch. Same shape for EEGLAB (.set + .fdt), a .stc stem +# (lh + rh) and the formats that are a directory rather than a file. +# 2. code that probes instead of opening. _get_head_surface calls +# os.path.exists before any reader runs, so a fetch-on-open hook never +# fires for it. +# 3. readers that open their file without validating it first. # -# Most of them only need that fetch, so they are driven by the table further -# below: _mods is where the name is bound (some are exported twice, publicly -# and on a private alias), _name is the function and _arg is the keyword its -# filename arrives under when it is not passed positionally. Tier two, the -# readers needing more than a single fetch -- a .stc stem that means two -# files, a directory, a sibling -- keep their own hand-written shim below. -def _lite_wrap_reader(_mods, _name, _arg): - _orig = getattr(_mods[0], _name) - - def _wrapped(*_a, **_kw): - if _a: - _a = (_lite_fetch_if_under_mne_data(_a[0]),) + _a[1:] - elif _arg in _kw: +# The ones in group 3 need nothing but the fetch, so they are driven by the +# table further down rather than a shim each. +# +# `module` is where the name is bound, `name` is the function and `arg` is the +# keyword its filename arrives under when it is not passed positionally. +def _lite_wrap_reader(module, name, arg): + orig = getattr(module, name) + + def wrapped(*args, **kwargs): + if args: + args = (_lite_fetch_if_under_mne_data(args[0]),) + args[1:] + elif arg in kwargs: # positionally, as the hand-written shims did - _a = (_lite_fetch_if_under_mne_data(_kw.pop(_arg)),) - return _orig(*_a, **_kw) + args = (_lite_fetch_if_under_mne_data(kwargs.pop(arg)),) + return orig(*args, **kwargs) - for _m in _mods: - setattr(_m, _name, _wrapped) + setattr(module, name, wrapped) # Lazily fetch the heavy sample raw / source-space files only when a @@ -380,21 +397,21 @@ def _wrapped(*_a, **_kw): # function covers read_info, read_evokeds, read_cov, read_label and the # rest without a wrapper each. Failures stay silent here so MNE still # raises its own, clearer error for a file that genuinely is missing. -import mne.utils.check as _mne_check +import mne.utils.check as mne_check -_orig_check_fname = _mne_check._check_fname +_orig_check_fname = mne_check._check_fname -def _lite_check_fname(fname, overwrite=False, must_exist=False, *_a, **_kw): +def _lite_check_fname(fname, overwrite=False, must_exist=False, *args, **kwargs): if must_exist: try: _lite_fetch_if_under_mne_data(fname) except Exception: pass - return _orig_check_fname(fname, overwrite, must_exist, *_a, **_kw) + return _orig_check_fname(fname, overwrite, must_exist, *args, **kwargs) -_mne_check._check_fname = _lite_check_fname +mne_check._check_fname = _lite_check_fname # modules that imported it before now hold their own reference; ones # loaded later (mne lazy-loads most of itself) pick up the patch for _m in list(sys.modules.values()): @@ -403,13 +420,14 @@ def _lite_check_fname(fname, overwrite=False, must_exist=False, *_a, **_kw): and getattr(_m, "_check_fname", None) is _orig_check_fname ): _m._check_fname = _lite_check_fname -# read_label, read_epochs and read_raw_edf open their file directly -# rather than validating it first, so the hook above never sees them -# an EEGLAB .set keeps its samples in a sibling .fdt, so fetch both +# Below are the readers the _check_fname hook cannot serve on its own, +# because one filename implies more than one file. +# +# An EEGLAB .set keeps its samples in a sibling .fdt, so fetch both. _orig_read_raw_eeglab = mne.io.read_raw_eeglab -def _lite_read_raw_eeglab(input_fname, *_a, **_kw): +def _lite_read_raw_eeglab(input_fname, *args, **kwargs): _p = str(input_fname) if _lite_rel_to_data(_p) is not None: for _cand in (_p, _p[:-4] + ".fdt"): @@ -417,7 +435,7 @@ def _lite_read_raw_eeglab(input_fname, *_a, **_kw): _lite_fetch_rel(_lite_rel_to_data(_cand)) except Exception: pass - return _orig_read_raw_eeglab(input_fname, *_a, **_kw) + return _orig_read_raw_eeglab(input_fname, *args, **kwargs) mne.io.read_raw_eeglab = _lite_read_raw_eeglab @@ -440,14 +458,14 @@ def _lite_fetch_dir(_rel): def _lite_dir_reader(_orig): - def _read(fname, *_a, **_kw): + def _read(fname, *args, **kwargs): _p = str(fname) if _lite_rel_to_data(_p) is not None: try: _lite_fetch_dir(_lite_rel_to_data(_p)) except Exception as _e: print("[JupyterLite] could not fetch " + _p + ": " + repr(_e)) - return _orig(fname, *_a, **_kw) + return _orig(fname, *args, **kwargs) return _read @@ -457,21 +475,21 @@ def _read(fname, *_a, **_kw): # the logging tutorial reads a KIT file from inside the installed # package; the wheel excludes mne/**/tests, so stage the served copy # into the path the tutorial builds rather than editing the tutorial -import shutil as _shutil +import shutil _orig_read_raw_kit = mne.io.read_raw_kit -def _lite_read_raw_kit(input_fname, *_a, **_kw): +def _lite_read_raw_kit(input_fname, *args, **kwargs): _p = str(input_fname) if _p.endswith("test.sqd") and not os.path.exists(_p): try: _staged = _lite_fetch_rel("MNE-kit-testdata/test.sqd") os.makedirs(os.path.dirname(_p), exist_ok=True) - _shutil.copyfile(_staged, _p) + shutil.copyfile(_staged, _p) except Exception as _e: print("[JupyterLite] could not stage test.sqd: " + repr(_e)) - return _orig_read_raw_kit(input_fname, *_a, **_kw) + return _orig_read_raw_kit(input_fname, *args, **kwargs) mne.io.read_raw_kit = _lite_read_raw_kit @@ -479,7 +497,7 @@ def _lite_read_raw_kit(input_fname, *_a, **_kw): _orig_read_raw_brainvision = mne.io.read_raw_brainvision -def _lite_read_raw_brainvision(vhdr_fname, *_a, **_kw): +def _lite_read_raw_brainvision(vhdr_fname, *args, **kwargs): _p = str(vhdr_fname) if _lite_rel_to_data(_p) is not None: _stem = _p[:-5] if _p.endswith(".vhdr") else _p @@ -488,7 +506,7 @@ def _lite_read_raw_brainvision(vhdr_fname, *_a, **_kw): _lite_fetch_rel(_lite_rel_to_data(_cand)) except Exception: pass - return _orig_read_raw_brainvision(vhdr_fname, *_a, **_kw) + return _orig_read_raw_brainvision(vhdr_fname, *args, **kwargs) mne.io.read_raw_brainvision = _lite_read_raw_brainvision @@ -496,58 +514,54 @@ def _lite_read_raw_brainvision(vhdr_fname, *_a, **_kw): # the heatmap example draws its stimulus straight through pyplot, and # read_xdf goes through pyxdf -- neither is an MNE reader, so shim the # two entry points as well -import matplotlib.pyplot as _plt +import matplotlib.pyplot as plt -_orig_imread = _plt.imread +_orig_imread = plt.imread -def _lite_imread(fname, *_a, **_kw): - return _orig_imread(_lite_fetch_if_under_mne_data(fname), *_a, **_kw) +def _lite_imread(fname, *args, **kwargs): + return _orig_imread(_lite_fetch_if_under_mne_data(fname), *args, **kwargs) -_plt.imread = _lite_imread +plt.imread = _lite_imread try: import pyxdf as _pyxdf _orig_load_xdf = _pyxdf.load_xdf - def _lite_load_xdf(fname, *_a, **_kw): - return _orig_load_xdf(_lite_fetch_if_under_mne_data(fname), *_a, **_kw) + def _lite_load_xdf(fname, *args, **kwargs): + return _orig_load_xdf(_lite_fetch_if_under_mne_data(fname), *args, **kwargs) _pyxdf.load_xdf = _lite_load_xdf except Exception: pass # The tier-one table (see "Reader overrides" above for why this exists). # Each row is one reader that needs nothing but its file fetched first: -# _mods where the name is bound, as a tuple because a couple of them -# are exported both publicly and on a private alias -# _name the function to wrap on each of those modules -# _arg the keyword its filename arrives under, for calls that pass it -# by name rather than positionally -import mne.minimum_norm as _mne_minv -import mne.chpi as _mne_chpi - -for _mods, _name, _arg in ( - ((mne,), "read_forward_solution", "fname"), - ((_mne_minv, mne.minimum_norm), "read_inverse_operator", "fname"), - ((mne.io,), "read_raw_fif", "fname"), - ((mne.io,), "read_raw", "fname"), - ((mne,), "read_source_spaces", "fname"), - ((mne,), "read_label", "filename"), - ((mne,), "read_epochs", "fname"), - ((mne.io,), "read_raw_edf", "input_fname"), - ((mne,), "read_bem_solution", "fname"), - ((mne,), "read_events", "fname"), - ((mne.io,), "read_raw_eyelink", "fname"), - ((_mne_chpi, mne.chpi), "read_head_pos", "fname"), +# module where the name is bound +# name the function to wrap there +# arg the keyword its filename arrives under, for calls that pass it +# by name rather than positionally +for _module, _name, _arg in ( + (mne, "read_forward_solution", "fname"), + (mne.minimum_norm, "read_inverse_operator", "fname"), + (mne.io, "read_raw_fif", "fname"), + (mne.io, "read_raw", "fname"), + (mne, "read_source_spaces", "fname"), + (mne, "read_label", "filename"), + (mne, "read_epochs", "fname"), + (mne.io, "read_raw_edf", "input_fname"), + (mne, "read_bem_solution", "fname"), + (mne, "read_events", "fname"), + (mne.io, "read_raw_eyelink", "fname"), + (mne.chpi, "read_head_pos", "fname"), ): - _lite_wrap_reader(_mods, _name, _arg) + _lite_wrap_reader(_module, _name, _arg) # read_source_estimate is handed the stem of a .stc pair, so fetch # both hemispheres before letting MNE resolve the name itself. _orig_read_source_estimate = mne.read_source_estimate -def _lite_read_source_estimate(fname, *_a, **_kw): +def _lite_read_source_estimate(fname, *args, **kwargs): _p = str(fname) if _lite_rel_to_data(_p) is not None: for _suf in ("", "-lh.stc", "-rh.stc"): @@ -555,7 +569,7 @@ def _lite_read_source_estimate(fname, *_a, **_kw): _lite_fetch_rel(_lite_rel_to_data(_p) + _suf) except Exception: pass - return _orig_read_source_estimate(fname, *_a, **_kw) + return _orig_read_source_estimate(fname, *args, **kwargs) mne.read_source_estimate = _lite_read_source_estimate @@ -564,9 +578,9 @@ def _lite_read_source_estimate(fname, *_a, **_kw): # fires. Fetch the candidates first and let MNE choose as it normally # would. Several viz modules bind the name at import time, so rebind # it wherever the original landed instead of in one known place. -import mne._freesurfer as _mne_fs +import mne._freesurfer as mne_fs -_orig_get_head_surface = _mne_fs._get_head_surface +_orig_get_head_surface = mne_fs._get_head_surface def _lite_get_head_surface(surf, subject, subjects_dir, bem=None, verbose=None): @@ -587,7 +601,7 @@ def _lite_get_head_surface(surf, subject, subjects_dir, bem=None, verbose=None): return _orig_get_head_surface(surf, subject, subjects_dir, bem=bem, verbose=verbose) -_mne_fs._get_head_surface = _lite_get_head_surface +mne_fs._get_head_surface = _lite_get_head_surface # import the 3D module first so the sweep below is guaranteed to see # it; anything imported later picks the patched name up on its own. import mne.viz._3d # noqa: F401 @@ -600,7 +614,7 @@ def _lite_get_head_surface(surf, subject, subjects_dir, bem=None, verbose=None): _m._get_head_surface = _lite_get_head_surface # same story for the skull surfaces, which _check_fname insists # already exist on disk -_orig_get_skull_surface = _mne_fs._get_skull_surface +_orig_get_skull_surface = mne_fs._get_skull_surface def _lite_get_skull_surface(surf, subject, subjects_dir, bem=None, verbose=None): @@ -622,7 +636,7 @@ def _lite_get_skull_surface(surf, subject, subjects_dir, bem=None, verbose=None) ) -_mne_fs._get_skull_surface = _lite_get_skull_surface +mne_fs._get_skull_surface = _lite_get_skull_surface for _m in list(sys.modules.values()): if ( getattr(_m, "__name__", "").startswith("mne") @@ -633,9 +647,9 @@ def _lite_get_skull_surface(surf, subject, subjects_dir, bem=None, verbose=None) # one in mne/surface.py: it takes a list of candidate sources and # probes bem/ with os.path.exists and glob, raising if the directory # is absent, so the candidates have to land before it runs. -import mne.surface as _mne_surface +import mne.surface as mne_surface -_orig_surface_head = _mne_surface._get_head_surface +_orig_surface_head = mne_surface._get_head_surface def _lite_surface_head_surface( @@ -655,14 +669,14 @@ def _lite_surface_head_surface( ) -_mne_surface._get_head_surface = _lite_surface_head_surface +mne_surface._get_head_surface = _lite_surface_head_surface # plot_bem globs bem/*.surf and requires the bem directory to exist, # so pull its three contours (plus the MRI it draws them on) down # first; fetching creates the directory as a side effect. _orig_plot_bem = mne.viz.plot_bem -def _lite_plot_bem(subject=None, subjects_dir=None, *_a, **_kw): +def _lite_plot_bem(subject=None, subjects_dir=None, *args, **kwargs): _sd = str(subjects_dir) if subjects_dir is not None else "" if subject and _lite_rel_to_data(_sd) is not None: _rel = _lite_rel_to_data(_sd) + "/" + str(subject) @@ -670,9 +684,9 @@ def _lite_plot_bem(subject=None, subjects_dir=None, *_a, **_kw): "bem/inner_skull.surf", "bem/outer_skull.surf", "bem/outer_skin.surf", - "mri/" + str(_kw.get("mri", "T1.mgz")), + "mri/" + str(kwargs.get("mri", "T1.mgz")), ] - _bs = _kw.get("brain_surfaces") + _bs = kwargs.get("brain_surfaces") if _bs is not None: _bs = [_bs] if isinstance(_bs, str) else list(_bs) for _b in _bs: @@ -682,7 +696,7 @@ def _lite_plot_bem(subject=None, subjects_dir=None, *_a, **_kw): _lite_fetch_rel(_rel + "/" + _c) except Exception: pass - return _orig_plot_bem(subject, subjects_dir, *_a, **_kw) + return _orig_plot_bem(subject, subjects_dir, *args, **kwargs) mne.viz.plot_bem = _lite_plot_bem @@ -698,7 +712,7 @@ def _lite_plot_bem(subject=None, subjects_dir=None, *_a, **_kw): from mne.utils import progressbar as _mpb _mpb._UpdateThread.start = lambda self: None - _mpb._UpdateThread.join = lambda self, *_a, **_kw: None + _mpb._UpdateThread.join = lambda self, *args, **kwargs: None except Exception: pass # tqdm also spawns its own monitor thread, which likewise can't start in @@ -715,7 +729,6 @@ def _lite_plot_bem(subject=None, subjects_dir=None, *_a, **_kw): import IPython IPython.get_ipython().run_line_magic("matplotlib", "inline") -import matplotlib.pyplot as plt # Silence the spurious 'FigureCanvasAgg is non-interactive' warning # at its source. MNE's plt_show calls fig.show() (the inline backend @@ -724,17 +737,17 @@ def _lite_plot_bem(subject=None, subjects_dir=None, *_a, **_kw): # `from .utils import plt_show` and hold their own reference. Every # path resolves fig.show on the class at call time, so a no-op here # silences it everywhere. Figures still render via the inline backend. -import matplotlib.figure as _mfig +import matplotlib.figure as mpl_figure -_mfig.Figure.show = lambda self, *a, **k: None +mpl_figure.Figure.show = lambda self, *a, **k: None import importlib -viz_utils = importlib.import_module("mne.viz.utils") +_viz_utils = importlib.import_module("mne.viz.utils") # Also display+close via IPython for paths that call plt_show # directly, so figures render exactly once. -def pyodide_plt_show(show=True, fig=None, **kwargs): +def _pyodide_plt_show(show=True, fig=None, **kwargs): if not show: return import IPython.display @@ -744,4 +757,4 @@ def pyodide_plt_show(show=True, fig=None, **kwargs): plt.close(_f) -viz_utils.plt_show = pyodide_plt_show +_viz_utils.plt_show = _pyodide_plt_show diff --git a/doc/sphinxext/_lite_setup_cell_3d.py b/doc/sphinxext/_lite_setup_cell_3d.py index a10f3b71192..a548d596f59 100644 --- a/doc/sphinxext/_lite_setup_cell_3d.py +++ b/doc/sphinxext/_lite_setup_cell_3d.py @@ -25,16 +25,16 @@ # 'brain' whose methods (add_foci/add_text/show_view/...) are safe # no-ops, so tutorials that call brain.add_foci(...) after plot() work. class _LiteBrain: - def screenshot(self, *_a, **_kw): + def screenshot(self, *args, **kwargs): import numpy as _np return _np.zeros((2, 2, 3), dtype="uint8") def __getattr__(self, _name): - return lambda *_a, **_kw: None + return lambda *args, **kwargs: None -def _lite_stc_plot(self, *_a, **_kw): +def _lite_stc_plot(self, *args, **kwargs): try: import numpy as _np import nibabel as _nib @@ -43,15 +43,16 @@ def _lite_stc_plot(self, *_a, **_kw): import pyvista_js as _pv _subj = ( - _kw.get("subject") - or (_a[0] if _a and isinstance(_a[0], str) else None) + kwargs.get("subject") + or (args[0] if args and isinstance(args[0], str) else None) or "sample" ) - _sdir = _kw.get("subjects_dir") + _sdir = kwargs.get("subjects_dir") + # kept as a str on both branches: it is concatenated below _sdir = ( str(_sdir) if _sdir is not None - else _lite_data_path("MNE-sample-data/subjects") + else str(_lite_data_path("MNE-sample-data/subjects")) ) # surfaces are fetched relative to the served mne_data root, so # derive that from subjects_dir rather than assuming sample -- @@ -61,7 +62,7 @@ def _lite_stc_plot(self, *_a, **_kw): if _lite_rel_to_data(_sdir) is not None else "MNE-sample-data/subjects" ) - _init = _kw.get("initial_time", None) + _init = kwargs.get("initial_time", None) if _init is None: _ti = int(_np.argmax(_np.abs(self.data).mean(0))) else: @@ -254,7 +255,7 @@ def _lite_plot_sparse_source_estimates( _ax.set_ylabel("Source amplitude (nAm)", fontsize=fontsize) if fig_name is not None: _ax.set_title(fig_name) - pyodide_plt_show(show) + _pyodide_plt_show(show) # --- glass brain + dipole markers --------------------------------- try: import pyvista_js as _pv @@ -312,7 +313,7 @@ def _lite_plot_sparse_source_estimates( mne.viz.plot_sparse_source_estimates = _lite_plot_sparse_source_estimates -# Each MNE plot is rendered once by pyodide_plt_show above (display()). +# Each MNE plot is rendered once by _pyodide_plt_show above (display()). # When a plot call is also a cell's last expression, the method returns # the Figure, which Jupyter echoes a SECOND time as the Out[] result # (the duplicate seen below inline plots). Drop that redundant echo for @@ -329,12 +330,12 @@ def _lite_plot_sparse_source_estimates( _lite_dh_call = _lite_dh.__call__ def _lite_displayhook(self, result=None): - if isinstance(result, _mfig.Figure): + if isinstance(result, mpl_figure.Figure): result = None elif ( isinstance(result, (list, tuple)) and result - and all(isinstance(_x, _mfig.Figure) for _x in result) + and all(isinstance(_x, mpl_figure.Figure) for _x in result) ): result = None return _lite_dh_call(self, result) From a2b3ae408c3d9d77e0d1a27d8846f02d4c85a63a Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Wed, 26 Aug 2026 12:27:02 -0400 Subject: [PATCH 08/11] MAINT: reorder the setup cell and fix two reader shims Group the fetch helpers together and split the reader overrides into the three kinds of treatment they need, per review. Along the way: read_events takes `filename`, not `fname`, and eegbci takes `subjects`, which 35_eeg_no_mri passes by keyword. The eager fetch now raises instead of continuing past a file the build did not stage. --- doc/sphinxext/_lite_setup_cell.py | 725 ++++++++++++------------ doc/sphinxext/jupyterlite_setup_cell.py | 5 +- 2 files changed, 364 insertions(+), 366 deletions(-) diff --git a/doc/sphinxext/_lite_setup_cell.py b/doc/sphinxext/_lite_setup_cell.py index c6d0abb94f2..404f8cceee9 100644 --- a/doc/sphinxext/_lite_setup_cell.py +++ b/doc/sphinxext/_lite_setup_cell.py @@ -15,12 +15,24 @@ # the same module binds the same object, so there is nothing to protect. # `mne_data_path` is the deliberate exception, since a reader may want it. +# Layout, in the order the sections appear below: +# 1. install MNE into the browser kernel +# 2. patch what Pyodide lacks, before MNE is imported +# 3. work out where the data is served and copy the core of it in +# 4. import MNE and point its datasets at that copy +# 5. define the fetch helpers everything after this point uses +# 6. tell MNE where each dataset lives +# 7. wrap the readers, in three groups by how much work each needs +# 8. stub out what WebAssembly cannot do + # --- JupyterLite setup cell ------------------------------------------------- # 💡 This cell is automatically added to the start of each notebook. # It installs MNE and patches the browser environment for Pyodide. # Downloading this notebook to run it locally? Delete this cell first: # piplite exists only inside JupyterLite, and a local MNE needs none of # the patches below. + +# === 1. Install ============================================================== import piplite # Use piplite (not micropip) so the locally-built development MNE wheel @@ -46,9 +58,12 @@ keep_going=True, ) +# === 2. Pyodide compatibility, before MNE is imported ======================== import sys import os import io +import inspect +from pathlib import Path # Mock multiprocessing — missing in Pyodide but imported by joblib from unittest.mock import MagicMock @@ -89,10 +104,11 @@ def _pyodide_send(self, request, **kwargs): requests.Session.send = _pyodide_send +# === 3. Where the data comes from =========================================== # /drive/ in Pyodide requires Cross-Origin-Isolation headers # (COOP/COEP) which many static servers (e.g. CircleCI artifacts) -# do not send. Fetch the data over HTTP into /tmp/mne_data instead -# — same-origin, no CORS. The data is served at the docs root +# do not send. Fetch the data over HTTP into /tmp/mne_data instead: +# same-origin, no CORS. The data is served at the docs root # (/mne_data/...) via Sphinx html_extra_path. # Pyodide may run in a web worker (no `window`); `location` exists # in both the main thread and workers, so use it to find the docs @@ -106,7 +122,8 @@ def _pyodide_send(self, request, **kwargs): _page = str(js.window.location.href) _base = _page.split("/lite/")[0] + "/mne_data/" mne_data_path = "/tmp/mne_data" -_sample_dir = mne_data_path + "/MNE-sample-data" +_mne_data_root = Path(mne_data_path) +_sample_dir = _mne_data_root / "MNE-sample-data" # Eager 'core': small, commonly-used sample files fetched once at # notebook start. The heavy files (raw / filt raw / ernoise / fwd / # inv / src, ~360 MB total) are intentionally omitted here -- they are @@ -134,27 +151,45 @@ def _pyodide_send(self, request, **kwargs): "SSS/sss_cal_mgh.dat", "SSS/ct_sparse_mgh.fif", ] +# These are served from the same origin as this page, so if the page loaded, +# the server is up: a miss here means the docs build did not stage the file, +# not that the network is flaky. Several of them (the SSS calibration pair, +# the surfaces read through nibabel) have no lazy path either, so a miss would +# otherwise surface as a confusing error many cells later. Collect every +# failure and raise once, naming them all, since one staging bug usually drops +# more than one file. +# print, not a logger: this cell runs in the browser kernel, not in the Sphinx +# process, so its output is simply what the notebook reader sees. print("Fetching MNE sample data (once per session)...") +_missing = [] for _f in _sample_files: - _dst = _sample_dir + "/" + _f - if os.path.exists(_dst): + _dst = _sample_dir / _f + if _dst.exists(): continue _url = _base + "MNE-sample-data/" + _f try: _r = await pyodide.http.pyfetch(_url) if _r.status != 200: - print(f" HTTP {_r.status} for {_url}") + _missing.append(f"{_f} (HTTP {_r.status})") continue _d = await _r.bytes() + # a static server answers a missing path with its 404 page and a 200 + # status, so the body is the only way to tell the two apart if _d[:4] == b"//`` (group B).""" + _rel = _lite_rel_to_data(subjects_dir if subjects_dir is not None else "") + if not subject or _rel is None: + return + _lite_fetch_optional(f"{_rel}/{subject}/{_p}" for _p in rel_paths) -def _lite_mtrf_data_path(*args, **kwargs): - return _lite_lazy_fetch("mTRF_1.5", "speech_data.mat") +def _lite_dataset_path(folder, probe=None): + """Build a ``data_path()`` that returns ``folder`` under the data root. + With ``probe``, the named file is fetched when data_path() is called. That + is what covers mtrf, whose .mat is read by scipy rather than by an MNE + reader, so nothing downstream would otherwise fetch it. + """ -mne.datasets.mtrf.data_path = _lite_mtrf_data_path + def _data_path(*args, **kwargs): + if probe is not None: + _lite_fetch_rel(folder + "/" + probe) + return _lite_data_path(folder) + return _data_path -# testing hands back the folder and lets the shimmed readers pull -# individual files, so a notebook that wants the EEGLAB recording does -# not also drag down the 39 MB movement raw. -def _lite_testing_data_path(*args, **kwargs): - return _lite_data_path("MNE-testing-data") +def _lite_wrap_reader(module, name): + """Wrap ``module.name`` so its filename argument is fetched before it opens. -mne.datasets.testing.data_path = _lite_testing_data_path + The keyword to intercept is read off the wrapped function rather than + listed by hand: the readers below disagree about whether it is ``fname``, + ``filename`` or ``input_fname``, and a name written out here that drifted + from the real one would silently stop fetching for keyword callers. + """ + orig = getattr(module, name) + arg = next(iter(inspect.signature(orig).parameters)) + def wrapped(*args, **kwargs): + if args: + args = (_lite_fetch_if_under_mne_data(args[0]),) + args[1:] + elif arg in kwargs: + # move it to a positional argument, since it is no longer in kwargs + args = (_lite_fetch_if_under_mne_data(kwargs.pop(arg)),) + return orig(*args, **kwargs) -# Same again for the datasets behind a single example each. Only the -# files those examples read are served, and the shimmed readers below -# pull them individually. -def _lite_folder_data_path(_folder): - def _data_path(*args, **kwargs): - return _lite_data_path(_folder) + setattr(module, name, wrapped) - return _data_path +def _lite_dir_reader(orig): + """Wrap a reader that is handed a folder rather than a file.""" -for _ds, _folder in ( - ("ssvep", "ssvep-example-data"), - ("misc", "MNE-misc-data"), - ("eyelink", "MNE-eyelink-data"), - ("fnirs_motor", "MNE-fNIRS-motor-data"), - ("refmeg_noise", "MNE-refmeg-noise-data"), - ("phantom_kernel", "MNE-phantom-kernel-data"), - ("multimodal", "MNE-multimodal-data"), + def _read(fname, *args, **kwargs): + _rel = _lite_rel_to_data(fname) + if _rel is not None: + try: + _lite_fetch_dir(_rel) + except Exception as _e: + print("[JupyterLite] could not fetch " + str(fname) + ": " + repr(_e)) + return orig(fname, *args, **kwargs) + + return _read + + +def _lite_rebind(name, old, new): + """Point every module that already imported ``old`` at ``new``. + + MNE lazy-loads most of itself, so a module that ran ``from x import f`` + before this cell holds its own reference and would not see the patch. + Modules imported afterwards pick it up on their own. + """ + for _m in list(sys.modules.values()): + if ( + getattr(_m, "__name__", "").startswith("mne") + and getattr(_m, name, None) is old + ): + setattr(_m, name, new) + + +# === 6. Where MNE looks for each dataset ==================================== +# data_path() normally checks for the .tar.gz archive, not just the extracted +# folder, and would try to download from OSF when it does not find one. Point +# each dataset at its folder under the data root instead. The ones with a probe +# file are used by only a couple of notebooks each, so nothing is fetched until +# their data_path() is actually called. +for _ds, _folder, _probe in ( + ("sample", "MNE-sample-data", None), + # testing hands back the folder and lets the shimmed readers pull + # individual files, so a notebook that wants the EEGLAB recording does + # not also drag down the 39 MB movement raw. + ("testing", "MNE-testing-data", None), + # datasets behind a single example each; only the files those examples + # read are served, and the readers below pull them individually + ("ssvep", "ssvep-example-data", None), + ("misc", "MNE-misc-data", None), + ("eyelink", "MNE-eyelink-data", None), + ("fnirs_motor", "MNE-fNIRS-motor-data", None), + ("refmeg_noise", "MNE-refmeg-noise-data", None), + ("phantom_kernel", "MNE-phantom-kernel-data", None), + ("multimodal", "MNE-multimodal-data", None), + # kiloword/erp_core for Epochs 30 & 40, mtrf for the decoding examples + ("kiloword", "MNE-kiloword-data", "kword_metadata-epo.fif"), + ("erp_core", "MNE-ERP-CORE-data", "ERP-CORE_Subject-001_Task-Flankers_eeg.fif"), + ("mtrf", "mTRF_1.5", "speech_data.mat"), ): - getattr(mne.datasets, _ds).data_path = _lite_folder_data_path(_folder) + getattr(mne.datasets, _ds).data_path = _lite_dataset_path(_folder, _probe) +del _ds, _folder, _probe -def _lite_eegbci_load_data(subject, runs, *args, **kwargs): +# eegbci is addressed by subject and run rather than by path, so it needs its +# own shim rather than a row in the table above. +def _lite_eegbci_load_data(subjects, runs, *args, **kwargs): + # the parameter is `subjects`, matching MNE: 35_eeg_no_mri calls it by + # keyword, so a shim spelled `subject` would raise TypeError there _runs = [runs] if isinstance(runs, (int, float)) else list(runs) - _subjects = list(subject) if isinstance(subject, (list, tuple)) else [subject] + _subjects = list(subjects) if isinstance(subjects, (list, tuple)) else [subjects] _out = [] for _s in _subjects: for _r in _runs: @@ -335,68 +459,27 @@ def _lite_eegbci_load_data(subject, runs, *args, **kwargs): mne.datasets.eegbci.load_data = _lite_eegbci_load_data - -# Some MNE-sample-data files (e.g. the fixed-orientation forward/ -# inverse used by the point-spread tutorial) aren't in the eager -# _sample_files list above because only one or two notebooks need -# them. Rather than hand-listing every such file, lazily fetch any -# sample-data path the first time read_forward_solution/ -# read_inverse_operator is asked to open it. -def _lite_fetch_if_under_mne_data(fname): - _p = str(fname) - if _lite_rel_to_data(_p) is not None: - _lite_fetch_rel(_lite_rel_to_data(_p)) - return fname - - -# Reader overrides. +# === 7. Reader overrides ==================================================== +# MNE functions need one of three treatments here, depending on how much they +# do before the file is actually opened. # -# Nothing is on disk here. The data is served over HTTP next to the docs, so -# a file has to be in the virtual filesystem by the time a reader opens it. -# -# There IS one general hook: nearly every MNE reader validates its filename -# through _check_fname(must_exist=True) first, so patching that one function -# (further down) covers read_info, read_evokeds, read_cov, read_label and the -# rest with no wrapper each. Three kinds of caller escape it, and those are -# what the wrappers below are for: -# -# 1. one filename that means several files. read_raw_brainvision is handed +# A. reads one file, and validates the name first. Nearly every MNE reader +# calls _check_fname(must_exist=True) before opening anything, so patching +# that single function covers read_info, read_evokeds, read_cov, +# read_label and the rest at once. A handful skip the validation, and are +# listed in a table instead. Nothing else is needed for this group. +# B. probes the filesystem before any reader runs. _get_head_surface calls +# os.path.exists, plot_bem globs bem/*.surf, so a fetch-on-open hook never +# fires. The candidates have to be on disk before the probe. +# C. one filename that means several files. read_raw_brainvision is handed # only the .vhdr, opens it, reads the names of its .eeg and .vmrk out of -# it, and opens those -- by which point we are inside the reader and it -# is too late to fetch. Same shape for EEGLAB (.set + .fdt), a .stc stem -# (lh + rh) and the formats that are a directory rather than a file. -# 2. code that probes instead of opening. _get_head_surface calls -# os.path.exists before any reader runs, so a fetch-on-open hook never -# fires for it. -# 3. readers that open their file without validating it first. -# -# The ones in group 3 need nothing but the fetch, so they are driven by the -# table further down rather than a shim each. -# -# `module` is where the name is bound, `name` is the function and `arg` is the -# keyword its filename arrives under when it is not passed positionally. -def _lite_wrap_reader(module, name, arg): - orig = getattr(module, name) - - def wrapped(*args, **kwargs): - if args: - args = (_lite_fetch_if_under_mne_data(args[0]),) + args[1:] - elif arg in kwargs: - # positionally, as the hand-written shims did - args = (_lite_fetch_if_under_mne_data(kwargs.pop(arg)),) - return orig(*args, **kwargs) +# it, and opens those, by which point we are inside the reader and it is +# too late to fetch. Same shape for EEGLAB (.set + .fdt), a .stc stem +# (lh + rh), and the formats that are a directory rather than a file. - setattr(module, name, wrapped) - - -# Lazily fetch the heavy sample raw / source-space files only when a -# notebook actually reads them (same pattern as the fwd/inv shims -# above), instead of pulling the whole sample set up front. -# Nearly every MNE reader validates its filename through -# _check_fname(must_exist=True) before opening it, so hooking that one -# function covers read_info, read_evokeds, read_cov, read_label and the -# rest without a wrapper each. Failures stay silent here so MNE still -# raises its own, clearer error for a file that genuinely is missing. +# --- A. reads one file ------------------------------------------------------ +# The general hook. Failures stay silent here so MNE still raises its own, +# clearer error for a file that genuinely is missing. import mne.utils.check as mne_check _orig_check_fname = mne_check._check_fname @@ -412,108 +495,28 @@ def _lite_check_fname(fname, overwrite=False, must_exist=False, *args, **kwargs) mne_check._check_fname = _lite_check_fname -# modules that imported it before now hold their own reference; ones -# loaded later (mne lazy-loads most of itself) pick up the patch -for _m in list(sys.modules.values()): - if ( - getattr(_m, "__name__", "").startswith("mne") - and getattr(_m, "_check_fname", None) is _orig_check_fname - ): - _m._check_fname = _lite_check_fname -# Below are the readers the _check_fname hook cannot serve on its own, -# because one filename implies more than one file. -# -# An EEGLAB .set keeps its samples in a sibling .fdt, so fetch both. -_orig_read_raw_eeglab = mne.io.read_raw_eeglab - - -def _lite_read_raw_eeglab(input_fname, *args, **kwargs): - _p = str(input_fname) - if _lite_rel_to_data(_p) is not None: - for _cand in (_p, _p[:-4] + ".fdt"): - try: - _lite_fetch_rel(_lite_rel_to_data(_cand)) - except Exception: - pass - return _orig_read_raw_eeglab(input_fname, *args, **kwargs) - - -mne.io.read_raw_eeglab = _lite_read_raw_eeglab - - -# read_raw_nirx and read_raw_egi open a folder, so there is no single -# name to fetch; conf.py leaves a listing next to the copy. -def _lite_fetch_dir(_rel): - _manifest = _lite_fetch_rel(_rel + "/_lite_manifest.txt") - with open(_manifest) as _fh: - _names = [_n.strip() for _n in _fh if _n.strip()] - for _name in _names: - # one unreachable member must not abandon the rest of the - # recording; the reader complains if it needed that file - try: - _lite_fetch_rel(_rel + "/" + _name) - except Exception as _e: - print("[JupyterLite] skipped " + _name + ": " + repr(_e)) - return _lite_data_path(_rel) - - -def _lite_dir_reader(_orig): - def _read(fname, *args, **kwargs): - _p = str(fname) - if _lite_rel_to_data(_p) is not None: - try: - _lite_fetch_dir(_lite_rel_to_data(_p)) - except Exception as _e: - print("[JupyterLite] could not fetch " + _p + ": " + repr(_e)) - return _orig(fname, *args, **kwargs) - - return _read - - -mne.io.read_raw_nirx = _lite_dir_reader(mne.io.read_raw_nirx) -mne.io.read_raw_egi = _lite_dir_reader(mne.io.read_raw_egi) -# the logging tutorial reads a KIT file from inside the installed -# package; the wheel excludes mne/**/tests, so stage the served copy -# into the path the tutorial builds rather than editing the tutorial -import shutil - -_orig_read_raw_kit = mne.io.read_raw_kit - - -def _lite_read_raw_kit(input_fname, *args, **kwargs): - _p = str(input_fname) - if _p.endswith("test.sqd") and not os.path.exists(_p): - try: - _staged = _lite_fetch_rel("MNE-kit-testdata/test.sqd") - os.makedirs(os.path.dirname(_p), exist_ok=True) - shutil.copyfile(_staged, _p) - except Exception as _e: - print("[JupyterLite] could not stage test.sqd: " + repr(_e)) - return _orig_read_raw_kit(input_fname, *args, **kwargs) - - -mne.io.read_raw_kit = _lite_read_raw_kit -# a BrainVision .vhdr is a text header pointing at a .eeg and a .vmrk -_orig_read_raw_brainvision = mne.io.read_raw_brainvision - - -def _lite_read_raw_brainvision(vhdr_fname, *args, **kwargs): - _p = str(vhdr_fname) - if _lite_rel_to_data(_p) is not None: - _stem = _p[:-5] if _p.endswith(".vhdr") else _p - for _cand in (_p, _stem + ".eeg", _stem + ".vmrk"): - try: - _lite_fetch_rel(_lite_rel_to_data(_cand)) - except Exception: - pass - return _orig_read_raw_brainvision(vhdr_fname, *args, **kwargs) - - -mne.io.read_raw_brainvision = _lite_read_raw_brainvision -# eyelink .asc recordings are single files -# the heatmap example draws its stimulus straight through pyplot, and -# read_xdf goes through pyxdf -- neither is an MNE reader, so shim the -# two entry points as well +_lite_rebind("_check_fname", _orig_check_fname, _lite_check_fname) +# The readers that open their file without validating it first, so the hook +# above never sees them. Each needs nothing but its file fetched. +for _module, _name in ( + (mne, "read_forward_solution"), + (mne.minimum_norm, "read_inverse_operator"), + (mne.io, "read_raw_fif"), + (mne.io, "read_raw"), + (mne, "read_source_spaces"), + (mne, "read_label"), + (mne, "read_epochs"), + (mne.io, "read_raw_edf"), + (mne, "read_bem_solution"), + (mne, "read_events"), + (mne.io, "read_raw_eyelink"), + (mne.chpi, "read_head_pos"), +): + _lite_wrap_reader(_module, _name) +del _module, _name +# The eyetracking heatmap example draws its stimulus straight through pyplot, +# and read_xdf goes through pyxdf. Neither is an MNE reader, but both take a +# path we serve, so they get the same treatment. import matplotlib.pyplot as plt _orig_imread = plt.imread @@ -524,125 +527,61 @@ def _lite_imread(fname, *args, **kwargs): plt.imread = _lite_imread +# guarded: pyxdf has no pure-Python wheel on every Pyodide build, and only the +# XDF example needs it try: - import pyxdf as _pyxdf + import pyxdf - _orig_load_xdf = _pyxdf.load_xdf + _orig_load_xdf = pyxdf.load_xdf def _lite_load_xdf(fname, *args, **kwargs): return _orig_load_xdf(_lite_fetch_if_under_mne_data(fname), *args, **kwargs) - _pyxdf.load_xdf = _lite_load_xdf + pyxdf.load_xdf = _lite_load_xdf except Exception: pass -# The tier-one table (see "Reader overrides" above for why this exists). -# Each row is one reader that needs nothing but its file fetched first: -# module where the name is bound -# name the function to wrap there -# arg the keyword its filename arrives under, for calls that pass it -# by name rather than positionally -for _module, _name, _arg in ( - (mne, "read_forward_solution", "fname"), - (mne.minimum_norm, "read_inverse_operator", "fname"), - (mne.io, "read_raw_fif", "fname"), - (mne.io, "read_raw", "fname"), - (mne, "read_source_spaces", "fname"), - (mne, "read_label", "filename"), - (mne, "read_epochs", "fname"), - (mne.io, "read_raw_edf", "input_fname"), - (mne, "read_bem_solution", "fname"), - (mne, "read_events", "fname"), - (mne.io, "read_raw_eyelink", "fname"), - (mne.chpi, "read_head_pos", "fname"), -): - _lite_wrap_reader(_module, _name, _arg) -# read_source_estimate is handed the stem of a .stc pair, so fetch -# both hemispheres before letting MNE resolve the name itself. -_orig_read_source_estimate = mne.read_source_estimate - - -def _lite_read_source_estimate(fname, *args, **kwargs): - _p = str(fname) - if _lite_rel_to_data(_p) is not None: - for _suf in ("", "-lh.stc", "-rh.stc"): - try: - _lite_fetch_rel(_lite_rel_to_data(_p) + _suf) - except Exception: - pass - return _orig_read_source_estimate(fname, *args, **kwargs) - -mne.read_source_estimate = _lite_read_source_estimate -# plot_alignment locates its head surface by probing the filesystem -# with os.path.exists before any reader runs, so a reader shim never -# fires. Fetch the candidates first and let MNE choose as it normally -# would. Several viz modules bind the name at import time, so rebind -# it wherever the original landed instead of in one known place. +# --- B. probes the filesystem first ----------------------------------------- +# plot_alignment locates its head surface with os.path.exists before any reader +# runs. Fetch the candidates first and let MNE choose as it normally would. +# Several viz modules bind the name at import time, so rebind it wherever the +# original landed rather than in one known place. import mne._freesurfer as mne_fs _orig_get_head_surface = mne_fs._get_head_surface def _lite_get_head_surface(surf, subject, subjects_dir, bem=None, verbose=None): - _sd = str(subjects_dir) if subjects_dir is not None else "" - if subject and _lite_rel_to_data(_sd) is not None: - _rel = _lite_rel_to_data(_sd) + "/" + str(subject) - if surf in ("head-dense", "seghead"): - _cands = ["bem/" + str(subject) + "-head-dense.fif", "surf/lh.seghead"] - else: - # same order MNE tries, so the browser picks the same - # surface the rendered docs did - _cands = ["bem/outer_skin.surf", "bem/" + str(subject) + "-head.fif"] - for _c in _cands: - try: - _lite_fetch_rel(_rel + "/" + _c) - except Exception: - pass + if surf in ("head-dense", "seghead"): + _cands = [f"bem/{subject}-head-dense.fif", "surf/lh.seghead"] + else: + # same order MNE tries, so the browser picks the same + # surface the rendered docs did + _cands = ["bem/outer_skin.surf", f"bem/{subject}-head.fif"] + _lite_fetch_candidates(subject, subjects_dir, _cands) return _orig_get_head_surface(surf, subject, subjects_dir, bem=bem, verbose=verbose) mne_fs._get_head_surface = _lite_get_head_surface -# import the 3D module first so the sweep below is guaranteed to see -# it; anything imported later picks the patched name up on its own. +# import the 3D module first so the rebind is guaranteed to see it; +# anything imported later picks the patched name up on its own. import mne.viz._3d # noqa: F401 -for _m in list(sys.modules.values()): - if ( - getattr(_m, "__name__", "").startswith("mne") - and getattr(_m, "_get_head_surface", None) is _orig_get_head_surface - ): - _m._get_head_surface = _lite_get_head_surface +_lite_rebind("_get_head_surface", _orig_get_head_surface, _lite_get_head_surface) # same story for the skull surfaces, which _check_fname insists # already exist on disk _orig_get_skull_surface = mne_fs._get_skull_surface def _lite_get_skull_surface(surf, subject, subjects_dir, bem=None, verbose=None): - _sd = str(subjects_dir) if subjects_dir is not None else "" - if subject and _lite_rel_to_data(_sd) is not None: - try: - _lite_fetch_rel( - _lite_rel_to_data(_sd) - + "/" - + str(subject) - + "/bem/" - + surf - + "_skull.surf" - ) - except Exception: - pass + _lite_fetch_candidates(subject, subjects_dir, [f"bem/{surf}_skull.surf"]) return _orig_get_skull_surface( surf, subject, subjects_dir, bem=bem, verbose=verbose ) mne_fs._get_skull_surface = _lite_get_skull_surface -for _m in list(sys.modules.values()): - if ( - getattr(_m, "__name__", "").startswith("mne") - and getattr(_m, "_get_skull_surface", None) is _orig_get_skull_surface - ): - _m._get_skull_surface = _lite_get_skull_surface +_lite_rebind("_get_skull_surface", _orig_get_skull_surface, _lite_get_skull_surface) # dig_mri_distances reaches a second, unrelated _get_head_surface, the # one in mne/surface.py: it takes a list of candidate sources and # probes bem/ with os.path.exists and glob, raising if the directory @@ -655,20 +594,17 @@ def _lite_get_skull_surface(surf, subject, subjects_dir, bem=None, verbose=None) def _lite_surface_head_surface( subject, source, subjects_dir, on_defects, raise_error=True ): - _sd = str(subjects_dir) if subjects_dir is not None else "" - if subject and _lite_rel_to_data(_sd) is not None: - _rel = _lite_rel_to_data(_sd) + "/" + str(subject) - _srcs = [source] if isinstance(source, str) else list(source) - for _s in _srcs: - try: - _lite_fetch_rel(_rel + "/bem/" + str(subject) + "-" + _s + ".fif") - except Exception: - pass + _srcs = [source] if isinstance(source, str) else list(source) + _lite_fetch_candidates( + subject, subjects_dir, [f"bem/{subject}-{_s}.fif" for _s in _srcs] + ) return _orig_surface_head( subject, source, subjects_dir, on_defects, raise_error=raise_error ) +# no _lite_rebind for this one: unlike the _freesurfer function above, nothing +# outside mne/surface.py imports it by name, so patching the module is enough. mne_surface._get_head_surface = _lite_surface_head_surface # plot_bem globs bem/*.surf and requires the bem directory to exist, # so pull its three contours (plus the MRI it draws them on) down @@ -677,51 +613,112 @@ def _lite_surface_head_surface( def _lite_plot_bem(subject=None, subjects_dir=None, *args, **kwargs): - _sd = str(subjects_dir) if subjects_dir is not None else "" - if subject and _lite_rel_to_data(_sd) is not None: - _rel = _lite_rel_to_data(_sd) + "/" + str(subject) - _want = [ - "bem/inner_skull.surf", - "bem/outer_skull.surf", - "bem/outer_skin.surf", - "mri/" + str(kwargs.get("mri", "T1.mgz")), - ] - _bs = kwargs.get("brain_surfaces") - if _bs is not None: - _bs = [_bs] if isinstance(_bs, str) else list(_bs) - for _b in _bs: - _want += ["surf/lh." + _b, "surf/rh." + _b] - for _c in _want: - try: - _lite_fetch_rel(_rel + "/" + _c) - except Exception: - pass + _want = [ + "bem/inner_skull.surf", + "bem/outer_skull.surf", + "bem/outer_skin.surf", + "mri/" + str(kwargs.get("mri", "T1.mgz")), + ] + _bs = kwargs.get("brain_surfaces") + if _bs is not None: + _bs = [_bs] if isinstance(_bs, str) else list(_bs) + for _b in _bs: + _want += [f"surf/lh.{_b}", f"surf/rh.{_b}"] + _lite_fetch_candidates(subject, subjects_dir, _want) return _orig_plot_bem(subject, subjects_dir, *args, **kwargs) mne.viz.plot_bem = _lite_plot_bem +# --- C. one filename, several files ----------------------------------------- +# An EEGLAB .set keeps its samples in a sibling .fdt, so fetch both. +_orig_read_raw_eeglab = mne.io.read_raw_eeglab + + +def _lite_read_raw_eeglab(input_fname, *args, **kwargs): + _rel = _lite_rel_to_data(input_fname) + if _rel is not None: + _lite_fetch_optional((_rel, _rel[:-4] + ".fdt")) + return _orig_read_raw_eeglab(input_fname, *args, **kwargs) + + +mne.io.read_raw_eeglab = _lite_read_raw_eeglab +# a BrainVision .vhdr is a text header pointing at a .eeg and a .vmrk +_orig_read_raw_brainvision = mne.io.read_raw_brainvision + + +def _lite_read_raw_brainvision(vhdr_fname, *args, **kwargs): + _rel = _lite_rel_to_data(vhdr_fname) + if _rel is not None: + _stem = _rel[:-5] if _rel.endswith(".vhdr") else _rel + _lite_fetch_optional((_rel, _stem + ".eeg", _stem + ".vmrk")) + return _orig_read_raw_brainvision(vhdr_fname, *args, **kwargs) + + +mne.io.read_raw_brainvision = _lite_read_raw_brainvision +# read_source_estimate is handed the stem of a .stc pair, so fetch +# both hemispheres before letting MNE resolve the name itself. +_orig_read_source_estimate = mne.read_source_estimate + + +def _lite_read_source_estimate(fname, *args, **kwargs): + _rel = _lite_rel_to_data(fname) + if _rel is not None: + _lite_fetch_optional(_rel + _suf for _suf in ("", "-lh.stc", "-rh.stc")) + return _orig_read_source_estimate(fname, *args, **kwargs) + + +mne.read_source_estimate = _lite_read_source_estimate +# read_raw_nirx and read_raw_egi open a folder, listed by its manifest +mne.io.read_raw_nirx = _lite_dir_reader(mne.io.read_raw_nirx) +mne.io.read_raw_egi = _lite_dir_reader(mne.io.read_raw_egi) +# the odd one out in this group: the name is enough, but it points inside the +# installed package rather than at the data root. The logging tutorial builds +# a path into mne/**/tests, which the wheel excludes, so copy the served file +# to where the tutorial expects it rather than editing the tutorial. +import shutil + +_orig_read_raw_kit = mne.io.read_raw_kit + + +def _lite_read_raw_kit(input_fname, *args, **kwargs): + _p = Path(str(input_fname)) + if _p.name == "test.sqd" and not _p.exists(): + try: + _staged = _lite_fetch_rel("MNE-kit-testdata/test.sqd") + _p.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(_staged, _p) + except Exception as _e: + print("[JupyterLite] could not stage test.sqd: " + repr(_e)) + return _orig_read_raw_kit(input_fname, *args, **kwargs) + + +mne.io.read_raw_kit = _lite_read_raw_kit +# === 8. What WebAssembly cannot do ========================================== # Pyodide/WASM has no OS threads, so MNE's ProgressBar background # updater thread (used by the ProgressBar context manager, e.g. in # permutation cluster tests) crashes with 'can't start new thread'. -# That thread only animates a cosmetic bar — the computation runs on -# the main thread and __exit__ writes the final state — so no-op its +# That thread only animates a cosmetic bar: the computation runs on +# the main thread and __exit__ writes the final state, so no-op its # start/join. Only affects notebooks that use it; results are unchanged. +# Guarded because this is a private MNE path: if it is ever renamed, losing a +# cosmetic patch is better than failing every notebook at the setup cell. try: - from mne.utils import progressbar as _mpb + from mne.utils import progressbar - _mpb._UpdateThread.start = lambda self: None - _mpb._UpdateThread.join = lambda self, *args, **kwargs: None + progressbar._UpdateThread.start = lambda self: None + progressbar._UpdateThread.join = lambda self, *args, **kwargs: None except Exception: pass # tqdm also spawns its own monitor thread, which likewise can't start in # WASM and emits a TqdmMonitorWarning. Setting monitor_interval=0 before # any bar is created skips that thread entirely (bars still display). +# Guarded because tqdm is a transitive dependency that may not be installed. try: - import tqdm as _tqdm + import tqdm - _tqdm.tqdm.monitor_interval = 0 + tqdm.tqdm.monitor_interval = 0 except Exception: pass diff --git a/doc/sphinxext/jupyterlite_setup_cell.py b/doc/sphinxext/jupyterlite_setup_cell.py index df51882e052..298347ce5aa 100644 --- a/doc/sphinxext/jupyterlite_setup_cell.py +++ b/doc/sphinxext/jupyterlite_setup_cell.py @@ -28,8 +28,9 @@ from jupyterlite_lite_renderer import LITE_RENDERER_CELL -# Everything after the banner is what the notebook runs. The license header and -# the ruff directives above it belong to the file, not to the cell. +# Each source file read below is split at this banner: everything after it is +# what the notebook runs, and what sits above it in that file (license header, +# ruff directives, notes for whoever edits it) stays behind. _BANNER = "# --- JupyterLite setup cell" From df7abccd55527d54856eb9427d0af83a4dd6dd30 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Wed, 26 Aug 2026 13:32:57 -0400 Subject: [PATCH 09/11] MAINT: stop the 3D stub faking a screenshot brain.screenshot() returned a blank 2x2 image, so 10_publication_figure cropped it and published two black squares as the real before/after. It raises now. Also: the requests shim reports the real HTTP status instead of always 200, so pooch fails on a 404 rather than on a later hash mismatch, and the plt_show comment no longer blames a bug MNE fixed in gh-14076. --- doc/sphinxext/_lite_setup_cell.py | 64 +++++++++++----------- doc/sphinxext/_lite_setup_cell_3d.py | 81 ++++++++++++++++++++++------ 2 files changed, 98 insertions(+), 47 deletions(-) diff --git a/doc/sphinxext/_lite_setup_cell.py b/doc/sphinxext/_lite_setup_cell.py index 404f8cceee9..60af172d23a 100644 --- a/doc/sphinxext/_lite_setup_cell.py +++ b/doc/sphinxext/_lite_setup_cell.py @@ -37,8 +37,8 @@ # Use piplite (not micropip) so the locally-built development MNE wheel # bundled into the JupyterLite build is preferred over the older PyPI -# release; -# piplite checks the local index first and falls back to PyPI for deps. +# release: piplite checks the local index first and falls back to PyPI +# for dependencies. # keep_going=True so a dependency with no pure-Python wheel is reported # at the end rather than aborting the whole install on the first one. await piplite.install( @@ -69,36 +69,40 @@ from unittest.mock import MagicMock if "multiprocessing" not in sys.modules: - m = MagicMock() - m.cpu_count.return_value = 1 - sys.modules["multiprocessing"] = m - sys.modules["multiprocessing.util"] = m.util - sys.modules["multiprocessing.pool"] = m.pool - -# Route requests through pyodide.http so the downloads that still go through -# pooch work in the browser. The one that matters is fetch_infant_template + _mp = MagicMock() + _mp.cpu_count.return_value = 1 + sys.modules["multiprocessing"] = _mp + sys.modules["multiprocessing.util"] = _mp.util + sys.modules["multiprocessing.pool"] = _mp.pool + +# Route requests over the browser's own transport so the downloads that still +# go through pooch work here. The one that matters is fetch_infant_template # (25_automated_coreg), which reaches pooch.retrieve -> pooch.HTTPDownloader -# -> requests, and whose files live on github.com rather than OSF. open_url -# handles both text and binary in Pyodide >= 0.21. +# -> requests, and whose files live on github.com rather than OSF. +# +# XMLHttpRequest rather than pyodide.http.open_url, and the same blocking call +# _lite_fetch_rel uses below: open_url reports no status, so a 404 page came +# back looking like a successful 200 and pooch wrote the error page to disk, +# only failing later on a confusing hash mismatch. XHR gives the real status, +# which is what pooch's raise_for_status() needs. Nothing is caught here: the +# browser is the only transport available, so a failure has no fallback worth +# taking and the real error is more useful than a substituted one. import requests -import pyodide _orig_send = requests.Session.send def _pyodide_send(self, request, **kwargs): - try: - buf = pyodide.http.open_url(request.url) - content = buf.getvalue() if hasattr(buf, "getvalue") else buf.read() - if isinstance(content, str): - content = content.encode("utf-8") - except Exception as e: - print(f"open_url failed for {request.url}: {e}") - return _orig_send(self, request, **kwargs) + from js import XMLHttpRequest + + _xhr = XMLHttpRequest.new() + _xhr.open(request.method or "GET", request.url, False) + _xhr.responseType = "arraybuffer" + _xhr.send() response = requests.Response() - response.status_code = 200 + response.status_code = _xhr.status response.url = request.url - response.raw = io.BytesIO(content) + response.raw = io.BytesIO(bytes(_xhr.response.to_py())) return response @@ -727,13 +731,13 @@ def _lite_read_raw_kit(input_fname, *args, **kwargs): IPython.get_ipython().run_line_magic("matplotlib", "inline") -# Silence the spurious 'FigureCanvasAgg is non-interactive' warning -# at its source. MNE's plt_show calls fig.show() (the inline backend -# isn't detected as 'agg'), and the inline Agg canvas warns. Patching -# viz.utils.plt_show is not enough: other modules did -# `from .utils import plt_show` and hold their own reference. Every -# path resolves fig.show on the class at call time, so a no-op here -# silences it everywhere. Figures still render via the inline backend. +# Silence the spurious 'FigureCanvasAgg is non-interactive' warning that the +# inline Agg canvas raises from fig.show(). MNE's own plt_show no longer +# triggers it (gh-14076 taught it to call plt.show() on inline backends), but +# tutorials still call fig.show() directly -- 50_ssvep does it four times, and +# 10_background_stats once -- and those warn. Every path resolves fig.show on +# the class at call time, so a no-op here covers them all. Figures still +# render via the inline backend. import matplotlib.figure as mpl_figure mpl_figure.Figure.show = lambda self, *a, **k: None diff --git a/doc/sphinxext/_lite_setup_cell_3d.py b/doc/sphinxext/_lite_setup_cell_3d.py index a548d596f59..0e5a8ad3097 100644 --- a/doc/sphinxext/_lite_setup_cell_3d.py +++ b/doc/sphinxext/_lite_setup_cell_3d.py @@ -20,15 +20,51 @@ # approximate MNE's Brain look with solid-colored meshes: a two-tone # curvature base (light gyri + dark sulci) plus many thin 'hot' bands # for the activation, on a black background with even scene lighting. -# Static, one time point, no time slider yet. Fully guarded — any -# failure prints a message so the notebook completes. Returns a stub -# 'brain' whose methods (add_foci/add_text/show_view/...) are safe -# no-ops, so tutorials that call brain.add_foci(...) after plot() work. +# Static, one time point, no time slider yet. +# +# A failed render prints and lets the notebook carry on, which is the opposite +# of how the data fetch in the base cell behaves. That is deliberate: a file +# missing there means the docs build is broken and should say so, while this +# whole shim stands in for a stack with no WebAssembly build at all, so failing +# hard would take out every 3D notebook rather than report one bug. +# +# The stub 'brain' it returns makes the decorating calls (add_foci/add_text/ +# show_view/...) no-ops so the rest of the notebook still runs. screenshot() is +# the one exception, and it raises: see below. +# Say once per session that what the browser draws is not what the rendered +# docs show, so a reader comparing the two is not left guessing. pyvista-js has +# no scalar colormap, so activation arrives as discrete solid bands rather than +# a continuous scale, there is no colorbar or time slider, and the hemispheres +# are drawn side by side rather than in anatomical position. +_lite_3d_noted = False + + +def _lite_note_3d_approximation(): + global _lite_3d_noted + if _lite_3d_noted: + return + _lite_3d_noted = True + print( + "[JupyterLite] 3D drawn with pyvista-js: activation is shown as solid " + "colour bands at a single time point, with the hemispheres side by " + "side and no colorbar. The figure in the rendered docs is MNE's full " + "Brain view and will not look the same." + ) + + class _LiteBrain: def screenshot(self, *args, **kwargs): - import numpy as _np - - return _np.zeros((2, 2, 3), dtype="uint8") + # No blank array here. vtk.js draws into a browser canvas that Python + # cannot read back, so there is no image to return, and handing back a + # blank one is worse than failing: 10_publication_figure crops its + # screenshot and shows before/after, so it would publish two black + # squares as though they were the real thing. A notebook that needs a + # screenshot belongs in JUPYTERLITE_EXCLUDE instead. + raise NotImplementedError( + "brain.screenshot() is not available in JupyterLite: the vtk.js " + "renderer draws to a browser canvas that Python cannot read back. " + "Run this notebook locally to capture the scene." + ) def __getattr__(self, _name): return lambda *args, **kwargs: None @@ -68,7 +104,17 @@ def _lite_stc_plot(self, *args, **kwargs): else: _ti = int(_np.argmin(_np.abs(self.times - _init))) _hot = _cmaps["hot"] - _N = 10 + # Tuned against the inflated FreeSurfer surfaces MNE ships, whose + # coordinates are in mm. + _N = 10 # activation value bands + _BLOB_MM = 12.0 # colour a surface vertex from an active source within + # this radius, so a single-vertex source reads as a blob and not a dot + _HEMI_MM = 60.0 # push the hemispheres apart so they do not overlap + _LIFT = 0.02 # raise each band off the surface to avoid z-fighting + _HOT_LO, _HOT_HI = 0.25, 0.66 # slice of 'hot' to use; its ends are + # near-black and near-white, which read as background here + _SPARSE_P90 = 0.05 # below this fraction of the max the 90th pct means + _SPARSE_FLOOR = 0.4 # the data is sparse, so threshold on the max def _flat(_t): return _np.hstack( @@ -124,9 +170,9 @@ def _sub(_pts, _tris, _mask, _lift=0.0, _cen=None): if _act.any(): _atree = _KDTree(_rr[_vno][_act]) _ad, _ai = _atree.query(_rr) - _scal = _np.where(_ad <= 12.0, _sv[_act][_ai], 0.0) + _scal = _np.where(_ad <= _BLOB_MM, _sv[_act][_ai], 0.0) # offset hemispheres along x so they do not overlap - _off = -60.0 if _h == "lh" else 60.0 + _off = -_HEMI_MM if _h == "lh" else _HEMI_MM _pts = _np.round(_rr, 2) _pts[:, 0] = _pts[:, 0] + _off _cen = _pts.mean(0) @@ -151,7 +197,7 @@ def _sub(_pts, _tris, _mask, _lift=0.0, _cen=None): # keep the background gray: for sparse point sources the # 90th pct is ~0 (most of the brain is zero), which would # paint everything, so fall back to a fraction of the max. - _fmin = _p90 if _p90 > _fmax * 0.05 else _fmax * 0.4 + _fmin = _p90 if _p90 > _fmax * _SPARSE_P90 else _fmax * _SPARSE_FLOOR if _fmax > _fmin: _edges = _np.linspace(_fmin, _fmax, _N + 1) for _i in range(_N): @@ -161,9 +207,9 @@ def _sub(_pts, _tris, _mask, _lift=0.0, _cen=None): _m = _fv >= _edges[_i] if int(_m.sum()) == 0: continue - _rgb = _hot(0.25 + 0.41 * (_i / (_N - 1))) + _rgb = _hot(_HOT_LO + (_HOT_HI - _HOT_LO) * (_i / (_N - 1))) _col = (float(_rgb[0]), float(_rgb[1]), float(_rgb[2])) - _s = _sub(_pts, _tris, _m, 0.02, _cen) + _s = _sub(_pts, _tris, _m, _LIFT, _cen) if _s is not None: _plotter.add_mesh( _pv.PolyData(points=_s[0], faces=_flat(_s[1])), @@ -179,6 +225,7 @@ def _sub(_pts, _tris, _mask, _lift=0.0, _cen=None): except Exception: pass _plotter.show() + _lite_note_3d_approximation() except Exception as _e: print("[JupyterLite] pyvista-js 3D render unavailable: " + repr(_e)) return _LiteBrain() @@ -307,6 +354,7 @@ def _lite_plot_sparse_source_estimates( except Exception: pass _plotter.show() + _lite_note_3d_approximation() except Exception as _e: print("[JupyterLite] pyvista-js glass brain unavailable: " + repr(_e)) @@ -355,16 +403,15 @@ def _lite_displayhook(self, result=None): # unreleased; Pyodide bundles the released 3.6.0 wheel. DROP THIS PATCH # once threadpoolctl 3.7.0 is released and Pyodide bundles it. try: - import os as _os - import threadpoolctl as _tpc + import threadpoolctl def _find_libraries_pyodide(self): from pyodide_js._module import LDSO for _fp in LDSO.loadedLibsByName.as_py_json(): - if _os.path.exists(_fp): + if Path(_fp).exists(): self._make_controller_from_path(_fp) - _tpc.ThreadpoolController._find_libraries_pyodide = _find_libraries_pyodide + threadpoolctl.ThreadpoolController._find_libraries_pyodide = _find_libraries_pyodide except Exception: pass From 48186a6056c5030b319e28fa4eafbede5064c7b0 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Wed, 26 Aug 2026 13:44:59 -0400 Subject: [PATCH 10/11] DOC: name the right caller of the requests shim It is fetch_fsaverage reached from montage.py, not fetch_infant_template: that one is only mentioned in prose by 25_automated_coreg, and the tutorial that really calls it is already excluded from the build. --- doc/sphinxext/_lite_setup_cell.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/doc/sphinxext/_lite_setup_cell.py b/doc/sphinxext/_lite_setup_cell.py index 60af172d23a..d065665d4b6 100644 --- a/doc/sphinxext/_lite_setup_cell.py +++ b/doc/sphinxext/_lite_setup_cell.py @@ -76,9 +76,12 @@ sys.modules["multiprocessing.pool"] = _mp.pool # Route requests over the browser's own transport so the downloads that still -# go through pooch work here. The one that matters is fetch_infant_template -# (25_automated_coreg), which reaches pooch.retrieve -> pooch.HTTPDownloader -# -> requests, and whose files live on github.com rather than OSF. +# go through pooch work here. That path is pooch.retrieve -> +# pooch.HTTPDownloader -> requests, used by the fetchers whose files live off +# the docs site (fetch_fsaverage and friends) and so are not in the copy +# html_extra_path serves. Most of their callers are on JUPYTERLITE_EXCLUDE +# already; examples/visualization/montage.py is the one that still reaches +# this, via fetch_fsaverage. # # XMLHttpRequest rather than pyodide.http.open_url, and the same blocking call # _lite_fetch_rel uses below: open_url reports no status, so a 404 page came From b868a145025058c2210213720b7cc5cbe27fd13d Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Wed, 26 Aug 2026 15:00:52 -0400 Subject: [PATCH 11/11] DOC: nothing badged reaches the requests shim any more montage.py was the last one, and it is back on the exclude list now that its rename is fixed. The shim stays: the list is the only thing keeping it unused, and a notebook added tomorrow would otherwise hit a Pyodide socket error instead of a real HTTP one. --- doc/sphinxext/_lite_setup_cell.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/doc/sphinxext/_lite_setup_cell.py b/doc/sphinxext/_lite_setup_cell.py index d065665d4b6..672eeca69a0 100644 --- a/doc/sphinxext/_lite_setup_cell.py +++ b/doc/sphinxext/_lite_setup_cell.py @@ -77,11 +77,13 @@ # Route requests over the browser's own transport so the downloads that still # go through pooch work here. That path is pooch.retrieve -> -# pooch.HTTPDownloader -> requests, used by the fetchers whose files live off -# the docs site (fetch_fsaverage and friends) and so are not in the copy -# html_extra_path serves. Most of their callers are on JUPYTERLITE_EXCLUDE -# already; examples/visualization/montage.py is the one that still reaches -# this, via fetch_fsaverage. +# pooch.HTTPDownloader -> requests, taken by the fetchers whose files live off +# the docs site (fetch_fsaverage, fetch_infant_template and the parcellation +# ones) and so are not in the copy html_extra_path serves. Every notebook that +# calls one is on JUPYTERLITE_EXCLUDE today, so nothing reaches this on the +# badged pages; it stays because that list is the only thing keeping it that +# way, and a notebook added to the gallery tomorrow would otherwise fail here +# with a Pyodide socket error rather than a real HTTP one. # # XMLHttpRequest rather than pyodide.http.open_url, and the same blocking call # _lite_fetch_rel uses below: open_url reports no status, so a 404 page came