Repository: mne-tools/mne-python

Issue:
error when saving TFR: unsupported type <class 'datetime.datetime'>
I am computing power on epochs and want to save the output. Since I updated MNE to 
0.20.dev0, I get the following error: 

```
  File "/home/sh254795/anaconda3/lib/python3.7/site-packages/mne/externals/h5io/_h5io.py", line 225, in _triage_write
    raise TypeError(err_str)

TypeError: unsupported type <class 'datetime.datetime'> (in <class 'list'>[0][1]["info"]["meas_date"])

```
Here is an example that reproduces the error:

```
import mne
from mne.datasets import sample

print(__doc__)

###############################################################################
# Set parameters
# --------------
data_path = sample.data_path()
raw_fname = data_path + '/MEG/sample/sample_audvis_raw.fif'
tmin, tmax, event_id = -0.3, 0.6, 1

# Setup for reading the raw data
raw = mne.io.read_raw_fif(raw_fname)
events = mne.find_events(raw, stim_channel='STI 014')

include = []
raw.info['bads'] += ['MEG 2443', 'EEG 053']  # bads + 2 more

# picks MEG gradiometers
picks = mne.pick_types(raw.info, meg='grad', eeg=False, eog=True,
                       stim=False, include=include, exclude='bads')

# Load condition 1
event_id = 1
epochs = mne.Epochs(raw, events, event_id, tmin, tmax, picks=picks,
                    baseline=(None, 0), preload=True,
                    reject=dict(grad=4000e-13, eog=150e-6))

# Take only one channel
ch_name = 'MEG 1332'
epochs.pick_channels([ch_name])

decim = 5
freqs = np.arange(8, 40, 2)  # define frequencies of interest
sfreq = raw.info['sfreq']  # sampling in Hz

power, itc = mne.time_frequency.tfr_morlet(epochs, freqs=freqs, return_itc=True, n_cycles=4., decim=decim)

power.save('TMP', overwrite=True)
```


Discussion:
I can replicate. let me have a look

in the mean time you can do:

del power.info['meas_date']

before saving.


Resolving patch:
diff --git a/mne/conftest.py b/mne/conftest.py
--- a/mne/conftest.py
+++ b/mne/conftest.py
@@ -109,6 +109,16 @@ def matplotlib_config():
         ETSConfig.toolkit = 'qt4'
 
 
+@pytest.fixture()
+def check_gui_ci():
+    """Skip tests that are not reliable on CIs."""
+    osx = (os.getenv('TRAVIS', 'false').lower() == 'true' and
+           sys.platform == 'darwin')
+    win = os.getenv('AZURE_CI_WINDOWS', 'false').lower() == 'true'
+    if win or osx:
+        pytest.skip('Skipping GUI tests on Travis OSX and Azure Windows')
+
+
 def _replace(mod, key):
     orig = getattr(mod, key)
 
diff --git a/mne/externals/h5io/_h5io.py b/mne/externals/h5io/_h5io.py
--- a/mne/externals/h5io/_h5io.py
+++ b/mne/externals/h5io/_h5io.py
@@ -3,6 +3,7 @@
 #
 # License: BSD (3-clause)
 
+from datetime import datetime, timezone, timedelta
 import json
 import sys
 import tempfile
@@ -61,7 +62,7 @@ def _create_pandas_dataset(fname, root, key, title, data):
 
 def write_hdf5(fname, data, overwrite=False, compression=4,
                title='h5io', slash='error', use_json=False):
-    """Write python object to HDF5 format using h5py
+    """Write python object to HDF5 format using h5py.
 
     Parameters
     ----------
@@ -69,7 +70,9 @@ def write_hdf5(fname, data, overwrite=False, compression=4,
         Filename to use.
     data : object
         Object to write. Can be of any of these types:
-            {ndarray, dict, list, tuple, int, float, str}
+
+            {ndarray, dict, list, tuple, int, float, str, Datetime}
+
         Note that dict objects must only have ``str`` keys. It is recommended
         to use ndarrays where possible, as it is handled most efficiently.
     overwrite : True | False | 'update'
@@ -135,7 +138,7 @@ def _triage_write(key, value, root, comp_kw, where,
             raise ValueError("slash must be one of ['error', 'replace'")
 
     if use_json and isinstance(value, (list, dict)) and \
-            json_compatible(value, slash=slash):
+            _json_compatible(value, slash=slash):
         value = np.frombuffer(json.dumps(value).encode('utf-8'), np.uint8)
         _create_titled_dataset(root, key, 'json', value, comp_kw)
     elif isinstance(value, dict):
@@ -161,6 +164,10 @@ def _triage_write(key, value, root, comp_kw, where,
         else:  # isinstance(value, float):
             title = 'float'
         _create_titled_dataset(root, key, title, np.atleast_1d(value))
+    elif isinstance(value, datetime):
+        title = 'datetime'
+        value = np.frombuffer(value.isoformat().encode('utf-8'), np.uint8)
+        _create_titled_dataset(root, key, title, value)
     elif isinstance(value, (np.integer, np.floating, np.bool_)):
         title = 'np_{0}'.format(value.__class__.__name__)
         _create_titled_dataset(root, key, title, np.atleast_1d(value))
@@ -325,6 +332,9 @@ def _triage_read(node, slash='ignore'):
     elif type_str in ('int', 'float'):
         cast = int if type_str == 'int' else float
         data = cast(np.array(node)[0])
+    elif type_str == 'datetime':
+        data = text_type(np.array(node).tostring().decode('utf-8'))
+        data = fromisoformat(data)
     elif type_str.startswith('np_'):
         np_type = type_str.split('_')[1]
         cast = getattr(np, np_type)
@@ -513,19 +523,20 @@ def list_file_contents(h5file):
         _list_file_contents(h5file)
 
 
-def json_compatible(obj, slash='error'):
+def _json_compatible(obj, slash='error'):
     if isinstance(obj, (string_types, int, float, bool, type(None))):
         return True
     elif isinstance(obj, list):
-        return all([json_compatible(item) for item in obj])
+        return all([_json_compatible(item) for item in obj])
     elif isinstance(obj, dict):
         _check_keys_in_dict(obj, slash=slash)
-        return all([json_compatible(item) for item in obj.values()])
+        return all([_json_compatible(item) for item in obj.values()])
     else:
         return False
 
 
 def _check_keys_in_dict(obj, slash='error'):
+    repl = list()
     for key in obj.keys():
         if '/' in key:
             key_prev = key
@@ -536,9 +547,11 @@ def _check_keys_in_dict(obj, slash='error'):
                 # Auto-replace keys with proper values
                 for key_spec, val_spec in special_chars.items():
                     key = key.replace(val_spec, key_spec)
-                obj[key] = obj.pop(key_prev)
+                repl.append((key, key_prev))
             else:
                 raise ValueError("slash must be one of ['error', 'replace'")
+    for key, key_prev in repl:
+        obj[key] = obj.pop(key_prev)
 
 
 ##############################################################################
@@ -595,3 +608,133 @@ def multiarray_load(index, array_merged):
         i_prev = i
     array_restore.append(array_merged[i_prev:])
     return np.array(array_restore)
+
+
+###############################################################################
+# BACKPORTS
+
+try:
+    fromisoformat = datetime.fromisoformat
+except AttributeError:  # Python < 3.7
+    # Code adapted from CPython
+    # https://github.com/python/cpython/blob/master/Lib/datetime.py
+
+    def _parse_hh_mm_ss_ff(tstr):
+        # Parses things of the form HH[:MM[:SS[.fff[fff]]]]
+        len_str = len(tstr)
+
+        time_comps = [0, 0, 0, 0]
+        pos = 0
+        for comp in range(0, 3):
+            if (len_str - pos) < 2:
+                raise ValueError('Incomplete time component')
+
+            time_comps[comp] = int(tstr[pos:pos + 2])
+
+            pos += 2
+            next_char = tstr[pos:pos + 1]
+
+            if not next_char or comp >= 2:
+                break
+
+            if next_char != ':':
+                raise ValueError('Invalid time separator: %c' % next_char)
+
+            pos += 1
+
+        if pos < len_str:
+            if tstr[pos] != '.':
+                raise ValueError('Invalid microsecond component')
+            else:
+                pos += 1
+
+                len_remainder = len_str - pos
+                if len_remainder not in (3, 6):
+                    raise ValueError('Invalid microsecond component')
+
+                time_comps[3] = int(tstr[pos:])
+                if len_remainder == 3:
+                    time_comps[3] *= 1000
+
+        return time_comps
+
+    def fromisoformat(date_string):
+        """Construct a datetime from the output of datetime.isoformat()."""
+        if not isinstance(date_string, str):
+            raise TypeError('fromisoformat: argument must be str')
+
+        # Split this at the separator
+        dstr = date_string[0:10]
+        tstr = date_string[11:]
+
+        try:
+            date_components = _parse_isoformat_date(dstr)
+        except ValueError:
+            raise ValueError(
+                'Invalid isoformat string: {!r}'.format(date_string))
+
+        if tstr:
+            try:
+                time_components = _parse_isoformat_time(tstr)
+            except ValueError:
+                raise ValueError(
+                    'Invalid isoformat string: {!r}'.format(date_string))
+        else:
+            time_components = [0, 0, 0, 0, None]
+
+        return datetime(*(date_components + time_components))
+
+    def _parse_isoformat_date(dtstr):
+        # It is assumed that this function will only be called with a
+        # string of length exactly 10, and (though this is not used) ASCII-only
+        year = int(dtstr[0:4])
+        if dtstr[4] != '-':
+            raise ValueError('Invalid date separator: %s' % dtstr[4])
+
+        month = int(dtstr[5:7])
+
+        if dtstr[7] != '-':
+            raise ValueError('Invalid date separator')
+
+        day = int(dtstr[8:10])
+
+        return [year, month, day]
+
+    def _parse_isoformat_time(tstr):
+        # Format supported is HH[:MM[:SS[.fff[fff]]]][+HH:MM[:SS[.ffffff]]]
+        len_str = len(tstr)
+        if len_str < 2:
+            raise ValueError('Isoformat time too short')
+
+        # This is equivalent to re.search('[+-]', tstr), but faster
+        tz_pos = (tstr.find('-') + 1 or tstr.find('+') + 1)
+        timestr = tstr[:tz_pos - 1] if tz_pos > 0 else tstr
+
+        time_comps = _parse_hh_mm_ss_ff(timestr)
+
+        tzi = None
+        if tz_pos > 0:
+            tzstr = tstr[tz_pos:]
+
+            # Valid time zone strings are:
+            # HH:MM               len: 5
+            # HH:MM:SS            len: 8
+            # HH:MM:SS.ffffff     len: 15
+
+            if len(tzstr) not in (5, 8, 15):
+                raise ValueError('Malformed time zone string')
+
+            tz_comps = _parse_hh_mm_ss_ff(tzstr)
+            if all(x == 0 for x in tz_comps):
+                tzi = timezone.utc
+            else:
+                tzsign = -1 if tstr[tz_pos - 1] == '-' else 1
+
+                td = timedelta(hours=tz_comps[0], minutes=tz_comps[1],
+                               seconds=tz_comps[2], microseconds=tz_comps[3])
+
+                tzi = timezone(tzsign * td)
+
+        time_comps.append(tzi)
+
+        return time_comps


Test patch:
diff --git a/mne/gui/tests/test_coreg_gui.py b/mne/gui/tests/test_coreg_gui.py
--- a/mne/gui/tests/test_coreg_gui.py
+++ b/mne/gui/tests/test_coreg_gui.py
@@ -6,8 +6,6 @@
 import os.path as op
 import re
 import shutil
-import sys
-from unittest import SkipTest
 
 import numpy as np
 from numpy.testing import assert_allclose, assert_array_almost_equal
@@ -159,17 +157,10 @@ def test_coreg_model(subjects_dir_tmp):
                     [model.trans_x, model.trans_y, model.trans_z])
 
 
-def _check_ci():
-    if os.getenv('TRAVIS', 'false').lower() == 'true' and \
-            sys.platform == 'darwin':
-        raise SkipTest('Skipping GUI tests on Travis OSX')
-
-
 @requires_mayavi
 @traits_test
-def test_coreg_gui_display(subjects_dir_tmp):
+def test_coreg_gui_display(subjects_dir_tmp, check_gui_ci):
     """Test CoregFrame."""
-    _check_ci()
     from mayavi import mlab
     from tvtk.api import tvtk
     home_dir = subjects_dir_tmp
@@ -178,8 +169,8 @@ def test_coreg_gui_display(subjects_dir_tmp):
                       'sample-fiducials.fif'))
     os.remove(op.join(subjects_dir_tmp, 'sample', 'mri', 'transforms',
                       'talairach.xfm'))
-    with modified_env(**{'_MNE_GUI_TESTING_MODE': 'true',
-                         '_MNE_FAKE_HOME_DIR': home_dir}):
+    with modified_env(_MNE_GUI_TESTING_MODE='true',
+                      _MNE_FAKE_HOME_DIR=home_dir):
         with pytest.raises(ValueError, match='not a valid subject'):
             mne.gui.coregistration(
                 subject='Elvis', subjects_dir=subjects_dir_tmp)
diff --git a/mne/gui/tests/test_file_traits.py b/mne/gui/tests/test_file_traits.py
--- a/mne/gui/tests/test_file_traits.py
+++ b/mne/gui/tests/test_file_traits.py
@@ -2,7 +2,6 @@
 #
 # License: BSD (3-clause)
 
-import os
 import os.path as op
 
 from numpy import array
@@ -10,7 +9,8 @@
 import pytest
 
 from mne.datasets import testing
-from mne.utils import _TempDir, requires_mayavi, run_tests_if_main, traits_test
+from mne.utils import (requires_mayavi, run_tests_if_main, traits_test,
+                       modified_env)
 from mne.channels import read_dig_fif
 
 data_path = testing.data_path(download=False)
@@ -59,10 +59,10 @@ def test_fiducials_source():
 @testing.requires_testing_data
 @requires_mayavi
 @traits_test
-def test_inst_source():
+def test_inst_source(tmpdir):
     """Test DigSource."""
     from mne.gui._file_traits import DigSource
-    tempdir = _TempDir()
+    tempdir = str(tmpdir)
 
     inst = DigSource()
     assert inst.inst_fname == '-'
@@ -102,10 +102,10 @@ def test_subject_source():
 @testing.requires_testing_data
 @requires_mayavi
 @traits_test
-def test_subject_source_with_fsaverage():
+def test_subject_source_with_fsaverage(tmpdir):
     """Test SubjectSelector."""
     from mne.gui._file_traits import MRISubjectSource
-    tempdir = _TempDir()
+    tempdir = str(tmpdir)
 
     mri = MRISubjectSource()
     assert not mri.can_create_fsaverage
@@ -115,13 +115,8 @@ def test_subject_source_with_fsaverage():
     assert mri.can_create_fsaverage
     assert not op.isdir(op.join(tempdir, 'fsaverage'))
     # fake FREESURFER_HOME
-    old_val = os.getenv('FREESURFER_HOME')
-    os.environ['FREESURFER_HOME'] = data_path
-    try:
+    with modified_env(FREESURFER_HOME=data_path):
         mri.create_fsaverage()
-    finally:
-        if old_val is not None:
-            os.environ['FREESURFER_HOME'] = old_val
     assert op.isdir(op.join(tempdir, 'fsaverage'))
 
 
diff --git a/mne/gui/tests/test_kit2fiff_gui.py b/mne/gui/tests/test_kit2fiff_gui.py
--- a/mne/gui/tests/test_kit2fiff_gui.py
+++ b/mne/gui/tests/test_kit2fiff_gui.py
@@ -3,17 +3,15 @@
 # License: BSD (3-clause)
 
 import os
-import sys
 
 import numpy as np
 from numpy.testing import assert_allclose, assert_array_equal
-from unittest import SkipTest
 
 import mne
 from mne.io.kit.tests import data_dir as kit_data_dir
 from mne.io import read_raw_fif
-from mne.utils import (_TempDir, requires_mayavi, run_tests_if_main,
-                       traits_test)
+from mne.utils import (requires_mayavi, run_tests_if_main, traits_test,
+                       modified_env)
 
 mrk_pre_path = os.path.join(kit_data_dir, 'test_mrk_pre.sqd')
 mrk_post_path = os.path.join(kit_data_dir, 'test_mrk_post.sqd')
@@ -23,20 +21,12 @@
 fif_path = os.path.join(kit_data_dir, 'test_bin_raw.fif')
 
 
-def _check_ci():
-    osx = (os.getenv('TRAVIS', 'false').lower() == 'true' and
-           sys.platform == 'darwin')
-    win = os.getenv('AZURE_CI_WINDOWS', 'false').lower() == 'true'
-    if win or osx:
-        raise SkipTest('Skipping GUI tests on Travis OSX and Azure Windows')
-
-
 @requires_mayavi
 @traits_test
-def test_kit2fiff_model():
+def test_kit2fiff_model(tmpdir):
     """Test Kit2Fiff model."""
     from mne.gui._kit2fiff_gui import Kit2FiffModel
-    tempdir = _TempDir()
+    tempdir = str(tmpdir)
     tgt_fname = os.path.join(tempdir, 'test-raw.fif')
 
     model = Kit2FiffModel()
@@ -125,13 +115,11 @@ def test_kit2fiff_model():
 
 @requires_mayavi
 @traits_test
-def test_kit2fiff_gui():
+def test_kit2fiff_gui(check_gui_ci, tmpdir):
     """Test Kit2Fiff GUI."""
-    _check_ci()
-    home_dir = _TempDir()
-    os.environ['_MNE_GUI_TESTING_MODE'] = 'true'
-    os.environ['_MNE_FAKE_HOME_DIR'] = home_dir
-    try:
+    home_dir = str(tmpdir)
+    with modified_env(_MNE_GUI_TESTING_MODE='true',
+                      _MNE_FAKE_HOME_DIR=home_dir):
         from pyface.api import GUI
         gui = GUI()
         gui.process_events()
@@ -170,9 +158,6 @@ def test_kit2fiff_gui():
         ui.dispose()
 
         gui.process_events()
-    finally:
-        del os.environ['_MNE_GUI_TESTING_MODE']
-        del os.environ['_MNE_FAKE_HOME_DIR']
 
 
 run_tests_if_main()
diff --git a/mne/gui/tests/test_marker_gui.py b/mne/gui/tests/test_marker_gui.py
--- a/mne/gui/tests/test_marker_gui.py
+++ b/mne/gui/tests/test_marker_gui.py
@@ -3,34 +3,26 @@
 # License: BSD (3-clause)
 
 import os
-import sys
-from unittest import SkipTest
 
 import numpy as np
 from numpy.testing import assert_array_equal
 
 from mne.io.kit.tests import data_dir as kit_data_dir
 from mne.io.kit import read_mrk
-from mne.utils import (_TempDir, requires_mayavi, run_tests_if_main,
-                       traits_test)
+from mne.utils import (requires_mayavi, run_tests_if_main, traits_test,
+                       modified_env)
 
 mrk_pre_path = os.path.join(kit_data_dir, 'test_mrk_pre.sqd')
 mrk_post_path = os.path.join(kit_data_dir, 'test_mrk_post.sqd')
 mrk_avg_path = os.path.join(kit_data_dir, 'test_mrk.sqd')
 
 
-def _check_ci():
-    if os.getenv('TRAVIS', 'false').lower() == 'true' and \
-            sys.platform == 'darwin':
-        raise SkipTest('Skipping GUI tests on Travis OSX')
-
-
 @requires_mayavi
 @traits_test
-def test_combine_markers_model():
+def test_combine_markers_model(tmpdir):
     """Test CombineMarkersModel Traits Model."""
-    from mne.gui._marker_gui import CombineMarkersModel, CombineMarkersPanel
-    tempdir = _TempDir()
+    from mne.gui._marker_gui import CombineMarkersModel
+    tempdir = str(tmpdir)
     tgt_fname = os.path.join(tempdir, 'test.txt')
 
     model = CombineMarkersModel()
@@ -77,12 +69,14 @@ def test_combine_markers_model():
     model.mrk2.file = mrk_post_path
     assert_array_equal(model.mrk3.points, points_interpolate_mrk1_mrk2)
 
-    _check_ci()
-    os.environ['_MNE_GUI_TESTING_MODE'] = 'true'
-    try:
+
+@requires_mayavi
+@traits_test
+def test_combine_markers_panel(check_gui_ci):
+    """Test CombineMarkersPanel."""
+    from mne.gui._marker_gui import CombineMarkersPanel
+    with modified_env(_MNE_GUI_TESTING_MODE='true'):
         CombineMarkersPanel()
-    finally:
-        del os.environ['_MNE_GUI_TESTING_MODE']
 
 
 run_tests_if_main()
diff --git a/mne/time_frequency/tests/test_tfr.py b/mne/time_frequency/tests/test_tfr.py
--- a/mne/time_frequency/tests/test_tfr.py
+++ b/mne/time_frequency/tests/test_tfr.py
@@ -1,4 +1,5 @@
 from itertools import product
+import datetime
 import os.path as op
 
 import numpy as np
@@ -171,8 +172,9 @@ def test_time_frequency():
     assert (np.sum(itc.data <= 0) == 0)
 
     tfr = tfr_morlet(epochs[0], freqs, use_fft=True, n_cycles=2, average=False,
-                     return_itc=False).data[0]
-    assert (tfr.shape == (len(picks), len(freqs), len(times)))
+                     return_itc=False)
+    tfr_data = tfr.data[0]
+    assert (tfr_data.shape == (len(picks), len(freqs), len(times)))
     tfr2 = tfr_morlet(epochs[0], freqs, use_fft=True, n_cycles=2,
                       decim=slice(0, 2), average=False,
                       return_itc=False).data[0]
@@ -406,6 +408,7 @@ def test_io():
 
     info = mne.create_info(['MEG 001', 'MEG 002', 'MEG 003'], 1000.,
                            ['mag', 'mag', 'mag'])
+    info['meas_date'] = datetime.datetime(year=2020, month=2, day=5)
     tfr = AverageTFR(info, data=data, times=times, freqs=freqs,
                      nave=20, comment='test', method='crazy-tfr')
     tfr.save(fname)