Repository: grabbles/grabbit

Issue:
Hierarchical specifications
Currently grabbit assumes that all folders/files below the project root obey the same spec. For a variety of reasons, it would be good to allow hierarchical specifications, so that on the initial scan, each folder would be checked for its own .json spec, and if one is found, the entities defined within that file will be used for any files below. Internally, it's probably best to handle this by initializing multiple `Layout` objects and maintaining a common index across them (to enable returning of files that match entities shared across specs).

Discussion:

Patch:
diff --git a/grabbit/__init__.py b/grabbit/__init__.py
index 35f4778..4d08018 100644
--- a/grabbit/__init__.py
+++ b/grabbit/__init__.py
@@ -1,10 +1,12 @@
-from .core import File, Entity, Layout, merge_layouts
+from .core import File, Entity, Layout, Tag, Domain, merge_layouts
 from .extensions import (replace_entities, build_path, write_contents_to_file)
 
 __all__ = [
     'File',
     'Entity',
     'Layout',
+    'Tag',
+    'Domain',
     'replace_entities',
     'build_path',
     'write_contents_to_file',
diff --git a/grabbit/core.py b/grabbit/core.py
index cc828e0..7767ea6 100644
--- a/grabbit/core.py
+++ b/grabbit/core.py
@@ -5,9 +5,10 @@ from collections import defaultdict, OrderedDict, namedtuple
 from grabbit.external import six, inflect
 from grabbit.utils import natural_sort, listify
 from grabbit.extensions.writable import build_path, write_contents_to_file
-from os.path import join, basename, dirname, abspath, split
+from os.path import (join, basename, dirname, abspath, split, isabs, exists)
 from functools import partial
 from copy import deepcopy
+import warnings
 
 
 __all__ = ['File', 'Entity', 'Layout']
@@ -22,15 +23,26 @@ class File(object):
         self.path = filename
         self.filename = basename(self.path)
         self.dirname = dirname(self.path)
-        self.entities = {}
+        self.tags = {}
+
+    @property
+    def entities(self):
+        return {k: v.value for k, v in self.tags.items()}
+
+    @property
+    def domains(self):
+        return tuple(set([t.entity.domain.name for t in self.tags.values()]))
 
-    def _matches(self, entities=None, extensions=None, regex_search=False):
+    def _matches(self, entities=None, extensions=None, domains=None,
+                 regex_search=False):
         """
         Checks whether the file matches all of the passed entities and
         extensions.
+
         Args:
             entities (dict): A dictionary of entity names -> regex patterns.
             extensions (str, list): One or more file extensions to allow.
+            domains (str, list): One or more domains the file must match.
             regex_search (bool): Whether to require exact match (False) or
                 regex search (True) when comparing the query string to each
                 entity.
@@ -44,11 +56,16 @@ class File(object):
             if re.search(extensions, self.path) is None:
                 return False
 
+        if domains is not None:
+            domains = listify(domains)
+            if not set(self.domains) & set(domains):
+                return False
+
         if entities is not None:
 
             for name, val in entities.items():
 
-                if name not in self.entities:
+                if name not in self.tags:
                     return False
 
                 def make_patt(x):
@@ -64,7 +81,7 @@ class File(object):
                 ent_patts = [make_patt(x) for x in listify(val)]
                 patt = '|'.join(ent_patts)
 
-                if re.search(patt, str(self.entities[name])) is None:
+                if re.search(patt, str(self.tags[name].value)) is None:
                     return False
         return True
 
@@ -73,9 +90,9 @@ class File(object):
         Returns the File as a named tuple. The full path plus all entity
         key/value pairs are returned as attributes.
         """
-        _File = namedtuple('File', 'filename ' +
-                           ' '.join(self.entities.keys()))
-        return _File(filename=self.path, **self.entities)
+        entities = self.entities
+        _File = namedtuple('File', 'filename ' + ' '.join(entities.keys()))
+        return _File(filename=self.path, **entities)
 
     def copy(self, path_patterns, symbolic_link=False, root=None,
              conflicts='fail'):
@@ -102,12 +119,45 @@ class File(object):
                                conflicts=conflicts)
 
 
+class Domain(object):
+
+    def __init__(self, name, config, root):
+
+        self.name = name
+        self.config = config
+        self.root = root
+        self.entities = {}
+        self.files = []
+        self.filtering_regex = {}
+        self.path_patterns = []
+
+        if 'index' in config:
+            self.filtering_regex = config['index']
+            if self.filtering_regex.get('include') and \
+               self.filtering_regex.get('exclude'):
+                raise ValueError("You can only define either include or "
+                                 "exclude regex, not both.")
+
+        if 'default_path_patterns' in config:
+            self.path_patterns += listify(config['default_path_patterns'])
+
+    def add_entity(self, ent):
+        self.entities[ent.name] = ent
+
+    def add_file(self, file):
+        self.files.append(file)
+
+
+Tag = namedtuple('Tag', ['entity', 'value'])
+
+
 class Entity(object):
 
-    def __init__(self, name, pattern=None, mandatory=False, directory=None,
-                 map_func=None, **kwargs):
+    def __init__(self, name, pattern=None, domain=None, mandatory=False,
+                 directory=None, map_func=None, **kwargs):
         """
         Represents a single entity defined in the JSON config.
+
         Args:
             name (str): The name of the entity (e.g., 'subject', 'run', etc.)
             pattern (str): A regex pattern used to match against file names.
@@ -119,6 +169,7 @@ class Entity(object):
             map_func (callable): Optional callable used to extract the Entity's
                 value from the passed string (instead of trying to match on the
                 defined .pattern).
+            domain (Domain): The Domain the Entity belongs to.
             kwargs (dict): Additional keyword arguments.
         """
         if pattern is None and map_func is None:
@@ -128,12 +179,14 @@ class Entity(object):
                              "set." % name)
         self.name = name
         self.pattern = pattern
+        self.domain = domain
         self.mandatory = mandatory
         self.directory = directory
         self.map_func = map_func
         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):
         for i in self.unique():
@@ -150,20 +203,29 @@ class Entity(object):
             setattr(result, k, new_val)
         return result
 
-    def matches(self, f):
+    def matches(self, f, update_file=False):
         """
         Determine whether the passed file matches the Entity and update the
         Entity/File mappings.
+
         Args:
             f (File): The File instance to match against.
+            update_file (bool): If True, the file's tag list is updated to
+                include the current Entity.
         """
         if self.map_func is not None:
-            f.entities[self.name] = self.map_func(f)
+            val = self.map_func(f)
         else:
             m = self.regex.search(f.path)
-            if m is not None:
-                val = m.group(1)
-                f.entities[self.name] = val
+            val = m.group(1) if m is not None else None
+
+        if val is None:
+            return False
+
+        if update_file:
+            f.tags[self.name] = Tag(self, val)
+
+        return True
 
     def add_file(self, filename, value):
         """ Adds the specified filename to tracking. """
@@ -175,6 +237,7 @@ class Entity(object):
 
     def count(self, files=False):
         """ Returns a count of unique values or files.
+
         Args:
             files (bool): When True, counts all files mapped to the Entity.
                 When False, counts all unique values.
@@ -203,7 +266,7 @@ class Layout(six.with_metaclass(LayoutMetaclass, object)):
 
     def __init__(self, path, config=None, index=None, dynamic_getters=False,
                  absolute_paths=True, regex_search=False, entity_mapper=None,
-                 path_patterns=None):
+                 path_patterns=None, config_filename='layout.json'):
         """
         A container for all the files and metadata found at the specified path.
 
@@ -245,6 +308,10 @@ class Layout(six.with_metaclass(LayoutMetaclass, object)):
             path_patterns (str, list): One or more filename patterns to use
                 as a default path pattern for this layout's files.  Can also
                 be specified in the config file.
+            config_filename (str): The name of directory-specific config files.
+                Every directory will be scanned for this file, and if found,
+                the config file will be read in and added to the list of
+                configs.
         """
 
         self.root = abspath(path) if absolute_paths else path
@@ -253,62 +320,100 @@ class Layout(six.with_metaclass(LayoutMetaclass, object)):
         self.mandatory = set()
         self.dynamic_getters = dynamic_getters
         self.regex_search = regex_search
-        self.filtering_regex = {}
         self.entity_mapper = self if entity_mapper == 'self' else entity_mapper
         self.path_patterns = path_patterns if path_patterns else []
+        self.config_filename = config_filename
+        self.domains = OrderedDict()
 
         if config is not None:
-            self._load_config(config)
+            for c in listify(config):
+                self._load_domain(c)
 
         if index is None:
             self.index()
         else:
             self.load_index(index)
 
-    def _load_config(self, config):
+    def _load_domain(self, config, root=None):
+
         if isinstance(config, six.string_types):
             config = json.load(open(config, 'r'))
-        elif isinstance(config, list):
-            merged = {}
-            for c in config:
-                if isinstance(c, six.string_types):
-                    c = json.load(open(c, 'r'))
-                merged.update(c)
-            config = merged
 
-        for e in config['entities']:
-            self.add_entity(**e)
-
-        if 'index' in config:
-            self.filtering_regex = config['index']
-            if self.filtering_regex.get('include') and \
-               self.filtering_regex.get('exclude'):
-                raise ValueError("You can only define either include or "
-                                 "exclude regex, not both.")
-
-        if 'default_path_patterns' in config:
-            self.path_patterns += listify(config['default_path_patterns'])
+        if 'name' not in config:
+            raise ValueError("Config file missing 'name' attribute.")
+        if config['name'] in self.domains:
+            raise ValueError("Config with name '%s' already exists in "
+                             "Layout. Name of each config file must be "
+                             "unique across entire Layout.")
+        if root is not None:
+            config['root'] = root
+
+        if 'root' not in config:
+            warnings.warn("No valid root directory found for domain '%s'."
+                          " Falling back on the Layout's root directory. "
+                          "If this isn't the intended behavior, make sure "
+                          "the config file for this domain includes a "
+                          "'root' key." % config['name'])
+            config['root'] = self.root
+        elif not isabs(config['root']):
+            _root = config['root']
+            config['root'] = abspath(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"
+                       " domain root as an absolute path, or make sure it "
+                       "points to a valid directory when appended to the "
+                       "Layout's root (%s)." % (_root, config['name'],
+                                                config['root'], self.root))
+                raise ValueError(msg)
+
+        # Load entities
+        domain = Domain(config['name'], config, config['root'])
+        for e in config.get('entities', []):
+            self.add_entity(domain=domain, **e)
+
+        self.domains[domain.name] = domain
+
+    def get_domain_entities(self, domains=None, file=None):
+        # Get all Entities included in the specified Domains, in the same
+        # order as Domains in the list. Alternatively, if a file is passed,
+        # identify its domains and then return the entities.
+
+        if file is None:
+            if domains is None:
+                domains = list(self.domains.keys())
+        else:
+            domains = self._get_domains_for_file(file)
 
-        return config
+        ents = {}
+        for d in domains:
+            ents.update(self.domains[d].entities)
+        return ents
 
-    def _check_inclusions(self, f):
+    def _check_inclusions(self, f, domains=None):
         ''' Check file or directory against regexes in config to determine if
             it should be included in the index '''
+
         filename = f if isinstance(f, six.string_types) else f.path
 
-        # If file matches any include regex, then True
-        include_regex = self.filtering_regex.get('include', [])
-        if include_regex:
-            for regex in include_regex:
-                if re.match(regex, filename):
-                    break
-            else:
-                return False
-        else:
-            # If file matches any excldue regex, then false
-            for regex in self.filtering_regex.get('exclude', []):
-                if re.match(regex, filename, flags=re.UNICODE):
+        if domains is None:
+            domains = list(self.domains.keys())
+
+        for dom in domains:
+            dom = self.domains[dom]
+            # If file matches any include regex, then True
+            include_regex = dom.filtering_regex.get('include', [])
+            if include_regex:
+                for regex in include_regex:
+                    if re.match(regex, filename):
+                        break
+                else:
                     return False
+            else:
+                # If file matches any excldue regex, then false
+                for regex in dom.filtering_regex.get('exclude', []):
+                    if re.match(regex, filename, flags=re.UNICODE):
+                        return False
 
         return True
 
@@ -342,25 +447,59 @@ class Layout(six.with_metaclass(LayoutMetaclass, object)):
         for ent in self.entities.values():
             ent.files = {}
 
-    def _index_file(self, root, f):
+    def _get_domains_for_file(self, f):
+        if isinstance(f, File):
+            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):
+
+        # If domains aren't explicitly passed, figure out what applies
+        if domains is None:
+            domains = self._get_domains_for_file(root)
+
+        # Create the file object--allows for subclassing
         f = self._make_file_object(root, f)
 
         if not (self._check_inclusions(f) and self._validate_file(f)):
             return
 
-        for e in self.entities.values():
-            e.matches(f)
+        for d in domains:
+            self.domains[d].add_file(f)
+
+        entities = self.get_domain_entities(domains)
+
+        if entities:
+            self.files[f.path] = f
+
+        for e in entities.values():
+            e.matches(f, update_file=True)
 
-        fe = f.entities.keys()
+        file_ents = f.tags.keys()
 
         # Only keep Files that match at least one Entity, and all
         # mandatory Entities
-        if fe and not (self.mandatory - set(fe)):
+        if file_ents and not (self.mandatory - set(file_ents)):
             self.files[f.path] = f
             # Bind the File to all of the matching entities
-            for ent, val in f.entities.items():
-                self.entities[ent].add_file(f.path, val)
+            for name, tag in f.tags.items():
+                ent_id = tag.entity.id
+                self.entities[ent_id].add_file(f.path, tag.value)
+
+    def _find_entity(self, entity):
+        ''' Find an Entity instance by name. Checks both name and id fields.'''
+        if entity in self.entities:
+            return self.entities[entity]
+        _ent = [e for e in self.entities.values() if e.name == entity]
+        if len(_ent) > 1:
+            raise ValueError("Entity name '%s' matches %d entities. To "
+                             "avoid ambiguity, please prefix the entity "
+                             "name with its domain (e.g., 'bids.%s'." %
+                             (entity, len(_ent), entity))
+        if _ent:
+            return _ent[0]
+
+        raise ValueError("No entity '%s' found." % entity)
 
     def index(self):
 
@@ -371,28 +510,63 @@ class Layout(six.with_metaclass(LayoutMetaclass, object)):
         # Loop over all files
         for root, directories, filenames in dataset:
 
+            # Determine which Domains apply to the current directory
+            domains = self._get_domains_for_file(root)
+
             # Exclude directories that match exclude regex from further search
             full_dirs = [os.path.join(root, d) for d in directories]
-            full_dirs = filter(self._check_inclusions, full_dirs)
-            directories[:] = [split(d)[1] for d in
-                              filter(self._validate_dir, full_dirs)]
 
-            # self._index_filenames(filenames)
+            def check_incl(directory):
+                return self._check_inclusions(directory, domains)
+
+            full_dirs = filter(check_incl, full_dirs)
+            full_dirs = filter(self._validate_dir, full_dirs)
+            directories[:] = [split(d)[1] for d in full_dirs]
+
+            if self.config_filename in filenames:
+                config_path = os.path.join(root, self.config_filename)
+                config = json.load(open(config_path, 'r'))
+                self._load_domain(config)
+
+                # Filter Domains if current dir's config file has an
+                # include directive
+                if 'include' in config:
+                    missing = set(config['include']) - set(domains)
+                    if missing:
+                        msg = ("Missing configs '%s' specified in include "
+                               "directive of config '%s'. Please make sure "
+                               "these config files are accessible from the "
+                               "directory %s.") % (missing, config['name'],
+                                                   root)
+                        raise ValueError(msg)
+                    domains = config['include']
+                domains.append(config['name'])
+
+                filenames.remove(self.config_filename)
 
             for f in filenames:
-                self._index_file(root, f)
+                self._index_file(root, f, domains)
 
     def save_index(self, filename):
         ''' Save the current Layout's index to a .json file.
+
         Args:
             filename (str): Filename to write to.
+
+        Note: At the moment, this won't serialize directory-specific config
+        files. This means reconstructed indexes will only work properly in
+        cases where there aren't multiple layout specs within a project.
         '''
-        data = {f.path: f.entities for f in self.files.values()}
+        data = {}
+        for f in self.files.values():
+            entities = {v.entity.id: v.value for k, v in f.tags.items()}
+            data[f.path] = entities
         with open(filename, 'w') as outfile:
             json.dump(data, outfile)
 
     def load_index(self, filename, reindex=False):
         ''' Load the Layout's index from a plaintext file.
+
         Args:
             filename (str): Path to the plaintext index file.
             reindex (bool): If True, discards entity values provided in the
@@ -401,24 +575,33 @@ class Layout(six.with_metaclass(LayoutMetaclass, object)):
                 False, in which case it is assumed that all entity definitions
                 in the loaded index are correct and do not need any further
                 validation.
+
+        Note: At the moment, directory-specific config files aren't serialized.
+        This means reconstructed indexes will only work properly in cases
+        where there aren't multiple layout specs within a project.
         '''
         self._reset_index()
         data = json.load(open(filename, 'r'))
 
         for path, ents in data.items():
 
+            # If file path isn't absolute, assume it's relative to layout root
+            if not isabs(path):
+                path = join(self.root, path)
+
             root, f = dirname(path), basename(path)
             if reindex:
                 self._index_file(root, f)
             else:
                 f = self._make_file_object(root, f)
-                f.entities = ents
+                tags = {k: Tag(self.entities[k], v) for k, v in ents.items()}
+                f.tags = tags
                 self.files[f.path] = f
 
                 for ent, val in f.entities.items():
                     self.entities[ent].add_file(f.path, val)
 
-    def add_entity(self, **kwargs):
+    def add_entity(self, domain, **kwargs):
         ''' Add a new Entity to tracking. '''
 
         # Set the entity's mapping func if one was specified
@@ -433,12 +616,14 @@ class Layout(six.with_metaclass(LayoutMetaclass, object)):
             map_func = getattr(self.entity_mapper, kwargs['map_func'])
             kwargs['map_func'] = map_func
 
-        ent = Entity(**kwargs)
+        ent = Entity(domain=domain, **kwargs)
+        domain.add_entity(ent)
+
         if ent.mandatory:
-            self.mandatory.add(ent.name)
+            self.mandatory.add(ent.id)
         if ent.directory is not None:
             ent.directory = ent.directory.replace('{{root}}', self.root)
-        self.entities[ent.name] = ent
+        self.entities[ent.id] = ent
         if self.dynamic_getters:
             func = partial(getattr(self, 'get'), target=ent.name,
                            return_type='id')
@@ -446,9 +631,10 @@ class Layout(six.with_metaclass(LayoutMetaclass, object)):
             setattr(self, 'get_%s' % func_name, func)
 
     def get(self, return_type='tuple', target=None, extensions=None,
-            regex_search=None, **kwargs):
+            domains=None, regex_search=None, **kwargs):
         """
         Retrieve files and/or metadata from the current Layout.
+
         Args:
             return_type (str): Type of result to return. Valid values:
                 'tuple': returns a list of namedtuples containing file name as
@@ -462,6 +648,8 @@ class Layout(six.with_metaclass(LayoutMetaclass, object)):
                 (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.
             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
@@ -481,7 +669,7 @@ class Layout(six.with_metaclass(LayoutMetaclass, object)):
         filters = {}
         filters.update(kwargs)
         for filename, file in self.files.items():
-            if not file._matches(filters, extensions, regex_search):
+            if not file._matches(filters, extensions, domains, regex_search):
                 continue
             result.append(file)
 
@@ -496,6 +684,8 @@ class Layout(six.with_metaclass(LayoutMetaclass, object)):
             return result
 
         else:
+            valid_entities = self.get_domain_entities(domains)
+
             if target is None:
                 raise ValueError('If return_type is "id" or "dir", a valid '
                                  'target entity must also be specified.')
@@ -506,7 +696,7 @@ class Layout(six.with_metaclass(LayoutMetaclass, object)):
                 return natural_sort(result)
 
             elif return_type == 'dir':
-                template = self.entities[target].directory
+                template = valid_entities[target].directory
                 if template is None:
                     raise ValueError('Return type set to directory, but no '
                                      'directory template is defined for the '
@@ -514,7 +704,7 @@ class Layout(six.with_metaclass(LayoutMetaclass, object)):
                 # Construct regex search pattern from target directory template
                 to_rep = re.findall('\{(.*?)\}', template)
                 for ent in to_rep:
-                    patt = self.entities[ent].pattern
+                    patt = valid_entities[ent].pattern
                     template = template.replace('{%s}' % ent, patt)
                 template += '[^\%s]*$' % os.path.sep
                 matches = [f.dirname for f in self.files.values()
@@ -528,27 +718,30 @@ class Layout(six.with_metaclass(LayoutMetaclass, object)):
     def unique(self, entity):
         """
         Return a list of unique values for the named entity.
+
         Args:
             entity (str): The name of the entity to retrieve unique values of.
         """
-        return self.entities[entity].unique()
+        return self._find_entity(entity).unique()
 
     def count(self, entity, files=False):
         """
         Return the count of unique values or files for the named entity.
+
         Args:
             entity (str): The name of the entity.
             files (bool): If True, counts the number of filenames that contain
                 at least one value of the entity, rather than the number of
                 unique values of the entity.
         """
-        return self.entities[entity].count(files)
+        return self._find_entity(entity).count(files)
 
     def as_data_frame(self, **kwargs):
         """
         Return information for all Files tracked in the Layout as a pandas
         DataFrame.
-        args:
+
+        Args:
             kwargs: Optional keyword arguments passed on to get(). This allows
                 one to easily select only a subset of files for export.
         Returns:
@@ -574,6 +767,7 @@ class Layout(six.with_metaclass(LayoutMetaclass, object)):
                     ignore_strict_entities=None, **kwargs):
         ''' Walk up the file tree from the specified path and return the
         nearest matching file(s).
+
         Args:
             path (str): The file to search from.
             return_type (str): What to return; must be one of 'file' (default)
@@ -593,10 +787,10 @@ class Layout(six.with_metaclass(LayoutMetaclass, object)):
         '''
 
         entities = {}
-        for name, ent in self.entities.items():
+        for ent in self.entities.values():
             m = ent.regex.search(path)
             if m:
-                entities[name] = m.group(1)
+                entities[ent.name] = m.group(1)
 
         # Remove any entities we want to ignore when strict matching is on
         if strict and ignore_strict_entities is not None:
@@ -612,9 +806,10 @@ class Layout(six.with_metaclass(LayoutMetaclass, object)):
             folders[f.dirname].append(f)
 
         def count_matches(f):
-            keys = set(entities.keys()) & set(f.entities.keys())
+            f_ents = f.entities
+            keys = set(entities.keys()) & set(f_ents.keys())
             shared = len(keys)
-            return [shared, sum([entities[k] == f.entities[k] for k in keys])]
+            return [shared, sum([entities[k] == f_ents[k] for k in keys])]
 
         matches = []
 
@@ -715,7 +910,7 @@ class Layout(six.with_metaclass(LayoutMetaclass, object)):
     def write_contents_to_file(self, entities, path_patterns=None,
                                contents=None, link_to=None,
                                content_mode='text', conflicts='fail',
-                               strict=False):
+                               strict=False, domains=None):
         """
         Write arbitrary data to a file defined by the passed entities and
         path patterns.
@@ -737,10 +932,17 @@ class Layout(six.with_metaclass(LayoutMetaclass, object)):
             strict (bool): If True, all entities must be matched inside a
                 pattern in order to be a valid match. If False, extra entities
                 will be ignored so long as all mandatory entities are found.
+            domains (list): List of Domains to scan for path_patterns. Order
+                determines precedence (i.e., earlier Domains will be scanned
+                first). If None, all available domains are included.
 
         """
         if not path_patterns:
             path_patterns = self.path_patterns
+            if domains is None:
+                domains = list(self.domains.keys())
+            for dom in domains:
+                path_patterns.extend(self.domains[dom].path_patterns)
         path = build_path(entities, path_patterns, strict)
         write_contents_to_file(path, contents=contents, link_to=link_to,
                                content_mode=content_mode, conflicts=conflicts,
@@ -766,6 +968,7 @@ def merge_layouts(layouts):
 
     for l in layouts[1:]:
         layout.files.update(l.files)
+        layout.domains.update(l.domains)
 
         for k, v in l.entities.items():
             if k not in layout.entities:


Test patch:
diff --git a/grabbit/tests/data/valuable_stamps/USA/dir_config.json b/grabbit/tests/data/valuable_stamps/USA/dir_config.json
new file mode 100644
index 0000000..0563cca
--- /dev/null
+++ b/grabbit/tests/data/valuable_stamps/USA/dir_config.json
@@ -0,0 +1,14 @@
+{
+  "name": "usa_stamps",
+  "root": ".",
+  "entities": [
+    {
+      "name": "name",
+      "pattern": "name=(.*?)\\#"
+    },
+    {
+      "name": "value",
+      "pattern": "value=([a-z0-9]+)\\.txt"
+    }
+  ]
+}
\ No newline at end of file
diff --git a/grabbit/tests/data/valuable_stamps/USA/name=1c_Washington_Irving#value=35cents.txt b/grabbit/tests/data/valuable_stamps/USA/name=1c_Washington_Irving#value=35cents.txt
new file mode 100644
index 0000000..e69de29
diff --git a/grabbit/tests/data/valuable_stamps/USA/name=5c_Francis_E_Willard#value=1dollar.txt b/grabbit/tests/data/valuable_stamps/USA/name=5c_Francis_E_Willard#value=1dollar.txt
new file mode 100644
index 0000000..e69de29
diff --git a/grabbit/tests/data/valuable_stamps/USA/name=5c_Walt_Whitman#value=80cents.txt b/grabbit/tests/data/valuable_stamps/USA/name=5c_Walt_Whitman#value=80cents.txt
new file mode 100644
index 0000000..e69de29
diff --git a/grabbit/tests/misc/index.json b/grabbit/tests/misc/index.json
index a1394b3..c3bbf1d 100644
--- a/grabbit/tests/misc/index.json
+++ b/grabbit/tests/misc/index.json
@@ -1,130 +1,130 @@
 {
-  "/mnt/c/Users/tyark/Dropbox/Code/grabbit/grabbit/tests/data/7t_trt/dataset_description.json": {
-    "type": "description"
-  },
-  "/mnt/c/Users/tyark/Dropbox/Code/grabbit/grabbit/tests/data/7t_trt/participants.tsv": {
-    "type": "trt/participants"
-  },
-  "/mnt/c/Users/tyark/Dropbox/Code/grabbit/grabbit/tests/data/7t_trt/task-rest_acq-fullbrain_bold.json": {
-    "type": "bold",
-    "task": "rest_acq"
-  },
-  "/mnt/c/Users/tyark/Dropbox/Code/grabbit/grabbit/tests/data/7t_trt/task-rest_acq-fullbrain_run-1_physio.json": {
-    "run": "1",
-    "type": "physio",
-    "task": "rest_acq",
-    "acquisition": "fullbrain_run"
-  },
-  "/mnt/c/Users/tyark/Dropbox/Code/grabbit/grabbit/tests/data/7t_trt/task-rest_acq-fullbrain_run-2_physio.json": {
-    "run": "2",
-    "type": "physio",
-    "task": "rest_acq",
-    "acquisition": "fullbrain_run"
-  },
-  "/mnt/c/Users/tyark/Dropbox/Code/grabbit/grabbit/tests/data/7t_trt/task-rest_acq-prefrontal_bold.json": {
-    "type": "bold",
-    "task": "rest_acq"
-  },
-  "/mnt/c/Users/tyark/Dropbox/Code/grabbit/grabbit/tests/data/7t_trt/task-rest_acq-prefrontal_physio.json": {
-    "type": "physio",
-    "task": "rest_acq"
-  },
-  "/mnt/c/Users/tyark/Dropbox/Code/grabbit/grabbit/tests/data/7t_trt/test.bval": {
-    "type": "trt/test",
-    "bval": "/mnt/c/Users/tyark/Dropbox/Code/grabbit/grabbit/tests/data/7t_trt/test.bval"
-  },
-  "/mnt/c/Users/tyark/Dropbox/Code/grabbit/grabbit/tests/data/7t_trt/models/excluded_model.json": {
-    "type": "model"
-  },
-  "/mnt/c/Users/tyark/Dropbox/Code/grabbit/grabbit/tests/data/7t_trt/sub-01/sub-01_sessions.tsv": {
-    "subject": "01",
-    "type": "sessions"
-  },
-  "/mnt/c/Users/tyark/Dropbox/Code/grabbit/grabbit/tests/data/7t_trt/sub-01/ses-1/sub-01_ses-1_scans.tsv": {
-    "subject": "01",
-    "session": "1",
-    "type": "scans"
-  },
-  "/mnt/c/Users/tyark/Dropbox/Code/grabbit/grabbit/tests/data/7t_trt/sub-01/ses-1/anat/sub-01_ses-1_T1map.nii.gz": {
-    "subject": "01",
-    "session": "1",
-    "type": "T1map"
-  },
-  "/mnt/c/Users/tyark/Dropbox/Code/grabbit/grabbit/tests/data/7t_trt/sub-01/ses-1/anat/sub-01_ses-1_T1w.nii.gz": {
-    "subject": "01",
-    "session": "1",
-    "type": "T1w"
-  },
-  "/mnt/c/Users/tyark/Dropbox/Code/grabbit/grabbit/tests/data/7t_trt/sub-01/ses-1/fmap/sub-01_ses-1_run-1_magnitude1.nii.gz": {
-    "subject": "01",
-    "session": "1",
-    "run": "1",
-    "type": "magnitude1"
-  },
-  "/mnt/c/Users/tyark/Dropbox/Code/grabbit/grabbit/tests/data/7t_trt/sub-01/ses-1/fmap/sub-01_ses-1_run-1_magnitude2.nii.gz": {
-    "subject": "01",
-    "session": "1",
-    "run": "1",
-    "type": "magnitude2"
-  },
-  "/mnt/c/Users/tyark/Dropbox/Code/grabbit/grabbit/tests/data/7t_trt/sub-01/ses-1/fmap/sub-01_ses-1_run-1_phasediff.json": {
-    "subject": "01",
-    "session": "1",
-    "run": "1",
-    "type": "phasediff"
-  },
-  "/mnt/c/Users/tyark/Dropbox/Code/grabbit/grabbit/tests/data/7t_trt/sub-01/ses-1/fmap/sub-01_ses-1_run-1_phasediff.nii.gz": {
-    "subject": "01",
-    "session": "1",
-    "run": "1",
-    "type": "phasediff"
-  },
-  "/mnt/c/Users/tyark/Dropbox/Code/grabbit/grabbit/tests/data/7t_trt/sub-01/ses-1/fmap/sub-01_ses-1_run-2_magnitude1.nii.gz": {
-    "subject": "01",
-    "session": "1",
-    "run": "2",
-    "type": "magnitude1"
-  },
-  "/mnt/c/Users/tyark/Dropbox/Code/grabbit/grabbit/tests/data/7t_trt/sub-01/ses-1/fmap/sub-01_ses-1_run-2_magnitude2.nii.gz": {
-    "subject": "01",
-    "session": "1",
-    "run": "2",
-    "type": "magnitude2"
-  },
-  "/mnt/c/Users/tyark/Dropbox/Code/grabbit/grabbit/tests/data/7t_trt/sub-01/ses-1/fmap/sub-01_ses-1_run-2_phasediff.json": {
-    "subject": "01",
-    "session": "1",
-    "run": "2",
-    "type": "phasediff"
-  },
-  "/mnt/c/Users/tyark/Dropbox/Code/grabbit/grabbit/tests/data/7t_trt/sub-01/ses-1/fmap/sub-01_ses-1_run-2_phasediff.nii.gz": {
-    "subject": "01",
-    "session": "1",
-    "run": "2",
-    "type": "phasediff"
-  },
-  "/mnt/c/Users/tyark/Dropbox/Code/grabbit/grabbit/tests/data/7t_trt/sub-01/ses-1/func/sub-01_ses-1_task-rest_acq-fullbrain_run-1_bold.nii.gz": {
-    "subject": "01",
-    "session": "1",
-    "run": "1",
-    "type": "bold",
-    "task": "rest_acq",
-    "acquisition": "fullbrain_run"
-  },
-  "/mnt/c/Users/tyark/Dropbox/Code/grabbit/grabbit/tests/data/7t_trt/sub-01/ses-1/func/sub-01_ses-1_task-rest_acq-fullbrain_run-1_physio.tsv.gz": {
-    "subject": "01",
-    "session": "1",
-    "run": "1",
-    "type": "physio",
-    "task": "rest_acq",
-    "acquisition": "fullbrain_run"
-  },
-  "/mnt/c/Users/tyark/Dropbox/Code/grabbit/grabbit/tests/data/7t_trt/sub-01/ses-1/func/sub-01_ses-1_task-rest_acq-fullbrain_run-2_bold.nii.gz": {
-    "subject": "01",
-    "session": "1",
-    "run": "2",
-    "type": "bold",
-    "task": "rest_acq",
-    "acquisition": "fullbrain_run"
+  "dataset_description.json": {
+    "test.type": "description"
+  },
+  "participants.tsv": {
+    "test.type": "trt/participants"
+  },
+  "task-rest_acq-fullbrain_bold.json": {
+    "test.type": "bold",
+    "test.task": "rest_acq"
+  },
+  "task-rest_acq-fullbrain_run-1_physio.json": {
+    "test.run": "1",
+    "test.type": "physio",
+    "test.task": "rest_acq",
+    "test.acquisition": "fullbrain_run"
+  },
+  "task-rest_acq-fullbrain_run-2_physio.json": {
+    "test.run": "2",
+    "test.type": "physio",
+    "test.task": "rest_acq",
+    "test.acquisition": "fullbrain_run"
+  },
+  "task-rest_acq-prefrontal_bold.json": {
+    "test.type": "bold",
+    "test.task": "rest_acq"
+  },
+  "task-rest_acq-prefrontal_physio.json": {
+    "test.type": "physio",
+    "test.task": "rest_acq"
+  },
+  "test.bval": {
+    "test.type": "trt/test",
+    "test.bval": "test.bval"
+  },
+  "models/excluded_model.json": {
+    "test.type": "model"
+  },
+  "sub-01/sub-01_sessions.tsv": {
+    "test.subject": "01",
+    "test.type": "sessions"
+  },
+  "sub-01/ses-1/sub-01_ses-1_scans.tsv": {
+    "test.subject": "01",
+    "test.session": "1",
+    "test.type": "scans"
+  },
+  "sub-01/ses-1/anat/sub-01_ses-1_T1map.nii.gz": {
+    "test.subject": "01",
+    "test.session": "1",
+    "test.type": "T1map"
+  },
+  "sub-01/ses-1/anat/sub-01_ses-1_T1w.nii.gz": {
+    "test.subject": "01",
+    "test.session": "1",
+    "test.type": "T1w"
+  },
+  "sub-01/ses-1/fmap/sub-01_ses-1_run-1_magnitude1.nii.gz": {
+    "test.subject": "01",
+    "test.session": "1",
+    "test.run": "1",
+    "test.type": "magnitude1"
+  },
+  "sub-01/ses-1/fmap/sub-01_ses-1_run-1_magnitude2.nii.gz": {
+    "test.subject": "01",
+    "test.session": "1",
+    "test.run": "1",
+    "test.type": "magnitude2"
+  },
+  "sub-01/ses-1/fmap/sub-01_ses-1_run-1_phasediff.json": {
+    "test.subject": "01",
+    "test.session": "1",
+    "test.run": "1",
+    "test.type": "phasediff"
+  },
+  "sub-01/ses-1/fmap/sub-01_ses-1_run-1_phasediff.nii.gz": {
+    "test.subject": "01",
+    "test.session": "1",
+    "test.run": "1",
+    "test.type": "phasediff"
+  },
+  "sub-01/ses-1/fmap/sub-01_ses-1_run-2_magnitude1.nii.gz": {
+    "test.subject": "01",
+    "test.session": "1",
+    "test.run": "2",
+    "test.type": "magnitude1"
+  },
+  "sub-01/ses-1/fmap/sub-01_ses-1_run-2_magnitude2.nii.gz": {
+    "test.subject": "01",
+    "test.session": "1",
+    "test.run": "2",
+    "test.type": "magnitude2"
+  },
+  "sub-01/ses-1/fmap/sub-01_ses-1_run-2_phasediff.json": {
+    "test.subject": "01",
+    "test.session": "1",
+    "test.run": "2",
+    "test.type": "phasediff"
+  },
+  "sub-01/ses-1/fmap/sub-01_ses-1_run-2_phasediff.nii.gz": {
+    "test.subject": "01",
+    "test.session": "1",
+    "test.run": "2",
+    "test.type": "phasediff"
+  },
+  "sub-01/ses-1/func/sub-01_ses-1_task-rest_acq-fullbrain_run-1_bold.nii.gz": {
+    "test.subject": "01",
+    "test.session": "1",
+    "test.run": "1",
+    "test.type": "bold",
+    "test.task": "rest_acq",
+    "test.acquisition": "fullbrain_run"
+  },
+  "sub-01/ses-1/func/sub-01_ses-1_task-rest_acq-fullbrain_run-1_physio.tsv.gz": {
+    "test.subject": "01",
+    "test.session": "1",
+    "test.run": "1",
+    "test.type": "physio",
+    "test.task": "rest_acq",
+    "test.acquisition": "fullbrain_run"
+  },
+  "sub-01/ses-1/func/sub-01_ses-1_task-rest_acq-fullbrain_run-2_bold.nii.gz": {
+    "test.subject": "01",
+    "test.session": "1",
+    "test.run": "2",
+    "test.type": "bold",
+    "test.task": "rest_acq",
+    "test.acquisition": "fullbrain_run"
   }
 }
diff --git a/grabbit/tests/specs/stamps.json b/grabbit/tests/specs/stamps.json
index d5eb2d3..37ce56d 100644
--- a/grabbit/tests/specs/stamps.json
+++ b/grabbit/tests/specs/stamps.json
@@ -1,4 +1,5 @@
 {
+  "name": "stamps",
   "entities": [
     {
       "name": "name",
diff --git a/grabbit/tests/specs/test.json b/grabbit/tests/specs/test.json
index a1dd0d3..d93c986 100644
--- a/grabbit/tests/specs/test.json
+++ b/grabbit/tests/specs/test.json
@@ -1,4 +1,5 @@
 {
+  "name": "test",
   "index" : {
     "exclude" : [".*derivatives.*"]
   },
@@ -25,11 +26,11 @@
     },
     {
       "name": "task",
-      "pattern": "task-(.*?)-"
+      "pattern": "task-(.*?)_"
     },
     {
       "name": "acquisition",
-      "pattern": "acq-(.*?)-"
+      "pattern": "acq-(.*?)_"
     },
     {
       "name": "bval",
diff --git a/grabbit/tests/specs/test_include.json b/grabbit/tests/specs/test_include.json
index 2529bd5..f3a87ef 100644
--- a/grabbit/tests/specs/test_include.json
+++ b/grabbit/tests/specs/test_include.json
@@ -1,4 +1,5 @@
 {
+  "name": "test_with_includes",
   "index" : {
     "include" : ["sub-(\\d+)", "ses-.*", "func", "fmap", ".*\\..*"]
   },
@@ -25,11 +26,11 @@
     },
     {
       "name": "task",
-      "pattern": "task-(.*?)-"
+      "pattern": "task-(.*?)_"
     },
     {
       "name": "acquisition",
-      "pattern": "acq-(.*?)-"
+      "pattern": "acq-(.*?)_"
     },
     {
       "name": "bval",
diff --git a/grabbit/tests/specs/test_with_mapper.json b/grabbit/tests/specs/test_with_mapper.json
index 063aa0d..84c3d28 100644
--- a/grabbit/tests/specs/test_with_mapper.json
+++ b/grabbit/tests/specs/test_with_mapper.json
@@ -1,4 +1,5 @@
 {
+  "name": "test_with_mapper",
   "index" : {
     "exclude" : [".*derivatives.*"]
   },
@@ -25,11 +26,11 @@
     },
     {
       "name": "task",
-      "pattern": "task-(.*?)-"
+      "pattern": "task-(.*?)_"
     },
     {
       "name": "acquisition",
-      "pattern": "acq-(.*?)-"
+      "pattern": "acq-(.*?)_"
     },
     {
       "name": "bval",
diff --git a/grabbit/tests/test_core.py b/grabbit/tests/test_core.py
index a98e3bd..193f0a4 100644
--- a/grabbit/tests/test_core.py
+++ b/grabbit/tests/test_core.py
@@ -1,5 +1,5 @@
 import pytest
-from grabbit import File, Entity, Layout, merge_layouts
+from grabbit import File, Entity, Layout, Tag, merge_layouts
 import os
 import posixpath as psp
 import tempfile
@@ -30,15 +30,18 @@ def bids_layout(request):
         hdfs = pytest.importorskip("hdfs")
         from grabbit.extensions import HDFSLayout
         client = hdfs.Config().get_client()
-        root = psp.join('hdfs://localhost:9000{0}'.format(client.root), 'data', '7t_trt')
-        config = psp.join('hdfs://localhost:9000{0}'.format(client.root), 'specs', 'test.json')
+        root = psp.join('hdfs://localhost:9000{0}'.format(
+            client.root), 'data', '7t_trt')
+        config = psp.join('hdfs://localhost:9000{0}'.format(
+            client.root), 'specs', 'test.json')
         return HDFSLayout(root, config, regex_search=True)
 
+
 @pytest.fixture(scope='module')
 def stamp_layout():
     root = os.path.join(DIRNAME, 'data', 'valuable_stamps')
     config = os.path.join(DIRNAME, 'specs', 'stamps.json')
-    return Layout(root, config)
+    return Layout(root, config, config_filename='dir_config.json')
 
 
 @pytest.fixture(scope='module')
@@ -63,7 +66,7 @@ class TestFile:
         assert file._matches()
         assert file._matches(extensions='nii.gz')
         assert not file._matches(extensions=['.txt', '.rtf'])
-        file.entities = {'task': 'rest', 'run': '2'}
+        file.tags = {'task': Tag(None, 'rest'), 'run': Tag(None, '2')}
         assert file._matches(entities={'task': 'rest', 'run': 2})
         assert not file._matches(entities={'task': 'rest', 'run': 4})
         assert not file._matches(entities={'task': 'st'})
@@ -75,7 +78,7 @@ class TestFile:
                              regex_search=True)
 
     def test_named_tuple(self, file):
-        file.entities = {'attrA': 'apple', 'attrB': 'banana'}
+        file.tags = {'attrA': Tag(None, 'apple'), 'attrB': Tag(None, 'banana')}
         tup = file.as_named_tuple()
         assert(tup.filename == file.path)
         assert isinstance(tup, tuple)
@@ -98,7 +101,7 @@ class TestEntity:
         tmpdir.mkdir("tmp").join(filename).write("###")
         f = File(os.path.join(str(tmpdir), filename))
         e = Entity('avaricious', 'aardvark-(\d+)')
-        e.matches(f)
+        e.matches(f, update_file=True)
         assert 'avaricious' in f.entities
         assert f.entities['avaricious'] == '4'
 
@@ -261,7 +264,7 @@ class TestLayout:
         assert targ not in layout_include.files
 
         with pytest.raises(ValueError):
-            layout_include._load_config({'entities': [],
+            layout_include._load_domain({'entities': [],
                                          'index': {'include': 'test',
                                                    'exclude': 'test'}})
 
@@ -273,9 +276,11 @@ class TestLayout:
             index = json.load(infile)
         assert len(index) == len(bids_layout.files)
         # Check that entities for first 10 files match
+        files = list(bids_layout.files.values())
         for i in range(10):
-            f = list(bids_layout.files.values())[i]
-            assert f.entities == index[f.path]
+            f = files[i]
+            entities = {v.entity.id: v.value for v in f.tags.values()}
+            assert entities == index[f.path]
         os.unlink(tmp)
 
     def test_load_index(self, bids_layout):
@@ -324,22 +329,52 @@ class TestLayout:
     def test_clone(self, bids_layout):
         lc = bids_layout.clone()
         attrs = ['root', 'mandatory', 'dynamic_getters', 'regex_search',
-                 'filtering_regex', 'entity_mapper']
+                 'entity_mapper']
         for a in attrs:
             assert getattr(bids_layout, a) == getattr(lc, a)
         assert set(bids_layout.files.keys()) == set(lc.files.keys())
         assert set(bids_layout.entities.keys()) == set(lc.entities.keys())
 
+    def test_multiple_domains(self, stamp_layout):
+        layout = stamp_layout.clone()
+        assert {'stamps', 'usa_stamps'} == set(layout.domains.keys())
+        usa = layout.domains['usa_stamps']
+        general = layout.domains['stamps']
+        assert len(usa.files) == 3
+        assert len(layout.files) == len(general.files)
+        assert not set(usa.files) - set(general.files)
+        assert layout.entities['usa_stamps.name'] == usa.entities['name']
+        assert layout.entities['stamps.name'] == general.entities['name']
+        assert usa.entities['name'] != general.entities['name']
+        f = layout.get(name='5c_Francis_E_Willard', return_type='obj')[0]
+        assert f.entities == {'name': '5c_Francis_E_Willard',
+                              'value': '1dollar'}
+
+    def test_get_by_domain(self, stamp_layout):
+        files = stamp_layout.get(domains='usa_stamps')
+        assert len(files) == 3
+        files = stamp_layout.get(domains=['nonexistent', 'doms'])
+        assert not files
+        files = stamp_layout.get(domains='usa_stamps', value='35',
+                                 regex_search=True)
+        assert len(files) == 1
+        files = stamp_layout.get(value='35', regex_search=True)
+        assert len(files) == 2
+
 
 def test_merge_layouts(bids_layout, stamp_layout):
     layout = merge_layouts([bids_layout, stamp_layout])
     assert len(layout.files) == len(bids_layout.files) + \
         len(stamp_layout.files)
-    assert 'country' in layout.entities
-    assert 'subject' in layout.entities
+    assert 'stamps.country' in layout.entities
+    assert 'test.subject' in layout.entities
+    dom = layout.domains['stamps']
+    assert 'country' in dom.entities
+    dom = layout.domains['test']
+    assert 'subject' in dom.entities
 
     # Make sure first Layout was cloned and not passed by reference
-    patt = layout.entities['subject'].pattern
-    assert patt == bids_layout.entities['subject'].pattern
-    bids_layout.entities['subject'].pattern = "meh"
+    patt = layout.entities['test.subject'].pattern
+    assert patt == bids_layout.entities['test.subject'].pattern
+    bids_layout.entities['test.subject'].pattern = "meh"
     assert patt != "meh"
diff --git a/grabbit/tests/test_writable.py b/grabbit/tests/test_writable.py
index 22c295a..68b4442 100644
--- a/grabbit/tests/test_writable.py
+++ b/grabbit/tests/test_writable.py
@@ -1,5 +1,5 @@
 import pytest
-from grabbit import Layout, File
+from grabbit import Layout, File, Tag
 from grabbit.extensions.writable import build_path
 import os
 import shutil
@@ -13,6 +13,7 @@ def writable_file(tmpdir):
     fn.write('###')
     return File(os.path.join(str(fn)))
 
+
 @pytest.fixture
 def layout():
     data_dir = join(dirname(__file__), 'data', '7t_trt')
@@ -20,15 +21,20 @@ def layout():
     layout = Layout(data_dir, config=config)
     return layout
 
+
 class TestWritableFile:
 
     def test_build_path(self, writable_file):
-        writable_file.entities = {'task': 'rest', 'run': '2', 'subject': '3'}
+        writable_file.tags = {
+            'task': Tag(None, 'rest'), 'run': Tag(None, '2'),
+            'subject': Tag(None, '3')
+        }
 
         # Single simple pattern
         with pytest.raises(TypeError):
             build_path(writable_file.entities)
-        pat = join(writable_file.dirname, '{task}/sub-{subject}/run-{run}.nii.gz')
+        pat = join(writable_file.dirname,
+                   '{task}/sub-{subject}/run-{run}.nii.gz')
         target = join(writable_file.dirname, 'rest/sub-3/run-2.nii.gz')
         assert build_path(writable_file.entities, pat) == target
 
@@ -79,11 +85,13 @@ class TestWritableFile:
         assert not build_path(entities, pats, True)
 
     def test_build_file(self, writable_file, tmpdir, caplog):
-        writable_file.entities = {'task': 'rest', 'run': '2', 'subject': '3'}
+        writable_file.tags = {'task': Tag(None, 'rest'), 'run': Tag(None, '2'),
+                              'subject': Tag(None, '3')}
 
         # Simple write out
         new_dir = join(writable_file.dirname, 'rest')
-        pat = join(writable_file.dirname, '{task}/sub-{subject}/run-{run}.nii.gz')
+        pat = join(writable_file.dirname,
+                   '{task}/sub-{subject}/run-{run}.nii.gz')
         target = join(writable_file.dirname, 'rest/sub-3/run-2.nii.gz')
         writable_file.copy(pat)
         assert exists(target)
@@ -98,7 +106,8 @@ class TestWritableFile:
         assert log_message == 'A file at path {} already exists, ' \
                               'skipping writing file.'.format(target)
         writable_file.copy(pat, conflicts='append')
-        append_target = join(writable_file.dirname, 'rest/sub-3/run-2_1.nii.gz')
+        append_target = join(writable_file.dirname,
+                             'rest/sub-3/run-2_1.nii.gz')
         assert exists(append_target)
         writable_file.copy(pat, conflicts='overwrite')
         assert exists(target)
@@ -138,12 +147,13 @@ class TestWritableLayout:
                                          '/sess-2'
                                          '/r-1'
                                          '/type-bold'
-                                         '/task-rest_acq.nii.gz')
+                                         '/task-rest.nii.gz')
         example_file2 = join(str(tmpdir), 'sub-04'
                                           '/sess-2'
                                           '/r-1'
                                           '/type-bold'
-                                          '/task-rest_acq.nii.gz')
+                                          '/task-rest.nii.gz')
+
         assert exists(example_file)
         assert not exists(example_file2)
 
@@ -157,7 +167,7 @@ class TestWritableLayout:
                                          '/sess-2'
                                          '/r-1'
                                          '/type-bold'
-                                         '/task-rest_acq.nii.gz')
+                                         '/task-rest.nii.gz')
         assert exists(example_file)
         assert exists(example_file2)
 
@@ -181,6 +191,7 @@ class TestWritableLayout:
         data_dir = join(dirname(__file__), 'data', '7t_trt')
         config = join(dirname(__file__), 'specs', 'test.json')
         layout = Layout(data_dir, config=[config, {
+            'name': "test_writable",
             'default_path_patterns': ['sub-{subject}/ses-{session}/{subject}'
                                       '{session}{run}{type}{task}{acquisition}'
                                       '{bval}']
@@ -200,8 +211,8 @@ class TestWritableLayout:
     def test_build_file_from_layout(self, tmpdir, layout):
         entities = {'subject': 'Bob', 'session': '01', 'run': '1'}
         pat = join(str(tmpdir), 'sub-{subject}'
-                        '/sess-{session}'
-                        '/r-{run}.nii.gz')
+                   '/sess-{session}'
+                   '/r-{run}.nii.gz')
         path = layout.build_path(entities, path_patterns=pat)
         assert path == join(str(tmpdir), 'sub-Bob/sess-01/r-1.nii.gz')