Skip to content
1 change: 1 addition & 0 deletions docs/source/whats_new.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
~~~~~~~~~~~
Expand Down
10 changes: 10 additions & 0 deletions moabb/evaluations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -68,17 +69,24 @@ 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 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
-----
.. versionadded:: 1.1.0
n_splits, save_model, cache_config parameters.
.. versionadded:: 1.1.1
optuna, time_out parameters.
.. versionadded:: 1.5
verbose parameter.
"""

search = False

@verbose
def __init__(
self,
paradigm,
Expand All @@ -98,6 +106,7 @@ def __init__(
cache_config=None,
optuna=False,
time_out=60 * 15,
verbose=None,
):
self.random_state = random_state
self.n_jobs = n_jobs
Expand All @@ -111,6 +120,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.")
Expand Down
54 changes: 54 additions & 0 deletions moabb/tests/test_verbose.py
Original file line number Diff line number Diff line change
@@ -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
74 changes: 74 additions & 0 deletions moabb/utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Util functions for moabb."""

import contextlib
import functools
import inspect
import logging
import os
Expand Down Expand Up @@ -73,6 +74,79 @@ 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 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:
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 (TypeError, ValueError) as exc:
logger.warning("Failed to set log level %r: %s", level, exc)

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.

Expand Down
Loading