Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
35 changes: 35 additions & 0 deletions docs/_gallery_execution.py
Original file line number Diff line number Diff line change
@@ -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_"
4 changes: 4 additions & 0 deletions docs/changes/devel/62.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 19 additions & 3 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ------------------------------------------------

Expand Down Expand Up @@ -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(
[
Expand Down
11 changes: 6 additions & 5 deletions examples/dss/plot_04_spectral_dss.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 6 additions & 5 deletions examples/dss/plot_06_temporal_dss.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion mne_denoise/dss/denoisers/temporal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
63 changes: 63 additions & 0 deletions tests/test_docs_gallery.py
Original file line number Diff line number Diff line change
@@ -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)
Loading