Repository: mne-tools/mne-bids

Issue:
mne_bids.BIDSPath throws cryptic error for non-string parameters
```
import mne_bids
bids_path = mne_bids.BIDSPath(subject=1)
```

Gives this stack trace

```
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/alexrockhill/software/mne-bids/mne_bids/path.py", line 277, in __init__
    suffix=suffix, extension=extension)
  File "/Users/alexrockhill/software/mne-bids/mne_bids/path.py", line 514, in update
    _check_key_val(key, val)
  File "/Users/alexrockhill/software/mne-bids/mne_bids/utils.py", line 183, in _check_key_val
    if any(ii in val for ii in ['-', '_', '/']):
  File "/Users/alexrockhill/software/mne-bids/mne_bids/utils.py", line 183, in <genexpr>
    if any(ii in val for ii in ['-', '_', '/']):
TypeError: argument of type 'int' is not iterable
```

but should give something more informative or just cast to string.


Discussion:

Resolving patch:
diff --git a/mne_bids/config.py b/mne_bids/config.py
--- a/mne_bids/config.py
+++ b/mne_bids/config.py
@@ -101,6 +101,21 @@
                                'space': 'space', 'rec': 'recording',
                                'split': 'split', 'suffix': 'suffix'}
 
+# See: https://bids-specification.readthedocs.io/en/latest/99-appendices/04-entity-table.html#encephalography-eeg-ieeg-and-meg  # noqa
+ENTITY_VALUE_TYPE = {
+    'subject': 'label',
+    'session': 'label',
+    'task': 'label',
+    'run': 'index',
+    'processing': 'label',
+    'recording': 'label',
+    'space': 'label',
+    'acquisition': 'label',
+    'split': 'index',
+    'suffix': 'label',
+    'extension': 'label'
+}
+
 # accepted BIDS formats, which may be subject to change
 # depending on the specification
 BIDS_IEEG_COORDINATE_FRAMES = ['acpc', 'pixels', 'other']
diff --git a/mne_bids/path.py b/mne_bids/path.py
--- a/mne_bids/path.py
+++ b/mne_bids/path.py
@@ -13,13 +13,13 @@
 from datetime import datetime
 
 import numpy as np
-from mne.utils import warn, logger
+from mne.utils import warn, logger, _validate_type
 
 from mne_bids.config import (
     ALLOWED_PATH_ENTITIES, ALLOWED_FILENAME_EXTENSIONS,
     ALLOWED_FILENAME_SUFFIX, ALLOWED_PATH_ENTITIES_SHORT,
     ALLOWED_DATATYPES, SUFFIX_TO_DATATYPE, ALLOWED_DATATYPE_EXTENSIONS,
-    reader)
+    reader, ENTITY_VALUE_TYPE)
 from mne_bids.utils import (_check_key_val, _check_empty_room_basename,
                             _check_types, param_regex,
                             _ensure_tuple)
@@ -41,10 +41,9 @@ def _get_matched_empty_room(bids_path):
     bids_fname = bids_path.update(suffix=datatype,
                                   root=bids_root).fpath
     _, ext = _parse_ext(bids_fname)
+    extra_params = None
     if ext == '.fif':
         extra_params = dict(allow_maxshield=True)
-    else:
-        extra_params = None
 
     raw = read_raw_bids(bids_path=bids_path, extra_params=extra_params)
     if raw.info['meas_date'] is None:
@@ -111,10 +110,9 @@ def _get_matched_empty_room(bids_path):
 
         if er_meas_date is None:  # No luck so far! Check info['meas_date']
             _, ext = _parse_ext(er_fname)
+            extra_params = None
             if ext == '.fif':
                 extra_params = dict(allow_maxshield=True)
-            else:
-                extra_params = None
 
             er_raw = read_raw_bids(bids_path=er_bids_path,
                                    extra_params=extra_params)
@@ -483,15 +481,33 @@ def update(self, *, check=None, **kwargs):
         >>> print(bids_path.basename)
         sub-test_ses-two_acq-test_ieeg.vhdr
         """
-        run = kwargs.get('run')
-        if run is not None and not isinstance(run, str):
-            # Ensure that run is a string
-            kwargs['run'] = '{:02}'.format(run)
+        for key, val in kwargs.items():
+            if key == 'root':
+                _validate_type(val, types=('path-like', None), item_name=key)
+                continue
+
+            if key == 'datatype':
+                if val is not None and val not in ALLOWED_DATATYPES:
+                    raise ValueError(f'datatype ({val}) is not valid. '
+                                     f'Should be one of '
+                                     f'{ALLOWED_DATATYPES}')
+                else:
+                    continue
+
+            if key not in ENTITY_VALUE_TYPE:
+                raise ValueError(f'Key must be one of '
+                                 f'{ALLOWED_PATH_ENTITIES}, got {key}')
 
-        split = kwargs.get('split')
-        if split is not None and not isinstance(split, str):
-            # Ensure that run is a string
-            kwargs['split'] = '{:02}'.format(split)
+            if ENTITY_VALUE_TYPE[key] == 'label':
+                _validate_type(val, types=(None, str),
+                               item_name=key)
+            else:
+                assert ENTITY_VALUE_TYPE[key] == 'index'
+                _validate_type(val, types=(int, str, None), item_name=key)
+                if isinstance(val, str) and not val.isdigit():
+                    raise ValueError(f'{key} is not an index (Got {val})')
+                elif isinstance(val, int):
+                    kwargs[key] = '{:02}'.format(val)
 
         # ensure extension starts with a '.'
         extension = kwargs.get('extension')
@@ -502,13 +518,6 @@ def update(self, *, check=None, **kwargs):
 
         # error check entities
         for key, val in kwargs.items():
-            # error check allowed BIDS entity keywords
-            if key in ('root', 'datatype'):
-                pass
-            elif key not in ALLOWED_PATH_ENTITIES:
-                raise ValueError(f'Key must be one of '
-                                 f'{ALLOWED_PATH_ENTITIES}, got {key}')
-
             # check if there are any characters not allowed
             if val is not None and key != 'root':
                 _check_key_val(key, val)
@@ -522,10 +531,6 @@ def update(self, *, check=None, **kwargs):
         if self.datatype is None and \
                 self.suffix in SUFFIX_TO_DATATYPE:
             self.datatype = SUFFIX_TO_DATATYPE[self.suffix]
-        # # set datatype based on suffix if calling update
-        # elif self.suffix in SUFFIX_TO_DATATYPE and \
-        #         'datatype' not in kwargs:
-        #     self.datatype = SUFFIX_TO_DATATYPE[self.suffix]
 
         # Update .check attribute and perform a check of the entities.
         if check is not None:
@@ -583,14 +588,6 @@ def _check(self):
                              'subject and session entities. BIDSPath '
                              f'currently contains {self.entities}.')
 
-        # error check datatype
-        if self.datatype is not None and \
-                self.datatype not in ALLOWED_DATATYPES:
-            raise ValueError(f'"datatype" can only be one of '
-                             f'{ALLOWED_DATATYPES}. You passed in '
-                             f'{self.datatype}, which is not '
-                             f'BIDS compliant. ')
-
         # perform deeper check if user has it turned on
         if self.check:
             if self.subject == 'emptyroom':
diff --git a/mne_bids/utils.py b/mne_bids/utils.py
--- a/mne_bids/utils.py
+++ b/mne_bids/utils.py
@@ -132,8 +132,9 @@ def _check_types(variables):
     """Make sure all vars are str or None."""
     for var in variables:
         if not isinstance(var, (str, type(None))):
-            raise ValueError(f"You supplied a value of type {type(var)}, "
-                             f"where a string or None was expected.")
+            raise ValueError(f"You supplied a value ({var}) of type "
+                             f"{type(var)}, where a string or None was "
+                             f"expected.")
 
 
 def _write_json(fname, dictionary, overwrite=False, verbose=False):


Test patch:
diff --git a/mne_bids/tests/test_path.py b/mne_bids/tests/test_path.py
--- a/mne_bids/tests/test_path.py
+++ b/mne_bids/tests/test_path.py
@@ -418,8 +418,7 @@ def test_bids_path(return_bids_test_dir):
                          suffix=suffix, check=False)
     # also inherits error check from instantiation
     # always error check datatype
-    with pytest.raises(ValueError, match='"datatype" can only be '
-                                         'one of'):
+    with pytest.raises(ValueError, match='datatype .* is not valid'):
         bids_path.copy().update(datatype=error_kind)
 
     # suffix won't be error checks if initial check was false
@@ -456,9 +455,9 @@ def test_make_filenames():
     """Test that we create filenames according to the BIDS spec."""
     # All keys work
     prefix_data = dict(subject='one', session='two', task='three',
-                       acquisition='four', run='five', processing='six',
+                       acquisition='four', run=1, processing='six',
                        recording='seven', suffix='ieeg', extension='.json')
-    expected_str = 'sub-one_ses-two_task-three_acq-four_run-five_proc-six_rec-seven_ieeg.json'  # noqa
+    expected_str = 'sub-one_ses-two_task-three_acq-four_run-01_proc-six_rec-seven_ieeg.json'  # noqa
     assert BIDSPath(**prefix_data).basename == expected_str
     assert BIDSPath(**prefix_data) == op.join('sub-one', 'ses-two',
                                               'ieeg', expected_str)
@@ -491,7 +490,7 @@ def test_make_filenames():
     with pytest.raises(ValueError, match='Extension .h5 is not allowed'):
         BIDSPath(**prefix_data)
     basename = BIDSPath(**prefix_data, check=False)
-    assert basename.basename == 'sub-one_ses-two_task-three_acq-four_run-five_proc-six_rec-seven_ieeg.h5'  # noqa
+    assert basename.basename == 'sub-one_ses-two_task-three_acq-four_run-01_proc-six_rec-seven_ieeg.h5'  # noqa
 
     # what happens with scans.tsv file
     with pytest.raises(ValueError, match='scans.tsv file name '
@@ -619,6 +618,10 @@ def test_find_empty_room(return_bids_test_dir):
     best_er_basename = bids_path.find_empty_room()
     assert best_er_basename.session == '20021204'
 
+    with pytest.raises(ValueError,
+                       match='The root of the "bids_path" must be set'):
+        bids_path.copy().update(root=None).find_empty_room()
+
     # assert that we get error if meas_date is not available.
     raw = read_raw_bids(bids_path=bids_path)
     if check_version('mne', '0.20'):
@@ -711,3 +714,14 @@ def test_find_emptyroom_no_meas_date():
 
     with pytest.warns(RuntimeWarning, match='Could not retrieve .* date'):
         bids_path.find_empty_room()
+
+
+def test_bids_path_label_vs_index_entity():
+    match = "subject must be an instance of None or str"
+    with pytest.raises(TypeError, match=match):
+        BIDSPath(subject=1)
+    match = "root must be an instance of path-like or None"
+    with pytest.raises(TypeError, match=match):
+        BIDSPath(root=1, subject='01')
+    BIDSPath(subject='01', run=1)  # ok as <index> entity
+    BIDSPath(subject='01', split=1)  # ok as <index> entity