Repository: bids-standard/pybids

Issue:
Question: analysis.layout returns file twice if both domains are set
@yarikoptic and I are still trying to get a fitlins analysis on a BIDS derivatives dataset working. One thing we're currently wondering about is that the BIDSLayout returns an identical file twice (one time for the bids domain, one time for the derivatives domain) - This in turn leads fitlins to complain with a ValueError that too many bold files are found. @yarikoptic especially complains that it shouldn't return the file for the bids domain because it has selections/keys that are not compatible with the raw BIDS specification.

Is that a bug or a feature that the same file can get returned twice?

Here's some output from debugging:

```python
(Pdb) p analysis.layout.domains
OrderedDict([('bids', <grabbit.core.Domain object at 0x7fc47552ed30>), ('derivatives', <grabbit.core.Domain object at 0x7fc475541470>)])
(Pdb) p ents
{'run': 1, 'subject': '01', 'session': 'movie', 'space': 'MNI152NLin6Sym', 'task': 'avmovie', 'desc': 'highpass'}
(Pdb) p analysis.layout.get(suffix='bold', extensions=['.nii.gz'], **ents)
[<BIDSFile filename='sub-01/ses-movie/func/sub-01_ses-movie_task-avmovie_run-1_space-MNI152NLin6Sym_desc-highpass_bold.nii.gz'>, <BIDSFile filename='sub-01/ses-movie/func/sub-01_ses-movie_task-avmovie_run-1_space-MNI152NLin6Sym_desc-highpass_bold.nii.gz', root='BIDS_sacc'>]
```

Thanks a lot in advance!

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e55ac41d..ee2751c8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,44 @@
 # Changelog
 
+## Version 0.8.0 (February 14, 2019)
+Version 0.8 refactors much of the layout module. It drops the grabbit
+dependency, overhauls the file indexing process, and features a number of other
+improvements. However, changes to the public API are very minimal, and in the
+vast majority of cases, 0.8 should be a drop-in replacement for 0.7.*.
+
+API-BREAKING CHANGES:
+* Changes to (rarely-used) BIDSLayout initialization arguments:
+	* `include` and `exclude` have been replaced with `ignore` and
+	`force_index`. Paths passed to `ignore` will be ignored from indexing;
+	paths passed to `force_index` will be forcibly indexed even if they are
+	otherwise BIDS-non-compliant. `force_index` takes precedence over `ignore`.
+* Most querying/selection methods add a new `scope` argument that controls
+scope of querying (e.g., `'raw'`, `'derivatives'`, `'all'`, etc.). In some
+cases this replaces the more limited `derivatives` argument.
+* No more `domains`: with the grabbit removal (see below), the notion of a
+`'domain'` has been removed. This should impact few users, but those who need
+to restrict indexing or querying to specific parts of a BIDS project should be
+able to use the `scope` argument more effectively.
+
+OTHER CHANGES:
+* FIX: Path indexing issues in `get_file()` (#379)
+* FIX: Duplicate file returns under certain conditions (#350)
+* REF: The grabbit package is no longer a dependency; as a result, much of the
+functionality from grabbit has been ported over to pybids.
+* REF: Required functionality from six and inflect is now bundled with pybids
+in `bids.external`, minimizing external dependencies.
+* REF: Core modules have been reorganized. Key data structures and containers
+(e.g., `BIDSFile`, `Entity`, etc.) are now in a new `bids.layout.core` module.
+* REF: A new `Config` class has been introduced to house the information
+found in `bids.json` and other layout configuration files.
+* REF: The file-indexing process has been completely refactored. A new
+hierarchy of `BIDSNode` objects has been introduced. While this has no real
+impact on the public API, and isn't really intended for public consumption yet,
+it will in future make it easier for users to work with BIDS projects in a
+tree-like way, while also laying the basis for a more sensible approach to
+reading and accessing associated BIDS data (e.g., .tsv files).
+* MNT: All invocations of `pd.read_table` have been replaced with `read_csv`.
+
 ## Version 0.7.1 (February 01, 2019)
 
 This is a bug fix release in the 0.7 series. The primary API change is improved
diff --git a/bids/analysis/auto_model.py b/bids/analysis/auto_model.py
index bde3ce0a..e1fb5c5a 100644
--- a/bids/analysis/auto_model.py
+++ b/bids/analysis/auto_model.py
@@ -35,7 +35,7 @@ def auto_model(layout, scan_length=None, one_vs_rest=False):
     '''
 
     base_name = split(layout.root)[-1]
-    tasks = layout.entities['bids.task'].unique()
+    tasks = layout.entities['task'].unique()
     task_models = []
 
     for task_name in tasks:
diff --git a/bids/external/__init__.py b/bids/external/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/bids/external/inflect.py b/bids/external/inflect.py
new file mode 100644
index 00000000..e68e2d7c
--- /dev/null
+++ b/bids/external/inflect.py
@@ -0,0 +1,3804 @@
+"""
+    inflect.py: correctly generate plurals, ordinals, indefinite articles;
+                convert numbers to words
+    Copyright (C) 2010 Paul Dyson
+
+    Based upon the Perl module Lingua::EN::Inflect by Damian Conway.
+
+    The original Perl module Lingua::EN::Inflect by Damian Conway is
+    available from http://search.cpan.org/~dconway/
+
+    This module can be downloaded at http://pypi.org/project/inflect
+
+methods:
+          classical inflect
+          plural plural_noun plural_verb plural_adj singular_noun no num a an
+          compare compare_nouns compare_verbs compare_adjs
+          present_participle
+          ordinal
+          number_to_words
+          join
+          defnoun defverb defadj defa defan
+
+    INFLECTIONS:    classical inflect
+          plural plural_noun plural_verb plural_adj singular_noun compare
+          no num a an present_participle
+
+    PLURALS:   classical inflect
+          plural plural_noun plural_verb plural_adj singular_noun no num
+          compare compare_nouns compare_verbs compare_adjs
+
+    COMPARISONS:    classical
+          compare compare_nouns compare_verbs compare_adjs
+
+    ARTICLES:   classical inflect num a an
+
+    NUMERICAL:      ordinal number_to_words
+
+    USER_DEFINED:   defnoun defverb defadj defa defan
+
+Exceptions:
+ UnknownClassicalModeError
+ BadNumValueError
+ BadChunkingOptionError
+ NumOutOfRangeError
+ BadUserDefinedPatternError
+ BadRcFileError
+ BadGenderError
+
+"""
+
+from __future__ import unicode_literals
+
+import ast
+import sys
+import re
+
+
+class UnknownClassicalModeError(Exception):
+    pass
+
+
+class BadNumValueError(Exception):
+    pass
+
+
+class BadChunkingOptionError(Exception):
+    pass
+
+
+class NumOutOfRangeError(Exception):
+    pass
+
+
+class BadUserDefinedPatternError(Exception):
+    pass
+
+
+class BadRcFileError(Exception):
+    pass
+
+
+class BadGenderError(Exception):
+    pass
+
+
+try:
+    __version__ = __import__("importlib_metadata").version("inflect")
+except Exception:
+    __version__ = "unknown"
+
+
+STDOUT_ON = False
+
+
+def print3(txt):
+    if STDOUT_ON:
+        print(txt)
+
+
+def enclose(s):
+    return "(?:%s)" % s
+
+
+def joinstem(cutpoint=0, words=""):
+    """
+    join stem of each word in words into a string for regex
+    each word is truncated at cutpoint
+    cutpoint is usually negative indicating the number of letters to remove
+    from the end of each word
+
+    e.g.
+    joinstem(-2, ["ephemeris", "iris", ".*itis"]) returns
+    (?:ephemer|ir|.*it)
+
+    """
+    return enclose("|".join(w[:cutpoint] for w in words))
+
+
+def bysize(words):
+    """
+    take a list of words and return a dict of sets sorted by word length
+    e.g.
+    ret[3]=set(['ant', 'cat', 'dog', 'pig'])
+    ret[4]=set(['frog', 'goat'])
+    ret[5]=set(['horse'])
+    ret[8]=set(['elephant'])
+    """
+    ret = {}
+    for w in words:
+        if len(w) not in ret:
+            ret[len(w)] = set()
+        ret[len(w)].add(w)
+    return ret
+
+
+def make_pl_si_lists(lst, plending, siendingsize, dojoinstem=True):
+    """
+    given a list of singular words: lst
+    an ending to append to make the plural: plending
+    the number of characters to remove from the singular
+        before appending plending: siendingsize
+    a flag whether to create a joinstem: dojoinstem
+
+    return:
+    a list of pluralised words: si_list (called si because this is what you need to
+                                         look for to make the singular)
+    the pluralised words as a dict of sets sorted by word length: si_bysize
+    the singular words as a dict of sets sorted by word length: pl_bysize
+    if dojoinstem is True: a regular expression that matches any of the stems: stem
+    """
+    if siendingsize is not None:
+        siendingsize = -siendingsize
+    si_list = [w[:siendingsize] + plending for w in lst]
+    pl_bysize = bysize(lst)
+    si_bysize = bysize(si_list)
+    if dojoinstem:
+        stem = joinstem(siendingsize, lst)
+        return si_list, si_bysize, pl_bysize, stem
+    else:
+        return si_list, si_bysize, pl_bysize
+
+
+# 1. PLURALS
+
+pl_sb_irregular_s = {
+    "corpus": "corpuses|corpora",
+    "opus": "opuses|opera",
+    "genus": "genera",
+    "mythos": "mythoi",
+    "penis": "penises|penes",
+    "testis": "testes",
+    "atlas": "atlases|atlantes",
+    "yes": "yeses",
+}
+
+pl_sb_irregular = {
+    "child": "children",
+    "brother": "brothers|brethren",
+    "loaf": "loaves",
+    "hoof": "hoofs|hooves",
+    "beef": "beefs|beeves",
+    "thief": "thiefs|thieves",
+    "money": "monies",
+    "mongoose": "mongooses",
+    "ox": "oxen",
+    "cow": "cows|kine",
+    "graffito": "graffiti",
+    "octopus": "octopuses|octopodes",
+    "genie": "genies|genii",
+    "ganglion": "ganglions|ganglia",
+    "trilby": "trilbys",
+    "turf": "turfs|turves",
+    "numen": "numina",
+    "atman": "atmas",
+    "occiput": "occiputs|occipita",
+    "sabretooth": "sabretooths",
+    "sabertooth": "sabertooths",
+    "lowlife": "lowlifes",
+    "flatfoot": "flatfoots",
+    "tenderfoot": "tenderfoots",
+    "romany": "romanies",
+    "jerry": "jerries",
+    "mary": "maries",
+    "talouse": "talouses",
+    "blouse": "blouses",
+    "rom": "roma",
+    "carmen": "carmina",
+}
+
+pl_sb_irregular.update(pl_sb_irregular_s)
+# pl_sb_irregular_keys = enclose('|'.join(pl_sb_irregular.keys()))
+
+pl_sb_irregular_caps = {
+    "Romany": "Romanies",
+    "Jerry": "Jerrys",
+    "Mary": "Marys",
+    "Rom": "Roma",
+}
+
+pl_sb_irregular_compound = {"prima donna": "prima donnas|prime donne"}
+
+si_sb_irregular = {v: k for (k, v) in pl_sb_irregular.items()}
+keys = list(si_sb_irregular.keys())
+for k in keys:
+    if "|" in k:
+        k1, k2 = k.split("|")
+        si_sb_irregular[k1] = si_sb_irregular[k2] = si_sb_irregular[k]
+        del si_sb_irregular[k]
+si_sb_irregular_caps = {v: k for (k, v) in pl_sb_irregular_caps.items()}
+si_sb_irregular_compound = {v: k for (k, v) in pl_sb_irregular_compound.items()}
+keys = list(si_sb_irregular_compound.keys())
+for k in keys:
+    if "|" in k:
+        k1, k2 = k.split("|")
+        si_sb_irregular_compound[k1] = si_sb_irregular_compound[
+            k2
+        ] = si_sb_irregular_compound[k]
+        del si_sb_irregular_compound[k]
+
+# si_sb_irregular_keys = enclose('|'.join(si_sb_irregular.keys()))
+
+# Z's that don't double
+
+pl_sb_z_zes_list = ("quartz", "topaz")
+pl_sb_z_zes_bysize = bysize(pl_sb_z_zes_list)
+
+pl_sb_ze_zes_list = ("snooze",)
+pl_sb_ze_zes_bysize = bysize(pl_sb_ze_zes_list)
+
+
+# CLASSICAL "..is" -> "..ides"
+
+pl_sb_C_is_ides_complete = [
+    # GENERAL WORDS...
+    "ephemeris",
+    "iris",
+    "clitoris",
+    "chrysalis",
+    "epididymis",
+]
+
+pl_sb_C_is_ides_endings = [
+    # INFLAMATIONS...
+    "itis"
+]
+
+pl_sb_C_is_ides = joinstem(
+    -2, pl_sb_C_is_ides_complete + [".*%s" % w for w in pl_sb_C_is_ides_endings]
+)
+
+pl_sb_C_is_ides_list = pl_sb_C_is_ides_complete + pl_sb_C_is_ides_endings
+
+(
+    si_sb_C_is_ides_list,
+    si_sb_C_is_ides_bysize,
+    pl_sb_C_is_ides_bysize,
+) = make_pl_si_lists(pl_sb_C_is_ides_list, "ides", 2, dojoinstem=False)
+
+
+# CLASSICAL "..a" -> "..ata"
+
+pl_sb_C_a_ata_list = (
+    "anathema",
+    "bema",
+    "carcinoma",
+    "charisma",
+    "diploma",
+    "dogma",
+    "drama",
+    "edema",
+    "enema",
+    "enigma",
+    "lemma",
+    "lymphoma",
+    "magma",
+    "melisma",
+    "miasma",
+    "oedema",
+    "sarcoma",
+    "schema",
+    "soma",
+    "stigma",
+    "stoma",
+    "trauma",
+    "gumma",
+    "pragma",
+)
+
+(
+    si_sb_C_a_ata_list,
+    si_sb_C_a_ata_bysize,
+    pl_sb_C_a_ata_bysize,
+    pl_sb_C_a_ata,
+) = make_pl_si_lists(pl_sb_C_a_ata_list, "ata", 1)
+
+# UNCONDITIONAL "..a" -> "..ae"
+
+pl_sb_U_a_ae_list = ("alumna", "alga", "vertebra", "persona")
+(
+    si_sb_U_a_ae_list,
+    si_sb_U_a_ae_bysize,
+    pl_sb_U_a_ae_bysize,
+    pl_sb_U_a_ae,
+) = make_pl_si_lists(pl_sb_U_a_ae_list, "e", None)
+
+# CLASSICAL "..a" -> "..ae"
+
+pl_sb_C_a_ae_list = (
+    "amoeba",
+    "antenna",
+    "formula",
+    "hyperbola",
+    "medusa",
+    "nebula",
+    "parabola",
+    "abscissa",
+    "hydra",
+    "nova",
+    "lacuna",
+    "aurora",
+    "umbra",
+    "flora",
+    "fauna",
+)
+(
+    si_sb_C_a_ae_list,
+    si_sb_C_a_ae_bysize,
+    pl_sb_C_a_ae_bysize,
+    pl_sb_C_a_ae,
+) = make_pl_si_lists(pl_sb_C_a_ae_list, "e", None)
+
+
+# CLASSICAL "..en" -> "..ina"
+
+pl_sb_C_en_ina_list = ("stamen", "foramen", "lumen")
+
+(
+    si_sb_C_en_ina_list,
+    si_sb_C_en_ina_bysize,
+    pl_sb_C_en_ina_bysize,
+    pl_sb_C_en_ina,
+) = make_pl_si_lists(pl_sb_C_en_ina_list, "ina", 2)
+
+
+# UNCONDITIONAL "..um" -> "..a"
+
+pl_sb_U_um_a_list = (
+    "bacterium",
+    "agendum",
+    "desideratum",
+    "erratum",
+    "stratum",
+    "datum",
+    "ovum",
+    "extremum",
+    "candelabrum",
+)
+(
+    si_sb_U_um_a_list,
+    si_sb_U_um_a_bysize,
+    pl_sb_U_um_a_bysize,
+    pl_sb_U_um_a,
+) = make_pl_si_lists(pl_sb_U_um_a_list, "a", 2)
+
+# CLASSICAL "..um" -> "..a"
+
+pl_sb_C_um_a_list = (
+    "maximum",
+    "minimum",
+    "momentum",
+    "optimum",
+    "quantum",
+    "cranium",
+    "curriculum",
+    "dictum",
+    "phylum",
+    "aquarium",
+    "compendium",
+    "emporium",
+    "enconium",
+    "gymnasium",
+    "honorarium",
+    "interregnum",
+    "lustrum",
+    "memorandum",
+    "millennium",
+    "rostrum",
+    "spectrum",
+    "speculum",
+    "stadium",
+    "trapezium",
+    "ultimatum",
+    "medium",
+    "vacuum",
+    "velum",
+    "consortium",
+    "arboretum",
+)
+
+(
+    si_sb_C_um_a_list,
+    si_sb_C_um_a_bysize,
+    pl_sb_C_um_a_bysize,
+    pl_sb_C_um_a,
+) = make_pl_si_lists(pl_sb_C_um_a_list, "a", 2)
+
+
+# UNCONDITIONAL "..us" -> "i"
+
+pl_sb_U_us_i_list = (
+    "alumnus",
+    "alveolus",
+    "bacillus",
+    "bronchus",
+    "locus",
+    "nucleus",
+    "stimulus",
+    "meniscus",
+    "sarcophagus",
+)
+(
+    si_sb_U_us_i_list,
+    si_sb_U_us_i_bysize,
+    pl_sb_U_us_i_bysize,
+    pl_sb_U_us_i,
+) = make_pl_si_lists(pl_sb_U_us_i_list, "i", 2)
+
+# CLASSICAL "..us" -> "..i"
+
+pl_sb_C_us_i_list = (
+    "focus",
+    "radius",
+    "genius",
+    "incubus",
+    "succubus",
+    "nimbus",
+    "fungus",
+    "nucleolus",
+    "stylus",
+    "torus",
+    "umbilicus",
+    "uterus",
+    "hippopotamus",
+    "cactus",
+)
+
+(
+    si_sb_C_us_i_list,
+    si_sb_C_us_i_bysize,
+    pl_sb_C_us_i_bysize,
+    pl_sb_C_us_i,
+) = make_pl_si_lists(pl_sb_C_us_i_list, "i", 2)
+
+
+# CLASSICAL "..us" -> "..us"  (ASSIMILATED 4TH DECLENSION LATIN NOUNS)
+
+pl_sb_C_us_us = (
+    "status",
+    "apparatus",
+    "prospectus",
+    "sinus",
+    "hiatus",
+    "impetus",
+    "plexus",
+)
+pl_sb_C_us_us_bysize = bysize(pl_sb_C_us_us)
+
+# UNCONDITIONAL "..on" -> "a"
+
+pl_sb_U_on_a_list = (
+    "criterion",
+    "perihelion",
+    "aphelion",
+    "phenomenon",
+    "prolegomenon",
+    "noumenon",
+    "organon",
+    "asyndeton",
+    "hyperbaton",
+)
+(
+    si_sb_U_on_a_list,
+    si_sb_U_on_a_bysize,
+    pl_sb_U_on_a_bysize,
+    pl_sb_U_on_a,
+) = make_pl_si_lists(pl_sb_U_on_a_list, "a", 2)
+
+# CLASSICAL "..on" -> "..a"
+
+pl_sb_C_on_a_list = ("oxymoron",)
+
+(
+    si_sb_C_on_a_list,
+    si_sb_C_on_a_bysize,
+    pl_sb_C_on_a_bysize,
+    pl_sb_C_on_a,
+) = make_pl_si_lists(pl_sb_C_on_a_list, "a", 2)
+
+
+# CLASSICAL "..o" -> "..i"  (BUT NORMALLY -> "..os")
+
+pl_sb_C_o_i = [
+    "solo",
+    "soprano",
+    "basso",
+    "alto",
+    "contralto",
+    "tempo",
+    "piano",
+    "virtuoso",
+]  # list not tuple so can concat for pl_sb_U_o_os
+
+pl_sb_C_o_i_bysize = bysize(pl_sb_C_o_i)
+si_sb_C_o_i_bysize = bysize(["%si" % w[:-1] for w in pl_sb_C_o_i])
+
+pl_sb_C_o_i_stems = joinstem(-1, pl_sb_C_o_i)
+
+# ALWAYS "..o" -> "..os"
+
+pl_sb_U_o_os_complete = {"ado", "ISO", "NATO", "NCO", "NGO", "oto"}
+si_sb_U_o_os_complete = {"%ss" % w for w in pl_sb_U_o_os_complete}
+
+
+pl_sb_U_o_os_endings = [
+    "aficionado",
+    "aggro",
+    "albino",
+    "allegro",
+    "ammo",
+    "Antananarivo",
+    "archipelago",
+    "armadillo",
+    "auto",
+    "avocado",
+    "Bamako",
+    "Barquisimeto",
+    "bimbo",
+    "bingo",
+    "Biro",
+    "bolero",
+    "Bolzano",
+    "bongo",
+    "Boto",
+    "burro",
+    "Cairo",
+    "canto",
+    "cappuccino",
+    "casino",
+    "cello",
+    "Chicago",
+    "Chimango",
+    "cilantro",
+    "cochito",
+    "coco",
+    "Colombo",
+    "Colorado",
+    "commando",
+    "concertino",
+    "contango",
+    "credo",
+    "crescendo",
+    "cyano",
+    "demo",
+    "ditto",
+    "Draco",
+    "dynamo",
+    "embryo",
+    "Esperanto",
+    "espresso",
+    "euro",
+    "falsetto",
+    "Faro",
+    "fiasco",
+    "Filipino",
+    "flamenco",
+    "furioso",
+    "generalissimo",
+    "Gestapo",
+    "ghetto",
+    "gigolo",
+    "gizmo",
+    "Greensboro",
+    "gringo",
+    "Guaiabero",
+    "guano",
+    "gumbo",
+    "gyro",
+    "hairdo",
+    "hippo",
+    "Idaho",
+    "impetigo",
+    "inferno",
+    "info",
+    "intermezzo",
+    "intertrigo",
+    "Iquico",
+    "jumbo",
+    "junto",
+    "Kakapo",
+    "kilo",
+    "Kinkimavo",
+    "Kokako",
+    "Kosovo",
+    "Lesotho",
+    "libero",
+    "libido",
+    "libretto",
+    "lido",
+    "Lilo",
+    "limbo",
+    "limo",
+    "lineno",
+    "lingo",
+    "lino",
+    "livedo",
+    "loco",
+    "logo",
+    "lumbago",
+    "macho",
+    "macro",
+    "mafioso",
+    "magneto",
+    "magnifico",
+    "Majuro",
+    "Malabo",
+    "manifesto",
+    "Maputo",
+    "Maracaibo",
+    "medico",
+    "memo",
+    "metro",
+    "Mexico",
+    "micro",
+    "Milano",
+    "Monaco",
+    "mono",
+    "Montenegro",
+    "Morocco",
+    "Muqdisho",
+    "myo",
+    "neutrino",
+    "Ningbo",
+    "octavo",
+    "oregano",
+    "Orinoco",
+    "Orlando",
+    "Oslo",
+    "panto",
+    "Paramaribo",
+    "Pardusco",
+    "pedalo",
+    "photo",
+    "pimento",
+    "pinto",
+    "pleco",
+    "Pluto",
+    "pogo",
+    "polo",
+    "poncho",
+    "Porto-Novo",
+    "Porto",
+    "pro",
+    "psycho",
+    "pueblo",
+    "quarto",
+    "Quito",
+    "rhino",
+    "risotto",
+    "rococo",
+    "rondo",
+    "Sacramento",
+    "saddo",
+    "sago",
+    "salvo",
+    "Santiago",
+    "Sapporo",
+    "Sarajevo",
+    "scherzando",
+    "scherzo",
+    "silo",
+    "sirocco",
+    "sombrero",
+    "staccato",
+    "sterno",
+    "stucco",
+    "stylo",
+    "sumo",
+    "Taiko",
+    "techno",
+    "terrazzo",
+    "testudo",
+    "timpano",
+    "tiro",
+    "tobacco",
+    "Togo",
+    "Tokyo",
+    "torero",
+    "Torino",
+    "Toronto",
+    "torso",
+    "tremolo",
+    "typo",
+    "tyro",
+    "ufo",
+    "UNESCO",
+    "vaquero",
+    "vermicello",
+    "verso",
+    "vibrato",
+    "violoncello",
+    "Virgo",
+    "weirdo",
+    "WHO",
+    "WTO",
+    "Yamoussoukro",
+    "yo-yo",
+    "zero",
+    "Zibo",
+] + pl_sb_C_o_i
+
+pl_sb_U_o_os_bysize = bysize(pl_sb_U_o_os_endings)
+si_sb_U_o_os_bysize = bysize(["%ss" % w for w in pl_sb_U_o_os_endings])
+
+
+# UNCONDITIONAL "..ch" -> "..chs"
+
+pl_sb_U_ch_chs_list = ("czech", "eunuch", "stomach")
+
+(
+    si_sb_U_ch_chs_list,
+    si_sb_U_ch_chs_bysize,
+    pl_sb_U_ch_chs_bysize,
+    pl_sb_U_ch_chs,
+) = make_pl_si_lists(pl_sb_U_ch_chs_list, "s", None)
+
+
+# UNCONDITIONAL "..[ei]x" -> "..ices"
+
+pl_sb_U_ex_ices_list = ("codex", "murex", "silex")
+(
+    si_sb_U_ex_ices_list,
+    si_sb_U_ex_ices_bysize,
+    pl_sb_U_ex_ices_bysize,
+    pl_sb_U_ex_ices,
+) = make_pl_si_lists(pl_sb_U_ex_ices_list, "ices", 2)
+
+pl_sb_U_ix_ices_list = ("radix", "helix")
+(
+    si_sb_U_ix_ices_list,
+    si_sb_U_ix_ices_bysize,
+    pl_sb_U_ix_ices_bysize,
+    pl_sb_U_ix_ices,
+) = make_pl_si_lists(pl_sb_U_ix_ices_list, "ices", 2)
+
+# CLASSICAL "..[ei]x" -> "..ices"
+
+pl_sb_C_ex_ices_list = (
+    "vortex",
+    "vertex",
+    "cortex",
+    "latex",
+    "pontifex",
+    "apex",
+    "index",
+    "simplex",
+)
+
+(
+    si_sb_C_ex_ices_list,
+    si_sb_C_ex_ices_bysize,
+    pl_sb_C_ex_ices_bysize,
+    pl_sb_C_ex_ices,
+) = make_pl_si_lists(pl_sb_C_ex_ices_list, "ices", 2)
+
+
+pl_sb_C_ix_ices_list = ("appendix",)
+
+(
+    si_sb_C_ix_ices_list,
+    si_sb_C_ix_ices_bysize,
+    pl_sb_C_ix_ices_bysize,
+    pl_sb_C_ix_ices,
+) = make_pl_si_lists(pl_sb_C_ix_ices_list, "ices", 2)
+
+
+# ARABIC: ".." -> "..i"
+
+pl_sb_C_i_list = ("afrit", "afreet", "efreet")
+
+(si_sb_C_i_list, si_sb_C_i_bysize, pl_sb_C_i_bysize, pl_sb_C_i) = make_pl_si_lists(
+    pl_sb_C_i_list, "i", None
+)
+
+
+# HEBREW: ".." -> "..im"
+
+pl_sb_C_im_list = ("goy", "seraph", "cherub")
+
+(si_sb_C_im_list, si_sb_C_im_bysize, pl_sb_C_im_bysize, pl_sb_C_im) = make_pl_si_lists(
+    pl_sb_C_im_list, "im", None
+)
+
+
+# UNCONDITIONAL "..man" -> "..mans"
+
+pl_sb_U_man_mans_list = """
+    ataman caiman cayman ceriman
+    desman dolman farman harman hetman
+    human leman ottoman shaman talisman
+""".split()
+pl_sb_U_man_mans_caps_list = """
+    Alabaman Bahaman Burman German
+    Hiroshiman Liman Nakayaman Norman Oklahoman
+    Panaman Roman Selman Sonaman Tacoman Yakiman
+    Yokohaman Yuman
+""".split()
+
+(
+    si_sb_U_man_mans_list,
+    si_sb_U_man_mans_bysize,
+    pl_sb_U_man_mans_bysize,
+) = make_pl_si_lists(pl_sb_U_man_mans_list, "s", None, dojoinstem=False)
+(
+    si_sb_U_man_mans_caps_list,
+    si_sb_U_man_mans_caps_bysize,
+    pl_sb_U_man_mans_caps_bysize,
+) = make_pl_si_lists(pl_sb_U_man_mans_caps_list, "s", None, dojoinstem=False)
+
+
+pl_sb_uninflected_s_complete = [
+    # PAIRS OR GROUPS SUBSUMED TO A SINGULAR...
+    "breeches",
+    "britches",
+    "pajamas",
+    "pyjamas",
+    "clippers",
+    "gallows",
+    "hijinks",
+    "headquarters",
+    "pliers",
+    "scissors",
+    "testes",
+    "herpes",
+    "pincers",
+    "shears",
+    "proceedings",
+    "trousers",
+    # UNASSIMILATED LATIN 4th DECLENSION
+    "cantus",
+    "coitus",
+    "nexus",
+    # RECENT IMPORTS...
+    "contretemps",
+    "corps",
+    "debris",
+    "siemens",
+    # DISEASES
+    "mumps",
+    # MISCELLANEOUS OTHERS...
+    "diabetes",
+    "jackanapes",
+    "series",
+    "species",
+    "subspecies",
+    "rabies",
+    "chassis",
+    "innings",
+    "news",
+    "mews",
+    "haggis",
+]
+
+pl_sb_uninflected_s_endings = [
+    # RECENT IMPORTS...
+    "ois",
+    # DISEASES
+    "measles",
+]
+
+pl_sb_uninflected_s = pl_sb_uninflected_s_complete + [
+    ".*%s" % w for w in pl_sb_uninflected_s_endings
+]
+
+pl_sb_uninflected_herd = (
+    # DON'T INFLECT IN CLASSICAL MODE, OTHERWISE NORMAL INFLECTION
+    "wildebeest",
+    "swine",
+    "eland",
+    "bison",
+    "buffalo",
+    "elk",
+    "rhinoceros",
+    "zucchini",
+    "caribou",
+    "dace",
+    "grouse",
+    "guinea fowl",
+    "guinea-fowl",
+    "haddock",
+    "hake",
+    "halibut",
+    "herring",
+    "mackerel",
+    "pickerel",
+    "pike",
+    "roe",
+    "seed",
+    "shad",
+    "snipe",
+    "teal",
+    "turbot",
+    "water fowl",
+    "water-fowl",
+)
+
+pl_sb_uninflected_complete = [
+    # SOME FISH AND HERD ANIMALS
+    "tuna",
+    "salmon",
+    "mackerel",
+    "trout",
+    "bream",
+    "sea-bass",
+    "sea bass",
+    "carp",
+    "cod",
+    "flounder",
+    "whiting",
+    "moose",
+    # OTHER ODDITIES
+    "graffiti",
+    "djinn",
+    "samuri",
+    "offspring",
+    "pence",
+    "quid",
+    "hertz",
+] + pl_sb_uninflected_s_complete
+# SOME WORDS ENDING IN ...s (OFTEN PAIRS TAKEN AS A WHOLE)
+
+pl_sb_uninflected_caps = [
+    # ALL NATIONALS ENDING IN -ese
+    "Portuguese",
+    "Amoyese",
+    "Borghese",
+    "Congoese",
+    "Faroese",
+    "Foochowese",
+    "Genevese",
+    "Genoese",
+    "Gilbertese",
+    "Hottentotese",
+    "Kiplingese",
+    "Kongoese",
+    "Lucchese",
+    "Maltese",
+    "Nankingese",
+    "Niasese",
+    "Pekingese",
+    "Piedmontese",
+    "Pistoiese",
+    "Sarawakese",
+    "Shavese",
+    "Vermontese",
+    "Wenchowese",
+    "Yengeese",
+]
+
+
+pl_sb_uninflected_endings = [
+    # SOME FISH AND HERD ANIMALS
+    "fish",
+    "deer",
+    "sheep",
+    # ALL NATIONALS ENDING IN -ese
+    "nese",
+    "rese",
+    "lese",
+    "mese",
+    # DISEASES
+    "pox",
+    # OTHER ODDITIES
+    "craft",
+] + pl_sb_uninflected_s_endings
+# SOME WORDS ENDING IN ...s (OFTEN PAIRS TAKEN AS A WHOLE)
+
+
+pl_sb_uninflected_bysize = bysize(pl_sb_uninflected_endings)
+
+
+# SINGULAR WORDS ENDING IN ...s (ALL INFLECT WITH ...es)
+
+pl_sb_singular_s_complete = [
+    "acropolis",
+    "aegis",
+    "alias",
+    "asbestos",
+    "bathos",
+    "bias",
+    "bronchitis",
+    "bursitis",
+    "caddis",
+    "cannabis",
+    "canvas",
+    "chaos",
+    "cosmos",
+    "dais",
+    "digitalis",
+    "epidermis",
+    "ethos",
+    "eyas",
+    "gas",
+    "glottis",
+    "hubris",
+    "ibis",
+    "lens",
+    "mantis",
+    "marquis",
+    "metropolis",
+    "pathos",
+    "pelvis",
+    "polis",
+    "rhinoceros",
+    "sassafras",
+    "trellis",
+] + pl_sb_C_is_ides_complete
+
+
+pl_sb_singular_s_endings = ["ss", "us"] + pl_sb_C_is_ides_endings
+
+pl_sb_singular_s_bysize = bysize(pl_sb_singular_s_endings)
+
+si_sb_singular_s_complete = ["%ses" % w for w in pl_sb_singular_s_complete]
+si_sb_singular_s_endings = ["%ses" % w for w in pl_sb_singular_s_endings]
+si_sb_singular_s_bysize = bysize(si_sb_singular_s_endings)
+
+pl_sb_singular_s_es = ["[A-Z].*es"]
+
+pl_sb_singular_s = enclose(
+    "|".join(
+        pl_sb_singular_s_complete
+        + [".*%s" % w for w in pl_sb_singular_s_endings]
+        + pl_sb_singular_s_es
+    )
+)
+
+
+# PLURALS ENDING IN uses -> use
+
+
+si_sb_ois_oi_case = ("Bolshois", "Hanois")
+
+si_sb_uses_use_case = ("Betelgeuses", "Duses", "Meuses", "Syracuses", "Toulouses")
+
+si_sb_uses_use = (
+    "abuses",
+    "applauses",
+    "blouses",
+    "carouses",
+    "causes",
+    "chartreuses",
+    "clauses",
+    "contuses",
+    "douses",
+    "excuses",
+    "fuses",
+    "grouses",
+    "hypotenuses",
+    "masseuses",
+    "menopauses",
+    "misuses",
+    "muses",
+    "overuses",
+    "pauses",
+    "peruses",
+    "profuses",
+    "recluses",
+    "reuses",
+    "ruses",
+    "souses",
+    "spouses",
+    "suffuses",
+    "transfuses",
+    "uses",
+)
+
+si_sb_ies_ie_case = (
+    "Addies",
+    "Aggies",
+    "Allies",
+    "Amies",
+    "Angies",
+    "Annies",
+    "Annmaries",
+    "Archies",
+    "Arties",
+    "Aussies",
+    "Barbies",
+    "Barries",
+    "Basies",
+    "Bennies",
+    "Bernies",
+    "Berties",
+    "Bessies",
+    "Betties",
+    "Billies",
+    "Blondies",
+    "Bobbies",
+    "Bonnies",
+    "Bowies",
+    "Brandies",
+    "Bries",
+    "Brownies",
+    "Callies",
+    "Carnegies",
+    "Carries",
+    "Cassies",
+    "Charlies",
+    "Cheries",
+    "Christies",
+    "Connies",
+    "Curies",
+    "Dannies",
+    "Debbies",
+    "Dixies",
+    "Dollies",
+    "Donnies",
+    "Drambuies",
+    "Eddies",
+    "Effies",
+    "Ellies",
+    "Elsies",
+    "Eries",
+    "Ernies",
+    "Essies",
+    "Eugenies",
+    "Fannies",
+    "Flossies",
+    "Frankies",
+    "Freddies",
+    "Gillespies",
+    "Goldies",
+    "Gracies",
+    "Guthries",
+    "Hallies",
+    "Hatties",
+    "Hetties",
+    "Hollies",
+    "Jackies",
+    "Jamies",
+    "Janies",
+    "Jannies",
+    "Jeanies",
+    "Jeannies",
+    "Jennies",
+    "Jessies",
+    "Jimmies",
+    "Jodies",
+    "Johnies",
+    "Johnnies",
+    "Josies",
+    "Julies",
+    "Kalgoorlies",
+    "Kathies",
+    "Katies",
+    "Kellies",
+    "Kewpies",
+    "Kristies",
+    "Laramies",
+    "Lassies",
+    "Lauries",
+    "Leslies",
+    "Lessies",
+    "Lillies",
+    "Lizzies",
+    "Lonnies",
+    "Lories",
+    "Lorries",
+    "Lotties",
+    "Louies",
+    "Mackenzies",
+    "Maggies",
+    "Maisies",
+    "Mamies",
+    "Marcies",
+    "Margies",
+    "Maries",
+    "Marjories",
+    "Matties",
+    "McKenzies",
+    "Melanies",
+    "Mickies",
+    "Millies",
+    "Minnies",
+    "Mollies",
+    "Mounties",
+    "Nannies",
+    "Natalies",
+    "Nellies",
+    "Netties",
+    "Ollies",
+    "Ozzies",
+    "Pearlies",
+    "Pottawatomies",
+    "Reggies",
+    "Richies",
+    "Rickies",
+    "Robbies",
+    "Ronnies",
+    "Rosalies",
+    "Rosemaries",
+    "Rosies",
+    "Roxies",
+    "Rushdies",
+    "Ruthies",
+    "Sadies",
+    "Sallies",
+    "Sammies",
+    "Scotties",
+    "Selassies",
+    "Sherries",
+    "Sophies",
+    "Stacies",
+    "Stefanies",
+    "Stephanies",
+    "Stevies",
+    "Susies",
+    "Sylvies",
+    "Tammies",
+    "Terries",
+    "Tessies",
+    "Tommies",
+    "Tracies",
+    "Trekkies",
+    "Valaries",
+    "Valeries",
+    "Valkyries",
+    "Vickies",
+    "Virgies",
+    "Willies",
+    "Winnies",
+    "Wylies",
+    "Yorkies",
+)
+
+si_sb_ies_ie = (
+    "aeries",
+    "baggies",
+    "belies",
+    "biggies",
+    "birdies",
+    "bogies",
+    "bonnies",
+    "boogies",
+    "bookies",
+    "bourgeoisies",
+    "brownies",
+    "budgies",
+    "caddies",
+    "calories",
+    "camaraderies",
+    "cockamamies",
+    "collies",
+    "cookies",
+    "coolies",
+    "cooties",
+    "coteries",
+    "crappies",
+    "curies",
+    "cutesies",
+    "dogies",
+    "eyrie",
+    "floozies",
+    "footsies",
+    "freebies",
+    "genies",
+    "goalies",
+    "groupies",
+    "hies",
+    "jalousies",
+    "junkies",
+    "kiddies",
+    "laddies",
+    "lassies",
+    "lies",
+    "lingeries",
+    "magpies",
+    "menageries",
+    "mommies",
+    "movies",
+    "neckties",
+    "newbies",
+    "nighties",
+    "oldies",
+    "organdies",
+    "overlies",
+    "pies",
+    "pinkies",
+    "pixies",
+    "potpies",
+    "prairies",
+    "quickies",
+    "reveries",
+    "rookies",
+    "rotisseries",
+    "softies",
+    "sorties",
+    "species",
+    "stymies",
+    "sweeties",
+    "ties",
+    "underlies",
+    "unties",
+    "veggies",
+    "vies",
+    "yuppies",
+    "zombies",
+)
+
+
+si_sb_oes_oe_case = (
+    "Chloes",
+    "Crusoes",
+    "Defoes",
+    "Faeroes",
+    "Ivanhoes",
+    "Joes",
+    "McEnroes",
+    "Moes",
+    "Monroes",
+    "Noes",
+    "Poes",
+    "Roscoes",
+    "Tahoes",
+    "Tippecanoes",
+    "Zoes",
+)
+
+si_sb_oes_oe = (
+    "aloes",
+    "backhoes",
+    "canoes",
+    "does",
+    "floes",
+    "foes",
+    "hoes",
+    "mistletoes",
+    "oboes",
+    "pekoes",
+    "roes",
+    "sloes",
+    "throes",
+    "tiptoes",
+    "toes",
+    "woes",
+)
+
+si_sb_z_zes = ("quartzes", "topazes")
+
+si_sb_zzes_zz = ("buzzes", "fizzes", "frizzes", "razzes")
+
+si_sb_ches_che_case = (
+    "Andromaches",
+    "Apaches",
+    "Blanches",
+    "Comanches",
+    "Nietzsches",
+    "Porsches",
+    "Roches",
+)
+
+si_sb_ches_che = (
+    "aches",
+    "avalanches",
+    "backaches",
+    "bellyaches",
+    "caches",
+    "cloches",
+    "creches",
+    "douches",
+    "earaches",
+    "fiches",
+    "headaches",
+    "heartaches",
+    "microfiches",
+    "niches",
+    "pastiches",
+    "psyches",
+    "quiches",
+    "stomachaches",
+    "toothaches",
+)
+
+si_sb_xes_xe = ("annexes", "axes", "deluxes", "pickaxes")
+
+si_sb_sses_sse_case = ("Hesses", "Jesses", "Larousses", "Matisses")
+si_sb_sses_sse = (
+    "bouillabaisses",
+    "crevasses",
+    "demitasses",
+    "impasses",
+    "mousses",
+    "posses",
+)
+
+si_sb_ves_ve_case = (
+    # *[nwl]ives -> [nwl]live
+    "Clives",
+    "Palmolives",
+)
+si_sb_ves_ve = (
+    # *[^d]eaves -> eave
+    "interweaves",
+    "weaves",
+    # *[nwl]ives -> [nwl]live
+    "olives",
+    # *[eoa]lves -> [eoa]lve
+    "bivalves",
+    "dissolves",
+    "resolves",
+    "salves",
+    "twelves",
+    "valves",
+)
+
+
+plverb_special_s = enclose(
+    "|".join(
+        [pl_sb_singular_s]
+        + pl_sb_uninflected_s
+        + list(pl_sb_irregular_s.keys())
+        + ["(.*[csx])is", "(.*)ceps", "[A-Z].*s"]
+    )
+)
+
+pl_sb_postfix_adj = {
+    "general": [r"(?!major|lieutenant|brigadier|adjutant|.*star)\S+"],
+    "martial": ["court"],
+    "force": ["pound"],
+}
+
+for k in list(pl_sb_postfix_adj.keys()):
+    pl_sb_postfix_adj[k] = enclose(
+        enclose("|".join(pl_sb_postfix_adj[k])) + "(?=(?:-|\\s+)%s)" % k
+    )
+
+pl_sb_postfix_adj_stems = "(" + "|".join(list(pl_sb_postfix_adj.values())) + ")(.*)"
+
+
+# PLURAL WORDS ENDING IS es GO TO SINGULAR is
+
+si_sb_es_is = (
+    "amanuenses",
+    "amniocenteses",
+    "analyses",
+    "antitheses",
+    "apotheoses",
+    "arterioscleroses",
+    "atheroscleroses",
+    "axes",
+    # 'bases', # bases -> basis
+    "catalyses",
+    "catharses",
+    "chasses",
+    "cirrhoses",
+    "cocces",
+    "crises",
+    "diagnoses",
+    "dialyses",
+    "diereses",
+    "electrolyses",
+    "emphases",
+    "exegeses",
+    "geneses",
+    "halitoses",
+    "hydrolyses",
+    "hypnoses",
+    "hypotheses",
+    "hystereses",
+    "metamorphoses",
+    "metastases",
+    "misdiagnoses",
+    "mitoses",
+    "mononucleoses",
+    "narcoses",
+    "necroses",
+    "nemeses",
+    "neuroses",
+    "oases",
+    "osmoses",
+    "osteoporoses",
+    "paralyses",
+    "parentheses",
+    "parthenogeneses",
+    "periphrases",
+    "photosyntheses",
+    "probosces",
+    "prognoses",
+    "prophylaxes",
+    "prostheses",
+    "preces",
+    "psoriases",
+    "psychoanalyses",
+    "psychokineses",
+    "psychoses",
+    "scleroses",
+    "scolioses",
+    "sepses",
+    "silicoses",
+    "symbioses",
+    "synopses",
+    "syntheses",
+    "taxes",
+    "telekineses",
+    "theses",
+    "thromboses",
+    "tuberculoses",
+    "urinalyses",
+)
+
+pl_prep_list = """
+    about above across after among around at athwart before behind
+    below beneath beside besides between betwixt beyond but by
+    during except for from in into near of off on onto out over
+    since till to under until unto upon with""".split()
+
+pl_prep_list_da = pl_prep_list + ["de", "du", "da"]
+
+pl_prep_bysize = bysize(pl_prep_list_da)
+
+pl_prep = enclose("|".join(pl_prep_list_da))
+
+pl_sb_prep_dual_compound = (
+    r"(.*?)((?:-|\s+)(?:" + pl_prep + r")(?:-|\s+))a(?:-|\s+)(.*)"
+)
+
+
+singular_pronoun_genders = {
+    "neuter",
+    "feminine",
+    "masculine",
+    "gender-neutral",
+    "feminine or masculine",
+    "masculine or feminine",
+}
+
+pl_pron_nom = {
+    # NOMINATIVE    REFLEXIVE
+    "i": "we",
+    "myself": "ourselves",
+    "you": "you",
+    "yourself": "yourselves",
+    "she": "they",
+    "herself": "themselves",
+    "he": "they",
+    "himself": "themselves",
+    "it": "they",
+    "itself": "themselves",
+    "they": "they",
+    "themself": "themselves",
+    #   POSSESSIVE
+    "mine": "ours",
+    "yours": "yours",
+    "hers": "theirs",
+    "his": "theirs",
+    "its": "theirs",
+    "theirs": "theirs",
+}
+
+si_pron = {}
+si_pron["nom"] = {v: k for (k, v) in pl_pron_nom.items()}
+si_pron["nom"]["we"] = "I"
+
+
+pl_pron_acc = {
+    # ACCUSATIVE    REFLEXIVE
+    "me": "us",
+    "myself": "ourselves",
+    "you": "you",
+    "yourself": "yourselves",
+    "her": "them",
+    "herself": "themselves",
+    "him": "them",
+    "himself": "themselves",
+    "it": "them",
+    "itself": "themselves",
+    "them": "them",
+    "themself": "themselves",
+}
+
+pl_pron_acc_keys = enclose("|".join(list(pl_pron_acc.keys())))
+pl_pron_acc_keys_bysize = bysize(list(pl_pron_acc.keys()))
+
+si_pron["acc"] = {v: k for (k, v) in pl_pron_acc.items()}
+
+for thecase, plur, gend, sing in (
+    ("nom", "they", "neuter", "it"),
+    ("nom", "they", "feminine", "she"),
+    ("nom", "they", "masculine", "he"),
+    ("nom", "they", "gender-neutral", "they"),
+    ("nom", "they", "feminine or masculine", "she or he"),
+    ("nom", "they", "masculine or feminine", "he or she"),
+    ("nom", "themselves", "neuter", "itself"),
+    ("nom", "themselves", "feminine", "herself"),
+    ("nom", "themselves", "masculine", "himself"),
+    ("nom", "themselves", "gender-neutral", "themself"),
+    ("nom", "themselves", "feminine or masculine", "herself or himself"),
+    ("nom", "themselves", "masculine or feminine", "himself or herself"),
+    ("nom", "theirs", "neuter", "its"),
+    ("nom", "theirs", "feminine", "hers"),
+    ("nom", "theirs", "masculine", "his"),
+    ("nom", "theirs", "gender-neutral", "theirs"),
+    ("nom", "theirs", "feminine or masculine", "hers or his"),
+    ("nom", "theirs", "masculine or feminine", "his or hers"),
+    ("acc", "them", "neuter", "it"),
+    ("acc", "them", "feminine", "her"),
+    ("acc", "them", "masculine", "him"),
+    ("acc", "them", "gender-neutral", "them"),
+    ("acc", "them", "feminine or masculine", "her or him"),
+    ("acc", "them", "masculine or feminine", "him or her"),
+    ("acc", "themselves", "neuter", "itself"),
+    ("acc", "themselves", "feminine", "herself"),
+    ("acc", "themselves", "masculine", "himself"),
+    ("acc", "themselves", "gender-neutral", "themself"),
+    ("acc", "themselves", "feminine or masculine", "herself or himself"),
+    ("acc", "themselves", "masculine or feminine", "himself or herself"),
+):
+    try:
+        si_pron[thecase][plur][gend] = sing
+    except TypeError:
+        si_pron[thecase][plur] = {}
+        si_pron[thecase][plur][gend] = sing
+
+
+si_pron_acc_keys = enclose("|".join(list(si_pron["acc"].keys())))
+si_pron_acc_keys_bysize = bysize(list(si_pron["acc"].keys()))
+
+
+def get_si_pron(thecase, word, gender):
+    try:
+        sing = si_pron[thecase][word]
+    except KeyError:
+        raise  # not a pronoun
+    try:
+        return sing[gender]  # has several types due to gender
+    except TypeError:
+        return sing  # answer independent of gender
+
+
+plverb_irregular_pres = {
+    # 1st PERS. SING.   2ND PERS. SING.   3RD PERS. SINGULAR
+    # 3RD PERS. (INDET.)
+    "am": "are",
+    "are": "are",
+    "is": "are",
+    "was": "were",
+    "were": "were",
+    "was": "were",
+    "have": "have",
+    "have": "have",
+    "has": "have",
+    "do": "do",
+    "do": "do",
+    "does": "do",
+}
+
+plverb_ambiguous_pres = {
+    # 1st PERS. SING.  2ND PERS. SING.   3RD PERS. SINGULAR
+    # 3RD PERS. (INDET.)
+    "act": "act",
+    "act": "act",
+    "acts": "act",
+    "blame": "blame",
+    "blame": "blame",
+    "blames": "blame",
+    "can": "can",
+    "can": "can",
+    "can": "can",
+    "must": "must",
+    "must": "must",
+    "must": "must",
+    "fly": "fly",
+    "fly": "fly",
+    "flies": "fly",
+    "copy": "copy",
+    "copy": "copy",
+    "copies": "copy",
+    "drink": "drink",
+    "drink": "drink",
+    "drinks": "drink",
+    "fight": "fight",
+    "fight": "fight",
+    "fights": "fight",
+    "fire": "fire",
+    "fire": "fire",
+    "fires": "fire",
+    "like": "like",
+    "like": "like",
+    "likes": "like",
+    "look": "look",
+    "look": "look",
+    "looks": "look",
+    "make": "make",
+    "make": "make",
+    "makes": "make",
+    "reach": "reach",
+    "reach": "reach",
+    "reaches": "reach",
+    "run": "run",
+    "run": "run",
+    "runs": "run",
+    "sink": "sink",
+    "sink": "sink",
+    "sinks": "sink",
+    "sleep": "sleep",
+    "sleep": "sleep",
+    "sleeps": "sleep",
+    "view": "view",
+    "view": "view",
+    "views": "view",
+}
+
+plverb_ambiguous_pres_keys = enclose("|".join(list(plverb_ambiguous_pres.keys())))
+
+
+plverb_irregular_non_pres = (
+    "did",
+    "had",
+    "ate",
+    "made",
+    "put",
+    "spent",
+    "fought",
+    "sank",
+    "gave",
+    "sought",
+    "shall",
+    "could",
+    "ought",
+    "should",
+)
+
+plverb_ambiguous_non_pres = enclose(
+    "|".join(("thought", "saw", "bent", "will", "might", "cut"))
+)
+
+# "..oes" -> "..oe" (the rest are "..oes" -> "o")
+
+pl_v_oes_oe = ("canoes", "floes", "oboes", "roes", "throes", "woes")
+pl_v_oes_oe_endings_size4 = ("hoes", "toes")
+pl_v_oes_oe_endings_size5 = ("shoes",)
+
+
+pl_count_zero = ("0", "no", "zero", "nil")
+
+
+pl_count_one = ("1", "a", "an", "one", "each", "every", "this", "that")
+
+pl_adj_special = {"a": "some", "an": "some", "this": "these", "that": "those"}
+
+pl_adj_special_keys = enclose("|".join(list(pl_adj_special.keys())))
+
+pl_adj_poss = {
+    "my": "our",
+    "your": "your",
+    "its": "their",
+    "her": "their",
+    "his": "their",
+    "their": "their",
+}
+
+pl_adj_poss_keys = enclose("|".join(list(pl_adj_poss.keys())))
+
+
+# 2. INDEFINITE ARTICLES
+
+# THIS PATTERN MATCHES STRINGS OF CAPITALS STARTING WITH A "VOWEL-SOUND"
+# CONSONANT FOLLOWED BY ANOTHER CONSONANT, AND WHICH ARE NOT LIKELY
+# TO BE REAL WORDS (OH, ALL RIGHT THEN, IT'S JUST MAGIC!)
+
+A_abbrev = r"""
+(?! FJO | [HLMNS]Y.  | RY[EO] | SQU
+  | ( F[LR]? | [HL] | MN? | N | RH? | S[CHKLMNPTVW]? | X(YL)?) [AEIOU])
+[FHLMNRSX][A-Z]
+"""
+
+# THIS PATTERN CODES THE BEGINNINGS OF ALL ENGLISH WORDS BEGINING WITH A
+# 'y' FOLLOWED BY A CONSONANT. ANY OTHER Y-CONSONANT PREFIX THEREFORE
+# IMPLIES AN ABBREVIATION.
+
+A_y_cons = "y(b[lor]|cl[ea]|fere|gg|p[ios]|rou|tt)"
+
+# EXCEPTIONS TO EXCEPTIONS
+
+A_explicit_a = enclose("|".join(("unabomber", "unanimous", "US")))
+
+A_explicit_an = enclose(
+    "|".join(("euler", "hour(?!i)", "heir", "honest", "hono[ur]", "mpeg"))
+)
+
+A_ordinal_an = enclose("|".join(("[aefhilmnorsx]-?th",)))
+
+A_ordinal_a = enclose("|".join(("[bcdgjkpqtuvwyz]-?th",)))
+
+
+# NUMERICAL INFLECTIONS
+
+nth = {
+    0: "th",
+    1: "st",
+    2: "nd",
+    3: "rd",
+    4: "th",
+    5: "th",
+    6: "th",
+    7: "th",
+    8: "th",
+    9: "th",
+    11: "th",
+    12: "th",
+    13: "th",
+}
+
+ordinal = dict(
+    ty="tieth",
+    one="first",
+    two="second",
+    three="third",
+    five="fifth",
+    eight="eighth",
+    nine="ninth",
+    twelve="twelfth",
+)
+
+ordinal_suff = "|".join(list(ordinal.keys()))
+
+
+# NUMBERS
+
+unit = ["", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine"]
+teen = [
+    "ten",
+    "eleven",
+    "twelve",
+    "thirteen",
+    "fourteen",
+    "fifteen",
+    "sixteen",
+    "seventeen",
+    "eighteen",
+    "nineteen",
+]
+ten = [
+    "",
+    "",
+    "twenty",
+    "thirty",
+    "forty",
+    "fifty",
+    "sixty",
+    "seventy",
+    "eighty",
+    "ninety",
+]
+mill = [
+    " ",
+    " thousand",
+    " million",
+    " billion",
+    " trillion",
+    " quadrillion",
+    " quintillion",
+    " sextillion",
+    " septillion",
+    " octillion",
+    " nonillion",
+    " decillion",
+]
+
+
+# SUPPORT CLASSICAL PLURALIZATIONS
+
+def_classical = dict(
+    all=False, zero=False, herd=False, names=True, persons=False, ancient=False
+)
+
+all_classical = {k: True for k in list(def_classical.keys())}
+no_classical = {k: False for k in list(def_classical.keys())}
+
+
+# Maps strings to built-in constant types
+string_to_constant = {"True": True, "False": False, "None": None}
+
+
+class engine:
+    def __init__(self):
+
+        self.classical_dict = def_classical.copy()
+        self.persistent_count = None
+        self.mill_count = 0
+        self.pl_sb_user_defined = []
+        self.pl_v_user_defined = []
+        self.pl_adj_user_defined = []
+        self.si_sb_user_defined = []
+        self.A_a_user_defined = []
+        self.thegender = "neuter"
+
+    deprecated_methods = dict(
+        pl="plural",
+        plnoun="plural_noun",
+        plverb="plural_verb",
+        pladj="plural_adj",
+        sinoun="single_noun",
+        prespart="present_participle",
+        numwords="number_to_words",
+        plequal="compare",
+        plnounequal="compare_nouns",
+        plverbequal="compare_verbs",
+        pladjequal="compare_adjs",
+        wordlist="join",
+    )
+
+    def __getattr__(self, meth):
+        if meth in self.deprecated_methods:
+            print3(
+                "{}() deprecated, use {}()".format(meth, self.deprecated_methods[meth])
+            )
+            raise DeprecationWarning
+        raise AttributeError
+
+    def defnoun(self, singular, plural):
+        """
+        Set the noun plural of singular to plural.
+
+        """
+        self.checkpat(singular)
+        self.checkpatplural(plural)
+        self.pl_sb_user_defined.extend((singular, plural))
+        self.si_sb_user_defined.extend((plural, singular))
+        return 1
+
+    def defverb(self, s1, p1, s2, p2, s3, p3):
+        """
+        Set the verb plurals for s1, s2 and s3 to p1, p2 and p3 respectively.
+
+        Where 1, 2 and 3 represent the 1st, 2nd and 3rd person forms of the verb.
+
+        """
+        self.checkpat(s1)
+        self.checkpat(s2)
+        self.checkpat(s3)
+        self.checkpatplural(p1)
+        self.checkpatplural(p2)
+        self.checkpatplural(p3)
+        self.pl_v_user_defined.extend((s1, p1, s2, p2, s3, p3))
+        return 1
+
+    def defadj(self, singular, plural):
+        """
+        Set the adjective plural of singular to plural.
+
+        """
+        self.checkpat(singular)
+        self.checkpatplural(plural)
+        self.pl_adj_user_defined.extend((singular, plural))
+        return 1
+
+    def defa(self, pattern):
+        """
+        Define the indefinate article as 'a' for words matching pattern.
+
+        """
+        self.checkpat(pattern)
+        self.A_a_user_defined.extend((pattern, "a"))
+        return 1
+
+    def defan(self, pattern):
+        """
+        Define the indefinate article as 'an' for words matching pattern.
+
+        """
+        self.checkpat(pattern)
+        self.A_a_user_defined.extend((pattern, "an"))
+        return 1
+
+    def checkpat(self, pattern):
+        """
+        check for errors in a regex pattern
+        """
+        if pattern is None:
+            return
+        try:
+            re.match(pattern, "")
+        except re.error:
+            print3("\nBad user-defined singular pattern:\n\t%s\n" % pattern)
+            raise BadUserDefinedPatternError
+
+    def checkpatplural(self, pattern):
+        """
+        check for errors in a regex replace pattern
+        """
+        return
+
+    def ud_match(self, word, wordlist):
+        for i in range(len(wordlist) - 2, -2, -2):  # backwards through even elements
+            mo = re.search(r"^%s$" % wordlist[i], word, re.IGNORECASE)
+            if mo:
+                if wordlist[i + 1] is None:
+                    return None
+                pl = re.sub(
+                    r"\$(\d+)", r"\\1", wordlist[i + 1]
+                )  # change $n to \n for expand
+                return mo.expand(pl)
+        return None
+
+    def classical(self, **kwargs):
+        """
+        turn classical mode on and off for various categories
+
+        turn on all classical modes:
+        classical()
+        classical(all=True)
+
+        turn on or off specific claassical modes:
+        e.g.
+        classical(herd=True)
+        classical(names=False)
+
+        By default all classical modes are off except names.
+
+        unknown value in args or key in kwargs rasies
+        exception: UnknownClasicalModeError
+
+        """
+        classical_mode = list(def_classical.keys())
+        if not kwargs:
+            self.classical_dict = all_classical.copy()
+            return
+        if "all" in kwargs:
+            if kwargs["all"]:
+                self.classical_dict = all_classical.copy()
+            else:
+                self.classical_dict = no_classical.copy()
+
+        for k, v in list(kwargs.items()):
+            if k in classical_mode:
+                self.classical_dict[k] = v
+            else:
+                raise UnknownClassicalModeError
+
+    def num(self, count=None, show=None):  # (;$count,$show)
+        """
+        Set the number to be used in other method calls.
+
+        Returns count.
+
+        Set show to False to return '' instead.
+
+        """
+        if count is not None:
+            try:
+                self.persistent_count = int(count)
+            except ValueError:
+                raise BadNumValueError
+            if (show is None) or show:
+                return str(count)
+        else:
+            self.persistent_count = None
+        return ""
+
+    def gender(self, gender):
+        """
+        set the gender for the singular of plural pronouns
+
+        can be one of:
+        'neuter'                ('they' -> 'it')
+        'feminine'              ('they' -> 'she')
+        'masculine'             ('they' -> 'he')
+        'gender-neutral'        ('they' -> 'they')
+        'feminine or masculine' ('they' -> 'she or he')
+        'masculine or feminine' ('they' -> 'he or she')
+        """
+        if gender in singular_pronoun_genders:
+            self.thegender = gender
+        else:
+            raise BadGenderError
+
+    def _get_value_from_ast(self, obj):
+        """
+        Return the value of the ast object.
+        """
+        if isinstance(obj, ast.Num):
+            return obj.n
+        elif isinstance(obj, ast.Str):
+            return obj.s
+        elif isinstance(obj, ast.List):
+            return [self._get_value_from_ast(e) for e in obj.elts]
+        elif isinstance(obj, ast.Tuple):
+            return tuple([self._get_value_from_ast(e) for e in obj.elts])
+
+        # None, True and False are NameConstants in Py3.4 and above.
+        elif sys.version_info.major >= 3 and isinstance(obj, ast.NameConstant):
+            return obj.value
+
+        # For python versions below 3.4
+        elif isinstance(obj, ast.Name) and (obj.id in ["True", "False", "None"]):
+            return string_to_constant[obj.id]
+
+        # Probably passed a variable name.
+        # Or passed a single word without wrapping it in quotes as an argument
+        # ex: p.inflect("I plural(see)") instead of p.inflect("I plural('see')")
+        raise NameError("name '%s' is not defined" % obj.id)
+
+    def _string_to_substitute(self, mo, methods_dict):
+        """
+        Return the string to be substituted for the match.
+        """
+        matched_text, f_name = mo.groups()
+        # matched_text is the complete match string. e.g. plural_noun(cat)
+        # f_name is the function name. e.g. plural_noun
+
+        # Return matched_text if function name is not in methods_dict
+        if f_name not in methods_dict:
+            return matched_text
+
+        # Parse the matched text
+        a_tree = ast.parse(matched_text)
+
+        # get the args and kwargs from ast objects
+        args_list = [self._get_value_from_ast(a) for a in a_tree.body[0].value.args]
+        kwargs_list = {
+            kw.arg: self._get_value_from_ast(kw.value)
+            for kw in a_tree.body[0].value.keywords
+        }
+
+        # Call the corresponding function
+        return methods_dict[f_name](*args_list, **kwargs_list)
+
+    # 0. PERFORM GENERAL INFLECTIONS IN A STRING
+
+    def inflect(self, text):
+        """
+        Perform inflections in a string.
+
+        e.g. inflect('The plural of cat is plural(cat)') returns
+        'The plural of cat is cats'
+
+        can use plural, plural_noun, plural_verb, plural_adj,
+        singular_noun, a, an, no, ordinal, number_to_words,
+        and prespart
+
+        """
+        save_persistent_count = self.persistent_count
+
+        # Dictionary of allowed methods
+        methods_dict = {
+            "plural": self.plural,
+            "plural_adj": self.plural_adj,
+            "plural_noun": self.plural_noun,
+            "plural_verb": self.plural_verb,
+            "singular_noun": self.singular_noun,
+            "a": self.a,
+            "an": self.a,
+            "no": self.no,
+            "ordinal": self.ordinal,
+            "number_to_words": self.number_to_words,
+            "present_participle": self.present_participle,
+            "num": self.num,
+        }
+
+        # Regular expression to find Python's function call syntax
+        functions_re = re.compile(r"((\w+)\([^)]*\)*)", re.IGNORECASE)
+        output = functions_re.sub(
+            lambda mo: self._string_to_substitute(mo, methods_dict), text
+        )
+        self.persistent_count = save_persistent_count
+        return output
+
+    # ## PLURAL SUBROUTINES
+
+    def postprocess(self, orig, inflected):
+        if "|" in inflected:
+            inflected = inflected.split("|")[self.classical_dict["all"]]
+        result = inflected.split(" ")
+        # Try to fix word wise capitalization
+        for index, word in enumerate(orig.split(" ")):
+            if word == "I":
+                # Is this the only word for exceptions like this
+                # Where the original is fully capitalized
+                # without 'meaning' capitalization?
+                # Also this fails to handle a capitalizaion in context
+                continue
+            if word.capitalize() == word:
+                result[index] = result[index].capitalize()
+            if word == word.upper():
+                result[index] = result[index].upper()
+        return " ".join(result)
+
+    def partition_word(self, text):
+        mo = re.search(r"\A(\s*)(.+?)(\s*)\Z", text)
+        try:
+            return mo.group(1), mo.group(2), mo.group(3)
+        except AttributeError:  # empty string
+            return "", "", ""
+
+    def plural(self, text, count=None):
+        """
+        Return the plural of text.
+
+        If count supplied, then return text if count is one of:
+            1, a, an, one, each, every, this, that
+        otherwise return the plural.
+
+        Whitespace at the start and end is preserved.
+
+        """
+        pre, word, post = self.partition_word(text)
+        if not word:
+            return text
+        plural = self.postprocess(
+            word,
+            self._pl_special_adjective(word, count)
+            or self._pl_special_verb(word, count)
+            or self._plnoun(word, count),
+        )
+        return "{}{}{}".format(pre, plural, post)
+
+    def plural_noun(self, text, count=None):
+        """
+        Return the plural of text, where text is a noun.
+
+        If count supplied, then return text if count is one of:
+            1, a, an, one, each, every, this, that
+        otherwise return the plural.
+
+        Whitespace at the start and end is preserved.
+
+        """
+        pre, word, post = self.partition_word(text)
+        if not word:
+            return text
+        plural = self.postprocess(word, self._plnoun(word, count))
+        return "{}{}{}".format(pre, plural, post)
+
+    def plural_verb(self, text, count=None):
+        """
+        Return the plural of text, where text is a verb.
+
+        If count supplied, then return text if count is one of:
+            1, a, an, one, each, every, this, that
+        otherwise return the plural.
+
+        Whitespace at the start and end is preserved.
+
+        """
+        pre, word, post = self.partition_word(text)
+        if not word:
+            return text
+        plural = self.postprocess(
+            word,
+            self._pl_special_verb(word, count) or self._pl_general_verb(word, count),
+        )
+        return "{}{}{}".format(pre, plural, post)
+
+    def plural_adj(self, text, count=None):
+        """
+        Return the plural of text, where text is an adjective.
+
+        If count supplied, then return text if count is one of:
+            1, a, an, one, each, every, this, that
+        otherwise return the plural.
+
+        Whitespace at the start and end is preserved.
+
+        """
+        pre, word, post = self.partition_word(text)
+        if not word:
+            return text
+        plural = self.postprocess(word, self._pl_special_adjective(word, count) or word)
+        return "{}{}{}".format(pre, plural, post)
+
+    def compare(self, word1, word2):
+        """
+        compare word1 and word2 for equality regardless of plurality
+
+        return values:
+        eq - the strings are equal
+        p:s - word1 is the plural of word2
+        s:p - word2 is the plural of word1
+        p:p - word1 and word2 are two different plural forms of the one word
+        False - otherwise
+
+        """
+        return (
+            self._plequal(word1, word2, self.plural_noun)
+            or self._plequal(word1, word2, self.plural_verb)
+            or self._plequal(word1, word2, self.plural_adj)
+        )
+
+    def compare_nouns(self, word1, word2):
+        """
+        compare word1 and word2 for equality regardless of plurality
+        word1 and word2 are to be treated as nouns
+
+        return values:
+        eq - the strings are equal
+        p:s - word1 is the plural of word2
+        s:p - word2 is the plural of word1
+        p:p - word1 and word2 are two different plural forms of the one word
+        False - otherwise
+
+        """
+        return self._plequal(word1, word2, self.plural_noun)
+
+    def compare_verbs(self, word1, word2):
+        """
+        compare word1 and word2 for equality regardless of plurality
+        word1 and word2 are to be treated as verbs
+
+        return values:
+        eq - the strings are equal
+        p:s - word1 is the plural of word2
+        s:p - word2 is the plural of word1
+        p:p - word1 and word2 are two different plural forms of the one word
+        False - otherwise
+
+        """
+        return self._plequal(word1, word2, self.plural_verb)
+
+    def compare_adjs(self, word1, word2):
+        """
+        compare word1 and word2 for equality regardless of plurality
+        word1 and word2 are to be treated as adjectives
+
+        return values:
+        eq - the strings are equal
+        p:s - word1 is the plural of word2
+        s:p - word2 is the plural of word1
+        p:p - word1 and word2 are two different plural forms of the one word
+        False - otherwise
+
+        """
+        return self._plequal(word1, word2, self.plural_adj)
+
+    def singular_noun(self, text, count=None, gender=None):
+        """
+        Return the singular of text, where text is a plural noun.
+
+        If count supplied, then return the singular if count is one of:
+            1, a, an, one, each, every, this, that or if count is None
+        otherwise return text unchanged.
+
+        Whitespace at the start and end is preserved.
+
+        """
+        pre, word, post = self.partition_word(text)
+        if not word:
+            return text
+        sing = self._sinoun(word, count=count, gender=gender)
+        if sing is not False:
+            plural = self.postprocess(
+                word, self._sinoun(word, count=count, gender=gender)
+            )
+            return "{}{}{}".format(pre, plural, post)
+        return False
+
+    def _plequal(self, word1, word2, pl):
+        classval = self.classical_dict.copy()
+        self.classical_dict = all_classical.copy()
+        if word1 == word2:
+            return "eq"
+        if word1 == pl(word2):
+            return "p:s"
+        if pl(word1) == word2:
+            return "s:p"
+        self.classical_dict = no_classical.copy()
+        if word1 == pl(word2):
+            return "p:s"
+        if pl(word1) == word2:
+            return "s:p"
+        self.classical_dict = classval.copy()
+
+        if pl == self.plural or pl == self.plural_noun:
+            if self._pl_check_plurals_N(word1, word2):
+                return "p:p"
+            if self._pl_check_plurals_N(word2, word1):
+                return "p:p"
+        if pl == self.plural or pl == self.plural_adj:
+            if self._pl_check_plurals_adj(word1, word2):
+                return "p:p"
+        return False
+
+    def _pl_reg_plurals(self, pair, stems, end1, end2):
+        pattern = r"({})({}\|\1{}|{}\|\1{})".format(stems, end1, end2, end2, end1)
+        return bool(re.search(pattern, pair))
+
+    def _pl_check_plurals_N(self, word1, word2):
+        stem_endings = (
+            (pl_sb_C_a_ata, "as", "ata"),
+            (pl_sb_C_is_ides, "is", "ides"),
+            (pl_sb_C_a_ae, "s", "e"),
+            (pl_sb_C_en_ina, "ens", "ina"),
+            (pl_sb_C_um_a, "ums", "a"),
+            (pl_sb_C_us_i, "uses", "i"),
+            (pl_sb_C_on_a, "ons", "a"),
+            (pl_sb_C_o_i_stems, "os", "i"),
+            (pl_sb_C_ex_ices, "exes", "ices"),
+            (pl_sb_C_ix_ices, "ixes", "ices"),
+            (pl_sb_C_i, "s", "i"),
+            (pl_sb_C_im, "s", "im"),
+            (".*eau", "s", "x"),
+            (".*ieu", "s", "x"),
+            (".*tri", "xes", "ces"),
+            (".{2,}[yia]n", "xes", "ges"),
+        )
+        pair = "{}|{}".format(word1, word2)
+
+        return (
+            pair in pl_sb_irregular_s.values()
+            or pair in pl_sb_irregular.values()
+            or pair in pl_sb_irregular_caps.values()
+            or any(
+                self._pl_reg_plurals(pair, stems, end1, end2)
+                for stems, end1, end2 in stem_endings
+            )
+        )
+
+    def _pl_check_plurals_adj(self, word1, word2):
+        word1a = word1[: word1.rfind("'")] if word1.endswith(("'s", "'")) else ""
+        word2a = word2[: word2.rfind("'")] if word2.endswith(("'s", "'")) else ""
+
+        return (
+            word1a
+            and word2a
+            and (
+                self._pl_check_plurals_N(word1a, word2a)
+                or self._pl_check_plurals_N(word2a, word1a)
+            )
+        )
+
+    def get_count(self, count=None):
+        if count is None and self.persistent_count is not None:
+            count = self.persistent_count
+
+        if count is not None:
+            count = (
+                1
+                if (
+                    (str(count) in pl_count_one)
+                    or (
+                        self.classical_dict["zero"]
+                        and str(count).lower() in pl_count_zero
+                    )
+                )
+                else 2
+            )
+        else:
+            count = ""
+        return count
+
+    # @profile
+    def _plnoun(self, word, count=None):
+        count = self.get_count(count)
+
+        # DEFAULT TO PLURAL
+
+        if count == 1:
+            return word
+
+        # HANDLE USER-DEFINED NOUNS
+
+        value = self.ud_match(word, self.pl_sb_user_defined)
+        if value is not None:
+            return value
+
+        # HANDLE EMPTY WORD, SINGULAR COUNT AND UNINFLECTED PLURALS
+
+        if word == "":
+            return word
+
+        lowerword = word.lower()
+
+        if lowerword in pl_sb_uninflected_complete:
+            return word
+
+        if word in pl_sb_uninflected_caps:
+            return word
+
+        for k, v in pl_sb_uninflected_bysize.items():
+            if lowerword[-k:] in v:
+                return word
+
+        if self.classical_dict["herd"] and lowerword in pl_sb_uninflected_herd:
+            return word
+
+        # HANDLE COMPOUNDS ("Governor General", "mother-in-law", "aide-de-camp", ETC.)
+
+        mo = re.search(r"^(?:%s)$" % pl_sb_postfix_adj_stems, word, re.IGNORECASE)
+        if mo and mo.group(2) != "":
+            return "{}{}".format(self._plnoun(mo.group(1), 2), mo.group(2))
+
+        if " a " in lowerword or "-a-" in lowerword:
+            mo = re.search(r"^(?:%s)$" % pl_sb_prep_dual_compound, word, re.IGNORECASE)
+            if mo and mo.group(2) != "" and mo.group(3) != "":
+                return "{}{}{}".format(
+                    self._plnoun(mo.group(1), 2), mo.group(2), self._plnoun(mo.group(3))
+                )
+
+        lowersplit = lowerword.split(" ")
+        if len(lowersplit) >= 3:
+            for numword in range(1, len(lowersplit) - 1):
+                if lowersplit[numword] in pl_prep_list_da:
+                    return " ".join(
+                        lowersplit[: numword - 1]
+                        + [self._plnoun(lowersplit[numword - 1], 2)]
+                        + lowersplit[numword:]
+                    )
+
+        # only pluralize denominators in units
+        mo = re.search(
+            r"(?P<denominator>.+)( (%s) .+)" % "|".join(["per", "a"]), lowerword
+        )
+        if mo:
+            index = len(mo.group("denominator"))
+            return "{}{}".format(self._plnoun(word[:index]), word[index:])
+
+        # handle units given in degrees (only accept if
+        # there is no more than one word following)
+        # degree Celsius => degrees Celsius but degree
+        # fahrenheit hour => degree fahrenheit hours
+        if len(lowersplit) >= 2 and lowersplit[-2] in ["degree"]:
+            return " ".join([self._plnoun(lowersplit[0])] + lowersplit[1:])
+
+        lowersplit = lowerword.split("-")
+        if len(lowersplit) >= 3:
+            for numword in range(1, len(lowersplit) - 1):
+                if lowersplit[numword] in pl_prep_list_da:
+                    return " ".join(
+                        lowersplit[: numword - 1]
+                        + [
+                            self._plnoun(lowersplit[numword - 1], 2)
+                            + "-"
+                            + lowersplit[numword]
+                            + "-"
+                        ]
+                    ) + " ".join(lowersplit[(numword + 1) :])
+
+        # HANDLE PRONOUNS
+
+        for k, v in pl_pron_acc_keys_bysize.items():
+            if lowerword[-k:] in v:  # ends with accusivate pronoun
+                for pk, pv in pl_prep_bysize.items():
+                    if lowerword[:pk] in pv:  # starts with a prep
+                        if lowerword.split() == [lowerword[:pk], lowerword[-k:]]:
+                            # only whitespace in between
+                            return lowerword[:-k] + pl_pron_acc[lowerword[-k:]]
+
+        try:
+            return pl_pron_nom[word.lower()]
+        except KeyError:
+            pass
+
+        try:
+            return pl_pron_acc[word.lower()]
+        except KeyError:
+            pass
+
+        # HANDLE ISOLATED IRREGULAR PLURALS
+
+        wordsplit = word.split()
+        wordlast = wordsplit[-1]
+        lowerwordlast = wordlast.lower()
+
+        if wordlast in list(pl_sb_irregular_caps.keys()):
+            llen = len(wordlast)
+            return "{}{}".format(word[:-llen], pl_sb_irregular_caps[wordlast])
+
+        if lowerwordlast in list(pl_sb_irregular.keys()):
+            llen = len(lowerwordlast)
+            return "{}{}".format(word[:-llen], pl_sb_irregular[lowerwordlast])
+
+        if (" ".join(wordsplit[-2:])).lower() in list(pl_sb_irregular_compound.keys()):
+            llen = len(
+                " ".join(wordsplit[-2:])
+            )  # TODO: what if 2 spaces between these words?
+            return "{}{}".format(
+                word[:-llen],
+                pl_sb_irregular_compound[(" ".join(wordsplit[-2:])).lower()],
+            )
+
+        if lowerword[-3:] == "quy":
+            return word[:-1] + "ies"
+
+        if lowerword[-6:] == "person":
+            if self.classical_dict["persons"]:
+                return word + "s"
+            else:
+                return word[:-4] + "ople"
+
+        # HANDLE FAMILIES OF IRREGULAR PLURALS
+
+        if lowerword[-3:] == "man":
+            for k, v in pl_sb_U_man_mans_bysize.items():
+                if lowerword[-k:] in v:
+                    return word + "s"
+            for k, v in pl_sb_U_man_mans_caps_bysize.items():
+                if word[-k:] in v:
+                    return word + "s"
+            return word[:-3] + "men"
+        if lowerword[-5:] == "mouse":
+            return word[:-5] + "mice"
+        if lowerword[-5:] == "louse":
+            return word[:-5] + "lice"
+        if lowerword[-5:] == "goose":
+            return word[:-5] + "geese"
+        if lowerword[-5:] == "tooth":
+            return word[:-5] + "teeth"
+        if lowerword[-4:] == "foot":
+            return word[:-4] + "feet"
+        if lowerword[-4:] == "taco":
+            return word[:-5] + "tacos"
+
+        if lowerword == "die":
+            return "dice"
+
+        # HANDLE UNASSIMILATED IMPORTS
+
+        if lowerword[-4:] == "ceps":
+            return word
+        if lowerword[-4:] == "zoon":
+            return word[:-2] + "a"
+        if lowerword[-3:] in ("cis", "sis", "xis"):
+            return word[:-2] + "es"
+
+        for lastlet, d, numend, post in (
+            ("h", pl_sb_U_ch_chs_bysize, None, "s"),
+            ("x", pl_sb_U_ex_ices_bysize, -2, "ices"),
+            ("x", pl_sb_U_ix_ices_bysize, -2, "ices"),
+            ("m", pl_sb_U_um_a_bysize, -2, "a"),
+            ("s", pl_sb_U_us_i_bysize, -2, "i"),
+            ("n", pl_sb_U_on_a_bysize, -2, "a"),
+            ("a", pl_sb_U_a_ae_bysize, None, "e"),
+        ):
+            if lowerword[-1] == lastlet:  # this test to add speed
+                for k, v in d.items():
+                    if lowerword[-k:] in v:
+                        return word[:numend] + post
+
+        # HANDLE INCOMPLETELY ASSIMILATED IMPORTS
+
+        if self.classical_dict["ancient"]:
+            if lowerword[-4:] == "trix":
+                return word[:-1] + "ces"
+            if lowerword[-3:] in ("eau", "ieu"):
+                return word + "x"
+            if lowerword[-3:] in ("ynx", "inx", "anx") and len(word) > 4:
+                return word[:-1] + "ges"
+
+            for lastlet, d, numend, post in (
+                ("n", pl_sb_C_en_ina_bysize, -2, "ina"),
+                ("x", pl_sb_C_ex_ices_bysize, -2, "ices"),
+                ("x", pl_sb_C_ix_ices_bysize, -2, "ices"),
+                ("m", pl_sb_C_um_a_bysize, -2, "a"),
+                ("s", pl_sb_C_us_i_bysize, -2, "i"),
+                ("s", pl_sb_C_us_us_bysize, None, ""),
+                ("a", pl_sb_C_a_ae_bysize, None, "e"),
+                ("a", pl_sb_C_a_ata_bysize, None, "ta"),
+                ("s", pl_sb_C_is_ides_bysize, -1, "des"),
+                ("o", pl_sb_C_o_i_bysize, -1, "i"),
+                ("n", pl_sb_C_on_a_bysize, -2, "a"),
+            ):
+                if lowerword[-1] == lastlet:  # this test to add speed
+                    for k, v in d.items():
+                        if lowerword[-k:] in v:
+                            return word[:numend] + post
+
+            for d, numend, post in (
+                (pl_sb_C_i_bysize, None, "i"),
+                (pl_sb_C_im_bysize, None, "im"),
+            ):
+                for k, v in d.items():
+                    if lowerword[-k:] in v:
+                        return word[:numend] + post
+
+        # HANDLE SINGULAR NOUNS ENDING IN ...s OR OTHER SILIBANTS
+
+        if lowerword in pl_sb_singular_s_complete:
+            return word + "es"
+
+        for k, v in pl_sb_singular_s_bysize.items():
+            if lowerword[-k:] in v:
+                return word + "es"
+
+        if lowerword[-2:] == "es" and word[0] == word[0].upper():
+            return word + "es"
+
+        if lowerword[-1] == "z":
+            for k, v in pl_sb_z_zes_bysize.items():
+                if lowerword[-k:] in v:
+                    return word + "es"
+
+            if lowerword[-2:-1] != "z":
+                return word + "zes"
+
+        if lowerword[-2:] == "ze":
+            for k, v in pl_sb_ze_zes_bysize.items():
+                if lowerword[-k:] in v:
+                    return word + "s"
+
+        if lowerword[-2:] in ("ch", "sh", "zz", "ss") or lowerword[-1] == "x":
+            return word + "es"
+
+        # HANDLE ...f -> ...ves
+
+        if lowerword[-3:] in ("elf", "alf", "olf"):
+            return word[:-1] + "ves"
+        if lowerword[-3:] == "eaf" and lowerword[-4:-3] != "d":
+            return word[:-1] + "ves"
+        if lowerword[-4:] in ("nife", "life", "wife"):
+            return word[:-2] + "ves"
+        if lowerword[-3:] == "arf":
+            return word[:-1] + "ves"
+
+        # HANDLE ...y
+
+        if lowerword[-1] == "y":
+            if lowerword[-2:-1] in "aeiou" or len(word) == 1:
+                return word + "s"
+
+            if self.classical_dict["names"]:
+                if lowerword[-1] == "y" and word[0] == word[0].upper():
+                    return word + "s"
+
+            return word[:-1] + "ies"
+
+        # HANDLE ...o
+
+        if lowerword in pl_sb_U_o_os_complete:
+            return word + "s"
+
+        for k, v in pl_sb_U_o_os_bysize.items():
+            if lowerword[-k:] in v:
+                return word + "s"
+
+        if lowerword[-2:] in ("ao", "eo", "io", "oo", "uo"):
+            return word + "s"
+
+        if lowerword[-1] == "o":
+            return word + "es"
+
+        # OTHERWISE JUST ADD ...s
+
+        return "%ss" % word
+
+    def _pl_special_verb(self, word, count=None):
+        if self.classical_dict["zero"] and str(count).lower() in pl_count_zero:
+            return False
+        count = self.get_count(count)
+
+        if count == 1:
+            return word
+
+        # HANDLE USER-DEFINED VERBS
+
+        value = self.ud_match(word, self.pl_v_user_defined)
+        if value is not None:
+            return value
+
+        # HANDLE IRREGULAR PRESENT TENSE (SIMPLE AND COMPOUND)
+
+        lowerword = word.lower()
+        try:
+            firstword = lowerword.split()[0]
+        except IndexError:
+            return False  # word is ''
+
+        if firstword in list(plverb_irregular_pres.keys()):
+            return "{}{}".format(
+                plverb_irregular_pres[firstword], word[len(firstword) :]
+            )
+
+        # HANDLE IRREGULAR FUTURE, PRETERITE AND PERFECT TENSES
+
+        if firstword in plverb_irregular_non_pres:
+            return word
+
+        # HANDLE PRESENT NEGATIONS (SIMPLE AND COMPOUND)
+
+        if firstword.endswith("n't") and firstword[:-3] in list(
+            plverb_irregular_pres.keys()
+        ):
+            return "{}n't{}".format(
+                plverb_irregular_pres[firstword[:-3]], word[len(firstword) :]
+            )
+
+        if firstword.endswith("n't"):
+            return word
+
+        # HANDLE SPECIAL CASES
+
+        mo = re.search(r"^(%s)$" % plverb_special_s, word)
+        if mo:
+            return False
+        if re.search(r"\s", word):
+            return False
+        if lowerword == "quizzes":
+            return "quiz"
+
+        # HANDLE STANDARD 3RD PERSON (CHOP THE ...(e)s OFF SINGLE WORDS)
+
+        if (
+            lowerword[-4:] in ("ches", "shes", "zzes", "sses")
+            or lowerword[-3:] == "xes"
+        ):
+            return word[:-2]
+
+        if lowerword[-3:] == "ies" and len(word) > 3:
+            return lowerword[:-3] + "y"
+
+        if (
+            lowerword in pl_v_oes_oe
+            or lowerword[-4:] in pl_v_oes_oe_endings_size4
+            or lowerword[-5:] in pl_v_oes_oe_endings_size5
+        ):
+            return word[:-1]
+
+        if lowerword.endswith("oes") and len(word) > 3:
+            return lowerword[:-2]
+
+        mo = re.search(r"^(.*[^s])s$", word, re.IGNORECASE)
+        if mo:
+            return mo.group(1)
+
+        # OTHERWISE, A REGULAR VERB (HANDLE ELSEWHERE)
+
+        return False
+
+    def _pl_general_verb(self, word, count=None):
+        count = self.get_count(count)
+
+        if count == 1:
+            return word
+
+        # HANDLE AMBIGUOUS PRESENT TENSES  (SIMPLE AND COMPOUND)
+
+        mo = re.search(
+            r"^(%s)((\s.*)?)$" % plverb_ambiguous_pres_keys, word, re.IGNORECASE
+        )
+        if mo:
+            return "{}{}".format(
+                plverb_ambiguous_pres[mo.group(1).lower()], mo.group(2)
+            )
+
+        # HANDLE AMBIGUOUS PRETERITE AND PERFECT TENSES
+
+        mo = re.search(
+            r"^(%s)((\s.*)?)$" % plverb_ambiguous_non_pres, word, re.IGNORECASE
+        )
+        if mo:
+            return word
+
+        # OTHERWISE, 1st OR 2ND PERSON IS UNINFLECTED
+
+        return word
+
+    def _pl_special_adjective(self, word, count=None):
+        count = self.get_count(count)
+
+        if count == 1:
+            return word
+
+        # HANDLE USER-DEFINED ADJECTIVES
+
+        value = self.ud_match(word, self.pl_adj_user_defined)
+        if value is not None:
+            return value
+
+        # HANDLE KNOWN CASES
+
+        mo = re.search(r"^(%s)$" % pl_adj_special_keys, word, re.IGNORECASE)
+        if mo:
+            return "%s" % (pl_adj_special[mo.group(1).lower()])
+
+        # HANDLE POSSESSIVES
+
+        mo = re.search(r"^(%s)$" % pl_adj_poss_keys, word, re.IGNORECASE)
+        if mo:
+            return "%s" % (pl_adj_poss[mo.group(1).lower()])
+
+        mo = re.search(r"^(.*)'s?$", word)
+        if mo:
+            pl = self.plural_noun(mo.group(1))
+            trailing_s = "" if pl[-1] == "s" else "s"
+            return "{}'{}".format(pl, trailing_s)
+
+        # OTHERWISE, NO IDEA
+
+        return False
+
+    # @profile
+    def _sinoun(self, word, count=None, gender=None):
+        count = self.get_count(count)
+
+        # DEFAULT TO PLURAL
+
+        if count == 2:
+            return word
+
+        # SET THE GENDER
+
+        try:
+            if gender is None:
+                gender = self.thegender
+            elif gender not in singular_pronoun_genders:
+                raise BadGenderError
+        except (TypeError, IndexError):
+            raise BadGenderError
+
+        # HANDLE USER-DEFINED NOUNS
+
+        value = self.ud_match(word, self.si_sb_user_defined)
+        if value is not None:
+            return value
+
+        # HANDLE EMPTY WORD, SINGULAR COUNT AND UNINFLECTED PLURALS
+
+        if word == "":
+            return word
+
+        lowerword = word.lower()
+
+        if word in si_sb_ois_oi_case:
+            return word[:-1]
+
+        if lowerword in pl_sb_uninflected_complete:
+            return word
+
+        if word in pl_sb_uninflected_caps:
+            return word
+
+        for k, v in pl_sb_uninflected_bysize.items():
+            if lowerword[-k:] in v:
+                return word
+
+        if self.classical_dict["herd"] and lowerword in pl_sb_uninflected_herd:
+            return word
+
+        if lowerword in pl_sb_C_us_us:
+            return word
+
+        # HANDLE COMPOUNDS ("Governor General", "mother-in-law", "aide-de-camp", ETC.)
+
+        mo = re.search(r"^(?:%s)$" % pl_sb_postfix_adj_stems, word, re.IGNORECASE)
+        if mo and mo.group(2) != "":
+            return "{}{}".format(
+                self._sinoun(mo.group(1), 1, gender=gender), mo.group(2)
+            )
+
+        lowersplit = lowerword.split(" ")
+        if len(lowersplit) >= 3:
+            for numword in range(1, len(lowersplit) - 1):
+                if lowersplit[numword] in pl_prep_list_da:
+                    return " ".join(
+                        lowersplit[: numword - 1]
+                        + [
+                            self._sinoun(lowersplit[numword - 1], 1, gender=gender)
+                            or lowersplit[numword - 1]
+                        ]
+                        + lowersplit[numword:]
+                    )
+
+        lowersplit = lowerword.split("-")
+        if len(lowersplit) >= 3:
+            for numword in range(1, len(lowersplit) - 1):
+                if lowersplit[numword] in pl_prep_list_da:
+                    return " ".join(
+                        lowersplit[: numword - 1]
+                        + [
+                            (
+                                self._sinoun(lowersplit[numword - 1], 1, gender=gender)
+                                or lowersplit[numword - 1]
+                            )
+                            + "-"
+                            + lowersplit[numword]
+                            + "-"
+                        ]
+                    ) + " ".join(lowersplit[(numword + 1) :])
+
+        # HANDLE PRONOUNS
+
+        for k, v in si_pron_acc_keys_bysize.items():
+            if lowerword[-k:] in v:  # ends with accusivate pronoun
+                for pk, pv in pl_prep_bysize.items():
+                    if lowerword[:pk] in pv:  # starts with a prep
+                        if lowerword.split() == [lowerword[:pk], lowerword[-k:]]:
+                            # only whitespace in between
+                            return lowerword[:-k] + get_si_pron(
+                                "acc", lowerword[-k:], gender
+                            )
+
+        try:
+            return get_si_pron("nom", word.lower(), gender)
+        except KeyError:
+            pass
+
+        try:
+            return get_si_pron("acc", word.lower(), gender)
+        except KeyError:
+            pass
+
+        # HANDLE ISOLATED IRREGULAR PLURALS
+
+        wordsplit = word.split()
+        wordlast = wordsplit[-1]
+        lowerwordlast = wordlast.lower()
+
+        if wordlast in list(si_sb_irregular_caps.keys()):
+            llen = len(wordlast)
+            return "{}{}".format(word[:-llen], si_sb_irregular_caps[wordlast])
+
+        if lowerwordlast in list(si_sb_irregular.keys()):
+            llen = len(lowerwordlast)
+            return "{}{}".format(word[:-llen], si_sb_irregular[lowerwordlast])
+
+        if (" ".join(wordsplit[-2:])).lower() in list(si_sb_irregular_compound.keys()):
+            llen = len(
+                " ".join(wordsplit[-2:])
+            )  # TODO: what if 2 spaces between these words?
+            return "{}{}".format(
+                word[:-llen],
+                si_sb_irregular_compound[(" ".join(wordsplit[-2:])).lower()],
+            )
+
+        if lowerword[-5:] == "quies":
+            return word[:-3] + "y"
+
+        if lowerword[-7:] == "persons":
+            return word[:-1]
+        if lowerword[-6:] == "people":
+            return word[:-4] + "rson"
+
+        # HANDLE FAMILIES OF IRREGULAR PLURALS
+
+        if lowerword[-4:] == "mans":
+            for k, v in si_sb_U_man_mans_bysize.items():
+                if lowerword[-k:] in v:
+                    return word[:-1]
+            for k, v in si_sb_U_man_mans_caps_bysize.items():
+                if word[-k:] in v:
+                    return word[:-1]
+        if lowerword[-3:] == "men":
+            return word[:-3] + "man"
+        if lowerword[-4:] == "mice":
+            return word[:-4] + "mouse"
+        if lowerword[-4:] == "lice":
+            return word[:-4] + "louse"
+        if lowerword[-5:] == "geese":
+            return word[:-5] + "goose"
+        if lowerword[-5:] == "teeth":
+            return word[:-5] + "tooth"
+        if lowerword[-4:] == "feet":
+            return word[:-4] + "foot"
+
+        if lowerword == "dice":
+            return "die"
+
+        # HANDLE UNASSIMILATED IMPORTS
+
+        if lowerword[-4:] == "ceps":
+            return word
+        if lowerword[-3:] == "zoa":
+            return word[:-1] + "on"
+
+        for lastlet, d, numend, post in (
+            ("s", si_sb_U_ch_chs_bysize, -1, ""),
+            ("s", si_sb_U_ex_ices_bysize, -4, "ex"),
+            ("s", si_sb_U_ix_ices_bysize, -4, "ix"),
+            ("a", si_sb_U_um_a_bysize, -1, "um"),
+            ("i", si_sb_U_us_i_bysize, -1, "us"),
+            ("a", si_sb_U_on_a_bysize, -1, "on"),
+            ("e", si_sb_U_a_ae_bysize, -1, ""),
+        ):
+            if lowerword[-1] == lastlet:  # this test to add speed
+                for k, v in d.items():
+                    if lowerword[-k:] in v:
+                        return word[:numend] + post
+
+        # HANDLE INCOMPLETELY ASSIMILATED IMPORTS
+
+        if self.classical_dict["ancient"]:
+
+            if lowerword[-6:] == "trices":
+                return word[:-3] + "x"
+            if lowerword[-4:] in ("eaux", "ieux"):
+                return word[:-1]
+            if lowerword[-5:] in ("ynges", "inges", "anges") and len(word) > 6:
+                return word[:-3] + "x"
+
+            for lastlet, d, numend, post in (
+                ("a", si_sb_C_en_ina_bysize, -3, "en"),
+                ("s", si_sb_C_ex_ices_bysize, -4, "ex"),
+                ("s", si_sb_C_ix_ices_bysize, -4, "ix"),
+                ("a", si_sb_C_um_a_bysize, -1, "um"),
+                ("i", si_sb_C_us_i_bysize, -1, "us"),
+                ("s", pl_sb_C_us_us_bysize, None, ""),
+                ("e", si_sb_C_a_ae_bysize, -1, ""),
+                ("a", si_sb_C_a_ata_bysize, -2, ""),
+                ("s", si_sb_C_is_ides_bysize, -3, "s"),
+                ("i", si_sb_C_o_i_bysize, -1, "o"),
+                ("a", si_sb_C_on_a_bysize, -1, "on"),
+                ("m", si_sb_C_im_bysize, -2, ""),
+                ("i", si_sb_C_i_bysize, -1, ""),
+            ):
+                if lowerword[-1] == lastlet:  # this test to add speed
+                    for k, v in d.items():
+                        if lowerword[-k:] in v:
+                            return word[:numend] + post
+
+        # HANDLE PLURLS ENDING IN uses -> use
+
+        if (
+            lowerword[-6:] == "houses"
+            or word in si_sb_uses_use_case
+            or lowerword in si_sb_uses_use
+        ):
+            return word[:-1]
+
+        # HANDLE PLURLS ENDING IN ies -> ie
+
+        if word in si_sb_ies_ie_case or lowerword in si_sb_ies_ie:
+            return word[:-1]
+
+        # HANDLE PLURLS ENDING IN oes -> oe
+
+        if (
+            lowerword[-5:] == "shoes"
+            or word in si_sb_oes_oe_case
+            or lowerword in si_sb_oes_oe
+        ):
+            return word[:-1]
+
+        # HANDLE SINGULAR NOUNS ENDING IN ...s OR OTHER SILIBANTS
+
+        if word in si_sb_sses_sse_case or lowerword in si_sb_sses_sse:
+            return word[:-1]
+
+        if lowerword in si_sb_singular_s_complete:
+            return word[:-2]
+
+        for k, v in si_sb_singular_s_bysize.items():
+            if lowerword[-k:] in v:
+                return word[:-2]
+
+        if lowerword[-4:] == "eses" and word[0] == word[0].upper():
+            return word[:-2]
+
+        if lowerword in si_sb_z_zes:
+            return word[:-2]
+
+        if lowerword in si_sb_zzes_zz:
+            return word[:-2]
+
+        if lowerword[-4:] == "zzes":
+            return word[:-3]
+
+        if word in si_sb_ches_che_case or lowerword in si_sb_ches_che:
+            return word[:-1]
+
+        if lowerword[-4:] in ("ches", "shes"):
+            return word[:-2]
+
+        if lowerword in si_sb_xes_xe:
+            return word[:-1]
+
+        if lowerword[-3:] == "xes":
+            return word[:-2]
+
+        # HANDLE ...f -> ...ves
+
+        if word in si_sb_ves_ve_case or lowerword in si_sb_ves_ve:
+            return word[:-1]
+
+        if lowerword[-3:] == "ves":
+            if lowerword[-5:-3] in ("el", "al", "ol"):
+                return word[:-3] + "f"
+            if lowerword[-5:-3] == "ea" and word[-6:-5] != "d":
+                return word[:-3] + "f"
+            if lowerword[-5:-3] in ("ni", "li", "wi"):
+                return word[:-3] + "fe"
+            if lowerword[-5:-3] == "ar":
+                return word[:-3] + "f"
+
+        # HANDLE ...y
+
+        if lowerword[-2:] == "ys":
+            if len(lowerword) > 2 and lowerword[-3] in "aeiou":
+                return word[:-1]
+
+            if self.classical_dict["names"]:
+                if lowerword[-2:] == "ys" and word[0] == word[0].upper():
+                    return word[:-1]
+
+        if lowerword[-3:] == "ies":
+            return word[:-3] + "y"
+
+        # HANDLE ...o
+
+        if lowerword[-2:] == "os":
+
+            if lowerword in si_sb_U_o_os_complete:
+                return word[:-1]
+
+            for k, v in si_sb_U_o_os_bysize.items():
+                if lowerword[-k:] in v:
+                    return word[:-1]
+
+            if lowerword[-3:] in ("aos", "eos", "ios", "oos", "uos"):
+                return word[:-1]
+
+        if lowerword[-3:] == "oes":
+            return word[:-2]
+
+        # UNASSIMILATED IMPORTS FINAL RULE
+
+        if word in si_sb_es_is:
+            return word[:-2] + "is"
+
+        # OTHERWISE JUST REMOVE ...s
+
+        if lowerword[-1] == "s":
+            return word[:-1]
+
+        # COULD NOT FIND SINGULAR
+
+        return False
+
+    # ADJECTIVES
+
+    def a(self, text, count=1):
+        """
+        Return the appropriate indefinite article followed by text.
+
+        The indefinite article is either 'a' or 'an'.
+
+        If count is not one, then return count followed by text
+        instead of 'a' or 'an'.
+
+        Whitespace at the start and end is preserved.
+
+        """
+        mo = re.search(r"\A(\s*)(?:an?\s+)?(.+?)(\s*)\Z", text, re.IGNORECASE)
+        if mo:
+            word = mo.group(2)
+            if not word:
+                return text
+            pre = mo.group(1)
+            post = mo.group(3)
+            result = self._indef_article(word, count)
+            return "{}{}{}".format(pre, result, post)
+        return ""
+
+    an = a
+
+    def _indef_article(self, word, count):
+        mycount = self.get_count(count)
+
+        if mycount != 1:
+            return "{} {}".format(count, word)
+
+        # HANDLE USER-DEFINED VARIANTS
+
+        value = self.ud_match(word, self.A_a_user_defined)
+        if value is not None:
+            return "{} {}".format(value, word)
+
+        # HANDLE ORDINAL FORMS
+
+        for a in ((r"^(%s)" % A_ordinal_a, "a"), (r"^(%s)" % A_ordinal_an, "an")):
+            mo = re.search(a[0], word, re.IGNORECASE)
+            if mo:
+                return "{} {}".format(a[1], word)
+
+        # HANDLE SPECIAL CASES
+
+        for a in (
+            (r"^(%s)" % A_explicit_an, "an"),
+            (r"^[aefhilmnorsx]$", "an"),
+            (r"^[bcdgjkpqtuvwyz]$", "a"),
+        ):
+            mo = re.search(a[0], word, re.IGNORECASE)
+            if mo:
+                return "{} {}".format(a[1], word)
+
+        # HANDLE ABBREVIATIONS
+
+        for a in (
+            (r"(%s)" % A_abbrev, "an", re.VERBOSE),
+            (r"^[aefhilmnorsx][.-]", "an", re.IGNORECASE),
+            (r"^[a-z][.-]", "a", re.IGNORECASE),
+        ):
+            mo = re.search(a[0], word, a[2])
+            if mo:
+                return "{} {}".format(a[1], word)
+
+        # HANDLE CONSONANTS
+
+        mo = re.search(r"^[^aeiouy]", word, re.IGNORECASE)
+        if mo:
+            return "a %s" % word
+
+        # HANDLE SPECIAL VOWEL-FORMS
+
+        for a in (
+            (r"^e[uw]", "a"),
+            (r"^onc?e\b", "a"),
+            (r"^onetime\b", "a"),
+            (r"^uni([^nmd]|mo)", "a"),
+            (r"^u[bcfghjkqrst][aeiou]", "a"),
+            (r"^ukr", "a"),
+            (r"^(%s)" % A_explicit_a, "a"),
+        ):
+            mo = re.search(a[0], word, re.IGNORECASE)
+            if mo:
+                return "{} {}".format(a[1], word)
+
+        # HANDLE SPECIAL CAPITALS
+
+        mo = re.search(r"^U[NK][AIEO]?", word)
+        if mo:
+            return "a %s" % word
+
+        # HANDLE VOWELS
+
+        mo = re.search(r"^[aeiou]", word, re.IGNORECASE)
+        if mo:
+            return "an %s" % word
+
+        # HANDLE y... (BEFORE CERTAIN CONSONANTS IMPLIES (UNNATURALIZED) "i.." SOUND)
+
+        mo = re.search(r"^(%s)" % A_y_cons, word, re.IGNORECASE)
+        if mo:
+            return "an %s" % word
+
+        # OTHERWISE, GUESS "a"
+        return "a %s" % word
+
+    # 2. TRANSLATE ZERO-QUANTIFIED $word TO "no plural($word)"
+
+    def no(self, text, count=None):
+        """
+        If count is 0, no, zero or nil, return 'no' followed by the plural
+        of text.
+
+        If count is one of:
+            1, a, an, one, each, every, this, that
+        return count followed by text.
+
+        Otherwise return count follow by the plural of text.
+
+        In the return value count is always followed by a space.
+
+        Whitespace at the start and end is preserved.
+
+        """
+        if count is None and self.persistent_count is not None:
+            count = self.persistent_count
+
+        if count is None:
+            count = 0
+        mo = re.search(r"\A(\s*)(.+?)(\s*)\Z", text)
+        pre = mo.group(1)
+        word = mo.group(2)
+        post = mo.group(3)
+
+        if str(count).lower() in pl_count_zero:
+            return "{}no {}{}".format(pre, self.plural(word, 0), post)
+        else:
+            return "{}{} {}{}".format(pre, count, self.plural(word, count), post)
+
+    # PARTICIPLES
+
+    def present_participle(self, word):
+        """
+        Return the present participle for word.
+
+        word is the 3rd person singular verb.
+
+        """
+        plv = self.plural_verb(word, 2)
+
+        for pat, repl in (
+            (r"ie$", r"y"),
+            (r"ue$", r"u"),  # TODO: isn't ue$ -> u encompassed in the following rule?
+            (r"([auy])e$", r"\g<1>"),
+            (r"ski$", r"ski"),
+            (r"[^b]i$", r""),
+            (r"^(are|were)$", r"be"),
+            (r"^(had)$", r"hav"),
+            (r"^(hoe)$", r"\g<1>"),
+            (r"([^e])e$", r"\g<1>"),
+            (r"er$", r"er"),
+            (r"([^aeiou][aeiouy]([bdgmnprst]))$", r"\g<1>\g<2>"),
+        ):
+            (ans, num) = re.subn(pat, repl, plv)
+            if num:
+                return "%sing" % ans
+        return "%sing" % ans
+
+    # NUMERICAL INFLECTIONS
+
+    def ordinal(self, num):
+        """
+        Return the ordinal of num.
+
+        num can be an integer or text
+
+        e.g. ordinal(1) returns '1st'
+        ordinal('one') returns 'first'
+
+        """
+        if re.match(r"\d", str(num)):
+            try:
+                num % 2
+                n = num
+            except TypeError:
+                if "." in str(num):
+                    try:
+                        # numbers after decimal,
+                        # so only need last one for ordinal
+                        n = int(num[-1])
+
+                    except ValueError:  # ends with '.', so need to use whole string
+                        n = int(num[:-1])
+                else:
+                    n = int(num)
+            try:
+                post = nth[n % 100]
+            except KeyError:
+                post = nth[n % 10]
+            return "{}{}".format(num, post)
+        else:
+            mo = re.search(r"(%s)\Z" % ordinal_suff, num)
+            try:
+                post = ordinal[mo.group(1)]
+                return re.sub(r"(%s)\Z" % ordinal_suff, post, num)
+            except AttributeError:
+                return "%sth" % num
+
+    def millfn(self, ind=0):
+        if ind > len(mill) - 1:
+            print3("number out of range")
+            raise NumOutOfRangeError
+        return mill[ind]
+
+    def unitfn(self, units, mindex=0):
+        return "{}{}".format(unit[units], self.millfn(mindex))
+
+    def tenfn(self, tens, units, mindex=0):
+        if tens != 1:
+            return "{}{}{}{}".format(
+                ten[tens],
+                "-" if tens and units else "",
+                unit[units],
+                self.millfn(mindex),
+            )
+        return "{}{}".format(teen[units], mill[mindex])
+
+    def hundfn(self, hundreds, tens, units, mindex):
+        if hundreds:
+            andword = " %s " % self.number_args["andword"] if tens or units else ""
+            return "{} hundred{}{}{}, ".format(
+                unit[hundreds],  # use unit not unitfn as simpler
+                andword,
+                self.tenfn(tens, units),
+                self.millfn(mindex),
+            )
+        if tens or units:
+            return "{}{}, ".format(self.tenfn(tens, units), self.millfn(mindex))
+        return ""
+
+    def group1sub(self, mo):
+        units = int(mo.group(1))
+        if units == 1:
+            return " %s, " % self.number_args["one"]
+        elif units:
+            return "%s, " % unit[units]
+        else:
+            return " %s, " % self.number_args["zero"]
+
+    def group1bsub(self, mo):
+        units = int(mo.group(1))
+        if units:
+            return "%s, " % unit[units]
+        else:
+            return " %s, " % self.number_args["zero"]
+
+    def group2sub(self, mo):
+        tens = int(mo.group(1))
+        units = int(mo.group(2))
+        if tens:
+            return "%s, " % self.tenfn(tens, units)
+        if units:
+            return " {} {}, ".format(self.number_args["zero"], unit[units])
+        return " {} {}, ".format(self.number_args["zero"], self.number_args["zero"])
+
+    def group3sub(self, mo):
+        hundreds = int(mo.group(1))
+        tens = int(mo.group(2))
+        units = int(mo.group(3))
+        if hundreds == 1:
+            hunword = " %s" % self.number_args["one"]
+        elif hundreds:
+            hunword = "%s" % unit[hundreds]
+        else:
+            hunword = " %s" % self.number_args["zero"]
+        if tens:
+            tenword = self.tenfn(tens, units)
+        elif units:
+            tenword = " {} {}".format(self.number_args["zero"], unit[units])
+        else:
+            tenword = " {} {}".format(
+                self.number_args["zero"], self.number_args["zero"]
+            )
+        return "{} {}, ".format(hunword, tenword)
+
+    def hundsub(self, mo):
+        ret = self.hundfn(
+            int(mo.group(1)), int(mo.group(2)), int(mo.group(3)), self.mill_count
+        )
+        self.mill_count += 1
+        return ret
+
+    def tensub(self, mo):
+        return "%s, " % self.tenfn(int(mo.group(1)), int(mo.group(2)), self.mill_count)
+
+    def unitsub(self, mo):
+        return "%s, " % self.unitfn(int(mo.group(1)), self.mill_count)
+
+    def enword(self, num, group):
+        # import pdb
+        # pdb.set_trace()
+
+        if group == 1:
+            num = re.sub(r"(\d)", self.group1sub, num)
+        elif group == 2:
+            num = re.sub(r"(\d)(\d)", self.group2sub, num)
+            num = re.sub(r"(\d)", self.group1bsub, num, 1)
+        elif group == 3:
+            num = re.sub(r"(\d)(\d)(\d)", self.group3sub, num)
+            num = re.sub(r"(\d)(\d)", self.group2sub, num, 1)
+            num = re.sub(r"(\d)", self.group1sub, num, 1)
+        elif int(num) == 0:
+            num = self.number_args["zero"]
+        elif int(num) == 1:
+            num = self.number_args["one"]
+        else:
+            num = num.lstrip().lstrip("0")
+            self.mill_count = 0
+            # surely there's a better way to do the next bit
+            mo = re.search(r"(\d)(\d)(\d)(?=\D*\Z)", num)
+            while mo:
+                num = re.sub(r"(\d)(\d)(\d)(?=\D*\Z)", self.hundsub, num, 1)
+                mo = re.search(r"(\d)(\d)(\d)(?=\D*\Z)", num)
+            num = re.sub(r"(\d)(\d)(?=\D*\Z)", self.tensub, num, 1)
+            num = re.sub(r"(\d)(?=\D*\Z)", self.unitsub, num, 1)
+        return num
+
+    def blankfn(self, mo):
+        """ do a global blank replace
+        TODO: surely this can be done with an option to re.sub
+              rather than this fn
+        """
+        return ""
+
+    def commafn(self, mo):
+        """ do a global ',' replace
+        TODO: surely this can be done with an option to re.sub
+              rather than this fn
+        """
+        return ","
+
+    def spacefn(self, mo):
+        """ do a global ' ' replace
+        TODO: surely this can be done with an option to re.sub
+              rather than this fn
+        """
+        return " "
+
+    def number_to_words(
+        self,
+        num,
+        wantlist=False,
+        group=0,
+        comma=",",
+        andword="and",
+        zero="zero",
+        one="one",
+        decimal="point",
+        threshold=None,
+    ):
+        """
+        Return a number in words.
+
+        group = 1, 2 or 3 to group numbers before turning into words
+        comma: define comma
+        andword: word for 'and'. Can be set to ''.
+            e.g. "one hundred and one" vs "one hundred one"
+        zero: word for '0'
+        one: word for '1'
+        decimal: word for decimal point
+        threshold: numbers above threshold not turned into words
+
+        parameters not remembered from last call. Departure from Perl version.
+        """
+        self.number_args = dict(andword=andword, zero=zero, one=one)
+        num = "%s" % num
+
+        # Handle "stylistic" conversions (up to a given threshold)...
+        if threshold is not None and float(num) > threshold:
+            spnum = num.split(".", 1)
+            while comma:
+                (spnum[0], n) = re.subn(r"(\d)(\d{3}(?:,|\Z))", r"\1,\2", spnum[0])
+                if n == 0:
+                    break
+            try:
+                return "{}.{}".format(spnum[0], spnum[1])
+            except IndexError:
+                return "%s" % spnum[0]
+
+        if group < 0 or group > 3:
+            raise BadChunkingOptionError
+        nowhite = num.lstrip()
+        if nowhite[0] == "+":
+            sign = "plus"
+        elif nowhite[0] == "-":
+            sign = "minus"
+        else:
+            sign = ""
+
+        myord = num[-2:] in ("st", "nd", "rd", "th")
+        if myord:
+            num = num[:-2]
+        finalpoint = False
+        if decimal:
+            if group != 0:
+                chunks = num.split(".")
+            else:
+                chunks = num.split(".", 1)
+            if chunks[-1] == "":  # remove blank string if nothing after decimal
+                chunks = chunks[:-1]
+                finalpoint = True  # add 'point' to end of output
+        else:
+            chunks = [num]
+
+        first = 1
+        loopstart = 0
+
+        if chunks[0] == "":
+            first = 0
+            if len(chunks) > 1:
+                loopstart = 1
+
+        for i in range(loopstart, len(chunks)):
+            chunk = chunks[i]
+            # remove all non numeric \D
+            chunk = re.sub(r"\D", self.blankfn, chunk)
+            if chunk == "":
+                chunk = "0"
+
+            if group == 0 and (first == 0 or first == ""):
+                chunk = self.enword(chunk, 1)
+            else:
+                chunk = self.enword(chunk, group)
+
+            if chunk[-2:] == ", ":
+                chunk = chunk[:-2]
+            chunk = re.sub(r"\s+,", self.commafn, chunk)
+
+            if group == 0 and first:
+                chunk = re.sub(r", (\S+)\s+\Z", " %s \\1" % andword, chunk)
+            chunk = re.sub(r"\s+", self.spacefn, chunk)
+            # chunk = re.sub(r"(\A\s|\s\Z)", self.blankfn, chunk)
+            chunk = chunk.strip()
+            if first:
+                first = ""
+            chunks[i] = chunk
+
+        numchunks = []
+        if first != 0:
+            numchunks = chunks[0].split("%s " % comma)
+
+        if myord and numchunks:
+            # TODO: can this be just one re as it is in perl?
+            mo = re.search(r"(%s)\Z" % ordinal_suff, numchunks[-1])
+            if mo:
+                numchunks[-1] = re.sub(
+                    r"(%s)\Z" % ordinal_suff, ordinal[mo.group(1)], numchunks[-1]
+                )
+            else:
+                numchunks[-1] += "th"
+
+        for chunk in chunks[1:]:
+            numchunks.append(decimal)
+            numchunks.extend(chunk.split("%s " % comma))
+
+        if finalpoint:
+            numchunks.append(decimal)
+
+        # wantlist: Perl list context. can explictly specify in Python
+        if wantlist:
+            if sign:
+                numchunks = [sign] + numchunks
+            return numchunks
+        elif group:
+            signout = "%s " % sign if sign else ""
+            return "{}{}".format(signout, ", ".join(numchunks))
+        else:
+            signout = "%s " % sign if sign else ""
+            num = "{}{}".format(signout, numchunks.pop(0))
+            if decimal is None:
+                first = True
+            else:
+                first = not num.endswith(decimal)
+            for nc in numchunks:
+                if nc == decimal:
+                    num += " %s" % nc
+                    first = 0
+                elif first:
+                    num += "{} {}".format(comma, nc)
+                else:
+                    num += " %s" % nc
+            return num
+
+    # Join words with commas and a trailing 'and' (when appropriate)...
+
+    def join(
+        self,
+        words,
+        sep=None,
+        sep_spaced=True,
+        final_sep=None,
+        conj="and",
+        conj_spaced=True,
+    ):
+        """
+        Join words into a list.
+
+        e.g. join(['ant', 'bee', 'fly']) returns 'ant, bee, and fly'
+
+        options:
+        conj: replacement for 'and'
+        sep: separator. default ',', unless ',' is in the list then ';'
+        final_sep: final separator. default ',', unless ',' is in the list then ';'
+        conj_spaced: boolean. Should conj have spaces around it
+
+        """
+        if not words:
+            return ""
+        if len(words) == 1:
+            return words[0]
+
+        if conj_spaced:
+            if conj == "":
+                conj = " "
+            else:
+                conj = " %s " % conj
+
+        if len(words) == 2:
+            return "{}{}{}".format(words[0], conj, words[1])
+
+        if sep is None:
+            if "," in "".join(words):
+                sep = ";"
+            else:
+                sep = ","
+        if final_sep is None:
+            final_sep = sep
+
+        final_sep = "{}{}".format(final_sep, conj)
+
+        if sep_spaced:
+            sep += " "
+
+        return "{}{}{}".format(sep.join(words[0:-1]), final_sep, words[-1])
\ No newline at end of file
diff --git a/bids/external/six.py b/bids/external/six.py
new file mode 100644
index 00000000..073b9643
--- /dev/null
+++ b/bids/external/six.py
@@ -0,0 +1,868 @@
+"""Utilities for writing code that runs on Python 2 and 3"""
+
+# Copyright (c) 2010-2015 Benjamin Peterson
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from __future__ import absolute_import
+
+import functools
+import itertools
+import operator
+import sys
+import types
+
+__author__ = "Benjamin Peterson <benjamin@python.org>"
+__version__ = "1.10.0"
+
+
+# Useful for very coarse version differentiation.
+PY2 = sys.version_info[0] == 2
+PY3 = sys.version_info[0] == 3
+PY34 = sys.version_info[0:2] >= (3, 4)
+
+if PY3:
+    string_types = str,
+    integer_types = int,
+    class_types = type,
+    text_type = str
+    binary_type = bytes
+
+    MAXSIZE = sys.maxsize
+else:
+    string_types = basestring,
+    integer_types = (int, long)
+    class_types = (type, types.ClassType)
+    text_type = unicode
+    binary_type = str
+
+    if sys.platform.startswith("java"):
+        # Jython always uses 32 bits.
+        MAXSIZE = int((1 << 31) - 1)
+    else:
+        # It's possible to have sizeof(long) != sizeof(Py_ssize_t).
+        class X(object):
+
+            def __len__(self):
+                return 1 << 31
+        try:
+            len(X())
+        except OverflowError:
+            # 32-bit
+            MAXSIZE = int((1 << 31) - 1)
+        else:
+            # 64-bit
+            MAXSIZE = int((1 << 63) - 1)
+        del X
+
+
+def _add_doc(func, doc):
+    """Add documentation to a function."""
+    func.__doc__ = doc
+
+
+def _import_module(name):
+    """Import module, returning the module after the last dot."""
+    __import__(name)
+    return sys.modules[name]
+
+
+class _LazyDescr(object):
+
+    def __init__(self, name):
+        self.name = name
+
+    def __get__(self, obj, tp):
+        result = self._resolve()
+        setattr(obj, self.name, result)  # Invokes __set__.
+        try:
+            # This is a bit ugly, but it avoids running this again by
+            # removing this descriptor.
+            delattr(obj.__class__, self.name)
+        except AttributeError:
+            pass
+        return result
+
+
+class MovedModule(_LazyDescr):
+
+    def __init__(self, name, old, new=None):
+        super(MovedModule, self).__init__(name)
+        if PY3:
+            if new is None:
+                new = name
+            self.mod = new
+        else:
+            self.mod = old
+
+    def _resolve(self):
+        return _import_module(self.mod)
+
+    def __getattr__(self, attr):
+        _module = self._resolve()
+        value = getattr(_module, attr)
+        setattr(self, attr, value)
+        return value
+
+
+class _LazyModule(types.ModuleType):
+
+    def __init__(self, name):
+        super(_LazyModule, self).__init__(name)
+        self.__doc__ = self.__class__.__doc__
+
+    def __dir__(self):
+        attrs = ["__doc__", "__name__"]
+        attrs += [attr.name for attr in self._moved_attributes]
+        return attrs
+
+    # Subclasses should override this
+    _moved_attributes = []
+
+
+class MovedAttribute(_LazyDescr):
+
+    def __init__(self, name, old_mod, new_mod, old_attr=None, new_attr=None):
+        super(MovedAttribute, self).__init__(name)
+        if PY3:
+            if new_mod is None:
+                new_mod = name
+            self.mod = new_mod
+            if new_attr is None:
+                if old_attr is None:
+                    new_attr = name
+                else:
+                    new_attr = old_attr
+            self.attr = new_attr
+        else:
+            self.mod = old_mod
+            if old_attr is None:
+                old_attr = name
+            self.attr = old_attr
+
+    def _resolve(self):
+        module = _import_module(self.mod)
+        return getattr(module, self.attr)
+
+
+class _SixMetaPathImporter(object):
+
+    """
+    A meta path importer to import six.moves and its submodules.
+
+    This class implements a PEP302 finder and loader. It should be compatible
+    with Python 2.5 and all existing versions of Python3
+    """
+
+    def __init__(self, six_module_name):
+        self.name = six_module_name
+        self.known_modules = {}
+
+    def _add_module(self, mod, *fullnames):
+        for fullname in fullnames:
+            self.known_modules[self.name + "." + fullname] = mod
+
+    def _get_module(self, fullname):
+        return self.known_modules[self.name + "." + fullname]
+
+    def find_module(self, fullname, path=None):
+        if fullname in self.known_modules:
+            return self
+        return None
+
+    def __get_module(self, fullname):
+        try:
+            return self.known_modules[fullname]
+        except KeyError:
+            raise ImportError("This loader does not know module " + fullname)
+
+    def load_module(self, fullname):
+        try:
+            # in case of a reload
+            return sys.modules[fullname]
+        except KeyError:
+            pass
+        mod = self.__get_module(fullname)
+        if isinstance(mod, MovedModule):
+            mod = mod._resolve()
+        else:
+            mod.__loader__ = self
+        sys.modules[fullname] = mod
+        return mod
+
+    def is_package(self, fullname):
+        """
+        Return true, if the named module is a package.
+
+        We need this method to get correct spec objects with
+        Python 3.4 (see PEP451)
+        """
+        return hasattr(self.__get_module(fullname), "__path__")
+
+    def get_code(self, fullname):
+        """Return None
+
+        Required, if is_package is implemented"""
+        self.__get_module(fullname)  # eventually raises ImportError
+        return None
+    get_source = get_code  # same as get_code
+
+_importer = _SixMetaPathImporter(__name__)
+
+
+class _MovedItems(_LazyModule):
+
+    """Lazy loading of moved objects"""
+    __path__ = []  # mark as package
+
+
+_moved_attributes = [
+    MovedAttribute("cStringIO", "cStringIO", "io", "StringIO"),
+    MovedAttribute("filter", "itertools", "builtins", "ifilter", "filter"),
+    MovedAttribute("filterfalse", "itertools", "itertools", "ifilterfalse", "filterfalse"),
+    MovedAttribute("input", "__builtin__", "builtins", "raw_input", "input"),
+    MovedAttribute("intern", "__builtin__", "sys"),
+    MovedAttribute("map", "itertools", "builtins", "imap", "map"),
+    MovedAttribute("getcwd", "os", "os", "getcwdu", "getcwd"),
+    MovedAttribute("getcwdb", "os", "os", "getcwd", "getcwdb"),
+    MovedAttribute("range", "__builtin__", "builtins", "xrange", "range"),
+    MovedAttribute("reload_module", "__builtin__", "importlib" if PY34 else "imp", "reload"),
+    MovedAttribute("reduce", "__builtin__", "functools"),
+    MovedAttribute("shlex_quote", "pipes", "shlex", "quote"),
+    MovedAttribute("StringIO", "StringIO", "io"),
+    MovedAttribute("UserDict", "UserDict", "collections"),
+    MovedAttribute("UserList", "UserList", "collections"),
+    MovedAttribute("UserString", "UserString", "collections"),
+    MovedAttribute("xrange", "__builtin__", "builtins", "xrange", "range"),
+    MovedAttribute("zip", "itertools", "builtins", "izip", "zip"),
+    MovedAttribute("zip_longest", "itertools", "itertools", "izip_longest", "zip_longest"),
+    MovedModule("builtins", "__builtin__"),
+    MovedModule("configparser", "ConfigParser"),
+    MovedModule("copyreg", "copy_reg"),
+    MovedModule("dbm_gnu", "gdbm", "dbm.gnu"),
+    MovedModule("_dummy_thread", "dummy_thread", "_dummy_thread"),
+    MovedModule("http_cookiejar", "cookielib", "http.cookiejar"),
+    MovedModule("http_cookies", "Cookie", "http.cookies"),
+    MovedModule("html_entities", "htmlentitydefs", "html.entities"),
+    MovedModule("html_parser", "HTMLParser", "html.parser"),
+    MovedModule("http_client", "httplib", "http.client"),
+    MovedModule("email_mime_multipart", "email.MIMEMultipart", "email.mime.multipart"),
+    MovedModule("email_mime_nonmultipart", "email.MIMENonMultipart", "email.mime.nonmultipart"),
+    MovedModule("email_mime_text", "email.MIMEText", "email.mime.text"),
+    MovedModule("email_mime_base", "email.MIMEBase", "email.mime.base"),
+    MovedModule("BaseHTTPServer", "BaseHTTPServer", "http.server"),
+    MovedModule("CGIHTTPServer", "CGIHTTPServer", "http.server"),
+    MovedModule("SimpleHTTPServer", "SimpleHTTPServer", "http.server"),
+    MovedModule("cPickle", "cPickle", "pickle"),
+    MovedModule("queue", "Queue"),
+    MovedModule("reprlib", "repr"),
+    MovedModule("socketserver", "SocketServer"),
+    MovedModule("_thread", "thread", "_thread"),
+    MovedModule("tkinter", "Tkinter"),
+    MovedModule("tkinter_dialog", "Dialog", "tkinter.dialog"),
+    MovedModule("tkinter_filedialog", "FileDialog", "tkinter.filedialog"),
+    MovedModule("tkinter_scrolledtext", "ScrolledText", "tkinter.scrolledtext"),
+    MovedModule("tkinter_simpledialog", "SimpleDialog", "tkinter.simpledialog"),
+    MovedModule("tkinter_tix", "Tix", "tkinter.tix"),
+    MovedModule("tkinter_ttk", "ttk", "tkinter.ttk"),
+    MovedModule("tkinter_constants", "Tkconstants", "tkinter.constants"),
+    MovedModule("tkinter_dnd", "Tkdnd", "tkinter.dnd"),
+    MovedModule("tkinter_colorchooser", "tkColorChooser",
+                "tkinter.colorchooser"),
+    MovedModule("tkinter_commondialog", "tkCommonDialog",
+                "tkinter.commondialog"),
+    MovedModule("tkinter_tkfiledialog", "tkFileDialog", "tkinter.filedialog"),
+    MovedModule("tkinter_font", "tkFont", "tkinter.font"),
+    MovedModule("tkinter_messagebox", "tkMessageBox", "tkinter.messagebox"),
+    MovedModule("tkinter_tksimpledialog", "tkSimpleDialog",
+                "tkinter.simpledialog"),
+    MovedModule("urllib_parse", __name__ + ".moves.urllib_parse", "urllib.parse"),
+    MovedModule("urllib_error", __name__ + ".moves.urllib_error", "urllib.error"),
+    MovedModule("urllib", __name__ + ".moves.urllib", __name__ + ".moves.urllib"),
+    MovedModule("urllib_robotparser", "robotparser", "urllib.robotparser"),
+    MovedModule("xmlrpc_client", "xmlrpclib", "xmlrpc.client"),
+    MovedModule("xmlrpc_server", "SimpleXMLRPCServer", "xmlrpc.server"),
+]
+# Add windows specific modules.
+if sys.platform == "win32":
+    _moved_attributes += [
+        MovedModule("winreg", "_winreg"),
+    ]
+
+for attr in _moved_attributes:
+    setattr(_MovedItems, attr.name, attr)
+    if isinstance(attr, MovedModule):
+        _importer._add_module(attr, "moves." + attr.name)
+del attr
+
+_MovedItems._moved_attributes = _moved_attributes
+
+moves = _MovedItems(__name__ + ".moves")
+_importer._add_module(moves, "moves")
+
+
+class Module_six_moves_urllib_parse(_LazyModule):
+
+    """Lazy loading of moved objects in six.moves.urllib_parse"""
+
+
+_urllib_parse_moved_attributes = [
+    MovedAttribute("ParseResult", "urlparse", "urllib.parse"),
+    MovedAttribute("SplitResult", "urlparse", "urllib.parse"),
+    MovedAttribute("parse_qs", "urlparse", "urllib.parse"),
+    MovedAttribute("parse_qsl", "urlparse", "urllib.parse"),
+    MovedAttribute("urldefrag", "urlparse", "urllib.parse"),
+    MovedAttribute("urljoin", "urlparse", "urllib.parse"),
+    MovedAttribute("urlparse", "urlparse", "urllib.parse"),
+    MovedAttribute("urlsplit", "urlparse", "urllib.parse"),
+    MovedAttribute("urlunparse", "urlparse", "urllib.parse"),
+    MovedAttribute("urlunsplit", "urlparse", "urllib.parse"),
+    MovedAttribute("quote", "urllib", "urllib.parse"),
+    MovedAttribute("quote_plus", "urllib", "urllib.parse"),
+    MovedAttribute("unquote", "urllib", "urllib.parse"),
+    MovedAttribute("unquote_plus", "urllib", "urllib.parse"),
+    MovedAttribute("urlencode", "urllib", "urllib.parse"),
+    MovedAttribute("splitquery", "urllib", "urllib.parse"),
+    MovedAttribute("splittag", "urllib", "urllib.parse"),
+    MovedAttribute("splituser", "urllib", "urllib.parse"),
+    MovedAttribute("uses_fragment", "urlparse", "urllib.parse"),
+    MovedAttribute("uses_netloc", "urlparse", "urllib.parse"),
+    MovedAttribute("uses_params", "urlparse", "urllib.parse"),
+    MovedAttribute("uses_query", "urlparse", "urllib.parse"),
+    MovedAttribute("uses_relative", "urlparse", "urllib.parse"),
+]
+for attr in _urllib_parse_moved_attributes:
+    setattr(Module_six_moves_urllib_parse, attr.name, attr)
+del attr
+
+Module_six_moves_urllib_parse._moved_attributes = _urllib_parse_moved_attributes
+
+_importer._add_module(Module_six_moves_urllib_parse(__name__ + ".moves.urllib_parse"),
+                      "moves.urllib_parse", "moves.urllib.parse")
+
+
+class Module_six_moves_urllib_error(_LazyModule):
+
+    """Lazy loading of moved objects in six.moves.urllib_error"""
+
+
+_urllib_error_moved_attributes = [
+    MovedAttribute("URLError", "urllib2", "urllib.error"),
+    MovedAttribute("HTTPError", "urllib2", "urllib.error"),
+    MovedAttribute("ContentTooShortError", "urllib", "urllib.error"),
+]
+for attr in _urllib_error_moved_attributes:
+    setattr(Module_six_moves_urllib_error, attr.name, attr)
+del attr
+
+Module_six_moves_urllib_error._moved_attributes = _urllib_error_moved_attributes
+
+_importer._add_module(Module_six_moves_urllib_error(__name__ + ".moves.urllib.error"),
+                      "moves.urllib_error", "moves.urllib.error")
+
+
+class Module_six_moves_urllib_request(_LazyModule):
+
+    """Lazy loading of moved objects in six.moves.urllib_request"""
+
+
+_urllib_request_moved_attributes = [
+    MovedAttribute("urlopen", "urllib2", "urllib.request"),
+    MovedAttribute("install_opener", "urllib2", "urllib.request"),
+    MovedAttribute("build_opener", "urllib2", "urllib.request"),
+    MovedAttribute("pathname2url", "urllib", "urllib.request"),
+    MovedAttribute("url2pathname", "urllib", "urllib.request"),
+    MovedAttribute("getproxies", "urllib", "urllib.request"),
+    MovedAttribute("Request", "urllib2", "urllib.request"),
+    MovedAttribute("OpenerDirector", "urllib2", "urllib.request"),
+    MovedAttribute("HTTPDefaultErrorHandler", "urllib2", "urllib.request"),
+    MovedAttribute("HTTPRedirectHandler", "urllib2", "urllib.request"),
+    MovedAttribute("HTTPCookieProcessor", "urllib2", "urllib.request"),
+    MovedAttribute("ProxyHandler", "urllib2", "urllib.request"),
+    MovedAttribute("BaseHandler", "urllib2", "urllib.request"),
+    MovedAttribute("HTTPPasswordMgr", "urllib2", "urllib.request"),
+    MovedAttribute("HTTPPasswordMgrWithDefaultRealm", "urllib2", "urllib.request"),
+    MovedAttribute("AbstractBasicAuthHandler", "urllib2", "urllib.request"),
+    MovedAttribute("HTTPBasicAuthHandler", "urllib2", "urllib.request"),
+    MovedAttribute("ProxyBasicAuthHandler", "urllib2", "urllib.request"),
+    MovedAttribute("AbstractDigestAuthHandler", "urllib2", "urllib.request"),
+    MovedAttribute("HTTPDigestAuthHandler", "urllib2", "urllib.request"),
+    MovedAttribute("ProxyDigestAuthHandler", "urllib2", "urllib.request"),
+    MovedAttribute("HTTPHandler", "urllib2", "urllib.request"),
+    MovedAttribute("HTTPSHandler", "urllib2", "urllib.request"),
+    MovedAttribute("FileHandler", "urllib2", "urllib.request"),
+    MovedAttribute("FTPHandler", "urllib2", "urllib.request"),
+    MovedAttribute("CacheFTPHandler", "urllib2", "urllib.request"),
+    MovedAttribute("UnknownHandler", "urllib2", "urllib.request"),
+    MovedAttribute("HTTPErrorProcessor", "urllib2", "urllib.request"),
+    MovedAttribute("urlretrieve", "urllib", "urllib.request"),
+    MovedAttribute("urlcleanup", "urllib", "urllib.request"),
+    MovedAttribute("URLopener", "urllib", "urllib.request"),
+    MovedAttribute("FancyURLopener", "urllib", "urllib.request"),
+    MovedAttribute("proxy_bypass", "urllib", "urllib.request"),
+]
+for attr in _urllib_request_moved_attributes:
+    setattr(Module_six_moves_urllib_request, attr.name, attr)
+del attr
+
+Module_six_moves_urllib_request._moved_attributes = _urllib_request_moved_attributes
+
+_importer._add_module(Module_six_moves_urllib_request(__name__ + ".moves.urllib.request"),
+                      "moves.urllib_request", "moves.urllib.request")
+
+
+class Module_six_moves_urllib_response(_LazyModule):
+
+    """Lazy loading of moved objects in six.moves.urllib_response"""
+
+
+_urllib_response_moved_attributes = [
+    MovedAttribute("addbase", "urllib", "urllib.response"),
+    MovedAttribute("addclosehook", "urllib", "urllib.response"),
+    MovedAttribute("addinfo", "urllib", "urllib.response"),
+    MovedAttribute("addinfourl", "urllib", "urllib.response"),
+]
+for attr in _urllib_response_moved_attributes:
+    setattr(Module_six_moves_urllib_response, attr.name, attr)
+del attr
+
+Module_six_moves_urllib_response._moved_attributes = _urllib_response_moved_attributes
+
+_importer._add_module(Module_six_moves_urllib_response(__name__ + ".moves.urllib.response"),
+                      "moves.urllib_response", "moves.urllib.response")
+
+
+class Module_six_moves_urllib_robotparser(_LazyModule):
+
+    """Lazy loading of moved objects in six.moves.urllib_robotparser"""
+
+
+_urllib_robotparser_moved_attributes = [
+    MovedAttribute("RobotFileParser", "robotparser", "urllib.robotparser"),
+]
+for attr in _urllib_robotparser_moved_attributes:
+    setattr(Module_six_moves_urllib_robotparser, attr.name, attr)
+del attr
+
+Module_six_moves_urllib_robotparser._moved_attributes = _urllib_robotparser_moved_attributes
+
+_importer._add_module(Module_six_moves_urllib_robotparser(__name__ + ".moves.urllib.robotparser"),
+                      "moves.urllib_robotparser", "moves.urllib.robotparser")
+
+
+class Module_six_moves_urllib(types.ModuleType):
+
+    """Create a six.moves.urllib namespace that resembles the Python 3 namespace"""
+    __path__ = []  # mark as package
+    parse = _importer._get_module("moves.urllib_parse")
+    error = _importer._get_module("moves.urllib_error")
+    request = _importer._get_module("moves.urllib_request")
+    response = _importer._get_module("moves.urllib_response")
+    robotparser = _importer._get_module("moves.urllib_robotparser")
+
+    def __dir__(self):
+        return ['parse', 'error', 'request', 'response', 'robotparser']
+
+_importer._add_module(Module_six_moves_urllib(__name__ + ".moves.urllib"),
+                      "moves.urllib")
+
+
+def add_move(move):
+    """Add an item to six.moves."""
+    setattr(_MovedItems, move.name, move)
+
+
+def remove_move(name):
+    """Remove item from six.moves."""
+    try:
+        delattr(_MovedItems, name)
+    except AttributeError:
+        try:
+            del moves.__dict__[name]
+        except KeyError:
+            raise AttributeError("no such move, %r" % (name,))
+
+
+if PY3:
+    _meth_func = "__func__"
+    _meth_self = "__self__"
+
+    _func_closure = "__closure__"
+    _func_code = "__code__"
+    _func_defaults = "__defaults__"
+    _func_globals = "__globals__"
+else:
+    _meth_func = "im_func"
+    _meth_self = "im_self"
+
+    _func_closure = "func_closure"
+    _func_code = "func_code"
+    _func_defaults = "func_defaults"
+    _func_globals = "func_globals"
+
+
+try:
+    advance_iterator = next
+except NameError:
+    def advance_iterator(it):
+        return it.next()
+next = advance_iterator
+
+
+try:
+    callable = callable
+except NameError:
+    def callable(obj):
+        return any("__call__" in klass.__dict__ for klass in type(obj).__mro__)
+
+
+if PY3:
+    def get_unbound_function(unbound):
+        return unbound
+
+    create_bound_method = types.MethodType
+
+    def create_unbound_method(func, cls):
+        return func
+
+    Iterator = object
+else:
+    def get_unbound_function(unbound):
+        return unbound.im_func
+
+    def create_bound_method(func, obj):
+        return types.MethodType(func, obj, obj.__class__)
+
+    def create_unbound_method(func, cls):
+        return types.MethodType(func, None, cls)
+
+    class Iterator(object):
+
+        def next(self):
+            return type(self).__next__(self)
+
+    callable = callable
+_add_doc(get_unbound_function,
+         """Get the function out of a possibly unbound function""")
+
+
+get_method_function = operator.attrgetter(_meth_func)
+get_method_self = operator.attrgetter(_meth_self)
+get_function_closure = operator.attrgetter(_func_closure)
+get_function_code = operator.attrgetter(_func_code)
+get_function_defaults = operator.attrgetter(_func_defaults)
+get_function_globals = operator.attrgetter(_func_globals)
+
+
+if PY3:
+    def iterkeys(d, **kw):
+        return iter(d.keys(**kw))
+
+    def itervalues(d, **kw):
+        return iter(d.values(**kw))
+
+    def iteritems(d, **kw):
+        return iter(d.items(**kw))
+
+    def iterlists(d, **kw):
+        return iter(d.lists(**kw))
+
+    viewkeys = operator.methodcaller("keys")
+
+    viewvalues = operator.methodcaller("values")
+
+    viewitems = operator.methodcaller("items")
+else:
+    def iterkeys(d, **kw):
+        return d.iterkeys(**kw)
+
+    def itervalues(d, **kw):
+        return d.itervalues(**kw)
+
+    def iteritems(d, **kw):
+        return d.iteritems(**kw)
+
+    def iterlists(d, **kw):
+        return d.iterlists(**kw)
+
+    viewkeys = operator.methodcaller("viewkeys")
+
+    viewvalues = operator.methodcaller("viewvalues")
+
+    viewitems = operator.methodcaller("viewitems")
+
+_add_doc(iterkeys, "Return an iterator over the keys of a dictionary.")
+_add_doc(itervalues, "Return an iterator over the values of a dictionary.")
+_add_doc(iteritems,
+         "Return an iterator over the (key, value) pairs of a dictionary.")
+_add_doc(iterlists,
+         "Return an iterator over the (key, [values]) pairs of a dictionary.")
+
+
+if PY3:
+    def b(s):
+        return s.encode("latin-1")
+
+    def u(s):
+        return s
+    unichr = chr
+    import struct
+    int2byte = struct.Struct(">B").pack
+    del struct
+    byte2int = operator.itemgetter(0)
+    indexbytes = operator.getitem
+    iterbytes = iter
+    import io
+    StringIO = io.StringIO
+    BytesIO = io.BytesIO
+    _assertCountEqual = "assertCountEqual"
+    if sys.version_info[1] <= 1:
+        _assertRaisesRegex = "assertRaisesRegexp"
+        _assertRegex = "assertRegexpMatches"
+    else:
+        _assertRaisesRegex = "assertRaisesRegex"
+        _assertRegex = "assertRegex"
+else:
+    def b(s):
+        return s
+    # Workaround for standalone backslash
+
+    def u(s):
+        return unicode(s.replace(r'\\', r'\\\\'), "unicode_escape")
+    unichr = unichr
+    int2byte = chr
+
+    def byte2int(bs):
+        return ord(bs[0])
+
+    def indexbytes(buf, i):
+        return ord(buf[i])
+    iterbytes = functools.partial(itertools.imap, ord)
+    import StringIO
+    StringIO = BytesIO = StringIO.StringIO
+    _assertCountEqual = "assertItemsEqual"
+    _assertRaisesRegex = "assertRaisesRegexp"
+    _assertRegex = "assertRegexpMatches"
+_add_doc(b, """Byte literal""")
+_add_doc(u, """Text literal""")
+
+
+def assertCountEqual(self, *args, **kwargs):
+    return getattr(self, _assertCountEqual)(*args, **kwargs)
+
+
+def assertRaisesRegex(self, *args, **kwargs):
+    return getattr(self, _assertRaisesRegex)(*args, **kwargs)
+
+
+def assertRegex(self, *args, **kwargs):
+    return getattr(self, _assertRegex)(*args, **kwargs)
+
+
+if PY3:
+    exec_ = getattr(moves.builtins, "exec")
+
+    def reraise(tp, value, tb=None):
+        if value is None:
+            value = tp()
+        if value.__traceback__ is not tb:
+            raise value.with_traceback(tb)
+        raise value
+
+else:
+    def exec_(_code_, _globs_=None, _locs_=None):
+        """Execute code in a namespace."""
+        if _globs_ is None:
+            frame = sys._getframe(1)
+            _globs_ = frame.f_globals
+            if _locs_ is None:
+                _locs_ = frame.f_locals
+            del frame
+        elif _locs_ is None:
+            _locs_ = _globs_
+        exec("""exec _code_ in _globs_, _locs_""")
+
+    exec_("""def reraise(tp, value, tb=None):
+    raise tp, value, tb
+""")
+
+
+if sys.version_info[:2] == (3, 2):
+    exec_("""def raise_from(value, from_value):
+    if from_value is None:
+        raise value
+    raise value from from_value
+""")
+elif sys.version_info[:2] > (3, 2):
+    exec_("""def raise_from(value, from_value):
+    raise value from from_value
+""")
+else:
+    def raise_from(value, from_value):
+        raise value
+
+
+print_ = getattr(moves.builtins, "print", None)
+if print_ is None:
+    def print_(*args, **kwargs):
+        """The new-style print function for Python 2.4 and 2.5."""
+        fp = kwargs.pop("file", sys.stdout)
+        if fp is None:
+            return
+
+        def write(data):
+            if not isinstance(data, basestring):
+                data = str(data)
+            # If the file has an encoding, encode unicode with it.
+            if (isinstance(fp, file) and
+                    isinstance(data, unicode) and
+                    fp.encoding is not None):
+                errors = getattr(fp, "errors", None)
+                if errors is None:
+                    errors = "strict"
+                data = data.encode(fp.encoding, errors)
+            fp.write(data)
+        want_unicode = False
+        sep = kwargs.pop("sep", None)
+        if sep is not None:
+            if isinstance(sep, unicode):
+                want_unicode = True
+            elif not isinstance(sep, str):
+                raise TypeError("sep must be None or a string")
+        end = kwargs.pop("end", None)
+        if end is not None:
+            if isinstance(end, unicode):
+                want_unicode = True
+            elif not isinstance(end, str):
+                raise TypeError("end must be None or a string")
+        if kwargs:
+            raise TypeError("invalid keyword arguments to print()")
+        if not want_unicode:
+            for arg in args:
+                if isinstance(arg, unicode):
+                    want_unicode = True
+                    break
+        if want_unicode:
+            newline = unicode("\n")
+            space = unicode(" ")
+        else:
+            newline = "\n"
+            space = " "
+        if sep is None:
+            sep = space
+        if end is None:
+            end = newline
+        for i, arg in enumerate(args):
+            if i:
+                write(sep)
+            write(arg)
+        write(end)
+if sys.version_info[:2] < (3, 3):
+    _print = print_
+
+    def print_(*args, **kwargs):
+        fp = kwargs.get("file", sys.stdout)
+        flush = kwargs.pop("flush", False)
+        _print(*args, **kwargs)
+        if flush and fp is not None:
+            fp.flush()
+
+_add_doc(reraise, """Reraise an exception.""")
+
+if sys.version_info[0:2] < (3, 4):
+    def wraps(wrapped, assigned=functools.WRAPPER_ASSIGNMENTS,
+              updated=functools.WRAPPER_UPDATES):
+        def wrapper(f):
+            f = functools.wraps(wrapped, assigned, updated)(f)
+            f.__wrapped__ = wrapped
+            return f
+        return wrapper
+else:
+    wraps = functools.wraps
+
+
+def with_metaclass(meta, *bases):
+    """Create a base class with a metaclass."""
+    # This requires a bit of explanation: the basic idea is to make a dummy
+    # metaclass for one level of class instantiation that replaces itself with
+    # the actual metaclass.
+    class metaclass(meta):
+
+        def __new__(cls, name, this_bases, d):
+            return meta(name, bases, d)
+    return type.__new__(metaclass, 'temporary_class', (), {})
+
+
+def add_metaclass(metaclass):
+    """Class decorator for creating a class with a metaclass."""
+    def wrapper(cls):
+        orig_vars = cls.__dict__.copy()
+        slots = orig_vars.get('__slots__')
+        if slots is not None:
+            if isinstance(slots, str):
+                slots = [slots]
+            for slots_var in slots:
+                orig_vars.pop(slots_var)
+        orig_vars.pop('__dict__', None)
+        orig_vars.pop('__weakref__', None)
+        return metaclass(cls.__name__, cls.__bases__, orig_vars)
+    return wrapper
+
+
+def python_2_unicode_compatible(klass):
+    """
+    A decorator that defines __unicode__ and __str__ methods under Python 2.
+    Under Python 3 it does nothing.
+
+    To support Python 2 and 3 with a single code base, define a __str__ method
+    returning text and apply this decorator to the class.
+    """
+    if PY2:
+        if '__str__' not in klass.__dict__:
+            raise ValueError("@python_2_unicode_compatible cannot be applied "
+                             "to %s because it doesn't define __str__()." %
+                             klass.__name__)
+        klass.__unicode__ = klass.__str__
+        klass.__str__ = lambda self: self.__unicode__().encode('utf-8')
+    return klass
+
+
+# Complete the moves implementation.
+# This code is at the end of this module to speed up module loading.
+# Turn this module into a package.
+__path__ = []  # required for PEP 302 and PEP 451
+__package__ = __name__  # see PEP 366 @ReservedAssignment
+if globals().get("__spec__") is not None:
+    __spec__.submodule_search_locations = []  # PEP 451 @UndefinedVariable
+# Remove other six meta path importers, since they cause problems. This can
+# happen if six is removed from sys.modules and then reloaded. (Setuptools does
+# this for some reason.)
+if sys.meta_path:
+    for i, importer in enumerate(sys.meta_path):
+        # Here's some real nastiness: Another "instance" of the six module might
+        # be floating around. Therefore, we can't use isinstance() to check for
+        # the six meta path importer, since the other six instance will have
+        # inserted an importer with different class.
+        if (type(importer).__name__ == "_SixMetaPathImporter" and
+                importer.name == __name__):
+            del sys.meta_path[i]
+            break
+    del i, importer
+# Finally, add the importer to the meta path import hook.
+sys.meta_path.append(_importer)
\ No newline at end of file
diff --git a/bids/layout/__init__.py b/bids/layout/__init__.py
index e5f77e68..9b5d6d2f 100644
--- a/bids/layout/__init__.py
+++ b/bids/layout/__init__.py
@@ -1,5 +1,7 @@
-from .layout import BIDSLayout, add_config_paths
+from .layout import BIDSLayout, add_config_paths, parse_file_entities
+from .core import BIDSFile
 # Backwards compatibility
 from bids_validator import BIDSValidator
 
-__all__ = ["BIDSLayout", "BIDSValidator", "add_config_paths"]
+__all__ = ["BIDSLayout", "BIDSValidator", "add_config_paths",
+           "parse_file_entities", "BIDSFile"]
diff --git a/bids/layout/core.py b/bids/layout/core.py
new file mode 100644
index 00000000..8a8cb121
--- /dev/null
+++ b/bids/layout/core.py
@@ -0,0 +1,550 @@
+""" Core pybids data structures. """
+
+import os
+import re
+import json
+from keyword import iskeyword
+import warnings
+from copy import deepcopy
+
+from .writing import build_path, write_contents_to_file
+from ..utils import listify, check_path_matches_patterns
+from ..config import get_option
+from ..external import six
+
+
+__all__ = [
+    "Config",
+    "Entity",
+    "BIDSFile",
+    "BIDSRootNode",
+    "BIDSSubjectNode",
+    "BIDSSessionNode"
+]
+
+
+class Config(object):
+    """ Container for BIDS configuration information.
+
+    Args:
+        name (str): The name to give the Config (e.g., 'bids').
+        entities (list): A list of dictionaries containing entity configuration
+            information.
+        default_path_patterns (list): Optional list of patterns used to build
+            new paths.
+    """
+
+    def __init__(self, name, entities=None, default_path_patterns=None):
+
+        self.name = name
+        self.entities = {}
+        self.default_path_patterns = default_path_patterns
+
+        if entities:
+            for ent in entities:
+                self.entities[ent['name']] = Entity(**ent)
+
+    @classmethod
+    def load(self, config):
+        if isinstance(config, six.string_types):
+            config_paths = get_option('config_paths')
+            if config in config_paths:
+                config = config_paths[config]
+            if not os.path.exists(config):
+                raise ValueError("{} is not a valid path.".format(config))
+            else:
+                with open(config, 'r') as f:
+                    config = json.load(f)
+        return Config(**config)
+
+
+class Entity(object):
+
+    def __init__(self, name, pattern=None, mandatory=False, directory=None,
+                 map_func=None, dtype=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.
+                Must define at least one group, and only the first group is
+                kept as the match.
+            mandatory (bool): If True, every File _must_ match this entity.
+            directory (str): Optional pattern defining a directory associated
+                with the entity.
+            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).
+            dtype (str): The optional data type of the Entity values. Must be
+                one of 'int', 'float', 'bool', or 'str'. If None, no type
+                enforcement will be attempted, which means the dtype of the
+                value may be unpredictable.
+            kwargs (dict): Additional keyword arguments.
+        """
+        if pattern is None and map_func is None:
+            raise ValueError("Invalid specification for Entity '%s'; no "
+                             "pattern or mapping function provided. Either the"
+                             " 'pattern' or the 'map_func' arguments must be "
+                             "set." % name)
+        self.name = name
+        self.pattern = pattern
+        self.mandatory = mandatory
+        self.directory = directory
+        self.map_func = map_func
+        self.kwargs = kwargs
+
+        if (isinstance(dtype, six.string_types) and
+            dtype in ('str', 'float', 'int', 'bool')):
+            dtype = eval(dtype)
+        if dtype not in [str, float, int, bool, None]:
+            raise ValueError("Invalid dtype '%s'. Must be one of int, float, "
+                             "bool, or str." % dtype)
+        self.dtype = dtype
+
+        self.files = {}
+        self.regex = re.compile(pattern) if pattern is not None else None
+
+    def __iter__(self):
+        for i in self.unique():
+            yield(i)
+
+    def __deepcopy__(self, memo):
+        cls = self.__class__
+        result = cls.__new__(cls)
+        memo[id(self)] = result
+
+        for k, v in self.__dict__.items():
+            new_val = getattr(self, k) if k == 'regex' else deepcopy(v, memo)
+            setattr(result, k, new_val)
+        return result
+
+    def match_file(self, f):
+        """
+        Determine whether the passed file matches the Entity.
+
+        Args:
+            f (File): The BIDSFile instance to match against.
+
+        Returns: the matched value if a match was found, otherwise None.
+        """
+        if self.map_func is not None:
+            val = self.map_func(f)
+        else:
+            m = self.regex.search(f.path)
+            val = m.group(1) if m is not None else None
+
+        return self._astype(val)
+
+    def add_file(self, filename, value):
+        """ Adds the specified filename to tracking. """
+        self.files[filename] = value
+
+    def unique(self):
+        """ Returns all unique values/levels for the current entity. """
+        return list(set(self.files.values()))
+
+    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.
+        Returns: an int.
+        """
+        return len(self.files) if files else len(self.unique())
+
+    def _astype(self, val):
+        if val is not None and self.dtype is not None:
+            val = self.dtype(val)
+        return val
+        
+
+class BIDSFile(object):
+    """ Represents a single file in a BIDS project.
+    
+    Args:
+        filename (str): Full path to file.
+        parent (BIDSNode): Optional parent node/directory.
+    
+    """
+    def __init__(self, filename, parent=None):
+        self.path = filename
+        self.filename = os.path.basename(self.path)
+        self.dirname = os.path.dirname(self.path)
+        self.tags = []
+        self.entities = {}
+        self.parent = parent
+
+    def _matches(self, entities=None, extensions=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.
+            regex_search (bool): Whether to require exact match (False) or
+                regex search (True) when comparing the query string to each
+                entity.
+        Returns:
+            True if _all_ entities and extensions match; False otherwise.
+        """
+        if extensions is not None:
+            extensions = map(re.escape, listify(extensions))
+            extensions = '(' + '|'.join(extensions) + ')$'
+            if re.search(extensions, self.filename) is None:
+                return False
+
+        if entities is None:
+            return True
+
+        for name, val in entities.items():
+
+            if (name not in self.entities) ^ (val is None):
+                return False
+
+            if val is None:
+                continue
+
+            def make_patt(x):
+                patt = str(x)
+                if not regex_search:
+                    patt = re.escape(patt)
+                if isinstance(x, (int, float)):
+                    # allow for leading zeros if a number was specified
+                    # regardless of regex_search
+                    patt = '0*' + patt
+                if not regex_search:
+                    patt = '^{}$'.format(patt)
+                return patt
+
+            ent_patts = [make_patt(x) for x in listify(val)]
+            patt = '|'.join(ent_patts)
+
+            if re.search(patt, str(self.entities[name])) is None:
+                return False
+
+        return True
+
+    def copy(self, path_patterns, symbolic_link=False, root=None,
+             conflicts='fail'):
+        """ Copy the contents of a file to a new location.
+        
+        Args:
+            path_patterns (list): List of patterns use to construct the new
+                filename. See build_path documentation for details.
+            symbolic_link (bool): If True, use a symbolic link to point to the
+                existing file. If False, creates a new file.
+            root (str): Optional path to prepend to the constructed filename.
+            conflicts (str): Defines the desired action when the output path
+                already exists. Must be one of:
+                    'fail': raises an exception
+                    'skip' does nothing
+                    'overwrite': overwrites the existing file
+                    'append': adds  a suffix to each file copy, starting with 1
+        """
+        new_filename = build_path(self.entities, path_patterns)
+        if not new_filename:
+            return None
+
+        if new_filename[-1] == os.sep:
+            new_filename += self.filename
+
+        if os.path.isabs(self.path) or root is None:
+            path = self.path
+        else:
+            path = os.path.join(root, self.path)
+
+        if not os.path.exists(path):
+            raise ValueError("Target filename to copy/symlink (%s) doesn't "
+                             "exist." % path)
+
+        if symbolic_link:
+            contents = None
+            link_to = path
+        else:
+            with open(path, 'r') as f:
+                contents = f.read()
+            link_to = None
+
+        write_contents_to_file(new_filename, contents=contents,
+                               link_to=link_to, content_mode='text', root=root,
+                               conflicts=conflicts)
+
+    def __getattr__(self, attr):
+        # Ensures backwards compatibility with old File_ namedtuple, which is
+        # deprecated as of 0.7.
+        if attr in self.entities:
+            warnings.warn("Accessing entities as attributes is deprecated as "
+                          "of 0.7. Please use the .entities dictionary instead"
+                          " (i.e., .entities['%s'] instead of .%s."
+                          % (attr, attr))
+            return self.entities[attr]
+        raise AttributeError("%s object has no attribute named %r" %
+                             (self.__class__.__name__, attr))
+
+    def __repr__(self):
+        source = ''
+        layout = self.parent.layout
+        if layout.sources:
+            source = ", root='{}'".format(os.path.basename(layout.root))
+        return "<BIDSFile filename='{}'{}>".format(
+            os.path.relpath(self.path, start=layout.root), source)
+
+    @property
+    def image(self):
+        """ Return the associated image file (if it exists) as a NiBabel object
+        """
+        try:
+            import nibabel as nb
+            return nb.load(self.path)
+        except Exception:
+            return None
+
+    @property
+    def metadata(self):
+        """ Return all associated metadata. """
+        return self.layout.get_metadata(self.path)
+
+    @property
+    def layout(self):
+        return self.parent.layout
+
+
+class BIDSNode(object):
+    """ Represents a single directory or other logical grouping within a
+    BIDS project.
+
+    Args:
+        path (str): The full path to the directory.
+        config (str, list): One or more names of built-in configurations
+            (e.g., 'bids' or 'derivatives') that specify the rules that apply
+            to this node.
+        root (BIDSNode): The node at the root of the tree the current node is
+            part of.
+        parent (BIDSNode): The parent of the current node.
+        force_index (bool): Whether or not to forcibly index every file below
+            this node, even if it fails standard BIDS validation.
+    """
+
+    _child_class = None
+    _child_entity = None
+    _entities = {}
+
+    def __init__(self, path, config, root=None, parent=None,
+                 force_index=False):
+        self.path = path
+        self.config = listify(config)
+        self.root = root
+        self.parent = parent
+        self.entities = {}
+        self.available_entities = {}
+        self.children = []
+        self.files = []
+        self.variables = []
+        self.force_index = force_index
+
+        # Check for additional config file in directory
+        layout_file = self.layout.config_filename
+        config_file = os.path.join(self.abs_path, layout_file)
+        if os.path.exists(config_file):
+            cfg = Config.load(config_file)
+            self.config.append(cfg)
+
+        # Consolidate all entities
+        self._update_entities()
+
+        # Extract local entity values
+        self._extract_entities()
+
+        # Do subclass-specific setup
+        self._setup()
+
+        # Append to layout's master list of nodes
+        self.layout.nodes.append(self)
+
+        # Index files and create child nodes
+        self.index()
+
+    def __getitem__(self, key):
+        if key in self.children:
+            return self.children[key]
+        if key in self.files:
+            return self.files[key]
+        raise AttributeError("BIDSNode at path {} has no child node or file "
+                             "named {}.".format(self.path, key))
+    
+    def _update_entities(self):
+        # Make all entities easily accessible in a single dict
+        self.available_entities = {}
+        for c in self.config:
+            self.available_entities.update(c.entities)
+    
+    def _extract_entities(self):
+        self.entities = {}
+        for ent in self._entities:
+            m = re.findall(self.available_entities[ent].pattern, self.path)
+            if m:
+                self.entities[ent] = m[0]
+
+    def _get_child_class(self, path):
+        """ Return the appropriate child class given a subdirectory path.
+        
+        Args:
+            path (str): The path to the subdirectory.
+        
+        Returns: An uninstantiated BIDSNode or one of its subclasses.
+        """
+        if self._child_entity is None:
+            return BIDSNode
+
+        for i, child_ent in enumerate(listify(self._child_entity)):
+            template = self.available_entities[child_ent].directory
+            if template is None:
+                return BIDSNode
+            # Replace {{root}} placeholder with root node's path
+            template = template.replace('{{root}}', self.root_path)
+            # Construct regex search pattern from target directory template
+            to_rep = re.findall(r'\{(.*?)\}', template)
+            for ent in to_rep:
+                patt = self.available_entities[ent].pattern
+                template = template.replace('{%s}' % ent, patt)
+            template += r'[^\%s]*$' % os.path.sep
+            if re.match(template, path):
+                return listify(self._child_class)[i]
+
+        return BIDSNode
+
+    def _setup(self):
+        pass
+
+    @property
+    def abs_path(self):
+        return os.path.join(self.root_path, self.path)
+
+    @property
+    def root_path(self):
+        return self.path if self.root is None else self.root.path
+    
+    @property
+    def layout(self):
+        return self._layout if self.root is None else self.root.layout
+
+    def index(self):
+        """ Index all files/directories below the current BIDSNode. """
+
+        config_list = self.config
+        layout = self.layout
+
+        for (dirpath, dirnames, filenames) in os.walk(self.path):
+
+            # If layout configuration file exists, delete it
+            layout_file = self.layout.config_filename
+            if layout_file in filenames:
+                filenames.remove(layout_file)
+
+            for f in filenames:
+
+                abs_fn = os.path.join(self.path, f)
+
+                # Skip files that fail validation, unless forcibly indexing
+                if not self.force_index and not layout._validate_file(abs_fn):
+                    continue
+
+                bf = BIDSFile(abs_fn, self)
+
+                # Extract entity values
+                match_vals = {}
+                for e in self.available_entities.values():
+                    m = e.match_file(bf)
+                    if m is None and e.mandatory:
+                        break
+                    if m is not None:
+                        match_vals[e.name] = (e, m)
+
+                # Create Entity <=> BIDSFile mappings
+                if match_vals:
+                    for name, (ent, val) in match_vals.items():
+                        bf.entities[name] = val
+                        ent.add_file(bf.path, val)
+
+                self.files.append(bf)
+                # Also add to the Layout's master list
+                self.layout.files[bf.path] = bf
+
+            root_node = self if self.root is None else self.root
+
+            for d in dirnames:
+
+                d = os.path.join(dirpath, d)
+
+                # Derivative directories must always be added separately and
+                # passed as their own root, so terminate if passed.
+                if d.startswith(os.path.join(self.layout.root, 'derivatives')):
+                    continue
+
+                # Skip directories that fail validation, unless force_index
+                # is defined, in which case we have to keep scanning, in the
+                # event that a file somewhere below the current level matches.
+                # Unfortunately we probably can't do much better than this
+                # without a lot of additional work, because the elements of
+                # .force_index can be SRE_Patterns that match files below in
+                # unpredictable ways.
+                if check_path_matches_patterns(d, self.layout.force_index):
+                    self.force_index = True
+                else:
+                    valid_dir = layout._validate_dir(d)
+                    # Note the difference between self.force_index and
+                    # self.layout.force_index.
+                    if not valid_dir and not self.layout.force_index:
+                        continue
+
+                child_class = self._get_child_class(d)
+                # TODO: filter the config files based on include/exclude rules
+                child = child_class(d, config_list, root_node, self,
+                                    force_index=self.force_index)
+
+                if self.force_index or valid_dir:
+                    self.children.append(child)
+
+            # prevent subdirectory traversal
+            break
+
+
+class BIDSSessionNode(BIDSNode):
+    """ A BIDSNode associated with a single BIDS session. """
+
+    _entities = {'subject', 'session'}
+
+    def _setup(self):
+        self.label = self.entities['session']
+
+
+class BIDSSubjectNode(BIDSNode):
+    """ A BIDSNode associated with a single BIDS subject. """
+
+    _child_entity = 'session'
+    _child_class = BIDSSessionNode
+    _entities = {'subject'}
+
+    def _setup(self):
+        self.sessions = [c for c in self.children if
+                         isinstance(c, BIDSSessionNode)]
+        self.label = self.entities['subject']
+
+
+class BIDSRootNode(BIDSNode):
+    """ A BIDSNode representing the top level of an entire BIDS project. """
+
+    _child_entity = 'subject'
+    _child_class = BIDSSubjectNode
+
+    def __init__(self, path, config, layout, force_index=False):
+        self._layout = layout
+        super(BIDSRootNode, self).__init__(path, config,
+                                           force_index=force_index)
+    
+    def _setup(self):
+        self.subjects = {c.label: c for c in self.children if
+                         isinstance(c, BIDSSubjectNode)}
diff --git a/bids/layout/layout.py b/bids/layout/layout.py
index 8c799a08..d1093110 100644
--- a/bids/layout/layout.py
+++ b/bids/layout/layout.py
@@ -1,18 +1,18 @@
 import os
-import re
 import json
-import warnings
-from io import open
-from bids_validator import BIDSValidator
-from .. import config as cf
-from grabbit import Layout, File
-from grabbit.external import six, inflect
-from grabbit.utils import listify
+import re
 from collections import defaultdict
+from io import open
 from functools import reduce, partial
 from itertools import chain
-from bids.config import get_option
+import copy
 
+from bids_validator import BIDSValidator
+from ..utils import listify, natural_sort, check_path_matches_patterns
+from ..external import inflect, six
+from .core import Config, BIDSFile, BIDSRootNode
+from .writing import build_path, write_contents_to_file
+from .. import config as cf
 
 try:
     from os.path import commonpath
@@ -27,12 +27,62 @@ except ImportError:
 __all__ = ['BIDSLayout']
 
 
+def parse_file_entities(filename, entities=None, config=None,
+                        include_unmatched=False):
+    """ Parse the passed filename for entity/value pairs.
+
+    Args:
+        filename (str): The filename to parse for entity values
+        entities (list): An optional list of Entity instances to use in
+            extraction. If passed, the config argument is ignored.
+        config (str, Config, list): One or more Config objects or names of
+            configurations to use in matching. Each element must be a Config
+            object, or a valid Config name (e.g., 'bids' or 'derivatives').
+            If None, all available configs are used.
+        include_unmatched (bool): If True, unmatched entities are included
+            in the returned dict, with values set to None. If False
+            (default), unmatched entities are ignored.
+
+    Returns: A dict, where keys are Entity names and values are the
+        values extracted from the filename.
+    """
+
+    # Load Configs if needed
+    if entities is None:
+
+        if config is None:
+            config = ['bids', 'derivatives']
+
+        config = [Config.load(c) if not isinstance(c, Config) else c
+                  for c in listify(config)]
+
+        # Consolidate entities from all Configs into a single dict
+        entities = {}
+        for c in config:
+            entities.update(c.entities)
+        entities = entities.values()
+
+    # Extract matches
+    bf = BIDSFile(filename)
+    ent_vals = {}
+    for ent in entities:
+        match = ent.match_file(bf)
+        if match is not None or include_unmatched:
+            ent_vals[ent.name] = match
+
+    return ent_vals
+
+
 def add_config_paths(**kwargs):
     """ Add to the pool of available configuration files for BIDSLayout.
+
     Args:
-        kwargs: each kwarg should be a pair of config key name, and path
+        kwargs: dictionary specifying where to find additional config files.
+            Keys are names, values are paths to the corresponding .json file.
 
-    Example: bids.layout.add_config_paths(my_config='/path/to/config')
+    Example:
+        > add_config_paths(my_config='/path/to/config')
+        > layout = BIDSLayout('/path/to/bids', config=['bids', 'my_config'])
     """
 
     for k, path in kwargs.items():
@@ -46,48 +96,7 @@ def add_config_paths(**kwargs):
     cf.set_option('config_paths', kwargs)
 
 
-class BIDSFile(File):
-    """ Represents a single BIDS file. """
-    def __init__(self, filename, layout):
-        super(BIDSFile, self).__init__(filename)
-        self.layout = layout
-
-    def __getattr__(self, attr):
-        # Ensures backwards compatibility with old File_ namedtuple, which is
-        # deprecated as of 0.7.
-        if attr in self.entities:
-            warnings.warn("Accessing entities as attributes is deprecated as "
-                          "of 0.7. Please use the .entities dictionary instead"
-                          " (i.e., .entities['%s'] instead of .%s."
-                          % (attr, attr))
-            return self.entities[attr]
-        raise AttributeError("%s object has no attribute named %r" %
-                             (self.__class__.__name__, attr))
-
-    def __repr__(self):
-        source = ''
-        if self.layout.sources:
-            source = ", root='{}'".format(os.path.basename(self.layout.root))
-        return "<BIDSFile filename='{}'{}>".format(
-            os.path.relpath(self.path, start=self.layout.root), source)
-
-    @property
-    def image(self):
-        """ Return the associated image file (if it exists) as a NiBabel object.
-        """
-        try:
-            import nibabel as nb
-            return nb.load(self.path)
-        except Exception:
-            return None
-
-    @property
-    def metadata(self):
-        """ Return all associated metadata. """
-        return self.layout.get_metadata(self.path)
-
-
-class BIDSLayout(Layout):
+class BIDSLayout(object):
     """ Layout class representing an entire BIDS dataset.
 
     Args:
@@ -100,10 +109,6 @@ class BIDSLayout(Layout):
             etc. to be ignored.
         index_associated (bool): Argument passed onto the BIDSValidator;
             ignored if validate = False.
-        include (str, list): String or list of strings specifying which of the
-            directories that are by default excluded from indexing should be
-            included. The default exclusion list is ['code', 'stimuli',
-            'sourcedata', 'models'].
         absolute_paths (bool): If True, queries always return absolute paths.
             If False, queries return relative paths, unless the root argument
             was left empty (in which case the root defaults to the file system
@@ -118,38 +123,100 @@ class BIDSLayout(Layout):
             By default (None), uses 'bids'.
         sources (BIDLayout, list): Optional BIDSLayout(s) from which the
             current BIDSLayout is derived.
-        kwargs: Optional keyword arguments to pass onto the Layout initializer
-            in grabbit.
+        ignore (str, SRE_Pattern, list): Path(s) to exclude from indexing. Each
+            path is either a string or a SRE_Pattern object (i.e., compiled
+            regular expression). If a string is passed, it must be either an
+            absolute path, or be relative to the BIDS project root. If an
+            SRE_Pattern is passed, the contained regular expression will be
+            matched against the full (absolute) path of all files and
+            directories.
+        force_index (str, SRE_Pattern, list): Path(s) to forcibly index in the
+            BIDSLayout, even if they would otherwise fail validation. See the
+            documentation for the ignore argument for input format details.
+            Note that paths in force_index takes precedence over those in
+            ignore (i.e., if a file matches both ignore and force_index, it
+            *will* be indexed).
+        config_filename (str): Optional name of filename within directories
+            that contains configuration information.
+        regex_search (bool): Whether to require exact matching (True) or regex
+            search (False, default) when comparing the query string to each
+            entity in .get() calls. This sets a default for the instance, but
+            can be overridden in individual .get() requests.
     """
 
+    _default_ignore = {"code", "stimuli", "sourcedata", "models",
+                       "derivatives", re.compile(r'^\.')}
+
     def __init__(self, root, validate=True, index_associated=True,
-                 include=None, absolute_paths=True, derivatives=False,
-                 config=None, sources=None, **kwargs):
+                 absolute_paths=True, derivatives=False, config=None,
+                 sources=None, ignore=None, force_index=None,
+                 config_filename='layout_config.json', regex_search=False):
 
-        self.validator = BIDSValidator(index_associated=index_associated)
+        self.root = root
+        self._validator = BIDSValidator(index_associated=index_associated)
         self.validate = validate
-        self.metadata_index = MetadataIndex(self)
+        self.absolute_paths = absolute_paths
         self.derivatives = {}
-        self.sources = listify(sources)
-
-        # Validate arguments
-        if not isinstance(root, six.string_types):
-            # attempt to handle pathlib paths (or other types that can be cast as str)
-            # before giving up
-            try:
-                root = str(root)
-            except:
-                raise TypeError("root argument must be a string (or a type that "
-                        "supports casting to string, such as pathlib.Path)"
-                        " specifying the directory containing the BIDS dataset.")
-        if not os.path.exists(root):
-            raise ValueError("BIDS root does not exist: %s" % root)
+        self.sources = sources
+        self.regex_search = regex_search
+        self.metadata_index = MetadataIndex(self)
+        self.config_filename = config_filename
+        self.files = {}
+        self.nodes = []
+        self.entities = {}
+        self.ignore = [os.path.abspath(os.path.join(self.root, patt))
+                       if isinstance(patt, six.string_types) else patt
+                       for patt in listify(ignore or [])]
+        self.force_index = [os.path.abspath(os.path.join(self.root, patt))
+                            if isinstance(patt, six.string_types) else patt
+                            for patt in listify(force_index or [])]
+
+        # Do basic BIDS validation on root directory
+        self._validate_root()
+
+        # Initialize the BIDS validator and examine ignore/force_index args
+        self._setup_file_validator()
+
+        # Set up configs
+        if config is None:
+            config = 'bids'
+        config = [Config.load(c) for c in listify(config)]
+        self.config = {c.name: c for c in config}
+        self.root_node = BIDSRootNode(self.root, config, self)
 
-        self.root = root
+        # Consolidate entities into master list. Note: no conflicts occur b/c
+        # multiple entries with the same name all point to the same instance.
+        for n in self.nodes:
+            self.entities.update(n.available_entities)
+
+        # Add derivatives if any are found
+        if derivatives:
+            if derivatives is True:
+                derivatives = os.path.join(root, 'derivatives')
+            self.add_derivatives(
+                derivatives, validate=validate,
+                index_associated=index_associated,
+                absolute_paths=absolute_paths, derivatives=None, config=None,
+                sources=self, ignore=ignore, force_index=force_index)
+
+    def _validate_root(self):
+        # Validate root argument and make sure it contains mandatory info
+
+        try:
+            self.root = str(self.root)
+        except:
+            raise TypeError("root argument must be a string (or a type that "
+                    "supports casting to string, such as pathlib.Path)"
+                    " specifying the directory containing the BIDS dataset.")
+
+        self.root = os.path.abspath(self.root)
+
+        if not os.path.exists(self.root):
+            raise ValueError("BIDS root does not exist: %s" % self.root)
 
         target = os.path.join(self.root, 'dataset_description.json')
         if not os.path.exists(target):
-            if validate is True:
+            if self.validate:
                 raise ValueError(
                     "'dataset_description.json' is missing from project root."
                     " Every valid BIDS dataset must have this file.")
@@ -158,45 +225,128 @@ class BIDSLayout(Layout):
         else:
             with open(target, 'r', encoding='utf-8') as desc_fd:
                 self.description = json.load(desc_fd)
-            if validate is True:
+            if self.validate:
                 for k in ['Name', 'BIDSVersion']:
                     if k not in self.description:
                         raise ValueError("Mandatory '%s' field missing from "
                                          "dataset_description.json." % k)
 
-        # Determine which subdirectories to exclude from indexing
-        excludes = {"code", "stimuli", "sourcedata", "models", "derivatives"}
-        if include is not None:
-            include = listify(include)
-            if "derivatives" in include:
-                raise ValueError("Do not pass 'derivatives' in the include "
-                                 "list. To index derivatives, either set "
-                                 "derivatives=True, or use add_derivatives().")
-            excludes -= set([d.strip(os.path.sep) for d in include])
-        self._exclude_dirs = list(excludes)
-
-        # Set up path and config for grabbit
-        if config is None:
-            config = 'bids'
-        config_paths = get_option('config_paths')
-        path = (root, [config_paths[c] for c in listify(config)])
+    def _setup_file_validator(self):
+        # Derivatives get special handling; they shouldn't be indexed normally
+        if self.force_index is not None:
+            for entry in self.force_index:
+                if (isinstance(entry, six.string_types) and
+                    os.path.normpath(entry).startswith('derivatives')):
+                        msg = ("Do not pass 'derivatives' in the force_index "
+                               "list. To index derivatives, either set "
+                               "derivatives=True, or use add_derivatives().")
+                        raise ValueError(msg)
 
-        # Initialize grabbit Layout
-        super(BIDSLayout, self).__init__(path, root=self.root,
-                                         dynamic_getters=True,
-                                         absolute_paths=absolute_paths,
-                                         **kwargs)
+    def _validate_dir(self, d):
+        return not check_path_matches_patterns(d, self.ignore)
 
-        # Add derivatives if any are found
-        self.derivatives = {}
-        if derivatives:
-            if derivatives is True:
-                derivatives = os.path.join(root, 'derivatives')
-            self.add_derivatives(
-                derivatives, validate=validate,
-                index_associated=index_associated, include=include,
-                absolute_paths=absolute_paths, derivatives=None, config=None,
-                sources=self, **kwargs)
+    def _validate_file(self, f):
+        # Validate a file.
+
+        if check_path_matches_patterns(f, self.force_index):
+            return True
+
+        if check_path_matches_patterns(f, self.ignore):
+            return False
+
+        if not self.validate:
+            return True
+
+        # Derivatives are currently not validated.
+        # TODO: raise warning the first time in a session this is encountered
+        if 'derivatives' in self.config:
+            return True
+
+        # BIDS validator expects absolute paths, but really these are relative
+        # to the BIDS project root.
+        to_check = os.path.relpath(f, self.root)
+        to_check = os.path.join(os.path.sep, to_check)
+
+        return self._validator.is_bids(to_check)
+
+    def _get_layouts_in_scope(self, scope):
+        # Determine which BIDSLayouts to search
+        layouts = []
+        scope = listify(scope)
+        if 'all' in scope or 'raw' in scope:
+            layouts.append(self)
+        for deriv in self.derivatives.values():
+            if ('all' in scope or 'derivatives' in scope
+                or deriv.description["PipelineDescription"]['Name'] in scope):
+                layouts.append(deriv)
+        return layouts
+    
+    def __getattr__(self, key):
+        ''' Dynamically inspect missing methods for get_<entity>() calls
+        and return a partial function of get() if a match is found. '''
+        if key.startswith('get_'):
+            ent_name = key.replace('get_', '')
+            # Use inflect to check both singular and plural forms
+            if ent_name not in self.entities:
+                sing = inflect.engine().singular_noun(ent_name)
+                if sing in self.entities:
+                    ent_name = sing
+                else:
+                    raise AttributeError(
+                        "'get_{}' can't be called because '{}' isn't a "
+                        "recognized entity name.".format(ent_name, ent_name))
+            return partial(self.get, return_type='id', target=ent_name)
+        # Spit out default message if we get this far
+        raise AttributeError("%s object has no attribute named %r" %
+                             (self.__class__.__name__, key))
+
+    def __repr__(self):
+        # A tidy summary of key properties
+        n_sessions = len([session for isub in self.get_subjects()
+                          for session in self.get_sessions(subject=isub)])
+        n_runs = len([run for isub in self.get_subjects()
+                      for run in self.get_runs(subject=isub)])
+        n_subjects = len(self.get_subjects())
+        root = self.root[-30:]
+        s = ("BIDS Layout: ...{} | Subjects: {} | Sessions: {} | "
+             "Runs: {}".format(root, n_subjects, n_sessions, n_runs))
+        return s
+
+    def clone(self):
+        """ Return a deep copy of the current BIDSLayout. """
+        return copy.deepcopy(self)
+
+    def parse_file_entities(self, filename, scope='all', entities=None,
+                            config=None, include_unmatched=False):
+        ''' Parse the passed filename for entity/value pairs.
+
+        Args:
+            filename (str): The filename to parse for entity values
+            scope (str, list): The scope of the search space. Indicates which
+                BIDSLayouts' entities to extract. See BIDSLayout docstring
+                for valid values. By default, extracts all entities
+            entities (list): An optional list of Entity instances to use in
+                extraction. If passed, the scope and config arguments are
+                ignored, and only the Entities in this list are used.
+            config (str, Config, list): One or more Config objects, or paths
+                to JSON config files on disk, containing the Entity definitions
+                to use in extraction. If passed, scope is ignored.
+            include_unmatched (bool): If True, unmatched entities are included
+                in the returned dict, with values set to None. If False
+                (default), unmatched entities are ignored.
+
+        Returns: A dict, where keys are Entity names and values are the
+            values extracted from the filename.
+        '''
+
+        # If either entities or config is specified, just pass through
+        if entities is None and config is None:
+            layouts = self._get_layouts_in_scope(scope)
+            config = chain(*[list(l.config.values()) for l in layouts])
+            config = list(set(config))
+
+        return parse_file_entities(filename, entities, config,
+                                   include_unmatched)
 
     def add_derivatives(self, path, **kwargs):
         ''' Add BIDS-Derivatives datasets to tracking.
@@ -230,13 +380,12 @@ class BIDSLayout(Layout):
                         if check_for_description(sd):
                             deriv_dirs.append(sd)
 
-        local_entities = set(ent.name for ent in self.entities.values())
         for deriv in deriv_dirs:
             dd = os.path.join(deriv, 'dataset_description.json')
             with open(dd, 'r', encoding='utf-8') as ddfd:
                 description = json.load(ddfd)
             pipeline_name = description.get(
-                'PipelineDescription', {}).get('Name', None)
+                'PipelineDescription', {}).get('Name')
             if pipeline_name is None:
                 raise ValueError("Every valid BIDS-derivatives dataset must "
                                  "have a PipelineDescription.Name field set "
@@ -250,21 +399,14 @@ class BIDSLayout(Layout):
             kwargs['sources'] = kwargs.get('sources') or self
             self.derivatives[pipeline_name] = BIDSLayout(deriv, **kwargs)
 
-            # Propagate derivative entities into top-level dynamic getters
-            deriv_entities = set(
-                ent.name
-                for ent in self.derivatives[pipeline_name].entities.values())
-            for deriv_ent in deriv_entities - local_entities:
-                local_entities.add(deriv_ent)
-                getter = 'get_' + inflect.engine().plural(deriv_ent)
-                if not hasattr(self, getter):
-                    func = partial(
-                        self.get, target=deriv_ent, return_type='id')
-                    setattr(self, getter, func)
+        # Consolidate all entities post-indexing. Note: no conflicts occur b/c
+        # multiple entries with the same name all point to the same instance.
+        for deriv in self.derivatives.values():
+            self.entities.update(deriv.entities)
 
     def to_df(self, **kwargs):
         """
-        Return information for all Files tracked in the Layout as a pandas
+        Return information for all BIDSFiles tracked in the Layout as a pandas
         DataFrame.
 
         Args:
@@ -275,72 +417,19 @@ class BIDSLayout(Layout):
                 a tracked entity. NaNs are injected whenever a file has no
                 value for a given attribute.
         """
-        return self.as_data_frame(**kwargs)
-
-    def __repr__(self):
-        n_sessions = len([session for isub in self.get_subjects()
-                          for session in self.get_sessions(subject=isub)])
-        n_runs = len([run for isub in self.get_subjects()
-                      for run in self.get_runs(subject=isub)])
-        n_subjects = len(self.get_subjects())
-        root = self.root[-30:]
-        s = ("BIDS Layout: ...{} | Subjects: {} | Sessions: {} | "
-             "Runs: {}".format(root, n_subjects, n_sessions, n_runs))
-        return s
-
-    def _validate_dir(self, d):
-        # Callback from grabbit. Exclude special directories like derivatives/
-        # and code/ from indexing unless they were explicitly included at
-        # initialization.
-        no_root = os.path.relpath(d, self.root).split(os.path.sep)[0]
-        if no_root in self._exclude_dirs:
-            check_paths = set(self._paths_to_index) - {self.root}
-            if not any([d.startswith(p) for p in check_paths]):
-                return False
-        return True
-
-    def _validate_file(self, f):
-        # Callback from grabbit. Files are excluded from indexing if validation
-        # is enabled and fails (i.e., file is not a valid BIDS file).
-        if not self.validate:
-            return True
-
-        # Derivatives are currently not validated.
-        if 'derivatives' in self.domains:
-            return True
-
-        # BIDS validator expects absolute paths, but really these are relative
-        # to the BIDS project root.
-        to_check = os.path.relpath(f, self.root)
-        to_check = os.path.join(os.path.sep, to_check)
-
-        return self.validator.is_bids(to_check)
-
-    def _get_nearest_helper(self, path, extension, suffix=None, **kwargs):
-        """ Helper function for grabbit get_nearest """
-        path = os.path.abspath(path)
-
-        if not suffix:
-            f = self.get_file(path)
-            if 'suffix' not in f.entities:
-                raise ValueError(
-                    "File '%s' does not have a valid suffix, most "
-                    "likely because it is not a valid BIDS file." % path
-                )
-            suffix = f.entities['suffix']
-
-        tmp = self.get_nearest(
-            path, extensions=extension, all_=True, suffix=suffix,
-            ignore_strict_entities=['suffix'], **kwargs)
-
-        if len(tmp):
-            return tmp
-        else:
-            return None
+        try:
+            import pandas as pd
+        except ImportError:
+            raise ImportError("What are you doing trying to export a BIDSLayout"
+                              " as a pandas DataFrame when you don't have "
+                              "pandas installed? Eh? Eh?")
+        files = self.get(return_type='obj', **kwargs)
+        data = pd.DataFrame.from_records([f.entities for f in files])
+        data.insert(0, 'path', [f.path for f in files])
+        return data
 
     def get(self, return_type='object', target=None, extensions=None,
-            derivatives=True, regex_search=None, defined_fields=None,
-            domains=None, **kwargs):
+            scope='all', regex_search=False, defined_fields=None, **kwargs):
         """
         Retrieve files and/or metadata from the current Layout.
 
@@ -354,30 +443,28 @@ class BIDSLayout(Layout):
             target (str): Optional name of the target entity to get results for
                 (only used if return_type is 'dir' or 'id').
             extensions (str, list): One or more file extensions to filter on.
-                Files with any other extensions will be excluded.
-            derivatives (bool, str, list): Whether/how to search associated
-                BIDS-Derivatives datasets. If True (default), all available
-                derivatives are searched. If a str or list, must be the name(s)
-                of the derivatives to search (as defined in the
-                PipelineDescription.Name field in dataset_description.json).
+                BIDSFiles with any other extensions will be excluded.
+            scope (str, list): Scope of the search space. If passed, only
+                nodes/directories that match the specified scope will be
+                searched. Possible values include:
+                    'all' (default): search all available directories.
+                    'derivatives': search all derivatives directories
+                    'raw': search only BIDS-Raw directories
+                    <PipelineName>: the name of a BIDS-Derivatives pipeline
             regex_search (bool or None): Whether to require exact matching
                 (False) or regex search (True) when comparing the query string
-                to each entity. If None (default), uses the value found in
-                self.
+                to each entity.
             defined_fields (list): Optional list of names of metadata fields
                 that must be defined in JSON sidecars in order to consider the
                 file a match, but which don't need to match any particular
                 value.
-            domains (str, list): Domain(s) to search in. Valid values are
-                'bids' and 'derivatives'.
             kwargs (dict): Any optional key/values to filter the entities on.
                 Keys are entity names, values are regexes to filter on. For
                 example, passing filter={ 'subject': 'sub-[12]'} would return
                 only files that match the first two subjects.
 
         Returns:
-            A list of BIDSFile (default) or other objects
-            (see return_type for details).
+            A list of BIDSFiles (default) or strings (see return_type).
         """
 
         # Warn users still expecting 0.6 behavior
@@ -385,30 +472,27 @@ class BIDSLayout(Layout):
             raise ValueError("As of pybids 0.7.0, the 'type' argument has been"
                              " replaced with 'suffix'.")
 
-        if derivatives is True:
-            derivatives = list(self.derivatives.keys())
-        elif derivatives:
-            derivatives = listify(derivatives)
+        layouts = self._get_layouts_in_scope(scope)
+        
+        # Create concatenated file, node, and entity lists
+        files, entities, nodes = {}, {}, []
+        for l in layouts:
+            files.update(l.files)
+            entities.update(l.entities)
+            nodes.extend(l.nodes)
 
         # Separate entity kwargs from metadata kwargs
         ent_kwargs, md_kwargs = {}, {}
-
-        all_ents = self.get_domain_entities()
-        if derivatives:
-            for deriv in derivatives:
-                deriv_ents = self.derivatives[deriv].get_domain_entities()
-                all_ents.update(deriv_ents)
-
         for k, v in kwargs.items():
-            if k in all_ents:
+            if k in entities:
                 ent_kwargs[k] = v
             else:
                 md_kwargs[k] = v
 
         # Provide some suggestions if target is specified and invalid.
-        if target is not None and target not in all_ents:
+        if target is not None and target not in entities:
             import difflib
-            potential = list(all_ents.keys())
+            potential = list(entities.keys())
             suggestions = difflib.get_close_matches(target, potential)
             if suggestions:
                 message = "Did you mean one of: {}?".format(suggestions)
@@ -417,44 +501,116 @@ class BIDSLayout(Layout):
             raise ValueError(("Unknown target '{}'. " + message)
                              .format(target))
 
-        all_results = []
+        results = []
 
-        # Get entity-based search results using the superclass's get()
-        result = []
-        result = super(
-            BIDSLayout, self).get(return_type, target=target,
-                                  extensions=extensions, domains=None,
-                                  regex_search=regex_search, **ent_kwargs)
+        # Search on entities
+        filters = ent_kwargs.copy()
 
-        # Search the metadata if needed
+        for f in files.values():
+            if f._matches(filters, extensions, regex_search):
+                results.append(f)
+
+        # Search on metadata
         if return_type not in {'dir', 'id'}:
 
             if md_kwargs:
-                if return_type.startswith('obj'):
-                    result = [f.path for f in result]
-
-                result = self.metadata_index.search(result, defined_fields,
+                results = [f.path for f in results]
+                results = self.metadata_index.search(results, defined_fields,
                                                     **md_kwargs)
+                results = [files[f] for f in results]
+
+        # Convert to relative paths if needed
+        if not self.absolute_paths:
+            for i, f in enumerate(results):
+                f = copy.copy(f)
+                f.path = os.path.relpath(f.path, self.root)
+                results[i] = f
+
+        if return_type == 'file':
+            results = natural_sort([f.path for f in results])
+
+        elif return_type in ['id', 'dir']:
+            if target is None:
+                raise ValueError('If return_type is "id" or "dir", a valid '
+                                 'target entity must also be specified.')
+            results = [x for x in results if target in x.entities]
+
+            if return_type == 'id':
+                results = list(set([x.entities[target] for x in results]))
+                results = natural_sort(results)
+
+            elif return_type == 'dir':
+                template = entities[target].directory
+                if template is None:
+                    raise ValueError('Return type set to directory, but no '
+                                     'directory template is defined for the '
+                                     'target entity (\"%s\").' % target)
+                # Construct regex search pattern from target directory template
+                template = template.replace('{{root}}', self.root)
+                to_rep = re.findall(r'\{(.*?)\}', template)
+                for ent in to_rep:
+                    patt = entities[ent].pattern
+                    template = template.replace('{%s}' % ent, patt)
+                template += r'[^\%s]*$' % os.path.sep
+                matches = [f.dirname for f in results
+                           if re.search(template, f.dirname)]
+                results = natural_sort(list(set(matches)))
+
+            else:
+                raise ValueError("Invalid return_type specified (must be one "
+                                 "of 'tuple', 'file', 'id', or 'dir'.")
 
-                if return_type.startswith('obj'):
-                    result = [self.files[f] for f in result]
+        return results
 
-        all_results.append(result)
+    def get_file(self, filename, scope='all'):
+        ''' Returns the BIDSFile object with the specified path.
 
-        # Add results from derivatives
-        if derivatives:
-            for deriv in derivatives:
-                deriv = self.derivatives[deriv]
-                deriv_res = deriv.get(return_type, target, extensions, None,
-                                      regex_search, **ent_kwargs)
-                all_results.append(deriv_res)
+        Args:
+            filename (str): The path of the file to retrieve. Must be either
+                an absolute path, or relative to the root of this BIDSLayout.
+            scope (str, list): Scope of the search space. If passed, only
+                BIDSLayouts that match the specified scope will be
+                searched. See BIDSLayout docstring for valid values.
 
-        # Flatten results
-        result = list(chain(*all_results))
-        if return_type in ['dir', 'id']:
-            result = list(set(result))
+        Returns: A BIDSFile, or None if no match was found.
+        '''
+        filename = os.path.abspath(os.path.join(self.root, filename))
+        layouts = self._get_layouts_in_scope(scope)
+        for ly in layouts:
+            if filename in ly.files:
+                return ly.files[filename]
+        return None
+
+    def get_collections(self, level, types=None, variables=None, merge=False,
+                        sampling_rate=None, skip_empty=False, **kwargs):
+        """Return one or more variable Collections in the BIDS project.
 
-        return result
+        Args:
+            level (str): The level of analysis to return variables for. Must be
+                one of 'run', 'session', 'subject', or 'dataset'.
+            types (str, list): Types of variables to retrieve. All valid values
+            reflect the filename stipulated in the BIDS spec for each kind of
+            variable. Valid values include: 'events', 'physio', 'stim',
+            'scans', 'participants', 'sessions', and 'regressors'.
+            variables (list): Optional list of variables names to return. If
+                None, all available variables are returned.
+            merge (bool): If True, variables are merged across all observations
+                of the current level. E.g., if level='subject', variables from
+                all subjects will be merged into a single collection. If False,
+                each observation is handled separately, and the result is
+                returned as a list.
+            sampling_rate (int, str): If level='run', the sampling rate to
+                pass onto the returned BIDSRunVariableCollection.
+            skip_empty (bool): Whether or not to skip empty Variables (i.e.,
+                where there are no rows/records in a file after applying any
+                filtering operations like dropping NaNs).
+            kwargs: Optional additional arguments to pass onto load_variables.
+        """
+        from bids.variables import load_variables
+        index = load_variables(self, types=types, levels=level,
+                               skip_empty=skip_empty, **kwargs)
+        return index.get_collections(level, variables, merge,
+                                     sampling_rate=sampling_rate)
 
     def get_metadata(self, path, include_entities=False, **kwargs):
         """Return metadata found in JSON sidecars for the specified file.
@@ -478,11 +634,12 @@ class BIDSLayout(Layout):
 
         """
 
+        f = self.get_file(path)
+
         # For querying efficiency, store metadata in the MetadataIndex cache
-        self.metadata_index.index_file(path)
+        self.metadata_index.index_file(f.path)
 
         if include_entities:
-            f = self.get_file(os.path.abspath(path))
             entities = f.entities
             results = entities
         else:
@@ -491,24 +648,122 @@ class BIDSLayout(Layout):
         results.update(self.metadata_index.file_index[path])
         return results
 
+    def get_nearest(self, path, return_type='file', strict=True, all_=False,
+                    ignore_strict_entities=None, full_search=False, **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)
+                or 'tuple'.
+            strict (bool): When True, all entities present in both the input
+                path and the target file(s) must match perfectly. When False,
+                files will be ordered by the number of matching entities, and
+                partial matches will be allowed.
+            all_ (bool): When True, returns all matching files. When False
+                (default), only returns the first match.
+            ignore_strict_entities (list): Optional list of entities to
+                exclude from strict matching when strict is True. This allows
+                one to search, e.g., for files of a different type while
+                matching all other entities perfectly by passing
+                ignore_strict_entities=['type'].
+            full_search (bool): If True, searches all indexed files, even if
+                they don't share a common root with the provided path. If
+                False, only files that share a common root will be scanned.
+            kwargs: Optional keywords to pass on to .get().
+        '''
+
+        path = os.path.abspath(path)
+
+        # Make sure we have a valid suffix
+        suffix = kwargs.get('suffix')
+        if not suffix:
+            f = self.get_file(path)
+            if 'suffix' not in f.entities:
+                raise ValueError(
+                    "File '%s' does not have a valid suffix, most "
+                    "likely because it is not a valid BIDS file." % path
+                )
+            suffix = f.entities['suffix']
+        kwargs['suffix'] = suffix
+
+        # Collect matches for all entities
+        entities = {}
+        for ent in self.entities.values():
+            m = ent.regex.search(path)
+            if m:
+                entities[ent.name] = ent._astype(m.group(1))
+
+        # Remove any entities we want to ignore when strict matching is on
+        if strict and ignore_strict_entities is not None:
+            for k in ignore_strict_entities:
+                entities.pop(k, None)
+
+        results = self.get(return_type='object', **kwargs)
+
+        # Make a dictionary of directories --> contained files
+        folders = defaultdict(list)
+        for f in results:
+            folders[f.dirname].append(f)
+
+        # Build list of candidate directories to check
+        search_paths = []
+        while True:
+            if path in folders and folders[path]:
+                search_paths.append(path)
+            parent = os.path.dirname(path)
+            if parent == path:
+                break
+            path = parent
+
+        if full_search:
+            unchecked = set(folders.keys()) - set(search_paths)
+            search_paths.extend(path for path in unchecked if folders[path])
+
+        def count_matches(f):
+            # Count the number of entities shared with the passed file
+            f_ents = f.entities
+            keys = set(entities.keys()) & set(f_ents.keys())
+            shared = len(keys)
+            return [shared, sum([entities[k] == f_ents[k] for k in keys])]
+
+        matches = []
+
+        for path in search_paths:
+            # Sort by number of matching entities. Also store number of
+            # common entities, for filtering when strict=True.
+            num_ents = [[f] + count_matches(f) for f in folders[path]]
+            # Filter out imperfect matches (i.e., where number of common
+            # entities does not equal number of matching entities).
+            if strict:
+                num_ents = [f for f in num_ents if f[1] == f[2]]
+            num_ents.sort(key=lambda x: x[2], reverse=True)
+
+            if num_ents:
+                for f_match in num_ents:
+                    matches.append(f_match[0])
+
+            if not all_:
+                break
+
+        matches = [m.path if return_type == 'file' else m for m in matches]
+        return matches if all_ else matches[0] if matches else None
+
     def get_bvec(self, path, **kwargs):
-        """Get bvec file for passed path."""
-        tmp = self._get_nearest_helper(path, 'bvec', suffix='dwi', **kwargs)[0]
-        if isinstance(tmp, list):
-            return tmp[0]
-        else:
-            return tmp
+        """ Get bvec file for passed path. """
+        result = self.get_nearest(path, extensions='bvec', suffix='dwi',
+                                  all_=True, **kwargs)
+        return listify(result)[0]
 
     def get_bval(self, path, **kwargs):
-        """Get bval file for passed path."""
-        tmp = self._get_nearest_helper(path, 'bval', suffix='dwi', **kwargs)[0]
-        if isinstance(tmp, list):
-            return tmp[0]
-        else:
-            return tmp
+        """ Get bval file for passed path. """
+        result = self.get_nearest(path, extensions='bval', suffix='dwi',
+                                  all_=True, **kwargs)
+        return listify(result)[0]
 
     def get_fieldmap(self, path, return_list=False):
-        """Get fieldmap(s) for specified path."""
+        """ Get fieldmap(s) for specified path. """
         fieldmaps = self._get_fieldmaps(path)
 
         if return_list:
@@ -527,10 +782,10 @@ class BIDSLayout(Layout):
                 return None
 
     def _get_fieldmaps(self, path):
-        sub = os.path.split(path)[1].split("_")[0].split("sub-")[1]
+        sub = self.parse_file_entities(path)['subject']
         fieldmap_set = []
         suffix = '(phase1|phasediff|epi|fieldmap)'
-        files = self.get(subject=sub, suffix=suffix,
+        files = self.get(subject=sub, suffix=suffix, regex_search=True,
                          extensions=['nii.gz', 'nii'])
         for file in files:
             metadata = self.get_metadata(file.path)
@@ -581,13 +836,13 @@ class BIDSLayout(Layout):
         Notes: Raises an exception if more than one unique TR is found.
         """
         # Constrain search to functional images
-        selectors['suffix'] = 'bold'
-        selectors['datatype'] = 'func'
-        images = self.get(extensions=['.nii', '.nii.gz'], derivatives=derivatives,
+        selectors.update(suffix='bold', datatype='func')
+        scope = None if derivatives else 'raw'
+        images = self.get(extensions=['.nii', '.nii.gz'], scope=scope,
                           **selectors)
         if not images:
             raise ValueError("No functional images that match criteria found.")
-        
+
         all_trs = set()
         for img in images:
             md = self.get_metadata(img.path, suffix='bold', full_search=True)
@@ -598,62 +853,132 @@ class BIDSLayout(Layout):
                              .format(selectors))
         return all_trs.pop()
 
-    def get_collections(self, level, types=None, variables=None, merge=False,
-                        sampling_rate=None, skip_empty=False, **kwargs):
-        """Return one or more variable Collections in the BIDS project.
+    def build_path(self, source, path_patterns=None, strict=False, scope='all'):
+        ''' Constructs a target filename for a file or dictionary of entities.
 
         Args:
-            level (str): The level of analysis to return variables for. Must be
-                one of 'run', 'session', 'subject', or 'dataset'.
-            types (str, list): Types of variables to retrieve. All valid values
-            reflect the filename stipulated in the BIDS spec for each kind of
-            variable. Valid values include: 'events', 'physio', 'stim',
-            'scans', 'participants', 'sessions', and 'regressors'.
-            variables (list): Optional list of variables names to return. If
-                None, all available variables are returned.
-            merge (bool): If True, variables are merged across all observations
-                of the current level. E.g., if level='subject', variables from
-                all subjects will be merged into a single collection. If False,
-                each observation is handled separately, and the result is
-                returned as a list.
-            sampling_rate (int, str): If level='run', the sampling rate to
-                pass onto the returned BIDSRunVariableCollection.
-            skip_empty (bool): Whether or not to skip empty Variables (i.e.,
-                where there are no rows/records in a file after applying any
-                filtering operations like dropping NaNs).
-            kwargs: Optional additional arguments to pass onto load_variables.
-        """
-        from bids.variables import load_variables
-        index = load_variables(self, types=types, levels=level,
-                               skip_empty=skip_empty, **kwargs)
-        return index.get_collections(level, variables, merge,
-                                     sampling_rate=sampling_rate)
+            source (str, BIDSFile, dict): The source data to use to construct
+                the new file path. Must be one of:
+                - A BIDSFile object
+                - A string giving the path of a BIDSFile contained within the
+                  current Layout.
+                - A dict of entities, with entity names in keys and values in
+                  values
+            path_patterns (list): Optional path patterns to use to construct
+                the new file path. If None, the Layout-defined patterns will
+                be used.
+            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.
+            scope (str, list): The scope of the search space. Indicates which
+                BIDSLayouts' path patterns to use. See BIDSLayout docstring
+                for valid values. By default, uses all available layouts. If
+                two or more values are provided, the order determines the
+                precedence of path patterns (i.e., earlier layouts will have
+                higher precedence).
+        '''
 
-    def _make_file_object(self, root, f):
-        # Override grabbit's File with a BIDSFile.
-        return BIDSFile(os.path.join(root, f), self)
+        # 'is_file' is a crude check for Path objects
+        if isinstance(source, six.string_types) or hasattr(source, 'is_file'):
+            source = str(source)
+            if source not in self.files:
+                source = os.path.join(self.root, source)
 
-    def get_file(self, filename, derivatives=True):
-        ''' Returns the BIDSFile object with the specified path.
+            source = self.get_file(source)
+
+        if isinstance(source, BIDSFile):
+            source = source.entities
+
+        if path_patterns is None:
+            layouts = self._get_layouts_in_scope(scope)
+            path_patterns = []
+            seen_configs = set()
+            for l in layouts:
+                for c in l.config.values():
+                    if c in seen_configs:
+                        continue
+                    path_patterns.extend(c.default_path_patterns)
+                    seen_configs.add(c)
+
+        return build_path(source, path_patterns, strict)
+
+    def copy_files(self, files=None, path_patterns=None, symbolic_links=True,
+                   root=None, conflicts='fail', **kwargs):
+        """
+        Copies one or more BIDSFiles to new locations defined by each
+        BIDSFile's entities and the specified path_patterns.
 
         Args:
-            filename (str): The path of the file to retrieve.
-            derivatives (bool: If True, checks all associated derivative
-                datasets as well.
+            files (list): Optional list of BIDSFile objects to write out. If
+                none provided, use files from running a get() query using
+                remaining **kwargs.
+            path_patterns (str, list): Write patterns to pass to each file's
+                write_file method.
+            symbolic_links (bool): Whether to copy each file as a symbolic link
+                or a deep copy.
+            root (str): Optional root directory that all patterns are relative
+                to. Defaults to current working directory.
+            conflicts (str):  Defines the desired action when the output path
+                already exists. Must be one of:
+                    'fail': raises an exception
+                    'skip' does nothing
+                    'overwrite': overwrites the existing file
+                    'append': adds  a suffix to each file copy, starting with 1
+            kwargs (kwargs): Optional key word arguments to pass into a get()
+                query.
+        """
+        _files = self.get(return_type='objects', **kwargs)
+        if files:
+            _files = list(set(files).intersection(_files))
+
+        for f in _files:
+            f.copy(path_patterns, symbolic_link=symbolic_links,
+                   root=self.root, conflicts=conflicts)
+
+    def write_contents_to_file(self, entities, path_patterns=None,
+                               contents=None, link_to=None,
+                               content_mode='text', conflicts='fail',
+                               strict=False):
+        """
+        Write arbitrary data to a file defined by the passed entities and
+        path patterns.
 
-        Returns: A BIDSFile.
-        '''
-        layouts = [self]
-        if derivatives:
-            layouts += self.derivatives.values()
-        for ly in layouts:
-            if filename in ly.files:
-                return ly.files[filename]
-        return None
+        Args:
+            entities (dict): A dictionary of entities, with Entity names in
+                keys and values for the desired file in values.
+            path_patterns (list): Optional path patterns to use when building
+                the filename. If None, the Layout-defined patterns will be
+                used.
+            contents (object): Contents to write to the generate file path.
+                Can be any object serializable as text or binary data (as
+                defined in the content_mode argument).
+            link_to (str): Optional path with which to create a symbolic link
+                to. Used as an alternative to and takes priority over the
+                contents argument.
+            conflicts (str):  Defines the desired action when the output path
+                already exists. Must be one of:
+                    'fail': raises an exception
+                    'skip' does nothing
+                    'overwrite': overwrites the existing file
+                    'append': adds  a suffix to each file copy, starting with 1
+            strict (bool): If True, all entities must be matched inside a
+                pattern in order to be a valid match. If False, extra entities
+        """
+        path = self.build_path(entities, path_patterns, strict)
+
+        if path is None:
+            raise ValueError("Cannot construct any valid filename for "
+                             "the passed entities given available path "
+                             "patterns.")
+
+        write_contents_to_file(path, contents=contents, link_to=link_to,
+                               content_mode=content_mode, conflicts=conflicts,
+                               root=self.root)
 
 
 class MetadataIndex(object):
     """A simple dict-based index for key/value pairs in JSON metadata.
+
     Args:
         layout (BIDSLayout): The BIDSLayout instance to index.
     """
@@ -689,8 +1014,10 @@ class MetadataIndex(object):
             self.file_index[f.path][md_key] = md_val
 
     def _get_metadata(self, path, **kwargs):
-        potential_jsons = self.layout._get_nearest_helper(path, '.json',
-                                                          **kwargs)
+        potential_jsons = listify(self.layout.get_nearest(
+                                  path, extensions='.json', all_=True,
+                                  ignore_strict_entities=['suffix'],
+                                  **kwargs))
 
         if potential_jsons is None:
             return {}
diff --git a/bids/layout/writing.py b/bids/layout/writing.py
new file mode 100644
index 00000000..edc9a77c
--- /dev/null
+++ b/bids/layout/writing.py
@@ -0,0 +1,177 @@
+'''
+Contains helper functions that involve writing operations.
+'''
+
+import logging
+import os
+import re
+import sys
+from ..utils import splitext, listify
+from os.path import join, dirname, exists, islink, isabs, isdir
+
+
+__all__ = ['replace_entities', 'build_path', 'write_contents_to_file']
+
+
+def replace_entities(entities, pattern):
+    """
+    Replaces all entity names in a given pattern with the corresponding
+    values provided by entities.
+
+    Args:
+        entities (dict): A dictionary mapping entity names to entity values.
+        pattern (str): A path pattern that contains entity names denoted
+            by curly braces. Optional portions denoted by square braces.
+            For example: 'sub-{subject}/[var-{name}/]{id}.csv'
+            Accepted entity values, using regex matching, denoted within angle
+            brackets.
+            For example: 'sub-{subject<01|02>}/{task}.csv'
+
+    Returns:
+        A new string with the entity values inserted where entity names
+        were denoted in the provided pattern.
+    """
+    ents = re.findall(r'\{(.*?)\}', pattern)
+    new_path = pattern
+    for ent in ents:
+        match = re.search(r'([^|<]+)(<.*?>)?(\|.*)?', ent)
+        if match is None:
+            return None
+        name, valid, default = match.groups()
+        default = default[1:] if default is not None else default
+
+        if name in entities and valid is not None:
+            ent_val = str(entities[name])
+            if not re.match(valid[1:-1], ent_val):
+                if default is None:
+                    return None
+                entities[name] = default
+
+        ent_val = entities.get(name, default)
+        if ent_val is None:
+            return None
+        new_path = new_path.replace('{%s}' % ent, str(ent_val))
+
+    return new_path
+
+
+def build_path(entities, path_patterns, strict=False):
+    """
+    Constructs a path given a set of entities and a list of potential
+    filename patterns to use.
+
+    Args:
+        entities (dict): A dictionary mapping entity names to entity values.
+        path_patterns (str, list): One or more filename patterns to write
+            the file to. Entities should be represented by the name
+            surrounded by curly braces. Optional portions of the patterns
+            should be denoted by square brackets. Entities that require a
+            specific value for the pattern to match can pass them inside
+            carets. Default values can be assigned by specifying a string after
+            the pipe operator. E.g., (e.g., {type<image>|bold} would only match
+            the pattern if the entity 'type' was passed and its value is
+            "image", otherwise the default value "bold" will be used).
+                Example 1: 'sub-{subject}/[var-{name}/]{id}.csv'
+                Result 2: 'sub-01/var-SES/1045.csv'
+        strict (bool): If True, all passed 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.
+
+    Returns:
+        A constructed path for this file based on the provided patterns.
+    """
+    path_patterns = listify(path_patterns)
+
+    # Loop over available patherns, return first one that matches all
+    for pattern in path_patterns:
+        # If strict, all entities must be contained in the pattern
+        if strict:
+            defined = re.findall(r'\{(.*?)(?:<[^>]+>)?\}', pattern)
+            if set(entities.keys()) - set(defined):
+                continue
+        # Iterate through the provided path patterns
+        new_path = pattern
+        optional_patterns = re.findall(r'\[(.*?)\]', pattern)
+        # First build from optional patterns if possible
+        for optional_pattern in optional_patterns:
+            optional_chunk = replace_entities(entities, optional_pattern) or ''
+            new_path = new_path.replace('[%s]' % optional_pattern,
+                                        optional_chunk)
+        # Replace remaining entities
+        new_path = replace_entities(entities, new_path)
+
+        if new_path:
+            return new_path
+
+    return None
+
+
+def write_contents_to_file(path, contents=None, link_to=None,
+                           content_mode='text', root=None, conflicts='fail'):
+    """
+    Uses provided filename patterns to write contents to a new path, given
+    a corresponding entity map.
+
+    Args:
+        path (str): Destination path of the desired contents.
+        contents (str): Raw text or binary encoded string of contents to write
+            to the new path.
+        link_to (str): Optional path with which to create a symbolic link to.
+            Used as an alternative to and takes priority over the contents
+            argument.
+        content_mode (str): Either 'text' or 'binary' to indicate the writing
+            mode for the new file. Only relevant if contents is provided.
+        root (str): Optional root directory that all patterns are relative
+            to. Defaults to current working directory.
+        conflicts (str): One of 'fail', 'skip', 'overwrite', or 'append'
+            that defines the desired action when the output path already
+            exists. 'fail' raises an exception; 'skip' does nothing;
+            'overwrite' overwrites the existing file; 'append' adds  a suffix
+            to each file copy, starting with 1. Default is 'fail'.
+    """
+
+    if root is None and not isabs(path):
+        root = os.getcwd()
+
+    if root:
+        path = join(root, path)
+
+    if exists(path) or islink(path):
+        if conflicts == 'fail':
+            msg = 'A file at path {} already exists.'
+            raise ValueError(msg.format(path))
+        elif conflicts == 'skip':
+            msg = 'A file at path {} already exists, skipping writing file.'
+            logging.warn(msg.format(path))
+            return
+        elif conflicts == 'overwrite':
+            if isdir(path):
+                logging.warn('New path is a directory, not going to '
+                             'overwrite it, skipping instead.')
+                return
+            os.remove(path)
+        elif conflicts == 'append':
+            i = 1
+            while i < sys.maxsize:
+                path_splits = splitext(path)
+                path_splits[0] = path_splits[0] + '_%d' % i
+                appended_filename = os.extsep.join(path_splits)
+                if not exists(appended_filename) and \
+                   not islink(appended_filename):
+                    path = appended_filename
+                    break
+                i += 1
+        else:
+            raise ValueError('Did not provide a valid conflicts parameter')
+
+    if not exists(dirname(path)):
+        os.makedirs(dirname(path))
+
+    if link_to:
+        os.symlink(link_to, path)
+    elif contents:
+        mode = 'wb' if content_mode == 'binary' else 'w'
+        with open(path, mode) as f:
+            f.write(contents)
+    else:
+        raise ValueError('One of contents or link_to must be provided.')
diff --git a/bids/utils.py b/bids/utils.py
index 126df722..55a2b043 100644
--- a/bids/utils.py
+++ b/bids/utils.py
@@ -1,4 +1,7 @@
 import re
+import os
+from .external import six
+
 
 def listify(obj):
     ''' Wraps all non-list or tuple objects in a list; provides a simple way
@@ -22,6 +25,22 @@ def matches_entities(obj, entities, strict=False):
             return False
     return True
 
+
+def natural_sort(l, field=None):
+    '''
+    based on snippet found at http://stackoverflow.com/a/4836734/2445984
+    '''
+    convert = lambda text: int(text) if text.isdigit() else text.lower()
+
+    def alphanum_key(key):
+        if field is not None:
+            key = getattr(key, field)
+        if not isinstance(key, str):
+            key = str(key)
+        return [convert(c) for c in re.split('([0-9]+)', key)]
+    return sorted(l, key=alphanum_key)
+
+
 def convert_JSON(j):
     """ Recursively convert CamelCase keys to snake_case.
     From: https://stackoverflow.com/questions/17156078/converting-identifier-naming-between-camelcase-and-underscores-during-json-seria
@@ -54,3 +73,28 @@ def convert_JSON(j):
             out[newK] = value
 
     return out
+
+
+def splitext(path):
+    """splitext for paths with directories that may contain dots.
+    From https://stackoverflow.com/questions/5930036/separating-file-extensions-using-python-os-path-module"""
+    li = []
+    path_without_extensions = os.path.join(os.path.dirname(path),
+        os.path.basename(path).split(os.extsep)[0])
+    extensions = os.path.basename(path).split(os.extsep)[1:]
+    li.append(path_without_extensions)
+    # li.append(extensions) if you want extensions in another list inside the list that is returned.
+    li.extend(extensions)
+    return li
+
+
+def check_path_matches_patterns(path, patterns):
+    ''' Check if the path matches at least one of the provided patterns. '''
+    path = os.path.abspath(path)
+    for patt in patterns:
+        if isinstance(patt, six.string_types):
+            if path == patt:
+                return True
+        elif patt.search(path):
+            return True
+    return False
diff --git a/bids/variables/io.py b/bids/variables/io.py
index 288262f6..b73cc68f 100644
--- a/bids/variables/io.py
+++ b/bids/variables/io.py
@@ -12,7 +12,7 @@ ALL_ENTITIES = BASE_ENTITIES + ['datatype', 'suffix', 'acquisition']
 
 
 def load_variables(layout, types=None, levels=None, skip_empty=True,
-                   dataset=None, **kwargs):
+                   dataset=None, scope='all', **kwargs):
     ''' A convenience wrapper for one or more load_*_variables() calls.
 
     Args:
@@ -32,6 +32,8 @@ def load_variables(layout, types=None, levels=None, skip_empty=True,
             loaded data in. Can be used to iteratively construct a dataset
             that contains otherwise heterogeneous sets of variables. If None,
             a new NodeIndex is used.
+        scope (str, list): The scope of the space to search for variables. See
+            docstring for BIDSLayout for details and valid predefined values.
         kwargs: Optional keyword arguments to pass onto the individual
             load_*_variables() calls.
 
@@ -73,18 +75,20 @@ def load_variables(layout, types=None, levels=None, skip_empty=True,
     if len(type_flags) < 4:
         _kwargs = kwargs.copy()
         _kwargs.update(type_flags)
-        dataset = _load_time_variables(layout, dataset, **_kwargs)
+        dataset = _load_time_variables(layout, dataset, scope=scope, **_kwargs)
 
     for t in ({'scans', 'sessions', 'participants'} & set(types)):
         kwargs.pop('suffix', None) # suffix is always one of values aboves
-        dataset = _load_tsv_variables(layout, t, dataset, **kwargs)
+        dataset = _load_tsv_variables(layout, t, dataset, scope=scope,
+                                      **kwargs)
 
     return dataset
 
 
 def _load_time_variables(layout, dataset=None, columns=None, scan_length=None,
                          drop_na=True, events=True, physio=True, stim=True,
-                         regressors=True, skip_empty=True, **selectors):
+                         regressors=True, skip_empty=True, scope='all',
+                         **selectors):
     ''' Loads all variables found in *_events.tsv files and returns them as a
     BIDSVariableCollection.
 
@@ -109,6 +113,8 @@ def _load_time_variables(layout, dataset=None, columns=None, scan_length=None,
         skip_empty (bool): Whether or not to skip empty Variables (i.e.,
             where there are no rows/records in a file, or all onsets,
             durations, and amplitudes are 0).
+        scope (str, list): The scope of the space to search for variables. See
+            docstring for BIDSLayout for details and valid predefined values.
         selectors (dict): Optional keyword arguments passed onto the
             BIDSLayout instance's get() method; can be used to constrain
             which data are loaded.
@@ -118,7 +124,6 @@ def _load_time_variables(layout, dataset=None, columns=None, scan_length=None,
 
     # Extract any non-keyword arguments
     kwargs = selectors.copy()
-    domains = kwargs.get('domains', None)
 
     if dataset is None:
         dataset = NodeIndex()
@@ -126,7 +131,7 @@ def _load_time_variables(layout, dataset=None, columns=None, scan_length=None,
     selectors['datatype'] = 'func'
     selectors['suffix'] = 'bold'
     images = layout.get(return_type='object', extensions='.nii.gz',
-                        derivatives=True, **selectors)
+                        scope=scope, **selectors)
 
     if not images:
         raise ValueError("No functional images that match criteria found.")
@@ -141,7 +146,7 @@ def _load_time_variables(layout, dataset=None, columns=None, scan_length=None,
         if 'run' in entities:
             entities['run'] = int(entities['run'])
 
-        tr = layout.get_metadata(img_f, suffix='bold', domains=domains,
+        tr = layout.get_metadata(img_f, suffix='bold', scope=scope,
                                  full_search=True)['RepetitionTime']
 
         # Get duration of run: first try to get it directly from the image
@@ -167,11 +172,12 @@ def _load_time_variables(layout, dataset=None, columns=None, scan_length=None,
 
         # Process event files
         if events:
-            dfs = layout._get_nearest_helper(img_f, '.tsv', suffix='events',
-                                             full_search=True, domains=domains)
+            dfs = layout.get_nearest(img_f, extensions='.tsv', suffix='events',
+                                      all_=True, full_search=True,
+                                      ignore_strict_entities=['suffix'])
             if dfs is not None:
                 for _data in dfs:
-                    _data = pd.read_table(_data, sep='\t')
+                    _data = pd.read_csv(_data, sep='\t')
                     if 'amplitude' in _data.columns:
                         if (_data['amplitude'].astype(int) == 1).all() and \
                                 'trial_type' in _data.columns:
@@ -181,8 +187,7 @@ def _load_time_variables(layout, dataset=None, columns=None, scan_length=None,
                             _data = _data.drop('amplitude', axis=1)
                         else:
                             msg = ("Column name 'amplitude' is reserved; "
-                                   "renaming "
-                                   "it to 'amplitude_'.")
+                                   "renaming it to 'amplitude_'.")
                             _data = _data.rename(
                                 columns={'amplitude': 'amplitude_'})
                         warnings.warn(msg)
@@ -218,7 +223,8 @@ def _load_time_variables(layout, dataset=None, columns=None, scan_length=None,
         if regressors:
             sub_ents = {k: v for k, v in entities.items()
                         if k in BASE_ENTITIES}
-            confound_files = layout.get(suffix='regressors', **sub_ents)
+            confound_files = layout.get(suffix='regressors', scope=scope,
+                                        **sub_ents)
             for cf in confound_files:
                 _data = pd.read_csv(cf.path, sep='\t', na_values='n/a')
                 if columns is not None:
@@ -232,14 +238,17 @@ def _load_time_variables(layout, dataset=None, columns=None, scan_length=None,
                     run.add_variable(var)
 
         # Process recordinging files
-        if physio or stim:
-            rec_types = ['physio'] if physio else []
-            if stim:
-                rec_types.append('stim')
+        rec_types = []
+        if physio:
+            rec_types.append('physio')
+        if stim:
+            rec_types.append('stim')
+
+        if rec_types:
             rec_files = layout.get_nearest(img_f, extensions='.tsv.gz',
                                            all_=True, suffix=rec_types,
                                            ignore_strict_entities=['suffix'],
-                                           full_search=True, domains=domains)
+                                           full_search=True)
             for rf in rec_files:
                 metadata = layout.get_metadata(rf)
                 if not metadata:
@@ -288,7 +297,7 @@ def _load_time_variables(layout, dataset=None, columns=None, scan_length=None,
 
 
 def _load_tsv_variables(layout, suffix, dataset=None, columns=None,
-                        prepend_type=False, **selectors):
+                        prepend_type=False, scope='all', **selectors):
     ''' Reads variables from scans.tsv, sessions.tsv, and participants.tsv.
 
     Args:
@@ -301,6 +310,8 @@ def _load_tsv_variables(layout, suffix, dataset=None, columns=None,
             files to return. If None, all columns are returned.
         prepend_type (bool): If True, variable names are prepended with the
             type name (e.g., 'age' becomes 'participants.age').
+        scope (str, list): The scope of the space to search for variables. See
+            docstring for BIDSLayout for details and valid predefined values.
         selectors (dict): Optional keyword arguments passed onto the
             BIDSLayout instance's get() method; can be used to constrain
             which data are loaded.
@@ -318,12 +329,12 @@ def _load_tsv_variables(layout, suffix, dataset=None, columns=None,
         dataset = NodeIndex()
 
     files = layout.get(extensions='.tsv', return_type='file', suffix=suffix,
-                       **layout_kwargs)
+                       scope=scope, **layout_kwargs)
 
     for f in files:
 
         f = layout.files[f]
-        _data = pd.read_table(f.path, sep='\t')
+        _data = pd.read_csv(f.path, sep='\t')
 
         # Entities can be defined either within the first column of the .tsv
         # file (for entities that vary by row), or from the full file path
diff --git a/bids/version.py b/bids/version.py
index 856a45bd..8a3fd26a 100644
--- a/bids/version.py
+++ b/bids/version.py
@@ -45,7 +45,7 @@ AUTHOR = "PyBIDS developers"
 AUTHOR_EMAIL = "bids-discussion@googlegroups.com"
 PLATFORMS = "OS Independent"
 # No data for now
-REQUIRES = ["grabbit==0.2.6", "six", "num2words", "numpy", "scipy", "pandas>=0.23.0",
+REQUIRES = ["num2words", "numpy", "scipy", "pandas>=0.23.0",
             "nibabel>=2.1", "patsy", "bids-validator"]
 EXTRAS_REQUIRE = {
    # Just to not break compatibility with externals requiring


Test patch:
diff --git a/bids/layout/tests/test_core.py b/bids/layout/tests/test_core.py
new file mode 100644
index 00000000..6ce68529
--- /dev/null
+++ b/bids/layout/tests/test_core.py
@@ -0,0 +1,199 @@
+import pytest
+import os
+import copy
+
+from bids.layout.core import Config, BIDSFile, Entity, BIDSRootNode
+from bids import BIDSLayout
+from bids.tests import get_test_data_path
+
+
+
+DIRNAME = os.path.dirname(__file__)
+
+
+@pytest.fixture
+def sample_bidsfile(tmpdir):
+    testfile = 'sub-03_ses-2_task-rest_acq-fullbrain_run-2_bold.nii.gz'
+    fn = tmpdir.mkdir("tmp").join(testfile)
+    fn.write('###')
+    return BIDSFile(os.path.join(str(fn)))
+
+
+@pytest.fixture(scope='module')
+def subject_entity():
+    return Entity('subject', "[/\\\\]sub-([a-zA-Z0-9]+)", False,
+               "{{root}}{subject}", None, bleargh=True)
+
+
+def test_config_init_bare():
+    config = Config('custom')
+    assert config.name == 'custom'
+    assert config.entities == {}
+    assert config.default_path_patterns is None
+
+
+def test_config_init_with_args():
+    ents = [
+        {
+            "name": "task",
+            "pattern": "[_/\\\\]task-([a-zA-Z0-9]+)"
+        },
+        {
+            "name": "acquisition",
+            "pattern": "[_/\\\\]acq-([a-zA-Z0-9]+)"
+        }
+    ]
+    patterns = ['this_will_never_match_anything', 'and_neither_will_this']
+    config = Config('custom', entities=ents, default_path_patterns=patterns)
+    assert config.name == 'custom'
+    assert {'task', 'acquisition'} == set(config.entities.keys())
+    assert config.default_path_patterns  == patterns
+
+
+def test_config_init_from_class_load_bids():
+    config = Config.load('bids')
+    assert config.name == 'bids'
+    ent_names = ('subject', 'run', 'suffix')
+    assert all([en in config.entities for en in ent_names])
+    assert 'space' not in config.entities
+    first_patt = 'sub-{subject}[/ses-{session}]/anat/sub-{subject}'
+    assert config.default_path_patterns[0].startswith(first_patt)
+
+
+def test_config_init_from_class_load_derivatives():
+    config = Config.load('derivatives')
+    assert config.name == 'derivatives'
+    ent_names = ('space', 'atlas', 'roi')
+    assert all([en in config.entities for en in ent_names])
+    assert 'subject' not in config.entities
+    assert config.default_path_patterns is None
+
+
+def test_entity_init_minimal():
+    e = Entity('avaricious', r'aardvark-(\d+)')
+    assert e.name == 'avaricious'
+    assert e.pattern == r'aardvark-(\d+)'
+    assert not e.mandatory
+    assert e.directory is None
+    assert e.files == {}
+
+
+def test_entity_init_all_args(subject_entity):
+    ent = subject_entity
+    assert ent.name == 'subject'
+    assert ent.pattern == "[/\\\\]sub-([a-zA-Z0-9]+)"
+    assert ent.mandatory == False
+    assert ent.directory == "{{root}}{subject}"
+    assert ent.map_func is None
+    assert ent.kwargs == {'bleargh': True}
+
+
+def test_entity_init_with_bad_dtype():
+    with pytest.raises(ValueError) as exc:
+        ent = Entity('test', dtype='superfloat')
+        msg = exc.value.message
+        assert msg.startswith("Invalid dtype")
+
+
+def test_entity_deepcopy(subject_entity):
+    e = subject_entity
+    clone = copy.deepcopy(subject_entity)
+    for attr in ['name', 'pattern', 'mandatory', 'directory', 'map_func',
+                 'regex', 'kwargs']:
+        assert getattr(e, attr) == getattr(clone, attr)
+    assert e != clone
+
+
+def test_entity_matches(tmpdir):
+    filename = "aardvark-4-reporting-for-duty.txt"
+    tmpdir.mkdir("tmp").join(filename).write("###")
+    f = BIDSFile(os.path.join(str(tmpdir), filename))
+    e = Entity('avaricious', r'aardvark-(\d+)')
+    result = e.match_file(f)
+    assert result == '4'
+
+
+def test_entity_matches_with_map_func(sample_bidsfile):
+    bf = sample_bidsfile
+    e = Entity('test', map_func=lambda x: x.filename.split('-')[1])
+    assert e.match_file(bf) == '03_ses'
+
+
+def test_entity_unique_and_count():
+    e = Entity('prop', r'-(\d+)')
+    e.files = {
+        'test1-10.txt': '10',
+        'test2-7.txt': '7',
+        'test3-7.txt': '7'
+    }
+    assert sorted(e.unique()) == ['10', '7']
+    assert e.count() == 2
+    assert e.count(files=True) == 3
+
+
+def test_entity_add_file():
+    e = Entity('prop', r'-(\d+)')
+    e.add_file('a', '1')
+    assert e.files['a'] == '1'
+
+
+def test_bidsfile_init():
+    path = '/tmp/sub-03_ses-2_task-rest_acq-pfc_run-2_bold.nii.gz'
+    bf = BIDSFile(path)
+    assert bf.path == path
+    assert bf.dirname == os.path.dirname(path)
+    assert bf.filename == os.path.basename(path)
+    assert bf.tags == []
+    assert bf.entities == {}
+    assert bf.parent is None
+
+
+def test_bidsfile_matches(sample_bidsfile):
+    'sub-03_ses-2_task-rest_acq-fullbrain_run-2_bold.nii.gz'
+    bf = sample_bidsfile
+    bf.entities = {
+        'subject': '03',
+        'ses': '2',
+        'task': 'rest',
+        'run': 2,
+        'suffix': 'bold'
+    }
+
+    # Plain Jane matching
+    entities = { 'subject': '03', 'task': 'rest', 'suffix': 'bold'}
+    assert bf._matches(entities)
+    assert bf._matches(entities, regex_search=True)
+
+    # Regex matching
+    regex_ents = { 'subject': r'\d+', 'task': 're.t', 'suffix': '.*ld'}
+    assert not bf._matches(regex_ents)
+    assert bf._matches(regex_ents, regex_search=True)
+
+    # Mis-matching entities
+    bad_ents = entities.copy()
+    bad_ents['suffix'] = 'no match'
+    assert not bf._matches(bad_ents)
+
+    # Missing leading 0s should match regardless of regex status
+    mod_ents = entities.copy()
+    mod_ents['subject'] = 3
+    assert bf._matches(mod_ents)
+    assert bf._matches(mod_ents, regex_search=True)
+
+    # Lists can be passed
+    mod_ents = entities.copy()
+    mod_ents['subject'] = [3, '3']
+    assert bf._matches(mod_ents)
+    assert bf._matches(mod_ents, regex_search=True)
+    mod_ents['subject'] = ['3', 2, r'\d+']
+    assert not bf._matches(mod_ents)
+    assert bf._matches(mod_ents, regex_search=True)
+
+
+def test_bidsfile_image_property():
+    path = os.path.join(get_test_data_path(), 'synthetic', 'sub-01', 'ses-01',
+                        'func', 'sub-01_ses-01_task-nback_run-01_bold.nii.gz')
+    bf = BIDSFile(path)
+    img = bf.image
+    assert img.__class__.__name__ == 'Nifti1Image'
+    assert img.header.get_data_shape() == (64, 64, 64, 64)
diff --git a/bids/layout/tests/test_layout.py b/bids/layout/tests/test_layout.py
index 7f992832..48288ca8 100644
--- a/bids/layout/tests/test_layout.py
+++ b/bids/layout/tests/test_layout.py
@@ -3,9 +3,10 @@ functionality should go in the grabbit package. """
 
 import os
 import pytest
-from bids.layout import BIDSLayout
-from bids.layout.layout import BIDSFile
-from os.path import join, abspath, basename
+import bids
+from bids.layout import BIDSLayout, parse_file_entities, add_config_paths
+from bids.layout.core import BIDSFile, Entity, Config
+from os.path import join, abspath, basename, dirname
 from bids.tests import get_test_data_path
 
 
@@ -50,7 +51,12 @@ def layout_ds005_multi_derivs():
 @pytest.fixture(scope='module')
 def layout_ds005_models():
     data_dir = join(get_test_data_path(), 'ds005')
-    return BIDSLayout(data_dir, validate=False, include=['models/'])
+    return BIDSLayout(data_dir, validate=True, force_index=['models'])
+
+@pytest.fixture(scope='module')
+def layout_synthetic():
+    path = join(get_test_data_path(), 'synthetic')
+    return BIDSLayout(path, derivatives=True)
 
 
 def test_layout_init(layout_7t_trt):
@@ -68,6 +74,43 @@ def test_load_description(layout_7t_trt):
     assert layout_7t_trt.description['BIDSVersion'] == "1.0.0rc3"
 
 
+def test_get_file(layout_ds005_derivs):
+    layout = layout_ds005_derivs
+
+    # relative path in BIDS-Raw
+    orig_file = 'sub-13/func/sub-13_task-mixedgamblestask_run-01_bold.nii.gz'
+    target = os.path.join(*orig_file.split('/'))
+    assert layout.get_file(target)
+    assert layout.get_file(target, scope='raw')
+    assert not layout.get_file(target, scope='derivatives')
+
+    # absolute path in BIDS-Raw
+    target = (layout.root + '/' +  orig_file).split('/')
+    target = os.path.sep + os.path.join(*target)
+    assert layout.get_file(target)
+    assert layout.get_file(target, scope='raw')
+    assert not layout.get_file(target, scope='derivatives')
+
+    # relative path in derivatives pipeline
+    orig_file = 'derivatives/events/sub-01/func/sub-01_task-mixedgamblestask_run-01_desc-extra_events.tsv'
+    target = os.path.join(*orig_file.split('/'))
+    assert layout.get_file(target)
+    assert not layout.get_file(target, scope='raw')
+    assert layout.get_file(target, scope='derivatives')
+
+    # absolute path in derivatives pipeline
+    target = (layout.root + '/' +  orig_file).split('/')
+    target = os.path.sep + os.path.join(*target)
+    assert layout.get_file(target)
+    assert not layout.get_file(target, scope='raw')
+    assert layout.get_file(target, scope='derivatives')
+    assert layout.get_file(target, scope='events')
+
+    # No such file
+    assert not layout.get_file('bleargh')
+    assert not layout.get_file('/absolute/bleargh')
+
+
 def test_get_metadata(layout_7t_trt):
     target = 'sub-03/ses-2/func/sub-03_ses-2_task-' \
              'rest_acq-fullbrain_run-2_bold.nii.gz'
@@ -186,7 +229,16 @@ def test_bids_json(layout_7t_trt):
     assert set(res) == {'1', '2'}
 
 
-def test_include(layout_ds005, layout_ds005_models):
+def test_get_return_type_dir(layout_7t_trt):
+    l = layout_7t_trt
+    res = l.get(target='subject', return_type='dir')
+    for i in range(1, 11):
+        sub_dir = "sub-{:02d}".format(i)
+        assert os.path.join(get_test_data_path(), '7t_trt', sub_dir) in res
+    assert len(res) == 10
+
+
+def test_force_index(layout_ds005, layout_ds005_models):
     target= join(layout_ds005_models.root, 'models',
                 'ds-005_type-test_model.json')
     assert target not in layout_ds005.files
@@ -206,9 +258,8 @@ def test_layout_with_derivs(layout_ds005_derivs):
     event_file = "sub-01_task-mixedgamblestask_run-01_desc-extra_events.tsv"
     deriv_files = [basename(f) for f in list(deriv.files.keys())]
     assert event_file in deriv_files
-    assert 'derivatives.roi' in deriv.entities
-    assert 'bids.roi' not in deriv.entities
-    assert 'bids.subject' in deriv.entities
+    assert 'roi' in deriv.entities
+    assert 'subject' in deriv.entities
 
 
 def test_layout_with_multi_derivs(layout_ds005_multi_derivs):
@@ -221,9 +272,8 @@ def test_layout_with_multi_derivs(layout_ds005_multi_derivs):
     deriv = layout_ds005_multi_derivs.derivatives['dummy']
     assert deriv.files
     assert len(deriv.files) == 4
-    assert 'derivatives.roi' in deriv.entities
-    assert 'bids.roi' not in deriv.entities
-    assert 'bids.subject' in deriv.entities
+    assert 'roi' in deriv.entities
+    assert 'subject' in deriv.entities
     preproc = layout_ds005_multi_derivs.get(desc='preproc')
     assert len(preproc) == 3
 
@@ -234,7 +284,7 @@ def test_query_derivatives(layout_ds005_derivs):
     assert len(result) == 49
     assert 'sub-01_task-mixedgamblestask_run-01_desc-extra_events.tsv' in result
     result = layout_ds005_derivs.get(suffix='events', return_type='object',
-                                     derivatives=False)
+                                     scope='raw')
     assert len(result) == 48
     result = [f.filename for f in result]
     assert 'sub-01_task-mixedgamblestask_run-01_desc-extra_events.tsv' not in result
@@ -291,3 +341,77 @@ def test_get_tr(layout_7t_trt):
     assert tr == 3.0
     tr = layout_7t_trt.get_tr(subject=['01', '02'], acquisition="prefrontal")
     assert tr == 4.0
+
+
+def test_to_df(layout_ds117):
+    df = layout_ds117.to_df()
+    assert df.shape == (115, 11)
+    target = {'datatype', 'fmap', 'run', 'path', 'acquisition', 'scans',
+              'session', 'subject', 'suffix', 'task', 'proc'}
+    assert set(df.columns) == target
+    assert set(df['subject'].dropna().unique()) == {'01', '02', 'emptyroom'}
+
+
+def test_parse_file_entities():
+    filename = '/sub-03_ses-07_run-4_desc-bleargh_sekret.nii.gz'
+
+    # Test with entities taken from bids config
+    target = {'subject': '03', 'session': '07', 'run': 4, 'suffix': 'sekret'}
+    assert target == parse_file_entities(filename, config='bids')
+    config = Config.load('bids')
+    assert target == parse_file_entities(filename, config=[config])
+
+    # Test with entities taken from bids and derivatives config
+    target = {'subject': '03', 'session': '07', 'run': 4, 'suffix': 'sekret',
+              'desc': 'bleargh'}
+    assert target == parse_file_entities(filename)
+    assert target == parse_file_entities(filename, config=['bids', 'derivatives'])
+
+    # Test with list of Entities
+    entities = [
+        Entity('subject', "[/\\\\]sub-([a-zA-Z0-9]+)"),
+        Entity('run', "[_/\\\\]run-0*(\\d+)", dtype=int),
+        Entity('suffix', "[._]*([a-zA-Z0-9]*?)\\.[^/\\\\]+$"),
+        Entity('desc', "desc-([a-zA-Z0-9]+)"),
+    ]
+    # Leave out session to distinguish from previous test target
+    target = {'subject': '03', 'run': 4, 'suffix': 'sekret', 'desc': 'bleargh'}
+    assert target == parse_file_entities(filename, entities=entities)
+
+
+def test_parse_file_entities_from_layout(layout_synthetic):
+    layout = layout_synthetic
+    filename = '/sub-03_ses-07_run-4_desc-bleargh_sekret.nii.gz'
+
+    # Test with entities taken from bids config
+    target = {'subject': '03', 'session': '07', 'run': 4, 'suffix': 'sekret'}
+    assert target == layout.parse_file_entities(filename, config='bids')
+    config = Config.load('bids')
+    assert target == layout.parse_file_entities(filename, config=[config])
+    assert target == layout.parse_file_entities(filename, scope='raw')
+
+    # Test with default scope--i.e., everything
+    target = {'subject': '03', 'session': '07', 'run': 4, 'suffix': 'sekret',
+              'desc': 'bleargh'}
+    assert target == layout.parse_file_entities(filename)
+    # Test with only the fmriprep pipeline (which includes both configs)
+    assert target == layout.parse_file_entities(filename, scope='fmriprep')
+    assert target == layout.parse_file_entities(filename, scope='derivatives')
+
+    # Test with only the derivative config
+    target = {'desc': 'bleargh'}
+    assert target == layout.parse_file_entities(filename, config='derivatives')
+
+
+def test_add_config_paths():
+    bids_dir = dirname(bids.__file__)
+    bids_json = os.path.join(bids_dir, 'layout', 'config', 'bids.json')
+    with pytest.raises(ValueError) as exc:
+        add_config_paths(test_config1='nonexistentpath.json')
+    assert str(exc.value).startswith('Configuration file')
+    with pytest.raises(ValueError) as exc:
+        add_config_paths(bids=bids_json)
+    assert str(exc.value).startswith("Configuration 'bids' already")
+    add_config_paths(dummy=bids_json)
+    config = Config.load('dummy')
+    assert 'subject' in config.entities
\ No newline at end of file
diff --git a/bids/layout/tests/test_path_building.py b/bids/layout/tests/test_path_building.py
index 3ebc1b25..2e53be75 100644
--- a/bids/layout/tests/test_path_building.py
+++ b/bids/layout/tests/test_path_building.py
@@ -9,6 +9,7 @@ def layout():
     data_dir = join(get_test_data_path(), '7t_trt')
     return BIDSLayout(data_dir)
 
+
 def test_bold_construction(layout):
     ents = dict(subject='01', run=1, task='rest', suffix='bold')
     assert layout.build_path(ents) == "sub-01/func/sub-01_task-rest_run-1_bold.nii.gz"
diff --git a/bids/layout/tests/test_writing.py b/bids/layout/tests/test_writing.py
new file mode 100644
index 00000000..52ebc952
--- /dev/null
+++ b/bids/layout/tests/test_writing.py
@@ -0,0 +1,250 @@
+import pytest
+import os
+import shutil
+from os.path import join, exists, islink, dirname
+
+from bids import BIDSLayout
+from bids.layout import BIDSFile
+from bids.layout.writing import build_path
+from bids.tests import get_test_data_path
+
+
+@pytest.fixture
+def writable_file(tmpdir):
+    testfile = 'sub-03_ses-2_task-rest_acq-fullbrain_run-2_bold.nii.gz'
+    fn = tmpdir.mkdir("tmp").join(testfile)
+    fn.write('###')
+    return BIDSFile(os.path.join(str(fn)))
+
+
+@pytest.fixture(scope='module')
+def tmp_bids(tmpdir_factory):
+    tmp_bids = tmpdir_factory.mktemp("tmp_bids")
+    yield tmp_bids
+    shutil.rmtree(str(tmp_bids))
+    # Ugly hack
+    shutil.rmtree(join(get_test_data_path(), '7t_trt', 'sub-Bob'))
+
+
+@pytest.fixture(scope='module')
+def layout(tmp_bids):
+    orig_dir = join(get_test_data_path(), '7t_trt')
+    # return BIDSLayout(data_dir, absolute_paths=False)
+    new_dir = join(str(tmp_bids), 'bids')
+    os.symlink(orig_dir, new_dir)
+    return BIDSLayout(new_dir)
+
+
+class TestWritableFile:
+
+    def test_build_path(self, writable_file):
+        writable_file.entities = {
+            'task': 'rest',
+            'run': 2,
+            'subject': '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')
+        target = join(writable_file.dirname, 'rest/sub-3/run-2.nii.gz')
+        assert build_path(writable_file.entities, pat) == target
+
+        # Multiple simple patterns
+        pats = ['{session}/{task}/r-{run}.nii.gz',
+                't-{task}/{subject}-{run}.nii.gz',
+                '{subject}/{task}.nii.gz']
+        pats = [join(writable_file.dirname, p) for p in pats]
+        target = join(writable_file.dirname, 't-rest/3-2.nii.gz')
+        assert build_path(writable_file.entities, pats) == target
+
+        # Pattern with optional entity
+        pats = ['[{session}/]{task}/r-{run}.nii.gz',
+                't-{task}/{subject}-{run}.nii.gz']
+        pats = [join(writable_file.dirname, p) for p in pats]
+        target = join(writable_file.dirname, 'rest/r-2.nii.gz')
+        assert build_path(writable_file.entities, pats) == target
+
+        # Pattern with conditional values
+        pats = ['{task<func|acq>}/r-{run}.nii.gz',
+                't-{task}/{subject}-{run}.nii.gz']
+        pats = [join(writable_file.dirname, p) for p in pats]
+        target = join(writable_file.dirname, 't-rest/3-2.nii.gz')
+        assert build_path(writable_file.entities, pats) == target
+
+        # Pattern with valid conditional values
+        pats = ['{task<func|rest>}/r-{run}.nii.gz',
+                't-{task}/{subject}-{run}.nii.gz']
+        pats = [join(writable_file.dirname, p) for p in pats]
+        target = join(writable_file.dirname, 'rest/r-2.nii.gz')
+        assert build_path(writable_file.entities, pats) == target
+
+        # Pattern with optional entity with conditional values
+        pats = ['[{task<func|acq>}/]r-{run}.nii.gz',
+                't-{task}/{subject}-{run}.nii.gz']
+        pats = [join(writable_file.dirname, p) for p in pats]
+        target = join(writable_file.dirname, 'r-2.nii.gz')
+        assert build_path(writable_file.entities, pats) == target
+
+        # Pattern with default value
+        pats = ['sess-{session|A}/r-{run}.nii.gz']
+        assert build_path({'run': 3}, pats) == 'sess-A/r-3.nii.gz'
+
+        # Pattern with both valid and default values
+        pats = ['sess-{session<A|B|C>|D}/r-{run}.nii.gz']
+        assert build_path({'session': 1, 'run': 3}, pats) == 'sess-D/r-3.nii.gz'
+        pats = ['sess-{session<A|B|C>|D}/r-{run}.nii.gz']
+        assert build_path({'session': 'B', 'run': 3}, pats) == 'sess-B/r-3.nii.gz'
+
+    def test_strict_build_path(self):
+
+        # Test with strict matching--should fail
+        pats = ['[{session}/]{task}/r-{run}.nii.gz',
+                't-{task}/{subject}-{run}.nii.gz']
+        entities = {'subject': 1, 'task': "A", 'run': 2}
+        assert build_path(entities, pats, True)
+        entities = {'subject': 1, 'task': "A", 'age': 22}
+        assert not build_path(entities, pats, True)
+
+    def test_build_file(self, writable_file, tmp_bids, caplog):
+        writable_file.entities = {
+            'task': 'rest',
+            'run': 2,
+            'subject': '3'
+        }
+
+        # Simple write out
+        new_dir = join(writable_file.dirname, 'rest')
+        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)
+
+        # Conflict handling
+        with pytest.raises(ValueError):
+            writable_file.copy(pat)
+        with pytest.raises(ValueError):
+            writable_file.copy(pat, conflicts='fail')
+        writable_file.copy(pat, conflicts='skip')
+        log_message = caplog.records[0].message
+        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')
+        assert exists(append_target)
+        writable_file.copy(pat, conflicts='overwrite')
+        assert exists(target)
+        shutil.rmtree(new_dir)
+
+        # Symbolic linking
+        writable_file.copy(pat, symbolic_link=True)
+        assert islink(target)
+        shutil.rmtree(new_dir)
+
+        # Using different root
+        root = str(tmp_bids.mkdir('tmp2'))
+        pat = join(root, '{task}/sub-{subject}/run-{run}.nii.gz')
+        target = join(root, 'rest/sub-3/run-2.nii.gz')
+        writable_file.copy(pat, root=root)
+        assert exists(target)
+
+        # Copy into directory functionality
+        pat = join(writable_file.dirname, '{task}/')
+        writable_file.copy(pat)
+        target = join(writable_file.dirname, 'rest', writable_file.filename)
+        assert exists(target)
+        shutil.rmtree(new_dir)
+
+
+class TestWritableLayout:
+
+    def test_write_files(self, tmp_bids, layout):
+
+        tmpdir = str(tmp_bids)
+        pat = join(str(tmpdir), 'sub-{subject<02>}'
+                                '/sess-{session}'
+                                '/r-{run}'
+                                '/suffix-{suffix}'
+                                '/acq-{acquisition}'
+                                '/task-{task}.nii.gz')
+        layout.copy_files(path_patterns=pat)
+        example_file = join(str(tmpdir), 'sub-02'
+                                         '/sess-2'
+                                         '/r-1'
+                                         '/suffix-bold'
+                                         '/acq-fullbrain'
+                                         '/task-rest.nii.gz')
+        example_file2 = join(str(tmpdir), 'sub-01'
+                                          '/sess-2'
+                                          '/r-1'
+                                          '/suffix-bold'
+                                          '/acq-fullbrain'
+                                          '/task-rest.nii.gz')
+
+        assert exists(example_file)
+        assert not exists(example_file2)
+
+        pat = join(str(tmpdir), 'sub-{subject<01>}'
+                                '/sess-{session}'
+                                '/r-{run}'
+                                '/suffix-{suffix}'
+                                '/task-{task}.nii.gz')
+        example_file = join(str(tmpdir), 'sub-01'
+                                         '/sess-2'
+                                         '/r-1'
+                                         '/suffix-bold'
+                                         '/task-rest.nii.gz')
+        # Should fail without the 'overwrite' because there are multiple
+        # files that produce the same path.
+        with pytest.raises(ValueError):
+            layout.copy_files(path_patterns=pat)
+        try:
+            os.remove(example_file)
+        except OSError:
+            pass
+        layout.copy_files(path_patterns=pat, conflicts='overwrite')
+        assert exists(example_file)
+
+    def test_write_contents_to_file(self, tmp_bids, layout):
+        contents = 'test'
+        entities = {'subject': 'Bob', 'session': '01'}
+        pat = join('sub-{subject}/sess-{session}/desc.txt')
+        layout.write_contents_to_file(entities, path_patterns=pat,
+                                      contents=contents)
+        target = join(str(tmp_bids), 'bids', 'sub-Bob/sess-01/desc.txt')
+        assert exists(target)
+        with open(target) as f:
+            written = f.read()
+        assert written == contents
+        assert target not in layout.files
+
+    def test_write_contents_to_file_defaults(self, tmp_bids, layout):
+        contents = 'test'
+        entities = {'subject': 'Bob', 'session': '01', 'run': '1',
+                    'suffix': 'bold', 'task': 'test', 'acquisition': 'test',
+                    'bval': 0}
+        layout.write_contents_to_file(entities, contents=contents)
+        target = join(str(tmp_bids), 'bids', 'sub-Bob', 'ses-01',
+                      'func', 'sub-Bob_ses-01_task-test_acq-test_run-1_bold.nii.gz')
+        assert exists(target)
+        with open(target) as f:
+            written = f.read()
+        assert written == contents
+
+    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')
+        path = layout.build_path(entities, path_patterns=pat)
+        assert path == join(str(tmpdir), 'sub-Bob/sess-01/r-1.nii.gz')
+
+        data_dir = join(dirname(__file__), 'data', '7t_trt')
+        filename = 'sub-04_ses-1_task-rest_acq-fullbrain_run-1_physio.tsv.gz'
+        file = join('sub-04', 'ses-1', 'func', filename)
+        path = layout.build_path(file, path_patterns=pat)
+        assert path.endswith('sub-04/sess-1/r-1.nii.gz')
diff --git a/bids/variables/tests/test_io.py b/bids/variables/tests/test_io.py
index 6447288e..0385890a 100644
--- a/bids/variables/tests/test_io.py
+++ b/bids/variables/tests/test_io.py
@@ -61,6 +61,7 @@ def test_load_participants(layout1):
 
 
 def test_load_synthetic_dataset(synthetic):
+
     param, index = synthetic
     # Runs
     runs = index.get_nodes('run')
@@ -72,10 +73,11 @@ def test_load_synthetic_dataset(synthetic):
     if param == 'preproc':
         # non-exhaustive
         match = ['Cosine01', 'stdDVARS', 'RotZ', 'FramewiseDisplacement']
-        sum_dense = 54
+        sum_dense = 56
     else:
-        match = ['trial_type', 'weight', 'respiratory', 'cardiac']
-        sum_dense = 2
+        match = ['trial_type', 'weight', 'respiratory', 'cardiac', 'stimA',
+                 'stimB']
+        sum_dense = 4
 
     for v in match:
         assert v in variables.keys()