Skip to content
Merged
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
5 changes: 3 additions & 2 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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: |
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
8 changes: 4 additions & 4 deletions pybemobil/io/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
4 changes: 0 additions & 4 deletions pybemobil/preproc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@
)
from pybemobil.preproc.utils import (
StepTimer,
_annotate_break_iter,
_handle_trans,
append_desc,
auto_coreg_fsaverage,
build_sys_info,
Expand Down Expand Up @@ -67,8 +65,6 @@
# Utilities
"format_duration",
"build_sys_info",
"_annotate_break_iter",
"_handle_trans",
# Epoching
"get_stimulus_rename_map",
]
45 changes: 13 additions & 32 deletions pybemobil/preproc/make_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import logging
import os
import re
import sys
import tempfile
import warnings
from pathlib import Path
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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")

Expand Down
6 changes: 3 additions & 3 deletions pybemobil/viz/plotting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------
Expand All @@ -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))
Expand Down
20 changes: 0 additions & 20 deletions pybemobil/viz/utils.py

This file was deleted.

21 changes: 14 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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/"
Expand All @@ -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"]

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