From 85000b1ae121429bc7ee2678e99397eb6ef48337 Mon Sep 17 00:00:00 2001 From: Bruno Aristimunha <42702466+bruAristimunha@users.noreply.github.com> Date: Thu, 8 Jan 2026 11:55:09 +0100 Subject: [PATCH 1/8] better control of verbose --- moabb/evaluations/base.py | 8 +++++ moabb/tests/test_verbose.py | 54 ++++++++++++++++++++++++++++ moabb/utils.py | 70 +++++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 moabb/tests/test_verbose.py diff --git a/moabb/evaluations/base.py b/moabb/evaluations/base.py index 7f8d70158..4af46a3d7 100644 --- a/moabb/evaluations/base.py +++ b/moabb/evaluations/base.py @@ -13,6 +13,7 @@ check_search_available, ) from moabb.paradigms.base import BaseParadigm +from moabb.utils import verbose search_methods, optuna_available = check_search_available() @@ -68,6 +69,10 @@ class BaseEvaluation(ABC): time_out: default=60*15 Cut off time for the optuna search expressed in seconds, the default value is 15 minutes. Only used with optuna equal to True. + verbose: bool, str, int, default=None + If not None, override default verbose level (see :func:`mne.verbose` + and :func:`mne.set_log_level` for more info). + If used, it should be passed as a keyword-argument only. Notes ----- @@ -79,6 +84,7 @@ class BaseEvaluation(ABC): search = False + @verbose def __init__( self, paradigm, @@ -98,6 +104,7 @@ def __init__( cache_config=None, optuna=False, time_out=60 * 15, + verbose=None, ): self.random_state = random_state self.n_jobs = n_jobs @@ -111,6 +118,7 @@ def __init__( self.cache_config = cache_config self.optuna = optuna self.time_out = time_out + self.verbose = verbose if self.optuna and not optuna_available: raise ImportError("Optuna is not available. Please install it first.") diff --git a/moabb/tests/test_verbose.py b/moabb/tests/test_verbose.py new file mode 100644 index 000000000..aae29cbaf --- /dev/null +++ b/moabb/tests/test_verbose.py @@ -0,0 +1,54 @@ +import logging + +import pytest + +from moabb.datasets.fake import FakeDataset +from moabb.evaluations import CrossSessionEvaluation +from moabb.paradigms import MotorImagery + + +def test_verbose_warning(caplog): + # Setup + dataset = FakeDataset(n_sessions=1, n_subjects=2) + paradigm = MotorImagery() + + # We expect a warning because n_sessions=1 < 2 required for CrossSessionEvaluation + # And subsequently an Exception because no datasets left + + with pytest.raises(Exception, match="No datasets left"): + with caplog.at_level(logging.WARNING): + CrossSessionEvaluation(paradigm=paradigm, datasets=[dataset]) + + # Check if warning was logged + assert "not compatible with evaluation" in caplog.text + + +def test_verbose_error_suppression(caplog): + # Setup + dataset = FakeDataset(n_sessions=1, n_subjects=2) + paradigm = MotorImagery() + + # We expect an Exception because no datasets left, but NO warning if verbose='ERROR' + with pytest.raises(Exception, match="No datasets left"): + with caplog.at_level(logging.WARNING): + # Passing verbose="ERROR" should suppress the warning + CrossSessionEvaluation(paradigm=paradigm, datasets=[dataset], verbose="ERROR") + + # Check if warning was suppressed + assert "not compatible with evaluation" not in caplog.text + + +def test_verbose_false_warning(caplog): + # Setup + dataset = FakeDataset(n_sessions=1, n_subjects=2) + paradigm = MotorImagery() + + # MNE style: verbose=False implies WARNING level, so warning should STILL appear + with pytest.raises(Exception, match="No datasets left"): + with caplog.at_level( + logging.INFO + ): # Set to INFO to see if behavior is consistent + CrossSessionEvaluation(paradigm=paradigm, datasets=[dataset], verbose=False) + + # Check if warning was logged (since verbose=False -> WARNING) + assert "not compatible with evaluation" in caplog.text diff --git a/moabb/utils.py b/moabb/utils.py index e4655f7ff..9aa4fa0c2 100644 --- a/moabb/utils.py +++ b/moabb/utils.py @@ -1,6 +1,7 @@ """Util functions for moabb.""" import contextlib +import functools import inspect import logging import os @@ -73,6 +74,75 @@ def set_log_level(level="INFO"): ) +def verbose(function): + """Verbose decorator to allow setting the level of verbosity. + + This decorator checks for a 'verbose' argument in the function signature + or 'self.verbose' if available, and sets the logging level of the 'moabb' + logger accordingly for the duration of the function. + + Parameters + ---------- + function : function + Function to be decorated. + + Returns + ------- + dec : function + The decorated function. + """ + arg_names = inspect.getfullargspec(function).args + + @functools.wraps(function) + def wrapper(*args, **kwargs): + verbose_val = None + + # Check kwargs + if "verbose" in kwargs: + verbose_val = kwargs["verbose"] + + # Check positional args + elif "verbose" in arg_names: + sig = inspect.signature(function) + try: + bound = sig.bind_partial(*args, **kwargs) + bound.apply_defaults() + if "verbose" in bound.arguments: + verbose_val = bound.arguments["verbose"] + except Exception: + pass + + # Check self.verbose + if verbose_val is None and len(args) > 0: + if hasattr(args[0], "verbose"): + verbose_val = getattr(args[0], "verbose", None) + + logger = logging.getLogger("moabb") + old_level = logger.level + level = None + + if verbose_val is True: + level = logging.INFO + elif verbose_val is False: + level = logging.WARNING + elif isinstance(verbose_val, (int, str)): + level = verbose_val + + if level is not None: + try: + logger.setLevel(level) + except Exception: + pass + + try: + return function(*args, **kwargs) + finally: + if level is not None: + logger.setLevel(old_level) + + return wrapper + + def set_download_dir(path): """Set the download directory if required to change from default mne path. From 67adc903847481e069109ebf7e49eb58336fb8d2 Mon Sep 17 00:00:00 2001 From: Bruno Aristimunha <42702466+bruAristimunha@users.noreply.github.com> Date: Thu, 8 Jan 2026 11:56:42 +0100 Subject: [PATCH 2/8] fill the whats new file --- docs/source/whats_new.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/whats_new.rst b/docs/source/whats_new.rst index eceb081b8..fa5fbea9b 100644 --- a/docs/source/whats_new.rst +++ b/docs/source/whats_new.rst @@ -19,7 +19,7 @@ Version 1.5 (Source - GitHub) Enhancements ~~~~~~~~~~~~ -- None yet. +- Better verbosity control for initialization of the library (:gh:`850` by `Bruno Aristimunha`_) API changes ~~~~~~~~~~~ From 14faa437bf36ec362e96720a5be6eb0a8850abe4 Mon Sep 17 00:00:00 2001 From: Bruno Aristimunha <42702466+bruAristimunha@users.noreply.github.com> Date: Thu, 8 Jan 2026 11:55:09 +0100 Subject: [PATCH 3/8] better control of verbose --- moabb/evaluations/base.py | 8 +++++ moabb/tests/test_verbose.py | 54 ++++++++++++++++++++++++++++ moabb/utils.py | 70 +++++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 moabb/tests/test_verbose.py diff --git a/moabb/evaluations/base.py b/moabb/evaluations/base.py index 7f8d70158..4af46a3d7 100644 --- a/moabb/evaluations/base.py +++ b/moabb/evaluations/base.py @@ -13,6 +13,7 @@ check_search_available, ) from moabb.paradigms.base import BaseParadigm +from moabb.utils import verbose search_methods, optuna_available = check_search_available() @@ -68,6 +69,10 @@ class BaseEvaluation(ABC): time_out: default=60*15 Cut off time for the optuna search expressed in seconds, the default value is 15 minutes. Only used with optuna equal to True. + verbose: bool, str, int, default=None + If not None, override default verbose level (see :func:`mne.verbose` + and :func:`mne.set_log_level` for more info). + If used, it should be passed as a keyword-argument only. Notes ----- @@ -79,6 +84,7 @@ class BaseEvaluation(ABC): search = False + @verbose def __init__( self, paradigm, @@ -98,6 +104,7 @@ def __init__( cache_config=None, optuna=False, time_out=60 * 15, + verbose=None, ): self.random_state = random_state self.n_jobs = n_jobs @@ -111,6 +118,7 @@ def __init__( self.cache_config = cache_config self.optuna = optuna self.time_out = time_out + self.verbose = verbose if self.optuna and not optuna_available: raise ImportError("Optuna is not available. Please install it first.") diff --git a/moabb/tests/test_verbose.py b/moabb/tests/test_verbose.py new file mode 100644 index 000000000..aae29cbaf --- /dev/null +++ b/moabb/tests/test_verbose.py @@ -0,0 +1,54 @@ +import logging + +import pytest + +from moabb.datasets.fake import FakeDataset +from moabb.evaluations import CrossSessionEvaluation +from moabb.paradigms import MotorImagery + + +def test_verbose_warning(caplog): + # Setup + dataset = FakeDataset(n_sessions=1, n_subjects=2) + paradigm = MotorImagery() + + # We expect a warning because n_sessions=1 < 2 required for CrossSessionEvaluation + # And subsequently an Exception because no datasets left + + with pytest.raises(Exception, match="No datasets left"): + with caplog.at_level(logging.WARNING): + CrossSessionEvaluation(paradigm=paradigm, datasets=[dataset]) + + # Check if warning was logged + assert "not compatible with evaluation" in caplog.text + + +def test_verbose_error_suppression(caplog): + # Setup + dataset = FakeDataset(n_sessions=1, n_subjects=2) + paradigm = MotorImagery() + + # We expect an Exception because no datasets left, but NO warning if verbose='ERROR' + with pytest.raises(Exception, match="No datasets left"): + with caplog.at_level(logging.WARNING): + # Passing verbose="ERROR" should suppress the warning + CrossSessionEvaluation(paradigm=paradigm, datasets=[dataset], verbose="ERROR") + + # Check if warning was suppressed + assert "not compatible with evaluation" not in caplog.text + + +def test_verbose_false_warning(caplog): + # Setup + dataset = FakeDataset(n_sessions=1, n_subjects=2) + paradigm = MotorImagery() + + # MNE style: verbose=False implies WARNING level, so warning should STILL appear + with pytest.raises(Exception, match="No datasets left"): + with caplog.at_level( + logging.INFO + ): # Set to INFO to see if behavior is consistent + CrossSessionEvaluation(paradigm=paradigm, datasets=[dataset], verbose=False) + + # Check if warning was logged (since verbose=False -> WARNING) + assert "not compatible with evaluation" in caplog.text diff --git a/moabb/utils.py b/moabb/utils.py index e32d43948..7e4828fc8 100644 --- a/moabb/utils.py +++ b/moabb/utils.py @@ -1,6 +1,7 @@ """Util functions for moabb.""" import contextlib +import functools import inspect import logging import os @@ -73,6 +74,75 @@ def set_log_level(level="INFO"): ) +def verbose(function): + """Verbose decorator to allow setting the level of verbosity. + + This decorator checks for a 'verbose' argument in the function signature + or 'self.verbose' if available, and sets the logging level of the 'moabb' + logger accordingly for the duration of the function. + + Parameters + ---------- + function : function + Function to be decorated. + + Returns + ------- + dec : function + The decorated function. + """ + arg_names = inspect.getfullargspec(function).args + + @functools.wraps(function) + def wrapper(*args, **kwargs): + verbose_val = None + + # Check kwargs + if "verbose" in kwargs: + verbose_val = kwargs["verbose"] + + # Check positional args + elif "verbose" in arg_names: + sig = inspect.signature(function) + try: + bound = sig.bind_partial(*args, **kwargs) + bound.apply_defaults() + if "verbose" in bound.arguments: + verbose_val = bound.arguments["verbose"] + except Exception: + pass + + # Check self.verbose + if verbose_val is None and len(args) > 0: + if hasattr(args[0], "verbose"): + verbose_val = getattr(args[0], "verbose", None) + + logger = logging.getLogger("moabb") + old_level = logger.level + level = None + + if verbose_val is True: + level = logging.INFO + elif verbose_val is False: + level = logging.WARNING + elif isinstance(verbose_val, (int, str)): + level = verbose_val + + if level is not None: + try: + logger.setLevel(level) + except Exception: + pass + + try: + return function(*args, **kwargs) + finally: + if level is not None: + logger.setLevel(old_level) + + return wrapper + + def set_download_dir(path): """Set the download directory if required to change from default mne path. From b28579ace8d578d87297fa5b6debf34220656a26 Mon Sep 17 00:00:00 2001 From: Bruno Aristimunha <42702466+bruAristimunha@users.noreply.github.com> Date: Thu, 8 Jan 2026 11:56:42 +0100 Subject: [PATCH 4/8] fill the whats new file --- docs/source/whats_new.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/source/whats_new.rst b/docs/source/whats_new.rst index 5221c71f3..1ba77d0c1 100644 --- a/docs/source/whats_new.rst +++ b/docs/source/whats_new.rst @@ -20,6 +20,7 @@ Version 1.5 (Source - GitHub) Enhancements ~~~~~~~~~~~~ - Introduce a new logo for the MOABB library (:gh:`858` by `Pierre Guetschel`_ and community) +- Better verbosity control for initialization of the library (:gh:`850` by `Bruno Aristimunha`_) API changes ~~~~~~~~~~~ From 310c424131dd1fb73cb07ec555eff271fdd2574e Mon Sep 17 00:00:00 2001 From: Bru Date: Thu, 8 Jan 2026 12:22:59 +0100 Subject: [PATCH 5/8] Update moabb/utils.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Bru --- moabb/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/moabb/utils.py b/moabb/utils.py index 7e4828fc8..ef42ee470 100644 --- a/moabb/utils.py +++ b/moabb/utils.py @@ -131,8 +131,8 @@ def wrapper(*args, **kwargs): if level is not None: try: logger.setLevel(level) - except Exception: - pass + except (TypeError, ValueError) as exc: + logger.warning("Failed to set log level %r: %s", level, exc) try: return function(*args, **kwargs) From 7afd588f4ad8adf81c089b868fe04937de599ab9 Mon Sep 17 00:00:00 2001 From: Bru Date: Thu, 8 Jan 2026 12:23:04 +0100 Subject: [PATCH 6/8] Update moabb/utils.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Bru --- moabb/utils.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/moabb/utils.py b/moabb/utils.py index ef42ee470..94236f0c9 100644 --- a/moabb/utils.py +++ b/moabb/utils.py @@ -109,8 +109,12 @@ def wrapper(*args, **kwargs): bound.apply_defaults() if "verbose" in bound.arguments: verbose_val = bound.arguments["verbose"] - except Exception: - pass + except TypeError as exc: + log.debug( + "Failed to bind 'verbose' argument for %s: %s", + function.__name__, + exc, + ) # Check self.verbose if verbose_val is None and len(args) > 0: From 7ed594b8318e8ba0042d5a8b453b655496fb1633 Mon Sep 17 00:00:00 2001 From: Bru Date: Thu, 8 Jan 2026 12:23:12 +0100 Subject: [PATCH 7/8] Update moabb/evaluations/base.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Bru --- moabb/evaluations/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/moabb/evaluations/base.py b/moabb/evaluations/base.py index 4af46a3d7..4820b8c11 100644 --- a/moabb/evaluations/base.py +++ b/moabb/evaluations/base.py @@ -70,8 +70,8 @@ class BaseEvaluation(ABC): Cut off time for the optuna search expressed in seconds, the default value is 15 minutes. Only used with optuna equal to True. verbose: bool, str, int, default=None - If not None, override default verbose level (see :func:`mne.verbose` - and :func:`mne.set_log_level` for more info). + If not None, override the default MOABB logging level used by this evaluation + (see :func:`moabb.utils.verbose` for more information on how this is handled). If used, it should be passed as a keyword-argument only. Notes From 31f015fe90b33f0e4c557673b5dbf8f25b814a8d Mon Sep 17 00:00:00 2001 From: Bruno Aristimunha <42702466+bruAristimunha@users.noreply.github.com> Date: Thu, 8 Jan 2026 12:24:26 +0100 Subject: [PATCH 8/8] updating the verbose parameter --- moabb/evaluations/base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/moabb/evaluations/base.py b/moabb/evaluations/base.py index 4af46a3d7..a9a6a9b08 100644 --- a/moabb/evaluations/base.py +++ b/moabb/evaluations/base.py @@ -80,6 +80,8 @@ class BaseEvaluation(ABC): n_splits, save_model, cache_config parameters. .. versionadded:: 1.1.1 optuna, time_out parameters. + .. versionadded:: 1.5 + verbose parameter. """ search = False