Repository: glotzerlab/signac
Pull request #140 (2019-02-25 01:46:45)

Elevate all dots-in-keys deprecation warnings to errors.
The use of an invalid key should lead to the raise of an
InvalidKeyError, which is newly defined in the errors module.

codecov-io: # [Codecov](https://codecov.io/gh/glotzerlab/signac/pull/140?src=pr&el=h1) Report
> Merging [#140](https://codecov.io/gh/glotzerlab/signac/pull/140?src=pr&el=desc) into [develop](https://codecov.io/gh/glotzerlab/signac/commit/66cdb2136d693c218fabe26f524ca3f21d74fa81?src=pr&el=desc) will **decrease** coverage by `0.03%`.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/glotzerlab/signac/pull/140/graphs/tree.svg?width=650&token=FghbjTyzgM&height=150&src=pr)](https://codecov.io/gh/glotzerlab/signac/pull/140?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           develop    #140      +/-   ##
==========================================
- Coverage    63.63%   63.6%   -0.04%     
==========================================
  Files           38      38              
  Lines         5594    5594              
==========================================
- Hits          3560    3558       -2     
- Misses        2034    2036       +2
```


| [Impacted Files](https://codecov.io/gh/glotzerlab/signac/pull/140?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [signac/contrib/collection.py](https://codecov.io/gh/glotzerlab/signac/pull/140/diff?src=pr&el=tree#diff-c2lnbmFjL2NvbnRyaWIvY29sbGVjdGlvbi5weQ==) | `88.49% <100%> (-0.4%)` | :arrow_down: |
| [signac/core/synceddict.py](https://codecov.io/gh/glotzerlab/signac/pull/140/diff?src=pr&el=tree#diff-c2lnbmFjL2NvcmUvc3luY2VkZGljdC5weQ==) | `95.6% <100%> (-0.03%)` | :arrow_down: |
| [signac/errors.py](https://codecov.io/gh/glotzerlab/signac/pull/140/diff?src=pr&el=tree#diff-c2lnbmFjL2Vycm9ycy5weQ==) | `90.62% <100%> (+0.3%)` | :arrow_up: |
| [signac/core/h5store.py](https://codecov.io/gh/glotzerlab/signac/pull/140/diff?src=pr&el=tree#diff-c2lnbmFjL2NvcmUvaDVzdG9yZS5weQ==) | `92.13% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/glotzerlab/signac/pull/140?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/glotzerlab/signac/pull/140?src=pr&el=footer). Last update [66cdb21...01b1034](https://codecov.io/gh/glotzerlab/signac/pull/140?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

vyasr: I'm a little confused by the organization with respect to the `validate_key` method. I think it's probably safe to prevent dots in keys for an arbitrary `dict_manager`, but if that's not the case it seems unnecessary to introduce the function at that level. A reasonable alternative would be to make it a `classmethod` and just include `cls` in the error message.
csadorf: > I'm a little confused by the organization with respect to the `validate_key` method. I think it's probably safe to prevent dots in keys for an arbitrary `dict_manager`, but if that's not the case it seems unnecessary to introduce the function at that level. A reasonable alternative would be to make it a `classmethod` and just include `cls` in the error message.

That's exact reason I did not do this. I don't think there is any reason to assume that *any* `DictManager` has this restriction.

Using a `staticmethod` provides a sensible path forward to overriding default behavior, why would you prefer a `classmethod` if there is no dependence on the `cls`?

Patch:
diff --git a/signac/__init__.py b/signac/__init__.py
index ec64fe41..94a38611 100644
--- a/signac/__init__.py
+++ b/signac/__init__.py
@@ -27,7 +27,6 @@ from .contrib import export_one
 from .contrib import export
 from .contrib import export_to_mirror
 from .contrib import export_pymongo
-from .contrib import fetch_one  # deprecated
 from .contrib import filesystems as fs
 from .contrib import Collection
 from .contrib import index_files
@@ -41,6 +40,9 @@ from .core.jsondict import in_buffered_mode as is_buffered
 from .core.jsondict import flush_all as flush
 from .core.jsondict import get_buffer_size
 from .core.jsondict import get_buffer_load
+from .core.jsondict import JSONDict
+from .core.h5store import H5Store
+from .core.h5store import H5StoreManager
 
 
 __version__ = '0.9.5'
@@ -48,7 +50,7 @@ __version__ = '0.9.5'
 __all__ = ['__version__', 'contrib', 'db', 'errors', 'warnings', 'sync',
            'cite',
            'Project', 'TemporaryProject', 'get_project', 'init_project', 'get_job',
-           'get_database', 'fetch', 'fetch_one',
+           'get_database', 'fetch',
            'export_one', 'export', 'export_to_mirror',
            'Collection',
            'export_pymongo', 'fs',
@@ -57,4 +59,6 @@ __all__ = ['__version__', 'contrib', 'db', 'errors', 'warnings', 'sync',
            'MasterCrawler',
            'SignacProjectCrawler',
            'buffered', 'is_buffered', 'flush', 'get_buffer_size', 'get_buffer_load',
+           'JSONDict',
+           'H5Store', 'H5StoreManager',
            ]
diff --git a/signac/contrib/__init__.py b/signac/contrib/__init__.py
index c886d89d..54f83626 100644
--- a/signac/contrib/__init__.py
+++ b/signac/contrib/__init__.py
@@ -14,7 +14,6 @@ from .indexing import JSONCrawler
 from .indexing import SignacProjectCrawler
 from .indexing import MasterCrawler
 from .indexing import fetch
-from .indexing import fetch_one  # deprecated
 from .indexing import fetched
 from .indexing import export_one
 from .indexing import export
@@ -31,7 +30,7 @@ __all__ = [
     'indexing',
     'Project', 'TemporaryProject', 'get_project', 'init_project', 'get_job',
     'BaseCrawler', 'RegexFileCrawler', 'JSONCrawler', 'SignacProjectCrawler',
-    'MasterCrawler', 'fetch', 'fetch_one', 'fetched',
+    'MasterCrawler', 'fetch', 'fetched',
     'export_one', 'export', 'export_to_mirror', 'export_pymongo',
     'index_files', 'index',
     'Collection',
diff --git a/signac/contrib/collection.py b/signac/contrib/collection.py
index e5cbaa8f..07e9d334 100644
--- a/signac/contrib/collection.py
+++ b/signac/contrib/collection.py
@@ -16,7 +16,6 @@ import sys
 import io
 import re
 import logging
-import warnings
 import argparse
 import operator
 from itertools import islice
@@ -201,9 +200,11 @@ def _build_index(docs, key, primary_key):
             except KeyError:
                 pass
             else:
-                warnings.warn(
-                    "Using keys with dots ('.') is pending deprecation in the future!",
-                    PendingDeprecationWarning)
+                from ..errors import InvalidKeyError
+                raise InvalidKeyError(
+                    "\nThe use of '.' (dots) in keys is invalid.\n\n"
+                    "See http://www.signac.io/document-wide-migration/ "
+                    "for a recipe on how to replace dots in all keys.")
                 # inlined for performance
                 if type(v) is list:     # performance
                     index[_to_tuples(v)].add(doc[primary_key])
diff --git a/signac/contrib/indexing.py b/signac/contrib/indexing.py
index 0a742cdf..1456f731 100644
--- a/signac/contrib/indexing.py
+++ b/signac/contrib/indexing.py
@@ -601,11 +601,6 @@ def fetch(doc_or_id, mode='r', mirrors=None, num_tries=3, timeout=60, ignore_loc
                 raise errors.FetchError("Unable to fetch object for '{}'.".format(file_id))
 
 
-def fetch_one(doc, *args, **kwargs):
-    raise DeprecationWarning(
-        "The fetch_one() function has been removed. Use fetch() instead.")
-
-
 def fetched(docs):
     """Iterate over documents and yield associated files."""
     for doc in docs:
diff --git a/signac/contrib/project.py b/signac/contrib/project.py
index 4e06d4c7..7903416c 100644
--- a/signac/contrib/project.py
+++ b/signac/contrib/project.py
@@ -649,30 +649,6 @@ class Project(object):
         """
         return self.find_jobs().to_dataframe(*args, **kwargs)
 
-    def find_statepoints(self, filter=None, doc_filter=None, skip_errors=False):
-        """Find all statepoints in the project's workspace.
-
-        This function is deprecated.
-
-        .. deprecated:: 0.9.0
-
-        :param filter: If not None, only yield statepoints matching the filter.
-        :type filter: mapping
-        :param skip_errors: Show, but otherwise ignore errors while
-            iterating over the workspace. Use this argument to repair
-            a corrupted workspace.
-        :type skip_errors: bool
-        :yields: statepoints as dict"""
-        warnings.warn(
-            "The Project.find_statepoints() method is deprecated.",
-            DeprecationWarning)
-
-        jobs = self.find_jobs(filter, doc_filter)
-        if skip_errors:
-            jobs = _skip_errors(jobs, logger.critical)
-        for job in jobs:
-            yield dict(job._statepoint)
-
     def read_statepoints(self, fn=None):
         """Read all statepoints from a file.
 
@@ -864,41 +840,6 @@ class Project(object):
         from .linked_view import create_linked_view
         return create_linked_view(self, prefix, job_ids, index, path)
 
-    def find_job_documents(self, filter=None):
-        """Find all job documents in the project's workspace.
-
-        This function is deprecated.
-
-        This method iterates through all jobs or all jobs matching
-        the filter and yields each job's document as a dict.
-        Each dict additionally contains a field 'statepoint',
-        with the job's statepoint and a field '_id', which is
-        the job's id.
-
-        .. deprecated:: 0.9.0
-
-        :param filter: If not None,
-            only find job documents matching filter.
-        :type filter: mapping
-        :yields: Instances of dict.
-        :raises KeyError: If the job document already contains the fields
-            '_id' or 'statepoint'."""
-        warnings.warn(
-            "The Project.find_job_documents() method is deprecated.",
-            DeprecationWarning)
-
-        for job in self.find_jobs(filter=filter):
-            doc = dict(job.document)
-            if '_id' in doc:
-                raise KeyError(
-                    "The job document already contains a field '_id'!")
-            if 'statepoint' in doc:
-                raise KeyError(
-                    "The job document already contains a field 'statepoint'!")
-            doc['_id'] = str(job)
-            doc['statepoint'] = dict(job._statepoint)
-            yield doc
-
     def reset_statepoint(self, job, new_statepoint):
         """Reset the state point of job.
 
@@ -1312,7 +1253,7 @@ class Project(object):
                 raise error
 
     def update_cache(self):
-        """Update the persistent state point cache (experimental).
+        """Update the persistent state point cache.
 
         This function updates a persistent state point cache, which
         is stored in the project root directory. Most data space operations,
@@ -1320,9 +1261,6 @@ class Project(object):
         to be significantly faster after calling this function, especially
         for large data spaces.
         """
-        warnings.warn(
-            "The Project.update_cache() method is experimental and "
-            "might be removed in future releases.", FutureWarning)
         logger.info('Update cache...')
         start = time.time()
         cache = self._read_cache()
diff --git a/signac/core/dict_manager.py b/signac/core/dict_manager.py
index eb6293f8..3c5f61c5 100644
--- a/signac/core/dict_manager.py
+++ b/signac/core/dict_manager.py
@@ -41,7 +41,13 @@ class DictManager(object):
             self._dict_registry[key] = self.cls(os.path.join(self.prefix, key) + self.suffix)
         return self._dict_registry[key]
 
+    @staticmethod
+    def _validate_key(key):
+        "Emit a warning or raise an exception if key is invalid. Returns key."
+        return key
+
     def __setitem__(self, key, value):
+        self._validate_key(key)
         tmp_key = str(uuid.uuid4())
         try:
             self[tmp_key].update(value)
diff --git a/signac/core/h5store.py b/signac/core/h5store.py
index 10f022af..0cfd4351 100644
--- a/signac/core/h5store.py
+++ b/signac/core/h5store.py
@@ -10,6 +10,7 @@ import array
 from threading import RLock
 
 from ..common import six
+from ..errors import InvalidKeyError
 from .dict_manager import DictManager
 
 if six.PY2:
@@ -65,7 +66,7 @@ class H5StoreAlreadyOpenError(OSError):
     """Indicates that the underlying HDF5-file is already openend."""
 
 
-def _h5set(file, grp, key, value, path=None):
+def _h5set(store, grp, key, value, path=None):
     """Set a key in an h5py container, recursively converting Mappings to h5py
     groups and transparently handling None."""
     import h5py
@@ -90,7 +91,7 @@ def _h5set(file, grp, key, value, path=None):
     if isinstance(value, Mapping):
         subgrp = grp.create_group(key)
         for k, v in value.items():
-            _h5set(file, subgrp, k, v, path)
+            _h5set(store, subgrp, k, v, path)
 
     # Regular built-in types:
     elif value is None:
@@ -113,10 +114,10 @@ def _h5set(file, grp, key, value, path=None):
         _load_pandas()   # might be a pandas type
         if _is_pandas_type(value):
             _requires_tables()
-            file.close()
-            with _pandas.HDFStore(file._filename) as store:
-                store[path] = value
-            file.open()
+            store.close()
+            with _pandas.HDFStore(store._filename) as store_:
+                store_[path] = value
+            store.open()
         else:
             grp[key] = value
             warnings.warn(
@@ -124,7 +125,7 @@ def _h5set(file, grp, key, value, path=None):
                 "type is not officially supported!".format(type(value)))
 
 
-def _h5get(file, grp, key, path=None):
+def _h5get(store, grp, key, path=None):
     """Retrieve the underlying data for a key from its h5py container."""
     path = path + '/' + key if path else key
     result = grp[key]
@@ -145,23 +146,11 @@ def _h5get(file, grp, key, path=None):
             return result[()]
     except AttributeError:
         if isinstance(result, MutableMapping):
-            return H5Group(file, path)
+            return H5Group(store, path)
         else:
             return result
 
 
-def _validate_key(key):
-    "Emit a warning or raise an exception if key is invalid. Returns key."
-    if '.' in key:
-        from ..warnings import SignacDeprecationWarning
-        warnings.warn(
-            "\nThe use of '.' (dots) in keys is deprecated and may lead to "
-            "unexpected behavior!\nSee http://www.signac.io/document-wide-migration/ "
-            "for a recipe on how to replace dots in all keys.",
-            SignacDeprecationWarning)
-    return key
-
-
 class _ensure_open(object):
 
     __slots__ = ['file', 'open', 'kwargs']
@@ -185,31 +174,31 @@ class _ensure_open(object):
 class H5Group(MutableMapping):
     """An abstraction layer over h5py's Group objects, to manage and return data."""
 
-    __slots__ = ['_file', '_path']
+    __slots__ = ['_store', '_path']
 
-    def __init__(self, file, path):
-        self._file = file
+    def __init__(self, store, path):
+        self._store = store
         self._path = path
 
     @property
     def _group(self):
-        return self._file.file[self._path]
+        return self._store.file[self._path]
 
     def __getitem__(self, key):
-        with _ensure_open(self._file):
-            return _h5get(self._file, self._group, key, self._path)
+        with _ensure_open(self._store):
+            return _h5get(self._store, self._group, key, self._path)
 
     def __setitem__(self, key, value):
-        with _ensure_open(self._file):
-            _h5set(self._file, self._group, _validate_key(key), value, self._path)
+        with _ensure_open(self._store):
+            _h5set(self._store, self._group, self._store._validate_key(key), value, self._path)
             return value
 
     def __delitem__(self, key):
-        with _ensure_open(self._file):
+        with _ensure_open(self._store):
             del self._group[key]
 
     def __getattr__(self, name):
-        with _ensure_open(self._file):
+        with _ensure_open(self._store):
             if name in self._group.keys():
                 return self.__getitem__(name)
             else:
@@ -224,16 +213,16 @@ class H5Group(MutableMapping):
     def __iter__(self):
         # The generator below should be refactored to use 'yield from'
         # once we drop Python 2.7 support.
-        with _ensure_open(self._file):
+        with _ensure_open(self._store):
             for key in self._group.keys():
                 yield key
 
     def __len__(self):
-        with _ensure_open(self._file):
+        with _ensure_open(self._store):
             return len(self._group)
 
     def __eq__(self, other):
-        with _ensure_open(self._file):
+        with _ensure_open(self._store):
             if isinstance(self, Mapping) and isinstance(other, Mapping):
                 return super(H5Group, self).__eq__(other)
             elif type(other) == type(self):
@@ -374,9 +363,16 @@ class H5Store(MutableMapping):
         with _ensure_open(self):
             return _h5get(self, self._file, key)
 
+    @staticmethod
+    def _validate_key(key):
+        "Emit a warning or raise an exception if key is invalid. Returns key."
+        if '.' in key:
+            raise InvalidKeyError("Keys for the H5Store may not contain dots ('.').")
+        return key
+
     def __setitem__(self, key, value):
         with _ensure_open(self):
-            _h5set(self, self._file, _validate_key(key), value)
+            _h5set(self, self._file, self._validate_key(key), value)
             return value
 
     def __delitem__(self, key):
@@ -447,3 +443,10 @@ class H5Store(MutableMapping):
 class H5StoreManager(DictManager):
     cls = H5Store
     suffix = '.h5'
+
+    @staticmethod
+    def _validate_key(key):
+        "Emit a warning or raise an exception if key is invalid. Returns key."
+        if '.' in key:
+            raise InvalidKeyError("Keys for the H5StoreManager may not contain dots ('.').")
+        return key
diff --git a/signac/core/search_engine.py b/signac/core/search_engine.py
deleted file mode 100644
index 950ccb96..00000000
--- a/signac/core/search_engine.py
+++ /dev/null
@@ -1,222 +0,0 @@
-# Copyright (c) 2017 The Regents of the University of Michigan
-# All rights reserved.
-# This software is licensed under the BSD 3-Clause License.
-from collections import defaultdict
-import warnings
-import logging
-
-from .json import json
-from ..common import six
-if six.PY2:
-    from collections import Mapping
-else:
-    from collections.abc import Mapping
-
-logger = logging.getLogger(__name__)
-
-
-def _flatten(container):
-    for i in container:
-        if isinstance(i, (list, tuple)):
-            for j in _flatten(i):
-                yield j
-        else:
-            yield i
-
-
-def _encode_tree(x):
-    if isinstance(x, list):
-        return json.dumps(x)
-    else:
-        return x
-
-
-def _traverse_tree(t, include=None, encode=None):
-    if encode is not None:
-        t = encode(t)
-    if include is False:
-        return
-    if isinstance(t, list):
-        for i in t:
-            for b in _traverse_tree(i, include, encode):
-                yield b
-    elif isinstance(t, Mapping):
-        for k in t:
-            if include is None or include is True:
-                for i in _traverse_tree(t[k], encode=encode):
-                    yield k, i
-            else:
-                if not include.get(k, False):
-                    continue
-                for i in _traverse_tree(t[k], include.get(k), encode=encode):
-                    yield k, i
-    else:
-        yield t
-
-
-def _traverse_filter(t, include=None):
-    for b in _traverse_tree(t, include=include, encode=_encode_tree):
-        yield b
-
-
-_traverse_docs = _traverse_filter
-
-
-def _valid_filter(f, top=True):
-    if isinstance(f, Mapping):
-        return all(_valid_filter(v, top=False) for v in f.values())
-    elif isinstance(f, list):
-        return not top
-    else:
-        return True
-
-
-class DocumentSearchEngine(object):
-    """Search for documents as part of an index.
-
-    Use the DocumentSearchEngine to search for specific
-    key-value pairs within a list of documents.
-    Each document must have a unique identifier.
-
-    Use the include argument to control what keys
-    are indexed and which are not. This may increase
-    indexing speed and reduce memory usage. See
-    :meth:`~.check_filter` for more information.
-
-    :param docs: A set of documents to index.
-    :type docs: list
-    :param include: A mapping of keys that shall be
-        included (True) or excluded (False).
-    :type include: Mapping
-    :param hash_: The hash function to use, defaults to :func:`hash`.
-    :type hash_: callable
-    """
-
-    def __init__(self, docs=None, include=None, hash_=None):
-        warnings.warn(
-            "The {} class is deprecated. Please use the Collection class instead.".format(
-                type(self).__name__),
-            DeprecationWarning)
-        self._hash = hash if hash_ is None else hash_
-        logger.debug("Building index...")
-        self.ids, self.index, self.included = self._build_index(docs, include)
-        logger.debug("Built index with {} entries.".format(len(self.index)))
-
-    def _build_index(self, docs, include=None):
-        index = defaultdict(set)
-        ids = set()
-        if include is None:
-            included = None
-        else:
-            included = dict()
-            for branch in _traverse_docs(include):
-                f = tuple(_flatten(branch))
-                included[self._hash(f[:-1])] = f[-1]
-        if docs is not None:
-            for doc in docs:
-                ids.add(doc['_id'])
-                for branch in _traverse_docs(doc, include=include):
-                    f = tuple(_flatten(branch))
-                    index[self._hash(f)].add(doc['_id'])
-        return ids, index, included
-
-    def _filter_supported(self, filter):
-        if self.included is None:
-            return True
-        else:
-            for branch in _traverse_tree(filter):
-                f = tuple(_flatten(branch))
-                for i in range(len(f)):
-                    h = self._hash(f[:-i])
-                    if self.included.get(h, False):
-                        break
-                else:
-                    return False
-            else:
-                return True
-
-    def check_filter(self, filter):
-        """Check whether the filter is valid and supported.
-
-        Not all filters are supported when the search engine
-        is build with specific keys to be included or excluded.
-
-        Example:
-
-        .. code-block:: python
-            incl = {'a': True, 'b': {'c': False, 'd': True}}
-            engine = DocumentSearchEngine(docs, incl)
-            # Examples for supported filters:
-            engine.find({'a': x})
-            engine.find({'a': x, 'b': y})
-            engine.find({'b': {'d': z}})
-            # Examples for filters that are not supported:
-            engine.find({'b': {'c': x}})
-            engine.find({'b': {'e': y}}) # *)
-            engine.find({'c': z})        # *)
-
-        *) Once one key within one hierarchy level is specified
-        to be either included or excluded, all other keys within
-        the same level are automatically excluded.
-
-        :param filter: The filter to be checked.
-        :type filter: Mapping
-        :raises ValueError: If the filter is invalid.
-        :raises RuntimeError: If the filter is not supported
-            by the index.
-        """
-        if filter is None:
-            return True
-        if not _valid_filter(filter):
-            raise ValueError(filter)
-        elif not self._filter_supported(filter):
-            msg = "{} not indexed for filter: '{}'."
-            raise RuntimeError(msg.format(type(self).__name__, filter))
-
-    def find(self, filter=None):
-        """Find all documents matching filter.
-
-        :param filter: A mapping of key-value pairs that
-            all indexed documents are compared against.
-        :type filter: Mapping
-        :yields: The ids of all indexed documents matching the
-            filter.
-        :raises ValueError: If the filter is invalid.
-        :raises RuntimeError: If the filter is not supported
-            by the index.
-        """
-        self.check_filter(filter)
-        if filter is None or not len(filter):
-            return _DocumentSearchEngineResults(self.ids)
-        else:
-            result = None
-            for branch in _traverse_filter(filter):
-                h = self._hash(tuple(_flatten(branch)))
-                m = self.index.get(h, set())
-                if result is None:
-                    result = m
-                    continue
-                if m is None:
-                    return
-                else:
-                    result = result.intersection(m)
-            if result is None:
-                return
-            else:
-                return _DocumentSearchEngineResults(result)
-
-    def __len__(self):
-        """Return the number of indexed documents."""
-        return len(self.ids)
-
-
-class _DocumentSearchEngineResults(object):
-
-    def __init__(self, ids):
-        self._ids = ids
-
-    def __len__(self):
-        return len(self._ids)
-
-    def __iter__(self):
-        return iter(self._ids)
diff --git a/signac/core/synceddict.py b/signac/core/synceddict.py
index 510dcb2d..8b77aced 100644
--- a/signac/core/synceddict.py
+++ b/signac/core/synceddict.py
@@ -3,7 +3,6 @@
 # This software is licensed under the BSD 3-Clause License.
 "Synchronized dictionary."
 import logging
-import warnings
 from contextlib import contextmanager
 from functools import wraps
 from copy import deepcopy
@@ -89,12 +88,11 @@ class _SyncedDict(MutableMapping):
     def _validate_key(key):
         "Emit a warning or raise an exception if key is invalid. Returns key."
         if '.' in key:
-            from ..warnings import SignacDeprecationWarning
-            warnings.warn(
+            from ..errors import InvalidKeyError
+            raise InvalidKeyError(
                 "\nThe use of '.' (dots) in keys is deprecated and may lead to "
                 "unexpected behavior!\nSee http://www.signac.io/document-wide-migration/ "
-                "for a recipe on how to replace dots in all keys.",
-                SignacDeprecationWarning)
+                "for a recipe on how to replace dots in all keys.")
         return key
 
     def _dfs_convert(self, root):
diff --git a/signac/errors.py b/signac/errors.py
index f8b0a685..437eaa03 100644
--- a/signac/errors.py
+++ b/signac/errors.py
@@ -52,6 +52,10 @@ class SchemaSyncConflict(SyncConflict):
         return "The synchronization failed, because of a schema conflict."
 
 
+class InvalidKeyError(ValueError):
+    """Raised when a user uses a non-conforming key."""
+
+
 __all__ = [
     'Error',
     'BufferException',
@@ -67,4 +71,5 @@ __all__ = [
     'FileSyncConflict',
     'DocumentSyncConflict',
     'SchemaSyncConflict',
+    'InvalidKeyError',
 ]


Test patch:
diff --git a/tests/test_collection.py b/tests/test_collection.py
index aeaa9582..3a40b75b 100644
--- a/tests/test_collection.py
+++ b/tests/test_collection.py
@@ -1,13 +1,13 @@
 from __future__ import division
 import os
 import io
-import warnings
 import unittest
 from collections import OrderedDict
 from itertools import islice
 
 from signac import Collection
 from signac.common import six
+from signac.errors import InvalidKeyError
 if six.PY2:
     from tempdir import TemporaryDirectory
 else:
@@ -293,8 +293,7 @@ class CollectionTest(unittest.TestCase):
         self.assertEqual(self.c.find_one({'a': {'$type': 'float'}})['_id'], id_float)
 
     def test_find_with_dots(self):
-        with warnings.catch_warnings(record=True) as w:
-            warnings.simplefilter('once')
+        with self.assertRaises(InvalidKeyError):
             self.assertEqual(len(self.c.find()), 0)
             self.assertEqual(list(self.c.find()), [])
             self.assertEqual(len(self.c.find({'a.b': 0})), 0)
@@ -309,10 +308,6 @@ class CollectionTest(unittest.TestCase):
             self.assertEqual(len(self.c.find()), 2 * len(docs))
             self.assertEqual(len(self.c.find({'a.b': 0})), 2)
             self.assertEqual(len(self.c.find({'a.b': -1})), 0)
-            if six.PY34:  # warning registry not cleared in earlier versions
-                assert len(w) == 1
-                assert issubclass(w[0].category, PendingDeprecationWarning)
-                assert 'deprecation' in str(w[0].message)
 
     def test_find_types(self):
         # Note: All of the iterables will be normalized to lists!
diff --git a/tests/test_h5store.py b/tests/test_h5store.py
index 884679ff..494eff40 100644
--- a/tests/test_h5store.py
+++ b/tests/test_h5store.py
@@ -7,7 +7,6 @@ import sys
 import unittest
 import random
 import string
-import warnings
 import subprocess
 from itertools import chain
 from array import array
@@ -20,7 +19,7 @@ from contextlib import closing
 
 from signac.core.h5store import H5Store, H5StoreClosedError, H5StoreAlreadyOpenError
 from signac.common import six
-from signac.warnings import SignacDeprecationWarning
+from signac.errors import InvalidKeyError
 
 if six.PY2:
     from tempdir import TemporaryDirectory
@@ -406,15 +405,12 @@ class H5StoreTest(BaseH5StoreTest):
             self.assertEqual(h5s[key], d)
 
     def test_keys_with_dots(self):
-        with warnings.catch_warnings(record=True) as w:
-            warnings.simplefilter('always')
+        with self.assertRaises(InvalidKeyError):
             with self.open_h5store() as h5s:
                 key = 'a.b'
                 d = self.get_testdata()
                 h5s[key] = d
                 self.assertEqual(h5s[key], d)
-            assert len(w) >= 1
-            assert any(issubclass(w_.category, SignacDeprecationWarning) for w_ in w)
 
     def test_keys_with_slashes(self):
         # HDF5 uses slashes for nested keys internally
diff --git a/tests/test_indexing.py b/tests/test_indexing.py
index 9699bb3b..e6ae98d0 100644
--- a/tests/test_indexing.py
+++ b/tests/test_indexing.py
@@ -418,11 +418,11 @@ class IndexingBaseTest(unittest.TestCase):
         self.assertEqual(2, len(list(crawler.crawl())))
         crawler.tags = {'test1', 'test2'}
         self.assertEqual(2, len(list(crawler.crawl())))
-        crawler.tags = {'test1', 'bs'}
+        crawler.tags = {'test1', 'non-existent-key'}
         self.assertEqual(2, len(list(crawler.crawl())))
-        crawler.tags = {'test2', 'bs'}
+        crawler.tags = {'test2', 'non-existent-key'}
         self.assertEqual(2, len(list(crawler.crawl())))
-        crawler.tags = {'test1', 'test2', 'bs'}
+        crawler.tags = {'test1', 'test2', 'non-existent-key'}
         self.assertEqual(2, len(list(crawler.crawl())))
 
 
diff --git a/tests/test_job.py b/tests/test_job.py
index 87dad692..0f9b570b 100644
--- a/tests/test_job.py
+++ b/tests/test_job.py
@@ -18,7 +18,7 @@ import signac.common.config
 from signac.common import six
 from signac.errors import DestinationExistsError
 from signac.errors import JobsCorruptedError
-from signac.warnings import SignacDeprecationWarning
+from signac.errors import InvalidKeyError
 
 if six.PY2:
     from tempdir import TemporaryDirectory
@@ -202,24 +202,10 @@ class JobSPInterfaceTest(BaseJobTest):
         self.assertNotEqual(old_id, job.get_id())
 
     def test_interface_nested_kws(self):
-        with warnings.catch_warnings():
-            warnings.simplefilter('error')
-            with self.assertRaises(SignacDeprecationWarning):
-                job = self.open_job({'a.b.c': 0})
-        with warnings.catch_warnings(record=True) as warning_record:
-            warnings.simplefilter('always')
+        with self.assertRaises(InvalidKeyError):
             job = self.open_job({'a.b.c': 0})
-            self.assertEqual(job.sp['a.b.c'], 0)
-            with self.assertRaises(AttributeError):
-                job.sp.a.b.c
-            job.sp['a.b.c'] = 1
-            self.assertEqual(job.sp['a.b.c'], 1)
-            self.assertEqual(len(warning_record), 4)
-            for warning in warning_record:
-                self.assertTrue(issubclass(warning.category, SignacDeprecationWarning))
-                self.assertIn('dots', str(warning.message))
-        job.sp.clear()
-        job.sp.a = dict(b=dict(c=2))
+
+        job = self.open_job(dict(a=dict(b=dict(c=2))))
         self.assertEqual(job.sp.a.b.c, 2)
         self.assertEqual(job.sp['a']['b']['c'], 2)
 
@@ -507,7 +493,7 @@ class JobDocumentTest(BaseJobTest):
         self.assertIn(key, job.document)
         self.assertEqual(job.document[key], d)
         self.assertEqual(job.document.get(key), d)
-        self.assertEqual(job.document.get('bs', d), d)
+        self.assertEqual(job.document.get('non-existent-key', d), d)
 
     def test_del(self):
         key = 'del0'
@@ -544,7 +530,7 @@ class JobDocumentTest(BaseJobTest):
         self.assertIn(key, job.doc)
         self.assertEqual(job.doc[key], d)
         self.assertEqual(job.doc.get(key), d)
-        self.assertEqual(job.doc.get('bs', d), d)
+        self.assertEqual(job.doc.get('non-existent-key', d), d)
 
     def test_set_set_doc(self):
         key0, key1 = 'set_set0', 'set_set1'
@@ -682,14 +668,14 @@ class JobDocumentTest(BaseJobTest):
         self.assertIn(key, job.document)
         self.assertEqual(job.document[key], d)
         self.assertEqual(job.document.get(key), d)
-        self.assertEqual(job.document.get('bs', d), d)
+        self.assertEqual(job.document.get('non-existent-key', d), d)
         copy = dict(job.document)
         self.assertTrue(bool(copy))
         self.assertEqual(len(copy), 1)
         self.assertIn(key, copy)
         self.assertEqual(copy[key], d)
         self.assertEqual(copy.get(key), d)
-        self.assertEqual(copy.get('bs', d), d)
+        self.assertEqual(copy.get('non-existent-key', d), d)
 
     def test_update(self):
         key = 'get_set'
@@ -958,7 +944,7 @@ class JobOpenDataTest(BaseJobTest):
             self.assertIn(key, job.data)
             self.assertEqual(job.data[key], d)
             self.assertEqual(job.data.get(key), d)
-            self.assertEqual(job.data.get('bs', d), d)
+            self.assertEqual(job.data.get('non-existent-key', d), d)
 
     def test_del(self):
         key = 'del0'
@@ -997,7 +983,7 @@ class JobOpenDataTest(BaseJobTest):
             self.assertIn(key, job.data)
             self.assertEqual(job.data[key], d)
             self.assertEqual(job.data.get(key), d)
-            self.assertEqual(job.data.get('bs', d), d)
+            self.assertEqual(job.data.get('non-existent-key', d), d)
 
     def test_set_set_data(self):
         key0, key1 = 'set_set0', 'set_set1'
@@ -1143,14 +1129,14 @@ class JobOpenDataTest(BaseJobTest):
             self.assertIn(key, job.data)
             self.assertEqual(job.data[key], d)
             self.assertEqual(job.data.get(key), d)
-            self.assertEqual(job.data.get('bs', d), d)
+            self.assertEqual(job.data.get('non-existent-key', d), d)
             copy = dict(job.data)
             self.assertTrue(bool(copy))
             self.assertEqual(len(copy), 1)
             self.assertIn(key, copy)
             self.assertEqual(copy[key], d)
             self.assertEqual(copy.get(key), d)
-            self.assertEqual(copy.get('bs', d), d)
+            self.assertEqual(copy.get('non-existent-key', d), d)
 
     def test_update(self):
         key = 'get_set'
@@ -1419,7 +1405,7 @@ class JobOpenCustomDataTest(BaseJobTest):
             self.assertIn(key, job.stores.test)
             self.assertEqual(job.stores.test[key], d)
             self.assertEqual(job.stores.test.get(key), d)
-            self.assertEqual(job.stores.test.get('bs', d), d)
+            self.assertEqual(job.stores.test.get('non-existent-key', d), d)
 
     def test_del(self):
         key = 'del0'
@@ -1458,7 +1444,7 @@ class JobOpenCustomDataTest(BaseJobTest):
             self.assertIn(key, job.stores.test)
             self.assertEqual(job.stores.test[key], d)
             self.assertEqual(job.stores.test.get(key), d)
-            self.assertEqual(job.stores.test.get('bs', d), d)
+            self.assertEqual(job.stores.test.get('non-existent-key', d), d)
 
     def test_set_set_data(self):
         key0, key1 = 'set_set0', 'set_set1'
@@ -1604,14 +1590,14 @@ class JobOpenCustomDataTest(BaseJobTest):
             self.assertIn(key, job.stores.test)
             self.assertEqual(job.stores.test[key], d)
             self.assertEqual(job.stores.test.get(key), d)
-            self.assertEqual(job.stores.test.get('bs', d), d)
+            self.assertEqual(job.stores.test.get('non-existent-key', d), d)
             copy = dict(job.stores.test)
             self.assertTrue(bool(copy))
             self.assertEqual(len(copy), 1)
             self.assertIn(key, copy)
             self.assertEqual(copy[key], d)
             self.assertEqual(copy.get(key), d)
-            self.assertEqual(copy.get('bs', d), d)
+            self.assertEqual(copy.get('non-existent-key', d), d)
 
     def test_update(self):
         key = 'get_set'
diff --git a/tests/test_jsondict.py b/tests/test_jsondict.py
index acf31e0d..b3019643 100644
--- a/tests/test_jsondict.py
+++ b/tests/test_jsondict.py
@@ -3,12 +3,11 @@
 # This software is licensed under the BSD 3-Clause License.
 import os
 import unittest
-import warnings
 import uuid
 
 from signac.core.jsondict import JSONDict
 from signac.common import six
-from signac.warnings import SignacDeprecationWarning
+from signac.errors import InvalidKeyError
 
 if six.PY2:
     from tempdir import TemporaryDirectory
@@ -229,10 +228,8 @@ class JSONDictTest(BaseJSONDictTest):
 
     def test_keys_with_dots(self):
         jsd = self.get_json_dict()
-        with warnings.catch_warnings():
-            warnings.simplefilter('error')
-            with self.assertRaises(SignacDeprecationWarning):
-                jsd['a.b'] = None
+        with self.assertRaises(InvalidKeyError):
+            jsd['a.b'] = None
 
 
 class JSONDictWriteConcernTest(JSONDictTest):
diff --git a/tests/test_project.py b/tests/test_project.py
index 64f81349..40e63d43 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -225,34 +225,6 @@ class ProjectTest(BaseProjectTest):
         self.assertFalse(os.path.isdir(self._tmp_wd))
         self.assertFalse(os.path.isdir(self.project.workspace()))
 
-    def test_find_statepoints(self):
-        with warnings.catch_warnings():
-            warnings.filterwarnings('ignore', category=DeprecationWarning, module='signac')
-            statepoints = [{'a': i} for i in range(5)]
-            for sp in statepoints:
-                self.project.open_job(sp).init()
-            self.assertEqual(
-                len(statepoints),
-                len(list(self.project.find_statepoints())))
-            self.assertEqual(
-                1, len(list(self.project.find_statepoints({'a': 0}))))
-
-    def test_find_statepoint_sequences(self):
-        with warnings.catch_warnings():
-            warnings.filterwarnings('ignore', category=DeprecationWarning, module='signac')
-            statepoints = [{'a': (i, i + 1)} for i in range(5)]
-            for sp in statepoints:
-                self.project.open_job(sp).init()
-            self.assertEqual(
-                len(statepoints),
-                len(list(self.project.find_statepoints())))
-            self.assertEqual(
-                1,
-                len(list(self.project.find_statepoints({'a': [0, 1]}))))
-            self.assertEqual(
-                1,
-                len(list(self.project.find_statepoints({'a': (0, 1)}))))
-
     def test_find_job_ids(self):
         statepoints = [{'a': i} for i in range(5)]
         for sp in statepoints:
@@ -395,41 +367,6 @@ class ProjectTest(BaseProjectTest):
         with self.assertRaises(KeyError):
             self.project.open_job(id='abc')
 
-    def test_find_job_documents(self):
-        with warnings.catch_warnings():
-            warnings.filterwarnings('ignore', category=DeprecationWarning, module='signac')
-            statepoints = [{'a': i} for i in range(5)]
-            for sp in statepoints:
-                self.project.open_job(sp).document['test'] = True
-            self.assertEqual(
-                len(list(self.project.find_job_documents({'a': 0}))), 1)
-            job_docs = list(self.project.find_job_documents())
-            self.assertEqual(len(statepoints), len(job_docs))
-            for job_doc in job_docs:
-                sp = job_doc['statepoint']
-                self.assertEqual(str(self.project.open_job(sp)), job_doc['_id'])
-
-    def test_find_job_documents_illegal_key(self):
-        with warnings.catch_warnings():
-            warnings.filterwarnings('ignore', category=DeprecationWarning, module='signac')
-            statepoints = [{'a': i} for i in range(5)]
-            for sp in statepoints:
-                self.project.open_job(sp).document['test'] = True
-            list(self.project.find_job_documents())
-            self.assertEqual(len(statepoints), len(
-                list(self.project.find_job_documents())))
-            list(self.project.find_job_documents({'a': 1}))
-            self.project.open_job({'a': 0}).document['_id'] = True
-            with self.assertRaises(KeyError):
-                list(self.project.find_job_documents())
-            del self.project.open_job({'a': 0}).document['_id']
-            list(self.project.find_job_documents())
-            self.project.open_job({'a': 1}).document['statepoint'] = True
-            with self.assertRaises(KeyError):
-                list(self.project.find_job_documents())
-            del self.project.open_job({'a': 1}).document['statepoint']
-            list(self.project.find_job_documents())
-
     def test_missing_statepoint_file(self):
         job = self.project.open_job(dict(a=0))
         job.init()
diff --git a/tests/test_sync.py b/tests/test_sync.py
index 118f713e..bd58a97b 100644
--- a/tests/test_sync.py
+++ b/tests/test_sync.py
@@ -375,7 +375,8 @@ class JobSyncTest(BaseJobTest):
         job_dst.sync(job_src, sync.FileSync.always, exclude='test', recursive=True)
         self.assertTrue(differs('test'))
         self.assertTrue(differs('subdir/test2'))
-        job_dst.sync(job_src, sync.FileSync.always, exclude=['test', 'bs'], recursive=True)
+        job_dst.sync(job_src, sync.FileSync.always,
+                     exclude=['test', 'non-existent-key'], recursive=True)
         self.assertTrue(differs('test'))
         self.assertTrue(differs('subdir/test2'))
         sleep(1)