diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79a662d5..33736f04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,6 +77,10 @@ jobs: python -m pip install --upgrade pip pip install -e .[docs] - name: Build documentation + env: + # Render, but do not execute, examples that download external data. + # Local builds keep executing the complete gallery by default. + MNE_DENOISE_DOCS_EXECUTE_EXTERNAL_DATA: "false" run: make -C docs html SPHINXOPTS="-W --keep-going" - name: Add .nojekyll to the build run: touch docs/_build/html/.nojekyll diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9ecc2344..2ac8f8dd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -276,6 +276,10 @@ Documentation is built with Sphinx and hosted on GitHub Pages. # Build HTML documentation make -C docs html +# Reproduce the network-isolated CI build. External-data pages are still +# rendered, but their scripts are not executed. +MNE_DENOISE_DOCS_EXECUTE_EXTERNAL_DATA=false make -C docs html + # View in browser open docs/_build/html/index.html # macOS xdg-open docs/_build/html/index.html # Linux @@ -289,6 +293,12 @@ start docs/_build/html/index.html # Windows - `docs/dss.md` - DSS module guide - `examples/` - Gallery examples (rendered by sphinx-gallery) +Normal local builds execute the complete example gallery. Set +`MNE_DENOISE_DOCS_EXECUTE_EXTERNAL_DATA=false` only for a network-isolated +build; this leaves external-data example pages available as source code without +depending on dataset hosts. The audited list of non-executed CI examples lives +in `docs/_gallery_execution.py`. + ### Adding Examples Examples are Python scripts in the `examples/` directory: diff --git a/docs/_gallery_execution.py b/docs/_gallery_execution.py new file mode 100644 index 00000000..779966d2 --- /dev/null +++ b/docs/_gallery_execution.py @@ -0,0 +1,35 @@ +"""Execution policy for the documentation example gallery.""" + +from __future__ import annotations + +import re + +# Keep this list explicit. A test audits all gallery scripts for the network and +# process-exit primitives that make them unsafe to execute in an offline build. +EXTERNAL_DATA_EXAMPLES = ( + "asr/plot_07_riemannian_asr.py", + "asr/plot_11_epochs_and_meg.py", + "asr/plot_13_pipeline_filter_asr_ica.py", + "dss/plot_01_dss_fundamentals.py", + "dss/plot_02_artifact_correction.py", + "dss/plot_03_evoked_responses.py", + "dss/plot_04_spectral_dss.py", + "dss/plot_05_periodic_dss.py", + "dss/plot_06_temporal_dss.py", + "dss/plot_07_spectrogram_dss.py", + "dss/plot_08_blind_source_separation.py", + "zapline/plot_02_parameter_tuning.py", + "zapline/plot_03_epoched_data.py", +) + + +def gallery_filename_pattern(*, execute_external_data_examples: bool) -> str: + """Return the Sphinx-Gallery execution pattern for this build.""" + if execute_external_data_examples: + return r"plot_" + + path_patterns = [ + re.escape(path).replace("/", r"[\\/]") for path in EXTERNAL_DATA_EXAMPLES + ] + external_data_pattern = "|".join(path_patterns) + return rf"^(?!.*(?:{external_data_pattern})$).*plot_" diff --git a/docs/changes/devel/62.bugfix.rst b/docs/changes/devel/62.bugfix.rst new file mode 100644 index 00000000..1955bcab --- /dev/null +++ b/docs/changes/devel/62.bugfix.rst @@ -0,0 +1,4 @@ +Make documentation CI independent of external dataset hosts while keeping the +affected example pages available as source code. This also prevents a handled +dataset download failure from ending Sphinx early with a false-success status; +local documentation builds continue to execute the complete gallery by default. diff --git a/docs/conf.py b/docs/conf.py index 1b476b33..1b339c2e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -4,16 +4,21 @@ import sys import tempfile from datetime import datetime +from pathlib import Path from sphinx_gallery.sorting import ExplicitOrder, FileNameSortKey -sys.path.insert(0, os.path.abspath("..")) +_DOCS_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(_DOCS_DIR)) +sys.path.insert(0, str(_DOCS_DIR.parent)) os.environ.setdefault( "NUMBA_CACHE_DIR", os.path.join(tempfile.gettempdir(), "numba_cache") ) os.environ.setdefault("MNE_HOME", os.path.join(tempfile.gettempdir(), "mne_home")) -import mne_denoise +from _gallery_execution import gallery_filename_pattern # noqa: E402 + +import mne_denoise # noqa: E402 # -- General configuration ------------------------------------------------ @@ -51,10 +56,21 @@ # MyST configuration myst_heading_anchors = 3 +# Sphinx-Gallery still renders non-matching scripts as source-code pages; the +# pattern only controls whether their code is executed. Keep the full gallery +# locally by default, while allowing network-isolated builders to skip examples +# whose datasets are downloaded at runtime. +_execute_external_data_examples = os.environ.get( + "MNE_DENOISE_DOCS_EXECUTE_EXTERNAL_DATA", "true" +).casefold() not in {"0", "false", "no"} +_gallery_filename_pattern = gallery_filename_pattern( + execute_external_data_examples=_execute_external_data_examples +) + sphinx_gallery_conf = { "examples_dirs": "../examples", "gallery_dirs": "auto_examples", - "filename_pattern": r"plot_", + "filename_pattern": _gallery_filename_pattern, "ignore_pattern": r"tutorials|_legacy", "subsection_order": ExplicitOrder( [ diff --git a/examples/dss/plot_04_spectral_dss.py b/examples/dss/plot_04_spectral_dss.py index 504d8cd6..2a35dd48 100644 --- a/examples/dss/plot_04_spectral_dss.py +++ b/examples/dss/plot_04_spectral_dss.py @@ -396,11 +396,12 @@ print("Loading EEGBCI resting-state data (eyes closed)...") try: data_path = eegbci.load_data(subjects=[1], runs=[1], update_path=True)[0] -except Exception as e: - import sys - - print(f"Skipping EEGBCI example due to network error: {e}") - sys.exit(0) +except Exception as error: + raise RuntimeError( + "The EEGBCI dataset is required for this external-data gallery example. " + "Check network access or pre-download the dataset before building the full " + "gallery." + ) from error raw_eeg = read_raw_edf( data_path, diff --git a/examples/dss/plot_06_temporal_dss.py b/examples/dss/plot_06_temporal_dss.py index bab24beb..340d65d7 100644 --- a/examples/dss/plot_06_temporal_dss.py +++ b/examples/dss/plot_06_temporal_dss.py @@ -323,11 +323,12 @@ try: eegbci.load_data(subject, runs, update_path=True) raw_path = eegbci.load_data(subject, runs)[0] -except Exception as e: - import sys - - print(f"Skipping EEGBCI example due to network error: {e}") - sys.exit(0) +except Exception as error: + raise RuntimeError( + "The EEGBCI dataset is required for this external-data gallery example. " + "Check network access or pre-download the dataset before building the full " + "gallery." + ) from error raw_eeg = mne.io.read_raw_edf(raw_path, preload=True, verbose=False) diff --git a/mne_denoise/dss/denoisers/temporal.py b/mne_denoise/dss/denoisers/temporal.py index 7b5bf056..0623cbc7 100644 --- a/mne_denoise/dss/denoisers/temporal.py +++ b/mne_denoise/dss/denoisers/temporal.py @@ -158,7 +158,7 @@ class SmoothingBias(LinearDenoiser): Uses a boxcar moving average filter to smooth the data. When used to split the signal into a smooth branch and a residual (``data - smooth``), fitting DSS on the residual and adding the smooth branch back follows ZapLine's - period-matched decomposition (de Cheveigné, 2020 [3]_): with + period-matched decomposition (de Cheveigné, 2020): with ``window = round(sfreq / f_line)`` the smoother has zeros at ``f_line`` and its harmonics, so the residual concentrates the narrowband artifact. diff --git a/tests/test_docs_gallery.py b/tests/test_docs_gallery.py new file mode 100644 index 00000000..f7537f78 --- /dev/null +++ b/tests/test_docs_gallery.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import importlib.util +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +EXAMPLES_DIR = REPO_ROOT / "examples" +POLICY_PATH = REPO_ROOT / "docs" / "_gallery_execution.py" + + +def _load_gallery_policy(): + spec = importlib.util.spec_from_file_location("_gallery_execution", POLICY_PATH) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _gallery_scripts(): + return { + path.relative_to(EXAMPLES_DIR).as_posix() + for path in EXAMPLES_DIR.rglob("plot_*.py") + } + + +def test_external_data_gallery_audit_is_complete(): + policy = _load_gallery_policy() + scripts = _gallery_scripts() + external_data_markers = ( + "mne.datasets", + "from mne.datasets", + "urlretrieve(", + ) + detected = { + path + for path in scripts + if any( + marker in (EXAMPLES_DIR / path).read_text(encoding="utf-8") + for marker in external_data_markers + ) + } + + assert detected == set(policy.EXTERNAL_DATA_EXAMPLES) + assert all( + "sys.exit(" not in (EXAMPLES_DIR / path).read_text(encoding="utf-8") + for path in scripts + ) + + +def test_offline_gallery_executes_only_audited_safe_scripts(): + policy = _load_gallery_policy() + scripts = _gallery_scripts() + external = set(policy.EXTERNAL_DATA_EXAMPLES) + offline_pattern = policy.gallery_filename_pattern( + execute_external_data_examples=False + ) + local_pattern = policy.gallery_filename_pattern(execute_external_data_examples=True) + + assert all(re.search(local_pattern, path) for path in scripts) + assert all(not re.search(offline_pattern, path) for path in external) + assert all(re.search(offline_pattern, path) for path in scripts - external)