diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 1001ad6..8d6dd19 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -6,10 +6,11 @@ on: pull_request: branches: [main] -# Allow only one concurrent deployment +# One run per branch at a time. Superseded pull request builds are +# cancelled, but a running deploy from main is never cut off. concurrency: group: pages-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: build: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 497c84a..a1e76c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,24 @@ on: workflow_dispatch: jobs: + test: + # Never publish a build whose tests fail + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Install package and test dependencies + run: | + pip install -e ".[test]" + - name: Run test suite + run: | + pytest + build: + needs: test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a29978e..6efcae6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,13 +19,14 @@ jobs: with: python-version: "3.12" - name: Ruff + # Same version as in .pre-commit-config.yaml run: | - pip install ruff + pip install ruff==0.17.1 ruff check . ruff format --check . test: - # Oldest and newest supported Python on Linux, plus Windows + # Oldest and newest supported Python on Linux, plus Windows and macOS runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -37,6 +38,8 @@ jobs: python-version: "3.13" - os: windows-latest python-version: "3.13" + - os: macos-latest + python-version: "3.13" steps: - uses: actions/checkout@v4 @@ -78,7 +81,7 @@ jobs: - name: Install package with minimum dependency versions run: | - pip install -e ".[test]" "mne==1.13.0" "mne-bids==0.18.0" "pyprep==0.8.0" "mne-icalabel==0.8.0" "jamica[jax]==0.3.0" + pip install -e ".[test]" "mne==1.13.0" "mne-bids==0.18.0" "pyprep==0.8.0" "mne-icalabel==0.8.0" "jamica[jax]==0.3.0" "mne-denoise==0.0.3" - name: Run test suite run: | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8c81ac3..bff0da6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.2 + rev: v0.17.1 # keep in sync with the ruff pin in tests.yml hooks: - id: ruff name: ruff check --fix diff --git a/pybemobil/io/utils.py b/pybemobil/io/utils.py index 4f2687c..9cb226b 100644 --- a/pybemobil/io/utils.py +++ b/pybemobil/io/utils.py @@ -13,14 +13,14 @@ class NumpyEncoder(json.JSONEncoder): - """Help encoding numpy arrays in JSON.""" + """Help encoding numpy arrays and scalars in JSON.""" def default(self, obj): - """Convert numpy arrays and floats to lists and native floats.""" + """Convert numpy arrays and scalars to lists and native Python types.""" if isinstance(obj, np.ndarray): return obj.tolist() - if isinstance(obj, np.float32): - return float(obj) + if isinstance(obj, np.generic): # np.int64, np.float32, np.bool_, ... + return obj.item() return super().default(obj) diff --git a/pybemobil/preproc/__init__.py b/pybemobil/preproc/__init__.py index 85a4adb..33ce8aa 100644 --- a/pybemobil/preproc/__init__.py +++ b/pybemobil/preproc/__init__.py @@ -15,8 +15,6 @@ ) from pybemobil.preproc.utils import ( StepTimer, - _annotate_break_iter, - _handle_trans, append_desc, auto_coreg_fsaverage, build_sys_info, @@ -67,8 +65,6 @@ # Utilities "format_duration", "build_sys_info", - "_annotate_break_iter", - "_handle_trans", # Epoching "get_stimulus_rename_map", ] diff --git a/pybemobil/preproc/make_report.py b/pybemobil/preproc/make_report.py index b966b1d..698aa6a 100644 --- a/pybemobil/preproc/make_report.py +++ b/pybemobil/preproc/make_report.py @@ -11,6 +11,7 @@ import logging import os import re +import sys import tempfile import warnings from pathlib import Path @@ -20,14 +21,17 @@ import numpy as np from pybemobil.preproc.utils import format_duration +from pybemobil.viz.plotting import glue_imgs # %% Settings & Constants LOGGER = logging.getLogger(__name__) -_REPORT_N_JOBS = int(os.environ.get("BPN_REPORT_N_JOBS", "1")) -DIPOLE_PLOT_N_JOBS = _REPORT_N_JOBS -ICA_REPORT_N_JOBS = _REPORT_N_JOBS +# Parallel jobs for the ICA section of the report (BPN_REPORT_N_JOBS is the +# old name of the variable). +ICA_REPORT_N_JOBS = int( + os.environ.get("PYBEMOBIL_REPORT_N_JOBS", os.environ.get("BPN_REPORT_N_JOBS", "1")) +) try: import seaborn as _sns @@ -57,12 +61,13 @@ def _add_temp_image(report, fig, *, title, caption="", section=None): def _gui_available() -> bool: """Return True when a display is available for 3-D rendering. - On Linux/macOS headless servers ``DISPLAY`` is unset; on Windows the check - is skipped (a display is always assumed to be available). + On Linux, headless servers have neither ``DISPLAY`` nor + ``WAYLAND_DISPLAY`` set. Windows and macOS do not use ``DISPLAY``, so a + display is assumed there. """ - if os.name == "nt": + if sys.platform in ("win32", "darwin"): return True - return bool(os.environ.get("DISPLAY", "")) + return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")) def _esc(value) -> str: @@ -432,29 +437,6 @@ def _add_trans(report, trans, raw_clean, subjects_dir) -> None: raise -def _glue_imgs(paths: list, out_path: str | Path) -> bool: - """Vertically concatenate images at *paths* and save to *out_path*. - - Returns ``True`` on success, ``False`` when Pillow is not available. - """ - try: - from PIL import Image - except ImportError: - warnings.warn("Pillow not available; skipping joint-plot combination.") - return False - - imgs = [Image.open(str(p)) for p in paths] - w = max(img.width for img in imgs) - h = sum(img.height for img in imgs) - combined = Image.new("RGB", (w, h), (255, 255, 255)) - y = 0 - for img in imgs: - combined.paste(img, (0, y)) - y += img.height - combined.save(str(out_path)) - return True - - def _safe_label(label: str) -> str: """Return *label* reduced to characters that are valid in file names. @@ -508,8 +490,7 @@ def _add_joint_plot(report, evokeds_dict_ave: dict, fname_out: Path | None) -> N combined_path = figdir / "joint-COMBINED.png" else: combined_path = Path(tmpdir) / "joint-COMBINED.png" - if not _glue_imgs(savenames, combined_path): - combined_path = savenames[0] + glue_imgs(savenames, combined_path, delete_fnames=False) report.add_image(str(combined_path), title="Butterfly, GFP, and Topoplots") diff --git a/pybemobil/viz/plotting.py b/pybemobil/viz/plotting.py index eebd77d..d6af247 100644 --- a/pybemobil/viz/plotting.py +++ b/pybemobil/viz/plotting.py @@ -60,8 +60,8 @@ def _parametric(data): def glue_imgs(fnames, fname, delete_fnames=True): """Glue images together. - Use Pillow to read standard image files from disk and concatenate them vertically. - Save the resulting image back to disk. + Use Pillow to read standard image files from disk and concatenate them vertically + (narrower images are padded with white). Save the resulting image back to disk. Parameters ---------- @@ -83,7 +83,7 @@ def glue_imgs(fnames, fname, delete_fnames=True): hx_total += hx images.append(img) - new_img = Image.new("RGB", (wx_max, hx_total)) + new_img = Image.new("RGB", (wx_max, hx_total), (255, 255, 255)) y_offset = 0 for img in images: new_img.paste(img, (0, y_offset)) diff --git a/pybemobil/viz/utils.py b/pybemobil/viz/utils.py deleted file mode 100644 index fcdf116..0000000 --- a/pybemobil/viz/utils.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Holds utilities for visualization in the PyBeMoBIL.""" - -# %% Imports - -import gc - -import matplotlib._pylab_helpers as plab_helpers -import matplotlib.pyplot as plt - -# %% Constants & Settings - - -# %% Classes & Functions - - -def clear_matplotlib_memory(): - """Clear and close all matplotlib figures and caches.""" - plt.close("all") - plab_helpers.Gcf.destroy_all() - gc.collect() diff --git a/pyproject.toml b/pyproject.toml index 20a8ec1..654d07e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,19 +22,20 @@ dependencies = [ "matplotlib", "meegkit", "mne-bids>=0.18", - "mne-denoise", + "mne-denoise>=0.0.3", # ZapLine n_select and adaptive mode (0.0.2 needs Python 3.12) "mne-faster", "mne-icalabel>=0.8", "mne>=1.13", - "nibabel", + "nibabel", # not imported directly: MNE reads fsaverage MRIs with it (plots) "numpy", "onnxruntime", - "pandas", - "pybv", + "Pillow", + "pybv", # not imported directly: MNE exports BrainVision (BIDS) with it "pyprep>=0.8", "python-picard", "pyxdf", - "scikit-learn", + "scikit-learn", # not imported directly: MNE's ICA (fastica) and covariances + "scipy", ] description = "MoBI data loading, preprocessing, and visualization built on MNE-Python" dynamic = ["version"] @@ -67,8 +68,11 @@ docs = [ # (the default). Optional: compute_ica falls back to picard with a warning # when this is not installed. jamica = ["jamica[jax]>=0.3"] +# Nicer ICLabel probability plots in the HTML report (falls back to +# matplotlib without it) +report = ["seaborn"] # Dependencies for running the test infrastructure -test = ["pybemobil[jamica]", "pytest", "pytest-cov", "pytest-sugar", "pytest-xdist"] +test = ["pybemobil[jamica,report]", "pytest", "pytest-cov", "pytest-sugar", "pytest-xdist"] [project.urls] "Bug Tracker" = "https://github.com/BeMoBIL/pybemobil/issues/" @@ -77,10 +81,13 @@ test = ["pybemobil[jamica]", "pytest", "pytest-cov", "pytest-sugar", "pytest-xdi [tool.hatch.build] exclude = [ - "**/tests", "/.*", ] +[tool.hatch.build.targets.sdist] +# The sdist carries the tests so that downstream packagers can run them. +include = ["/pybemobil", "/tests", "/doc", "/CITATION.cff", "/README.md", "/LICENSE"] + [tool.hatch.build.targets.wheel] packages = ["pybemobil"] diff --git a/tests/test_encoder_display.py b/tests/test_encoder_display.py new file mode 100644 index 0000000..5024585 --- /dev/null +++ b/tests/test_encoder_display.py @@ -0,0 +1,48 @@ +"""NumpyEncoder handles numpy scalars; the report's display check per OS.""" + +import importlib +import json + +import numpy as np +import pytest + +from pybemobil.io.utils import NumpyEncoder + +# pybemobil.preproc.make_report is shadowed by the function of that name +mr = importlib.import_module("pybemobil.preproc.make_report") + + +def test_numpy_encoder_scalars_and_arrays(): + """Integers, floats, bools and arrays become native JSON values.""" + data = { + "n": np.int64(3), + "x": np.float32(0.5), + "ok": np.bool_(True), + "a": np.arange(3), + } + assert json.loads(json.dumps(data, cls=NumpyEncoder)) == { + "n": 3, + "x": 0.5, + "ok": True, + "a": [0, 1, 2], + } + + +@pytest.mark.parametrize( + "platform, env, expected", + [ + ("darwin", {}, True), + ("win32", {}, True), + ("linux", {}, False), + ("linux", {"DISPLAY": ":0"}, True), + ("linux", {"WAYLAND_DISPLAY": "wayland-0"}, True), + ], +) +def test_gui_available(monkeypatch, platform, env, expected): + """Windows and macOS have a display without DISPLAY; Linux needs one set.""" + monkeypatch.setattr(mr.sys, "platform", platform) + monkeypatch.delenv("DISPLAY", raising=False) + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) + for key, value in env.items(): + monkeypatch.setenv(key, value) + assert mr._gui_available() is expected