diff --git a/.circleci/config.yml b/.circleci/config.yml index 3b88e176092..907756525fa 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -232,6 +232,17 @@ jobs: no_output_timeout: 10m command: | ./tools/circleci_download.sh + # Ensure the data the JupyterLite notebooks need is on disk so conf.py can + # copy the required subset for the build. lite_data fetches only the + # curated files the browser notebooks use (from the MNE-lite-data OSF + # project) instead of the full sample/kiloword/erp_core/mtrf/eegbci + # datasets, which slims the build. The curated files share hashes with the + # full datasets, so on a full build that already downloaded them nothing + # extra is fetched. + - run: + name: Ensure MNE data for JupyterLite + command: | + python -c "import mne; mne.datasets.lite_data.data_path(update_path=True)" - run: name: Verify build type command: | diff --git a/.gitignore b/.gitignore index 21275b21c0b..8c3de85c2c6 100644 --- a/.gitignore +++ b/.gitignore @@ -103,4 +103,10 @@ venv/ .hypothesis/ .ruff_cache/ .ipynb_checkpoints/ + +# generated by the JupyterLite docs build (doc/conf.py) +doc/jupyterlite_contents/* +!doc/jupyterlite_contents/.gitkeep +doc/lite_extra/ + /.claude/ \ No newline at end of file diff --git a/doc/Makefile b/doc/Makefile index be167d67c33..3926d206a71 100644 --- a/doc/Makefile +++ b/doc/Makefile @@ -65,7 +65,7 @@ html_dev-noplot: html-noplot html_dev-front: html-front linkcheck: - @$(SPHINXBUILD) -b linkcheck -D nitpicky=0 -q -D plot_gallery=0 -D exclude_patterns="cited.rst,whats_new.rst,configure_git.rst,_includes,changes/dev" -d _build/doctrees . _build/linkcheck + @$(SPHINXBUILD) -b linkcheck -D nitpicky=0 -q -D plot_gallery=0 -D exclude_patterns="cited.rst,whats_new.rst,configure_git.rst,_includes,changes/dev,jupyterlite_contents,lite_extra,pypi" -d _build/doctrees . _build/linkcheck doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) _build/doctest diff --git a/doc/changes/dev/14157.newfeature.rst b/doc/changes/dev/14157.newfeature.rst new file mode 100644 index 00000000000..879bbec54a8 --- /dev/null +++ b/doc/changes/dev/14157.newfeature.rst @@ -0,0 +1 @@ +Add an "Open in JupyterLite" badge to the tutorials and examples that can run in the browser, by `Natneal B`_. diff --git a/doc/conf.py b/doc/conf.py index 4684ffe0343..02f55ee20ba 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -11,6 +11,7 @@ import faulthandler import os +import shutil import subprocess import sys import tomllib @@ -53,6 +54,7 @@ curpath = Path(__file__).parent.resolve(strict=True) sys.path.append(str(curpath / "sphinxext")) +from build_lite_wheel import build_wheel, find_wheels # noqa: E402 from credit_tools import generate_credit_rst # noqa: E402 from mne_doc_utils import report_scraper, reset_warnings, sphinx_logger # noqa: E402 @@ -117,6 +119,7 @@ "sphinx_copybutton", "sphinx_design", "sphinx_gallery.gen_gallery", + "jupyterlite_sphinx", "sphinxcontrib.bibtex", "sphinxcontrib.youtube", "sphinxcontrib.towncrier.ext", @@ -140,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"] +exclude_patterns = [ + "_includes", + "changes/dev", + "jupyterlite_contents", + "lite_extra", + "pypi", +] # The suffix of source filenames. source_suffix = ".rst" @@ -476,7 +485,342 @@ compress_images = () sphinx_gallery_parallel = int(os.getenv("MNE_DOC_BUILD_N_JOBS", "1")) +jupyterlite_contents = ["jupyterlite_contents"] +jupyterlite_bind_ipynb_suffix = False + +# 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. +# lite_data (mne.datasets.lite_data) extracts the curated subset here, with the +# files under their original dataset folders (MNE-sample-data/, ...). +mne_data_base = Path(os.path.expanduser("~/mne_data")) +lite_root = mne_data_base / "MNE-lite-data" +src_sample_data = lite_root / "MNE-sample-data" +lite_extra_base = ( + 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) + + +def _lite_src(folder, rel): + """Return where a dataset file can be read from, or None if nowhere. + + The curated lite_data archive only carries the files it was published with, + so look in whatever CI restored of the real dataset first and fall back to + the archive. Sourcing from the archive alone means anything added since it + was last uploaded goes missing without the build failing. + """ + for root in (mne_data_base / folder, lite_root / folder): + candidate = root / rel + if candidate.exists(): + return candidate + return None + + +sphinx_logger.info( + f"[JupyterLite] Sample data: real dataset=" + f"{(mne_data_base / 'MNE-sample-data').exists()}, " + f"curated archive={src_sample_data.exists()}" +) +if (mne_data_base / "MNE-sample-data").exists() or 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_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", + "MEG/sample/sample_audvis-meg-oct-6-meg-inv.fif", + "MEG/sample/sample_audvis-meg-oct-6-fwd.fif", + "MEG/sample/sample_audvis-meg-oct-6-meg-fixed-inv.fif", + "MEG/sample/ernoise_raw.fif", + "MEG/sample/sample_audvis-no-filter-ave.fif", + "MEG/sample/sample_audvis_raw-trans.fif", + "MEG/sample/sample_audvis-shrunk-cov.fif", + "MEG/sample/sample_audvis-meg-lh.stc", + "MEG/sample/sample_audvis-meg-rh.stc", + "MEG/sample/sample_audvis-meg-eeg-lh.stc", + "MEG/sample/sample_audvis-meg-eeg-rh.stc", + "MEG/sample/sample_audvis_ecg-eve.fif", + # Maxwell-filter calibration pair, read from inside maxwell_filter + # rather than through a shimmable reader (86 KB, so fetched eagerly) + "SSS/sss_cal_mgh.dat", + "SSS/ct_sparse_mgh.fif", + "subjects/sample/mri/T1.mgz", + "subjects/sample/mri/aseg.mgz", + # read_talxfm builds this path itself, so nothing in the tutorials + # names it; plot_alignment needs it to estimate MRI fiducials + "subjects/sample/mri/transforms/talairach.xfm", + "subjects/sample/bem/sample-oct-6-src.fif", + # Head and skull surfaces for plot_alignment. outer_skin.surf is what + # MNE picks first, so serving it makes the browser figure match the + # rendered docs; sample-head.fif is the later fallback. There is no + # sample-head-dense.fif in the dataset; lh.seghead is the documented + # second candidate for the dense surface. (These three .surf paths are + # symlinks into bem/flash/, and copy2 follows them.) + "subjects/sample/bem/outer_skin.surf", + "subjects/sample/bem/outer_skull.surf", + "subjects/sample/bem/inner_skull.surf", + "subjects/sample/bem/sample-head.fif", + "subjects/sample/surf/lh.seghead", + # single-layer BEM solution (the 3-layer one is 237 MB, so notebooks + # needing that are excluded instead) + "subjects/sample/bem/sample-5120-bem-sol.fif", + # fsaverage source space, used by the morphing and cluster-stats + # notebooks; it ships inside MNE-sample-data + "subjects/fsaverage/bem/fsaverage-ico-5-src.fif", + "subjects/sample/surf/rh.pial", + "subjects/sample/surf/lh.pial", + "subjects/sample/surf/rh.white", + "subjects/sample/surf/lh.white", + "subjects/sample/surf/rh.inflated", + "subjects/sample/surf/lh.inflated", + "subjects/sample/surf/rh.curv", + "subjects/sample/surf/lh.curv", + # setup_source_space maps each hemisphere onto its sphere for any + # ico/oct spacing, and _create_surf_spacing reads surf/{hemi}.sphere + # by a path it builds itself (5.6 MB each) + "subjects/sample/surf/lh.sphere", + "subjects/sample/surf/rh.sphere", + "subjects/sample/label/lh.aparc.annot", + "subjects/sample/label/rh.aparc.annot", + # the auditory/visual ROIs; about nine notebooks build these names with + # an f-string, so a scan of the tutorial text never sees them + "MEG/sample/labels/Aud-lh.label", + "MEG/sample/labels/Aud-rh.label", + "MEG/sample/labels/Vis-lh.label", + "MEG/sample/labels/Vis-rh.label", + ] + for req in required_files: + s = _lite_src("MNE-sample-data", req) + d = dst_sample_data / req + if s is not None: + d.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(s, d) + sphinx_logger.info(f"[JupyterLite] Copied: {req}") + else: + sphinx_logger.info(f"[JupyterLite] MISSING: {req}") + + +# Also inject SSVEP and EEGLAB testing datasets for JupyterLite +lite_data_base = lite_extra_base +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" +sphinx_logger.info(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) + sphinx_logger.info("[JupyterLite] Copied ssvep-example-data") + +src_eeglab = mne_data_base / "MNE-testing-data" / "EEGLAB" +dst_eeglab = lite_data_base / "MNE-testing-data" / "EEGLAB" +sphinx_logger.info(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) + sphinx_logger.info("[JupyterLite] Copied MNE-testing-data/EEGLAB") + +# The head-position and Maxwell-filtering tutorials read one continuous +# movement recording out of the testing dataset. CI already restores it from +# data-cache-testing, so only these two files are copied, not the 1.6 GB set. +testing_files = [ + "SSS/test_move_anon_raw.fif", + "SSS/test_move_anon_raw.pos", +] +for testing_file in testing_files: + s = _lite_src("MNE-testing-data", testing_file) + d = lite_data_base / "MNE-testing-data" / testing_file + if s is not None and not d.exists(): + d.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(s, d) + _mb = s.stat().st_size / 1e6 + sphinx_logger.info(f"[JupyterLite] Copied {testing_file} ({_mb:.1f} MB)") + elif s is None: + sphinx_logger.info(f"[JupyterLite] MISSING {testing_file}") + +# The remaining datasets are each used by one or two notebooks that read only a +# couple of files out of them. CI already downloads all of these in +# tools/circleci_download.sh, so copying is free, but their sizes vary a lot, +# so refuse anything past this limit rather than bloat the artifact. For scale, +# the largest file already served (sample_audvis_raw.fif) is 128 MB. +LITE_MAX_FILE_MB = 150 + + +def _lite_copy(folder, rel_paths): + """Copy selected files of a dataset into the served tree.""" + for rel in rel_paths: + s = _lite_src(folder, rel) + if s is None: + sphinx_logger.info(f"[JupyterLite] MISSING {folder}/{rel}") + continue + size_mb = s.stat().st_size / 1e6 + if size_mb > LITE_MAX_FILE_MB: + sphinx_logger.info( + f"[JupyterLite] SKIPPED {folder}/{rel} ({size_mb:.1f} MB)" + ) + continue + d = lite_data_base / folder / rel + if not d.exists(): + d.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(s, d) + sphinx_logger.info( + f"[JupyterLite] Copied {folder}/{rel} ({size_mb:.1f} MB)" + ) + + +def _lite_copy_tree(folder, rel_dir): + """Copy a directory-shaped recording, leaving a manifest for the browser. + + read_raw_nirx and read_raw_egi are handed a folder rather than a file, so + the setup cell has no way to know what to fetch without a listing. + """ + src = mne_data_base / folder / rel_dir + if not src.is_dir(): + sphinx_logger.info(f"[JupyterLite] MISSING {folder}/{rel_dir}") + return + names, total_mb = [], 0.0 + for f in sorted(src.rglob("*")): + if not f.is_file(): + continue + # zero-byte members (an .mff carries a couple of lock files) do not + # survive the artifact upload, so listing them only yields a 404 + if f.stat().st_size == 0: + continue + size_mb = f.stat().st_size / 1e6 + if size_mb > LITE_MAX_FILE_MB: + sphinx_logger.info( + f"[JupyterLite] SKIPPED {folder}/{rel_dir} ({size_mb:.1f} MB)" + ) + return + names.append(str(f.relative_to(src))) + total_mb += size_mb + dst = lite_data_base / folder / rel_dir + dst.mkdir(parents=True, exist_ok=True) + for name in names: + d = dst / name + d.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src / name, d) + (dst / "_lite_manifest.txt").write_text("\n".join(names)) + sphinx_logger.info( + f"[JupyterLite] Copied {folder}/{rel_dir} " + f"({len(names)} files, {total_mb:.1f} MB)" + ) + + +_lite_copy( + "MNE-misc-data", + [ + "xdf/sub-P001_ses-S004_task-Default_run-001_eeg_a2.xdf", + "movement/simulated_quats.pos", + "movement/simulated_movement_raw.fif", + "movement/simulated_stationary_raw.fif", + "eyetracking/eyelink/px_textpage_ws.asc", + "eyetracking/eyelink/HREF_textpage_ws.asc", + ], +) +_lite_copy( + "MNE-eyelink-data", + [ + "freeviewing/sub-01_task-freeview_eyetrack.asc", + "freeviewing/stim/naturalistic.png", + "eeg-et/sub-01_task-plr_eyetrack.asc", + ], +) +_lite_copy_tree("MNE-eyelink-data", "eeg-et/sub-01_task-plr_eeg.mff") +_lite_copy_tree("MNE-fNIRS-motor-data", "Participant-1") + +# The logging tutorial reads a KIT file that lives inside the package itself, +# under mne/io/kit/tests/. pyproject excludes "/mne/**/tests" from the wheel, so +# it is absent from the browser kernel, so serve it and let the setup cell stage +# it back into the path the tutorial builds. +_kit_src = Path(mne.__file__).parent / "io" / "kit" / "tests" / "data" / "test.sqd" +_kit_dst = lite_data_base / "MNE-kit-testdata" / "test.sqd" +if _kit_src.exists(): + if not _kit_dst.exists(): + _kit_dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(_kit_src, _kit_dst) + sphinx_logger.info( + f"[JupyterLite] Copied MNE-kit-testdata/test.sqd " + f"({_kit_src.stat().st_size / 1e6:.1f} MB)" + ) +else: + sphinx_logger.info("[JupyterLite] MISSING MNE-kit-testdata/test.sqd") + +_lite_copy("MNE-phantom-kernel-data", ["phantom_32_100nam_raw.fif"]) +_lite_copy("MNE-multimodal-data", ["multimodal_raw.fif"]) +_lite_copy("MNE-refmeg-noise-data", ["sample_reference_MEG_noise-raw.fif"]) + +# somato is deliberately not served: its raw alone is 344 MB and the six +# notebooks that read it are on the exclude list instead. + +# 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", + # exactly the runs tools/circleci_download.sh fetches: subject 1 runs + # 3/6/10/14 and run 3 for subjects 2-4. Notebooks wanting run 1 or 2 are + # excluded instead, since that data never reaches the CI box. + [ + "files/eegmmidb/1.0.0/S001/S001R03.edf", + "files/eegmmidb/1.0.0/S001/S001R06.edf", + "files/eegmmidb/1.0.0/S001/S001R10.edf", + "files/eegmmidb/1.0.0/S001/S001R14.edf", + "files/eegmmidb/1.0.0/S002/S002R03.edf", + "files/eegmmidb/1.0.0/S003/S003R03.edf", + "files/eegmmidb/1.0.0/S004/S004R03.edf", + ], + ), +): + _dst_ds = lite_data_base / _folder + for _ds_file in _ds_files: + s = _lite_src(_folder, _ds_file) + d = _dst_ds / _ds_file + if s is not None: + d.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(s, d) + sphinx_logger.info(f"[JupyterLite] Copied: {_folder}/{_ds_file}") + else: + sphinx_logger.info(f"[JupyterLite] MISSING: {_folder}/{_ds_file}") + + +# Provide the development MNE wheel so JupyterLite installs the current version +# rather than the older release from PyPI. ``doc/sphinxext/build_lite_wheel.py`` +# builds it into ``doc/pypi``, where the jupyterlite-pyodide-kernel PipliteAddon +# discovers and indexes it. Running that script before the docs build (in CI or +# locally) means Sphinx reuses the wheel instead of rebuilding it on every +# invocation; if none is present we build it here, so the docs build never +# depends on the pre-step having run. +_lite_wheels = find_wheels() or build_wheel() +_lite_wheel_names = ", ".join(str(_wheel) for _wheel in _lite_wheels) +sphinx_logger.info( + f"[JupyterLite] MNE wheel for the browser kernel: {_lite_wheel_names}" +) + sphinx_gallery_conf = { + "jupyterlite": { + "use_jupyter_lab": True, + "jupyterlite_contents": "jupyterlite_contents", + # named rather than passed: sphinx_gallery_conf has to stay + # JSON-serializable (see the is_serializable assert below), so + # sphinx-gallery imports this dotted path itself + "notebook_modification_function": ( + "jupyterlite_cell_notes.note_unrunnable_cells" + ), + }, "doc_module": ("mne",), "reference_url": dict(mne=None), "examples_dirs": examples_dirs, @@ -578,6 +922,138 @@ "parallel": sphinx_gallery_parallel, } assert is_serializable(sphinx_gallery_conf) + +# --------------------------------------------------------------------------- +# Drop the "Open in JupyterLite" launch badge from gallery pages whose +# notebooks cannot run in the browser kernel at all: they need the R runtime +# (rpy2), a compiled package Pyodide does not ship (antio), or multi-GB +# datasets that cannot be bundled/slimmed. sphinx-gallery adds the badge to +# every example unconditionally, so we wrap its badge generator and return an +# empty string for these files. This only removes the badge/link; the +# notebook source is untouched (no in-code guard). Files that merely need data +# bundled, a pure-Python package installed, or pyvista 3D are NOT listed here +# (they are fixable, not impossible). +JUPYTERLITE_EXCLUDE = ( + # Tier 1, impossible: R runtime / compiled package / huge single dataset + "examples/stats/r_interop.py", # rpy2 -> needs the R runtime + "examples/io/read_impedances.py", # antio (compiled, not in Pyodide) + "examples/decoding/decoding_rsa_sgskip.py", # visual_92_categories ~6 GB + "examples/decoding/decoding_spoc_CMC.py", # fieldtrip_cmc ~700 MB + "examples/decoding/ssd_spatial_filters.py", # fieldtrip_cmc ~700 MB + # Tier 2: multi-GB datasets (brainstorm / spm_face / opm / hf_sef) + "examples/datasets/brainstorm_data.py", + "examples/datasets/hf_sef_data.py", + "examples/datasets/opm_data.py", + "examples/datasets/spm_faces_dataset.py", + "examples/preprocessing/movement_detection.py", + "examples/preprocessing/muscle_detection.py", + "examples/preprocessing/otp.py", + "examples/time_frequency/source_power_spectrum_opm.py", + "examples/visualization/evoked_arrowmap.py", + "examples/visualization/meg_sensors.py", + "tutorials/inverse/80_brainstorm_phantom_elekta.py", + "tutorials/inverse/85_brainstorm_phantom_ctf.py", + "tutorials/io/60_ctf_bst_auditory.py", + "tutorials/preprocessing/80_opm_processing.py", + # Tier 3: several blockers each, none of them worth clearing on its own + # the volume inverse is ~178 MB and volume source estimates are not + # rendered in the browser + "examples/inverse/compute_mne_inverse_volume.py", + # needs aseg.mgz and the mixed source space, and calls src.plot(), which + # is the 3D SourceSpaces view + "examples/inverse/mixed_source_space_inverse.py", + # nilearn.datasets.load_mni152_template() downloads a template at runtime, + # which the browser blocks (CORS); the surrounding try only catches + # TypeError, so the failure is not survivable + "tutorials/inverse/20_dipole_fit.py", + # make_field_map(upsampling=2) subdivides the helmet mesh through VTK, and + # plot_field needs the interactive viewer that the browser renderer skips + "examples/visualization/mne_helmet.py", + # Tier 4: mne.viz.Brain. The browser renderer draws static meshes; Brain + # additionally wants dock widgets, a toolbar and a time slider, so these + # are blocked on the interactive layer rather than on data. + "examples/visualization/brain.py", + "examples/visualization/parcellation.py", + "tutorials/clinical/20_seeg.py", + "tutorials/forward/10_background_freesurfer.py", + "tutorials/forward/50_background_freesurfer_mne.py", + "tutorials/inverse/60_visualize_stc.py", + "tutorials/io/30_reading_fnirs_data.py", + "tutorials/preprocessing/70_fnirs_processing.py", + # Tier 5: one-off blockers with no browser path + # plot_field needs the interactive viewer + "tutorials/evoked/20_visualize_evoked.py", + # the three-layer BEM solution alone is 237 MB + "examples/inverse/multi_dipole_model.py", + # openneuro fetches the recording at runtime, which the browser blocks + "examples/preprocessing/esg_rm_heart_artefact_pcaobs.py", + # physionet.org is not CORS-enabled and the dataset is not on the CI box + "tutorials/clinical/60_sleep.py", + # the 4D/BTi phantom dataset is not among the ones CI downloads + "tutorials/inverse/90_phantom_4DBTi.py", + # needs mne_bids as well as the epilepsy_ecog dataset and 3D sensor views + "tutorials/clinical/30_ecog.py", + # Tier 6: fetch_fsaverage. _manifest_check_download only skips the + # download when every one of its ~190 manifest entries is already present, + # so fsaverage cannot be part-bundled, and MNE-sample-data ships no + # fsaverage/bem at all. The volume forward and inverse these two want are + # 187 MB and 360 MB on top of that. + "examples/inverse/morph_volume_stc.py", + "tutorials/inverse/50_beamformer_lcmv.py", + "examples/visualization/montage_sgskip.py", + # same, plus fetch_infant_template downloads a second template + "tutorials/forward/35_eeg_no_mri.py", + # snapshot_brain_montage needs a real 3D window to read pixels back from + "examples/visualization/3d_to_2d.py", + # the three-layer BEM solution is 237 MB, and T1_electrodes.mgz would pull + # in the misc dataset's MRI as well + "tutorials/inverse/70_eeg_mri_coords.py", + # mne_bids is not installable in the browser kernel + "tutorials/inverse/95_phantom_KIT.py", + # Tier 7: somato. Serving it costs 404 MB (the raw alone is 344 MB) on + # every docs deploy, which is more than these six pages are worth; the + # dataset is not copied at all. Restoring them means putting the somato + # block back in the copy step above. + "examples/inverse/dics_epochs.py", + "examples/inverse/dics_source_power.py", + "examples/inverse/evoked_ers_source_power.py", + "examples/inverse/multidict_reweighted_tfmxne.py", + "examples/time_frequency/time_frequency_global_field_power.py", + "tutorials/time-freq/20_sensors_time_frequency.py", + # Single recordings well past LITE_MAX_FILE_MB, confirmed against the full + # build: 379 MB and 251 MB for one example each, so they are skipped by the + # copy step and the badge would have nothing to load. + "examples/datasets/kernel_phantom.py", + "examples/io/elekta_epochs.py", + # These want EEGBCI runs 1 and 2, which tools/circleci_download.sh never + # fetches (it takes subject 1 runs 3/6/10/14 and run 3 for subjects 2-4), + # so the data is not on the machine that builds the docs. eeg_bridging + # alone would need run 1 for ten subjects. + "examples/visualization/onionskin.py", + "examples/preprocessing/muscle_ica.py", + "examples/preprocessing/eeg_bridging.py", + # Both Report tutorials build their figures by screenshotting a 3D scene + # (Report._itv calls backend._take_3d_screenshot), and vtk.js cannot hand a + # framebuffer back to Python, so those sections would embed blank images. + # 70_report additionally round-trips a report through HDF5. + "tutorials/intro/70_report.py", + "tutorials/preprocessing/14_quality_control_report.py", +) + +import sphinx_gallery.gen_rst as _sg_gen_rst # noqa: E402 + +_orig_gen_jupyterlite_rst = _sg_gen_rst.gen_jupyterlite_rst + + +def _lite_badge_filtered(fpath, gallery_conf): + """Return the JupyterLite badge reST, or "" for excluded notebooks.""" + _p = str(fpath).replace(os.sep, "/") + if any(_p.endswith(_ex) for _ex in JUPYTERLITE_EXCLUDE): + return "" + return _orig_gen_jupyterlite_rst(fpath, gallery_conf) + + +_sg_gen_rst.gen_jupyterlite_rst = _lite_badge_filtered # Files were renamed from plot_* with: # find . -type f -name 'plot_*.py' -exec sh -c 'x="{}"; xn=`basename "${x}"`; git mv "$x" `dirname "${x}"`/${xn:5}' \; # noqa @@ -883,6 +1359,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. diff --git a/doc/documentation/datasets.rst b/doc/documentation/datasets.rst index 2ec98664e74..66b93b992d9 100644 --- a/doc/documentation/datasets.rst +++ b/doc/documentation/datasets.rst @@ -541,6 +541,17 @@ the people in the scene were unrecognizable. * :ref:`tut-eyetrack-heatmap` +.. note:: Not every tutorial and example can run in the browser, so the + "Open in JupyterLite" badge is only shown on the pages that work there. A page + is left without a badge when it needs a non-Python runtime (for example the R + interoperability example, via ``rpy2``), a compiled reader with no WebAssembly + build (such as ``antio``), or a dataset too large to serve to a browser + (brainstorm, spm_face, opm, hf_sef, and similar). + + 3D is also limited: source estimates are rendered with vtk.js, but the + coregistration and sensor-plotting views that rely on the full VTK stack are + not available. + References ========== diff --git a/doc/jupyterlite_contents/.gitkeep b/doc/jupyterlite_contents/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/doc/sphinxext/jupyterlite_cell_notes.py b/doc/sphinxext/jupyterlite_cell_notes.py new file mode 100644 index 00000000000..8897185d0ae --- /dev/null +++ b/doc/sphinxext/jupyterlite_cell_notes.py @@ -0,0 +1,109 @@ +"""Per-notebook fixups applied to the JupyterLite copies only. + +:func:`note_unrunnable_cells` does two things to every notebook sphinx-gallery +copies into the JupyterLite contents: + +1. Prepends the setup cell that installs MNE and patches the browser + environment (see :mod:`jupyterlite_setup_cell`). +2. Swaps any cell that cannot run in the browser for a note that keeps the code + in view and says what it needs instead; a cell here and there is blocked + even though the rest of its notebook runs, and dropping a whole page from + the launcher over one cell costs more than it saves. + +Both are deliberately done here rather than through ``first_notebook_cell``, +which sphinx-gallery applies while *generating* the notebook and therefore also +writes into the ``.ipynb`` offered for download, where ``piplite`` does not +exist and the notebook would fail on its first cell. Doing it at copy time +keeps the download and the rendered page exactly as the docs built them. + +It lives here rather than in ``conf.py`` because ``sphinx_gallery_conf`` has to +stay JSON-serializable (``sphinx.config.is_serializable`` rejects functions), so +the config names the dotted path and sphinx-gallery imports it. +""" + +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +import os + +import sphinx.util.logging +from jupyterlite_setup_cell import LITE_SETUP_CELL + +# not mne_doc_utils.sphinx_logger: that module pulls in mne and pyvista, which +# this one has no use for +logger = sphinx.util.logging.getLogger("mne") + +# first line of LITE_SETUP_CELL, used to spot a cell that is already there +_SETUP_MARKER = LITE_SETUP_CELL.strip().split("\n", 1)[0] + +# (notebook path suffix, substring identifying the cell, replacement markdown). +# Keep this short: a page that is mostly unavailable belongs in +# JUPYTERLITE_EXCLUDE instead of here. +CELL_NOTES = ( + ( + "forward/20_source_alignment.ipynb", + "mne.gui.coregistration", + "**This cell does not run in the browser.**\n" + "\n" + "`mne.gui.coregistration` sets the fiducials by clicking on the scalp\n" + "surface, and the vtk.js renderer used here draws scenes without a\n" + "picker, so there is nothing for those clicks to hit. Run it from a\n" + "local MNE install instead:\n" + "\n" + "```python\n" + 'mne.gui.coregistration(subject="sample", subjects_dir=subjects_dir)\n' + "```\n" + "\n" + "The video above walks through the same steps, and the rest of this\n" + "notebook runs normally.\n", + ), +) + + +def note_unrunnable_cells(notebook_content, notebook_filename): + """Add the setup cell and note the cells that cannot run in the browser. + + Parameters + ---------- + notebook_content : dict + The parsed notebook, modified in place. + notebook_filename : path-like + Where the notebook will be written inside the JupyterLite contents. + """ + # setdefault, not get: a missing key would otherwise hand back a throwaway + # list and the insert would silently not stick + cells = notebook_content.setdefault("cells", []) + # stale .ipynb from an earlier build can already carry the cell; adding a + # second one would install everything twice + already = cells and _SETUP_MARKER in "".join(cells[0].get("source", [])) + if not already: + cells.insert( + 0, + { + "cell_type": "code", + "execution_count": None, + "metadata": {"collapsed": False}, + "outputs": [], + # .strip() to match what sphinx-gallery's add_code_cell wrote + # while this went through first_notebook_cell + "source": [LITE_SETUP_CELL.strip()], + }, + ) + path = str(notebook_filename).replace(os.sep, "/") + for suffix, needle, note in CELL_NOTES: + if not path.endswith(suffix): + continue + for cell in cells: + # prose mentions the same function, so only rewrite real code + if cell.get("cell_type") != "code": + continue + if needle not in "".join(cell.get("source", [])): + continue + cell["cell_type"] = "markdown" + cell["source"] = [note] + cell["metadata"] = {} + # markdown cells carry neither of these, and nbformat rejects them + cell.pop("outputs", None) + cell.pop("execution_count", None) + logger.info(f"[JupyterLite] {suffix}: noted {needle} cell")