Repository: mne-tools/mne-python

Issue:
read_* functions should support pathlib objects
#### Describe the problem
This currently throws an error:
```python

import pathlib
import mne

data_dir = pathlib.Path('/Users/hoechenberger/Projects/TaLa/'
                        'Data/EEG/Preprocessed')

data_file = data_dir / 'TaLa_460_preped-epo.fif'
data = mne.read_epochs(data_file)
```

Generates:
```python
Reading File-like PosixPath('/Users/hoechenberger/Projects/TaLa/Data/EEG/Preprocessed/TaLa_460_preped-epo.fif') ...
Traceback (most recent call last):
  File "/Users/hoechenberger/Library/Preferences/PyCharm2019.2/scratches/scratch_45.py", line 8, in <module>
    data = mne.read_epochs(data_file)
  File "</Users/hoechenberger/Development/mne-python/mne/externals/decorator.py:decorator-gen-179>", line 2, in read_epochs
  File "/Users/hoechenberger/Development/mne-python/mne/utils/_logging.py", line 89, in wrapper
    return function(*args, **kwargs)
  File "/Users/hoechenberger/Development/mne-python/mne/epochs.py", line 2347, in read_epochs
    return EpochsFIF(fname, proj, preload, verbose)
  File "</Users/hoechenberger/Development/mne-python/mne/externals/decorator.py:decorator-gen-180>", line 2, in __init__
  File "/Users/hoechenberger/Development/mne-python/mne/utils/_logging.py", line 89, in wrapper
    return function(*args, **kwargs)
  File "/Users/hoechenberger/Development/mne-python/mne/epochs.py", line 2413, in __init__
    fid, tree, _ = fiff_open(fname, preload=preload)
  File "</Users/hoechenberger/Development/mne-python/mne/externals/decorator.py:decorator-gen-6>", line 2, in fiff_open
  File "/Users/hoechenberger/Development/mne-python/mne/utils/_logging.py", line 89, in wrapper
    return function(*args, **kwargs)
  File "/Users/hoechenberger/Development/mne-python/mne/io/open.py", line 119, in fiff_open
    fid = _fiff_get_fid(fname)
  File "/Users/hoechenberger/Development/mne-python/mne/io/open.py", line 50, in _fiff_get_fid
    fid.seek(0)
  File "/Users/hoechenberger/Development/mne-python/mne/io/open.py", line 33, in seek
    return self.fid.seek(offset, whence)
AttributeError: 'PosixPath' object has no attribute 'seek'
```

#### Describe your solution
My current workaround: convert the `PosixPath` object to a string:
```python
data = mne.read_epochs(str(data_file))
```

#### Describe possible alternatives
Ideally, there would be "built-in" `pathlib` support in MNE.


Discussion:
This is a relatively new problem because we basically used to assume that everything was a str/path-like. Now we also support file-like objects.

Maybe the most backward compatible thing to do would be to check if the object is file like with:
```
isinstance(something, (TextIOBase, BufferedIOBase, RawIOBase, IOBase))
```
and if not, cast with `str` and treat as a path.

@hoechenberger if this sounds reasonable to you, do you want to try making this change?
@larsoner I just checked how this was solved in PsychoPy, and we're using a small converter function there that converts pathlib objects to strings, but only works withPython 3.6+:

https://github.com/psychopy/psychopy/blob/86046113feaf8afbbeb65aec0cc9a0bc0acde580/psychopy/tools/filetools.py#L283-L304

Could that be a valid approach for MNE too?
In your approach, we would treat/cast `str` and `PosixPath` as `str`, and everything else `file-like`, which seems less general. For just one example, `tmpdir` pytest fixtures can be `str`-ified properly, but would need to be added as yet another case in your approach. Having "treat as str" as the default use case / mode should in this sense be more backward compatible, yet still work with any file-like object (with proper inheritance patterns at least).

If the file-like object approach ends up not working for some file-like objects, we can change it (e.g., by checking for `.read()` and `.seek()` methods) and this seems easier than trying to keep up with all `str`-like types we should support, or supported implicitly in the past.
Sounds reasonable. Will try to look into this, but cannot make any promises on a time frame. However, this issue seems to be sufficiently annoying to drive me to actually fix it ;)

Resolving patch:
diff --git a/mne/io/open.py b/mne/io/open.py
--- a/mne/io/open.py
+++ b/mne/io/open.py
@@ -14,7 +14,7 @@
 from .tag import read_tag_info, read_tag, read_big, Tag, _call_dict_names
 from .tree import make_dir_tree, dir_tree_find
 from .constants import FIFF
-from ..utils import logger, verbose
+from ..utils import logger, verbose, _file_like
 
 
 class _NoCloseRead(object):
@@ -38,16 +38,17 @@ def read(self, size=-1):
 
 def _fiff_get_fid(fname):
     """Open a FIF file with no additional parsing."""
-    if isinstance(fname, str):
+    if _file_like(fname):
+        fid = _NoCloseRead(fname)
+        fid.seek(0)
+    else:
+        fname = str(fname)
         if op.splitext(fname)[1].lower() == '.gz':
             logger.debug('Using gzip')
             fid = GzipFile(fname, "rb")  # Open in binary mode
         else:
             logger.debug('Using normal I/O')
             fid = open(fname, "rb")  # Open in binary mode
-    else:
-        fid = _NoCloseRead(fname)
-        fid.seek(0)
     return fid
 
 
diff --git a/mne/utils/__init__.py b/mne/utils/__init__.py
--- a/mne/utils/__init__.py
+++ b/mne/utils/__init__.py
@@ -24,7 +24,7 @@
 from ._logging import (verbose, logger, set_log_level, set_log_file,
                        use_log_level, catch_logging, warn, filter_out_warnings,
                        ETSContext)
-from .misc import (run_subprocess, _pl, _clean_names, pformat,
+from .misc import (run_subprocess, _pl, _clean_names, pformat, _file_like,
                    _explain_exception, _get_argvalues, sizeof_fmt,
                    running_subprocess, _DefaultEventParser)
 from .progressbar import ProgressBar
diff --git a/mne/utils/misc.py b/mne/utils/misc.py
--- a/mne/utils/misc.py
+++ b/mne/utils/misc.py
@@ -269,3 +269,13 @@ def sizeof_fmt(num):
         return '0 bytes'
     if num == 1:
         return '1 byte'
+
+
+def _file_like(obj):
+    # An alternative would be::
+    #
+    #   isinstance(obj, (TextIOBase, BufferedIOBase, RawIOBase, IOBase))
+    #
+    # but this might be more robust to file-like objects not properly
+    # inheriting from these classes:
+    return all(callable(getattr(obj, name, None)) for name in ('read', 'seek'))


Test patch:
diff --git a/mne/io/fiff/tests/test_raw_fiff.py b/mne/io/fiff/tests/test_raw_fiff.py
--- a/mne/io/fiff/tests/test_raw_fiff.py
+++ b/mne/io/fiff/tests/test_raw_fiff.py
@@ -8,6 +8,7 @@
 from functools import partial
 from io import BytesIO
 import os.path as op
+import pathlib
 import pickle
 import sys
 
@@ -1538,4 +1539,12 @@ def test_file_like(kind, preload, split, tmpdir):
     assert file_fid.closed
 
 
+def test_str_like():
+    """Test handling with str-like objects."""
+    fname = pathlib.Path(test_fif_fname)
+    raw_path = read_raw_fif(fname, preload=True)
+    raw_str = read_raw_fif(test_fif_fname, preload=True)
+    assert_allclose(raw_path._data, raw_str._data)
+
+
 run_tests_if_main()