Repository: grabbles/grabbit

Issue:
would fail to work on bids datasets which have a directory starting with . somewhere in their paths
Originally (https://github.com/datalad/datalad/issues/2150) I thought that the issue is symlinked directory somewhere in the path, so was trying to figure out where path `realpath`-ing is happening... long story short that apparently the issue with my `TMPDIR=/home/yoh/.tmp` (which is a symlink to `/tmp`) is having that `.tmp/` in the path since ATM https://github.com/INCF/pybids/blob/master/bids/grabbids/config/bids.json#L4 leads to exclusion of the paths which have it anywhere within their path.

I wondered -- may be  the relative path under the dataset root could be considered instead of the full path? (will submit a tentative fix in this vein)

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..a84092b 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,106 @@ 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 os.path.isabs(filename) and filename.startswith(self.root + os.path.sep):
+            # for filenames under the root - analyze relative path to avoid
+            # bringing injustice to the grandkids of some unfortunately named
+            # root directories.
+            filename = os.path.relpath(filename, self.root)
+
+        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 +453,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 +516,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 +581,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 +622,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 +637,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 +654,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 +675,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 +690,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 +702,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 +710,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 +724,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 +773,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 +793,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 +812,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 +916,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 +938,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 +974,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..02eb754 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,59 @@ 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_excludes(self, tmpdir):
+        root = tmpdir.mkdir("ohmyderivatives").mkdir("ds")
+        config = os.path.join(DIRNAME, 'specs', 'test.json')
+        layout = Layout(str(root), config, regex_search=True)
+        assert layout._check_inclusions(str(root.join("ohmyimportantfile")))
+        assert not layout._check_inclusions(str(root.join("badbadderivatives")))
+
+    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')