Repository: mne-tools/mne-python

Issue:
Warnings are displayed twice
Whenever a warning is emitted, the message is shown twice because the logger also prints this information to the screen. This results into warnings that are difficult to read (e.g. it is hard to tell how many warnings occurred). For example:

```
Extracting parameters from /Users/clemens/Desktop/Functions_1b_16.vhdr...
<ipython-input-2-ea8161bea387>:1: RuntimeWarning: No coordinate information found for channels ['EXG1', 'EXG2', 'EXG3', 'EXG4', 'EXG5', 'EXG6', 'EXG7', 'EXG8', 'Status']. Setting channel types to misc. To avoid this warning, set channel types explicitly.
  raw = mne.io.read_raw_brainvision("/Users/clemens/Desktop/Functions_1b_16.vhdr", preload=True)
No coordinate information found for channels ['EXG1', 'EXG2', 'EXG3', 'EXG4', 'EXG5', 'EXG6', 'EXG7', 'EXG8', 'Status']. Setting channel types to misc. To avoid this warning, set channel types explicitly.
Setting channel info structure...
Reading 0 ... 100863  =      0.000 ...   393.996 secs...
Used Annotations descriptions: ['Stimulus/S 11', 'Stimulus/S120', 'Stimulus/S  2', 'Stimulus/S 90', 'Stimulus/S  1', 'Stimulus/S 13', 'Stimulus/S 70', 'Stimulus/S 77', 'Stimulus/S 86', 'Stimulus/S119', 'Stimulus/S112', 'Stimulus/S 67', 'Stimulus/S 96', 'Stimulus/S 75', 'Stimulus/S 83', 'Stimulus/S118', 'Stimulus/S 68', 'Stimulus/S113', 'Stimulus/S100', 'Stimulus/S 74', 'Stimulus/S 93', 'Stimulus/S 72', 'Stimulus/S 84', 'Stimulus/S 64', 'Stimulus/S108', 'Stimulus/S 85', 'Stimulus/S106', 'Stimulus/S 81', 'Stimulus/S 79', 'Stimulus/S 73', 'Stimulus/S 89', 'Stimulus/S111', 'Stimulus/S 61', 'Stimulus/S 71', 'Stimulus/S115', 'Stimulus/S 82', 'Stimulus/S 78', 'Stimulus/S 65', 'Stimulus/S109', 'Stimulus/S 66', 'Stimulus/S 98', 'Stimulus/S104', 'Stimulus/S 62', 'Stimulus/S117', 'Stimulus/S 69', 'Stimulus/S 91', 'Stimulus/S 97', 'Stimulus/S101', 'Stimulus/S 80', 'Stimulus/S 63', 'Stimulus/S 87', 'Stimulus/S 92', 'Stimulus/S105', 'Stimulus/S 99', 'Stimulus/S116', 'Stimulus/S102', 'Stimulus/S110', 'Stimulus/S103', 'Stimulus/S 88', 'Stimulus/S 94', 'Stimulus/S114', 'Stimulus/S 95', 'Stimulus/S107', 'Stimulus/S 76']
<ipython-input-2-ea8161bea387>:1: RuntimeWarning: 7 annotation(s) will be dropped, such as ['Comment/ActiveTwo MK2', 'Comment/Start Epoch', 'Comment/CMS in range', 'Comment/Speed Mode 4', 'Bad Interval/Multiple']. Consider using ``regexp`` to ignore annotations that do not follow a specific pattern.
  raw = mne.io.read_raw_brainvision("/Users/clemens/Desktop/Functions_1b_16.vhdr", preload=True)
7 annotation(s) will be dropped, such as ['Comment/ActiveTwo MK2', 'Comment/Start Epoch', 'Comment/CMS in range', 'Comment/Speed Mode 4', 'Bad Interval/Multiple']. Consider using ``regexp`` to ignore annotations that do not follow a specific pattern.
```

Ideally, warnings should be prominently visible, but only once. I could completely disable the logger, but then I wouldn't be able to see messages with less severity than warnings (i.e. infos).


Discussion:
fair comment. How do you suggest to proceed?

No idea. How do other projects handle this? We could hide logging output for warnings/errors, but I'm pretty sure this is not a good solution. We could also write all logging messages to a file, which is probably also not ideal. However, if we clutter the output people will likely just ignore all messages (including really important ones).
let's keep thinking then but I agree with the concern.

What is the reason for adding a module-specific handler that writes to stdout? Wouldn't this issue go away without it?
> What is the reason for adding a module-specific handler that writes to stdout? Wouldn't this issue go away by not doing that?

I'm not sure what you mean. We do some module-related magic to figure out the `stacklevel` in a `warnings.warn` call but otherwise I don't think we do anything module-specific. We basically do a `warnings.warn(..., stacklevel=...)` and `logger.warning(...)`. The former is useful for seeing the stack level and parsing warnings / stderr, the latter is useful in a logging context, which for example you can have write to a file (`mne.set_log_file`).

Do you mean something else?
In the `utils` module, a new handler is added upon initialization (https://github.com/mne-tools/mne-python/blob/master/mne/__init__.py#L118). The reason the warnings are printed twice is that they are printed once by the standard Python handler (which has level WARNINGS unless changed) and then by the `mne` handler. Or did I misunderstand the issue?
> The reason the warnings are printed twice is that they are printed once by the standard Python handler (which has level WARNINGS unless changed) and then by the mne handler. Or did I misunderstand the issue?

This is not why it happens. In `utils.py` we have:
```
        warnings.warn_explicit(message, category, fname, lineno,
                               'mne', globals().get('__warningregistry__', {}))
    logger.warning(message)
```
If you comment out either of those two lines, you only get one message printed.
... which actually makes me realize one workaround/solution is to check if any of the `logger` handlers is a file handler. If so, send the message (and you'll get two in stdout, which is less than ideal). If not (which is the far more common use case) don't.
Aah ok, sorry for my confusion then.

Resolving patch:
diff --git a/mne/preprocessing/maxwell.py b/mne/preprocessing/maxwell.py
--- a/mne/preprocessing/maxwell.py
+++ b/mne/preprocessing/maxwell.py
@@ -111,7 +111,7 @@ def maxwell_filter(raw, origin='auto', int_order=8, ext_order=3,
         with reference channels is not currently supported.
     bad_condition : str
         How to deal with ill-conditioned SSS matrices. Can be "error"
-        (default), "warning", or "ignore".
+        (default), "warning", "info", or "ignore".
     head_pos : array | None
         If array, movement compensation will be performed.
         The array should be of shape (N, 10), holding the position
@@ -307,9 +307,9 @@ def maxwell_filter(raw, origin='auto', int_order=8, ext_order=3,
         if not 0. < st_correlation <= 1:
             raise ValueError('st_correlation must be between 0. and 1.')
     if not isinstance(bad_condition, string_types) or \
-            bad_condition not in ['error', 'warning', 'ignore']:
-        raise ValueError('bad_condition must be "error", "warning", or '
-                         '"ignore", not %s' % bad_condition)
+            bad_condition not in ['error', 'warning', 'ignore', 'info']:
+        raise ValueError('bad_condition must be "error", "warning", "info", or'
+                         ' "ignore", not %s' % bad_condition)
     if raw.info['dev_head_t'] is None and coord_frame == 'head':
         raise RuntimeError('coord_frame cannot be "head" because '
                            'info["dev_head_t"] is None; if this is an '
@@ -857,8 +857,10 @@ def _get_decomp(trans, all_coils, cal, regularize, exp, ignore_ref,
         msg = 'Matrix is badly conditioned: %0.0f >= 1000' % cond
         if bad_condition == 'error':
             raise RuntimeError(msg)
-        else:  # condition == 'warning':
+        elif bad_condition == 'warning':
             warn(msg)
+        else:  # condition == 'info'
+            logger.info(msg)
 
     # Build in our data scaling here
     pS_decomp *= coil_scale[good_picks].T
diff --git a/mne/utils.py b/mne/utils.py
--- a/mne/utils.py
+++ b/mne/utils.py
@@ -367,8 +367,7 @@ def warn(message, category=RuntimeWarning):
     category : instance of Warning
         The warning class. Defaults to ``RuntimeWarning``.
     """
-    import mne
-    root_dir = op.dirname(mne.__file__)
+    root_dir = op.dirname(__file__)
     frame = None
     if logger.level <= logging.WARN:
         last_fname = ''
@@ -395,7 +394,12 @@ def warn(message, category=RuntimeWarning):
         # recognize the module name (and our warnings.simplefilter will fail)
         warnings.warn_explicit(message, category, fname, lineno,
                                'mne', globals().get('__warningregistry__', {}))
-    logger.warning(message)
+    # To avoid a duplicate warning print, we only emit the logger.warning if
+    # one of the handlers is a FileHandler. See gh-5592
+    if any(isinstance(h, logging.FileHandler) or getattr(h, '_mne_file_like',
+                                                         False)
+           for h in logger.handlers):
+        logger.warning(message)
 
 
 def filter_out_warnings(warn_record, category=None, match=None):
@@ -1425,6 +1429,7 @@ def __enter__(self):  # noqa: D105
         self._data = StringIO()
         self._lh = logging.StreamHandler(self._data)
         self._lh.setFormatter(logging.Formatter('%(message)s'))
+        self._lh._mne_file_like = True  # monkey patch for warn() use
         for lh in logger.handlers:
             logger.removeHandler(lh)
         logger.addHandler(self._lh)


Test patch:
diff --git a/mne/preprocessing/tests/test_maxwell.py b/mne/preprocessing/tests/test_maxwell.py
--- a/mne/preprocessing/tests/test_maxwell.py
+++ b/mne/preprocessing/tests/test_maxwell.py
@@ -229,19 +229,18 @@ def test_other_systems():
             pytest.raises(RuntimeError, maxwell_filter, raw_kit,
                           ignore_ref=True, regularize=None)  # bad condition
             raw_sss = maxwell_filter(raw_kit, origin='auto',
-                                     ignore_ref=True, bad_condition='warning',
-                                     verbose='warning')
+                                     ignore_ref=True, bad_condition='info',
+                                     verbose=True)
     log = log.getvalue()
     assert 'badly conditioned' in log
     assert 'more than 20 mm from' in log
     # fits can differ slightly based on scipy version, so be lenient here
     _assert_n_free(raw_sss, 28, 34)  # bad origin == brutal reg
     # Let's set the origin
-    with pytest.warns(RuntimeWarning, match='badly conditioned'):
-        with catch_logging() as log:
-            raw_sss = maxwell_filter(raw_kit, origin=(0., 0., 0.04),
-                                     ignore_ref=True, bad_condition='warning',
-                                     regularize=None, verbose=True)
+    with catch_logging() as log:
+        raw_sss = maxwell_filter(raw_kit, origin=(0., 0., 0.04),
+                                 ignore_ref=True, bad_condition='info',
+                                 regularize=None, verbose=True)
     log = log.getvalue()
     assert 'badly conditioned' in log
     assert '80/80 in, 12/15 out' in log
@@ -750,22 +749,18 @@ def test_head_translation():
     assert_meg_snr(raw_sss, read_crop(sss_std_fname, (0., 1.)), 200.)
     # Now with default
     with pytest.warns(RuntimeWarning, match='over 25 mm'):
-        with catch_logging() as log:
-            raw_sss = maxwell_filter(raw, destination=mf_head_origin,
-                                     origin=mf_head_origin, regularize=None,
-                                     bad_condition='ignore', verbose='warning')
-    assert ('over 25 mm' in log.getvalue())
+        raw_sss = maxwell_filter(raw, destination=mf_head_origin,
+                                 origin=mf_head_origin, regularize=None,
+                                 bad_condition='ignore', verbose=True)
     assert_meg_snr(raw_sss, read_crop(sss_trans_default_fname), 125.)
     destination = np.eye(4)
     destination[2, 3] = 0.04
     assert_allclose(raw_sss.info['dev_head_t']['trans'], destination)
     # Now to sample's head pos
     with pytest.warns(RuntimeWarning, match='= 25.6 mm'):
-        with catch_logging() as log:
-            raw_sss = maxwell_filter(raw, destination=sample_fname,
-                                     origin=mf_head_origin, regularize=None,
-                                     bad_condition='ignore', verbose='warning')
-    assert ('= 25.6 mm' in log.getvalue())
+        raw_sss = maxwell_filter(raw, destination=sample_fname,
+                                 origin=mf_head_origin, regularize=None,
+                                 bad_condition='ignore', verbose=True)
     assert_meg_snr(raw_sss, read_crop(sss_trans_sample_fname), 350.)
     assert_allclose(raw_sss.info['dev_head_t']['trans'],
                     read_info(sample_fname)['dev_head_t']['trans'])
diff --git a/mne/tests/test_chpi.py b/mne/tests/test_chpi.py
--- a/mne/tests/test_chpi.py
+++ b/mne/tests/test_chpi.py
@@ -294,7 +294,7 @@ def test_simulate_calculate_chpi_positions():
     info_trans = info['dev_head_t']['trans'].copy()
 
     dev_head_pos_ini = np.concatenate([rot_to_quat(info_trans[:3, :3]),
-                                      info_trans[:3, 3]])
+                                       info_trans[:3, 3]])
     ez = np.array([0, 0, 1])  # Unit vector in z-direction of head coordinates
 
     # Define some constants
diff --git a/mne/tests/test_utils.py b/mne/tests/test_utils.py
--- a/mne/tests/test_utils.py
+++ b/mne/tests/test_utils.py
@@ -28,7 +28,7 @@
                        set_memmap_min_size, _get_stim_channel, _check_fname,
                        create_slices, _time_mask, random_permutation,
                        _get_call_line, compute_corr, sys_info, verbose,
-                       check_fname, get_config_path,
+                       check_fname, get_config_path, warn,
                        object_size, buggy_mkl_svd, _get_inst_data,
                        copy_doc, copy_function_doc_to_method_doc, ProgressBar,
                        linkcode_resolve, array_split_idx, filter_out_warnings)
@@ -886,4 +886,13 @@ def test_linkcode_resolve():
     assert '/mne/datasets/sample/sample.py' + ex in url
 
 
+def test_warn(capsys):
+    """Test the smart warn() function."""
+    with pytest.warns(RuntimeWarning, match='foo'):
+        warn('foo')
+    captured = capsys.readouterr()
+    assert captured.out == ''  # gh-5592
+    assert captured.err == ''  # this is because pytest.warns took it already
+
+
 run_tests_if_main()