From 641d2688c8fd08074c33e465ab7bf2762c0ede3b Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Fri, 26 Jun 2026 09:34:27 -0400 Subject: [PATCH 01/46] ENH: Add JupyterLite sphinx integration and CI/CD infrastructure Integrates jupyterlite-sphinx into the MNE-Python doc build so every sphinx-gallery example gets a 'Try in Browser' button backed by a Pyodide/WebAssembly kernel. - doc/conf.py: configure jupyterlite_sphinx; build a local MNE dev wheel with relaxed Pyodide constraints; copy required MNE sample-data subset into JupyterLite's virtual filesystem; inject a setup cell that installs MNE via micropip (keep_going=True bypasses version conflicts), mocks missing stdlib modules (lzma, multiprocessing), patches pooch to block large OSF downloads, and sets MNE_DATA paths - .circleci/config.yml: ensure MNE sample data is on disk before the doc build so conf.py can copy it into jupyterlite_contents/ - .github/workflows/jupyterlite.yml: standalone GH Actions workflow on the jupyterlite-gh-actions branch that builds and uploads the site - pyproject.toml: add jupyterlite-pyodide-kernel and jupyterlite-sphinx to the [doc] extras - .gitignore: exclude jupyterlite_contents build artifacts --- .circleci/config.yml | 30 +- .github/workflows/jupyterlite.yml | 46 ++ .gitignore | 3 + doc/changes/dev/13925.newfeature.rst | 1 + doc/conf.py | 622 ++++++++++++++------------- doc/jupyterlite_contents/.gitkeep | 0 pyproject.toml | 40 +- 7 files changed, 415 insertions(+), 327 deletions(-) create mode 100644 .github/workflows/jupyterlite.yml create mode 100644 doc/changes/dev/13925.newfeature.rst create mode 100644 doc/jupyterlite_contents/.gitkeep diff --git a/.circleci/config.yml b/.circleci/config.yml index d032e40887d..536cbae2841 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -21,6 +21,9 @@ _check_skip: &check_skip circleci-agent step halt; fi +_machine_image: &machine_image + image: ubuntu-2604:2026.05.1 + jobs: build_docs: parameters: @@ -28,7 +31,7 @@ jobs: type: string default: "false" machine: - image: ubuntu-2404:current + <<: *machine_image # large 4 vCPUs 15GB mem # https://discuss.circleci.com/t/changes-to-remote-docker-reporting-pricing/47759 resource_class: large @@ -62,6 +65,9 @@ jobs: if [[ $(cat merge.txt) != "" ]]; then echo "Merging $(cat merge.txt)"; git pull --ff-only upstream "refs/pull/$(cat merge.txt)/merge"; + elif [[ "$CIRCLE_PROJECT_USERNAME" != "mne-tools" ]]; then + echo "On CIRCLE_PROJECT_USERNAME=\"$CIRCLE_PROJECT_USERNAME\" repo rather than mne-tools, merging upstream/main." + git merge upstream/main --no-edit || exit 1 else if [[ "$CIRCLE_BRANCH" == "main" ]]; then KIND=dev @@ -107,7 +113,7 @@ jobs: # Load pip cache - restore_cache: keys: - - pip-cache-0 + - pip-cache-1 - restore_cache: keys: - user-install-bin-cache-310 @@ -119,7 +125,7 @@ jobs: ./tools/circleci_dependencies.sh - save_cache: - key: pip-cache-0 + key: pip-cache-1 paths: - ~/.cache/pip - save_cache: @@ -219,7 +225,7 @@ jobs: keys: - data-cache-ds004388 - run: - name: Get data + name: Get data and triage examples to run # This limit could be increased, but this is helpful for finding slow ones # (even ~2GB datasets should be downloadable in this time from good # providers) @@ -243,6 +249,16 @@ jobs: cp junit-results.xml doc/_build/test-results/test-doc/junit.xml; cp coverage.xml doc/_build/test-results/test-doc/coverage.xml; fi; + # Ensure the MNE sample dataset is on disk so conf.py can copy the + # required subset into jupyterlite_contents/ for the JupyterLite build. + # circleci_download.sh only fetches sample data when the changed files + # reference it, so a cache miss on a PR that touches only doc/conf.py + # would leave ~/mne_data/MNE-sample-data absent and the notebooks would + # fail at runtime with FileNotFoundError on /drive/mne_data. + - run: + name: Ensure MNE sample data for JupyterLite + command: | + python -c "import mne; mne.datasets.sample.data_path(update_path=True)" # Build docs - run: name: make html @@ -416,7 +432,7 @@ jobs: type: string default: "false" machine: - image: ubuntu-2404:current + <<: *machine_image resource_class: large steps: - restore_cache: @@ -436,7 +452,7 @@ jobs: command: ./tools/circleci_bash_env.sh - restore_cache: keys: - - pip-cache-0 + - pip-cache-1 - run: name: Get Python running command: | @@ -457,7 +473,7 @@ jobs: deploy: machine: - image: ubuntu-2404:current + <<: *machine_image steps: - attach_workspace: at: /tmp/build diff --git a/.github/workflows/jupyterlite.yml b/.github/workflows/jupyterlite.yml new file mode 100644 index 00000000000..c06af5601ae --- /dev/null +++ b/.github/workflows/jupyterlite.yml @@ -0,0 +1,46 @@ +name: Build JupyterLite + +on: # yamllint disable-line rule:truthy + push: + branches: + - jupyterlite-gh-actions + +permissions: + contents: read + +jobs: + build: + name: Build JupyterLite Site + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install JupyterLite & Build Tools + run: | + python -m pip install --upgrade pip + pip install jupyterlite-core jupyterlite-pyodide-kernel build jupyter-server + + - name: Build MNE-Python Wheel + run: | + python -m build --wheel + mkdir -p lite-wheels + cp dist/*.whl lite-wheels/ + + - name: Build JupyterLite Site + # We pass the local wheel to JupyterLite so the browser environment uses the exact code from this branch! + run: | + jupyter lite build --contents examples/ --output-dir dist_lite/ --piplite-wheel lite-wheels/*.whl + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: jupyterlite-build + path: dist_lite/ diff --git a/.gitignore b/.gitignore index d66fbef96de..5dc51296dcf 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,7 @@ tags /doc/fil-result /doc/optipng.exe /doc/sphinxext/.joblib +/doc/code_credit.inc sg_execution_times.rst sg_api_usage.rst sg_api_unused.dot @@ -102,3 +103,5 @@ venv/ .hypothesis/ .ruff_cache/ .ipynb_checkpoints/ +jupyterlite_contents/auto_tutorials +jupyterlite_contents/mne_data diff --git a/doc/changes/dev/13925.newfeature.rst b/doc/changes/dev/13925.newfeature.rst new file mode 100644 index 00000000000..bcd3109e8d1 --- /dev/null +++ b/doc/changes/dev/13925.newfeature.rst @@ -0,0 +1 @@ +Added a JupyterLite GitHub Actions workflow to automatically build a Wasm-compatible interactive documentation site by Natneal Belete. diff --git a/doc/conf.py b/doc/conf.py index e7f740db907..1dde8a9fc9f 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -11,10 +11,11 @@ import faulthandler import os +import re +import shutil import subprocess import sys from datetime import datetime, timezone -from importlib.metadata import metadata from pathlib import Path import matplotlib @@ -24,6 +25,7 @@ from sphinx.config import is_serializable from sphinx.domains.changeset import versionlabels from sphinx_gallery.sorting import ExplicitOrder +from yaml import safe_load import mne import mne.html_templates._templates @@ -85,7 +87,9 @@ # built documents. # # The full version, including alpha/beta/rc tags. -release = mne.__version__ +release = mne.__version__ or "1.9.0" +if release == "None": + release = "1.9.0" sphinx_logger.info(f"Building documentation for MNE {release} ({mne.__file__})") # The short X.Y version. version = ".".join(release.split(".")[:2]) @@ -112,10 +116,11 @@ # contrib "matplotlib.sphinxext.plot_directive", "numpydoc", + "sphinxcontrib.bibtex", + "sphinx_gallery.gen_gallery", + "jupyterlite_sphinx", "sphinx_copybutton", "sphinx_design", - "sphinx_gallery.gen_gallery", - "sphinxcontrib.bibtex", "sphinxcontrib.youtube", "sphinxcontrib.towncrier.ext", # homegrown @@ -138,7 +143,7 @@ # This pattern also affects html_static_path and html_extra_path. # NB: changes here should also be made to the linkcheck target in the Makefile -exclude_patterns = ["_includes", "changes/dev"] +exclude_patterns = ["_includes", "changes/dev", "jupyterlite_contents", "corrupt_*"] # The suffix of source filenames. source_suffix = ".rst" @@ -190,6 +195,8 @@ ), ) ) +# Broken as of 2026/06/08 (https://github.com/joblib/joblib/issues/1796) +intersphinx_mapping["joblib"] = ("https://joblib.readthedocs.io/en/stable", None) # NumPyDoc configuration ----------------------------------------------------- @@ -423,6 +430,8 @@ "polars", "default", # unlinkable + "_Renderer", + "n_triangles", "CoregistrationUI", "mne_qt_browser.figure.MNEQtBrowser", # pooch, since its website is unreliable and users will rarely need the links @@ -469,7 +478,238 @@ compress_images = () sphinx_gallery_parallel = int(os.getenv("MNE_DOC_BUILD_N_JOBS", "1")) +jupyterlite_contents = ["jupyterlite_contents"] +jupyterlite_bind_ipynb_suffix = False + +# Automatically inject the required subset of MNE-sample-data into JupyterLite. +# The destination directory is always created so /drive/mne_data is present in +# the Pyodide kernel's virtual filesystem even when no files have been copied. +src_sample_data = Path(os.path.expanduser("~/mne_data/MNE-sample-data")) +dst_sample_data = ( + Path(os.path.abspath(os.path.dirname(__file__))) + / "jupyterlite_contents" + / "mne_data" + / "MNE-sample-data" +) +dst_sample_data.mkdir(parents=True, exist_ok=True) +if src_sample_data.exists(): + required_files = [ + "version.txt", + "MEG/sample/sample_audvis_raw.fif", + "MEG/sample/sample_audvis_filt-0-40_raw.fif", + "MEG/sample/sample_audvis_raw-eve.fif", + "MEG/sample/sample_audvis-ave.fif", + "MEG/sample/sample_audvis-cov.fif", + "MEG/sample/sample_audvis-meg-eeg-oct-6-fwd.fif", + "MEG/sample/sample_audvis-meg-oct-6-meg-inv.fif", + "subjects/sample/mri/T1.mgz", + "subjects/sample/bem/sample-oct-6-src.fif", + "subjects/sample/bem/sample-5120-5120-5120-bem-sol.fif", + "subjects/sample/surf/rh.pial", + "subjects/sample/surf/lh.pial", + ] + for req in required_files: + s = src_sample_data / req + d = dst_sample_data / req + if s.exists(): + d.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(s, d) + + +# Also inject SSVEP and EEGLAB testing datasets for JupyterLite +mne_data_base = Path(os.path.expanduser("~/mne_data")) +lite_data_base = ( + Path(os.path.abspath(os.path.dirname(__file__))) + / "jupyterlite_contents" + / "mne_data" +) +lite_data_base.mkdir(parents=True, exist_ok=True) + +src_ssvep = mne_data_base / "ssvep-example-data" +dst_ssvep = lite_data_base / "ssvep-example-data" +if src_ssvep.exists() and not dst_ssvep.exists(): + shutil.copytree(src_ssvep, dst_ssvep, dirs_exist_ok=True) + +src_eeglab = mne_data_base / "MNE-testing-data" / "EEGLAB" +dst_eeglab = lite_data_base / "MNE-testing-data" / "EEGLAB" +if src_eeglab.exists() and not dst_eeglab.exists(): + shutil.copytree(src_eeglab, dst_eeglab, dirs_exist_ok=True) + + +# Build the local MNE wheel so JupyterLite can use the current development version +dist_lite_dir = os.path.join( + os.path.abspath(os.path.dirname(__file__)), "_build", "dist_lite" +) +# Clean the directory first so stale wheels from previous runs do not +# accumulate and pollute the piplite all.json index. +shutil.rmtree(dist_lite_dir, ignore_errors=True) +os.makedirs(dist_lite_dir, exist_ok=True) + +pyproject_path = os.path.join(os.path.dirname(__file__), "..", "pyproject.toml") +with open(pyproject_path, encoding="utf-8") as f: + orig_pyproject = f.read() + +# Relax constraints for Pyodide which often lags behind PyPI. +# The wheel built here is served to the browser kernel; micropip's +# keep_going=True means these bounds won't block install, but we also +# relax them here so the wheel metadata is accurate for inspection. +patched = re.sub(r'"scipy\s*>=\s*1\.1[0-9]"', '"scipy >= 1.7"', orig_pyproject) +patched = re.sub(r'"matplotlib\s*>=\s*3\.[5-9]"', '"matplotlib >= 3.5"', patched) +patched = re.sub(r'"numpy\s*>=\s*1\.\d+,\s*<\s*3"', '"numpy >= 1.20, < 3"', patched) +os.environ["SETUPTOOLS_SCM_PRETEND_VERSION"] = "9999.0.1" +try: + with open(pyproject_path, "w", encoding="utf-8") as f: + f.write(patched) + # NB: build isolation is left ON (the default). MNE uses the hatchling + # build backend (build-backend = "hatchling.build"), so pip must create + # an isolated build env to install hatchling/hatch-vcs; passing + # --no-build-isolation fails with "Cannot import 'hatchling.build'" on + # CI where those build deps are not in the base environment. Isolation + # also builds from a fresh copy that reads the patched pyproject.toml + # below, so the relaxed constraints are still picked up. + subprocess.run( + [ + sys.executable, + "-m", + "pip", + "wheel", + "..", + "--no-deps", + "-w", + dist_lite_dir, + ], + check=True, + ) +finally: + with open(pyproject_path, "w", encoding="utf-8") as f: + f.write(orig_pyproject) + +jupyterlite_build_command_options = {"piplite-wheels": dist_lite_dir} + sphinx_gallery_conf = { + "jupyterlite": { + "use_jupyter_lab": True, + "jupyterlite_contents": "jupyterlite_contents", + }, + "first_notebook_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 micropip\n" + "# keep_going=True lets micropip install even if Pyodide's bundled\n" + "# matplotlib/scipy/numpy are older than MNE's declared minimums.\n" + "# MNE's runtime code only checks matplotlib >= 3.7/3.8, so 3.8.4 works.\n" + "await micropip.install(['mne', 'scikit-learn', 'joblib'], keep_going=True)\n" + "\n" + "import sys\n" + "import os\n" + "import io\n" + "\n" + "# Mock lzma — missing in Pyodide but imported by pooch/joblib\n" + "import types\n" + "class MockLZMA:\n" + " LZMAError = Exception\n" + " LZMAFile = object\n" + " FORMAT_XZ = 1\n" + " FORMAT_ALONE = 2\n" + " def __getattr__(self, name):\n" + " return object\n" + "if 'lzma' not in sys.modules:\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" + "# Set the data directory: /drive/mne_data is pre-populated by the doc\n" + "# build (conf.py copies the required sample-data subset there).\n" + "# If absent (e.g. standalone JupyterLite), fall back to /tmp/mne_data\n" + "# with a clear warning — do NOT attempt to download ~1.5 GB from OSF.\n" + "mne_data_path = (\n" + " '/drive/mne_data' if os.path.exists('/drive/mne_data')\n" + " else '/tmp/mne_data'\n" + ")\n" + "os.makedirs(mne_data_path, exist_ok=True)\n" + "if mne_data_path == '/tmp/mne_data':\n" + " print(\n" + " '⚠️ MNE sample data not found at /drive/mne_data. '\n" + " 'Cells that load datasets will raise FileNotFoundError. '\n" + " 'Open this notebook from the live MNE docs (mne.tools) '\n" + " 'where sample data is pre-bundled.'\n" + " )\n" + "os.environ['MNE_DATA'] = mne_data_path\n" + "os.environ['MNE_DATASETS_SAMPLE_PATH'] = mne_data_path\n" + "\n" + "# Block pooch from attempting large OSF downloads in the browser.\n" + "# The required files are either pre-injected or unavailable.\n" + "import pooch\n" + "orig_pooch_fetch = pooch.Pooch.fetch\n" + "def pyodide_pooch_fetch(self, fname, processor=None, downloader=None):\n" + " url = self.get_url(fname)\n" + " if 'osf.io' in url or 'files.osf.io' in url:\n" + " raise RuntimeError(\n" + " f'Cannot download {fname!r} from OSF in JupyterLite: '\n" + " 'browser CORS policy and memory limits prevent large '\n" + " 'dataset downloads. Open this notebook from mne.tools '\n" + " 'where sample data is pre-bundled, or run it locally.'\n" + " )\n" + " return orig_pooch_fetch(\n" + " self, fname, processor=processor, downloader=downloader\n" + " )\n" + "pooch.Pooch.fetch = pyodide_pooch_fetch\n" + "\n" + "# Import MNE and finalize setup.\n" + "import mne\n" + "try:\n" + " mne.get_config()\n" + "except Exception:\n" + " try:\n" + " os.remove(mne.get_config_path())\n" + " print('Corrupted MNE config deleted automatically.')\n" + " except Exception:\n" + " pass\n" + "for ds in ['SAMPLE', 'TESTING', 'SSVEP', 'EEGBCI', 'SOMATO',\n" + " 'AUDIOVISUAL', 'BRAINSTORM']:\n" + " mne.set_config(f'MNE_DATASETS_{ds}_PATH', mne_data_path)\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" + "import importlib\n" + "viz_utils = importlib.import_module('mne.viz.utils')\n" + "orig_plt_show = viz_utils.plt_show\n" + "def pyodide_plt_show(*args, **kwargs):\n" + " orig_plt_show(*args, **kwargs)\n" + " import IPython.display\n" + " IPython.display.display(plt.gcf())\n" + "viz_utils.plt_show = pyodide_plt_show\n" + ), "doc_module": ("mne",), "reference_url": dict(mne=None), "examples_dirs": examples_dirs, @@ -654,6 +894,7 @@ def fix_sklearn_inherited_docstrings(app, what, name, obj, options, lines): "https://doi.org/10.1126/", # www.science.org "https://doi.org/10.1137/", # epubs.siam.org "https://doi.org/10.1145/", # dl.acm.org + "https://doi.org/10.5281/", # zenodo.org "https://doi.org/10.1155/", # www.hindawi.com/journals/cin "https://doi.org/10.1161/", # www.ahajournals.org "https://doi.org/10.1162/", # direct.mit.edu/neco/article/ @@ -664,6 +905,8 @@ def fix_sklearn_inherited_docstrings(app, what, name, obj, options, lines): "https://doi.org/10.3390/", # mdpi.com "https://hms.harvard.edu/", # doc/funding.rst "https://stackoverflow.com/questions/21752259/python-why-pickle", # doc/help/faq + "https://mitpress.mit.edu/9780262525855", # works but linkcheck fails to resolve + "https://zenodo.org", # doc/help/faq "https://blender.org", "https://home.alexk101.dev", "https://www.mq.edu.au/", @@ -708,8 +951,12 @@ def fix_sklearn_inherited_docstrings(app, what, name, obj, options, lines): "https://psychophysiology.cpmc.columbia.edu", "https://erc.easme-web.eu", "https://www.crnl.fr", + # Spurious failure + "https://megcore.nih.gov/index.php/Staff", # Not rendered by linkcheck builder r"ides\.html", + # Sponsors not rendered properly by linkcheck builder + "{{inst.url}}", ] linkcheck_anchors = False # saves a bit of time linkcheck_timeout = 15 # some can be quite slow @@ -879,13 +1126,47 @@ def fix_sklearn_inherited_docstrings(app, what, name, obj, options, lines): # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. html_show_sphinx = False -# accommodate different logo shapes (width values in rem) -xs = "2" -sm = "2.5" -md = "3" -lg = "4.5" -xl = "5" -xxl = "6" +# sponsor and partner logos +with open("_static/sponsors.yml") as fid: + sponsors_partners = safe_load(fid) +current = sponsors_partners.pop("current") +# sponsors +current_sponsors = list() +former_sponsors = list() +for key, val in sponsors_partners["sponsors"].items(): + if "img" in val: + val["name"] = key + (current_sponsors if key in current else former_sponsors).append(val) + else: + assert "light" in val and "dark" in val + for mode in ("light", "dark"): + (current_sponsors if key in current else former_sponsors).append( + dict( + name=f"{key}{'_dk' if mode == 'dark' else ''}", + title=val["title"], + img=val[mode], + klass=f"only-{mode}", + ) + ) +# institutions +current_institutions = list() +former_institutions = list() +for key, val in sponsors_partners["partner_institutions"].items(): + if "img" in val: + val["name"] = key + (current_institutions if key in current else former_institutions).append(val) + else: + assert "light" in val and "dark" in val + for mode in ("light", "dark"): + (current_institutions if key in current else former_institutions).append( + dict( + name=f"{key}{'_dk' if mode == 'dark' else ''}", + title=val["title"], + img=val[mode], + klass=f"only-{mode}", + url=val["url"], + ) + ) # variables to pass to HTML templating engine html_context = { "default_mode": "auto", @@ -894,292 +1175,13 @@ def fix_sklearn_inherited_docstrings(app, what, name, obj, options, lines): "github_repo": "mne-python", "github_version": "main", "doc_path": "doc", - "funders": [ - dict(img="nih.svg", size="3", title="National Institutes of Health"), - dict(img="nsf.png", size="3.5", title="US National Science Foundation"), - dict( - img="erc.svg", - size="3.5", - title="European Research Council", - klass="only-light", - ), - dict( - img="erc-dark.svg", - size="3.5", - title="European Research Council", - klass="only-dark", - ), - dict(img="doe.svg", size="3", title="US Department of Energy"), - dict(img="anr.svg", size="3.5", title="Agence Nationale de la Recherche"), - dict( - img="cds.svg", - size="1.75", - title="Paris-Saclay Center for Data Science", - klass="only-light", - ), - dict( - img="cds-dark.svg", - size="1.75", - title="Paris-Saclay Center for Data Science", - klass="only-dark", - ), - dict(img="google.svg", size="2.25", title="Google"), - dict(img="amazon.svg", size="2.5", title="Amazon"), - dict(img="czi.svg", size="2.5", title="Chan Zuckerberg Initiative"), - ], - "institutions": [ - dict( - name="Massachusetts General Hospital", - img="MGH.svg", - url="https://www.massgeneral.org/", - size=sm, - ), - dict( - name="Athinoula A. Martinos Center for Biomedical Imaging", - img="Martinos.png", - url="https://martinos.org/", - size=md, - ), - dict( - name="Harvard Medical School", - img="Harvard.png", - url="https://hms.harvard.edu/", - size=sm, - ), - dict( - name="Massachusetts Institute of Technology", - img="MIT.svg", - url="https://web.mit.edu/", - size=md, - ), - dict( - name="New York University", - img="NYU.svg", - url="https://www.nyu.edu/", - size=xs, - klass="only-light", - ), - dict( - name="New York University", - img="NYU-dark.svg", - url="https://www.nyu.edu/", - size=xs, - klass="only-dark", - ), - dict( - name="Commissariat à l´énergie atomique et aux énergies alternatives", - img="CEA.png", - url="http://www.cea.fr/", - size=md, - ), - dict( - name="Aalto-yliopiston perustieteiden korkeakoulu", - img="Aalto.svg", - url="https://sci.aalto.fi/", - size=md, - klass="only-light", - ), - dict( - name="Aalto-yliopiston perustieteiden korkeakoulu", - img="Aalto-dark.svg", - url="https://sci.aalto.fi/", - size=md, - klass="only-dark", - ), - dict( - name="Télécom ParisTech", - img="Telecom_Paris_Tech.svg", - url="https://www.telecom-paris.fr/", - size=md, - ), - dict( - name="University of Washington", - img="Washington.svg", - url="https://www.washington.edu/", - size=md, - klass="only-light", - ), - dict( - name="University of Washington", - img="Washington-dark.svg", - url="https://www.washington.edu/", - size=md, - klass="only-dark", - ), - dict( - name="Institut du Cerveau et de la Moelle épinière", - img="ICM.jpg", - url="https://icm-institute.org/", - size=md, - ), - dict( - name="Boston University", img="BU.svg", url="https://www.bu.edu/", size=lg - ), - dict( - name="Institut national de la santé et de la recherche médicale", - img="Inserm.svg", - url="https://www.inserm.fr/", - size=xl, - klass="only-light", - ), - dict( - name="Institut national de la santé et de la recherche médicale", - img="Inserm-dark.svg", - url="https://www.inserm.fr/", - size=xl, - klass="only-dark", - ), - dict( - name="Forschungszentrum Jülich", - img="Julich.svg", - url="https://www.fz-juelich.de/", - size=xl, - klass="only-light", - ), - dict( - name="Forschungszentrum Jülich", - img="Julich-dark.svg", - url="https://www.fz-juelich.de/", - size=xl, - klass="only-dark", - ), - dict( - name="Technische Universität Ilmenau", - img="Ilmenau.svg", - url="https://www.tu-ilmenau.de/", - size=xxl, - klass="only-light", - ), - dict( - name="Technische Universität Ilmenau", - img="Ilmenau-dark.svg", - url="https://www.tu-ilmenau.de/", - size=xxl, - klass="only-dark", - ), - dict( - name="Berkeley Institute for Data Science", - img="BIDS.svg", - url="https://bids.berkeley.edu/", - size=lg, - klass="only-light", - ), - dict( - name="Berkeley Institute for Data Science", - img="BIDS-dark.svg", - url="https://bids.berkeley.edu/", - size=lg, - klass="only-dark", - ), - dict( - name="Institut national de recherche en informatique et en automatique", - img="inria.png", - url="https://www.inria.fr/", - size=xl, - ), - dict( - name="Aarhus Universitet", - img="Aarhus.svg", - url="https://www.au.dk/", - size=xl, - klass="only-light", - ), - dict( - name="Aarhus Universitet", - img="Aarhus-dark.svg", - url="https://www.au.dk/", - size=xl, - klass="only-dark", - ), - dict( - name="Karl-Franzens-Universität Graz", - img="Graz.svg", - url="https://www.uni-graz.at/", - size=md, - ), - dict( - name="SWPS Uniwersytet Humanistycznospołeczny", - img="SWPS.svg", - url="https://www.swps.pl/", - size=xl, - klass="only-light", - ), - dict( - name="SWPS Uniwersytet Humanistycznospołeczny", - img="SWPS-dark.svg", - url="https://www.swps.pl/", - size=xl, - klass="only-dark", - ), - dict( - name="Max-Planck-Institut für Bildungsforschung", - img="MPIB.svg", - url="https://www.mpib-berlin.mpg.de/", - size=xxl, - klass="only-light", - ), - dict( - name="Max-Planck-Institut für Bildungsforschung", - img="MPIB-dark.svg", - url="https://www.mpib-berlin.mpg.de/", - size=xxl, - klass="only-dark", - ), - dict( - name="Macquarie University", - img="Macquarie.svg", - url="https://www.mq.edu.au/", - size=lg, - klass="only-light", - ), - dict( - name="Macquarie University", - img="Macquarie-dark.svg", - url="https://www.mq.edu.au/", - size=lg, - klass="only-dark", - ), - dict( - name="AE Studio", - img="AE-Studio-light.svg", - url="https://ae.studio/", - size=xxl, - klass="only-light", - ), - dict( - name="AE Studio", - img="AE-Studio-dark.svg", - url="https://ae.studio/", - size=xxl, - klass="only-dark", - ), - dict( - name="Children’s Hospital of Philadelphia Research Institute", - img="CHOP.svg", - url="https://www.research.chop.edu/imaging", - size=xxl, - klass="only-light", - ), - dict( - name="Children’s Hospital of Philadelphia Research Institute", - img="CHOP-dark.svg", - url="https://www.research.chop.edu/imaging", - size=xxl, - klass="only-dark", - ), - dict( - name="Donders Institute for Brain, Cognition and Behaviour at Radboud University", # noqa E501 - img="Donders.png", - url="https://www.ru.nl/donders/", - size=xl, - ), - dict( - name="Fondation Campus Biotech Geneva", - img="FCBG.svg", - url="https://fcbg.ch/", - size=sm, - ), - ], + "current_sponsors_partners": current, + "current_sponsors": current_sponsors, + "former_sponsors": former_sponsors, + "all_sponsors": [*current_sponsors, *former_sponsors], + "current_institutions": current_institutions, + "former_institutions": former_institutions, + "all_institutions": [*current_institutions, *former_institutions], # \u00AD is an optional hyphen (not rendered unless needed) # If these are changed, the Makefile should be updated, too "carousel": [ @@ -1332,11 +1334,10 @@ def fix_sklearn_inherited_docstrings(app, what, name, obj, options, lines): # -- Dependency info ---------------------------------------------------------- -min_py = metadata("mne")["Requires-Python"].lstrip(" =<>") +min_py = "3.10" +min_py_minor = "10" rst_prolog += f"\n.. |min_python_version| replace:: {min_py}\n" -# -- website redirects -------------------------------------------------------- - # Static list created 2021/04/13 based on what we needed to redirect, # since we don't need to add redirects for examples added after this date. needed_plot_redirects = { @@ -1525,9 +1526,12 @@ def fix_sklearn_inherited_docstrings(app, what, name, obj, options, lines): custom_redirects = { # Custom redirects (one HTML path to another, relative to outdir) # can be added here as fr->to key->value mappings + "credit": "credits/credit", + "funding": "credits/sponsors", "install/contributing": "development/contributing", "overview/cite": "documentation/cite", "overview/get_help": "help/index", + "overview/people": "credits/leaders", "overview/roadmap": "development/roadmap", "whats_new": "development/whats_new", f"{tu}/evoked/plot_eeg_erp": f"{tu}/evoked/30_eeg_erp", @@ -1682,7 +1686,9 @@ def make_custom_redirects(app, exception): else: to_path = Path(app.outdir) / to assert to_path.is_file(), to_path - # recreate folders that no longer exist + # recreate overview folder (only for redirects now) + os.makedirs(Path(app.outdir) / "overview", exist_ok=True) + # recreate gallery folders that no longer exist defunct_gallery_folders = ( "misc", "discussions", @@ -1720,6 +1726,17 @@ def make_version(app, exception): sphinx_logger.info(f'Added "{stdout.rstrip()}" > _version.txt') +def rstjinja(app, docname, source): + """Use Jinja to process the sponsors page.""" + # Make sure we're outputting HTML + if app.builder.format != "html": + return + if docname == "credits/sponsors": + src = source[0] + rendered = app.builder.templates.render_string(src, app.config.html_context) + source[0] = rendered + + # -- Connect our handlers to the main Sphinx app --------------------------- @@ -1734,3 +1751,4 @@ def setup(app): app.connect("build-finished", make_api_redirects) app.connect("build-finished", make_custom_redirects) app.connect("build-finished", make_version) + app.connect("source-read", rstjinja) diff --git a/doc/jupyterlite_contents/.gitkeep b/doc/jupyterlite_contents/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pyproject.toml b/pyproject.toml index ae2120938ee..257f639b52c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,12 +3,14 @@ build-backend = "hatchling.build" requires = ["hatch-vcs", "hatchling >= 1.27"] [dependency-groups] -dev = ["pip >= 25.1", "rcssmin >= 1.1", {include-group = "doc"}, {include-group = "test_extra"}] +dev = ["pip >= 25.1", "pyside6 >= 6.11.1", "rcssmin >= 1.1", {include-group = "doc"}, {include-group = "test_extra"}] # Dependencies for building the documentation doc = [ "graphviz", "intersphinx_registry >= 0.2405.27", "ipython != 8.7.0", # also in "full-no-qt" and "test" + "jupyterlite-pyodide-kernel", + "jupyterlite-sphinx", "memory_profiler >= 0.16", "mne-bids", "mne-connectivity", @@ -17,10 +19,9 @@ doc = [ "numpydoc >= 0.5", "openneuro-py >= 2020.1", "psutil", - "pydata_sphinx_theme >= 0.15.2", + "pydata-sphinx-theme >= 0.15.2", "pygments >= 2.13", "pymef", - "pytest", "pyvistaqt >= 0.11", # released 2023-06-30, no newer version available "pyxdf", "pyzmq != 24.0.0", @@ -49,7 +50,7 @@ test = [ "numpydoc >= 1.6", "pillow >= 10.2", "pre-commit", - "pytest >= 8.0", + "pytest >= 8.0,!=9.1.0", # https://github.com/pytest-dev/pytest/issues/14591 "pytest-cov >= 4.1", "pytest-qt >= 4.3", "pytest-rerunfailures", @@ -57,27 +58,31 @@ test = [ "ruff >= 0.1", "twine", "vulture", - "wheel >= 0.21", +] +# Move non-free-threaded dependencies into the "test_extra" superset +# Exclusions determined 2026/06/16 +test_extra = [ + "jupyter_client", # requires tornado, which has no ft version + "nbclient", # requires jupyter_client + "nitime >= 0.7", + "pymef", + "statsmodels", + {include-group = "test_extra_ft"}, ] # Dependencies for being able to run additional tests (rare/CIs/advanced devs) # Changes here should be reflected in the mne/utils/config.py dev dependencies section -test_extra = [ +test_extra_ft = [ "edfio >= 0.4.10", "eeglabio", "hedtools", "imageio >= 2.6.1", "imageio-ffmpeg >= 0.4.1", - "jupyter_client", "mne-bids", - "nbclient", "nbformat", "neo", - "nitime >= 0.7", "pybv", - "pymef", "snirf", "sphinx-gallery", - "statsmodels", {include-group = "test"}, ] @@ -134,12 +139,11 @@ scripts = {mne = "mne.commands.utils:main"} [project.optional-dependencies] # Leave this one here for backward-compat data = [] -full = ["mne[full-no-qt]", "PyQt6 != 6.6.0", "PyQt6-Qt6 != 6.6.0, != 6.7.0"] +full = ["mne[full-no-qt]", "PySide6 != 6.7.0, != 6.8.0, != 6.8.0.1, != 6.9.1"] # Dependencies for full MNE-Python functionality (other than raw/epochs export) # We first define a variant without any Qt bindings. The "complete" variant, mne[full], -# makes an opinionated choice and installs PyQt6. -# We also offter two more variants: mne[full-qt6] (which is equivalent to mne[full]), -# and mne[full-pyside6], which will install PySide6 instead of PyQt6. +# makes an opinionated choice and installs PySide6. +# We also offer mne[full-pyqt6], which will install PyQt6 instead of PySide6. full-no-qt = [ "antio >= 0.5.0", "curryreader >= 0.1.2", @@ -158,7 +162,7 @@ full-no-qt = [ "ipywidgets", "joblib >= 0.8", "jupyter", - "mffpy >= 0.5.7", + "mffpy >= 0.11.0", "mne-qt-browser", "mne[hdf5]", "neo", @@ -191,8 +195,8 @@ full-no-qt = [ "vtk >= 9.2", "xlrd", ] -full-pyqt6 = ["mne[full]"] -full-pyside6 = ["mne[full-no-qt]", "PySide6 != 6.7.0, != 6.8.0, != 6.8.0.1, != 6.9.1"] +full-pyqt6 = ["mne[full-no-qt]", "PyQt6 != 6.6.0", "PyQt6-Qt6 != 6.6.0, != 6.7.0"] +full-pyside6 = ["mne[full]"] # Dependencies for MNE-Python functions that use HDF5 I/O hdf5 = ["h5io >= 0.2.4", "pymatreader"] From e37acb339c93da2b75704a6bdc0afd5f81e9f9f6 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Fri, 26 Jun 2026 09:34:36 -0400 Subject: [PATCH 02/46] FIX: Patch MNE internals for Pyodide/emscripten compatibility - mne/parallel.py: return False early in _running_in_joblib_context() on emscripten; joblib parallel backends are unavailable in the browser - mne/utils/config.py: catch Exception (not just ValueError) when loading the MNE config JSON; Pyodide's json parser raises SyntaxError on a corrupt or absent config file --- mne/parallel.py | 4 ++ mne/utils/config.py | 109 ++++++++++++++++++++++++++++---------------- 2 files changed, 73 insertions(+), 40 deletions(-) diff --git a/mne/parallel.py b/mne/parallel.py index 22443dab762..df90c6d1c30 100644 --- a/mne/parallel.py +++ b/mne/parallel.py @@ -156,6 +156,10 @@ def parallel_progress(op_iter): def _running_in_joblib_context(): """Check if we are running in a joblib.parallel_config context manager.""" + import sys + + if sys.platform == "emscripten": + return False try: from joblib.parallel import get_active_backend except ImportError: diff --git a/mne/utils/config.py b/mne/utils/config.py index ff7ec4f6285..8bd70e4a1d8 100644 --- a/mne/utils/config.py +++ b/mne/utils/config.py @@ -6,17 +6,19 @@ import atexit import contextlib +import importlib.metadata +import importlib.util import json import multiprocessing import os import os.path as op import platform import shutil +import site import subprocess import sys import tempfile from functools import lru_cache, partial -from importlib import import_module from pathlib import Path from urllib.error import URLError from urllib.request import urlopen @@ -272,8 +274,8 @@ def _load_config(config_path, raise_error=False): with _open_lock(config_path, "r+") as fid: try: config = json.load(fid) - except ValueError: - # No JSON object could be decoded --> corrupt file? + except Exception: + # Catch ANY exception (including SyntaxError from Pyodide json parser) msg = ( f"The MNE-Python config file ({config_path}) is not a valid JSON " "file and might be corrupted" @@ -757,6 +759,8 @@ def sys_info( .. versionadded:: 1.6 """ + import matplotlib + _validate_type(dependencies, str) _check_option("dependencies", dependencies, ("user", "developer")) _validate_type(check_version, (bool, "numeric"), "check_version") @@ -764,7 +768,10 @@ def sys_info( _check_option("unicode", unicode, ("auto", True, False)) if unicode == "auto": if platform.system() in ("Darwin", "Linux"): - unicode = True + try: + unicode = sys.stdout.encoding.lower().startswith("utf") + except Exception: # in case someone overrides sys.stdout in an unsafe way + unicode = False else: # Windows unicode = False ljust = 24 if dependencies == "developer" else 21 @@ -788,6 +795,13 @@ def sys_info( else: total_memory = f"{total_memory / 1024**3:.1f}" # convert to GiB out(f"{total_memory} GiB\n") + site_packages_path = (site.getsitepackages() or [None])[0] + if show_paths and site_packages_path is not None: + out("Site-packages".ljust(ljust) + f"{site_packages_path}\n") + site_packages_path = Path(site_packages_path) + out("".ljust(ljust)) + out("└►" if unicode else "^-") + out(" Any paths not listed below are in site-packages") out("\n") ljust -= 3 # account for +/- symbols libs = _get_numpy_libs() @@ -800,12 +814,13 @@ def sys_info( "matplotlib", "", "# Numerical (optional)", - "sklearn", + "scikit-learn", "numba", "nibabel", "nilearn", "dipy", "openmeeg", + "python-picard", "cupy", "pandas", "h5io", @@ -820,7 +835,7 @@ def sys_info( "pyqtgraph", "mne-qt-browser", "ipywidgets", - # "trame", # no version, see https://github.com/Kitware/trame/issues/183 + "trame", "trame_client", "trame_server", "trame_pyvista", @@ -834,6 +849,7 @@ def sys_info( "mne-connectivity", "mne-icalabel", "mne-bids-pipeline", + "autoreject", "neo", "eeglabio", "edfio", @@ -849,6 +865,18 @@ def sys_info( use_mod_names += ( "# Testing", "pytest", + "pytest-cov", + "pytest-qt", + "pytest-rerunfailures", + "pytest-timeout", + "codespell", + "ipython", + "mypy", + "pillow", + "pre-commit", + "ruff", + "vulture", + "", "hedtools", "statsmodels", "numpydoc", @@ -859,6 +887,7 @@ def sys_info( "imageio", "imageio-ffmpeg", "snirf", + "twine", "", "# Documentation", "sphinx", @@ -874,12 +903,24 @@ def sys_info( "tqdm", "", ) - try: - unicode = unicode and (sys.stdout.encoding.lower().startswith("utf")) - except Exception: # in case someone overrides sys.stdout in an unsafe way - unicode = False - mne_version_good = True - import_names = dict(hedtools="hed") + if check_version: + timeout = 2.0 if check_version is True else float(check_version) + mne_version_good, mne_extra = _check_mne_version(timeout) + if mne_version_good is None: + mne_version_good = True + del timeout + else: + mne_version_good = True + mne_extra = "" + del check_version + import_names = { + "codespell": "codespell_lib", + "hedtools": "hed", + "ipython": "IPython", + "pillow": "PIL", + "pytest-qt": "pytestqt", + "scikit-learn": "sklearn", + } for mi, mod_name in enumerate(use_mod_names): # upcoming break if mod_name == "": # break @@ -897,45 +938,32 @@ def sys_info( continue pre = "├" last = use_mod_names[mi + 1] == "" and not unavailable + import_name = import_names.get(mod_name, mod_name).replace("-", "_") if last: pre = "└" try: - import_name = import_names.get(mod_name, mod_name.replace("-", "_")) - mod = import_module(import_name) + ver = importlib.metadata.version(mod_name) + mod_loc = Path(importlib.util.find_spec(import_name).origin) except Exception: unavailable.append(mod_name) else: + if mod_loc.stem == "__init__": + mod_loc = mod_loc.parent + if site_packages_path and mod_loc.is_relative_to(site_packages_path): + mod_loc = None mark = "☑" if unicode else "+" - mne_extra = "" - if mod_name == "mne" and check_version: - timeout = 2.0 if check_version is True else float(check_version) - mne_version_good, mne_extra = _check_mne_version(timeout) - if mne_version_good is None: - mne_version_good = True - elif not mne_version_good: - mark = "☒" if unicode else "X" + if mod_name == "mne" and not mne_version_good: + mark = "☒" if unicode else "X" out(f"{pre}{mark} " if unicode else f" {mark} ") out(f"{mod_name}".ljust(ljust)) - if mod_name == "vtk": - vtk_version = mod.vtkVersion() - # 9.0 dev has VersionFull but 9.0 doesn't - for attr in ("GetVTKVersionFull", "GetVTKVersion"): - if hasattr(vtk_version, attr): - version = getattr(vtk_version, attr)() - if version != "": - out(version) - break - else: - out("unknown") - else: - out(mod.__version__.lstrip("v")) + out(ver) if mod_name == "numpy": out(f" ({libs})") elif mod_name == "qtpy": version, api = _check_qt_version(return_api=True) out(f" ({api}={version})") elif mod_name == "matplotlib": - out(f" (backend={mod.get_backend()})") + out(f" (backend={matplotlib.get_backend()})") elif mod_name == "pyvista": version, renderer = _get_gpu_info() if version is None: @@ -943,16 +971,17 @@ def sys_info( else: out(f" (OpenGL {version} via {renderer})") elif mod_name == "mne": - out(f" ({mne_extra})") + if mne_extra: + out(f" ({mne_extra})") # Now comes stuff after the version - if show_paths: + if show_paths and mod_loc is not None: if last: pre = " " elif unicode: pre = "│ " else: pre = " | " - out(f"\n{pre}{' ' * ljust}{op.dirname(mod.__file__)}") + out(f"\n{pre}{' ' * ljust}{mod_loc}") out("\n") if not mne_version_good: @@ -986,7 +1015,7 @@ def _check_mne_version(timeout): if not rel_ver[0].isnumeric(): return None, (f"unable to check for latest version on GitHub, {rel_ver}") rel_ver = parse(rel_ver) - this_ver = parse(import_module("mne").__version__) + this_ver = parse(importlib.metadata.version("mne")) if this_ver > rel_ver: return True, f"development, latest release is {rel_ver}" if this_ver == rel_ver: From 9e6e28835f425af141b593118fe48d7e34faa51a Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Fri, 26 Jun 2026 09:34:50 -0400 Subject: [PATCH 03/46] FIX: Guard interactive Qt plots and unavailable datasets for the browser Tutorials and examples that call interactive Qt backends (raw.plot(), epochs.plot(), ica.plot_sources(), etc.) or depend on large datasets not bundled in JupyterLite will hang or error in Pyodide. Wrap them with sys.platform guards so they are skipped when running in the browser. Interactive Qt plots (skip on emscripten): - tutorials/intro/10_overview.py: raw.plot(), stc.plot() - tutorials/intro/15_inplace.py: original_raw.plot(), rereferenced_raw.plot() - tutorials/intro/20_events_from_raw.py: raw.copy().pick().plot(), raw.plot() - tutorials/intro/40_sensor_locations.py: mne.viz.plot_alignment() - tutorials/evoked/40_whitened.py: raw.plot(), epochs.plot() - examples/preprocessing/muscle_ica.py: all ica.plot_* calls Large datasets unavailable in the browser (raise RuntimeError on emscripten): - tutorials/io/60_ctf_bst_auditory.py: BST auditory dataset (~2.9 GB) - tutorials/io/70_reading_eyetracking_data.py: EyeLink misc dataset - examples/visualization/eyetracking_plot_heatmap.py: EyeLink dataset --- examples/preprocessing/muscle_ica.py | 22 +++++++++++++------ .../visualization/eyetracking_plot_heatmap.py | 15 +++++++++++-- tutorials/evoked/40_whitened.py | 12 ++++++---- tutorials/intro/10_overview.py | 18 +++++++++------ tutorials/intro/15_inplace.py | 10 ++++++--- tutorials/intro/20_events_from_raw.py | 8 +++++-- tutorials/intro/40_sensor_locations.py | 20 +++++++++-------- tutorials/io/60_ctf_bst_auditory.py | 9 ++++++++ tutorials/io/70_reading_eyetracking_data.py | 9 ++++++++ 9 files changed, 89 insertions(+), 34 deletions(-) diff --git a/examples/preprocessing/muscle_ica.py b/examples/preprocessing/muscle_ica.py index 8ef1e451985..7b2cc05acd3 100644 --- a/examples/preprocessing/muscle_ica.py +++ b/examples/preprocessing/muscle_ica.py @@ -19,6 +19,8 @@ # %% +import sys + import mne data_path = mne.datasets.sample.data_path() @@ -44,7 +46,8 @@ # %% # Remove components with postural muscle artifact using ICA -ica.plot_sources(raw) +if sys.platform != "emscripten": + ica.plot_sources(raw) # %% # By inspection, let's select out the muscle-artifact components based on @@ -71,19 +74,22 @@ # slope in log-log units; this is a very typical pattern for muscle artifact. muscle_idx = [6, 7, 8, 9, 10, 11, 12, 13, 14] -ica.plot_properties(raw, picks=muscle_idx, log_scale=True) +if sys.platform != "emscripten": + ica.plot_properties(raw, picks=muscle_idx, log_scale=True) # first, remove blinks and heartbeat to compare blink_idx = [0] heartbeat_idx = [5] ica.apply(raw, exclude=blink_idx + heartbeat_idx) -ica.plot_overlay(raw, exclude=muscle_idx) +if sys.platform != "emscripten": + ica.plot_overlay(raw, exclude=muscle_idx) # %% # Finally, let's try an automated algorithm to find muscle components # and ensure that it gets the same components we did manually. muscle_idx_auto, scores = ica.find_bads_muscle(raw) -ica.plot_scores(scores, exclude=muscle_idx_auto) +if sys.platform != "emscripten": + ica.plot_scores(scores, exclude=muscle_idx_auto) print( f"Manually found muscle artifact ICA components: {muscle_idx}\n" f"Automatically found muscle artifact ICA components: {muscle_idx_auto}" @@ -107,10 +113,12 @@ n_components=15, method="picard", max_iter="auto", random_state=97 ) ica.fit(raw) - ica.plot_sources(raw) + if sys.platform != "emscripten": + ica.plot_sources(raw) muscle_idx_auto, scores = ica.find_bads_muscle(raw) - ica.plot_properties(raw, picks=muscle_idx_auto, log_scale=True) - ica.plot_scores(scores, exclude=muscle_idx_auto) + if sys.platform != "emscripten": + ica.plot_properties(raw, picks=muscle_idx_auto, log_scale=True) + ica.plot_scores(scores, exclude=muscle_idx_auto) print( f"Manually found muscle artifact ICA components: {muscle_idx}\n" diff --git a/examples/visualization/eyetracking_plot_heatmap.py b/examples/visualization/eyetracking_plot_heatmap.py index 07983685b5e..e0104aa4af2 100644 --- a/examples/visualization/eyetracking_plot_heatmap.py +++ b/examples/visualization/eyetracking_plot_heatmap.py @@ -26,11 +26,20 @@ # :ref:`example data `: eye-tracking data recorded from SR research's # ``'.asc'`` file format. +import sys + import matplotlib.pyplot as plt import mne from mne.viz.eyetracking import plot_gaze +if sys.platform == "emscripten": + raise RuntimeError( + "This example requires the MNE EyeLink dataset, " + "which is not available in the browser. Please run this example " + "locally. Visit https://mne.tools for instructions." + ) + task_fpath = mne.datasets.eyelink.data_path() / "freeviewing" et_fpath = task_fpath / "sub-01_task-freeview_eyetrack.asc" stim_fpath = task_fpath / "stim" / "naturalistic.png" @@ -82,8 +91,10 @@ # start at a value greater than the darkest value in our previous heatmap, which will # make the darkest colors of the heatmap transparent. -cmap.set_under("k", alpha=0) # make the lowest values transparent -ax = plt.subplot() +cmap = cmap.with_extremes( + under=(0.0, 0.0, 0.0, 0.0) +) # make the lowest values transparent +_, ax = plt.subplots(figsize=(6, 3.5), layout="constrained") ax.imshow(plt.imread(stim_fpath)) plot_gaze( epochs["natural"], diff --git a/tutorials/evoked/40_whitened.py b/tutorials/evoked/40_whitened.py index a3110139b4e..06abc4102e1 100644 --- a/tutorials/evoked/40_whitened.py +++ b/tutorials/evoked/40_whitened.py @@ -21,6 +21,8 @@ # %% +import sys + import mne from mne.datasets import sample @@ -52,14 +54,16 @@ ) # butterfly mode shows the differences most clearly -raw.plot(events=events, butterfly=True) -raw.plot(noise_cov=noise_cov, events=events, butterfly=True) +if sys.platform != "emscripten": + raw.plot(events=events, butterfly=True) + raw.plot(noise_cov=noise_cov, events=events, butterfly=True) # %% # Epochs with whitening # --------------------- -epochs.plot(events=True) -epochs.plot(noise_cov=noise_cov, events=True) +if sys.platform != "emscripten": + epochs.plot(events=True) + epochs.plot(noise_cov=noise_cov, events=True) # %% # Evoked data with whitening diff --git a/tutorials/intro/10_overview.py b/tutorials/intro/10_overview.py index f61745b0024..7a9bccfb868 100644 --- a/tutorials/intro/10_overview.py +++ b/tutorials/intro/10_overview.py @@ -18,7 +18,7 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. -# %% +import sys import numpy as np @@ -81,8 +81,10 @@ # sessions, `~mne.io.Raw.plot` is interactive and allows scrolling, scaling, # bad channel marking, annotations, projector toggling, etc. + raw.compute_psd(fmax=50).plot(picks="data", exclude="bads", amplitude=False) -raw.plot(duration=5, n_channels=30) +if sys.platform != "emscripten": + raw.plot(duration=5, n_channels=30) # %% # Preprocessing @@ -140,8 +142,9 @@ "EEG 008", ] chan_idxs = [raw.ch_names.index(ch) for ch in chs] -orig_raw.plot(order=chan_idxs, start=12, duration=4) -raw.plot(order=chan_idxs, start=12, duration=4) +if sys.platform != "emscripten": + orig_raw.plot(order=chan_idxs, start=12, duration=4) + raw.plot(order=chan_idxs, start=12, duration=4) # %% # .. _overview-tut-events-section: @@ -400,9 +403,10 @@ # path to subjects' MRI files subjects_dir = sample_data_folder / "subjects" # plot the STC -stc.plot( - initial_time=0.1, hemi="split", views=["lat", "med"], subjects_dir=subjects_dir -) +if sys.platform != "emscripten": + stc.plot( + initial_time=0.1, hemi="split", views=["lat", "med"], subjects_dir=subjects_dir + ) ############################################################################## # The remaining tutorials have *much more detail* on each of these topics (as diff --git a/tutorials/intro/15_inplace.py b/tutorials/intro/15_inplace.py index e9cbd4769f1..a66c162c878 100644 --- a/tutorials/intro/15_inplace.py +++ b/tutorials/intro/15_inplace.py @@ -22,6 +22,8 @@ # %% +import sys + import mne sample_data_folder = mne.datasets.sample.data_path() @@ -83,9 +85,11 @@ # we specified ``copy=True``: # sphinx_gallery_thumbnail_number=2 -rereferenced_raw, ref_data = mne.set_eeg_reference(original_raw, ["EEG 003"], copy=True) -fig_orig = original_raw.plot() -fig_reref = rereferenced_raw.plot() +rereferenced_raw, ref_data = mne.set_eeg_reference(original_raw, "EEG 003", copy=True) + +if sys.platform != "emscripten": + fig_orig = original_raw.plot() + fig_reref = rereferenced_raw.plot() # %% # Another example is the picking function `mne.pick_info`, which operates on diff --git a/tutorials/intro/20_events_from_raw.py b/tutorials/intro/20_events_from_raw.py index 2c368646908..b2b3c8732d5 100644 --- a/tutorials/intro/20_events_from_raw.py +++ b/tutorials/intro/20_events_from_raw.py @@ -32,6 +32,8 @@ # %% +import sys + import numpy as np import mne @@ -94,7 +96,8 @@ # on newer systems it is more commonly ``STI101``. You can see the STIM # channels in the raw data file here: -raw.copy().pick(picks="stim").plot(start=3, duration=6) +if sys.platform != "emscripten": + raw.copy().pick(picks="stim").plot(start=3, duration=6) # %% # You can see that ``STI 014`` (the summation channel) contains pulses of @@ -265,7 +268,8 @@ # Now, the annotations will appear automatically when plotting the raw data, # and will be color-coded by their label value: -raw.plot(start=5, duration=5) +if sys.platform != "emscripten": + raw.plot(start=5, duration=5) # %% # .. _`chunk-duration`: diff --git a/tutorials/intro/40_sensor_locations.py b/tutorials/intro/40_sensor_locations.py index 6046e252f47..673d66599b8 100644 --- a/tutorials/intro/40_sensor_locations.py +++ b/tutorials/intro/40_sensor_locations.py @@ -15,6 +15,7 @@ # %% +import sys from pathlib import Path import matplotlib.pyplot as plt @@ -218,15 +219,16 @@ # It is also possible to render an image of an MEG sensor helmet using 3D surface # rendering instead of matplotlib. This works by calling :func:`mne.viz.plot_alignment`: -fig = mne.viz.plot_alignment( - sample_raw.info, - dig=False, - eeg=False, - surfaces=[], - meg=["helmet", "sensors"], - coord_frame="meg", -) -mne.viz.set_3d_view(fig, azimuth=50, elevation=90, distance=0.5) +if sys.platform != "emscripten": + fig = mne.viz.plot_alignment( + sample_raw.info, + dig=False, + eeg=False, + surfaces=[], + meg=["helmet", "sensors"], + coord_frame="meg", + ) + mne.viz.set_3d_view(fig, azimuth=50, elevation=90, distance=0.5) # %% # Note that :func:`~mne.viz.plot_alignment` requires an `~mne.Info` object, and can also diff --git a/tutorials/io/60_ctf_bst_auditory.py b/tutorials/io/60_ctf_bst_auditory.py index 4c3249996aa..945c0648232 100644 --- a/tutorials/io/60_ctf_bst_auditory.py +++ b/tutorials/io/60_ctf_bst_auditory.py @@ -26,6 +26,8 @@ # %% +import sys + import numpy as np import pandas as pd @@ -35,6 +37,13 @@ from mne.io import read_raw_ctf from mne.minimum_norm import apply_inverse +if sys.platform == "emscripten": + raise RuntimeError( + "This tutorial requires the Brainstorm auditory dataset (~2.9 GB) " + "which is not available in the browser. Please run this tutorial " + "locally. Visit https://mne.tools for instructions." + ) + # %% # To reduce memory consumption and running time, some of the steps are # precomputed. To run everything from scratch change ``use_precomputed`` to diff --git a/tutorials/io/70_reading_eyetracking_data.py b/tutorials/io/70_reading_eyetracking_data.py index 15c58bd940c..b256273ccec 100644 --- a/tutorials/io/70_reading_eyetracking_data.py +++ b/tutorials/io/70_reading_eyetracking_data.py @@ -85,8 +85,17 @@ """ # %% +import sys + import mne +if sys.platform == "emscripten": + raise RuntimeError( + "This tutorial requires the MNE misc dataset with eyetracking data, " + "which is not available in the browser. Please run this tutorial " + "locally. Visit https://mne.tools for instructions." + ) + # %% fpath = mne.datasets.misc.data_path() / "eyetracking" / "eyelink" fname = fpath / "px_textpage_ws.asc" From d2d22b0b12057dd91b754efbde25943002ca1f9f Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Fri, 26 Jun 2026 16:06:34 -0400 Subject: [PATCH 04/46] FIX: Guard browser-incompatible code in all intro tutorials for JupyterLite - doc/conf.py: Fix lzma mock to use real stdlib lzma when available in Pyodide instead of LZMAFile=object which broke joblib's compressor registration - 10_overview.py: Guard ica.plot_properties() which opens an interactive Qt window - 15_inplace.py: Guard set_eeg_reference block which fails under Python 3.13 in Pyodide - 20_events_from_raw.py: Guard STIM channel plot and all EEGLAB sections that require the unavailable testing dataset - 40_sensor_locations.py: Guard ssvep dataset loading and sphere plot that require the unavailable ssvep dataset - 50_configure_mne.py: Guard KIT test data loading whose test files are stripped from the Pyodide wheel - 70_report.py: Skip Report.save() file-writing in browser, guard nibabel-dependent add_bem, 3D methods (add_trans/add_stc/add_forward/ add_inverse_operator), missing ECG/events files, pandas-dependent make_metadata, and the HDF5 round-trip section All intro tutorials (10, 15, 20, 30, 40, 50, 70) now run cleanly in JupyterLite/Pyodide without errors. Co-Authored-By: Claude Sonnet 4.6 --- doc/conf.py | 30 ++-- tutorials/intro/10_overview.py | 3 +- tutorials/intro/15_inplace.py | 3 +- tutorials/intro/20_events_from_raw.py | 38 ++--- tutorials/intro/40_sensor_locations.py | 26 ++-- tutorials/intro/50_configure_mne.py | 28 ++-- tutorials/intro/70_report.py | 192 ++++++++++++++----------- 7 files changed, 181 insertions(+), 139 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 1dde8a9fc9f..83c5f503c07 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -604,17 +604,25 @@ "import os\n" "import io\n" "\n" - "# Mock lzma — missing in Pyodide but imported by pooch/joblib\n" - "import types\n" - "class MockLZMA:\n" - " LZMAError = Exception\n" - " LZMAFile = object\n" - " FORMAT_XZ = 1\n" - " FORMAT_ALONE = 2\n" - " def __getattr__(self, name):\n" - " return object\n" - "if 'lzma' not in sys.modules:\n" - " sys.modules['lzma'] = MockLZMA()\n" + "# lzma: try the real stdlib module 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" diff --git a/tutorials/intro/10_overview.py b/tutorials/intro/10_overview.py index 7a9bccfb868..2c5fa04b362 100644 --- a/tutorials/intro/10_overview.py +++ b/tutorials/intro/10_overview.py @@ -103,7 +103,8 @@ ica = mne.preprocessing.ICA(n_components=20, random_state=97, max_iter=800) ica.fit(raw) ica.exclude = [1, 2] # details on how we picked these are omitted here -ica.plot_properties(raw, picks=ica.exclude) +if sys.platform != "emscripten": + ica.plot_properties(raw, picks=ica.exclude) # %% # Once we're confident about which component(s) we want to remove, we pass them diff --git a/tutorials/intro/15_inplace.py b/tutorials/intro/15_inplace.py index a66c162c878..ad35a5cfeb3 100644 --- a/tutorials/intro/15_inplace.py +++ b/tutorials/intro/15_inplace.py @@ -85,9 +85,8 @@ # we specified ``copy=True``: # sphinx_gallery_thumbnail_number=2 -rereferenced_raw, ref_data = mne.set_eeg_reference(original_raw, "EEG 003", copy=True) - if sys.platform != "emscripten": + rereferenced_raw, ref_data = mne.set_eeg_reference(original_raw, "EEG 003", copy=True) fig_orig = original_raw.plot() fig_reref = rereferenced_raw.plot() diff --git a/tutorials/intro/20_events_from_raw.py b/tutorials/intro/20_events_from_raw.py index b2b3c8732d5..6d57ddf0898 100644 --- a/tutorials/intro/20_events_from_raw.py +++ b/tutorials/intro/20_events_from_raw.py @@ -166,10 +166,11 @@ # stored events into an `~mne.Annotations` object and store it as the # :attr:`~mne.io.Raw.annotations` attribute of the `~mne.io.Raw` object: -testing_data_folder = mne.datasets.testing.data_path() -eeglab_raw_file = testing_data_folder / "EEGLAB" / "test_raw.set" -eeglab_raw = mne.io.read_raw_eeglab(eeglab_raw_file) -print(eeglab_raw.annotations) +if sys.platform != "emscripten": + testing_data_folder = mne.datasets.testing.data_path() + eeglab_raw_file = testing_data_folder / "EEGLAB" / "test_raw.set" + eeglab_raw = mne.io.read_raw_eeglab(eeglab_raw_file) + print(eeglab_raw.annotations) # %% # The core data within an `~mne.Annotations` object is accessible @@ -179,10 +180,11 @@ # different types of events, and the first event occurred about 1 second after # the recording began: -print(len(eeglab_raw.annotations)) -print(set(eeglab_raw.annotations.duration)) -print(set(eeglab_raw.annotations.description)) -print(eeglab_raw.annotations.onset[0]) +if sys.platform != "emscripten": + print(len(eeglab_raw.annotations)) + print(set(eeglab_raw.annotations.duration)) + print(set(eeglab_raw.annotations.description)) + print(eeglab_raw.annotations.onset[0]) # %% # More information on working with `~mne.Annotations` objects, including @@ -213,9 +215,10 @@ # :ref:`fixed-length-events` for direct creation of an Events array of # equally-spaced events). -events_from_annot, event_dict = mne.events_from_annotations(eeglab_raw) -print(event_dict) -print(events_from_annot[:5]) +if sys.platform != "emscripten": + events_from_annot, event_dict = mne.events_from_annotations(eeglab_raw) + print(event_dict) + print(events_from_annot[:5]) # %% # If you want to control which integers are mapped to each unique description @@ -227,12 +230,13 @@ # `~mne.io.Raw` objects, as demonstrated in the tutorial # :ref:`tut-epochs-class`. -custom_mapping = {"rt": 77, "square": 42} -(events_from_annot, event_dict) = mne.events_from_annotations( - eeglab_raw, event_id=custom_mapping -) -print(event_dict) -print(events_from_annot[:5]) +if sys.platform != "emscripten": + custom_mapping = {"rt": 77, "square": 42} + (events_from_annot, event_dict) = mne.events_from_annotations( + eeglab_raw, event_id=custom_mapping + ) + print(event_dict) + print(events_from_annot[:5]) # %% # To make the opposite conversion (from an Events array to an diff --git a/tutorials/intro/40_sensor_locations.py b/tutorials/intro/40_sensor_locations.py index 673d66599b8..216276bc1c6 100644 --- a/tutorials/intro/40_sensor_locations.py +++ b/tutorials/intro/40_sensor_locations.py @@ -92,19 +92,20 @@ # It is also possible to skip the manual montage loading step by passing the montage # name directly to the :meth:`~mne.io.Raw.set_montage` method. -ssvep_folder = mne.datasets.ssvep.data_path() -ssvep_data_raw_path = ( - ssvep_folder / "sub-02" / "ses-01" / "eeg" / "sub-02_ses-01_task-ssvep_eeg.vhdr" -) -ssvep_raw = mne.io.read_raw_brainvision(ssvep_data_raw_path, verbose=False) +if sys.platform != "emscripten": + ssvep_folder = mne.datasets.ssvep.data_path() + ssvep_data_raw_path = ( + ssvep_folder / "sub-02" / "ses-01" / "eeg" / "sub-02_ses-01_task-ssvep_eeg.vhdr" + ) + ssvep_raw = mne.io.read_raw_brainvision(ssvep_data_raw_path, verbose=False) -# Use the preloaded montage -ssvep_raw.set_montage(easycap_montage) -fig = ssvep_raw.plot_sensors(show_names=True) + # Use the preloaded montage + ssvep_raw.set_montage(easycap_montage) + fig = ssvep_raw.plot_sensors(show_names=True) -# Apply a template montage directly, without preloading -ssvep_raw.set_montage("easycap-M1") -fig = ssvep_raw.plot_sensors(show_names=True) + # Apply a template montage directly, without preloading + ssvep_raw.set_montage("easycap-M1") + fig = ssvep_raw.plot_sensors(show_names=True) # %% # .. note:: @@ -134,7 +135,8 @@ # If you prefer to draw the head circle using 10–20 conventions (which are also used by # EEGLAB), you can pass ``sphere='eeglab'``: -fig = ssvep_raw.plot_sensors(show_names=True, sphere="eeglab") +if sys.platform != "emscripten": + fig = ssvep_raw.plot_sensors(show_names=True, sphere="eeglab") # %% # Because the data we're using here doesn't contain an Fpz channel, its putative diff --git a/tutorials/intro/50_configure_mne.py b/tutorials/intro/50_configure_mne.py index 9e6896eaf98..ba5643fb638 100644 --- a/tutorials/intro/50_configure_mne.py +++ b/tutorials/intro/50_configure_mne.py @@ -18,6 +18,7 @@ # %% import os +import sys import mne @@ -196,22 +197,24 @@ # set. First, with log level ``warning``: -kit_data_path = os.path.join( - os.path.abspath(os.path.dirname(mne.__file__)), - "io", - "kit", - "tests", - "data", - "test.sqd", -) -raw = mne.io.read_raw_kit(kit_data_path, verbose="warning") +if sys.platform != "emscripten": + kit_data_path = os.path.join( + os.path.abspath(os.path.dirname(mne.__file__)), + "io", + "kit", + "tests", + "data", + "test.sqd", + ) + raw = mne.io.read_raw_kit(kit_data_path, verbose="warning") # %% # No messages were generated, because none of the messages were of severity # "warning" or worse. Next, we'll load the same file with log level ``info`` # (the default level): -raw = mne.io.read_raw_kit(kit_data_path, verbose="info") +if sys.platform != "emscripten": + raw = mne.io.read_raw_kit(kit_data_path, verbose="info") # %% # This time, we got a few messages about extracting information from the file, @@ -221,8 +224,9 @@ # manager, which is another way to accomplish the same thing as passing # ``verbose='debug'``: -with mne.use_log_level("debug"): - raw = mne.io.read_raw_kit(kit_data_path) +if sys.platform != "emscripten": + with mne.use_log_level("debug"): + raw = mne.io.read_raw_kit(kit_data_path) # %% # We've been passing string values to the ``verbose`` parameter, but we can see diff --git a/tutorials/intro/70_report.py b/tutorials/intro/70_report.py index 76d15fc0251..25f81c1d3ad 100644 --- a/tutorials/intro/70_report.py +++ b/tutorials/intro/70_report.py @@ -25,6 +25,7 @@ # %% +import sys import tempfile from pathlib import Path @@ -38,6 +39,13 @@ sample_dir = data_path / "MEG" / "sample" subjects_dir = data_path / "subjects" +# In JupyterLite, file writing is not supported — skip report.save() calls. +# The report content still renders inline in Jupyter via add_* methods. +if sys.platform == "emscripten": + def _noop_save(self, fname=None, open_browser=False, overwrite=False, verbose=None): + pass + mne.Report.save = _noop_save + # %% # The basic process for creating an HTML report is to instantiate the # :class:`~mne.Report` class and then use one or more of its many methods to @@ -81,12 +89,14 @@ # supply the sampling frequency used during the recording; this information is # used to generate a meaningful time axis. -events_path = sample_dir / "sample_audvis_filt-0-40_raw-eve.fif" events = mne.find_events(raw=raw) sfreq = raw.info["sfreq"] report = mne.Report(title="Events example") -report.add_events(events=events_path, title="Events from Path", sfreq=sfreq) +# sample_audvis_filt-0-40_raw-eve.fif is not bundled in JupyterLite +if sys.platform != "emscripten": + events_path = sample_dir / "sample_audvis_filt-0-40_raw-eve.fif" + report.add_events(events=events_path, title="Events from Path", sfreq=sfreq) report.add_events(events=events, title='Events from "events"', sfreq=sfreq) report.save("report_events.html", overwrite=True) @@ -108,9 +118,13 @@ "buttonpress": 32, } -metadata, _, _ = mne.epochs.make_metadata( - events=events, event_id=event_id, tmin=-0.2, tmax=0.5, sfreq=raw.info["sfreq"] -) +# make_metadata requires pandas; skip metadata in JupyterLite +if sys.platform != "emscripten": + metadata, _, _ = mne.epochs.make_metadata( + events=events, event_id=event_id, tmin=-0.2, tmax=0.5, sfreq=raw.info["sfreq"] + ) +else: + metadata = None epochs = mne.Epochs(raw=raw, events=events, event_id=event_id, metadata=metadata) report = mne.Report(title="Epochs example") @@ -179,29 +193,30 @@ # is read from the `~mne.Info`, but projectors potentially included will be # ignored; instead, only the explicitly passed projectors will be plotted. -ecg_proj_path = sample_dir / "sample_audvis_ecg-proj.fif" report = mne.Report(title="Projectors example") report.add_projs(info=raw_path, title="Projs from info") -# Now a joint plot -events = mne.read_events(sample_dir / "sample_audvis_ecg-eve.fif") -raw_full = mne.io.read_raw(sample_dir / "sample_audvis_raw.fif").crop(0, 60).load_data() -ecg_evoked = mne.Epochs( - raw=raw_full, - events=events, - tmin=-0.5, - tmax=0.5, - baseline=(None, None), -).average() -report.img_max_width = None # do not constrain image width -report.add_projs( - info=ecg_evoked, - projs=ecg_proj_path, - title="ECG projs from path", - joint=True, # use joint version of the plot -) +# The ECG projectors and events files are not bundled in JupyterLite +if sys.platform != "emscripten": + ecg_proj_path = sample_dir / "sample_audvis_ecg-proj.fif" + events = mne.read_events(sample_dir / "sample_audvis_ecg-eve.fif") + raw_full = mne.io.read_raw(sample_dir / "sample_audvis_raw.fif").crop(0, 60).load_data() + ecg_evoked = mne.Epochs( + raw=raw_full, + events=events, + tmin=-0.5, + tmax=0.5, + baseline=(None, None), + ).average() + report.img_max_width = None # do not constrain image width + report.add_projs( + info=ecg_evoked, + projs=ecg_proj_path, + title="ECG projs from path", + joint=True, # use joint version of the plot + ) + del raw_full, events, ecg_evoked report.save("report_projs.html", overwrite=True) -del raw_full, events, ecg_evoked # %% # Adding `~mne.preprocessing.ICA` @@ -282,15 +297,16 @@ # every n-th volume slice, and ``width`` to specify the width of the resulting # figures in pixels. -report = mne.Report(title="BEM example") -report.add_bem( - subject="sample", - subjects_dir=subjects_dir, - title="MRI & BEM", - decim=40, - width=256, -) -report.save("report_mri_and_bem.html", overwrite=True) +if sys.platform != "emscripten": + report = mne.Report(title="BEM example") + report.add_bem( + subject="sample", + subjects_dir=subjects_dir, + title="MRI & BEM", + decim=40, + width=256, + ) + report.save("report_mri_and_bem.html", overwrite=True) # %% # Adding coregistration @@ -303,18 +319,19 @@ # subjects directory, and a title. The ``alpha`` parameter can be used to # control the transparency of the head, where a value of 1 means fully opaque. -trans_path = sample_dir / "sample_audvis_raw-trans.fif" - -report = mne.Report(title="Coregistration example") -report.add_trans( - trans=trans_path, - info=raw_path, - subject="sample", - subjects_dir=subjects_dir, - alpha=1.0, - title="Coregistration", -) -report.save("report_coregistration.html", overwrite=True) +if sys.platform != "emscripten": + trans_path = sample_dir / "sample_audvis_raw-trans.fif" + + report = mne.Report(title="Coregistration example") + report.add_trans( + trans=trans_path, + info=raw_path, + subject="sample", + subjects_dir=subjects_dir, + alpha=1.0, + title="Coregistration", + ) + report.save("report_coregistration.html", overwrite=True) # %% # Adding a `~mne.Forward` solution @@ -324,13 +341,14 @@ # object or the path to a forward solution stored on disk to # :meth:`mne.Report.add_forward`. -fwd_path = sample_dir / "sample_audvis-meg-oct-6-fwd.fif" +if sys.platform != "emscripten": + fwd_path = sample_dir / "sample_audvis-meg-oct-6-fwd.fif" -report = mne.Report(title="Forward solution example") -report.add_forward( - forward=fwd_path, title="Forward solution", plot=True, subjects_dir=subjects_dir -) -report.save("report_forward_sol.html", overwrite=True) + report = mne.Report(title="Forward solution example") + report.add_forward( + forward=fwd_path, title="Forward solution", plot=True, subjects_dir=subjects_dir + ) + report.save("report_forward_sol.html", overwrite=True) # %% # Adding an `~mne.minimum_norm.InverseOperator` @@ -340,16 +358,17 @@ # The method expects an `~mne.minimum_norm.InverseOperator` object or a path to # one stored on disk, and a title. -inverse_op_path = sample_dir / "sample_audvis-meg-oct-6-meg-inv.fif" +if sys.platform != "emscripten": + inverse_op_path = sample_dir / "sample_audvis-meg-oct-6-meg-inv.fif" -report = mne.Report(title="Inverse operator example") -report.add_inverse_operator( - inverse_operator=inverse_op_path, - title="Inverse operator", - plot=True, - subjects_dir=subjects_dir, -) -report.save("report_inverse_op.html", overwrite=True) + report = mne.Report(title="Inverse operator example") + report.add_inverse_operator( + inverse_operator=inverse_op_path, + title="Inverse operator", + plot=True, + subjects_dir=subjects_dir, + ) + report.save("report_inverse_op.html", overwrite=True) # %% # Adding a `~mne.SourceEstimate` @@ -362,17 +381,18 @@ # snapshots at 51 equally-spaced time points (or fewer, if the data contains # fewer time points). We can adjust this via the ``n_time_points`` parameter. -stc_path = sample_dir / "sample_audvis-meg" +if sys.platform != "emscripten": + stc_path = sample_dir / "sample_audvis-meg" -report = mne.Report(title="Source estimate example") -report.add_stc( - stc=stc_path, - subject="sample", - subjects_dir=subjects_dir, - title="Source estimate", - n_time_points=2, # few for speed -) -report.save("report_inverse_sol.html", overwrite=True) + report = mne.Report(title="Source estimate example") + report.add_stc( + stc=stc_path, + subject="sample", + subjects_dir=subjects_dir, + title="Source estimate", + n_time_points=2, # few for speed + ) + report.save("report_inverse_sol.html", overwrite=True) # %% # Adding source code (e.g., a Python script) @@ -532,26 +552,29 @@ # to edit a report once it's no longer in-memory in an active Python session, # save it as an HDF5 file instead of HTML: -report = mne.Report(title="Saved report example", verbose=True) -report.add_image(image=mne_logo_path, title="MNE 1") -report.save("report_partial.hdf5", overwrite=True) +if sys.platform != "emscripten": + report = mne.Report(title="Saved report example", verbose=True) + report.add_image(image=mne_logo_path, title="MNE 1") + report.save("report_partial.hdf5", overwrite=True) # %% # The saved report can be read back and modified or amended. This allows the # possibility to e.g. run multiple scripts in a processing pipeline, where each # script adds new content to an existing report. -report_from_disk = mne.open_report("report_partial.hdf5") -report_from_disk.add_image(image=mne_logo_path, title="MNE 2") -report_from_disk.save("report_partial.hdf5", overwrite=True) +if sys.platform != "emscripten": + report_from_disk = mne.open_report("report_partial.hdf5") + report_from_disk.add_image(image=mne_logo_path, title="MNE 2") + report_from_disk.save("report_partial.hdf5", overwrite=True) # %% # To make this even easier, :class:`mne.Report` can be used as a # context manager (note the ``with`` statement)`): -with mne.open_report("report_partial.hdf5") as report: - report.add_image(image=mne_logo_path, title="MNE 3") - report.save("report_final.html", overwrite=True) +if sys.platform != "emscripten": + with mne.open_report("report_partial.hdf5") as report: + report.add_image(image=mne_logo_path, title="MNE 3") + report.save("report_final.html", overwrite=True) # %% # With the context manager, the updated report is also automatically saved @@ -637,11 +660,12 @@ # expensive, we'll also pass the ``mri_decim`` parameter for the benefit of our # documentation servers, and skip processing the :file:`.fif` files. -report = mne.Report( - title="parse_folder example 3", subject="sample", subjects_dir=subjects_dir -) -report.parse_folder(data_path=data_path, pattern="", mri_decim=40) -report.save("report_parse_folder_mri_bem.html", overwrite=True) +if sys.platform != "emscripten": + report = mne.Report( + title="parse_folder example 3", subject="sample", subjects_dir=subjects_dir + ) + report.parse_folder(data_path=data_path, pattern="", mri_decim=40) + report.save("report_parse_folder_mri_bem.html", overwrite=True) # %% # Now let's look at how :class:`~mne.Report` handles :class:`~mne.Evoked` From 724f80bb92c46a18ae4af34fd63d6b5fabea7500 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:07:20 +0000 Subject: [PATCH 05/46] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tutorials/intro/15_inplace.py | 4 +++- tutorials/intro/70_report.py | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tutorials/intro/15_inplace.py b/tutorials/intro/15_inplace.py index ad35a5cfeb3..c95d0214899 100644 --- a/tutorials/intro/15_inplace.py +++ b/tutorials/intro/15_inplace.py @@ -86,7 +86,9 @@ # sphinx_gallery_thumbnail_number=2 if sys.platform != "emscripten": - rereferenced_raw, ref_data = mne.set_eeg_reference(original_raw, "EEG 003", copy=True) + rereferenced_raw, ref_data = mne.set_eeg_reference( + original_raw, "EEG 003", copy=True + ) fig_orig = original_raw.plot() fig_reref = rereferenced_raw.plot() diff --git a/tutorials/intro/70_report.py b/tutorials/intro/70_report.py index 25f81c1d3ad..76bab0293e5 100644 --- a/tutorials/intro/70_report.py +++ b/tutorials/intro/70_report.py @@ -42,8 +42,10 @@ # In JupyterLite, file writing is not supported — skip report.save() calls. # The report content still renders inline in Jupyter via add_* methods. if sys.platform == "emscripten": + def _noop_save(self, fname=None, open_browser=False, overwrite=False, verbose=None): pass + mne.Report.save = _noop_save # %% @@ -200,7 +202,9 @@ def _noop_save(self, fname=None, open_browser=False, overwrite=False, verbose=No if sys.platform != "emscripten": ecg_proj_path = sample_dir / "sample_audvis_ecg-proj.fif" events = mne.read_events(sample_dir / "sample_audvis_ecg-eve.fif") - raw_full = mne.io.read_raw(sample_dir / "sample_audvis_raw.fif").crop(0, 60).load_data() + raw_full = ( + mne.io.read_raw(sample_dir / "sample_audvis_raw.fif").crop(0, 60).load_data() + ) ecg_evoked = mne.Epochs( raw=raw_full, events=events, From 9566839ba4b5325eef43df3a1baf69fe9608387b Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Fri, 26 Jun 2026 16:19:03 -0400 Subject: [PATCH 06/46] STY: shorten lzma comment in conf.py to fix E501 --- doc/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/conf.py b/doc/conf.py index 83c5f503c07..65e7573ca04 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -604,7 +604,7 @@ "import os\n" "import io\n" "\n" - "# lzma: try the real stdlib module first (Pyodide ships it); only mock if absent\n" + "# lzma: try real stdlib first (Pyodide ships it); only mock if absent\n" "try:\n" " import lzma\n" "except ImportError:\n" From 9f3272e9d865e978023027c2e71b5905cf7e72d7 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Fri, 26 Jun 2026 16:58:41 -0400 Subject: [PATCH 07/46] FIX: pre-create MNE config file to suppress setup warnings in JupyterLite --- doc/conf.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 65e7573ca04..6347ae90a89 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -693,16 +693,15 @@ "\n" "# Import MNE and finalize setup.\n" "import mne\n" - "try:\n" - " mne.get_config()\n" - "except Exception:\n" - " try:\n" - " os.remove(mne.get_config_path())\n" - " print('Corrupted MNE config deleted automatically.')\n" - " except Exception:\n" - " pass\n" + "# Pre-create a valid empty config file so MNE never hits a corrupt read.\n" + "_cfg = mne.get_config_path()\n" + "os.makedirs(os.path.dirname(_cfg), exist_ok=True)\n" + "if not os.path.exists(_cfg):\n" + " with open(_cfg, 'w') as _f:\n" + " _f.write('{}')\n" + "mne.set_config('MNE_DATA', mne_data_path)\n" "for ds in ['SAMPLE', 'TESTING', 'SSVEP', 'EEGBCI', 'SOMATO',\n" - " 'AUDIOVISUAL', 'BRAINSTORM']:\n" + " 'BRAINSTORM']:\n" " mne.set_config(f'MNE_DATASETS_{ds}_PATH', mne_data_path)\n" "\n" "# Switch matplotlib to inline so figures render in the notebook.\n" From 825740e86f25f7221039f056a1086f57d5eee626 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sat, 27 Jun 2026 17:30:20 -0400 Subject: [PATCH 08/46] download sample data in conf.py if missing so CI artifacts work --- doc/conf.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/doc/conf.py b/doc/conf.py index 6347ae90a89..42d15a74d57 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -492,6 +492,12 @@ / "MNE-sample-data" ) dst_sample_data.mkdir(parents=True, exist_ok=True) +if not src_sample_data.exists(): + try: + import mne as _mne + _mne.datasets.sample.data_path(verbose=False) + except Exception: + pass if src_sample_data.exists(): required_files = [ "version.txt", From 60fcc7f18baca3d373300b7c05dff1ebd2eb0b39 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:30:44 +0000 Subject: [PATCH 09/46] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- doc/conf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/conf.py b/doc/conf.py index 42d15a74d57..9f14d2aed31 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -495,6 +495,7 @@ if not src_sample_data.exists(): try: import mne as _mne + _mne.datasets.sample.data_path(verbose=False) except Exception: pass From 4d0586d4ff36b621d3dfdc6c99a3d0db06c95863 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sat, 27 Jun 2026 17:50:58 -0400 Subject: [PATCH 10/46] Revert "[pre-commit.ci] auto fixes from pre-commit.com hooks" This reverts commit 60fcc7f18baca3d373300b7c05dff1ebd2eb0b39. --- doc/conf.py | 1 - 1 file changed, 1 deletion(-) diff --git a/doc/conf.py b/doc/conf.py index 9f14d2aed31..42d15a74d57 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -495,7 +495,6 @@ if not src_sample_data.exists(): try: import mne as _mne - _mne.datasets.sample.data_path(verbose=False) except Exception: pass From f7a9aa9d271392e6c509b018b1b6547c5aab8b05 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sat, 27 Jun 2026 17:50:58 -0400 Subject: [PATCH 11/46] Revert "download sample data in conf.py if missing so CI artifacts work" This reverts commit 825740e86f25f7221039f056a1086f57d5eee626. --- doc/conf.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 42d15a74d57..6347ae90a89 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -492,12 +492,6 @@ / "MNE-sample-data" ) dst_sample_data.mkdir(parents=True, exist_ok=True) -if not src_sample_data.exists(): - try: - import mne as _mne - _mne.datasets.sample.data_path(verbose=False) - except Exception: - pass if src_sample_data.exists(): required_files = [ "version.txt", From 41d26c4c5b8b1e5e8702bf2734e1a76fcc3f84ec Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sat, 27 Jun 2026 18:27:00 -0400 Subject: [PATCH 12/46] DBG: log JupyterLite data copy steps in conf.py to diagnose CI artifact issue --- doc/conf.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/doc/conf.py b/doc/conf.py index 6347ae90a89..fcae12f7b2e 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -492,6 +492,8 @@ / "MNE-sample-data" ) dst_sample_data.mkdir(parents=True, exist_ok=True) +print(f"[JupyterLite] Sample data source exists: {src_sample_data.exists()}") +print(f"[JupyterLite] Source path: {src_sample_data}") if src_sample_data.exists(): required_files = [ "version.txt", @@ -514,6 +516,9 @@ if s.exists(): d.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(s, d) + print(f"[JupyterLite] Copied: {req}") + else: + print(f"[JupyterLite] MISSING: {req}") # Also inject SSVEP and EEGLAB testing datasets for JupyterLite @@ -527,13 +532,17 @@ src_ssvep = mne_data_base / "ssvep-example-data" dst_ssvep = lite_data_base / "ssvep-example-data" +print(f"[JupyterLite] SSVEP data source exists: {src_ssvep.exists()}") if src_ssvep.exists() and not dst_ssvep.exists(): shutil.copytree(src_ssvep, dst_ssvep, dirs_exist_ok=True) + print("[JupyterLite] Copied ssvep-example-data") src_eeglab = mne_data_base / "MNE-testing-data" / "EEGLAB" dst_eeglab = lite_data_base / "MNE-testing-data" / "EEGLAB" +print(f"[JupyterLite] EEGLAB data source exists: {src_eeglab.exists()}") if src_eeglab.exists() and not dst_eeglab.exists(): shutil.copytree(src_eeglab, dst_eeglab, dirs_exist_ok=True) + print("[JupyterLite] Copied MNE-testing-data/EEGLAB") # Build the local MNE wheel so JupyterLite can use the current development version From 43dca987214f83676c6b1a6b109facb8f4f93529 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sat, 27 Jun 2026 20:39:26 -0400 Subject: [PATCH 13/46] DBG: print drive/mne_data filesystem state in JupyterLite setup cell --- doc/conf.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/doc/conf.py b/doc/conf.py index fcae12f7b2e..6494d3a3b06 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -667,8 +667,18 @@ "# build (conf.py copies the required sample-data subset there).\n" "# If absent (e.g. standalone JupyterLite), fall back to /tmp/mne_data\n" "# with a clear warning — do NOT attempt to download ~1.5 GB from OSF.\n" + "drive_exists = os.path.exists('/drive/mne_data')\n" + "sample_exists = os.path.isdir('/drive/mne_data/MNE-sample-data')\n" + "print(f'[DBG] /drive/mne_data exists: {drive_exists}')\n" + "print(f'[DBG] /drive/mne_data/MNE-sample-data isdir: {sample_exists}')\n" + "if sample_exists:\n" + " try:\n" + " sample_files = os.listdir('/drive/mne_data/MNE-sample-data/MEG/sample')\n" + " print(f'[DBG] MEG/sample files: {sample_files}')\n" + " except Exception as e:\n" + " print(f'[DBG] listdir failed: {e}')\n" "mne_data_path = (\n" - " '/drive/mne_data' if os.path.exists('/drive/mne_data')\n" + " '/drive/mne_data' if drive_exists\n" " else '/tmp/mne_data'\n" ")\n" "os.makedirs(mne_data_path, exist_ok=True)\n" From 2aebf896931d5c9f01dcb038f37bfcb0585aa31b Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sat, 27 Jun 2026 20:41:57 -0400 Subject: [PATCH 14/46] STY: fix E501 in JupyterLite setup cell diagnostics --- doc/conf.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 6494d3a3b06..e5f03a111d9 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -670,10 +670,11 @@ "drive_exists = os.path.exists('/drive/mne_data')\n" "sample_exists = os.path.isdir('/drive/mne_data/MNE-sample-data')\n" "print(f'[DBG] /drive/mne_data exists: {drive_exists}')\n" - "print(f'[DBG] /drive/mne_data/MNE-sample-data isdir: {sample_exists}')\n" + "print(f'[DBG] MNE-sample-data isdir: {sample_exists}')\n" "if sample_exists:\n" " try:\n" - " sample_files = os.listdir('/drive/mne_data/MNE-sample-data/MEG/sample')\n" + " _meg = '/drive/mne_data/MNE-sample-data/MEG/sample'\n" + " sample_files = os.listdir(_meg)\n" " print(f'[DBG] MEG/sample files: {sample_files}')\n" " except Exception as e:\n" " print(f'[DBG] listdir failed: {e}')\n" From 714de0692057569e0aa5ef3eed25a6f39942b06c Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sat, 27 Jun 2026 21:35:23 -0400 Subject: [PATCH 15/46] FIX: patch mne.datasets.sample.data_path in JupyterLite to bypass pooch archive check --- doc/conf.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/doc/conf.py b/doc/conf.py index e5f03a111d9..b008b1d49d5 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -724,6 +724,16 @@ " 'BRAINSTORM']:\n" " mne.set_config(f'MNE_DATASETS_{ds}_PATH', mne_data_path)\n" "\n" + "# Patch mne.datasets.sample.data_path to return the pre-bundled\n" + "# extracted directory directly. Without this, pooch looks for the\n" + "# original .tar.gz archive and tries to download from OSF when\n" + "# missing — which our guard then blocks.\n" + "_drive_sample = os.path.join(mne_data_path, 'MNE-sample-data')\n" + "if os.path.isdir(_drive_sample):\n" + " def _lite_sample_data_path(*_a, **_kw):\n" + " return _drive_sample\n" + " mne.datasets.sample.data_path = _lite_sample_data_path\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" From 06ad00c2bb748f9bec714277648a95034bda65ce Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sat, 27 Jun 2026 22:20:44 -0400 Subject: [PATCH 16/46] FIX: fetch MNE sample data via HTTP in JupyterLite setup cell --- doc/conf.py | 79 ++++++++++++++++++++++++++++++----------------------- 1 file changed, 45 insertions(+), 34 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index b008b1d49d5..1a35619cf97 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -663,33 +663,46 @@ " return response\n" "requests.Session.send = pyodide_send\n" "\n" - "# Set the data directory: /drive/mne_data is pre-populated by the doc\n" - "# build (conf.py copies the required sample-data subset there).\n" - "# If absent (e.g. standalone JupyterLite), fall back to /tmp/mne_data\n" - "# with a clear warning — do NOT attempt to download ~1.5 GB from OSF.\n" - "drive_exists = os.path.exists('/drive/mne_data')\n" - "sample_exists = os.path.isdir('/drive/mne_data/MNE-sample-data')\n" - "print(f'[DBG] /drive/mne_data exists: {drive_exists}')\n" - "print(f'[DBG] MNE-sample-data isdir: {sample_exists}')\n" - "if sample_exists:\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 data files directly from the JupyterLite\n" + "# files/ path into /tmp/mne_data instead — same-origin, no CORS.\n" + "import pyodide.http as _phttp\n" + "import js as _js\n" + "_page = str(_js.window.location.href)\n" + "_base = _page.rsplit('/lab/', 1)[0] + '/files/mne_data/'\n" + "mne_data_path = '/tmp/mne_data'\n" + "_sample_dir = mne_data_path + '/MNE-sample-data'\n" + "_sample_files = [\n" + " 'version.txt',\n" + " 'MEG/sample/sample_audvis_raw-eve.fif',\n" + " 'MEG/sample/sample_audvis-cov.fif',\n" + " 'MEG/sample/sample_audvis-ave.fif',\n" + " 'MEG/sample/sample_audvis_filt-0-40_raw.fif',\n" + " 'MEG/sample/sample_audvis-meg-eeg-oct-6-fwd.fif',\n" + " 'MEG/sample/sample_audvis-meg-oct-6-meg-inv.fif',\n" + " 'subjects/sample/mri/T1.mgz',\n" + " 'subjects/sample/bem/sample-oct-6-src.fif',\n" + " 'subjects/sample/bem/sample-5120-5120-5120-bem-sol.fif',\n" + " 'subjects/sample/surf/rh.pial',\n" + " 'subjects/sample/surf/lh.pial',\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" - " _meg = '/drive/mne_data/MNE-sample-data/MEG/sample'\n" - " sample_files = os.listdir(_meg)\n" - " print(f'[DBG] MEG/sample files: {sample_files}')\n" - " except Exception as e:\n" - " print(f'[DBG] listdir failed: {e}')\n" - "mne_data_path = (\n" - " '/drive/mne_data' if drive_exists\n" - " else '/tmp/mne_data'\n" - ")\n" + " _r = await _phttp.pyfetch(_url)\n" + " _d = await _r.bytes()\n" + " os.makedirs(os.path.dirname(_dst), exist_ok=True)\n" + " open(_dst, 'wb').write(_d)\n" + " _kb = len(_d) // 1024\n" + " print(f' {_f.split(\"/\")[-1]} ({_kb} KB)')\n" + " except Exception as _e:\n" + " print(f' FAILED {_f}: {_e}')\n" "os.makedirs(mne_data_path, exist_ok=True)\n" - "if mne_data_path == '/tmp/mne_data':\n" - " print(\n" - " '⚠️ MNE sample data not found at /drive/mne_data. '\n" - " 'Cells that load datasets will raise FileNotFoundError. '\n" - " 'Open this notebook from the live MNE docs (mne.tools) '\n" - " 'where sample data is pre-bundled.'\n" - " )\n" "os.environ['MNE_DATA'] = mne_data_path\n" "os.environ['MNE_DATASETS_SAMPLE_PATH'] = mne_data_path\n" "\n" @@ -724,15 +737,13 @@ " 'BRAINSTORM']:\n" " mne.set_config(f'MNE_DATASETS_{ds}_PATH', mne_data_path)\n" "\n" - "# Patch mne.datasets.sample.data_path to return the pre-bundled\n" - "# extracted directory directly. Without this, pooch looks for the\n" - "# original .tar.gz archive and tries to download from OSF when\n" - "# missing — which our guard then blocks.\n" - "_drive_sample = os.path.join(mne_data_path, 'MNE-sample-data')\n" - "if os.path.isdir(_drive_sample):\n" - " def _lite_sample_data_path(*_a, **_kw):\n" - " return _drive_sample\n" - " mne.datasets.sample.data_path = _lite_sample_data_path\n" + "# Bypass pooch's archive check: data_path() normally looks for the\n" + "# .tar.gz archive, not just the extracted folder. Return the folder\n" + "# directly so pooch never tries to download from OSF.\n" + "_sample_path = _sample_dir\n" + "def _lite_sample_data_path(*_a, **_kw):\n" + " return _sample_path\n" + "mne.datasets.sample.data_path = _lite_sample_data_path\n" "\n" "# Switch matplotlib to inline so figures render in the notebook.\n" "import IPython\n" From 149b2673559fe75303ea920d0f965cf489f1c7cc Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sat, 27 Jun 2026 23:08:57 -0400 Subject: [PATCH 17/46] FIX: use js.location instead of js.window for worker-based Pyodide --- doc/conf.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 1a35619cf97..4e96d242a9c 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -667,10 +667,18 @@ "# (COOP/COEP) which many static servers (e.g. CircleCI artifacts)\n" "# do not send. Fetch data files directly from the JupyterLite\n" "# files/ path into /tmp/mne_data instead — same-origin, no CORS.\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 app\n" + "# base URL by splitting on '/lite/'.\n" "import pyodide.http as _phttp\n" "import js as _js\n" - "_page = str(_js.window.location.href)\n" - "_base = _page.rsplit('/lab/', 1)[0] + '/files/mne_data/'\n" + "try:\n" + " _page = str(_js.location.href)\n" + "except Exception:\n" + " _page = str(_js.window.location.href)\n" + "_base = _page.split('/lite/')[0] + '/lite/files/mne_data/'\n" + "print(f'[DBG] page url: {_page}')\n" + "print(f'[DBG] data base: {_base}')\n" "mne_data_path = '/tmp/mne_data'\n" "_sample_dir = mne_data_path + '/MNE-sample-data'\n" "_sample_files = [\n" From ccf2523c8873123cf2aa35fce1ff0a45585b5a7f Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sun, 28 Jun 2026 00:02:51 -0400 Subject: [PATCH 18/46] FIX: return Path from patched data_path so tutorials can use / operator --- doc/conf.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 4e96d242a9c..3561cb72708 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -747,8 +747,10 @@ "\n" "# Bypass pooch's archive check: data_path() normally looks for the\n" "# .tar.gz archive, not just the extracted folder. Return the folder\n" - "# directly so pooch never tries to download from OSF.\n" - "_sample_path = _sample_dir\n" + "# directly so pooch never tries to download from OSF. Return a Path\n" + "# (not a str) since tutorials use the / operator on the result.\n" + "from pathlib import Path as _Path\n" + "_sample_path = _Path(_sample_dir)\n" "def _lite_sample_data_path(*_a, **_kw):\n" " return _sample_path\n" "mne.datasets.sample.data_path = _lite_sample_data_path\n" From c04d8f3c82bd4663a65bae2dcb64632c3fdc1483 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sun, 28 Jun 2026 07:01:32 -0400 Subject: [PATCH 19/46] DBG: check HTTP status and detect HTML responses when fetching data --- doc/conf.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 3561cb72708..7393edad307 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -703,11 +703,18 @@ " _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' Date: Sun, 28 Jun 2026 07:45:18 -0400 Subject: [PATCH 20/46] FIX: serve JupyterLite sample data via html_extra_path so CI artifacts include it --- doc/conf.py | 43 ++++++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 7393edad307..76e8b9af65b 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -143,7 +143,13 @@ # This pattern also affects html_static_path and html_extra_path. # NB: changes here should also be made to the linkcheck target in the Makefile -exclude_patterns = ["_includes", "changes/dev", "jupyterlite_contents", "corrupt_*"] +exclude_patterns = [ + "_includes", + "changes/dev", + "jupyterlite_contents", + "lite_extra", + "corrupt_*", +] # The suffix of source filenames. source_suffix = ".rst" @@ -481,16 +487,19 @@ jupyterlite_contents = ["jupyterlite_contents"] jupyterlite_bind_ipynb_suffix = False -# Automatically inject the required subset of MNE-sample-data into JupyterLite. -# The destination directory is always created so /drive/mne_data is present in -# the Pyodide kernel's virtual filesystem even when no files have been copied. +# Inject the required subset of MNE-sample-data for JupyterLite. The data is +# placed under doc/lite_extra/mne_data and served at the docs root via +# html_extra_path (added below). The JupyterLite setup cell fetches these +# files over HTTP into the Pyodide kernel — the /drive virtual-filesystem +# bridge needs cross-origin-isolation (COOP/COEP) headers that static +# artifact servers (e.g. CircleCI) do not send, so it is unusable there. src_sample_data = Path(os.path.expanduser("~/mne_data/MNE-sample-data")) -dst_sample_data = ( +lite_extra_base = ( Path(os.path.abspath(os.path.dirname(__file__))) - / "jupyterlite_contents" + / "lite_extra" / "mne_data" - / "MNE-sample-data" ) +dst_sample_data = lite_extra_base / "MNE-sample-data" dst_sample_data.mkdir(parents=True, exist_ok=True) print(f"[JupyterLite] Sample data source exists: {src_sample_data.exists()}") print(f"[JupyterLite] Source path: {src_sample_data}") @@ -523,11 +532,7 @@ # Also inject SSVEP and EEGLAB testing datasets for JupyterLite mne_data_base = Path(os.path.expanduser("~/mne_data")) -lite_data_base = ( - Path(os.path.abspath(os.path.dirname(__file__))) - / "jupyterlite_contents" - / "mne_data" -) +lite_data_base = lite_extra_base lite_data_base.mkdir(parents=True, exist_ok=True) src_ssvep = mne_data_base / "ssvep-example-data" @@ -665,18 +670,19 @@ "\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 data files directly from the JupyterLite\n" - "# files/ path into /tmp/mne_data instead — same-origin, no CORS.\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 app\n" - "# base URL by splitting on '/lite/'.\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] + '/lite/files/mne_data/'\n" + "_base = _page.split('/lite/')[0] + '/mne_data/'\n" "print(f'[DBG] page url: {_page}')\n" "print(f'[DBG] data base: {_base}')\n" "mne_data_path = '/tmp/mne_data'\n" @@ -1177,6 +1183,9 @@ def fix_sklearn_inherited_docstrings(app, what, name, obj, options, lines): "documentation.html", "getting_started.html", "install_mne_python.html", + # Serve the pre-bundled JupyterLite sample data at the docs root + # (e.g. /mne_data/...). The lite setup cell fetches it over HTTP. + "lite_extra", ] # Custom sidebar templates, maps document names to template names. From 41e7c148532db22bf56e243882f8d0eda7e4dd5d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:45:37 +0000 Subject: [PATCH 21/46] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- doc/conf.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 76e8b9af65b..194c0fe8ccc 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -495,9 +495,7 @@ # artifact servers (e.g. CircleCI) do not send, so it is unusable there. src_sample_data = Path(os.path.expanduser("~/mne_data/MNE-sample-data")) lite_extra_base = ( - Path(os.path.abspath(os.path.dirname(__file__))) - / "lite_extra" - / "mne_data" + Path(os.path.abspath(os.path.dirname(__file__))) / "lite_extra" / "mne_data" ) dst_sample_data = lite_extra_base / "MNE-sample-data" dst_sample_data.mkdir(parents=True, exist_ok=True) From 8dd75075d4b9c0a25af6ec27f6fadac2ad9f9924 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sun, 28 Jun 2026 07:53:07 -0400 Subject: [PATCH 22/46] re-trigger CI From 64cab447aaf75bc0211c5c9d68cf0af09e0190ea Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sun, 28 Jun 2026 10:44:04 -0400 Subject: [PATCH 23/46] FIX: silence FigureCanvasAgg warning and clean up JupyterLite setup cell output --- doc/conf.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 194c0fe8ccc..df865ec4b42 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -681,8 +681,6 @@ "except Exception:\n" " _page = str(_js.window.location.href)\n" "_base = _page.split('/lite/')[0] + '/mne_data/'\n" - "print(f'[DBG] page url: {_page}')\n" - "print(f'[DBG] data base: {_base}')\n" "mne_data_path = '/tmp/mne_data'\n" "_sample_dir = mne_data_path + '/MNE-sample-data'\n" "_sample_files = [\n" @@ -712,15 +710,13 @@ " continue\n" " _d = await _r.bytes()\n" " if _d[:4] == b' Date: Sun, 28 Jun 2026 11:41:31 -0400 Subject: [PATCH 24/46] FIX: no-op Figure.show to silence FigureCanvasAgg warning at its source --- doc/conf.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index df865ec4b42..bb3df211532 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -766,13 +766,19 @@ "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" - "# Patch MNE's plt_show to display via IPython instead of calling\n" - "# fig.show(). On the inline backend get_backend() is not 'agg', so\n" - "# MNE's plt_show calls fig.show(), and the inline FigureCanvasAgg\n" - "# emits 'non-interactive, cannot be shown'. Displaying directly\n" - "# (and closing) renders the figure once with no warning.\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" From 059c1478a5305539ace24565bec51271c708a9fe Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sun, 28 Jun 2026 18:15:55 -0400 Subject: [PATCH 25/46] FIX: fetch sample_audvis_raw.fif in JupyterLite so unfiltered-raw tutorials work --- doc/conf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/conf.py b/doc/conf.py index bb3df211532..c8e289cc9aa 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -685,6 +685,7 @@ "_sample_dir = mne_data_path + '/MNE-sample-data'\n" "_sample_files = [\n" " 'version.txt',\n" + " 'MEG/sample/sample_audvis_raw.fif',\n" " 'MEG/sample/sample_audvis_raw-eve.fif',\n" " 'MEG/sample/sample_audvis-cov.fif',\n" " 'MEG/sample/sample_audvis-ave.fif',\n" From 6c6c93552e737c5f4b2a7977ca4afa0e36272caf Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sun, 28 Jun 2026 19:47:54 -0400 Subject: [PATCH 26/46] TEST: un-guard raw.plot() in 15_inplace to check if it renders in Pyodide --- tutorials/intro/15_inplace.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/tutorials/intro/15_inplace.py b/tutorials/intro/15_inplace.py index c95d0214899..49cdd033b38 100644 --- a/tutorials/intro/15_inplace.py +++ b/tutorials/intro/15_inplace.py @@ -22,8 +22,6 @@ # %% -import sys - import mne sample_data_folder = mne.datasets.sample.data_path() @@ -85,12 +83,9 @@ # we specified ``copy=True``: # sphinx_gallery_thumbnail_number=2 -if sys.platform != "emscripten": - rereferenced_raw, ref_data = mne.set_eeg_reference( - original_raw, "EEG 003", copy=True - ) - fig_orig = original_raw.plot() - fig_reref = rereferenced_raw.plot() +rereferenced_raw, ref_data = mne.set_eeg_reference(original_raw, "EEG 003", copy=True) +fig_orig = original_raw.plot() +fig_reref = rereferenced_raw.plot() # %% # Another example is the picking function `mne.pick_info`, which operates on From f4486173e9bce177ba4b3448c9666d410c5c7f34 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sun, 28 Jun 2026 20:53:21 -0400 Subject: [PATCH 27/46] FIX: install dev MNE wheel via piplite, render reports inline, un-guard raw.plot --- doc/conf.py | 12 ++++++---- tutorials/intro/20_events_from_raw.py | 6 ++--- tutorials/intro/70_report.py | 34 +++++++++++++++++++++------ 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index c8e289cc9aa..bc2b6aa3aae 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -562,7 +562,7 @@ orig_pyproject = f.read() # Relax constraints for Pyodide which often lags behind PyPI. -# The wheel built here is served to the browser kernel; micropip's +# The wheel built here is served to the browser kernel; piplite's # keep_going=True means these bounds won't block install, but we also # relax them here so the wheel metadata is accurate for inspection. patched = re.sub(r'"scipy\s*>=\s*1\.1[0-9]"', '"scipy >= 1.7"', orig_pyproject) @@ -606,11 +606,13 @@ "first_notebook_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 micropip\n" - "# keep_going=True lets micropip install even if Pyodide's bundled\n" + "import piplite\n" + "# Use piplite (not micropip) so the locally-built development MNE wheel\n" + "# served via piplite-wheels is preferred over the older PyPI 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" - "# MNE's runtime code only checks matplotlib >= 3.7/3.8, so 3.8.4 works.\n" - "await micropip.install(['mne', 'scikit-learn', 'joblib'], keep_going=True)\n" + "await piplite.install(['mne', 'scikit-learn', 'joblib'], keep_going=True)\n" "\n" "import sys\n" "import os\n" diff --git a/tutorials/intro/20_events_from_raw.py b/tutorials/intro/20_events_from_raw.py index 6d57ddf0898..80bad559ac9 100644 --- a/tutorials/intro/20_events_from_raw.py +++ b/tutorials/intro/20_events_from_raw.py @@ -96,8 +96,7 @@ # on newer systems it is more commonly ``STI101``. You can see the STIM # channels in the raw data file here: -if sys.platform != "emscripten": - raw.copy().pick(picks="stim").plot(start=3, duration=6) +raw.copy().pick(picks="stim").plot(start=3, duration=6) # %% # You can see that ``STI 014`` (the summation channel) contains pulses of @@ -272,8 +271,7 @@ # Now, the annotations will appear automatically when plotting the raw data, # and will be color-coded by their label value: -if sys.platform != "emscripten": - raw.plot(start=5, duration=5) +raw.plot(start=5, duration=5) # %% # .. _`chunk-duration`: diff --git a/tutorials/intro/70_report.py b/tutorials/intro/70_report.py index 76bab0293e5..0a19378a381 100644 --- a/tutorials/intro/70_report.py +++ b/tutorials/intro/70_report.py @@ -39,14 +39,34 @@ sample_dir = data_path / "MEG" / "sample" subjects_dir = data_path / "subjects" -# In JupyterLite, file writing is not supported — skip report.save() calls. -# The report content still renders inline in Jupyter via add_* methods. +# In JupyterLite, render each report inline instead of writing to disk and +# opening a browser tab (open_browser/webbrowser don't work in Pyodide). +# Writes to /tmp DO work in Pyodide's in-memory filesystem, so we save there, +# read the HTML back, and embed it in an isolated ' + ) + ) + except Exception as exc: + print(f"(report preview unavailable in JupyterLite: {exc})") + + mne.Report.save = _inline_report_save # %% # The basic process for creating an HTML report is to instantiate the From 5b6033e8406856334b1b4af05abe57cca85c92d9 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 29 Jun 2026 07:15:35 -0400 Subject: [PATCH 28/46] FIX: use mkdtemp instead of deprecated mktemp in report preview --- tutorials/intro/70_report.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tutorials/intro/70_report.py b/tutorials/intro/70_report.py index 0a19378a381..346c5a9f8be 100644 --- a/tutorials/intro/70_report.py +++ b/tutorials/intro/70_report.py @@ -54,9 +54,9 @@ def _inline_report_save(self, fname=None, *args, **kwargs): from IPython.display import HTML, display try: - tmp = tempfile.mktemp(suffix=".html") - _orig_report_save(self, tmp, open_browser=False, overwrite=True) - doc = Path(tmp).read_text(encoding="utf-8") + tmp = Path(tempfile.mkdtemp()) / "report.html" + _orig_report_save(self, str(tmp), open_browser=False, overwrite=True) + doc = tmp.read_text(encoding="utf-8") display( HTML( f'' + f'
' ) ) except Exception as exc: From e33a2c5d87df3ce95256f187e800a95d85b0fa4e Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 29 Jun 2026 14:38:48 -0400 Subject: [PATCH 30/46] FIX: avoid deprecated Pyodide as_object_map() in threadpoolctl during sys_info --- doc/conf.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/doc/conf.py b/doc/conf.py index 1f45457b872..3ac8672bb54 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -811,6 +811,26 @@ " IPython.display.display(_f)\n" " plt.close(_f)\n" "viz_utils.plt_show = pyodide_plt_show\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" + "# still calls the deprecated Pyodide JsProxy.as_object_map(). Pyodide's\n" + "# own message says to use as_py_json() instead; both yield the same\n" + "# library filepaths, so we swap the call at its source. This removes the\n" + "# deprecated API usage entirely, so the warning is never emitted.\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" ), "doc_module": ("mne",), "reference_url": dict(mne=None), From 0e07c6e93853f8021de2e34ddc885f27368309d0 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 29 Jun 2026 16:09:39 -0400 Subject: [PATCH 31/46] FIX: install pandas for to_data_frame tutorials; note threadpoolctl patch can be dropped at 3.7.0 --- doc/conf.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 3ac8672bb54..26ab3513694 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -633,7 +633,9 @@ "# 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(['mne', 'scikit-learn', 'joblib'], keep_going=True)\n" + "await piplite.install(\n" + " ['mne', 'scikit-learn', 'joblib', 'pandas'], keep_going=True\n" + ")\n" "\n" "import sys\n" "import os\n" @@ -813,11 +815,14 @@ "viz_utils.plt_show = pyodide_plt_show\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" - "# still calls the deprecated Pyodide JsProxy.as_object_map(). Pyodide's\n" - "# own message says to use as_py_json() instead; both yield the same\n" - "# library filepaths, so we swap the call at its source. This removes the\n" - "# deprecated API usage entirely, so the warning is never emitted.\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" From 54d8451f325b25c4aee65da4dfb3c69ceca19bb1 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 29 Jun 2026 17:04:06 -0400 Subject: [PATCH 32/46] FIX: bundle ecg-proj and filt eve files for epochs tutorials 20 and 50 --- doc/conf.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/doc/conf.py b/doc/conf.py index 26ab3513694..0b2e1323564 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -508,6 +508,8 @@ "MEG/sample/sample_audvis_raw.fif", "MEG/sample/sample_audvis_filt-0-40_raw.fif", "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-ave.fif", "MEG/sample/sample_audvis-cov.fif", "MEG/sample/sample_audvis-meg-eeg-oct-6-fwd.fif", @@ -712,6 +714,8 @@ " 'version.txt',\n" " 'MEG/sample/sample_audvis_raw.fif',\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_filt-0-40_raw.fif',\n" From 55e140d7d3b55f5195a25a988b61b10fcbc4f713 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 29 Jun 2026 18:25:38 -0400 Subject: [PATCH 33/46] ENH: bundle kiloword and erp_core data so epochs tutorials 30 and 40 run in JupyterLite --- .circleci/config.yml | 16 ++++++------ doc/conf.py | 58 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 536cbae2841..c636b3f55db 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -249,16 +249,18 @@ jobs: cp junit-results.xml doc/_build/test-results/test-doc/junit.xml; cp coverage.xml doc/_build/test-results/test-doc/coverage.xml; fi; - # Ensure the MNE sample dataset is on disk so conf.py can copy the - # required subset into jupyterlite_contents/ for the JupyterLite build. - # circleci_download.sh only fetches sample data when the changed files - # reference it, so a cache miss on a PR that touches only doc/conf.py - # would leave ~/mne_data/MNE-sample-data absent and the notebooks would - # fail at runtime with FileNotFoundError on /drive/mne_data. + # Ensure the datasets the JupyterLite notebooks need are on disk so + # conf.py can copy the required subset for the build. circleci_download.sh + # only fetches them when the changed files reference them, so a cache miss + # on a PR that touches only doc/conf.py would leave them absent and the + # notebooks would fail at runtime with FileNotFoundError. kiloword and + # erp_core back the Epochs metadata tutorials (30 & 40). - run: - name: Ensure MNE sample data for JupyterLite + name: Ensure MNE data for JupyterLite command: | python -c "import mne; mne.datasets.sample.data_path(update_path=True)" + python -c "import mne; mne.datasets.kiloword.data_path(update_path=True)" + python -c "import mne; mne.datasets.erp_core.data_path(update_path=True)" # Build docs - run: name: make html diff --git a/doc/conf.py b/doc/conf.py index 0b2e1323564..9562b195251 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -550,6 +550,28 @@ shutil.copytree(src_eeglab, dst_eeglab, dirs_exist_ok=True) print("[JupyterLite] Copied MNE-testing-data/EEGLAB") +# Inject the kiloword and erp_core datasets needed by the Epochs tutorials +# (30_epochs_metadata and 40_autogenerate_metadata). Each tutorial uses a +# single file; their sizes (28.7 MB, 123.6 MB) are within what we already +# serve (sample_audvis_raw.fif is 128.5 MB). The CI "Ensure ... data" step +# downloads them so the sources exist here. +for _folder, _ds_files in ( + ("MNE-kiloword-data", ["kword_metadata-epo.fif"]), + ("MNE-ERP-CORE-data", ["ERP-CORE_Subject-001_Task-Flankers_eeg.fif"]), +): + _src_ds = mne_data_base / _folder + _dst_ds = lite_data_base / _folder + print(f"[JupyterLite] {_folder} source exists: {_src_ds.exists()}") + for _ds_file in _ds_files: + s = _src_ds / _ds_file + d = _dst_ds / _ds_file + if s.exists(): + d.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(s, d) + print(f"[JupyterLite] Copied: {_folder}/{_ds_file}") + else: + print(f"[JupyterLite] MISSING: {_folder}/{_ds_file}") + # Build the local MNE wheel so JupyterLite installs the current development # version instead of the older release from PyPI. The wheel is written to @@ -636,7 +658,9 @@ "# 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'], keep_going=True\n" + " ['mne', 'scikit-learn', 'joblib', 'pandas', 'seaborn', " + "'mne-connectivity'],\n" + " keep_going=True,\n" ")\n" "\n" "import sys\n" @@ -791,6 +815,38 @@ "def _lite_sample_data_path(*_a, **_kw):\n" " return _sample_path\n" "mne.datasets.sample.data_path = _lite_sample_data_path\n" + "# kiloword + erp_core (Epochs tutorials 30 & 40) are large and used by\n" + "# only those two notebooks, so fetch them LAZILY — only when data_path()\n" + "# is actually called — to avoid taxing every other notebook's setup.\n" + "# Pyodide runs in a web worker here, where a synchronous XHR may set\n" + "# responseType='arraybuffer', letting a sync data_path() read binary.\n" + "def _lite_lazy_fetch(_folder, _fname):\n" + " _dst = mne_data_path + '/' + _folder + '/' + _fname\n" + " if not os.path.exists(_dst):\n" + " from js import XMLHttpRequest\n" + " _xhr = XMLHttpRequest.new()\n" + " _xhr.open('GET', _base + _folder + '/' + _fname, False)\n" + " _xhr.responseType = 'arraybuffer'\n" + " _xhr.send()\n" + " if _xhr.status != 200:\n" + " raise FileNotFoundError(\n" + " f'Could not fetch {_folder}/{_fname} " + "(HTTP {_xhr.status})'\n" + " )\n" + " os.makedirs(os.path.dirname(_dst), exist_ok=True)\n" + " with open(_dst, 'wb') as _fh:\n" + " _fh.write(bytes(_xhr.response.to_py()))\n" + " return _Path(mne_data_path + '/' + _folder)\n" + "def _lite_kiloword_data_path(*_a, **_kw):\n" + " return _lite_lazy_fetch(" + "'MNE-kiloword-data', 'kword_metadata-epo.fif')\n" + "mne.datasets.kiloword.data_path = _lite_kiloword_data_path\n" + "def _lite_erp_core_data_path(*_a, **_kw):\n" + " return _lite_lazy_fetch(\n" + " 'MNE-ERP-CORE-data', " + "'ERP-CORE_Subject-001_Task-Flankers_eeg.fif'\n" + " )\n" + "mne.datasets.erp_core.data_path = _lite_erp_core_data_path\n" "\n" "# Switch matplotlib to inline so figures render in the notebook.\n" "import IPython\n" From 8ed2c814fe13337822f83073193c692c51f75f60 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 29 Jun 2026 18:54:26 -0400 Subject: [PATCH 34/46] FIX: no-op MNE ProgressBar thread so permutation cluster tests run in JupyterLite --- doc/conf.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/doc/conf.py b/doc/conf.py index 9562b195251..deca2121430 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -848,6 +848,19 @@ " )\n" "mne.datasets.erp_core.data_path = _lite_erp_core_data_path\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" + "\n" "# Switch matplotlib to inline so figures render in the notebook.\n" "import IPython\n" "IPython.get_ipython().run_line_magic('matplotlib', 'inline')\n" From 64338b6dda57bfc111aaa11641995ca78de208c6 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 29 Jun 2026 23:50:30 -0400 Subject: [PATCH 35/46] FIX: disable tqdm monitor thread to suppress TqdmMonitorWarning in JupyterLite --- doc/conf.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/doc/conf.py b/doc/conf.py index deca2121430..f335bf2e823 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -860,6 +860,14 @@ " _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" From 034d722825d98696c53d45127fc45e3f1b8222b1 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Tue, 30 Jun 2026 00:05:36 -0400 Subject: [PATCH 36/46] ENH: bundle mtrf and eegbci data so decoding examples run in JupyterLite --- .circleci/config.yml | 5 +++- doc/conf.py | 60 +++++++++++++++++++++++++++++++++----------- 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index c636b3f55db..f6ed1ad4768 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -254,13 +254,16 @@ jobs: # only fetches them when the changed files reference them, so a cache miss # on a PR that touches only doc/conf.py would leave them absent and the # notebooks would fail at runtime with FileNotFoundError. kiloword and - # erp_core back the Epochs metadata tutorials (30 & 40). + # erp_core back the Epochs metadata tutorials (30 & 40); mtrf and eegbci + # back the decoding examples (receptive_field_mtrf, decoding_csp_*). - run: name: Ensure MNE data for JupyterLite command: | python -c "import mne; mne.datasets.sample.data_path(update_path=True)" python -c "import mne; mne.datasets.kiloword.data_path(update_path=True)" python -c "import mne; mne.datasets.erp_core.data_path(update_path=True)" + python -c "import mne; mne.datasets.mtrf.data_path(update_path=True)" + python -c "import mne; mne.datasets.eegbci.load_data(1, [6, 10, 14], update_path=True)" # Build docs - run: name: make html diff --git a/doc/conf.py b/doc/conf.py index f335bf2e823..99d695b9c8e 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -550,14 +550,23 @@ shutil.copytree(src_eeglab, dst_eeglab, dirs_exist_ok=True) print("[JupyterLite] Copied MNE-testing-data/EEGLAB") -# Inject the kiloword and erp_core datasets needed by the Epochs tutorials -# (30_epochs_metadata and 40_autogenerate_metadata). Each tutorial uses a -# single file; their sizes (28.7 MB, 123.6 MB) are within what we already -# serve (sample_audvis_raw.fif is 128.5 MB). The CI "Ensure ... data" step +# Inject the single needed file(s) from extra datasets used by the Epochs and +# decoding examples. Sizes are all within what we already serve +# (sample_audvis_raw.fif is 128.5 MB): kiloword 28.7 MB, erp_core 123.6 MB, +# mtrf speech_data.mat 17.2 MB, eegbci 3x2.6 MB. The CI "Ensure ... data" step # downloads them so the sources exist here. for _folder, _ds_files in ( ("MNE-kiloword-data", ["kword_metadata-epo.fif"]), ("MNE-ERP-CORE-data", ["ERP-CORE_Subject-001_Task-Flankers_eeg.fif"]), + ("mTRF_1.5", ["speech_data.mat"]), + ( + "MNE-eegbci-data", + [ + "files/eegmmidb/1.0.0/S001/S001R06.edf", + "files/eegmmidb/1.0.0/S001/S001R10.edf", + "files/eegmmidb/1.0.0/S001/S001R14.edf", + ], + ), ): _src_ds = mne_data_base / _folder _dst_ds = lite_data_base / _folder @@ -815,27 +824,31 @@ "def _lite_sample_data_path(*_a, **_kw):\n" " return _sample_path\n" "mne.datasets.sample.data_path = _lite_sample_data_path\n" - "# kiloword + erp_core (Epochs tutorials 30 & 40) are large and used by\n" - "# only those two notebooks, so fetch them LAZILY — only when data_path()\n" - "# is actually called — to avoid taxing every other notebook's setup.\n" - "# Pyodide runs in a web worker here, where a synchronous XHR may set\n" - "# responseType='arraybuffer', letting a sync data_path() read binary.\n" - "def _lite_lazy_fetch(_folder, _fname):\n" - " _dst = mne_data_path + '/' + _folder + '/' + _fname\n" + "# Several non-sample datasets are each used by only a couple of\n" + "# notebooks (kiloword/erp_core for Epochs 30 & 40; mtrf/eegbci for the\n" + "# decoding examples), so fetch them LAZILY — only when their\n" + "# data_path()/load_data() is called — to avoid taxing every other\n" + "# notebook's setup. Pyodide runs in a web worker here, where a\n" + "# synchronous XHR may set responseType='arraybuffer', letting a sync\n" + "# data_path() read binary.\n" + "def _lite_fetch_rel(_rel):\n" + " _dst = mne_data_path + '/' + _rel\n" " if not os.path.exists(_dst):\n" " from js import XMLHttpRequest\n" " _xhr = XMLHttpRequest.new()\n" - " _xhr.open('GET', _base + _folder + '/' + _fname, False)\n" + " _xhr.open('GET', _base + _rel, False)\n" " _xhr.responseType = 'arraybuffer'\n" " _xhr.send()\n" " if _xhr.status != 200:\n" " raise FileNotFoundError(\n" - " f'Could not fetch {_folder}/{_fname} " - "(HTTP {_xhr.status})'\n" + " f'Could not fetch {_rel} (HTTP {_xhr.status})'\n" " )\n" " os.makedirs(os.path.dirname(_dst), exist_ok=True)\n" " with open(_dst, 'wb') as _fh:\n" " _fh.write(bytes(_xhr.response.to_py()))\n" + " return _dst\n" + "def _lite_lazy_fetch(_folder, _fname):\n" + " _lite_fetch_rel(_folder + '/' + _fname)\n" " return _Path(mne_data_path + '/' + _folder)\n" "def _lite_kiloword_data_path(*_a, **_kw):\n" " return _lite_lazy_fetch(" @@ -847,6 +860,25 @@ "'ERP-CORE_Subject-001_Task-Flankers_eeg.fif'\n" " )\n" "mne.datasets.erp_core.data_path = _lite_erp_core_data_path\n" + "def _lite_mtrf_data_path(*_a, **_kw):\n" + " return _lite_lazy_fetch('mTRF_1.5', 'speech_data.mat')\n" + "mne.datasets.mtrf.data_path = _lite_mtrf_data_path\n" + "def _lite_eegbci_load_data(subject, runs, *_a, **_kw):\n" + " _runs = [runs] if isinstance(runs, (int, float)) else list(runs)\n" + " _subjects = (\n" + " list(subject) if isinstance(subject, (list, tuple))\n" + " else [subject]\n" + " )\n" + " _out = []\n" + " for _s in _subjects:\n" + " for _r in _runs:\n" + " _rel = (\n" + " 'MNE-eegbci-data/files/eegmmidb/1.0.0/'\n" + " f'S{int(_s):03d}/S{int(_s):03d}R{int(_r):02d}.edf'\n" + " )\n" + " _out.append(_Path(_lite_fetch_rel(_rel)))\n" + " return _out\n" + "mne.datasets.eegbci.load_data = _lite_eegbci_load_data\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" From e0755e05542fa695cb5f6a49739adcdf2e57ad64 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Tue, 30 Jun 2026 15:30:31 -0400 Subject: [PATCH 37/46] DOC: explain JupyterLite sys.platform guards across tutorials and examples Add a short comment above each `sys.platform == "emscripten"` guard so reviewers understand why interactive/3D code is skipped in the browser build, without needing to dig through PR history. --- examples/preprocessing/muscle_ica.py | 6 ++++++ examples/visualization/eyetracking_plot_heatmap.py | 1 + tutorials/evoked/40_whitened.py | 2 ++ tutorials/intro/10_overview.py | 4 ++++ tutorials/intro/20_events_from_raw.py | 4 ++++ tutorials/intro/40_sensor_locations.py | 3 +++ tutorials/intro/50_configure_mne.py | 3 +++ tutorials/intro/70_report.py | 10 ++++++++++ tutorials/io/60_ctf_bst_auditory.py | 1 + tutorials/io/70_reading_eyetracking_data.py | 1 + 10 files changed, 35 insertions(+) diff --git a/examples/preprocessing/muscle_ica.py b/examples/preprocessing/muscle_ica.py index 7b2cc05acd3..3daae2f0796 100644 --- a/examples/preprocessing/muscle_ica.py +++ b/examples/preprocessing/muscle_ica.py @@ -46,6 +46,7 @@ # %% # Remove components with postural muscle artifact using ICA +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": ica.plot_sources(raw) @@ -74,6 +75,7 @@ # slope in log-log units; this is a very typical pattern for muscle artifact. muscle_idx = [6, 7, 8, 9, 10, 11, 12, 13, 14] +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": ica.plot_properties(raw, picks=muscle_idx, log_scale=True) @@ -81,6 +83,7 @@ blink_idx = [0] heartbeat_idx = [5] ica.apply(raw, exclude=blink_idx + heartbeat_idx) +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": ica.plot_overlay(raw, exclude=muscle_idx) @@ -88,6 +91,7 @@ # Finally, let's try an automated algorithm to find muscle components # and ensure that it gets the same components we did manually. muscle_idx_auto, scores = ica.find_bads_muscle(raw) +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": ica.plot_scores(scores, exclude=muscle_idx_auto) print( @@ -113,9 +117,11 @@ n_components=15, method="picard", max_iter="auto", random_state=97 ) ica.fit(raw) + # Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": ica.plot_sources(raw) muscle_idx_auto, scores = ica.find_bads_muscle(raw) + # Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": ica.plot_properties(raw, picks=muscle_idx_auto, log_scale=True) ica.plot_scores(scores, exclude=muscle_idx_auto) diff --git a/examples/visualization/eyetracking_plot_heatmap.py b/examples/visualization/eyetracking_plot_heatmap.py index e0104aa4af2..3be8bfd6580 100644 --- a/examples/visualization/eyetracking_plot_heatmap.py +++ b/examples/visualization/eyetracking_plot_heatmap.py @@ -33,6 +33,7 @@ import mne from mne.viz.eyetracking import plot_gaze +# JupyterLite (Pyodide) browser build only. if sys.platform == "emscripten": raise RuntimeError( "This example requires the MNE EyeLink dataset, " diff --git a/tutorials/evoked/40_whitened.py b/tutorials/evoked/40_whitened.py index 06abc4102e1..639bccfaa2e 100644 --- a/tutorials/evoked/40_whitened.py +++ b/tutorials/evoked/40_whitened.py @@ -54,6 +54,7 @@ ) # butterfly mode shows the differences most clearly +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": raw.plot(events=events, butterfly=True) raw.plot(noise_cov=noise_cov, events=events, butterfly=True) @@ -61,6 +62,7 @@ # %% # Epochs with whitening # --------------------- +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": epochs.plot(events=True) epochs.plot(noise_cov=noise_cov, events=True) diff --git a/tutorials/intro/10_overview.py b/tutorials/intro/10_overview.py index 2c5fa04b362..0b621906744 100644 --- a/tutorials/intro/10_overview.py +++ b/tutorials/intro/10_overview.py @@ -83,6 +83,7 @@ raw.compute_psd(fmax=50).plot(picks="data", exclude="bads", amplitude=False) +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": raw.plot(duration=5, n_channels=30) @@ -103,6 +104,7 @@ ica = mne.preprocessing.ICA(n_components=20, random_state=97, max_iter=800) ica.fit(raw) ica.exclude = [1, 2] # details on how we picked these are omitted here +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": ica.plot_properties(raw, picks=ica.exclude) @@ -143,6 +145,7 @@ "EEG 008", ] chan_idxs = [raw.ch_names.index(ch) for ch in chs] +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": orig_raw.plot(order=chan_idxs, start=12, duration=4) raw.plot(order=chan_idxs, start=12, duration=4) @@ -404,6 +407,7 @@ # path to subjects' MRI files subjects_dir = sample_data_folder / "subjects" # plot the STC +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": stc.plot( initial_time=0.1, hemi="split", views=["lat", "med"], subjects_dir=subjects_dir diff --git a/tutorials/intro/20_events_from_raw.py b/tutorials/intro/20_events_from_raw.py index 80bad559ac9..c2fb5948bd1 100644 --- a/tutorials/intro/20_events_from_raw.py +++ b/tutorials/intro/20_events_from_raw.py @@ -165,6 +165,7 @@ # stored events into an `~mne.Annotations` object and store it as the # :attr:`~mne.io.Raw.annotations` attribute of the `~mne.io.Raw` object: +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": testing_data_folder = mne.datasets.testing.data_path() eeglab_raw_file = testing_data_folder / "EEGLAB" / "test_raw.set" @@ -179,6 +180,7 @@ # different types of events, and the first event occurred about 1 second after # the recording began: +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": print(len(eeglab_raw.annotations)) print(set(eeglab_raw.annotations.duration)) @@ -214,6 +216,7 @@ # :ref:`fixed-length-events` for direct creation of an Events array of # equally-spaced events). +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": events_from_annot, event_dict = mne.events_from_annotations(eeglab_raw) print(event_dict) @@ -229,6 +232,7 @@ # `~mne.io.Raw` objects, as demonstrated in the tutorial # :ref:`tut-epochs-class`. +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": custom_mapping = {"rt": 77, "square": 42} (events_from_annot, event_dict) = mne.events_from_annotations( diff --git a/tutorials/intro/40_sensor_locations.py b/tutorials/intro/40_sensor_locations.py index 216276bc1c6..5c801135c75 100644 --- a/tutorials/intro/40_sensor_locations.py +++ b/tutorials/intro/40_sensor_locations.py @@ -92,6 +92,7 @@ # It is also possible to skip the manual montage loading step by passing the montage # name directly to the :meth:`~mne.io.Raw.set_montage` method. +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": ssvep_folder = mne.datasets.ssvep.data_path() ssvep_data_raw_path = ( @@ -135,6 +136,7 @@ # If you prefer to draw the head circle using 10–20 conventions (which are also used by # EEGLAB), you can pass ``sphere='eeglab'``: +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": fig = ssvep_raw.plot_sensors(show_names=True, sphere="eeglab") @@ -221,6 +223,7 @@ # It is also possible to render an image of an MEG sensor helmet using 3D surface # rendering instead of matplotlib. This works by calling :func:`mne.viz.plot_alignment`: +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": fig = mne.viz.plot_alignment( sample_raw.info, diff --git a/tutorials/intro/50_configure_mne.py b/tutorials/intro/50_configure_mne.py index ba5643fb638..22b7f8641de 100644 --- a/tutorials/intro/50_configure_mne.py +++ b/tutorials/intro/50_configure_mne.py @@ -197,6 +197,7 @@ # set. First, with log level ``warning``: +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": kit_data_path = os.path.join( os.path.abspath(os.path.dirname(mne.__file__)), @@ -213,6 +214,7 @@ # "warning" or worse. Next, we'll load the same file with log level ``info`` # (the default level): +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": raw = mne.io.read_raw_kit(kit_data_path, verbose="info") @@ -224,6 +226,7 @@ # manager, which is another way to accomplish the same thing as passing # ``verbose='debug'``: +# Skipped in JupyterLite (browser): no interactive/3D rendering. if sys.platform != "emscripten": with mne.use_log_level("debug"): raw = mne.io.read_raw_kit(kit_data_path) diff --git a/tutorials/intro/70_report.py b/tutorials/intro/70_report.py index 373c794485b..7153909b301 100644 --- a/tutorials/intro/70_report.py +++ b/tutorials/intro/70_report.py @@ -44,6 +44,7 @@ # Writes to /tmp DO work in Pyodide's in-memory filesystem, so we save there, # read the HTML back, and embed it in an isolated