Repository: bids-standard/pybids
Pull request #444 (2019-05-14 17:29:58)

Add BIDSJSONFile
Adds a `BIDSJSONFile` to the `BIDSFile` hierarchy. This introduces `.get_dict()` and `.get_json()` methods that return the contents of a .json file. See #442 for discussion.


Patch:
diff --git a/bids/layout/__init__.py b/bids/layout/__init__.py
index 3082589f..88c9a55e 100644
--- a/bids/layout/__init__.py
+++ b/bids/layout/__init__.py
@@ -1,7 +1,15 @@
 from .layout import BIDSLayout, add_config_paths, parse_file_entities
-from .models import BIDSFile
+from .models import BIDSFile, BIDSImageFile, BIDSDataFile, BIDSJSONFile
 # Backwards compatibility
 from bids_validator import BIDSValidator
 
-__all__ = ["BIDSLayout", "BIDSValidator", "add_config_paths",
-           "parse_file_entities", "BIDSFile"]
+__all__ = [
+    "BIDSLayout",
+    "BIDSValidator",
+    "add_config_paths",
+    "parse_file_entities",
+    "BIDSFile",
+    "BIDSImageFIle",
+    "BIDSDataFile",
+    "BIDSJSONFile",
+]
diff --git a/bids/layout/layout.py b/bids/layout/layout.py
index a2c8b748..c2976184 100644
--- a/bids/layout/layout.py
+++ b/bids/layout/layout.py
@@ -366,6 +366,10 @@ class BIDSLayout(object):
 
     def _get_layouts_in_scope(self, scope):
         """Return all layouts in the passed scope."""
+
+        if scope == 'self':
+            return [self]
+
         def collect_layouts(layout):
             """Recursively build a list of layouts."""
             children = list(layout.derivatives.values())
@@ -418,8 +422,8 @@ class BIDSLayout(object):
 
         Args:
             scope (str): The scope of the search space. Indicates which
-                BIDSLayouts' entities to extract. See BIDSLayout docstring
-                for valid values.
+                BIDSLayouts' entities to extract. See BIDSLayout.get()
+                docstring for valid values.
             metadata (bool, None): By default (None), all available entities
                 are returned. If True, only entities found in metadata files
                 (and not defined for filenames) are returned. If False, only
@@ -445,8 +449,8 @@ class BIDSLayout(object):
 
         Args:
             scope (str): The scope of the search space. Indicates which
-                BIDSLayouts' entities to extract. See BIDSLayout docstring
-                for valid values.
+                BIDSLayouts' entities to extract. See BIDSLayout.get()
+                docstring for valid values.
 
         Returns: A dict, where keys are file paths and values are BIDSFile
             instances.
@@ -470,8 +474,8 @@ class BIDSLayout(object):
         Args:
             filename (str): The filename to parse for entity values
             scope (str, list): The scope of the search space. Indicates which
-                BIDSLayouts' entities to extract. See BIDSLayout docstring
-                for valid values. By default, extracts all entities.
+                BIDSLayouts' entities to extract. See BIDSLayout.get()
+                docstring for valid values. By default, extracts all entities.
             entities (list): An optional list of Entity instances to use in
                 extraction. If passed, the scope and config arguments are
                 ignored, and only the Entities in this list are used.
@@ -622,6 +626,7 @@ class BIDSLayout(object):
                     'all' (default): search all available directories.
                     'derivatives': search all derivatives directories
                     'raw': search only BIDS-Raw directories
+                    'self': search only the directly called BIDSLayout
                     <PipelineName>: the name of a BIDS-Derivatives pipeline
             regex_search (bool or None): Whether to require exact matching
                 (False) or regex search (True) when comparing the query string
@@ -758,7 +763,7 @@ class BIDSLayout(object):
                 an absolute path, or relative to the root of this BIDSLayout.
             scope (str, list): Scope of the search space. If passed, only
                 BIDSLayouts that match the specified scope will be
-                searched. See BIDSLayout docstring for valid values.
+                searched. See BIDSLayout.get() docstring for valid values.
 
         Returns: A BIDSFile, or None if no match was found.
         """
@@ -851,8 +856,9 @@ class BIDSLayout(object):
                 from the filename (rather than JSON sidecars) are included in
                 the returned metadata dictionary.
             scope (str, list): The scope of the search space. Each element must
-                be one of 'all', 'raw', 'derivatives', or a BIDS-Derivatives
-                pipeline name. Defaults to searching all available datasets.
+                be one of 'all', 'raw', 'self', 'derivatives', or a
+                BIDS-Derivatives pipeline name. Defaults to searching all
+                available datasets.
 
         Returns: A dictionary of key/value pairs extracted from all of the
             target file's associated JSON sidecars.
@@ -879,6 +885,27 @@ class BIDSLayout(object):
 
         return {}
 
+    def get_dataset_description(self, scope='self', all_=False):
+        """Return contents of dataset_description.json.
+
+        Args:
+            scope (str): The scope of the search space. Only descriptions of
+                BIDSLayouts that match the specified scope will be returned.
+                See BIDSLayout.get() docstring for valid values. Defaults to
+                'self'--i.e., returns the dataset_description.json file for
+                only the directly-called BIDSLayout.
+            all_ (bool): If True, returns a list containing descriptions for
+                all matching layouts. If False (default), returns for only the
+                first matching layout.
+
+        Returns: a dictionary or list of dictionaries (depending on all_).
+        """
+        layouts = self._get_layouts_in_scope(scope)
+        if not all_:
+            return layouts[0].get_file('dataset_description.json').get_dict()
+        return [l.get_file('dataset_description.json').get_dict()
+              for l in layouts]
+
     def get_nearest(self, path, return_type='filename', strict=True,
                     all_=False, ignore_strict_entities='extension',
                     full_search=False, **filters):
diff --git a/bids/layout/models.py b/bids/layout/models.py
index c0ac8b1b..ac6bee0b 100644
--- a/bids/layout/models.py
+++ b/bids/layout/models.py
@@ -332,6 +332,25 @@ class BIDSImageFile(BIDSFile):
                              "NiBabel can read.".format(self.path))
 
 
+class BIDSJSONFile(BIDSFile):
+
+    __mapper_args__ = {
+        'polymorphic_identity': 'json_file'
+    }
+
+    def get_dict(self):
+        ''' Return the contents of the current file as a dictionary. '''
+        d = json.loads(self.get_json())
+        if not isinstance(d, dict):
+            raise ValueError("File %s is a json containing %s, not a dict which was expected" % (self.path, type(d)))
+        return d
+
+    def get_json(self):
+        ''' Return the contents of the current file as a JSON string. '''
+        with open(self.path, 'r') as f:
+            return f.read()
+
+
 class Entity(Base):
     """
     Represents a single entity defined in the JSON config.
diff --git a/bids/utils.py b/bids/utils.py
index 02acf9c6..fe078847 100644
--- a/bids/utils.py
+++ b/bids/utils.py
@@ -101,6 +101,8 @@ def make_bidsfile(filename):
         cls = 'BIDSImageFile'
     elif ext in ['tsv', 'tsv.gz']:
         cls = 'BIDSDataFile'
+    elif ext == 'json':
+        cls = 'BIDSJSONFile'
     else:
         cls = 'BIDSFile'
 


Test patch:
diff --git a/bids/layout/tests/test_layout.py b/bids/layout/tests/test_layout.py
index 2a6acc66..ca263989 100644
--- a/bids/layout/tests/test_layout.py
+++ b/bids/layout/tests/test_layout.py
@@ -501,6 +501,21 @@ def test_get_layouts_in_scope(layout_ds005_multi_derivs):
     assert len(l._get_layouts_in_scope(['events', 'dummy'])) == 2
     assert len(l._get_layouts_in_scope(['derivatives'])) == 2
     assert len(l._get_layouts_in_scope('raw')) == 1
+    self_scope = l._get_layouts_in_scope('self')
+    assert len(self_scope) == 1
+    assert self_scope == [l]
+
+
+def test_get_dataset_description(layout_ds005_multi_derivs):
+    l = layout_ds005_multi_derivs
+    dd = l.get_dataset_description()
+    assert isinstance(dd, dict)
+    assert dd['Name'] == 'Mixed-gambles task'
+    dd = l.get_dataset_description('all', True)
+    assert isinstance(dd, list)
+    assert len(dd) == 3
+    names = {'Mixed-gambles task', 'Mixed-gambles task -- dummy derivative'}
+    assert set([d['Name'] for d in dd]) == names
 
 
 def test_indexed_file_associations(layout_7t_trt):
diff --git a/bids/layout/tests/test_models.py b/bids/layout/tests/test_models.py
index c06928c6..8bebd3c6 100644
--- a/bids/layout/tests/test_models.py
+++ b/bids/layout/tests/test_models.py
@@ -3,6 +3,7 @@ import os
 import pytest
 import bids
 import copy
+import json
 from pathlib import Path
 
 from sqlalchemy import create_engine
@@ -13,6 +14,7 @@ from bids.layout.models import (BIDSFile, Entity, Tag, Base, Config,
                                 FileAssociation, BIDSImageFile)
 from bids.layout import BIDSLayout
 from bids.tests import get_test_data_path
+from bids.external import six
 
 
 def create_session():
@@ -187,6 +189,17 @@ def test_bidsimagefile_get_image():
     assert bf.get_image().shape == (64, 64, 64, 64)
 
 
+def test_bidsjsonfile(layout_synthetic):
+    jf = layout_synthetic.get(suffix='bold', extension='json')[0]
+    d = jf.get_dict()
+    assert isinstance(d, dict)
+    assert d['RepetitionTime'] == 2.5
+    j = jf.get_json()
+    assert isinstance(j, six.string_types)
+    assert 'RepetitionTime' in j
+    assert json.loads(j) == d
+
+
 def test_bidsfile_get_metadata(layout_synthetic):
     bf = layout_synthetic.get(suffix='physio', extension='tsv.gz')[0]
     md = bf.get_metadata()