Skip to content

Commit 7c133c7

Browse files
FIX: resolve JupyterLite notebook install errors in CircleCI preview
- Switch piplite.install → micropip.install(..., keep_going=True) so the matplotlib/scipy version mismatch between Pyodide's bundled packages and MNE's declared minimums no longer blocks notebook startup - Add --no-build-isolation to pip wheel so it reads the patched pyproject.toml from disk rather than the editable-install cache - Wrap pyproject.toml patching in try/finally so it is always restored even if the wheel build crashes mid-run - Tighten regex patterns so the relaxation covers any future bumps to the minimum versions in pyproject.toml (scipy>=1.1x, mpl>=3.x) - Remove the 1.5 GB OSF sample-data download fallback; replace with a clear warning pointing users to the live mne.tools documentation - Make pyodide_pooch_fetch actually raise RuntimeError for OSF URLs instead of just printing a message and proceeding (which caused silent hangs and OOM crashes in the browser) - Tidy first_notebook_cell: remove duplicate matplotlib import, consolidate os import at the top of the cell Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 57b416f commit 7c133c7

1 file changed

Lines changed: 70 additions & 63 deletions

File tree

doc/conf.py

Lines changed: 70 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -549,20 +549,34 @@
549549
with open(pyproject_path, encoding="utf-8") as f:
550550
orig_pyproject = f.read()
551551

552-
# Relax constraints for Pyodide which often lags behind PyPI
553-
patched = re.sub(r'"scipy\s*>=\s*1\.13"', '"scipy >= 1.11"', orig_pyproject)
554-
patched = re.sub(r'"matplotlib\s*>=\s*3\.9"', '"matplotlib >= 3.5"', patched)
555-
patched = re.sub(r'"numpy\s*>=\s*1\.26,\s*<\s*3"', '"numpy >= 1.21, < 3"', patched)
556-
with open(pyproject_path, "w", encoding="utf-8") as f:
557-
f.write(patched)
558-
552+
# Relax constraints for Pyodide which often lags behind PyPI.
553+
# The wheel built here is served to the browser kernel; micropip's
554+
# keep_going=True means these bounds won't block install, but we also
555+
# relax them here so the wheel metadata is accurate for inspection.
556+
patched = re.sub(r'"scipy\s*>=\s*1\.1[0-9]"', '"scipy >= 1.7"', orig_pyproject)
557+
patched = re.sub(r'"matplotlib\s*>=\s*3\.[5-9]"', '"matplotlib >= 3.5"', patched)
558+
patched = re.sub(r'"numpy\s*>=\s*1\.\d+,\s*<\s*3"', '"numpy >= 1.20, < 3"', patched)
559559
os.environ["SETUPTOOLS_SCM_PRETEND_VERSION"] = "9999.0.1"
560-
subprocess.run(
561-
[sys.executable, "-m", "pip", "wheel", "..", "--no-deps", "-w", dist_lite_dir],
562-
check=True,
563-
)
564-
with open(pyproject_path, "w", encoding="utf-8") as f:
565-
f.write(orig_pyproject)
560+
try:
561+
with open(pyproject_path, "w", encoding="utf-8") as f:
562+
f.write(patched)
563+
subprocess.run(
564+
[
565+
sys.executable,
566+
"-m",
567+
"pip",
568+
"wheel",
569+
"..",
570+
"--no-deps",
571+
"--no-build-isolation",
572+
"-w",
573+
dist_lite_dir,
574+
],
575+
check=True,
576+
)
577+
finally:
578+
with open(pyproject_path, "w", encoding="utf-8") as f:
579+
f.write(orig_pyproject)
566580

567581
jupyterlite_build_command_options = {"piplite-wheels": dist_lite_dir}
568582

@@ -573,16 +587,18 @@
573587
},
574588
"first_notebook_cell": (
575589
"# 💡 This cell is automatically added to the start of each notebook.\n"
576-
"import piplite\n"
577-
"# Install mne without a version pin so piplite resolves the local\n"
578-
"# dev wheel from all.json when available, and falls back cleanly to\n"
579-
"# the latest stable release from PyPI in restricted preview\n"
580-
"# environments (e.g. CircleCI artifact viewer) where the local index\n"
581-
"# is blocked by the host's authentication proxy.\n"
582-
"await piplite.install(['mne', 'scikit-learn', 'joblib'])\n"
590+
"# It installs MNE and patches the browser environment for Pyodide.\n"
591+
"import micropip\n"
592+
"# keep_going=True lets micropip install even if Pyodide's bundled\n"
593+
"# matplotlib/scipy/numpy are older than MNE's declared minimums.\n"
594+
"# MNE's runtime code only checks matplotlib >= 3.7/3.8, so 3.8.4 works.\n"
595+
"await micropip.install(['mne', 'scikit-learn', 'joblib'], keep_going=True)\n"
583596
"\n"
584-
"# Mock lzma since it is missing in Pyodide but required by pooch/joblib\n"
585597
"import sys\n"
598+
"import os\n"
599+
"import io\n"
600+
"\n"
601+
"# Mock lzma — missing in Pyodide but imported by pooch/joblib\n"
586602
"import types\n"
587603
"class MockLZMA:\n"
588604
" LZMAError = Exception\n"
@@ -594,7 +610,7 @@
594610
"if 'lzma' not in sys.modules:\n"
595611
" sys.modules['lzma'] = MockLZMA()\n"
596612
"\n"
597-
"# Mock multiprocessing since it is missing in Pyodide but required by joblib\n"
613+
"# Mock multiprocessing missing in Pyodide but imported by joblib\n"
598614
"from unittest.mock import MagicMock\n"
599615
"if 'multiprocessing' not in sys.modules:\n"
600616
" m = MagicMock()\n"
@@ -603,74 +619,64 @@
603619
" sys.modules['multiprocessing.util'] = m.util\n"
604620
" sys.modules['multiprocessing.pool'] = m.pool\n"
605621
"\n"
606-
"# 1. Patch networking so pooch can download datasets\n"
607-
"import pooch\n"
622+
"# Patch requests so pooch can fetch files already on /drive/mne_data.\n"
623+
"# open_url works for both text and binary in Pyodide >= 0.21.\n"
608624
"import requests\n"
609-
"import urllib.request\n"
610-
"import urllib.error\n"
611-
"import io\n"
612-
"\n"
613-
"orig_send = requests.Session.send\n"
614625
"import pyodide\n"
626+
"orig_send = requests.Session.send\n"
615627
"def pyodide_send(self, request, **kwargs):\n"
616628
" try:\n"
617-
" content = pyodide.http.open_url(request.url).read()\n"
629+
" buf = pyodide.http.open_url(request.url)\n"
630+
" content = buf.getvalue() if hasattr(buf, 'getvalue') else buf.read()\n"
618631
" if isinstance(content, str):\n"
619632
" content = content.encode('utf-8')\n"
620-
" status = 200\n"
621633
" except Exception as e:\n"
622-
" print(f'pyodide.http.open_url failed for {request.url}: {e}')\n"
634+
" print(f'open_url failed for {request.url}: {e}')\n"
623635
" return orig_send(self, request, **kwargs)\n"
624636
" response = requests.Response()\n"
625-
" response.status_code = status\n"
637+
" response.status_code = 200\n"
626638
" response.url = request.url\n"
627639
" response.raw = io.BytesIO(content)\n"
628640
" return response\n"
629641
"requests.Session.send = pyodide_send\n"
630642
"\n"
631-
"import os\n"
643+
"# Set the data directory: /drive/mne_data is pre-populated by the doc\n"
644+
"# build (conf.py copies the required sample-data subset there).\n"
645+
"# If absent (e.g. standalone JupyterLite), fall back to /tmp/mne_data\n"
646+
"# with a clear warning — do NOT attempt to download ~1.5 GB from OSF.\n"
632647
"mne_data_path = (\n"
633648
" '/drive/mne_data' if os.path.exists('/drive/mne_data')\n"
634649
" else '/tmp/mne_data'\n"
635650
")\n"
636651
"os.makedirs(mne_data_path, exist_ok=True)\n"
637652
"if mne_data_path == '/tmp/mne_data':\n"
638-
" from pyodide.http import pyfetch\n"
639-
" import tarfile\n"
640-
" sample_dir = os.path.join(mne_data_path, 'MNE-sample-data')\n"
641-
" if not os.path.exists(sample_dir):\n"
642-
" print('Downloading MNE-sample-data via pyfetch...')\n"
643-
" try:\n"
644-
" res = await pyfetch(\n"
645-
" 'https://osf.io/86qa2/download?version=5'\n"
646-
" )\n"
647-
" tar_path = os.path.join(mne_data_path, 'sample.tar.gz')\n"
648-
" with open(tar_path, 'wb') as f:\n"
649-
" f.write(await res.bytes())\n"
650-
" with tarfile.open(tar_path, 'r:gz') as tar:\n"
651-
" tar.extractall(mne_data_path)\n"
652-
" os.remove(tar_path)\n"
653-
" print('MNE-sample-data extracted successfully.')\n"
654-
" except Exception as e:\n"
655-
" print(f'Failed to fetch or extract sample data: {e}')\n"
653+
" print(\n"
654+
" '⚠️ MNE sample data not found at /drive/mne_data. '\n"
655+
" 'Cells that load datasets will raise FileNotFoundError. '\n"
656+
" 'Open this notebook from the live MNE docs (mne.tools) '\n"
657+
" 'where sample data is pre-bundled.'\n"
658+
" )\n"
656659
"os.environ['MNE_DATA'] = mne_data_path\n"
657660
"os.environ['MNE_DATASETS_SAMPLE_PATH'] = mne_data_path\n"
658661
"\n"
659-
"# Intercept pooch to provide helpful errors for large OSF datasets\n"
662+
"# Block pooch from attempting large OSF downloads in the browser.\n"
663+
"# The required files are either pre-injected or unavailable.\n"
664+
"import pooch\n"
660665
"orig_pooch_fetch = pooch.Pooch.fetch\n"
661666
"def pyodide_pooch_fetch(self, fname, processor=None, downloader=None):\n"
662667
" url = self.get_url(fname)\n"
663668
" if 'osf.io' in url or 'files.osf.io' in url:\n"
664-
" print(f'Bypassing OSF download for"
665-
" {fname}. Using injected local cache.')\n"
666-
" return orig_pooch_fetch(\n"
667-
" self, fname, processor=processor, downloader=downloader\n"
668-
" )\n"
669+
" raise RuntimeError(\n"
670+
" f'Cannot download {fname!r} from OSF in JupyterLite: '\n"
671+
" 'browser CORS policy and memory limits prevent large '\n"
672+
" 'dataset downloads. Open this notebook from mne.tools '\n"
673+
" 'where sample data is pre-bundled, or run it locally.'\n"
674+
" )\n"
675+
" return orig_pooch_fetch(self, fname, processor=processor, downloader=downloader)\n"
669676
"pooch.Pooch.fetch = pyodide_pooch_fetch\n"
670677
"\n"
671-
"# 2. Patch MNEBrowseFigure to auto-display in Pyodide's inline backend\n"
678+
"# Import MNE and finalize setup.\n"
672679
"import mne\n"
673-
"import os\n"
674680
"try:\n"
675681
" mne.get_config()\n"
676682
"except Exception:\n"
@@ -679,16 +685,17 @@
679685
" print('Corrupted MNE config deleted automatically.')\n"
680686
" except Exception:\n"
681687
" pass\n"
682-
"for ds in ['SAMPLE', 'TESTING', 'SSVEP',"
683-
" 'EEGBCI', 'SOMATO', 'AUDIOVISUAL', 'BRAINSTORM']:\n"
688+
"for ds in ['SAMPLE', 'TESTING', 'SSVEP', 'EEGBCI', 'SOMATO',\n"
689+
" 'AUDIOVISUAL', 'BRAINSTORM']:\n"
684690
" mne.set_config(f'MNE_DATASETS_{ds}_PATH', mne_data_path)\n"
691+
"\n"
692+
"# Switch matplotlib to inline so figures render in the notebook.\n"
685693
"import IPython\n"
686694
"IPython.get_ipython().run_line_magic('matplotlib', 'inline')\n"
687695
"import matplotlib.pyplot as plt\n"
688696
"import importlib\n"
689697
"viz_utils = importlib.import_module('mne.viz.utils')\n"
690698
"orig_plt_show = viz_utils.plt_show\n"
691-
"import matplotlib.pyplot as plt\n"
692699
"def pyodide_plt_show(*args, **kwargs):\n"
693700
" orig_plt_show(*args, **kwargs)\n"
694701
" import IPython.display\n"

0 commit comments

Comments
 (0)