From 29341d3cee383d8ef0c03726e80ca5ba5ec2fd85 Mon Sep 17 00:00:00 2001 From: "mne[bot]" <50266005+mne-bot@users.noreply.github.com> Date: Mon, 26 Jan 2026 19:40:33 +0000 Subject: [PATCH 01/18] mne[bot]: Update dependency specifiers --- environment.yml | 6 +++--- pyproject.toml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/environment.yml b/environment.yml index e8e3d01f688..6caa1234d67 100644 --- a/environment.yml +++ b/environment.yml @@ -36,7 +36,7 @@ dependencies: - numpy >=1.26,<3 - openmeeg >=2.5.7 - packaging - - pandas >=2.1 + - pandas >=2.2 - pillow - pip - pooch >=1.5 @@ -50,8 +50,8 @@ dependencies: - pyvistaqt >=0.11 - qdarkstyle !=3.2.2 - qtpy - - scikit-learn >=1.3 - - scipy >=1.11 + - scikit-learn >=1.4 + - scipy >=1.12 - sip - snirf - statsmodels diff --git a/pyproject.toml b/pyproject.toml index 08d85f5cea4..f4498eadee8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,7 +97,7 @@ dependencies = [ "numpy >= 1.26, < 3", # released 2023-09-16, will become 2.0 on 2026-06-16 "packaging", "pooch >= 1.5", - "scipy >= 1.11", # released 2023-06-28, will become 1.12 on 2026-01-19 + "scipy >= 1.12", # released 2024-01-20, will become 1.13 on 2026-04-02 "tqdm", ] description = "MNE-Python project for MEG and EEG data analysis." @@ -155,7 +155,7 @@ full-no-qt = [ "nilearn", "numba", "openmeeg >= 2.5.7", - "pandas >= 2.1", # released 2023-08-30, will become 2.2 on 2026-01-19 + "pandas >= 2.2", # released 2024-01-20, will become 2.3 on 2027-06-05 "pillow", # for `Brain.save_image` and `mne.Report` "pyarrow", # only needed to avoid a deprecation warning in pandas "pybv", @@ -165,7 +165,7 @@ full-no-qt = [ "pyvistaqt >= 0.11", # released 2023-06-30, no newer version available "qdarkstyle != 3.2.2", "qtpy", - "scikit-learn >= 1.3", # released 2023-06-30, will become 1.4 on 2026-01-17 + "scikit-learn >= 1.4", # released 2024-01-18, will become 1.5 on 2026-05-21 "sip", "snirf", "statsmodels", From 936d04b59cffe95242824691e5346204a25cb1af Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 26 Jan 2026 21:05:39 -0500 Subject: [PATCH 02/18] WIP: Should fail [skip azp] [skip circle] --- .github/workflows/tests.yml | 23 ++--------------------- tools/environment_old.yml | 2 +- tools/github_actions_check_old.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 22 deletions(-) create mode 100644 tools/github_actions_check_old.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 49bc3ed44c9..0cec9278ae4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -67,27 +67,6 @@ jobs: fail-fast: false matrix: include: - - os: ubuntu-latest - python: '3.13' - kind: pip - - os: ubuntu-latest - python: '3.14' - kind: pip-pre - - os: ubuntu-latest - python: '3.13' - kind: conda - - os: macos-latest # arm64 (Apple Silicon): Sequoia - python: '3.13' - kind: mamba - - os: macos-15-intel # intel: Sequoia - python: '3.13' - kind: mamba - - os: windows-latest - python: '3.11' - kind: mamba - - os: ubuntu-latest - python: '3.12' - kind: minimal - os: ubuntu-22.04 python: '3.10' kind: old @@ -154,6 +133,8 @@ jobs: if: ${{ !startswith(matrix.kind, 'pip') }} timeout-minutes: 20 - run: bash ./tools/github_actions_dependencies.sh + - run: python ./tools/github_actions_check_old.py + if: matrix.kind == 'old' # Minimal commands on Linux (macOS stalls) - run: bash ./tools/get_minimal_commands.sh if: startswith(matrix.os, 'ubuntu') && matrix.kind != 'minimal' && matrix.kind != 'old' diff --git a/tools/environment_old.yml b/tools/environment_old.yml index 3b99b93afde..2fb1cfdd3a7 100644 --- a/tools/environment_old.yml +++ b/tools/environment_old.yml @@ -8,7 +8,7 @@ dependencies: - scipy =1.11 - matplotlib =3.7 - pandas =2.0 - - scikit-learn =1.3.0 + - scikit-learn =1.3 - nibabel - tqdm - pooch =1.5 diff --git a/tools/github_actions_check_old.py b/tools/github_actions_check_old.py new file mode 100644 index 00000000000..7757156226f --- /dev/null +++ b/tools/github_actions_check_old.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python + +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +import importlib +import re +import sys +from pathlib import Path + +want_parts = 7 # should be updated when we add more pins! +regex = re.compile(r"^ - ([a-zA-Z\-]+) =([0-9.]+)$", re.MULTILINE) +this_root = Path(__file__).parent +env_old_text = (this_root / "environment_old.yml").read_text("utf-8") +parts = regex.findall(env_old_text) +assert len(parts) == want_parts, f"{len(parts)=} != {want_parts=}" +bad = list() +mod_name_map = { + "scikit-learn": "sklearn", +} +for mod_name, want_ver in parts: + if mod_name == "python": + got_ver = ".".join(map(str, sys.version_info[:2])) + else: + mod = importlib.import_module(mod_name_map.get(mod_name, mod_name)) + got_ver = mod.__version__.lstrip("v") # pooch prepends v + if ".".join(got_ver.split(".")[:2]) != want_ver: + bad.append(f"{mod_name}: {got_ver} != {want_ver}") +if bad: + raise RuntimeError("At least one module is the wrong version:\n" + "\n".join(bad)) From b300f4060cdd7f9c9aca55c9bbf371ed48ad79bb Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 26 Jan 2026 21:11:39 -0500 Subject: [PATCH 03/18] FIX: More [skip azp] [skip circle] --- tools/github_actions_check_old.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tools/github_actions_check_old.py b/tools/github_actions_check_old.py index 7757156226f..d1dc5550beb 100644 --- a/tools/github_actions_check_old.py +++ b/tools/github_actions_check_old.py @@ -23,7 +23,11 @@ if mod_name == "python": got_ver = ".".join(map(str, sys.version_info[:2])) else: - mod = importlib.import_module(mod_name_map.get(mod_name, mod_name)) + try: + mod = importlib.import_module(mod_name_map.get(mod_name, mod_name)) + except Exception as exc: + bad.append(f"{mod_name}: not importable ({type(exc).__name__}: {exc})") + continue got_ver = mod.__version__.lstrip("v") # pooch prepends v if ".".join(got_ver.split(".")[:2]) != want_ver: bad.append(f"{mod_name}: {got_ver} != {want_ver}") From e167eebef5e135faac6b7c01b9e75ae7e64bdb71 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 26 Jan 2026 21:15:56 -0500 Subject: [PATCH 04/18] FIX: Better [skip azp] [skip circle] --- tools/github_actions_dependencies.sh | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tools/github_actions_dependencies.sh b/tools/github_actions_dependencies.sh index ffe75afca44..e78bdffd277 100755 --- a/tools/github_actions_dependencies.sh +++ b/tools/github_actions_dependencies.sh @@ -16,10 +16,8 @@ if [ ! -z "$CONDA_ENV" ]; then if [[ "${RUNNER_OS}" != "Windows" ]] && [[ "${CONDA_ENV}" != "environment_"* ]]; then INSTALL_ARGS="" fi - # TODO: Until a PyVista release supports VTK 9.5+ - STD_ARGS="$STD_ARGS https://github.com/pyvista/pyvista/archive/refs/heads/main.zip" # If on minimal or old, just install testing deps - if [[ "${CONDA_ENV}" == "environment_"* ]]; then + if [[ "${CONDA_ENV}" == *'environment_'* ]]; then GROUP="test" EXTRAS="" STD_ARGS="--progress-bar off" From 1ea4168e06311e5a2746fab1e7325dbfd7adb834 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 26 Jan 2026 21:20:45 -0500 Subject: [PATCH 05/18] FIX: Maybe [skip azp] [skip circle] --- tools/github_actions_dependencies.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/github_actions_dependencies.sh b/tools/github_actions_dependencies.sh index e78bdffd277..1b80a6dab2e 100755 --- a/tools/github_actions_dependencies.sh +++ b/tools/github_actions_dependencies.sh @@ -37,7 +37,9 @@ else fi echo "" # until quantities releases... -STD_ARGS="$STD_ARGS git+https://github.com/python-quantities/python-quantities" +if [[ "${MNE_CI_KIND}" != "old" ]]; then + STD_ARGS="$STD_ARGS git+https://github.com/python-quantities/python-quantities" +fi echo "::group::Installing test dependencies using pip" set -x From 8e210a69a6dc7547b61d7b3fcfa647ca6bcaf02b Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 26 Jan 2026 21:24:23 -0500 Subject: [PATCH 06/18] FIX: Versions [skip azp] [skip circle] --- tools/environment_old.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/environment_old.yml b/tools/environment_old.yml index 2fb1cfdd3a7..9db84301747 100644 --- a/tools/environment_old.yml +++ b/tools/environment_old.yml @@ -5,10 +5,10 @@ channels: dependencies: - python =3.10 - numpy =1.25 - - scipy =1.11 + - scipy =1.12 - matplotlib =3.7 - - pandas =2.0 - - scikit-learn =1.3 + - pandas =2.2 + - scikit-learn =1.4 - nibabel - tqdm - pooch =1.5 From 608b4c11408c2dc89c248ce55f416f8660528279 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 26 Jan 2026 21:29:14 -0500 Subject: [PATCH 07/18] FIX: More [skip azp] [skip circle] --- .github/workflows/spec_zero.yml | 2 +- tools/environment_old.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/spec_zero.yml b/.github/workflows/spec_zero.yml index aafac8ff1da..75786eb8510 100644 --- a/.github/workflows/spec_zero.yml +++ b/.github/workflows/spec_zero.yml @@ -66,6 +66,6 @@ jobs: git checkout -b spec_zero git commit -am "mne[bot]: Update dependency specifiers" git push origin spec_zero - PR_NUM=$(gh pr create --base main --head spec_zero --title "MAINT: Update dependency specifiers" --body "Created by spec_zero [GitHub action](https://github.com/mne-tools/mne-python/actions/runs/${{ github.run_id }}).

*Adjustments may need to be made to shims in \`mne/fixes.py\` in this or another PR. \`git grep TODO VERSION\` is a good starting point for finding potential updates.*" --label "no-changelog-entry-needed") + PR_NUM=$(gh pr create --base main --head spec_zero --title "MAINT: Update dependency specifiers" --body "Created by spec_zero [GitHub action](https://github.com/mne-tools/mne-python/actions/runs/${{ github.run_id }}).

*It is very likely that `tools/environment_old.yml` needs to be updated.*

*Adjustments may need to be made to shims in \`mne/fixes.py\` and elswhere in this or another PR. \`git grep TODO VERSION\` is a good starting point for finding potential updates.*" --label "no-changelog-entry-needed") echo "Opened https://github.com/mne-tools/mne-python/pull/${PR_NUM}" >> $GITHUB_STEP_SUMMARY if: steps.status.outputs.dirty == 'true' diff --git a/tools/environment_old.yml b/tools/environment_old.yml index 9db84301747..6a9430496cb 100644 --- a/tools/environment_old.yml +++ b/tools/environment_old.yml @@ -4,9 +4,9 @@ channels: - conda-forge dependencies: - python =3.10 - - numpy =1.25 + - numpy =1.26 - scipy =1.12 - - matplotlib =3.7 + - matplotlib =3.8 - pandas =2.2 - scikit-learn =1.4 - nibabel From 714caba544db36e33905ae41306b8c3604b622fe Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 26 Jan 2026 21:38:22 -0500 Subject: [PATCH 08/18] FIX: Working? --- mne/decoding/_fixes.py | 153 +-------------------- mne/decoding/base.py | 3 +- mne/decoding/receptive_field.py | 3 +- mne/decoding/tests/test_csp.py | 1 - mne/decoding/tests/test_ems.py | 1 - mne/decoding/tests/test_receptive_field.py | 2 - mne/decoding/tests/test_ssd.py | 1 - mne/decoding/tests/test_time_frequency.py | 1 - mne/decoding/tests/test_transformer.py | 1 - mne/decoding/time_delaying_ridge.py | 4 +- mne/decoding/transformer.py | 3 +- 11 files changed, 7 insertions(+), 166 deletions(-) diff --git a/mne/decoding/_fixes.py b/mne/decoding/_fixes.py index f0f7689bc75..d75715d1203 100644 --- a/mne/decoding/_fixes.py +++ b/mne/decoding/_fixes.py @@ -2,159 +2,8 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. -try: - from sklearn.utils.validation import validate_data -except ImportError: - from sklearn.utils.validation import check_array, check_X_y - # Use a limited version pulled from sklearn 1.7 - def validate_data( - _estimator, - /, - X="no_validation", - y="no_validation", - reset=True, - validate_separately=False, - skip_check_array=False, - **check_params, - ): - """Validate input data and set or check feature names and counts of the input. - - This helper function should be used in an estimator that requires input - validation. This mutates the estimator and sets the `n_features_in_` and - `feature_names_in_` attributes if `reset=True`. - - .. versionadded:: 1.6 - - Parameters - ---------- - _estimator : estimator instance - The estimator to validate the input for. - - X : {array-like, sparse matrix, dataframe} of shape \ - (n_samples, n_features), default='no validation' - The input samples. - If `'no_validation'`, no validation is performed on `X`. This is - useful for meta-estimator which can delegate input validation to - their underlying estimator(s). In that case `y` must be passed and - the only accepted `check_params` are `multi_output` and - `y_numeric`. - - y : array-like of shape (n_samples,), default='no_validation' - The targets. - - - If `None`, :func:`~sklearn.utils.check_array` is called on `X`. If - the estimator's `requires_y` tag is True, then an error will be raised. - - If `'no_validation'`, :func:`~sklearn.utils.check_array` is called - on `X` and the estimator's `requires_y` tag is ignored. This is a default - placeholder and is never meant to be explicitly set. In that case `X` must - be passed. - - Otherwise, only `y` with `_check_y` or both `X` and `y` are checked with - either :func:`~sklearn.utils.check_array` or - :func:`~sklearn.utils.check_X_y` depending on `validate_separately`. - - reset : bool, default=True - Whether to reset the `n_features_in_` attribute. - If False, the input will be checked for consistency with data - provided when reset was last True. - - .. note:: - - It is recommended to call `reset=True` in `fit` and in the first - call to `partial_fit`. All other methods that validate `X` - should set `reset=False`. - - validate_separately : False or tuple of dicts, default=False - Only used if `y` is not `None`. - If `False`, call :func:`~sklearn.utils.check_X_y`. Else, it must be a tuple - of kwargs to be used for calling :func:`~sklearn.utils.check_array` on `X` - and `y` respectively. - - `estimator=self` is automatically added to these dicts to generate - more informative error message in case of invalid input data. - - skip_check_array : bool, default=False - If `True`, `X` and `y` are unchanged and only `feature_names_in_` and - `n_features_in_` are checked. Otherwise, :func:`~sklearn.utils.check_array` - is called on `X` and `y`. - - **check_params : kwargs - Parameters passed to :func:`~sklearn.utils.check_array` or - :func:`~sklearn.utils.check_X_y`. Ignored if validate_separately - is not False. - - `estimator=self` is automatically added to these params to generate - more informative error message in case of invalid input data. - - Returns - ------- - out : {ndarray, sparse matrix} or tuple of these - The validated input. A tuple is returned if both `X` and `y` are - validated. - """ - no_val_X = isinstance(X, str) and X == "no_validation" - no_val_y = y is None or (isinstance(y, str) and y == "no_validation") - - if no_val_X and no_val_y: - raise ValueError("Validation should be done on X, y or both.") - - default_check_params = {"estimator": _estimator} - check_params = {**default_check_params, **check_params} - - if skip_check_array: - if not no_val_X and no_val_y: - out = X - elif no_val_X and not no_val_y: - out = y - else: - out = X, y - elif not no_val_X and no_val_y: - out = check_array(X, input_name="X", **check_params) - elif no_val_X and not no_val_y: - out = check_array(y, input_name="y", **check_params) - else: - if validate_separately: - # We need this because some estimators validate X and y - # separately, and in general, separately calling check_array() - # on X and y isn't equivalent to just calling check_X_y() - # :( - check_X_params, check_y_params = validate_separately - if "estimator" not in check_X_params: - check_X_params = {**default_check_params, **check_X_params} - X = check_array(X, input_name="X", **check_X_params) - if "estimator" not in check_y_params: - check_y_params = {**default_check_params, **check_y_params} - y = check_array(y, input_name="y", **check_y_params) - else: - X, y = check_X_y(X, y, **check_params) - out = X, y - - return out - - -def _check_n_features_3d(estimator, X, reset): - """Set the `n_features_in_` attribute, or check against it on an estimator. - - Sklearn takes n_features from X.shape[1], but we need X.shape[-1] - - Parameters - ---------- - estimator : estimator instance - The estimator to validate the input for. - - X : {ndarray, sparse matrix} of shape ([n_epochs], n_samples, n_features) - The input samples. - - reset : bool - If True, the `n_features_in_` attribute is set to `X.shape[1]`. - If False and the attribute exists, then check that it is equal to - `X.shape[1]`. If False and the attribute does *not* exist, then - the check is skipped. - .. note:: - It is recommended to call reset=True in `fit` and in the first - call to `partial_fit`. All other methods that validate `X` - should set `reset=False`. - """ +def _check_n_features_3d(estimator, X, reset): # pragma: no cover n_features = X.shape[-1] if reset: estimator.n_features_in_ = n_features diff --git a/mne/decoding/base.py b/mne/decoding/base.py index 3a51a04bed7..577d8765a56 100644 --- a/mne/decoding/base.py +++ b/mne/decoding/base.py @@ -23,7 +23,7 @@ from sklearn.metrics import check_scoring from sklearn.model_selection import KFold, StratifiedKFold, check_cv from sklearn.utils import indexable -from sklearn.utils.validation import check_is_fitted +from sklearn.utils.validation import check_is_fitted, validate_data from ..parallel import parallel_func from ..utils import ( @@ -35,7 +35,6 @@ verbose, warn, ) -from ._fixes import validate_data from ._ged import ( _handle_restr_mat, _is_cov_pos_semidef, diff --git a/mne/decoding/receptive_field.py b/mne/decoding/receptive_field.py index 7b8fb63dfd3..e213c4fe5d6 100644 --- a/mne/decoding/receptive_field.py +++ b/mne/decoding/receptive_field.py @@ -14,10 +14,11 @@ ) from sklearn.exceptions import NotFittedError from sklearn.metrics import r2_score +from sklearn.utils.validation import validate_data from ..fixes import _reshape_view from ..utils import _validate_type, fill_doc, pinv -from ._fixes import _check_n_features_3d, validate_data +from ._fixes import _check_n_features_3d from .base import _check_estimator, get_coef from .time_delaying_ridge import TimeDelayingRidge diff --git a/mne/decoding/tests/test_csp.py b/mne/decoding/tests/test_csp.py index 4411267b407..9c2720956a3 100644 --- a/mne/decoding/tests/test_csp.py +++ b/mne/decoding/tests/test_csp.py @@ -495,5 +495,4 @@ def test_csp_component_ordering(): @parametrize_with_checks([CSP(), SPoC()]) def test_sklearn_compliance(estimator, check): """Test compliance with sklearn.""" - pytest.importorskip("sklearn", minversion="1.4") # TODO VERSION remove on 1.4+ check(estimator) diff --git a/mne/decoding/tests/test_ems.py b/mne/decoding/tests/test_ems.py index c713e1bce17..dc54303a541 100644 --- a/mne/decoding/tests/test_ems.py +++ b/mne/decoding/tests/test_ems.py @@ -97,5 +97,4 @@ def test_ems(): @parametrize_with_checks([EMS()]) def test_sklearn_compliance(estimator, check): """Test compliance with sklearn.""" - pytest.importorskip("sklearn", minversion="1.4") # TODO VERSION remove on 1.4+ check(estimator) diff --git a/mne/decoding/tests/test_receptive_field.py b/mne/decoding/tests/test_receptive_field.py index b9bf9693bd8..fd95408e25c 100644 --- a/mne/decoding/tests/test_receptive_field.py +++ b/mne/decoding/tests/test_receptive_field.py @@ -590,7 +590,6 @@ def test_linalg_warning(): @parametrize_with_checks([TimeDelayingRidge(0, 10, 1.0, 0.1, "laplacian", n_jobs=1)]) def test_tdr_sklearn_compliance(estimator, check): """Test sklearn estimator compliance.""" - pytest.importorskip("sklearn", minversion="1.4") # TODO VERSION remove on 1.4+ ignores = ( # TDR convolves and thus its output cannot be invariant when # shuffled or subsampled. @@ -606,7 +605,6 @@ def test_tdr_sklearn_compliance(estimator, check): @parametrize_with_checks([ReceptiveField(-1, 2, 1.0, estimator=Ridge(), patterns=True)]) def test_rf_sklearn_compliance(estimator, check): """Test sklearn RF compliance.""" - pytest.importorskip("sklearn", minversion="1.4") # TODO VERSION remove on 1.4+ ignores = ( # RF does time-lagging, so its output cannot be invariant when # shuffled or subsampled. diff --git a/mne/decoding/tests/test_ssd.py b/mne/decoding/tests/test_ssd.py index 236e65b82fd..eba25a607d2 100644 --- a/mne/decoding/tests/test_ssd.py +++ b/mne/decoding/tests/test_ssd.py @@ -621,7 +621,6 @@ def test_get_spectral_ratio(): ) def test_sklearn_compliance(estimator, check): """Test LinearModel compliance with sklearn.""" - pytest.importorskip("sklearn", minversion="1.4") # TODO VERSION remove on 1.4+ ignores = ( # Checks below fail because what sklearn passes as (n_samples, n_features) # is considered (n_channels, n_times) by SSD and creates problems diff --git a/mne/decoding/tests/test_time_frequency.py b/mne/decoding/tests/test_time_frequency.py index 1ac6bba5dcb..638cebda21e 100644 --- a/mne/decoding/tests/test_time_frequency.py +++ b/mne/decoding/tests/test_time_frequency.py @@ -57,5 +57,4 @@ def test_timefrequency_basic(): @parametrize_with_checks([TimeFrequency([300, 400], 1000.0, n_cycles=0.25)]) def test_sklearn_compliance(estimator, check): """Test LinearModel compliance with sklearn.""" - pytest.importorskip("sklearn", minversion="1.4") # TODO VERSION remove on 1.4+ check(estimator) diff --git a/mne/decoding/tests/test_transformer.py b/mne/decoding/tests/test_transformer.py index 1911aa650e5..54d1a3c1c28 100644 --- a/mne/decoding/tests/test_transformer.py +++ b/mne/decoding/tests/test_transformer.py @@ -339,7 +339,6 @@ def test_bad_triage(): ) def test_sklearn_compliance(estimator, check): """Test LinearModel compliance with sklearn.""" - pytest.importorskip("sklearn", minversion="1.4") # TODO VERSION remove on 1.4+ ignores = [] if estimator.__class__.__name__ == "FilterEstimator": ignores += [ diff --git a/mne/decoding/time_delaying_ridge.py b/mne/decoding/time_delaying_ridge.py index 6a754ed361d..b52f3aed87c 100644 --- a/mne/decoding/time_delaying_ridge.py +++ b/mne/decoding/time_delaying_ridge.py @@ -9,13 +9,13 @@ from scipy.signal import fftconvolve from scipy.sparse.csgraph import laplacian from sklearn.base import BaseEstimator, RegressorMixin -from sklearn.utils.validation import check_is_fitted +from sklearn.utils.validation import check_is_fitted, validate_data from ..cuda import _setup_cuda_fft_multiply_repeated from ..filter import next_fast_len from ..fixes import jit from ..utils import ProgressBar, _check_option, logger, warn -from ._fixes import _check_n_features_3d, validate_data +from ._fixes import _check_n_features_3d def _compute_corrs( diff --git a/mne/decoding/transformer.py b/mne/decoding/transformer.py index c5fd14d9568..240ccf66ca5 100644 --- a/mne/decoding/transformer.py +++ b/mne/decoding/transformer.py @@ -6,7 +6,7 @@ from sklearn.base import BaseEstimator, TransformerMixin, check_array, clone from sklearn.preprocessing import RobustScaler, StandardScaler from sklearn.utils import check_X_y -from sklearn.utils.validation import check_is_fitted +from sklearn.utils.validation import check_is_fitted, validate_data from .._fiff.pick import ( _pick_data_channels, @@ -20,7 +20,6 @@ from ..fixes import _reshape_view from ..time_frequency import psd_array_multitaper from ..utils import _check_option, _validate_type, check_version, fill_doc -from ._fixes import validate_data # TODO VERSION remove with sklearn 1.4+ class MNETransformerMixin(TransformerMixin): From 751d740e9bbeb0609dabef05be10abe9c04612fc Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 26 Jan 2026 21:38:51 -0500 Subject: [PATCH 09/18] FIX: Restore --- .github/workflows/tests.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0cec9278ae4..14c79460e2f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -67,6 +67,27 @@ jobs: fail-fast: false matrix: include: + - os: ubuntu-latest + python: '3.13' + kind: pip + - os: ubuntu-latest + python: '3.14' + kind: pip-pre + - os: ubuntu-latest + python: '3.13' + kind: conda + - os: macos-latest # arm64 (Apple Silicon): Sequoia + python: '3.13' + kind: mamba + - os: macos-15-intel # intel: Sequoia + python: '3.13' + kind: mamba + - os: windows-latest + python: '3.11' + kind: mamba + - os: ubuntu-latest + python: '3.12' + kind: minimal - os: ubuntu-22.04 python: '3.10' kind: old From 18880ab502c4d024a61a307ed5535c0806a48120 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 26 Jan 2026 21:59:10 -0500 Subject: [PATCH 10/18] FIX: Skip --- mne/tests/test_annotations.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mne/tests/test_annotations.py b/mne/tests/test_annotations.py index 596e37d5ce3..b67c8774cf0 100644 --- a/mne/tests/test_annotations.py +++ b/mne/tests/test_annotations.py @@ -1051,6 +1051,8 @@ def dummy_annotation_file(tmp_path_factory, ch_names, fmt, with_extras): @pytest.mark.parametrize("with_extras", [True, False]) def test_io_annotation(dummy_annotation_file, tmp_path, fmt, ch_names, with_extras): """Test CSV, TXT, and FIF input/output (which support ch_names).""" + if with_extras: + pytest.importorskip("pandas") annot = read_annotations(dummy_annotation_file) assert annot.orig_time == _ORIG_TIME kwargs = dict(orig_time=_ORIG_TIME) From 8f6628467cb1c30a1b300b9e829f6e3976881fa7 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 27 Jan 2026 11:41:32 -0500 Subject: [PATCH 11/18] FIX: Better --- .github/workflows/spec_zero.yml | 3 +- doc/changes/dev/13611.dependency.rst | 6 + mne/decoding/_fixes.py | 154 +++++++++++++++++- mne/decoding/base.py | 3 +- mne/decoding/receptive_field.py | 3 +- mne/decoding/time_delaying_ridge.py | 4 +- mne/decoding/transformer.py | 3 +- .../nirs/tests/test_beer_lambert_law.py | 2 + mne/preprocessing/nirs/tests/test_nirs.py | 16 +- mne/tests/test_chpi.py | 4 + tools/dev/spec_zero_update_versions.py | 51 ++++-- 11 files changed, 225 insertions(+), 24 deletions(-) create mode 100644 doc/changes/dev/13611.dependency.rst diff --git a/.github/workflows/spec_zero.yml b/.github/workflows/spec_zero.yml index 75786eb8510..db30bbb15a1 100644 --- a/.github/workflows/spec_zero.yml +++ b/.github/workflows/spec_zero.yml @@ -64,7 +64,8 @@ jobs: git config --global user.email "50266005+mne-bot@users.noreply.github.com" git config --global user.name "mne[bot]" git checkout -b spec_zero - git commit -am "mne[bot]: Update dependency specifiers" + git add doc/changes/dev/dependency.rst pyproject.toml + git commit -m "mne[bot]: Update dependency specifiers" git push origin spec_zero PR_NUM=$(gh pr create --base main --head spec_zero --title "MAINT: Update dependency specifiers" --body "Created by spec_zero [GitHub action](https://github.com/mne-tools/mne-python/actions/runs/${{ github.run_id }}).

*It is very likely that `tools/environment_old.yml` needs to be updated.*

*Adjustments may need to be made to shims in \`mne/fixes.py\` and elswhere in this or another PR. \`git grep TODO VERSION\` is a good starting point for finding potential updates.*" --label "no-changelog-entry-needed") echo "Opened https://github.com/mne-tools/mne-python/pull/${PR_NUM}" >> $GITHUB_STEP_SUMMARY diff --git a/doc/changes/dev/13611.dependency.rst b/doc/changes/dev/13611.dependency.rst new file mode 100644 index 00000000000..b382db209fd --- /dev/null +++ b/doc/changes/dev/13611.dependency.rst @@ -0,0 +1,6 @@ +Updated minimum for: + +- Core dependency ``scipy >= 1.12`` +- Optional dependency ``pandas >= 2.2`` +- Optional dependency ``pyobjc-framework-Cocoa >= 5.2.0; platform_system == "Darwin"`` +- Optional dependency ``scikit-learn >= 1.4`` \ No newline at end of file diff --git a/mne/decoding/_fixes.py b/mne/decoding/_fixes.py index d75715d1203..0a71721279e 100644 --- a/mne/decoding/_fixes.py +++ b/mne/decoding/_fixes.py @@ -2,8 +2,160 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. +try: + # TODO VERSION remove once we require sklearn 1.6+ + from sklearn.utils.validation import validate_data +except ImportError: + from sklearn.utils.validation import check_array, check_X_y -def _check_n_features_3d(estimator, X, reset): # pragma: no cover + # Use a limited version pulled from sklearn 1.7 + def validate_data( + _estimator, + /, + X="no_validation", + y="no_validation", + reset=True, + validate_separately=False, + skip_check_array=False, + **check_params, + ): + """Validate input data and set or check feature names and counts of the input. + + This helper function should be used in an estimator that requires input + validation. This mutates the estimator and sets the `n_features_in_` and + `feature_names_in_` attributes if `reset=True`. + + .. versionadded:: 1.6 + + Parameters + ---------- + _estimator : estimator instance + The estimator to validate the input for. + + X : {array-like, sparse matrix, dataframe} of shape \ + (n_samples, n_features), default='no validation' + The input samples. + If `'no_validation'`, no validation is performed on `X`. This is + useful for meta-estimator which can delegate input validation to + their underlying estimator(s). In that case `y` must be passed and + the only accepted `check_params` are `multi_output` and + `y_numeric`. + + y : array-like of shape (n_samples,), default='no_validation' + The targets. + + - If `None`, :func:`~sklearn.utils.check_array` is called on `X`. If + the estimator's `requires_y` tag is True, then an error will be raised. + - If `'no_validation'`, :func:`~sklearn.utils.check_array` is called + on `X` and the estimator's `requires_y` tag is ignored. This is a default + placeholder and is never meant to be explicitly set. In that case `X` must + be passed. + - Otherwise, only `y` with `_check_y` or both `X` and `y` are checked with + either :func:`~sklearn.utils.check_array` or + :func:`~sklearn.utils.check_X_y` depending on `validate_separately`. + + reset : bool, default=True + Whether to reset the `n_features_in_` attribute. + If False, the input will be checked for consistency with data + provided when reset was last True. + + .. note:: + + It is recommended to call `reset=True` in `fit` and in the first + call to `partial_fit`. All other methods that validate `X` + should set `reset=False`. + + validate_separately : False or tuple of dicts, default=False + Only used if `y` is not `None`. + If `False`, call :func:`~sklearn.utils.check_X_y`. Else, it must be a tuple + of kwargs to be used for calling :func:`~sklearn.utils.check_array` on `X` + and `y` respectively. + + `estimator=self` is automatically added to these dicts to generate + more informative error message in case of invalid input data. + + skip_check_array : bool, default=False + If `True`, `X` and `y` are unchanged and only `feature_names_in_` and + `n_features_in_` are checked. Otherwise, :func:`~sklearn.utils.check_array` + is called on `X` and `y`. + + **check_params : kwargs + Parameters passed to :func:`~sklearn.utils.check_array` or + :func:`~sklearn.utils.check_X_y`. Ignored if validate_separately + is not False. + + `estimator=self` is automatically added to these params to generate + more informative error message in case of invalid input data. + + Returns + ------- + out : {ndarray, sparse matrix} or tuple of these + The validated input. A tuple is returned if both `X` and `y` are + validated. + """ + no_val_X = isinstance(X, str) and X == "no_validation" + no_val_y = y is None or (isinstance(y, str) and y == "no_validation") + + if no_val_X and no_val_y: + raise ValueError("Validation should be done on X, y or both.") + + default_check_params = {"estimator": _estimator} + check_params = {**default_check_params, **check_params} + + if skip_check_array: + if not no_val_X and no_val_y: + out = X + elif no_val_X and not no_val_y: + out = y + else: + out = X, y + elif not no_val_X and no_val_y: + out = check_array(X, input_name="X", **check_params) + elif no_val_X and not no_val_y: + out = check_array(y, input_name="y", **check_params) + else: + if validate_separately: + # We need this because some estimators validate X and y + # separately, and in general, separately calling check_array() + # on X and y isn't equivalent to just calling check_X_y() + # :( + check_X_params, check_y_params = validate_separately + if "estimator" not in check_X_params: + check_X_params = {**default_check_params, **check_X_params} + X = check_array(X, input_name="X", **check_X_params) + if "estimator" not in check_y_params: + check_y_params = {**default_check_params, **check_y_params} + y = check_array(y, input_name="y", **check_y_params) + else: + X, y = check_X_y(X, y, **check_params) + out = X, y + + return out + + +def _check_n_features_3d(estimator, X, reset): + """Set the `n_features_in_` attribute, or check against it on an estimator. + + Sklearn takes n_features from X.shape[1], but we need X.shape[-1] + + Parameters + ---------- + estimator : estimator instance + The estimator to validate the input for. + + X : {ndarray, sparse matrix} of shape ([n_epochs], n_samples, n_features) + The input samples. + + reset : bool + If True, the `n_features_in_` attribute is set to `X.shape[1]`. + If False and the attribute exists, then check that it is equal to + `X.shape[1]`. If False and the attribute does *not* exist, then + the check is skipped. + .. note:: + It is recommended to call reset=True in `fit` and in the first + call to `partial_fit`. All other methods that validate `X` + should set `reset=False`. + """ n_features = X.shape[-1] if reset: estimator.n_features_in_ = n_features diff --git a/mne/decoding/base.py b/mne/decoding/base.py index 577d8765a56..3a51a04bed7 100644 --- a/mne/decoding/base.py +++ b/mne/decoding/base.py @@ -23,7 +23,7 @@ from sklearn.metrics import check_scoring from sklearn.model_selection import KFold, StratifiedKFold, check_cv from sklearn.utils import indexable -from sklearn.utils.validation import check_is_fitted, validate_data +from sklearn.utils.validation import check_is_fitted from ..parallel import parallel_func from ..utils import ( @@ -35,6 +35,7 @@ verbose, warn, ) +from ._fixes import validate_data from ._ged import ( _handle_restr_mat, _is_cov_pos_semidef, diff --git a/mne/decoding/receptive_field.py b/mne/decoding/receptive_field.py index e213c4fe5d6..7b8fb63dfd3 100644 --- a/mne/decoding/receptive_field.py +++ b/mne/decoding/receptive_field.py @@ -14,11 +14,10 @@ ) from sklearn.exceptions import NotFittedError from sklearn.metrics import r2_score -from sklearn.utils.validation import validate_data from ..fixes import _reshape_view from ..utils import _validate_type, fill_doc, pinv -from ._fixes import _check_n_features_3d +from ._fixes import _check_n_features_3d, validate_data from .base import _check_estimator, get_coef from .time_delaying_ridge import TimeDelayingRidge diff --git a/mne/decoding/time_delaying_ridge.py b/mne/decoding/time_delaying_ridge.py index b52f3aed87c..6a754ed361d 100644 --- a/mne/decoding/time_delaying_ridge.py +++ b/mne/decoding/time_delaying_ridge.py @@ -9,13 +9,13 @@ from scipy.signal import fftconvolve from scipy.sparse.csgraph import laplacian from sklearn.base import BaseEstimator, RegressorMixin -from sklearn.utils.validation import check_is_fitted, validate_data +from sklearn.utils.validation import check_is_fitted from ..cuda import _setup_cuda_fft_multiply_repeated from ..filter import next_fast_len from ..fixes import jit from ..utils import ProgressBar, _check_option, logger, warn -from ._fixes import _check_n_features_3d +from ._fixes import _check_n_features_3d, validate_data def _compute_corrs( diff --git a/mne/decoding/transformer.py b/mne/decoding/transformer.py index 240ccf66ca5..7791265af0d 100644 --- a/mne/decoding/transformer.py +++ b/mne/decoding/transformer.py @@ -6,7 +6,7 @@ from sklearn.base import BaseEstimator, TransformerMixin, check_array, clone from sklearn.preprocessing import RobustScaler, StandardScaler from sklearn.utils import check_X_y -from sklearn.utils.validation import check_is_fitted, validate_data +from sklearn.utils.validation import check_is_fitted from .._fiff.pick import ( _pick_data_channels, @@ -20,6 +20,7 @@ from ..fixes import _reshape_view from ..time_frequency import psd_array_multitaper from ..utils import _check_option, _validate_type, check_version, fill_doc +from ._fixes import validate_data class MNETransformerMixin(TransformerMixin): diff --git a/mne/preprocessing/nirs/tests/test_beer_lambert_law.py b/mne/preprocessing/nirs/tests/test_beer_lambert_law.py index c889237bae9..4d949331f29 100644 --- a/mne/preprocessing/nirs/tests/test_beer_lambert_law.py +++ b/mne/preprocessing/nirs/tests/test_beer_lambert_law.py @@ -43,6 +43,8 @@ ) def test_beer_lambert(fname, fmt, tmp_path): """Test converting raw CW amplitude files.""" + if fname.suffix == ".snirf": + pytest.importorskip("h5py") match fmt: case "nirx": raw_volt = read_raw_nirx(fname) diff --git a/mne/preprocessing/nirs/tests/test_nirs.py b/mne/preprocessing/nirs/tests/test_nirs.py index 069657e2501..49578a58fd0 100644 --- a/mne/preprocessing/nirs/tests/test_nirs.py +++ b/mne/preprocessing/nirs/tests/test_nirs.py @@ -40,12 +40,18 @@ ) +def read_raw_snirf_safe(fname): + """Wrap to read_raw_snirf, skipping if h5py is not installed.""" + pytest.importorskip("h5py") + return read_raw_snirf(fname) + + @testing.requires_testing_data @pytest.mark.parametrize( "fname, readerfn", [ (fname_nirx_15_0, read_raw_nirx), - (fname_labnirs_multi_wavelength, read_raw_snirf), + (fname_labnirs_multi_wavelength, read_raw_snirf_safe), ], ) def test_fnirs_picks(fname, readerfn): @@ -121,7 +127,7 @@ def _fnirs_check_bads(info): (fname_nirx_15_0, read_raw_nirx), (fname_nirx_15_2_short, read_raw_nirx), (fname_nirx_15_2, read_raw_nirx), - (fname_labnirs_multi_wavelength, read_raw_snirf), + (fname_labnirs_multi_wavelength, read_raw_snirf_safe), ], ) def test_fnirs_check_bads(fname, readerfn): @@ -166,7 +172,7 @@ def test_fnirs_check_bads(fname, readerfn): (fname_nirx_15_0, read_raw_nirx), (fname_nirx_15_2_short, read_raw_nirx), (fname_nirx_15_2, read_raw_nirx), - (fname_labnirs_multi_wavelength, read_raw_snirf), + (fname_labnirs_multi_wavelength, read_raw_snirf_safe), ], ) def test_fnirs_spread_bads(fname, readerfn): @@ -210,7 +216,7 @@ def test_fnirs_spread_bads(fname, readerfn): (fname_nirx_15_0, read_raw_nirx), (fname_nirx_15_2_short, read_raw_nirx), (fname_nirx_15_2, read_raw_nirx), - (fname_labnirs_multi_wavelength, read_raw_snirf), + (fname_labnirs_multi_wavelength, read_raw_snirf_safe), ], ) def test_fnirs_channel_frequency_ordering(fname, readerfn): @@ -598,7 +604,7 @@ def test_order_agnostic(nirx_snirf): (fname_nirx_15_0, read_raw_nirx), (fname_nirx_15_2_short, read_raw_nirx), (fname_nirx_15_2, read_raw_nirx), - (fname_labnirs_multi_wavelength, read_raw_snirf), + (fname_labnirs_multi_wavelength, read_raw_snirf_safe), ], ) def test_nirs_channel_grouping(fname, readerfn): diff --git a/mne/tests/test_chpi.py b/mne/tests/test_chpi.py index ec0d9c3c70f..dbebeeb2eca 100644 --- a/mne/tests/test_chpi.py +++ b/mne/tests/test_chpi.py @@ -51,6 +51,7 @@ _record_warnings, assert_meg_snr, catch_logging, + check_version, object_diff, verbose, ) @@ -884,6 +885,9 @@ def assert_slopes_correlated(actual_meas, desired_meas, *, lim=(0.99, 1.0)): @testing.requires_testing_data def test_refit_hpi_locs_basic(): """Test that HPI locations can be refit.""" + if not check_version("scipy", "1.13"): + # TODO VERSION remove when scipy >= 1.13 is required + pytest.xfail("SciPy 1.12 has an lwork bug affecting this test") raw = read_raw_fif(chpi_fif_fname, allow_maxshield="yes").crop(0, 2).load_data() # These should be similar (and both should work) locs = compute_chpi_amplitudes(raw, t_step_min=2, t_window=1) diff --git a/tools/dev/spec_zero_update_versions.py b/tools/dev/spec_zero_update_versions.py index 4f2945e8a4e..bd06a3ea6ad 100644 --- a/tools/dev/spec_zero_update_versions.py +++ b/tools/dev/spec_zero_update_versions.py @@ -9,6 +9,7 @@ adopted. MNE-Python's policy differs from SPEC0 in the following ways: + - Python versions are supported for at least 3 years after release, but possibly longer at the discretion of the MNE-Python maintainers based on, e.g., maintainability, features. @@ -23,6 +24,7 @@ https://github.com/mne-tools/mne-python/pull/13451#discussion_r2445337934 For example, in October 2025: + - The latest version of NumPy available 2 years prior was 1.26.1 (released October 2023), making the latest minor release 1.26, which would be pinned. Support for 1.26 would be dropped in June 2026 in favour of 2.0, which was released in June 2024. @@ -39,6 +41,8 @@ import collections import datetime import re +from copy import deepcopy +from pathlib import Path import requests from packaging.requirements import Requirement @@ -60,6 +64,8 @@ SUPPORT_TIME = datetime.timedelta(days=365 * 2) CURRENT_DATE = datetime.datetime.now() +project_root = Path(__file__).parent.parent.parent + def get_release_and_drop_dates(package): """Get release and drop dates for a given package from pypi.org.""" @@ -70,7 +76,7 @@ def get_release_and_drop_dates(package): headers={"Accept": "application/vnd.pypi.simple.v1+json"}, timeout=10, ).json() - print("OK") + print("OK", flush=True) file_date = collections.defaultdict(list) for f in response["files"]: if f["filename"].endswith(".tar.gz") or f["filename"].endswith(".zip"): @@ -99,7 +105,7 @@ def get_release_and_drop_dates(package): def update_specifiers(dependencies, releases): - """Update dependency version specifiers.""" + """Update dependency version specifiers inplace.""" for idx, dep in enumerate(dependencies): req = Requirement(dep) pkg_name = req.name @@ -153,7 +159,6 @@ def update_specifiers(dependencies, releases): dependencies._value[idx], min_ver_release, next_ver, next_ver_release ) dependencies[idx] = _prettify_requirement(req) - return dependencies def _as_minor_version(ver): @@ -239,19 +244,43 @@ def _find_specifier_order(specifiers): } # Get dependencies from pyproject.toml -pyproject = TOMLFile("pyproject.toml") +pyproject = TOMLFile(project_root / "pyproject.toml") pyproject_data = pyproject.read() -project_info = pyproject_data.get("project") +project_info = pyproject_data["project"] core_dependencies = project_info["dependencies"] -opt_dependencies = project_info.get("optional-dependencies", {}) +opt_dependencies = project_info["optional-dependencies"] # Update version specifiers -core_dependencies = update_specifiers(core_dependencies, package_releases) +changed = [] +old_deps = deepcopy(core_dependencies) +update_specifiers(core_dependencies, package_releases) +changed.extend( + [ + f"Core dependency ``{new}``" + for new, old in zip(core_dependencies, old_deps) + if new != old + ] +) for key in opt_dependencies: - opt_dependencies[key] = update_specifiers(opt_dependencies[key], package_releases) -pyproject_data["project"]["dependencies"] = core_dependencies -if opt_dependencies: - pyproject_data["project"]["optional-dependencies"] = opt_dependencies + old_deps = deepcopy(opt_dependencies[key]) + update_specifiers(opt_dependencies[key], package_releases) + changed.extend( + [ + f"Optional dependency ``{new}``" + for new, old in zip(opt_dependencies[key], old_deps) + if new != old + ] + ) + +# Need to write a changelog entry if versions were updated +if changed: + changelog_text = "Updated minimum for:\n\n" + changelog_text += "\n".join(f"- {change}" for change in changed) + changelog_path = project_root / "doc" / "changes" / "dev" / "dependency.rst" + changelog_path.write_text(changelog_text, encoding="utf-8") + print(changelog_text, flush=True) +else: + print("No dependency versions needed updating.", flush=True) # Save updated pyproject.toml (replace ugly \" with ' first) pyproject_data = parse(pyproject_data.as_string().replace('\\"', "'")) From 3596cb3421ebc97899f913f14f1693ab6eddc4c6 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 27 Jan 2026 11:43:58 -0500 Subject: [PATCH 12/18] FIX: Better --- doc/changes/dev/13611.dependency.rst | 4 +++- tools/dev/spec_zero_update_versions.py | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/doc/changes/dev/13611.dependency.rst b/doc/changes/dev/13611.dependency.rst index b382db209fd..61fa8132670 100644 --- a/doc/changes/dev/13611.dependency.rst +++ b/doc/changes/dev/13611.dependency.rst @@ -3,4 +3,6 @@ Updated minimum for: - Core dependency ``scipy >= 1.12`` - Optional dependency ``pandas >= 2.2`` - Optional dependency ``pyobjc-framework-Cocoa >= 5.2.0; platform_system == "Darwin"`` -- Optional dependency ``scikit-learn >= 1.4`` \ No newline at end of file +- Optional dependency ``scikit-learn >= 1.4`` + +Changed implemented via CI action created by `Thomas Binns`_. \ No newline at end of file diff --git a/tools/dev/spec_zero_update_versions.py b/tools/dev/spec_zero_update_versions.py index bd06a3ea6ad..cd925dd3531 100644 --- a/tools/dev/spec_zero_update_versions.py +++ b/tools/dev/spec_zero_update_versions.py @@ -276,9 +276,13 @@ def _find_specifier_order(specifiers): if changed: changelog_text = "Updated minimum for:\n\n" changelog_text += "\n".join(f"- {change}" for change in changed) + print(changelog_text, flush=True) + # no reason to print this but it should go in the changelog + changelog_text += ( + "\n\nChanged implemented via CI action created by `Thomas Binns`_.\n" + ) changelog_path = project_root / "doc" / "changes" / "dev" / "dependency.rst" changelog_path.write_text(changelog_text, encoding="utf-8") - print(changelog_text, flush=True) else: print("No dependency versions needed updating.", flush=True) From 1bdaa8f1e54e5eca4665fc090d4543114730483d Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 27 Jan 2026 11:46:26 -0500 Subject: [PATCH 13/18] FIX: Remove label --- .github/workflows/spec_zero.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/spec_zero.yml b/.github/workflows/spec_zero.yml index db30bbb15a1..c768f84014e 100644 --- a/.github/workflows/spec_zero.yml +++ b/.github/workflows/spec_zero.yml @@ -67,6 +67,6 @@ jobs: git add doc/changes/dev/dependency.rst pyproject.toml git commit -m "mne[bot]: Update dependency specifiers" git push origin spec_zero - PR_NUM=$(gh pr create --base main --head spec_zero --title "MAINT: Update dependency specifiers" --body "Created by spec_zero [GitHub action](https://github.com/mne-tools/mne-python/actions/runs/${{ github.run_id }}).

*It is very likely that `tools/environment_old.yml` needs to be updated.*

*Adjustments may need to be made to shims in \`mne/fixes.py\` and elswhere in this or another PR. \`git grep TODO VERSION\` is a good starting point for finding potential updates.*" --label "no-changelog-entry-needed") + PR_NUM=$(gh pr create --base main --head spec_zero --title "MAINT: Update dependency specifiers" --body "Created by spec_zero [GitHub action](https://github.com/mne-tools/mne-python/actions/runs/${{ github.run_id }}).

*It is very likely that `tools/environment_old.yml` needs to be updated.*

*Adjustments may need to be made to shims in \`mne/fixes.py\` and elswhere in this or another PR. \`git grep TODO VERSION\` is a good starting point for finding potential updates.*") echo "Opened https://github.com/mne-tools/mne-python/pull/${PR_NUM}" >> $GITHUB_STEP_SUMMARY if: steps.status.outputs.dirty == 'true' From a3e0ffde8782e2ddedb9599fb9fef4840ee3c30f Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 27 Jan 2026 11:47:17 -0500 Subject: [PATCH 14/18] FIX: Whoops --- .github/workflows/spec_zero.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/spec_zero.yml b/.github/workflows/spec_zero.yml index c768f84014e..d76bd064561 100644 --- a/.github/workflows/spec_zero.yml +++ b/.github/workflows/spec_zero.yml @@ -65,7 +65,7 @@ jobs: git config --global user.name "mne[bot]" git checkout -b spec_zero git add doc/changes/dev/dependency.rst pyproject.toml - git commit -m "mne[bot]: Update dependency specifiers" + git commit -am "mne[bot]: Update dependency specifiers" git push origin spec_zero PR_NUM=$(gh pr create --base main --head spec_zero --title "MAINT: Update dependency specifiers" --body "Created by spec_zero [GitHub action](https://github.com/mne-tools/mne-python/actions/runs/${{ github.run_id }}).

*It is very likely that `tools/environment_old.yml` needs to be updated.*

*Adjustments may need to be made to shims in \`mne/fixes.py\` and elswhere in this or another PR. \`git grep TODO VERSION\` is a good starting point for finding potential updates.*") echo "Opened https://github.com/mne-tools/mne-python/pull/${PR_NUM}" >> $GITHUB_STEP_SUMMARY From dd4aaf2ff68da35079d8afaa4251b3ab80aef78c Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 27 Jan 2026 11:47:43 -0500 Subject: [PATCH 15/18] FIX: Why --- .github/workflows/spec_zero.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/spec_zero.yml b/.github/workflows/spec_zero.yml index d76bd064561..e3cd7a0b1c7 100644 --- a/.github/workflows/spec_zero.yml +++ b/.github/workflows/spec_zero.yml @@ -64,7 +64,7 @@ jobs: git config --global user.email "50266005+mne-bot@users.noreply.github.com" git config --global user.name "mne[bot]" git checkout -b spec_zero - git add doc/changes/dev/dependency.rst pyproject.toml + git add doc/changes/dev/dependency.rst # one new file, others changed git commit -am "mne[bot]: Update dependency specifiers" git push origin spec_zero PR_NUM=$(gh pr create --base main --head spec_zero --title "MAINT: Update dependency specifiers" --body "Created by spec_zero [GitHub action](https://github.com/mne-tools/mne-python/actions/runs/${{ github.run_id }}).

*It is very likely that `tools/environment_old.yml` needs to be updated.*

*Adjustments may need to be made to shims in \`mne/fixes.py\` and elswhere in this or another PR. \`git grep TODO VERSION\` is a good starting point for finding potential updates.*") From 842c29b4ad1b241f5cdc528a0db5249a69b5b2df Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 27 Jan 2026 12:25:59 -0500 Subject: [PATCH 16/18] FIX: Test --- mne/preprocessing/nirs/tests/test_optical_density.py | 2 ++ mne/preprocessing/nirs/tests/test_scalp_coupling_index.py | 1 + 2 files changed, 3 insertions(+) diff --git a/mne/preprocessing/nirs/tests/test_optical_density.py b/mne/preprocessing/nirs/tests/test_optical_density.py index 53b66f46238..cbb79f97b72 100644 --- a/mne/preprocessing/nirs/tests/test_optical_density.py +++ b/mne/preprocessing/nirs/tests/test_optical_density.py @@ -27,6 +27,8 @@ ) def test_optical_density(fname, readerfn): """Test return type for optical density.""" + if fname.suffix == ".snirf": + pytest.importorskip("h5py") raw_volt = readerfn(fname, preload=False) _validate_type(raw_volt, BaseRaw, "raw") diff --git a/mne/preprocessing/nirs/tests/test_scalp_coupling_index.py b/mne/preprocessing/nirs/tests/test_scalp_coupling_index.py index 832a1158486..a5089623477 100644 --- a/mne/preprocessing/nirs/tests/test_scalp_coupling_index.py +++ b/mne/preprocessing/nirs/tests/test_scalp_coupling_index.py @@ -85,6 +85,7 @@ def test_scalp_coupling_index_multi_wavelength(): Similar to test in test_scalp_coupling_index, considers cases specific to multi-wavelength data. """ + pytest.importorskip("h5py") raw = optical_density(read_raw_snirf(fname_labnirs_multi_wavelength)) times = np.arange(raw.n_times) / raw.info["sfreq"] signal = np.sin(2 * np.pi * 1.0 * times) + 1 From 371a88b73dd3dbc123c5d1138364685ba9c6732c Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 27 Jan 2026 12:27:41 -0500 Subject: [PATCH 17/18] TST: Ping From 2b64d9e9d4c6d72089e965d85088e1410b24094b Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 27 Jan 2026 12:57:18 -0500 Subject: [PATCH 18/18] FIX: Versions --- mne/decoding/tests/test_csp.py | 1 + mne/decoding/tests/test_ems.py | 1 + mne/decoding/tests/test_receptive_field.py | 2 ++ mne/decoding/tests/test_ssd.py | 1 + mne/decoding/tests/test_time_frequency.py | 1 + mne/decoding/tests/test_transformer.py | 1 + mne/decoding/transformer.py | 2 +- 7 files changed, 8 insertions(+), 1 deletion(-) diff --git a/mne/decoding/tests/test_csp.py b/mne/decoding/tests/test_csp.py index 9c2720956a3..f3f5e16cf98 100644 --- a/mne/decoding/tests/test_csp.py +++ b/mne/decoding/tests/test_csp.py @@ -495,4 +495,5 @@ def test_csp_component_ordering(): @parametrize_with_checks([CSP(), SPoC()]) def test_sklearn_compliance(estimator, check): """Test compliance with sklearn.""" + pytest.importorskip("sklearn", minversion="1.5") # TODO VERSION remove on 1.5+ check(estimator) diff --git a/mne/decoding/tests/test_ems.py b/mne/decoding/tests/test_ems.py index dc54303a541..6dadf5094c3 100644 --- a/mne/decoding/tests/test_ems.py +++ b/mne/decoding/tests/test_ems.py @@ -97,4 +97,5 @@ def test_ems(): @parametrize_with_checks([EMS()]) def test_sklearn_compliance(estimator, check): """Test compliance with sklearn.""" + pytest.importorskip("sklearn", minversion="1.6") # TODO VERSION remove on 1.6+ check(estimator) diff --git a/mne/decoding/tests/test_receptive_field.py b/mne/decoding/tests/test_receptive_field.py index fd95408e25c..e1d6de8166b 100644 --- a/mne/decoding/tests/test_receptive_field.py +++ b/mne/decoding/tests/test_receptive_field.py @@ -590,6 +590,7 @@ def test_linalg_warning(): @parametrize_with_checks([TimeDelayingRidge(0, 10, 1.0, 0.1, "laplacian", n_jobs=1)]) def test_tdr_sklearn_compliance(estimator, check): """Test sklearn estimator compliance.""" + pytest.importorskip("sklearn", minversion="1.6") # TODO VERSION remove on 1.6+ ignores = ( # TDR convolves and thus its output cannot be invariant when # shuffled or subsampled. @@ -605,6 +606,7 @@ def test_tdr_sklearn_compliance(estimator, check): @parametrize_with_checks([ReceptiveField(-1, 2, 1.0, estimator=Ridge(), patterns=True)]) def test_rf_sklearn_compliance(estimator, check): """Test sklearn RF compliance.""" + pytest.importorskip("sklearn", minversion="1.6") # TODO VERSION remove on 1.6+ ignores = ( # RF does time-lagging, so its output cannot be invariant when # shuffled or subsampled. diff --git a/mne/decoding/tests/test_ssd.py b/mne/decoding/tests/test_ssd.py index eba25a607d2..086413b043f 100644 --- a/mne/decoding/tests/test_ssd.py +++ b/mne/decoding/tests/test_ssd.py @@ -621,6 +621,7 @@ def test_get_spectral_ratio(): ) def test_sklearn_compliance(estimator, check): """Test LinearModel compliance with sklearn.""" + pytest.importorskip("sklearn", minversion="1.6") # TODO VERSION remove on 1.6+ ignores = ( # Checks below fail because what sklearn passes as (n_samples, n_features) # is considered (n_channels, n_times) by SSD and creates problems diff --git a/mne/decoding/tests/test_time_frequency.py b/mne/decoding/tests/test_time_frequency.py index 638cebda21e..9765c85533e 100644 --- a/mne/decoding/tests/test_time_frequency.py +++ b/mne/decoding/tests/test_time_frequency.py @@ -57,4 +57,5 @@ def test_timefrequency_basic(): @parametrize_with_checks([TimeFrequency([300, 400], 1000.0, n_cycles=0.25)]) def test_sklearn_compliance(estimator, check): """Test LinearModel compliance with sklearn.""" + pytest.importorskip("sklearn", minversion="1.6") # TODO VERSION remove on 1.6+ check(estimator) diff --git a/mne/decoding/tests/test_transformer.py b/mne/decoding/tests/test_transformer.py index 54d1a3c1c28..0660b358c3d 100644 --- a/mne/decoding/tests/test_transformer.py +++ b/mne/decoding/tests/test_transformer.py @@ -339,6 +339,7 @@ def test_bad_triage(): ) def test_sklearn_compliance(estimator, check): """Test LinearModel compliance with sklearn.""" + pytest.importorskip("sklearn", minversion="1.6") # TODO VERSION remove on 1.6+ ignores = [] if estimator.__class__.__name__ == "FilterEstimator": ignores += [ diff --git a/mne/decoding/transformer.py b/mne/decoding/transformer.py index 7791265af0d..c5b3a5ca704 100644 --- a/mne/decoding/transformer.py +++ b/mne/decoding/transformer.py @@ -44,7 +44,7 @@ def _check_data( if isinstance(epochs_data, BaseEpochs): epochs_data = epochs_data.get_data(copy=False) kwargs = dict(dtype=np.float64, allow_nd=True, order="C") - if check_version("sklearn", "1.4"): # TODO VERSION sklearn 1.4+ + if check_version("sklearn", "1.5"): # TODO VERSION sklearn 1.5+ kwargs["force_writeable"] = True if hasattr(self, "n_features_in_") and check_n_features: if y is None: