Repository: grabbles/grabbit

Issue:
Add type declaration to entity specification
This is an oversight on my part, but there should really be a field for type declarations in the entity specification. Those can then be enforced when entity values are first read in, ensuring that we avoid ambiguous cases (e.g., should '1' be read in as a str or int?).

For example:

```json
{
    "entities": [
        {
            "name": "subject",
            "pattern": ".*sub-([a-zA-Z0-9]+)",
            "directory": "{{root}}/{subject}",
            "type": "str"
        }
    ]
}


Discussion:

Patch:
diff --git a/.coveragerc b/.coveragerc
deleted file mode 100644
index a72e893..0000000
--- a/.coveragerc
+++ /dev/null
@@ -1,5 +0,0 @@
-[run]
-source =
-	grabbit/
-omit =
-    */tests/*
diff --git a/.travis.yml b/.travis.yml
index 57590d2..5e2016c 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -4,17 +4,21 @@ sudo: false
 python:
   - "2.7"
   - "3.5"
-  - "3.6"
 
 install:
-  - pip install --upgrade pip pytest
-  - pip install runipy coveralls pytest-cov
+  - pip install pip --upgrade
+  - pip install pytest --upgrade
+  - pip install codecov runipy
   - pip install -e '.'
+  - pip install flake8
 
 script:
   - PYTHONPATH=$PWD coverage run `which py.test` grabbit
-  - py.test --cov-report term-missing --cov=grabbit
+  - python setup.py install  # test installation
   - cd examples; PYTHONPATH=.. runipy *.ipynb  # test example notebooks to run and not fail
+  # for now flaking only the stub.py
+  # - flake8 grabbit
 
 after_success:
-  - coveralls
+  - codecov
+
diff --git a/README.md b/README.md
index aee4d0b..e528243 100644
--- a/README.md
+++ b/README.md
@@ -5,10 +5,6 @@ Get grabby with file trees
 
 Grabbit is a lightweight Python 3 package for simple queries over filenames within a project. It's geared towards projects or applications with highly structured filenames that allow useful queries to be performed without having to inspect the file metadata or contents.
 
-## Status
-* [![Build Status](https://travis-ci.org/grabbles/grabbit.svg?branch=master)](https://travis-ci.org/grabbles/grabbit)
-* [![Coverage Status](https://coveralls.io/repos/github/grabbles/grabbit/badge.svg?branch=master)](https://coveralls.io/github/grabbles/grabbit?branch=master)
-
 ## Installation
 
 ```
diff --git a/grabbit/core.py b/grabbit/core.py
index a84092b..9cb60dc 100644
--- a/grabbit/core.py
+++ b/grabbit/core.py
@@ -154,7 +154,7 @@ Tag = namedtuple('Tag', ['entity', 'value'])
 class Entity(object):
 
     def __init__(self, name, pattern=None, domain=None, mandatory=False,
-                 directory=None, map_func=None, **kwargs):
+                 directory=None, map_func=None, dtype=None, **kwargs):
         """
         Represents a single entity defined in the JSON config.
 
@@ -171,6 +171,10 @@ class Entity(object):
                 defined .pattern).
             domain (Domain): The Domain the Entity belongs to.
             kwargs (dict): Additional keyword arguments.
+            dtype (str): The optional data type of the Entity values. Must be
+                one of 'int', 'float', 'bool', or 'str'. If None, no type
+                enforcement will be attempted, which means the dtype of the
+                value may be unpredictable.
         """
         if pattern is None and map_func is None:
             raise ValueError("Invalid specification for Entity '%s'; no "
@@ -183,9 +187,17 @@ class Entity(object):
         self.mandatory = mandatory
         self.directory = directory
         self.map_func = map_func
+        self.kwargs = kwargs
+
+        if isinstance(dtype, six.string_types):
+            dtype = eval(dtype)
+        if dtype not in [str, float, int, bool, None]:
+            raise ValueError("Invalid dtype '%s'. Must be one of int, float, "
+                             "bool, or str." % dtype)
+        self.dtype = dtype
+
         self.files = {}
         self.regex = re.compile(pattern) if pattern is not None else None
-        self.kwargs = kwargs
         self.id = '.'.join([getattr(domain, 'name', ''), name])
 
     def __iter__(self):
@@ -223,6 +235,8 @@ class Entity(object):
             return False
 
         if update_file:
+            if self.dtype is not None:
+                val = self.dtype(val)
             f.tags[self.name] = Tag(self, val)
 
         return True
@@ -254,7 +268,8 @@ class LayoutMetaclass(type):
 
         paths = listify(path)
         if len(paths) == 1:
-            return super(LayoutMetaclass, cls).__call__(path, *args, **kwargs)
+            return super(LayoutMetaclass, cls).__call__(paths[0], *args,
+                                                        **kwargs)
         layouts = []
         for p in paths:
             layout = super(LayoutMetaclass, cls).__call__(p, *args, **kwargs)
@@ -355,9 +370,11 @@ class Layout(six.with_metaclass(LayoutMetaclass, object)):
                           "the config file for this domain includes a "
                           "'root' key." % config['name'])
             config['root'] = self.root
+        elif config['root'] == '.':
+            config['root'] = self.root
         elif not isabs(config['root']):
             _root = config['root']
-            config['root'] = abspath(join(self.root, config['root']))
+            config['root'] = join(self.root, config['root'])
             if not exists(config['root']):
                 msg = ("Relative path '%s' for domain '%s' interpreted as '%s'"
                        ", but this directory doesn't exist. Either specify the"
@@ -458,7 +475,7 @@ class Layout(six.with_metaclass(LayoutMetaclass, object)):
             return f.domains
         return [d.name for d in self.domains.values() if f.startswith(d.root)]
 
-    def _index_file(self, root, f, domains=None):
+    def _index_file(self, root, f, domains=None, update_layout=True):
 
         # If domains aren't explicitly passed, figure out what applies
         if domains is None:
@@ -485,13 +502,16 @@ class Layout(six.with_metaclass(LayoutMetaclass, object)):
 
         # Only keep Files that match at least one Entity, and all
         # mandatory Entities
-        if file_ents and not (self.mandatory - set(file_ents)):
+        if update_layout and file_ents and not (self.mandatory
+                                                - set(file_ents)):
             self.files[f.path] = f
             # Bind the File to all of the matching entities
             for name, tag in f.tags.items():
                 ent_id = tag.entity.id
                 self.entities[ent_id].add_file(f.path, tag.value)
 
+        return f
+
     def _find_entity(self, entity):
         ''' Find an Entity instance by name. Checks both name and id fields.'''
         if entity in self.entities:
@@ -851,6 +871,17 @@ class Layout(six.with_metaclass(LayoutMetaclass, object)):
     def clone(self):
         return deepcopy(self)
 
+    def parse_file_entities(self, filename, domains=None):
+        root, f = dirname(filename), basename(filename)
+        if not root and domains is None:
+            raise ValueError("If a relative path is provided as the filename "
+                             "argument, you *must* specify the names of the "
+                             "domains whose entities are to be extracted. "
+                             "Available domains for the current layout are: %s"
+                             % list(self.domains.keys()))
+        result = self._index_file(root, f, domains, update_layout=False)
+        return result.entities
+
     def build_path(self, source, path_patterns=None, strict=False):
         ''' Constructs a target filename for a file or dictionary of entities.
 


Test patch:
diff --git a/grabbit/tests/specs/test.json b/grabbit/tests/specs/test.json
index d93c986..e0c6630 100644
--- a/grabbit/tests/specs/test.json
+++ b/grabbit/tests/specs/test.json
@@ -7,7 +7,8 @@
     {
       "name": "subject",
       "pattern": "sub-(\\d+)",
-      "directory": "{{root}}/{subject}"
+      "directory": "{{root}}/{subject}",
+      "dtype": "str"
     },
     {
       "name": "session",
@@ -18,7 +19,8 @@
     },
     {
       "name": "run",
-      "pattern": "run-0*(\\d+)"
+      "pattern": "run-(\\d+)",
+      "dtype": "int"
     },
     {
       "name": "type",
diff --git a/grabbit/tests/test_core.py b/grabbit/tests/test_core.py
index 02eb754..c0087ee 100644
--- a/grabbit/tests/test_core.py
+++ b/grabbit/tests/test_core.py
@@ -200,7 +200,7 @@ class TestLayout:
         assert len(result) == 1
         assert 'phasediff.json' in result[0].filename
         assert hasattr(result[0], 'run')
-        assert result[0].run == '1'
+        assert result[0].run == 1
 
         # With exact matching...
         result = bids_layout.get(subject='1', run=1, session=1,
@@ -368,6 +368,14 @@ class TestLayout:
         files = stamp_layout.get(value='35', regex_search=True)
         assert len(files) == 2
 
+    def test_parse_file_entities(self, bids_layout):
+        filename = 'sub-03_ses-07_run-4_sekret.nii.gz'
+        with pytest.raises(ValueError):
+            bids_layout.parse_file_entities(filename)
+        ents = bids_layout.parse_file_entities(filename, domains=['test'])
+        assert ents == {'subject': '03', 'session': '7', 'run': 4,
+                        'type': 'sekret'}
+
 
 def test_merge_layouts(bids_layout, stamp_layout):
     layout = merge_layouts([bids_layout, stamp_layout])