Repository: bids-standard/pybids

Issue:
layout.get doesn't find any entities when passed a numpy.int64 value for run
When doing a `layout.get()`, if the type of the run argument is np.int64, it fails to find any entities. Here's a minimal reproduction using pybids test data:

```
In [1]: from os.path import join 
   ...: from bids.analysis import Analysis 
   ...: from bids.analysis.analysis import ContrastInfo, DesignMatrixInfo 
   ...: from bids.layout import BIDSLayout 
   ...: from bids.tests import get_test_data_path 
   ...: import numpy as np 
   ...:  
   ...: def analysis(): 
   ...:     layout_path = join(get_test_data_path(), 'ds005') 
   ...:     layout = BIDSLayout(layout_path) 
   ...:     json_file = join(layout_path, 'models', 'ds-005_type-test_model.json') 
   ...:     analysis = Analysis(layout, json_file) 
   ...:     analysis.setup(scan_length=480, subject=['01', '02']) 
   ...:     return analysis 
   ...: foo = analysis()                                                                                                                                                          

In [2]: layout_path = join(get_test_data_path(), 'ds005') 
   ...: layout = BIDSLayout(layout_path) 
   ...: layout.get_runs()                                                                                                                                                         
Out[2]: [1, 2, 3]

In [3]: layout.get(suffix="bold",subject='01', run=1)                                                                                                                             
Out[3]: [<BIDSImageFile filename='/gpfs/gsfs11/users/MBDU/midla/notebooks/code/pybids/bids/tests/data/ds005/sub-01/func/sub-01_task-mixedgamblestask_run-01_bold.nii.gz'>]

In [4]: layout.get(suffix="bold",subject='01', run=np.int64(1)) 
   ...:                                                                                                                                                                           
Out[4]: []

In [5]: # Now just to show that pybids itself is generating numpy.int64 run values, here's the output from get design matrix                                                      

In [6]: for _, _, ents in foo.steps[0].get_design_matrix(): 
   ...:     break 
   ...: ents                                                                                                                                                                      
Out[6]: 
{'task': 'mixedgamblestask',
 'run': 1,
 'suffix': 'bold',
 'datatype': 'func',
 'subject': '01'}

In [7]: type(ents['run'])                                                                                                                                                         
Out[7]: numpy.int64

In [8]: layout.get(**ents)                                                                                                                                                        
Out[8]: []

In [9]: ents['run']=1 
   ...: layout.get(**ents)                                                                                                                                                        
Out[9]: [<BIDSImageFile filename='/gpfs/gsfs11/users/MBDU/midla/notebooks/code/pybids/bids/tests/data/ds005/sub-01/func/sub-01_task-mixedgamblestask_run-01_bold.nii.gz'>]
```

And before anyone suggests that the solution is to avoid passing int64s as run numbers, notice that `get_design_matrix` returns an int64. I've reproduced the above results with both 0.9.2 and 0.9.3. 
layout.get doesn't find any entities when passed a numpy.int64 value for run
When doing a `layout.get()`, if the type of the run argument is np.int64, it fails to find any entities. Here's a minimal reproduction using pybids test data:

```
In [1]: from os.path import join 
   ...: from bids.analysis import Analysis 
   ...: from bids.analysis.analysis import ContrastInfo, DesignMatrixInfo 
   ...: from bids.layout import BIDSLayout 
   ...: from bids.tests import get_test_data_path 
   ...: import numpy as np 
   ...:  
   ...: def analysis(): 
   ...:     layout_path = join(get_test_data_path(), 'ds005') 
   ...:     layout = BIDSLayout(layout_path) 
   ...:     json_file = join(layout_path, 'models', 'ds-005_type-test_model.json') 
   ...:     analysis = Analysis(layout, json_file) 
   ...:     analysis.setup(scan_length=480, subject=['01', '02']) 
   ...:     return analysis 
   ...: foo = analysis()                                                                                                                                                          

In [2]: layout_path = join(get_test_data_path(), 'ds005') 
   ...: layout = BIDSLayout(layout_path) 
   ...: layout.get_runs()                                                                                                                                                         
Out[2]: [1, 2, 3]

In [3]: layout.get(suffix="bold",subject='01', run=1)                                                                                                                             
Out[3]: [<BIDSImageFile filename='/gpfs/gsfs11/users/MBDU/midla/notebooks/code/pybids/bids/tests/data/ds005/sub-01/func/sub-01_task-mixedgamblestask_run-01_bold.nii.gz'>]

In [4]: layout.get(suffix="bold",subject='01', run=np.int64(1)) 
   ...:                                                                                                                                                                           
Out[4]: []

In [5]: # Now just to show that pybids itself is generating numpy.int64 run values, here's the output from get design matrix                                                      

In [6]: for _, _, ents in foo.steps[0].get_design_matrix(): 
   ...:     break 
   ...: ents                                                                                                                                                                      
Out[6]: 
{'task': 'mixedgamblestask',
 'run': 1,
 'suffix': 'bold',
 'datatype': 'func',
 'subject': '01'}

In [7]: type(ents['run'])                                                                                                                                                         
Out[7]: numpy.int64

In [8]: layout.get(**ents)                                                                                                                                                        
Out[8]: []

In [9]: ents['run']=1 
   ...: layout.get(**ents)                                                                                                                                                        
Out[9]: [<BIDSImageFile filename='/gpfs/gsfs11/users/MBDU/midla/notebooks/code/pybids/bids/tests/data/ds005/sub-01/func/sub-01_task-mixedgamblestask_run-01_bold.nii.gz'>]
```

And before anyone suggests that the solution is to avoid passing int64s as run numbers, notice that `get_design_matrix` returns an int64. I've reproduced the above results with both 0.9.2 and 0.9.3. 


Discussion:
Well, obviously the solution is to make sure that `get_design_matrix` doesn't return `int64`. ;)

But yeah, it makes sense to convert to `int` at the front end. Feel free to open a PR, otherwise I'll get to it soon.
Sure happy to make the change, can you point me to where the most efficient spot to do the input type massaging would be?
Looking through layout.py it seems like filters always go through `_build_file_query`, so that's where I'm going to enforce the type of the run value. Let me know if there's somewhere else I should be casting the type.
Good question... I think the natural place for a fix is probably at the level of query construction, in `BIDSLayout._build_file_query()`. I don't think the SQLAlchemy query constructed from all the `get()` arguments currently does any type checking, so I expect the numpy dtype is getting passed through to the DB, where it fails to match. Previously such queries would have worked fine, because the object comparisons happened in Python.

The annoying thing about this is that it will require retrieving the `Entity` record for each passed argument from the DB, checking its `dtype`, and then using that to sanitize (if possible) the passed value (probably we can just call the conversion function directly inside a `try` block). Doing this naively is likely to be very inefficient, because it will require a DB query for every requested entity. The more efficient way would be to collect all the entities we need to inspect up front and retrieve them in a single shot.

This may require quite a bit of mucking around with SQLAlchemy, so if you'd rather not wade into that morass, no worries—I can try to get to it in the next couple of days (possibly this afternoon).
I guess you *could* enforce just the type of the `run` entity, but I think we should probably go for the general fix. Otherwise at some point we're going to have to do the same thing for a bunch other fields.
I'm leaning towards the run only fix because its not clear to me that the issue is going to come up for many other entities. If use proves me wrong then we tackle the more general case when the need arises.
It will likely come up with any other indices. I think right now that's just `echo`.
What about just having a list of numeric filters on which to enforce the type? That's what I've implemented in #491.

If we could could get the dtype of all of the entities up front, then the list of integer entities could be a layout property.
Do we only care about the case where numpy dtypes are passed in instead of numeric python types? If so, then we don't even need to consider the target entity; we can just convert all numpy int types to `int`, and all float types to `float`. That seems more straightforward. Numpy dtypes can't be represented in JSON, so there should never be a situation where a user is *legitimately* trying to match a numpy type.

My general concern though is that this kind of situation comes up in other place as well. E.g., if the dtype is string, and you pass `subject=1` instead of `subject='1'`, `1` is technically never valid, so why wouldn't we want to apply the same treatment and convert to a string before querying the DB?
Also, there's no reason to limit ourselves to numpy dtypes. The benefit of checking the DB and then calling the conversion function (e.g., `int()`) is that we can sanitize *any* Python object that implements the corresponding type conversion magic method (e.g., `__int__`). So that seems to me like the way to go unless we think numpy dtypes are really going to be the only case like this.
Let me try to work up a quick fix and see how it goes... might be faster than I think.
Well, obviously the solution is to make sure that `get_design_matrix` doesn't return `int64`. ;)

But yeah, it makes sense to convert to `int` at the front end. Feel free to open a PR, otherwise I'll get to it soon.
Sure happy to make the change, can you point me to where the most efficient spot to do the input type massaging would be?
Looking through layout.py it seems like filters always go through `_build_file_query`, so that's where I'm going to enforce the type of the run value. Let me know if there's somewhere else I should be casting the type.
Good question... I think the natural place for a fix is probably at the level of query construction, in `BIDSLayout._build_file_query()`. I don't think the SQLAlchemy query constructed from all the `get()` arguments currently does any type checking, so I expect the numpy dtype is getting passed through to the DB, where it fails to match. Previously such queries would have worked fine, because the object comparisons happened in Python.

The annoying thing about this is that it will require retrieving the `Entity` record for each passed argument from the DB, checking its `dtype`, and then using that to sanitize (if possible) the passed value (probably we can just call the conversion function directly inside a `try` block). Doing this naively is likely to be very inefficient, because it will require a DB query for every requested entity. The more efficient way would be to collect all the entities we need to inspect up front and retrieve them in a single shot.

This may require quite a bit of mucking around with SQLAlchemy, so if you'd rather not wade into that morass, no worries—I can try to get to it in the next couple of days (possibly this afternoon).
I guess you *could* enforce just the type of the `run` entity, but I think we should probably go for the general fix. Otherwise at some point we're going to have to do the same thing for a bunch other fields.
I'm leaning towards the run only fix because its not clear to me that the issue is going to come up for many other entities. If use proves me wrong then we tackle the more general case when the need arises.
It will likely come up with any other indices. I think right now that's just `echo`.
What about just having a list of numeric filters on which to enforce the type? That's what I've implemented in #491.

If we could could get the dtype of all of the entities up front, then the list of integer entities could be a layout property.
Do we only care about the case where numpy dtypes are passed in instead of numeric python types? If so, then we don't even need to consider the target entity; we can just convert all numpy int types to `int`, and all float types to `float`. That seems more straightforward. Numpy dtypes can't be represented in JSON, so there should never be a situation where a user is *legitimately* trying to match a numpy type.

My general concern though is that this kind of situation comes up in other place as well. E.g., if the dtype is string, and you pass `subject=1` instead of `subject='1'`, `1` is technically never valid, so why wouldn't we want to apply the same treatment and convert to a string before querying the DB?
Also, there's no reason to limit ourselves to numpy dtypes. The benefit of checking the DB and then calling the conversion function (e.g., `int()`) is that we can sanitize *any* Python object that implements the corresponding type conversion magic method (e.g., `__int__`). So that seems to me like the way to go unless we think numpy dtypes are really going to be the only case like this.
Let me try to work up a quick fix and see how it goes... might be faster than I think.

Resolving patch:
diff --git a/bids/layout/layout.py b/bids/layout/layout.py
--- a/bids/layout/layout.py
+++ b/bids/layout/layout.py
@@ -381,6 +381,25 @@ def collect_layouts(layout):
         layouts = [l for l in collect_layouts(self) if l._in_scope(scope)]
         return list(set(layouts))
 
+    def _sanitize_query_dtypes(self, entities):
+        """ Automatically convert entity query values to correct dtypes. """
+        entities = entities.copy()
+        names = list(entities.keys())
+        ents = {e.name: e for e in
+                self.session.query(Entity)
+                    .filter(Entity.name.in_(names)).all()}
+            # Fail silently because the DB may still know how to reconcile
+            # type differences.
+        for name, val in entities.items():
+            try:
+                if isinstance(val, (list, tuple)):
+                    entities[name] = [ents[name]._astype(v) for v in val]
+                else:
+                    entities[name] = ents[name]._astype(val)
+            except:
+                pass
+        return entities
+
     @property
     def entities(self):
         """Get the entities."""
@@ -786,6 +805,9 @@ def _build_file_query(self, **kwargs):
         if filters:
             query = query.join(BIDSFile.tags)
             regex = kwargs.get('regex_search', False)
+
+            filters = self._sanitize_query_dtypes(filters)
+
             for name, val in filters.items():
                 if isinstance(val, (list, tuple)) and len(val) == 1:
                     val = val[0]


Test patch:
diff --git a/bids/layout/tests/test_layout.py b/bids/layout/tests/test_layout.py
--- a/bids/layout/tests/test_layout.py
+++ b/bids/layout/tests/test_layout.py
@@ -2,16 +2,20 @@
 functionality should go in the grabbit package. """
 
 import os
+import re
+import tempfile
+from os.path import join, abspath, basename, dirname
+
+import numpy as np
 import pytest
+
 import bids
-import re
 from bids.layout import BIDSLayout, parse_file_entities, add_config_paths
 from bids.layout.models import (BIDSFile, BIDSImageFile, Entity, Config,
                                 FileAssociation)
-from os.path import join, abspath, basename, dirname
 from bids.tests import get_test_data_path
 from bids.utils import natural_sort
-import tempfile
+
 
 
 def test_layout_init(layout_7t_trt):
@@ -557,4 +561,13 @@ def test_indexing_tag_conflict():
         layout = BIDSLayout(data_dir)
         print(exc.value.message)
         assert exc.value.message.startswith("Conflicting values found")
-        assert 'run' in exc.value.message
\ No newline at end of file
+        assert 'run' in exc.value.message
+
+
+def test_get_with_wrong_dtypes(layout_7t_trt):
+    ''' Test automatic dtype sanitization. '''
+    l = layout_7t_trt
+    assert (l.get(run=1) == l.get(run='1') == l.get(run=np.int64(1)) ==
+            l.get(run=[1, '15']))
+    assert not l.get(run='not_numeric')
+    assert l.get(session=1) == l.get(session='1')
\ No newline at end of file