Repository: mne-tools/mne-python

Issue:
Make `config_fname` available as `_raw_extras[0]` data in BTI reader
### Description of the problem

- xref: https://github.com/mne-tools/mne-bids/issues/1155

When reading in a 4D dataset, raw.__init_kwargs is correctly populated with the full path for the dataset, i.e.

raw._init_kwargs['pdf_fname'] = '/path_to_file/4D/c,rfDC'

but the field for the config file is missing the full path:

raw._init_kwargs['config_fname'] = 'config'

This is only an issue for programs using this field - such as the mne_bids cophyfile_bti_mod function

### Steps to reproduce

```Python
#requires definition of the 'meg_fname' filepath and 'topdir' filepath

import mne
from mne_bids import write_raw_bids

raw=mne.io.read_raw_bti(meg_fname, head_shape_fname=None, convert=False)
bids_path = BIDSPath(subject=subjid, session='1', task='rest',
                    run='01', root=topdir, suffix='meg')
write_raw_bids(raw, bids_path, overwrite=True)
```


### Link to data

_No response_

### Expected results

I expect write_raw_bids to create the BIDS dir with the 4D dataset

### Actual results

Traceback (most recent call last):

Cell In[7], line 48
write_raw_bids(raw, bids_path, overwrite=True)

File :12 in write_raw_bids

File ~/mambaforge/envs/enigma_meg/lib/python3.11/site-packages/mne_bids/write.py:1979 in write_raw_bids
copyfile_bti(raw_orig, raw_dir)

File ~/mambaforge/envs/enigma_meg/lib/python3.11/site-packages/mne_bids/copyfiles.py:600 in copyfile_bti
sh.copyfile(raw._init_kwargs['config_fname'],

File ~/mambaforge/envs/enigma_meg/lib/python3.11/shutil.py:256 in copyfile
with open(src, 'rb') as fsrc:

FileNotFoundError: [Errno 2] No such file or directory: 'config'

### Additional information

Using mne version 1.4.2 and mne-bids 0.12
Make `config_fname` available as `_raw_extras[0]` data in BTI reader
### Description of the problem

- xref: https://github.com/mne-tools/mne-bids/issues/1155

When reading in a 4D dataset, raw.__init_kwargs is correctly populated with the full path for the dataset, i.e.

raw._init_kwargs['pdf_fname'] = '/path_to_file/4D/c,rfDC'

but the field for the config file is missing the full path:

raw._init_kwargs['config_fname'] = 'config'

This is only an issue for programs using this field - such as the mne_bids cophyfile_bti_mod function

### Steps to reproduce

```Python
#requires definition of the 'meg_fname' filepath and 'topdir' filepath

import mne
from mne_bids import write_raw_bids

raw=mne.io.read_raw_bti(meg_fname, head_shape_fname=None, convert=False)
bids_path = BIDSPath(subject=subjid, session='1', task='rest',
                    run='01', root=topdir, suffix='meg')
write_raw_bids(raw, bids_path, overwrite=True)
```


### Link to data

_No response_

### Expected results

I expect write_raw_bids to create the BIDS dir with the 4D dataset

### Actual results

Traceback (most recent call last):

Cell In[7], line 48
write_raw_bids(raw, bids_path, overwrite=True)

File :12 in write_raw_bids

File ~/mambaforge/envs/enigma_meg/lib/python3.11/site-packages/mne_bids/write.py:1979 in write_raw_bids
copyfile_bti(raw_orig, raw_dir)

File ~/mambaforge/envs/enigma_meg/lib/python3.11/site-packages/mne_bids/copyfiles.py:600 in copyfile_bti
sh.copyfile(raw._init_kwargs['config_fname'],

File ~/mambaforge/envs/enigma_meg/lib/python3.11/shutil.py:256 in copyfile
with open(src, 'rb') as fsrc:

FileNotFoundError: [Errno 2] No such file or directory: 'config'

### Additional information

Using mne version 1.4.2 and mne-bids 0.12


Discussion:
@larsoner why do you think that this is an mne-bids issue?

https://github.com/mne-tools/mne-bids/blob/d483a617fc127bad8e9da197d6dcc468a67eda36/mne_bids/copyfiles.py#L619-L628
This is actually a mne-bids issue I think. MNE-Python should not modify `_init_kwargs` -- they should be taken exactly as they were passed by the user. So MNE-BIDS should probably adopt similar logic as here:

https://github.com/mne-tools/mne-python/blob/b5a9a25d3fac7e4058b91efcadf6561b85aa45a3/mne/io/bti/bti.py#L1223-L1230

Or if desired we could add something to `_raw_extras[0]`, which ends up being the `bti_info` dict from `bti.py`. That way the logic wouldn't have to be duplicated. In that case I can transfer back.
... in other words, this seems like an issue with how MNE-BIDS uses `_init_kwargs` incorrectly rather than MNE-Python needing to change anything about how `_init_kwargs` gets populated. But we could modify it to an issue where MNE-Python should provide *more complete information* about BTI files somewhere like `_raw_extras[0]` to make things more DRY across MNE-Python and MNE-BIDS.
ahh I see, thanks for the explanation!

> But we could modify it to an issue where MNE-Python should provide more complete information about BTI files somewhere like _raw_extras[0] to make things more DRY across MNE-Python and MNE-BIDS.

this sounds good to me 👍 
@larsoner why do you think that this is an mne-bids issue?

https://github.com/mne-tools/mne-bids/blob/d483a617fc127bad8e9da197d6dcc468a67eda36/mne_bids/copyfiles.py#L619-L628
This is actually a mne-bids issue I think. MNE-Python should not modify `_init_kwargs` -- they should be taken exactly as they were passed by the user. So MNE-BIDS should probably adopt similar logic as here:

https://github.com/mne-tools/mne-python/blob/b5a9a25d3fac7e4058b91efcadf6561b85aa45a3/mne/io/bti/bti.py#L1223-L1230

Or if desired we could add something to `_raw_extras[0]`, which ends up being the `bti_info` dict from `bti.py`. That way the logic wouldn't have to be duplicated. In that case I can transfer back.
... in other words, this seems like an issue with how MNE-BIDS uses `_init_kwargs` incorrectly rather than MNE-Python needing to change anything about how `_init_kwargs` gets populated. But we could modify it to an issue where MNE-Python should provide *more complete information* about BTI files somewhere like `_raw_extras[0]` to make things more DRY across MNE-Python and MNE-BIDS.
ahh I see, thanks for the explanation!

> But we could modify it to an issue where MNE-Python should provide more complete information about BTI files somewhere like _raw_extras[0] to make things more DRY across MNE-Python and MNE-BIDS.

this sounds good to me 👍 

Resolving patch:
diff --git a/mne/io/bti/bti.py b/mne/io/bti/bti.py
--- a/mne/io/bti/bti.py
+++ b/mne/io/bti/bti.py
@@ -15,7 +15,7 @@
 
 import numpy as np
 
-from ...utils import logger, verbose, _stamp_to_dt, path_like
+from ...utils import logger, verbose, _stamp_to_dt, path_like, _validate_type
 from ...transforms import combine_transforms, invert_transform, Transform
 from .._digitization import _make_bti_dig_points
 from ..constants import FIFF
@@ -923,7 +923,7 @@ def _read_bti_header_pdf(pdf_fname):
         ]
 
         info["extra_data"] = fid.read(start - fid.tell())
-        info["pdf_fname"] = pdf_fname
+        info["pdf"] = pdf_fname
 
     info["total_slices"] = sum(e["pts_in_epoch"] for e in info["epochs"])
 
@@ -1074,6 +1074,7 @@ def __init__(
         preload=False,
         verbose=None,
     ):  # noqa: D102
+        _validate_type(pdf_fname, ("path-like", BytesIO), "pdf_fname")
         info, bti_info = _get_bti_info(
             pdf_fname=pdf_fname,
             config_fname=config_fname,
@@ -1088,12 +1089,13 @@ def __init__(
         )
         bti_info["bti_ch_labels"] = [c["chan_label"] for c in bti_info["chs"]]
         # make Raw repr work if we have a BytesIO as input
-        if isinstance(pdf_fname, BytesIO):
-            pdf_fname = repr(pdf_fname)
+        filename = bti_info["pdf"]
+        if isinstance(filename, BytesIO):
+            filename = repr(filename)
         super(RawBTi, self).__init__(
             info,
             preload,
-            filenames=[pdf_fname],
+            filenames=[filename],
             raw_extras=[bti_info],
             last_samps=[bti_info["total_slices"] - 1],
             verbose=verbose,
@@ -1102,7 +1104,7 @@ def __init__(
     def _read_segment_file(self, data, idx, fi, start, stop, cals, mult):
         """Read a segment of data from a file."""
         bti_info = self._raw_extras[fi]
-        fname = bti_info["pdf_fname"]
+        fname_or_bytes = bti_info["pdf"]
         dtype = bti_info["dtype"]
         assert len(bti_info["chs"]) == self._raw_extras[fi]["orig_nchan"]
         n_channels = len(bti_info["chs"])
@@ -1115,7 +1117,7 @@ def _read_segment_file(self, data, idx, fi, start, stop, cals, mult):
         block_size = ((int(100e6) // n_bytes) // n_channels) * n_channels
         block_size = min(data_left, block_size)
         # extract data in chunks
-        with _bti_open(fname, "rb") as fid:
+        with _bti_open(fname_or_bytes, "rb") as fid:
             fid.seek(bti_info["bytes_per_slice"] * start, 0)
             for sample_start in np.arange(0, data_left, block_size) // n_channels:
                 count = min(block_size, data_left - sample_start * n_channels)
@@ -1252,6 +1254,13 @@ def _get_bti_info(
     bti_info = _read_bti_header(
         pdf_fname, config_fname, sort_by_ch_name=sort_by_ch_name
     )
+    extras = dict(
+        pdf_fname=pdf_fname,
+        head_shape_fname=head_shape_fname,
+        config_fname=config_fname,
+    )
+    for key, val in extras.items():
+        bti_info[key] = None if isinstance(val, BytesIO) else val
 
     dev_ctf_t = Transform(
         "ctf_meg", "ctf_head", _correct_trans(bti_info["bti_transform"][0])


Test patch:
diff --git a/mne/io/bti/tests/test_bti.py b/mne/io/bti/tests/test_bti.py
--- a/mne/io/bti/tests/test_bti.py
+++ b/mne/io/bti/tests/test_bti.py
@@ -2,10 +2,10 @@
 #
 # License: BSD-3-Clause
 
-import os
 from collections import Counter
-from io import BytesIO
 from functools import reduce, partial
+from io import BytesIO
+import os
 from pathlib import Path
 
 import numpy as np
@@ -47,7 +47,9 @@
 config_fnames = [base_dir / f"test_config_{a}" for a in archs]
 hs_fnames = [base_dir / f"test_hs_{a}" for a in archs]
 exported_fnames = [base_dir / f"exported4D_{a}_raw.fif" for a in archs]
-tmp_raw_fname = base_dir / "tmp_raw.fif"
+pdf_config_hs_exporteds = list(
+    zip(pdf_fnames, config_fnames, hs_fnames, exported_fnames)
+)
 
 testing_path_bti = testing.data_path(download=False) / "BTi"
 fname_2500 = testing_path_bti / "erm_HFH" / "c,rfDC"
@@ -115,157 +117,151 @@ def test_crop_append():
     assert y_.shape[0] == y.shape[0]
 
 
-def test_transforms():
+@pytest.mark.parametrize("pdf, config, hs, exported", pdf_config_hs_exporteds)
+def test_transforms(pdf, config, hs, exported):
     """Test transformations."""
     bti_trans = (0.0, 0.02, 0.11)
     bti_dev_t = Transform("ctf_meg", "meg", _get_bti_dev_t(0.0, bti_trans))
-    for (
-        pdf,
-        config,
-        hs,
-    ) in zip(pdf_fnames, config_fnames, hs_fnames):
-        raw = read_raw_bti(pdf, config, hs, preload=False)
-        dev_ctf_t = raw.info["dev_ctf_t"]
-        dev_head_t_old = raw.info["dev_head_t"]
-        ctf_head_t = raw.info["ctf_head_t"]
-
-        # 1) get BTI->Neuromag
-        bti_dev_t = Transform("ctf_meg", "meg", _get_bti_dev_t(0.0, bti_trans))
-
-        # 2) get Neuromag->BTI head
-        t = combine_transforms(
-            invert_transform(bti_dev_t), dev_ctf_t, "meg", "ctf_head"
-        )
-        # 3) get Neuromag->head
-        dev_head_t_new = combine_transforms(t, ctf_head_t, "meg", "head")
+    raw = read_raw_bti(pdf, config, hs, preload=False)
+    dev_ctf_t = raw.info["dev_ctf_t"]
+    dev_head_t_old = raw.info["dev_head_t"]
+    ctf_head_t = raw.info["ctf_head_t"]
+
+    # 1) get BTI->Neuromag
+    bti_dev_t = Transform("ctf_meg", "meg", _get_bti_dev_t(0.0, bti_trans))
 
-        assert_array_equal(dev_head_t_new["trans"], dev_head_t_old["trans"])
+    # 2) get Neuromag->BTI head
+    t = combine_transforms(invert_transform(bti_dev_t), dev_ctf_t, "meg", "ctf_head")
+    # 3) get Neuromag->head
+    dev_head_t_new = combine_transforms(t, ctf_head_t, "meg", "head")
+
+    assert_array_equal(dev_head_t_new["trans"], dev_head_t_old["trans"])
 
 
 @pytest.mark.slowtest
-def test_raw():
+@pytest.mark.parametrize("pdf, config, hs, exported", pdf_config_hs_exporteds)
+def test_raw(pdf, config, hs, exported, tmp_path):
     """Test bti conversion to Raw object."""
-    for pdf, config, hs, exported in zip(
-        pdf_fnames, config_fnames, hs_fnames, exported_fnames
-    ):
-        # rx = 2 if 'linux' in pdf else 0
-        pytest.raises(ValueError, read_raw_bti, pdf, "eggs", preload=False)
-        pytest.raises(ValueError, read_raw_bti, pdf, config, "spam", preload=False)
-        if tmp_raw_fname.exists():
-            os.remove(tmp_raw_fname)
-        ex = read_raw_fif(exported, preload=True)
-        ra = read_raw_bti(pdf, config, hs, preload=False)
-        assert "RawBTi" in repr(ra)
-        assert_equal(ex.ch_names[:NCH], ra.ch_names[:NCH])
-        assert_array_almost_equal(
-            ex.info["dev_head_t"]["trans"], ra.info["dev_head_t"]["trans"], 7
-        )
-        assert len(ex.info["dig"]) in (3563, 5154)
-        assert_dig_allclose(ex.info, ra.info, limit=100)
-        coil1, coil2 = [
-            np.concatenate([d["loc"].flatten() for d in r_.info["chs"][:NCH]])
-            for r_ in (ra, ex)
-        ]
-        assert_array_almost_equal(coil1, coil2, 7)
-
-        loc1, loc2 = [
-            np.concatenate([d["loc"].flatten() for d in r_.info["chs"][:NCH]])
-            for r_ in (ra, ex)
-        ]
-        assert_allclose(loc1, loc2)
-
-        assert_allclose(ra[:NCH][0], ex[:NCH][0])
-        assert_array_equal(
-            [c["range"] for c in ra.info["chs"][:NCH]],
-            [c["range"] for c in ex.info["chs"][:NCH]],
-        )
-        assert_array_equal(
-            [c["cal"] for c in ra.info["chs"][:NCH]],
-            [c["cal"] for c in ex.info["chs"][:NCH]],
-        )
-        assert_array_equal(ra._cals[:NCH], ex._cals[:NCH])
-
-        # check our transforms
-        for key in ("dev_head_t", "dev_ctf_t", "ctf_head_t"):
-            if ex.info[key] is None:
-                pass
-            else:
-                assert ra.info[key] is not None
-                for ent in ("to", "from", "trans"):
-                    assert_allclose(ex.info[key][ent], ra.info[key][ent])
-
-        ra.save(tmp_raw_fname)
-        re = read_raw_fif(tmp_raw_fname)
-        print(re)
-        for key in ("dev_head_t", "dev_ctf_t", "ctf_head_t"):
-            assert isinstance(re.info[key], dict)
-            this_t = re.info[key]["trans"]
-            assert_equal(this_t.shape, (4, 4))
-            # check that matrix by is not identity
-            assert not np.allclose(this_t, np.eye(4))
-        os.remove(tmp_raw_fname)
-
-
-def test_info_no_rename_no_reorder_no_pdf():
+    # rx = 2 if 'linux' in pdf else 0
+    pytest.raises(ValueError, read_raw_bti, pdf, "eggs", preload=False)
+    pytest.raises(ValueError, read_raw_bti, pdf, config, "spam", preload=False)
+    tmp_raw_fname = tmp_path / "tmp_raw.fif"
+    ex = read_raw_fif(exported, preload=True)
+    ra = read_raw_bti(pdf, config, hs, preload=False)
+    assert "RawBTi" in repr(ra)
+    assert_equal(ex.ch_names[:NCH], ra.ch_names[:NCH])
+    assert_array_almost_equal(
+        ex.info["dev_head_t"]["trans"], ra.info["dev_head_t"]["trans"], 7
+    )
+    assert len(ex.info["dig"]) in (3563, 5154)
+    assert_dig_allclose(ex.info, ra.info, limit=100)
+    coil1, coil2 = [
+        np.concatenate([d["loc"].flatten() for d in r_.info["chs"][:NCH]])
+        for r_ in (ra, ex)
+    ]
+    assert_array_almost_equal(coil1, coil2, 7)
+
+    loc1, loc2 = [
+        np.concatenate([d["loc"].flatten() for d in r_.info["chs"][:NCH]])
+        for r_ in (ra, ex)
+    ]
+    assert_allclose(loc1, loc2)
+
+    assert_allclose(ra[:NCH][0], ex[:NCH][0])
+    assert_array_equal(
+        [c["range"] for c in ra.info["chs"][:NCH]],
+        [c["range"] for c in ex.info["chs"][:NCH]],
+    )
+    assert_array_equal(
+        [c["cal"] for c in ra.info["chs"][:NCH]],
+        [c["cal"] for c in ex.info["chs"][:NCH]],
+    )
+    assert_array_equal(ra._cals[:NCH], ex._cals[:NCH])
+
+    # check our transforms
+    for key in ("dev_head_t", "dev_ctf_t", "ctf_head_t"):
+        if ex.info[key] is None:
+            pass
+        else:
+            assert ra.info[key] is not None
+            for ent in ("to", "from", "trans"):
+                assert_allclose(ex.info[key][ent], ra.info[key][ent])
+
+    # MNE-BIDS needs these
+    for key in ("pdf_fname", "config_fname", "head_shape_fname"):
+        assert os.path.isfile(ra._raw_extras[0][key])
+
+    ra.save(tmp_raw_fname)
+    re = read_raw_fif(tmp_raw_fname)
+    print(re)
+    for key in ("dev_head_t", "dev_ctf_t", "ctf_head_t"):
+        assert isinstance(re.info[key], dict)
+        this_t = re.info[key]["trans"]
+        assert_equal(this_t.shape, (4, 4))
+        # check that matrix by is not identity
+        assert not np.allclose(this_t, np.eye(4))
+
+
+@pytest.mark.parametrize("pdf, config, hs, exported", pdf_config_hs_exporteds)
+def test_info_no_rename_no_reorder_no_pdf(pdf, config, hs, exported):
     """Test private renaming, reordering and partial construction option."""
-    for pdf, config, hs in zip(pdf_fnames, config_fnames, hs_fnames):
-        info, bti_info = _get_bti_info(
-            pdf_fname=pdf,
-            config_fname=config,
-            head_shape_fname=hs,
-            rotation_x=0.0,
-            translation=(0.0, 0.02, 0.11),
-            convert=False,
-            ecg_ch="E31",
-            eog_ch=("E63", "E64"),
-            rename_channels=False,
-            sort_by_ch_name=False,
-        )
-        info2, bti_info = _get_bti_info(
-            pdf_fname=None,
-            config_fname=config,
-            head_shape_fname=hs,
-            rotation_x=0.0,
-            translation=(0.0, 0.02, 0.11),
-            convert=False,
-            ecg_ch="E31",
-            eog_ch=("E63", "E64"),
-            rename_channels=False,
-            sort_by_ch_name=False,
-        )
+    info, bti_info = _get_bti_info(
+        pdf_fname=pdf,
+        config_fname=config,
+        head_shape_fname=hs,
+        rotation_x=0.0,
+        translation=(0.0, 0.02, 0.11),
+        convert=False,
+        ecg_ch="E31",
+        eog_ch=("E63", "E64"),
+        rename_channels=False,
+        sort_by_ch_name=False,
+    )
+    info2, bti_info = _get_bti_info(
+        pdf_fname=None,
+        config_fname=config,
+        head_shape_fname=hs,
+        rotation_x=0.0,
+        translation=(0.0, 0.02, 0.11),
+        convert=False,
+        ecg_ch="E31",
+        eog_ch=("E63", "E64"),
+        rename_channels=False,
+        sort_by_ch_name=False,
+    )
 
-        assert_equal(info["ch_names"], [ch["ch_name"] for ch in info["chs"]])
-        assert_equal(
-            [n for n in info["ch_names"] if n.startswith("A")][:5],
-            ["A22", "A2", "A104", "A241", "A138"],
-        )
-        assert_equal(
-            [n for n in info["ch_names"] if n.startswith("A")][-5:],
-            ["A133", "A158", "A44", "A134", "A216"],
-        )
+    assert_equal(info["ch_names"], [ch["ch_name"] for ch in info["chs"]])
+    assert_equal(
+        [n for n in info["ch_names"] if n.startswith("A")][:5],
+        ["A22", "A2", "A104", "A241", "A138"],
+    )
+    assert_equal(
+        [n for n in info["ch_names"] if n.startswith("A")][-5:],
+        ["A133", "A158", "A44", "A134", "A216"],
+    )
 
-        info = pick_info(info, pick_types(info, meg=True, stim=True, resp=True))
-        info2 = pick_info(info2, pick_types(info2, meg=True, stim=True, resp=True))
+    info = pick_info(info, pick_types(info, meg=True, stim=True, resp=True))
+    info2 = pick_info(info2, pick_types(info2, meg=True, stim=True, resp=True))
 
-        assert info["sfreq"] is not None
-        assert info["lowpass"] is not None
-        assert info["highpass"] is not None
-        assert info["meas_date"] is not None
+    assert info["sfreq"] is not None
+    assert info["lowpass"] is not None
+    assert info["highpass"] is not None
+    assert info["meas_date"] is not None
 
-        assert_equal(info2["sfreq"], None)
-        assert_equal(info2["lowpass"], None)
-        assert_equal(info2["highpass"], None)
-        assert_equal(info2["meas_date"], None)
+    assert_equal(info2["sfreq"], None)
+    assert_equal(info2["lowpass"], None)
+    assert_equal(info2["highpass"], None)
+    assert_equal(info2["meas_date"], None)
 
-        assert_equal(info["ch_names"], info2["ch_names"])
-        assert_equal(info["ch_names"], info2["ch_names"])
-        for key in ["dev_ctf_t", "dev_head_t", "ctf_head_t"]:
-            assert_array_equal(info[key]["trans"], info2[key]["trans"])
+    assert_equal(info["ch_names"], info2["ch_names"])
+    assert_equal(info["ch_names"], info2["ch_names"])
+    for key in ["dev_ctf_t", "dev_head_t", "ctf_head_t"]:
+        assert_array_equal(info[key]["trans"], info2[key]["trans"])
 
-        assert_array_equal(
-            np.array([ch["loc"] for ch in info["chs"]]),
-            np.array([ch["loc"] for ch in info2["chs"]]),
-        )
+    assert_array_equal(
+        np.array([ch["loc"] for ch in info["chs"]]),
+        np.array([ch["loc"] for ch in info2["chs"]]),
+    )
 
     # just check reading data | corner case
     raw1 = read_raw_bti(
@@ -293,7 +289,8 @@ def test_info_no_rename_no_reorder_no_pdf():
     assert_array_equal(bti_ch_labels_2, raw2.ch_names)
 
 
-def test_no_conversion():
+@pytest.mark.parametrize("pdf, config, hs, exported", pdf_config_hs_exporteds)
+def test_no_conversion(pdf, config, hs, exported):
     """Test bti no-conversion option."""
     get_info = partial(
         _get_bti_info,
@@ -306,129 +303,126 @@ def test_no_conversion():
         sort_by_ch_name=False,
     )
 
-    for pdf, config, hs in zip(pdf_fnames, config_fnames, hs_fnames):
-        raw_info, _ = get_info(pdf, config, hs, convert=False)
-        raw_info_con = read_raw_bti(
-            pdf_fname=pdf,
-            config_fname=config,
-            head_shape_fname=hs,
-            convert=True,
-            preload=False,
-        ).info
-
-        pick_info(
-            raw_info_con, pick_types(raw_info_con, meg=True, ref_meg=True), copy=False
-        )
-        pick_info(raw_info, pick_types(raw_info, meg=True, ref_meg=True), copy=False)
-        bti_info = _read_bti_header(pdf, config)
-        dev_ctf_t = _correct_trans(bti_info["bti_transform"][0])
-        assert_array_equal(dev_ctf_t, raw_info["dev_ctf_t"]["trans"])
-        assert_array_equal(raw_info["dev_head_t"]["trans"], np.eye(4))
-        assert_array_equal(raw_info["ctf_head_t"]["trans"], np.eye(4))
-
-        nasion, lpa, rpa, hpi, dig_points = _read_head_shape(hs)
-        dig, t, _ = _make_bti_dig_points(
-            nasion, lpa, rpa, hpi, dig_points, convert=False, use_hpi=False
+    raw_info, _ = get_info(pdf, config, hs, convert=False)
+    raw_info_con = read_raw_bti(
+        pdf_fname=pdf,
+        config_fname=config,
+        head_shape_fname=hs,
+        convert=True,
+        preload=False,
+    ).info
+
+    pick_info(
+        raw_info_con, pick_types(raw_info_con, meg=True, ref_meg=True), copy=False
+    )
+    pick_info(raw_info, pick_types(raw_info, meg=True, ref_meg=True), copy=False)
+    bti_info = _read_bti_header(pdf, config)
+    dev_ctf_t = _correct_trans(bti_info["bti_transform"][0])
+    assert_array_equal(dev_ctf_t, raw_info["dev_ctf_t"]["trans"])
+    assert_array_equal(raw_info["dev_head_t"]["trans"], np.eye(4))
+    assert_array_equal(raw_info["ctf_head_t"]["trans"], np.eye(4))
+
+    nasion, lpa, rpa, hpi, dig_points = _read_head_shape(hs)
+    dig, t, _ = _make_bti_dig_points(
+        nasion, lpa, rpa, hpi, dig_points, convert=False, use_hpi=False
+    )
+
+    assert_array_equal(t["trans"], np.eye(4))
+
+    for ii, (old, new, con) in enumerate(
+        zip(dig, raw_info["dig"], raw_info_con["dig"])
+    ):
+        assert_equal(old["ident"], new["ident"])
+        assert_array_equal(old["r"], new["r"])
+        assert not np.allclose(old["r"], con["r"])
+
+        if ii > 10:
+            break
+
+    ch_map = {ch["chan_label"]: ch["loc"] for ch in bti_info["chs"]}
+
+    for ii, ch_label in enumerate(raw_info["ch_names"]):
+        if not ch_label.startswith("A"):
+            continue
+        t1 = ch_map[ch_label]  # correction already performed in bti_info
+        t2 = raw_info["chs"][ii]["loc"]
+        t3 = raw_info_con["chs"][ii]["loc"]
+        assert_allclose(t1, t2, atol=1e-15)
+        assert not np.allclose(t1, t3)
+        idx_a = raw_info_con["ch_names"].index("MEG 001")
+        idx_b = raw_info["ch_names"].index("A22")
+        assert_equal(raw_info_con["chs"][idx_a]["coord_frame"], FIFF.FIFFV_COORD_DEVICE)
+        assert_equal(
+            raw_info["chs"][idx_b]["coord_frame"], FIFF.FIFFV_MNE_COORD_4D_HEAD
         )
 
-        assert_array_equal(t["trans"], np.eye(4))
-
-        for ii, (old, new, con) in enumerate(
-            zip(dig, raw_info["dig"], raw_info_con["dig"])
-        ):
-            assert_equal(old["ident"], new["ident"])
-            assert_array_equal(old["r"], new["r"])
-            assert not np.allclose(old["r"], con["r"])
-
-            if ii > 10:
-                break
-
-        ch_map = {ch["chan_label"]: ch["loc"] for ch in bti_info["chs"]}
-
-        for ii, ch_label in enumerate(raw_info["ch_names"]):
-            if not ch_label.startswith("A"):
-                continue
-            t1 = ch_map[ch_label]  # correction already performed in bti_info
-            t2 = raw_info["chs"][ii]["loc"]
-            t3 = raw_info_con["chs"][ii]["loc"]
-            assert_allclose(t1, t2, atol=1e-15)
-            assert not np.allclose(t1, t3)
-            idx_a = raw_info_con["ch_names"].index("MEG 001")
-            idx_b = raw_info["ch_names"].index("A22")
-            assert_equal(
-                raw_info_con["chs"][idx_a]["coord_frame"], FIFF.FIFFV_COORD_DEVICE
-            )
-            assert_equal(
-                raw_info["chs"][idx_b]["coord_frame"], FIFF.FIFFV_MNE_COORD_4D_HEAD
-            )
-
-
-def test_bytes_io():
+
+@pytest.mark.parametrize("pdf, config, hs, exported", pdf_config_hs_exporteds)
+def test_bytes_io(pdf, config, hs, exported):
     """Test bti bytes-io API."""
-    for pdf, config, hs in zip(pdf_fnames, config_fnames, hs_fnames):
-        raw = read_raw_bti(pdf, config, hs, convert=True, preload=False)
+    raw = read_raw_bti(pdf, config, hs, convert=True, preload=False)
 
-        with open(pdf, "rb") as fid:
-            pdf = BytesIO(fid.read())
-        with open(config, "rb") as fid:
-            config = BytesIO(fid.read())
-        with open(hs, "rb") as fid:
-            hs = BytesIO(fid.read())
+    with open(pdf, "rb") as fid:
+        pdf = BytesIO(fid.read())
+    with open(config, "rb") as fid:
+        config = BytesIO(fid.read())
+    with open(hs, "rb") as fid:
+        hs = BytesIO(fid.read())
 
-        raw2 = read_raw_bti(pdf, config, hs, convert=True, preload=False)
-        repr(raw2)
-        assert_array_equal(raw[:][0], raw2[:][0])
+    raw2 = read_raw_bti(pdf, config, hs, convert=True, preload=False)
+    repr(raw2)
+    assert_array_equal(raw[:][0], raw2[:][0])
 
 
-def test_setup_headshape():
+@pytest.mark.parametrize("hs", hs_fnames)
+def test_setup_headshape(hs):
     """Test reading bti headshape."""
-    for hs in hs_fnames:
-        nasion, lpa, rpa, hpi, dig_points = _read_head_shape(hs)
-        dig, t, _ = _make_bti_dig_points(nasion, lpa, rpa, hpi, dig_points)
+    nasion, lpa, rpa, hpi, dig_points = _read_head_shape(hs)
+    dig, t, _ = _make_bti_dig_points(nasion, lpa, rpa, hpi, dig_points)
 
-        expected = {"kind", "ident", "r"}
-        found = set(reduce(lambda x, y: list(x) + list(y), [d.keys() for d in dig]))
-        assert not expected - found
+    expected = {"kind", "ident", "r"}
+    found = set(reduce(lambda x, y: list(x) + list(y), [d.keys() for d in dig]))
+    assert not expected - found
 
 
-def test_nan_trans():
+@pytest.mark.parametrize("pdf, config, hs, exported", pdf_config_hs_exporteds)
+def test_nan_trans(pdf, config, hs, exported):
     """Test unlikely case that the device to head transform is empty."""
-    for ii, pdf_fname in enumerate(pdf_fnames):
-        bti_info = _read_bti_header(pdf_fname, config_fnames[ii], sort_by_ch_name=True)
+    bti_info = _read_bti_header(pdf, config, sort_by_ch_name=True)
 
-        dev_ctf_t = Transform(
-            "ctf_meg", "ctf_head", _correct_trans(bti_info["bti_transform"][0])
-        )
+    dev_ctf_t = Transform(
+        "ctf_meg", "ctf_head", _correct_trans(bti_info["bti_transform"][0])
+    )
 
-        # reading params
-        convert = True
-        rotation_x = 0.0
-        translation = (0.0, 0.02, 0.11)
-        bti_dev_t = _get_bti_dev_t(rotation_x, translation)
-        bti_dev_t = Transform("ctf_meg", "meg", bti_dev_t)
-        ecg_ch = "E31"
-        eog_ch = ("E63", "E64")
-
-        # read parts of info to get trans
-        bti_ch_names = list()
-        for ch in bti_info["chs"]:
-            ch_name = ch["name"]
-            if not ch_name.startswith("A"):
-                ch_name = ch.get("chan_label", ch_name)
-            bti_ch_names.append(ch_name)
-
-        neuromag_ch_names = _rename_channels(bti_ch_names, ecg_ch=ecg_ch, eog_ch=eog_ch)
-        ch_mapping = zip(bti_ch_names, neuromag_ch_names)
-
-        # add some nan in some locations!
-        dev_ctf_t["trans"][:, 3] = np.nan
-        _check_nan_dev_head_t(dev_ctf_t)
-        for idx, (chan_4d, chan_neuromag) in enumerate(ch_mapping):
-            loc = bti_info["chs"][idx]["loc"]
-            if loc is not None:
-                if convert:
-                    t = _loc_to_coil_trans(bti_info["chs"][idx]["loc"])
-                    t = _convert_coil_trans(t, dev_ctf_t, bti_dev_t)
+    # reading params
+    convert = True
+    rotation_x = 0.0
+    translation = (0.0, 0.02, 0.11)
+    bti_dev_t = _get_bti_dev_t(rotation_x, translation)
+    bti_dev_t = Transform("ctf_meg", "meg", bti_dev_t)
+    ecg_ch = "E31"
+    eog_ch = ("E63", "E64")
+
+    # read parts of info to get trans
+    bti_ch_names = list()
+    for ch in bti_info["chs"]:
+        ch_name = ch["name"]
+        if not ch_name.startswith("A"):
+            ch_name = ch.get("chan_label", ch_name)
+        bti_ch_names.append(ch_name)
+
+    neuromag_ch_names = _rename_channels(bti_ch_names, ecg_ch=ecg_ch, eog_ch=eog_ch)
+    ch_mapping = zip(bti_ch_names, neuromag_ch_names)
+
+    # add some nan in some locations!
+    dev_ctf_t["trans"][:, 3] = np.nan
+    _check_nan_dev_head_t(dev_ctf_t)
+    for idx, (chan_4d, chan_neuromag) in enumerate(ch_mapping):
+        loc = bti_info["chs"][idx]["loc"]
+        if loc is not None:
+            if convert:
+                t = _loc_to_coil_trans(bti_info["chs"][idx]["loc"])
+                t = _convert_coil_trans(t, dev_ctf_t, bti_dev_t)
 
 
 @testing.requires_testing_data