Repository: bids-standard/pybids

Issue:
Refactor derivative pipelines into their own BIDSLayouts
As of Derivatives RC1, each derivatives pipeline directory is supposed to be compliant with all BIDS-Raw rules, including mandating a `dataset_description.json`. A sensible way to represent pipeline directories is to treat each one as an independent `BIDSLayout` object, with references to `.derivatives` and `.sources` in each `BIDSLayout`.


Discussion:

Resolving patch:
diff --git a/bids/layout/layout.py b/bids/layout/layout.py
--- a/bids/layout/layout.py
+++ b/bids/layout/layout.py
@@ -9,6 +9,7 @@
 import nibabel as nb
 from collections import defaultdict
 from functools import reduce
+from itertools import chain
 import re
 
 
@@ -67,32 +68,29 @@ class BIDSLayout(Layout):
             If False, queries return relative paths, unless the root argument
             was left empty (in which case the root defaults to the file system
             root).
-        derivatives (bool, str, list): Specification of whether/how to index
-            derivatives directories. Can be one of the following types:
-            * None or False: No derivatives are indexed.
-            * True: If a derivatives/ directory exists below the root, it will
-                be indexed.
-            * string or iterable of strings: One or more directories to be
-                treated as derivatives and indexed. Note that if derivatives
-                are specified this way, a derivatives/ directory under {root}
-                must be explicitly included if it is to be indexed.
+        derivatives (bool, str, list): Specificies whether and/or which
+            derivatives to to index. If True, all pipelines found in the
+            derivatives/ subdirectory will be indexed. If a str or list, gives
+            the paths to one or more derivatives directories to index. If False
+            or None, the derivatives/ directory is ignored during indexing, and
+            derivatives will have to be added manually via add_derivatives().
+        config (str, list): Optional name(s) of configuration file(s) to use.
+            By default (None), uses 'bids'.
+        sources (BIDLayout, list): Optional BIDSLayout(s) from which the
+            current BIDSLayout is derived.
         kwargs: Optional keyword arguments to pass onto the Layout initializer
             in grabbit.
     """
 
     def __init__(self, root, validate=True, index_associated=True,
-                 include=None, absolute_paths=True, derivatives=None,
-                 **kwargs):
+                 include=None, absolute_paths=True, derivatives=False,
+                 config=None, sources=None, **kwargs):
 
         self.validator = BIDSValidator(index_associated=index_associated)
         self.validate = validate
         self.metadata_index = MetadataIndex(self)
-
-        # Determine which configs to load
-        conf_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
-                                 'config', '%s.json')
-        bids_conf = conf_path % 'bids'
-        deriv_conf = [bids_conf, conf_path % 'derivatives']
+        self.derivatives = {}
+        self.sources = listify(sources)
 
         # Validate arguments
         if not isinstance(root, six.string_types):
@@ -108,7 +106,6 @@ def __init__(self, root, validate=True, index_associated=True,
             raise ValueError("'dataset_description.json' file is missing from "
                           "project root. Every valid BIDS dataset must have "
                           "this file.")
-            self.description = None
         else:
             self.description = json.load(open(target, 'r'))
             for k in ['Name', 'BIDSVersion']:
@@ -116,32 +113,91 @@ def __init__(self, root, validate=True, index_associated=True,
                     raise ValueError("Mandatory '%s' field missing from "
                                      "dataset_description.json." % k)
 
-        # Construct paths to pass to grabbit
-        paths = [(root, bids_conf)]
-
-        if derivatives:
-            if derivatives == True:
-                derivatives = os.path.join(root, 'derivatives')
-            derivatives = [os.path.normpath(os.path.join(root, der))
-                           for der in listify(derivatives)]
-            paths.append((derivatives, deriv_conf))
-
-        # We'll need this for file validation later
-        self.derivatives = derivatives
-
         # Determine which subdirectories to exclude from indexing
-        excludes = {"code", "stimuli", "sourcedata", "models"}
-        if not derivatives:
-            excludes.add("derivatives")
+        excludes = {"code", "stimuli", "sourcedata", "models", "derivatives"}
         if include is not None:
+            include = listify(include)
+            if "derivatives" in include:
+                raise ValueError("Do not pass 'derivatives' in the include "
+                                 "list. To index derivatives, either set "
+                                 "derivatives=True, or use add_derivatives().")
             excludes -= set([d.strip(os.path.sep) for d in include])
         self._exclude_dirs = list(excludes)
 
-        super(BIDSLayout, self).__init__(paths, root=self.root,
+        # Set up path and config for grabbit
+        if config is None:
+            config = 'bids'
+        config = listify(config)
+        conf_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
+                                 'config', '%s.json')
+        bids_conf = [conf_path % c for c in config]
+        path = (root, bids_conf)
+
+        # Initialize grabbit Layout
+        super(BIDSLayout, self).__init__(path, root=self.root,
                                          dynamic_getters=True,
                                          absolute_paths=absolute_paths,
                                          **kwargs)
 
+        # Add derivatives if any are found
+        self.derivatives = {}
+        if derivatives:
+            if derivatives == True:
+                derivatives = os.path.join(root, 'derivatives')
+            self.add_derivatives(
+                derivatives, validate=validate,
+                index_associated=index_associated,include=include,
+                absolute_paths=absolute_paths, derivatives=None, config=None,
+                sources=self, **kwargs)
+
+    def add_derivatives(self, path, **kwargs):
+        ''' Add BIDS-Derivatives datasets to tracking.
+
+        Args:
+            path (str, list): One or more paths to BIDS-Derivatives datasets.
+                Each path can point to either a derivatives/ directory
+                containing one more more pipeline directories, or to a single
+                pipeline directory (e.g., derivatives/fmriprep).
+            kwargs (dict): Optional keyword arguments to pass on to
+                BIDSLayout() when initializing each of the derivative datasets.
+        '''
+        paths = listify(path)
+        deriv_dirs = []
+
+        # Collect all paths that contain a dataset_description.json
+        def check_for_description(dir):
+            dd = os.path.join(dir, 'dataset_description.json')
+            return os.path.exists(dd)
+
+        for p in paths:
+            p = os.path.abspath(p)
+            if check_for_description(p):
+                deriv_dirs.append(p)
+            else:
+                subdirs = [d for d in os.listdir(p)
+                           if os.path.isdir(os.path.join(p, d))]
+                for sd in subdirs:
+                    sd = os.path.join(p, sd)
+                    if check_for_description(sd):
+                        deriv_dirs.append(sd)
+
+        for deriv in deriv_dirs:
+            dd = os.path.join(deriv, 'dataset_description.json')
+            description = json.load(open(dd, 'r'))
+            pipeline_name = description.get('PipelineDescription.Name', None)
+            if pipeline_name is None:
+                raise ValueError("Every valid BIDS-derivatives dataset must "
+                                "have a PipelineDescription.Name field set "
+                                "inside dataset_description.json.")
+            if pipeline_name in self.derivatives:
+                raise ValueError("Pipeline name '%s' has already been added "
+                                 "to this BIDSLayout. Every added pipeline "
+                                 "must have a unique name!")
+            # Default config and sources values
+            kwargs['config'] = kwargs.get('config') or ['bids', 'derivatives']
+            kwargs['sources'] = kwargs.get('sources') or self
+            self.derivatives[pipeline_name] = BIDSLayout(deriv, **kwargs)
+
     def to_df(self, **kwargs):
         """
         Return information for all Files tracked in the Layout as a pandas
@@ -171,7 +227,7 @@ def __repr__(self):
     def _validate_dir(self, d):
         # Callback from grabbit. Exclude special directories like derivatives/
         # and code/ from indexing unless they were explicitly included at
-        # initialization in either the include or paths arguments.
+        # initialization.
         no_root = os.path.relpath(d, self.root).split(os.path.sep)[0]
         if no_root in self._exclude_dirs:
             check_paths = set(self._paths_to_index) - {self.root}
@@ -185,17 +241,13 @@ def _validate_file(self, f):
         if not self.validate:
             return True
 
-        if f.startswith(self.root):
-            to_check = os.path.relpath(f, self.root)
-        # For validation purposes, we need to treat all derivatives outside
-        # the project root as if they were inside it
-        elif self.derivatives:
-            for der in self.derivatives:
-                if f.startswith(der):
-                    to_check = f.replace(der, 'derivatives' + os.path.sep)
-                    break
-        else:
-            to_check = f
+        # For derivatives, we need to cheat a bit and construct a fake
+        # derivatives path--prepend 'derivatives' and the pipeline name
+        to_check = os.path.relpath(f, self.root)
+        if 'derivatives' in self.domains:
+            to_check = os.path.join(
+                'derivatives', self.description['PipelineDescription.Name'],
+                to_check)
 
         sep = os.path.sep
         if to_check[:len(sep)] != sep:
@@ -208,12 +260,13 @@ def _get_nearest_helper(self, path, extension, suffix=None, **kwargs):
         path = os.path.abspath(path)
 
         if not suffix:
-            if 'suffix' not in self.files[path].entities:
+            f = self.get_file(path)
+            if 'suffix' not in f.entities:
                 raise ValueError(
                     "File '%s' does not have a valid suffix, most "
                     "likely because it is not a valid BIDS file." % path
                 )
-            suffix = self.files[path].entities['suffix']
+            suffix = f.entities['suffix']
 
         tmp = self.get_nearest(path, extensions=extension, all_=True,
                                suffix=suffix, ignore_strict_entities=['suffix'],
@@ -225,7 +278,8 @@ def _get_nearest_helper(self, path, extension, suffix=None, **kwargs):
             return None
 
     def get(self, return_type='tuple', target=None, extensions=None,
-            domains=None, regex_search=None, defined_fields=None, **kwargs):
+            derivatives=True, regex_search=None, defined_fields=None,
+            domains=None, **kwargs):
         """
         Retrieve files and/or metadata from the current Layout.
 
@@ -238,12 +292,15 @@ def get(self, return_type='tuple', target=None, extensions=None,
                 'id': returns a list of unique IDs. Must be used together with
                     a valid target.
                 'obj': returns a list of matching File objects.
-            target (str): The name of the target entity to get results for
-                (if return_type is 'dir' or 'id').
+            target (str): Optional name of the target entity to get results for
+                (only used if return_type is 'dir' or 'id').
             extensions (str, list): One or more file extensions to filter on.
                 Files with any other extensions will be excluded.
-            domains (list): Optional list of domain names to scan for files.
-                If None, all available domains are scanned.
+            derivatives (bool, str, list): Whether/how to search associated
+                BIDS-Derivatives datasets. If True (default), all available
+                derivatives are searched. If a str or list, must be the name(s)
+                of the derivatives to search (as defined in the
+                PipelineDescription.Name field in dataset_description.json).
             regex_search (bool or None): Whether to require exact matching
                 (False) or regex search (True) when comparing the query string
                 to each entity. If None (default), uses the value found in
@@ -252,6 +309,8 @@ def get(self, return_type='tuple', target=None, extensions=None,
                 that must be defined in JSON sidecars in order to consider the
                 file a match, but which don't need to match any particular
                 value.
+            domains (str, list): Domain(s) to search in. Valid values are
+                'bids' and 'derivatives'.
             kwargs (dict): Any optional key/values to filter the entities on.
                 Keys are entity names, values are regexes to filter on. For
                 example, passing filter={ 'subject': 'sub-[12]'} would return
@@ -261,10 +320,17 @@ def get(self, return_type='tuple', target=None, extensions=None,
             A named tuple (default) or a list (see return_type for details).
         """
 
+        if derivatives == True:
+            derivatives = list(self.derivatives.keys())
+
         # Separate entity kwargs from metadata kwargs
         ent_kwargs, md_kwargs = {}, {}
 
         all_ents = self.get_domain_entities()
+        if derivatives:
+            for deriv in derivatives:
+                deriv_ents = self.derivatives[deriv].get_domain_entities()
+                all_ents.update(deriv_ents)
 
         for k, v in kwargs.items():
             if k in all_ents:
@@ -272,23 +338,41 @@ def get(self, return_type='tuple', target=None, extensions=None,
             else:
                 md_kwargs[k] = v
 
+        all_results = []
+
         # Get entity-based search results using the superclass's get()
+        result = []
         result = super(
-            BIDSLayout, self).get(return_type, target, extensions, domains,
+            BIDSLayout, self).get(return_type, target, extensions, None,
                                   regex_search, **ent_kwargs)
 
-        if return_type in ['dir', 'id']:
-            return result
+        # Search the metadata if needed
+        if return_type not in {'dir', 'id'}:
+
+            if md_kwargs:
+                if return_type.startswith('obj'):
+                    result = [f.path for f in result]
 
-        if return_type.startswith('obj'):
-            result = [f.path for f in result]
+                result = self.metadata_index.search(result, defined_fields,
+                                                    **md_kwargs)
 
-        if md_kwargs:
-            result = self.metadata_index.search(result, defined_fields,
-                                                **md_kwargs)
+                if return_type.startswith('obj'):
+                    result = [self.files[f] for f in result]
 
-        if return_type.startswith('obj'):
-            result = [self.files[f] for f in result]
+        all_results.append(result)
+
+        # Add results from derivatives
+        if derivatives:
+            for deriv in derivatives:
+                deriv = self.derivatives[deriv]
+                deriv_res = deriv.get(return_type, target, extensions, None,
+                                      regex_search, **ent_kwargs)
+                all_results.append(deriv_res)
+
+        # Flatten results
+        result = list(chain(*all_results))
+        if return_type in ['dir', 'id']:
+            result = list(set(result))
 
         return result
 
@@ -318,7 +402,8 @@ def get_metadata(self, path, include_entities=False, **kwargs):
         self.metadata_index.index_file(path)
 
         if include_entities:
-            entities = self.files[os.path.abspath(path)].entities
+            f = self.get_file(os.path.abspath(path))
+            entities = f.entities
             results = entities
         else:
             results = {}
@@ -438,6 +523,24 @@ def _make_file_object(self, root, f):
         # Override grabbit's File with a BIDSFile.
         return BIDSFile(os.path.join(root, f), self)
 
+    def get_file(self, filename, derivatives=True):
+        ''' Returns the BIDSFile object with the specified path.
+
+        Args:
+            filename (str): The path of the file to retrieve.
+            derivatives (bool: If True, checks all associated derivative
+                datasets as well.
+
+        Returns: A BIDSFile.
+        '''
+        layouts = [self]
+        if derivatives:
+            layouts += self.derivatives.values()
+        for ly in layouts:
+            if filename in ly.files:
+                return ly.files[filename]
+        return None
+
 
 class MetadataIndex(object):
     """A simple dict-based index for key/value pairs in JSON metadata.
@@ -460,7 +563,7 @@ def index_file(self, f, overwrite=False):
                 an entry already exists.
         """
         if isinstance(f, six.string_types):
-            f = self.layout.files[f]
+            f = self.layout.get_file(f)
 
         if f.path in self.file_index and not overwrite:
             return
diff --git a/bids/variables/io.py b/bids/variables/io.py
--- a/bids/variables/io.py
+++ b/bids/variables/io.py
@@ -6,7 +6,6 @@
 from .entities import NodeIndex
 from .variables import SparseRunVariable, DenseRunVariable, SimpleVariable
 import warnings
-from bids.config import get_option
 
 
 BASE_ENTITIES = ['subject', 'session', 'task', 'run']
@@ -127,15 +126,17 @@ def _load_time_variables(layout, dataset=None, columns=None, scan_length=None,
 
     selectors['datatype'] = 'func'
     selectors['suffix'] = 'bold'
-    images = layout.get(return_type='file', extensions='.nii.gz', **selectors)
+    images = layout.get(return_type='object', extensions='.nii.gz',
+                        derivatives=True, **selectors)
 
     if not images:
         raise ValueError("No functional images that match criteria found.")
 
     # Main loop over images
-    for img_f in images:
+    for img_obj in images:
 
-        entities = layout.files[img_f].entities
+        entities = img_obj.entities
+        img_f = img_obj.path
 
         # Run is not mandatory, but we need a default for proper indexing
         if 'run' in entities:
diff --git a/bids/version.py b/bids/version.py
--- a/bids/version.py
+++ b/bids/version.py
@@ -45,7 +45,7 @@
 AUTHOR_EMAIL = "bids-discussion@googlegroups.com"
 PLATFORMS = "OS Independent"
 # No data for now
-REQUIRES = ["grabbit==0.2.4", "six", "num2words", "numpy", "scipy", "pandas",
+REQUIRES = ["grabbit==0.2.5", "six", "num2words", "numpy", "scipy", "pandas",
             "nibabel", "patsy"]
 EXTRAS_REQUIRE = {
    # Just to not break compatibility with externals requiring


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
@@ -4,7 +4,7 @@
 import pytest
 from bids.layout import BIDSLayout
 from bids.layout.layout import BIDSFile
-from os.path import join, abspath, sep
+from os.path import join, abspath, sep, basename
 from bids.tests import get_test_data_path
 
 
@@ -30,14 +30,20 @@ def layout_ds117():
 @pytest.fixture(scope='module')
 def layout_ds005_derivs():
     data_dir = join(get_test_data_path(), 'ds005')
-    return BIDSLayout(data_dir, derivatives=True)
+    layout = BIDSLayout(data_dir)
+    deriv_dir = join(data_dir, 'derivatives', 'events')
+    layout.add_derivatives(deriv_dir)
+    return layout
 
 
 @pytest.fixture(scope='module')
 def layout_ds005_multi_derivs():
     data_dir = join(get_test_data_path(), 'ds005')
-    deriv_dir = join(get_test_data_path(), 'ds005_derivs')
-    return BIDSLayout(data_dir, derivatives=['derivatives', deriv_dir])
+    layout = BIDSLayout(data_dir)
+    deriv_dir1 = join(get_test_data_path(), 'ds005_derivs')
+    deriv_dir2 = join(data_dir, 'derivatives', 'events')
+    layout.add_derivatives([deriv_dir1, deriv_dir2])
+    return layout
 
 
 @pytest.fixture(scope='module')
@@ -140,7 +146,7 @@ def test_get_bvals_bvecs(layout_ds005):
 def test_get_subjects(layout_7t_trt):
     result = layout_7t_trt.get_subjects()
     predicted = ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10']
-    assert predicted == result
+    assert set(predicted) == set(result)
 
 
 def test_get_fieldmap(layout_7t_trt):
@@ -162,8 +168,10 @@ def test_get_fieldmap2(layout_7t_trt):
 
 
 def test_bids_json(layout_7t_trt):
-    assert layout_7t_trt.get(return_type='id', target='run') == [1, 2]
-    assert layout_7t_trt.get(return_type='id', target='session') == ['1', '2']
+    res = layout_7t_trt.get(return_type='id', target='run')
+    assert set(res) == {1, 2}
+    res = layout_7t_trt.get(return_type='id', target='session')
+    assert set(res) == {'1', '2'}
 
 
 def test_include(layout_ds005, layout_ds005_models):
@@ -179,32 +187,50 @@ def test_include(layout_ds005, layout_ds005_models):
 def test_layout_with_derivs(layout_ds005_derivs):
     assert layout_ds005_derivs.root == join(get_test_data_path(), 'ds005')
     assert isinstance(layout_ds005_derivs.files, dict)
-    assert set(layout_ds005_derivs.domains.keys()) == {'bids', 'derivatives'}
-    assert layout_ds005_derivs.domains['bids'].files
-    assert layout_ds005_derivs.domains['derivatives'].files
-    assert 'derivatives.roi' in layout_ds005_derivs.entities
-    assert 'bids.roi' not in layout_ds005_derivs.entities
-    assert 'bids.subject' in layout_ds005_derivs.entities
+    assert len(layout_ds005_derivs.derivatives) == 1
+    deriv = layout_ds005_derivs.derivatives['events']
+    assert deriv.files
+    assert len(deriv.files) == 2
+    event_file = "sub-01_task-mixedgamblestask_run-01_desc-extra_events.tsv"
+    deriv_files = [basename(f) for f in list(deriv.files.keys())]
+    assert event_file in deriv_files
+    assert 'derivatives.roi' in deriv.entities
+    assert 'bids.roi' not in deriv.entities
+    assert 'bids.subject' in deriv.entities
 
 
 def test_layout_with_multi_derivs(layout_ds005_multi_derivs):
     assert layout_ds005_multi_derivs.root == join(get_test_data_path(), 'ds005')
     assert isinstance(layout_ds005_multi_derivs.files, dict)
-    assert set(layout_ds005_multi_derivs.domains.keys()) == {'bids', 'derivatives'}
-    assert layout_ds005_multi_derivs.domains['bids'].files
-    assert layout_ds005_multi_derivs.domains['derivatives'].files
-    assert 'derivatives.roi' in layout_ds005_multi_derivs.entities
-    assert 'bids.roi' not in layout_ds005_multi_derivs.entities
-    assert 'bids.subject' in layout_ds005_multi_derivs.entities
+    assert len(layout_ds005_multi_derivs.derivatives) == 2
+    deriv = layout_ds005_multi_derivs.derivatives['events']
+    assert deriv.files
+    assert len(deriv.files) == 2
+    deriv = layout_ds005_multi_derivs.derivatives['dummy']
+    assert deriv.files
+    assert len(deriv.files) == 4
+    assert 'derivatives.roi' in deriv.entities
+    assert 'bids.roi' not in deriv.entities
+    assert 'bids.subject' in deriv.entities
     preproc = layout_ds005_multi_derivs.get(desc='preproc')
     assert len(preproc) == 3
 
 
 def test_query_derivatives(layout_ds005_derivs):
+    result = layout_ds005_derivs.get(suffix='events', return_type='object')
+    result = [f.filename for f in result]
+    assert len(result) == 49
+    assert 'sub-01_task-mixedgamblestask_run-01_desc-extra_events.tsv' in result
     result = layout_ds005_derivs.get(suffix='events', return_type='object',
-                              domains='derivatives')
+                                     derivatives=False)
+    assert len(result) == 48
+    result = [f.filename for f in result]
+    assert 'sub-01_task-mixedgamblestask_run-01_desc-extra_events.tsv' not in result
+    result = layout_ds005_derivs.get(suffix='events', return_type='object',
+                                     desc='extra')
     assert len(result) == 1
-    assert result[0].filename == 'sub-01_task-mixedgamblestask_run-01_events.tsv'
+    result = [f.filename for f in result]
+    assert 'sub-01_task-mixedgamblestask_run-01_desc-extra_events.tsv' in result
 
 
 def test_get_bidsfile_image_prop():
diff --git a/bids/tests/data/ds005/derivatives/events/dataset_description.json b/bids/tests/data/ds005/derivatives/events/dataset_description.json
new file mode 100644
--- /dev/null
+++ b/bids/tests/data/ds005/derivatives/events/dataset_description.json
@@ -0,0 +1,7 @@
+{
+    "PipelineDescription.Name": "events",
+    "BIDSVersion": "1.0.0rc2",
+    "License": "This dataset is made available under the Public Domain Dedication and License \nv1.0, whose full text can be found at \nhttp://www.opendatacommons.org/licenses/pddl/1.0/. \nWe hope that all users will follow the ODC Attribution/Share-Alike \nCommunity Norms (http://www.opendatacommons.org/norms/odc-by-sa/); \nin particular, while not legally required, we hope that all users \nof the data will acknowledge the OpenfMRI project and NSF Grant \nOCI-1131441 (R. Poldrack, PI) in any publications.",
+    "Name": "Mixed-gambles task",
+    "ReferencesAndLinks": "Tom, S.M., Fox, C.R., Trepel, C., Poldrack, R.A. (2007). The neural basis of loss aversion in decision-making under risk. Science, 315(5811):515-8"
+}
\ No newline at end of file
diff --git a/bids/tests/data/ds005/derivatives/events/func/sub-01_task-mixedgamblestask_run-01_events.tsv b/bids/tests/data/ds005/derivatives/events/sub-01/func/sub-01_task-mixedgamblestask_run-01_desc-extra_events.tsv
similarity index 100%
rename from bids/tests/data/ds005/derivatives/events/func/sub-01_task-mixedgamblestask_run-01_events.tsv
rename to bids/tests/data/ds005/derivatives/events/sub-01/func/sub-01_task-mixedgamblestask_run-01_desc-extra_events.tsv
diff --git a/bids/tests/data/ds005_derivs/dummy/dataset_description.json b/bids/tests/data/ds005_derivs/dummy/dataset_description.json
new file mode 100644
--- /dev/null
+++ b/bids/tests/data/ds005_derivs/dummy/dataset_description.json
@@ -0,0 +1,6 @@
+{
+    "PipelineDescription.Name": "dummy",
+    "BIDSVersion": "1.0.0rc2",
+    "Name": "Mixed-gambles task -- dummy derivative",
+    "ReferencesAndLinks": "Tom, S.M., Fox, C.R., Trepel, C., Poldrack, R.A. (2007). The neural basis of loss aversion in decision-making under risk. Science, 315(5811):515-8"
+}
\ No newline at end of file
diff --git a/bids/tests/data/synthetic/derivatives/fmriprep/dataset_description.json b/bids/tests/data/synthetic/derivatives/fmriprep/dataset_description.json
new file mode 100644
--- /dev/null
+++ b/bids/tests/data/synthetic/derivatives/fmriprep/dataset_description.json
@@ -0,0 +1,7 @@
+{
+	"PipelineDescription.Name": "fmriprep",
+	"Name": "synthetic",
+	"BIDSVersion": "1.0.2",
+	"License": "PD",
+	"Authors": ["Markiewicz, C. J."]
+}
diff --git a/bids/variables/tests/test_io.py b/bids/variables/tests/test_io.py
--- a/bids/variables/tests/test_io.py
+++ b/bids/variables/tests/test_io.py
@@ -18,10 +18,9 @@ def layout1():
 @pytest.fixture(scope="module", params=["events", "preproc"])
 def synthetic(request):
     root = join(get_test_data_path(), 'synthetic')
-    deriv = join(root, 'derivatives')
     if request.param == 'preproc':
         layout = BIDSLayout(root, derivatives=True)
-        dataset = load_variables(layout, skip_empty=True, suffix='preproc',
+        dataset = load_variables(layout, skip_empty=True, desc='preproc',
                                  space='T1w')
     else:
         layout = BIDSLayout(root)