Repository: mne-tools/mne-python

Issue:
mne.io.read_info() of CTF .ds returns IsADirectoryError
### Description of the problem

Hi all,

I have CTF 275 channel .ds, which reads normally with `mne.io.read_raw_ctf()`.

When imported, the `raw.info` attribute 'appears' to be a complete `mne.Info` object, with about 19 keys.

However, `mne.io.read_info()` returns error: 
`IsADirectoryError: [Errno 21] Is a directory 'path/to/my/data.ds'`,

~~When, later in script, I run `mne.make_forward_solution()`, which requires an `mne.Info` arg, I get the same `IsADirectoryError` while checking the inputs, if I pass `raw.info` (as opposed to trying a `read_info()` earlier).~~ (Edit: this was a typo on my behalf)

While this is technically correct that the CTF .ds spec is a directory, is this expected behaviour? I don't see any specific docs about doing format conversion for CTF (eg .ds to .fif) to produce an info object.

Thanks,
Matt

### Steps to reproduce

```Python
import mne
from mne.datasets.brainstorm import bst_auditory

data_path = bst_auditory.data_path()
subject = 'bst_auditory'
subjects_dir = data_path / 'subjects'

raw_fname = data_path / "MEG" / subject / "S01_AEF_20131218_01.ds"

raw = mne.io.read_raw_ctf(raw_fname)
print("Info from read_raw_ctf raw.info:")
print(raw.info)

info = mne.io.read_info(raw_fname)
```


### Link to data

_No response_

### Expected results

`mne.io.read_info` of CTF data returns an `mne.Info` object,
or,
`raw.info` from `mne.io.read_raw_ctf` is interpreted as an `mne.Info` object

### Actual results

```
In [8]: raw = mne.io.read_raw_ctf(raw_fname)
ds directory : /home/matt/mne_data/MNE-brainstorm-data/bst_auditory/MEG/bst_auditory/S01_AEF_20131218_01.ds
    res4 data read.
    hc data read.
    Separate EEG position data file read.
    Quaternion matching (desired vs. transformed):
       2.51   74.26    0.00 mm <->    2.51   74.26    0.00 mm (orig :  -56.69   50.20 -264.38 mm) diff =    0.000 mm
      -2.51  -74.26    0.00 mm <->   -2.51  -74.26    0.00 mm (orig :   50.89  -52.31 -265.88 mm) diff =    0.000 mm
     108.63    0.00    0.00 mm <->  108.63    0.00    0.00 mm (orig :   67.41   77.68 -239.53 mm) diff =    0.000 mm
    Coordinate transformations established.
    Reading digitizer points from ['/home/matt/mne_data/MNE-brainstorm-data/bst_auditory/MEG/bst_auditory/S01_AEF_20131218_01.ds/S01_20131218_01.pos']...
    Polhemus data for 3 HPI coils added
    Device coordinate locations for 3 HPI coils added
    5 extra points added to Polhemus data.
    Measurement info composed.
Finding samples for /home/matt/mne_data/MNE-brainstorm-data/bst_auditory/MEG/bst_auditory/S01_AEF_20131218_01.ds/S01_AEF_20131218_01.meg4: 
    System clock channel is available, checking which samples are valid.
    360 x 2400 = 864000 samples from 340 chs
Current compensation grade : 3
``
```
```
In [9]: print(raw.info)
<Info | 16 non-empty values
 bads: []
 ch_names: UDIO001, UPPT001, UTRG001, SCLK01-177, BG1-4408, BG2-4408, ...
 chs: 3 Stimulus, 32 misc, 26 Reference Magnetometers, 274 Magnetometers, 5 EEG
 comps: 5 items (list)
 ctf_head_t: CTF/4D/KIT head -> head transform
 custom_ref_applied: False
 dev_ctf_t: MEG device -> CTF/4D/KIT head transform
 dev_head_t: MEG device -> head transform
 dig: 263 items (3 Cardinal, 260 Extra)
 experimenter: EAB
 highpass: 0.0 Hz
 hpi_results: 1 item (list)
 lowpass: 1200.0 Hz
 meas_date: 2013-12-18 09:43:00 UTC
 meas_id: 4 items (dict)
 nchan: 340
 projs: []
 sfreq: 2400.0 Hz
 subject_info: 1 item (dict)
>

```

```
In [10]: info = mne.io.read_info(raw_fname)
---------------------------------------------------------------------------
IsADirectoryError                         Traceback (most recent call last)
Cell In[10], line 1
----> 1 info = mne.io.read_info(raw_fname)

File <decorator-gen-42>:12, in read_info(fname, verbose)

File ~/envs/mne/lib/python3.10/site-packages/mne/io/meas_info.py:1992, in read_info(fname, verbose)
   1978 @verbose
   1979 def read_info(fname, verbose=None):
   1980     """Read measurement info from a file.
   1981 
   1982     Parameters
   (...)
   1990     %(info_not_none)s
   1991     """
-> 1992     f, tree, _ = fiff_open(fname)
   1993     with f as fid:
   1994         info = read_meas_info(fid, tree)[0]

File <decorator-gen-10>:12, in fiff_open(fname, preload, verbose)

File ~/envs/mne/lib/python3.10/site-packages/mne/io/open.py:124, in fiff_open(fname, preload, verbose)
    100 @verbose
    101 def fiff_open(fname, preload=False, verbose=None):
    102     """Open a FIF file.
    103 
    104     Parameters
   (...)
    122         A list of tags.
    123     """
--> 124     fid = _fiff_get_fid(fname)
    125     try:
    126         return _fiff_open(fname, fid, preload)

File ~/envs/mne/lib/python3.10/site-packages/mne/io/open.py:52, in _fiff_get_fid(fname)
     50     else:
     51         logger.debug("Using normal I/O")
---> 52         fid = open(fname, "rb")  # Open in binary mode
     53 return fid

IsADirectoryError: [Errno 21] Is a directory: '/home/matt/mne_data/MNE-brainstorm-data/bst_auditory/MEG/bst_auditory/S01_AEF_20131218_01.ds'
```

### Additional information

Windows 11/WSL2/Ubuntu 22.04 (such that FreeSurfer is accessible also within WSL)

```
Platform             Linux-5.15.90.1-microsoft-standard-WSL2-x86_64-with-glibc2.35
Python               3.10.12 (main, Jun 11 2023, 05:26:28) [GCC 11.4.0]
Executable           /home/matt/envs/mne/bin/python
CPU                  x86_64 (16 cores)
Memory               31.3 GB

Core
├☑ mne               1.5.1
├☑ numpy             1.26.0 (OpenBLAS 0.3.23.dev with 16 threads)
├☑ scipy             1.11.3
├☑ matplotlib        3.8.0 (backend=module://matplotlib_inline.backend_inline)
├☑ pooch             1.7.0
└☑ jinja2            3.1.2

Numerical (optional)
├☑ sklearn           1.3.1
├☑ nibabel           5.1.0
├☑ pandas            2.1.1
└☐ unavailable       numba, nilearn, dipy, openmeeg, cupy

Visualization (optional)
├☑ pyvistaqt         0.11.0
├☑ vtk               9.2.6
├☑ ipympl            0.9.3
├☑ mne-qt-browser    0.5.2
├☑ ipywidgets        8.1.1
├☑ trame_client      2.12.0
├☑ trame_server      2.12.0
├☑ trame_vtk         2.5.8
├☑ trame_vuetify     2.3.1
└☐ unavailable       pyvista, qtpy, pyqtgraph

Ecosystem (optional)
├☑ mne-connectivity  0.5.0
├☑ mne-icalabel      0.4
└☐ unavailable       mne-bids, mne-nirs, mne-features, mne-bids-pipeline

```


Discussion:
`read_info` is really only meant to read FIF info files. You should be able to use
```
info = mne.io.read_raw_ctf(...).info
```
instead. If this makes sense would you be up for making a PR to improve the documentation of `read_info` to mention that it's designed for FIF info only but you can use code like the above to get info for other file formats?
I confirm that `read_info` works with sample_audvis_raw.fif.

I must have had a typo, as after going back to my script, the ctf raw.info appears to do the job.

I'll get to a PR for the read_info docs this afternoon.
@larsoner Why don't we fail early if the filename extension is not .fif?
Checking for `.fif` / `.fif.gz` is a good idea. I think `check_fname` could do that and emit a warning
Why just a warning and not fail hard?
Technically you can read and write FIF (and many other formats) just fine with no file extension. IIRC for many other functions like read raw fif etc we just emit a warning when you use non standard naming so we should continue that practice

Resolving patch:
diff --git a/mne/_fiff/meas_info.py b/mne/_fiff/meas_info.py
--- a/mne/_fiff/meas_info.py
+++ b/mne/_fiff/meas_info.py
@@ -80,6 +80,7 @@
     _check_on_missing,
     fill_doc,
     _check_fname,
+    check_fname,
     repr_html,
 )
 from ._digitization import (
@@ -2006,6 +2007,8 @@ def read_info(fname, verbose=None):
     -------
     %(info_not_none)s
     """
+    check_fname(fname, "Info", (".fif", ".fif.gz"))
+    fname = _check_fname(fname, must_exist=True, overwrite="read")
     f, tree, _ = fiff_open(fname)
     with f as fid:
         info = read_meas_info(fid, tree)[0]
diff --git a/mne/forward/forward.py b/mne/forward/forward.py
--- a/mne/forward/forward.py
+++ b/mne/forward/forward.py
@@ -81,6 +81,7 @@
     _stamp_to_dt,
     _on_missing,
     repr_html,
+    _import_h5io_funcs,
 )
 from ..label import Label
 
@@ -165,6 +166,18 @@ def copy(self):
         """Copy the Forward instance."""
         return Forward(deepcopy(self))
 
+    @verbose
+    def save(self, fname, *, overwrite=False, verbose=None):
+        """Save the forward solution.
+
+        Parameters
+        ----------
+        %(fname_fwd)s
+        %(overwrite)s
+        %(verbose)s
+        """
+        write_forward_solution(fname, self, overwrite=overwrite)
+
     def _get_src_type_and_ori_for_repr(self):
         src_types = np.array([src["type"] for src in self["src"]])
 
@@ -520,7 +533,8 @@ def read_forward_solution(fname, include=(), exclude=(), *, ordered=None, verbos
     Parameters
     ----------
     fname : path-like
-        The file name, which should end with ``-fwd.fif`` or ``-fwd.fif.gz``.
+        The file name, which should end with ``-fwd.fif``, ``-fwd.fif.gz``,
+        ``_fwd.fif``, ``_fwd.fif.gz``, ``-fwd.h5``, or ``_fwd.h5``.
     include : list, optional
         List of names of channels to include. If empty all channels
         are included.
@@ -554,11 +568,15 @@ def read_forward_solution(fname, include=(), exclude=(), *, ordered=None, verbos
     forward solution with :func:`read_forward_solution`.
     """
     check_fname(
-        fname, "forward", ("-fwd.fif", "-fwd.fif.gz", "_fwd.fif", "_fwd.fif.gz")
+        fname,
+        "forward",
+        ("-fwd.fif", "-fwd.fif.gz", "_fwd.fif", "_fwd.fif.gz", "-fwd.h5", "_fwd.h5"),
     )
     fname = _check_fname(fname=fname, must_exist=True, overwrite="read")
     #   Open the file, create directory
     logger.info("Reading forward solution from %s..." % fname)
+    if fname.suffix == ".h5":
+        return _read_forward_hdf5(fname)
     f, tree, _ = fiff_open(fname)
     with f as fid:
         #   Find all forward solutions
@@ -861,9 +879,7 @@ def write_forward_solution(fname, fwd, overwrite=False, verbose=None):
 
     Parameters
     ----------
-    fname : path-like
-        File name to save the forward solution to. It should end with
-        ``-fwd.fif`` or ``-fwd.fif.gz``.
+    %(fname_fwd)s
     fwd : Forward
         Forward solution.
     %(overwrite)s
@@ -889,13 +905,28 @@ def write_forward_solution(fname, fwd, overwrite=False, verbose=None):
     forward solution with :func:`read_forward_solution`.
     """
     check_fname(
-        fname, "forward", ("-fwd.fif", "-fwd.fif.gz", "_fwd.fif", "_fwd.fif.gz")
+        fname,
+        "forward",
+        ("-fwd.fif", "-fwd.fif.gz", "_fwd.fif", "_fwd.fif.gz", "-fwd.h5", "_fwd.h5"),
     )
 
     # check for file existence and expand `~` if present
     fname = _check_fname(fname, overwrite)
-    with start_and_end_file(fname) as fid:
-        _write_forward_solution(fid, fwd)
+    if fname.suffix == ".h5":
+        _write_forward_hdf5(fname, fwd)
+    else:
+        with start_and_end_file(fname) as fid:
+            _write_forward_solution(fid, fwd)
+
+
+def _write_forward_hdf5(fname, fwd):
+    _, write_hdf5 = _import_h5io_funcs()
+    write_hdf5(fname, dict(fwd=fwd), overwrite=True)
+
+
+def _read_forward_hdf5(fname):
+    read_hdf5, _ = _import_h5io_funcs()
+    return Forward(read_hdf5(fname)["fwd"])
 
 
 def _write_forward_solution(fid, fwd):
diff --git a/mne/utils/docs.py b/mne/utils/docs.py
--- a/mne/utils/docs.py
+++ b/mne/utils/docs.py
@@ -1694,6 +1694,15 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75):
     Name of the output file.
 """
 
+docdict[
+    "fname_fwd"
+] = """
+fname : path-like
+    File name to save the forward solution to. It should end with
+    ``-fwd.fif`` or ``-fwd.fif.gz`` to save to FIF, or ``-fwd.h5`` to save to
+    HDF5.
+"""
+
 docdict[
     "fnirs"
 ] = """


Test patch:
diff --git a/mne/_fiff/tests/test_meas_info.py b/mne/_fiff/tests/test_meas_info.py
--- a/mne/_fiff/tests/test_meas_info.py
+++ b/mne/_fiff/tests/test_meas_info.py
@@ -345,6 +345,14 @@ def test_read_write_info(tmp_path):
         write_info(fname, info)
 
 
+@testing.requires_testing_data
+def test_dir_warning():
+    """Test that trying to read a bad filename emits a warning before an error."""
+    with pytest.raises(OSError, match="directory"):
+        with pytest.warns(RuntimeWarning, match="foo"):
+            read_info(ctf_fname)
+
+
 def test_io_dig_points(tmp_path):
     """Test Writing for dig files."""
     dest = tmp_path / "test.txt"
diff --git a/mne/forward/tests/test_forward.py b/mne/forward/tests/test_forward.py
--- a/mne/forward/tests/test_forward.py
+++ b/mne/forward/tests/test_forward.py
@@ -197,6 +197,15 @@ def test_io_forward(tmp_path):
     fwd_read = read_forward_solution(fname_temp)
     assert_forward_allclose(fwd, fwd_read)
 
+    h5py = pytest.importorskip("h5py")
+    pytest.importorskip("h5io")
+    fname_h5 = fname_temp.with_suffix(".h5")
+    fwd.save(fname_h5)
+    with h5py.File(fname_h5, "r"):
+        pass  # just checks for hdf5-ness
+    fwd_read = read_forward_solution(fname_h5)
+    assert_forward_allclose(fwd, fwd_read)
+
 
 @testing.requires_testing_data
 def test_apply_forward():