Repository: glotzerlab/signac
Pull request #378 (2020-08-20 20:17:25)

Added validation layer to SyncedCollection
<!-- Provide a general summary of your changes in the Title above -->

## Description
<!-- Describe your changes in detail -->
This PR adds the validation layer to the `SyncedCollection`.
Currently, we only validate the key of `SyncedAttrDict`. This PR extends the behavior to validate the data input to `SyncedCollection`. It adds `AttrFormatValidator` for `SyncedAttrDict` and `JSONFormatValidator` for `JSONCollection`. 
## Motivation and Context
<!-- Why is this change required? What problem does it solve? -->
<!-- If it fixes an open issue, please link to the issue here. -->
Related to #249 and #316.
## Types of Changes
<!-- Please select all items that apply either now or after creating the pull request: -->
- [ ] Documentation update
- [ ] Bug fix
- [x] New feature
- [ ] Breaking change<sup>1</sup>

<sup>1</sup>The change breaks (or has the potential to break) existing functionality.

## Checklist:
<!-- Please select all items that apply either now or after creating the pull request. -->
<!-- If you are unsure about any of these items, do not hesitate to ask! -->
- [x] I am familiar with the [**Contributing Guidelines**](https://github.com/glotzerlab/signac/blob/master/CONTRIBUTING.md).
- [x] I agree with the terms of the [**Contributor Agreement**](https://github.com/glotzerlab/signac/blob/master/ContributorAgreement.md).
- [x] My name is on the [list of contributors](https://github.com/glotzerlab/signac/blob/master/contributors.yaml).
- [x] My code follows the [code style guideline](https://github.com/glotzerlab/signac/blob/master/CONTRIBUTING.md#code-style) of this project.
- [x] The changes introduced by this pull request are covered by existing or newly introduced tests.

If necessary:
- [ ] I have updated the API documentation as part of the package doc-strings.
- [ ] I have created a separate pull request to update the [framework documentation](https://docs.signac.io/) on [signac-docs](https://github.com/glotzerlab/signac-docs) and linked it here.
- [ ] I have updated the [changelog](https://github.com/glotzerlab/signac/blob/master/changelog.txt) and added all related issue and pull request numbers for future reference (if applicable). See example below.


Example for a changelog entry: `Fix issue with launching rockets to the moon (#101, #212).`


codecov[bot]: # [Codecov](https://codecov.io/gh/glotzerlab/signac/pull/378?src=pr&el=h1) Report
> Merging [#378](https://codecov.io/gh/glotzerlab/signac/pull/378?src=pr&el=desc) into [feature/synced_collections](https://codecov.io/gh/glotzerlab/signac/commit/7605b6b1def69d28ac97498e5a0382de370cd877?el=desc) will **increase** coverage by `0.11%`.
> The diff coverage is `96.20%`.

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

```diff
@@                      Coverage Diff                       @@
##           feature/synced_collections     #378      +/-   ##
==============================================================
+ Coverage                       77.28%   77.40%   +0.11%     
==============================================================
  Files                              47       47              
  Lines                            7453     7510      +57     
==============================================================
+ Hits                             5760     5813      +53     
- Misses                           1693     1697       +4     
```


| [Impacted Files](https://codecov.io/gh/glotzerlab/signac/pull/378?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [signac/core/syncedattrdict.py](https://codecov.io/gh/glotzerlab/signac/pull/378/diff?src=pr&el=tree#diff-c2lnbmFjL2NvcmUvc3luY2VkYXR0cmRpY3QucHk=) | `98.59% <92.30%> (-1.41%)` | :arrow_down: |
| [signac/core/jsoncollection.py](https://codecov.io/gh/glotzerlab/signac/pull/378/diff?src=pr&el=tree#diff-c2lnbmFjL2NvcmUvanNvbmNvbGxlY3Rpb24ucHk=) | `98.68% <97.36%> (-1.32%)` | :arrow_down: |
| [signac/core/synced\_collection.py](https://codecov.io/gh/glotzerlab/signac/pull/378/diff?src=pr&el=tree#diff-c2lnbmFjL2NvcmUvc3luY2VkX2NvbGxlY3Rpb24ucHk=) | `93.61% <100.00%> (+0.35%)` | :arrow_up: |
| [signac/core/synced\_list.py](https://codecov.io/gh/glotzerlab/signac/pull/378/diff?src=pr&el=tree#diff-c2lnbmFjL2NvcmUvc3luY2VkX2xpc3QucHk=) | `99.01% <100.00%> (-0.99%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/glotzerlab/signac/pull/378?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/378?src=pr&el=footer). Last update [7605b6b...64df461](https://codecov.io/gh/glotzerlab/signac/pull/378?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

vishav1771: @vyasr We are using JSON encoding in `Zarr` and `Redis`  backends. Should I add `JSONFormatValidator` to their default validators?
vyasr: > @vyasr We are using JSON encoding in `Zarr` and `Redis` backends. Should I add `JSONFormatValidator` to their default validators?

No, the `SyncedCollection` classes should only have default validation if the back end itself requires that validation to work. All of these classes should be developed to work seamlessly with `signac`, but I think we should development them as a completely independent module to the greatest degree possible. The requirement for `Zarr` and `Redis` backends to have JSON formatting is not a requirement of these types of collections, but rather a requirement of `signac`, which requires valid JSON no matter what back end is being used. Therefore, `signac` should take care of registering the appropriate additional validators for all back ends.
bdice: Also @vishav1771 it looks like the Zarr backend is failing some tests, because of something with JSON encoding of `str` and `int` keys. We are planning to disable the automatic conversion of `int` keys to `str` in signac 2.0 but I would be fine with disabling support for that with non-JSON backends and skipping some tests of that behavior (rather than enabling the automatic conversion for other backends). Be sure to add comments in the test files explaining the reason if you skip tests.
vishav1771: @bdice  @vyasr  I have done the changes except changing the `_validator` to `set`.
bdice: @vishav1771 I made a few commits to fix some of the items @vyasr requested. I'll leave the rest to you.

Patch:
diff --git a/setup.cfg b/setup.cfg
index 75d15899..2b0b4d37 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -28,7 +28,7 @@ omit =
 
 [tool:pytest]
 filterwarnings =
-	ignore: .*[The indexing module | get_statepoint] is deprecated.*: DeprecationWarning
+	ignore: .*[The indexing module | get_statepoint | Use of.+as key] is deprecated.*: DeprecationWarning
 
 [bumpversion:file:setup.py]
 
diff --git a/signac/core/collection_json.py b/signac/core/collection_json.py
index 18bcd7de..e1d70e4f 100644
--- a/signac/core/collection_json.py
+++ b/signac/core/collection_json.py
@@ -11,16 +11,39 @@ import os
 import json
 import errno
 import uuid
+import warnings
 
 from .synced_collection import SyncedCollection
 from .syncedattrdict import SyncedAttrDict
 from .synced_list import SyncedList
+from .validators import json_format_validator
+
+
+def _convert_key_to_str(data):
+    """Recursively convert non-string keys to strings for (potentially nested) input collections.
+
+    This retains compatibility with auto-casting keys to strings, and will be
+    removed in signac 2.0.
+    Input collections must be of "base type" (dict or list).
+    """
+    if isinstance(data, dict):
+        def _str_key(key):
+            if not isinstance(key, str):
+                warnings.warn(f"Use of {type(key).__name__} as key is deprecated "
+                              "and will be removed in version 2.0",
+                              DeprecationWarning)
+                key = str(key)
+            return key
+        return {_str_key(key): _convert_key_to_str(value) for key, value in data.items()}
+    elif isinstance(data, list):
+        return [_convert_key_to_str(value) for value in data]
+    return data
 
 
 class JSONCollection(SyncedCollection):
     """Implement sync and load using a JSON back end."""
 
-    backend = __name__  # type: ignore
+    _backend = __name__  # type: ignore
 
     def __init__(self, filename=None, write_concern=False, **kwargs):
         self._filename = None if filename is None else os.path.realpath(filename)
@@ -29,7 +52,7 @@ class JSONCollection(SyncedCollection):
         super().__init__(**kwargs)
 
     def _load(self):
-        """Load the data from a JSON-file."""
+        """Load the data from a JSON file."""
         try:
             with open(self._filename, 'rb') as file:
                 blob = file.read()
@@ -39,9 +62,11 @@ class JSONCollection(SyncedCollection):
                 return None
 
     def _sync(self):
-        """Write the data to json file."""
+        """Write the data to JSON file."""
         data = self.to_base()
-        # Serialize data:
+        # Converting non-string keys to string
+        data = _convert_key_to_str(data)
+        # Serialize data
         blob = json.dumps(data).encode()
         # When write_concern flag is set, we write the data into dummy file and then
         # replace that file with original file.
@@ -57,10 +82,13 @@ class JSONCollection(SyncedCollection):
                 file.write(blob)
 
 
+JSONCollection.add_validator(json_format_validator)
+
+
 class JSONDict(JSONCollection, SyncedAttrDict):
     """A dict-like mapping interface to a persistent JSON file.
 
-    The JSONDict inherits from :class:`~core.collection_api.SyncedCollection`
+    The JSONDict inherits from :class:`~core.synced_collection.SyncedCollection`
     and :class:`~core.syncedattrdict.SyncedAttrDict`.
 
     .. code-block:: python
@@ -106,7 +134,7 @@ class JSONDict(JSONCollection, SyncedAttrDict):
 class JSONList(JSONCollection, SyncedList):
     """A non-string sequence interface to a persistent JSON file.
 
-    The JSONList inherits from :class:`~core.collection_api.SyncedCollection`
+    The JSONList inherits from :class:`~core.synced_collection.SyncedCollection`
     and :class:`~core.syncedlist.SyncedList`.
 
     .. code-block:: python
diff --git a/signac/core/collection_mongodb.py b/signac/core/collection_mongodb.py
index 83897e71..62fd1263 100644
--- a/signac/core/collection_mongodb.py
+++ b/signac/core/collection_mongodb.py
@@ -16,10 +16,10 @@ from .synced_list import SyncedList
 class MongoDBCollection(SyncedCollection):
     """Implement sync and load using a MongoDB backend."""
 
-    backend = __name__  # type: ignore
+    _backend = __name__  # type: ignore
 
     def __init__(self, collection=None, **kwargs):
-        import bson  # for InvalidDocument
+        import bson  # for InvalidDocument error
 
         self._collection = collection
         self._errors = bson.errors
@@ -53,7 +53,7 @@ class MongoDBCollection(SyncedCollection):
 class MongoDBDict(MongoDBCollection, SyncedAttrDict):
     """A dict-like mapping interface to a persistent Mongo-database.
 
-    The MongoDBDict inherits from :class:`~core.collection_api.MongoCollection`
+    The MongoDBDict inherits from :class:`~core.synced_collection.MongoCollection`
     and :class:`~core.syncedattrdict.SyncedAttrDict`.
 
     .. code-block:: python
diff --git a/signac/core/collection_redis.py b/signac/core/collection_redis.py
index 1dc9e504..e6c9b79b 100644
--- a/signac/core/collection_redis.py
+++ b/signac/core/collection_redis.py
@@ -17,7 +17,7 @@ from .synced_list import SyncedList
 class RedisCollection(SyncedCollection):
     """Implement sync and load using a Redis backend."""
 
-    backend = __name__  # type: ignore
+    _backend = __name__  # type: ignore
 
     def __init__(self, client=None, **kwargs):
         self._client = client
@@ -90,7 +90,7 @@ class RedisDict(RedisCollection, SyncedAttrDict):
 class RedisList(RedisCollection, SyncedList):
     """A non-string sequence interface to a persistent Redis file.
 
-    The RedisList inherits from :class:`~core.collection_api.SyncedCollection`
+    The RedisList inherits from :class:`~core.synced_collection.SyncedCollection`
     and :class:`~core.syncedlist.SyncedList`.
 
     .. code-block:: python
diff --git a/signac/core/collection_zarr.py b/signac/core/collection_zarr.py
index 3cfef408..1ad5b01f 100644
--- a/signac/core/collection_zarr.py
+++ b/signac/core/collection_zarr.py
@@ -16,7 +16,7 @@ from .synced_list import SyncedList
 class ZarrCollection(SyncedCollection):
     """Implement sync and load using a Zarr backend."""
 
-    backend = __name__  # type: ignore
+    _backend = __name__  # type: ignore
 
     def __init__(self, group=None, **kwargs):
         import numcodecs  # zarr depends on numcodecs
@@ -47,7 +47,7 @@ class ZarrCollection(SyncedCollection):
 class ZarrDict(ZarrCollection, SyncedAttrDict):
     """A dict-like mapping interface to a persistent Zarr-database.
 
-    The ZarrDict inherits from :class:`~core.collection_api.ZarrCollection`
+    The ZarrDict inherits from :class:`~core.synced_collection.ZarrCollection`
     and :class:`~core.syncedattrdict.SyncedAttrDict`.
 
     .. code-block:: python
@@ -92,7 +92,7 @@ class ZarrDict(ZarrCollection, SyncedAttrDict):
 class ZarrList(ZarrCollection, SyncedList):
     """A non-string sequence interface to a persistent Zarr file.
 
-    The ZarrList inherits from :class:`~core.collection_api.ZarrCollection`
+    The ZarrList inherits from :class:`~core.synced_collection.ZarrCollection`
     and :class:`~core.syncedlist.SyncedList`.
 
     .. code-block:: python
diff --git a/signac/core/synced_collection.py b/signac/core/synced_collection.py
index 52eb7c81..b9f6f0e3 100644
--- a/signac/core/synced_collection.py
+++ b/signac/core/synced_collection.py
@@ -9,6 +9,7 @@ backend with different data-structures or vice-versa. It declares as abstract
 methods the methods that must be implemented by any subclass to match the API.
 """
 
+from typing import List, Callable
 from contextlib import contextmanager
 from abc import abstractmethod
 from collections import defaultdict
@@ -29,7 +30,8 @@ class SyncedCollection(Collection):
     in the underlying backend. The backend name wil be same as the module name.
     """
 
-    backend = None
+    _backend = None
+    _validators: List[Callable] = []
 
     def __init__(self, name=None, parent=None):
         self._data = None
@@ -41,26 +43,61 @@ class SyncedCollection(Collection):
                 "Illegal argument combination, one of the two arguments, "
                 "parent or name must be None, but not both.")
 
+    @classmethod
+    def __init_subclass__(cls):
+        """Add  ``_validator`` attribute to every subclass.
+
+        Subclasses contain a list of validators that are applied to collection input data.
+        Every subclass must have a separate list so that distinct sets of validators can
+        be registered to each of them.
+        """
+        cls._validators = []
+
     @classmethod
     def register(cls, *args):
-        """Register the synced data structures.
+        r"""Register the synced data structures.
 
-        Registry is used when recursively converting synced data structures to determine
-        what to convert their children into.
+        The registry is used by :meth:`from_base` to determine the appropriate subclass
+        of :class:`SyncedCollection` that should be constructed from a particular object.
+        This functionality is necessary for converting nested data structures because
+        given, for instance, a dict of lists, it must be possible to map the nested lists to
+        the appropriate subclass of :class:`SyncedList` corresponding to the desired
+        backend.
 
         Parameters
         ----------
-        *args
+        \*args
             Classes to register
         """
         if not hasattr(cls, 'registry'):
             cls.registry = defaultdict(list)
-        for _cls in args:
-            cls.registry[_cls.backend].append(_cls)
+        for base_cls in args:
+            cls.registry[base_cls._backend].append(base_cls)
+
+    @property
+    def validators(self):
+        """Return the list of validators applied to the instance."""
+        validators = []
+        # Classes inherit the validators of their parent classes.
+        for base_cls in type(self).__mro__:
+            if hasattr(base_cls, '_validators'):
+                validators.extend([v for v in base_cls._validators if v not in validators])
+        return validators
+
+    @classmethod
+    def add_validator(cls, *args):
+        r"""Register validator.
+
+        Parameters
+        ----------
+        \*args
+            Validator(s) to register.
+        """
+        cls._validators.extend([v for v in args if v not in cls._validators])
 
     @classmethod
     def from_base(cls, data, backend=None, **kwargs):
-        """Dynamically resolve the type of object to the corresponding synced collection.
+        r"""Dynamically resolve the type of object to the corresponding synced collection.
 
         Parameters
         ----------
@@ -68,7 +105,7 @@ class SyncedCollection(Collection):
             Data to be converted from base class.
         backend: str
             Name of backend for synchronization. Default to backend of class.
-        **kwargs:
+        \*\*kwargs:
             Kwargs passed to instance of synced collection.
 
         Returns
@@ -76,12 +113,12 @@ class SyncedCollection(Collection):
         data : object
             Synced object of corresponding base type.
         """
-        backend = cls.backend if backend is None else backend
+        backend = cls._backend if backend is None else backend
         if backend is None:
             raise ValueError("No backend found.")
-        for _cls in cls.registry[backend]:
-            if _cls.is_base_type(data):
-                return _cls(data=data, **kwargs)
+        for base_cls in cls.registry[backend]:
+            if base_cls.is_base_type(data):
+                return base_cls(data=data, **kwargs)
         if NUMPY:
             if isinstance(data, numpy.number):
                 return data.item()
@@ -133,6 +170,11 @@ class SyncedCollection(Collection):
             else:
                 self._parent.load()
 
+    def _validate(self, data):
+        """Validate the input data."""
+        for validator in self.validators:
+            validator(data)
+
     # The following methods share a common implementation for
     # all data structures and regardless of backend.
     def __getitem__(self, key):
diff --git a/signac/core/synced_list.py b/signac/core/synced_list.py
index 771330ae..184dd70e 100644
--- a/signac/core/synced_list.py
+++ b/signac/core/synced_list.py
@@ -3,8 +3,8 @@
 # This software is licensed under the BSD 3-Clause License.
 """Implements the SyncedList class.
 
-This implements the list data-structure for SyncedCollection API by
-implementing the convert methods (`to_base`, `from_base`) for lists.
+This implements the list data structure for SyncedCollection API by
+implementing the convert method `to_base` for lists.
 """
 
 from collections.abc import Sequence
@@ -20,18 +20,19 @@ if NUMPY:
 class SyncedList(SyncedCollection, MutableSequence):
     """Implementation of list data structure.
 
-    The SyncedList inherits from :class:`~core.collection_api.SyncedCollection`
+    The SyncedList inherits from :class:`~core.synced_collection.SyncedCollection`
     and :class:`~collections.abc.MutableSequence`. Therefore, it behaves similar
     to a :class:`list`.
 
     .. warning::
 
-        While the SyncedList object behaves like a dictionary, there are
+        While the SyncedList object behaves like a :class:`list`, there are
         important distinctions to remember. In particular, because operations
         are reflected as changes to an underlying backend, copying (even deep
         copying) a SyncedList instance may exhibit unexpected behavior. If a
         true copy is required, you should use the `to_base()` method to get a
-        dictionary representation, and if necessary construct a new SyncedList.
+        :class:`list` representation, and if necessary construct a new
+        SyncedList.
     """
 
     def __init__(self, data=None, **kwargs):
@@ -39,6 +40,7 @@ class SyncedList(SyncedCollection, MutableSequence):
         if data is None:
             self._data = []
         else:
+            self._validate(data)
             if NUMPY and isinstance(data, numpy.ndarray):
                 data = data.tolist()
             with self._suspend_sync():
@@ -85,6 +87,7 @@ class SyncedList(SyncedCollection, MutableSequence):
         """Update the instance of SyncedList with data using depth-first traversal."""
         if data is None:
             data = []
+        self._validate(data)
         if isinstance(data, Sequence) and not isinstance(data, str):
             with self._suspend_sync():
                 # This loop avoids rebuilding existing synced collections for performance.
@@ -123,8 +126,9 @@ class SyncedList(SyncedCollection, MutableSequence):
         """
         if data is None:
             data = []
-        if NUMPY and isinstance(data, numpy.ndarray):
+        elif NUMPY and isinstance(data, numpy.ndarray):
             data = data.tolist()
+        self._validate(data)
         if isinstance(data, Sequence) and not isinstance(data, str):
             with self._suspend_sync():
                 self._data = [self.from_base(data=value, parent=self) for value in data]
@@ -135,6 +139,7 @@ class SyncedList(SyncedCollection, MutableSequence):
                 .format(type(data)))
 
     def __setitem__(self, key, value):
+        self._validate(value)
         self.load()
         with self._suspend_sync():
             self._data[key] = self.from_base(data=value, parent=self)
@@ -145,28 +150,36 @@ class SyncedList(SyncedCollection, MutableSequence):
         return reversed(self._data)
 
     def __iadd__(self, iterable):
+        # Convert input to a list so that iterators work as well as iterables.
+        iterable_data = list(iterable)
+        self._validate(iterable_data)
         self.load()
         with self._suspend_sync():
-            self._data += [self.from_base(data=value, parent=self) for value in iterable]
+            self._data += [self.from_base(data=value, parent=self) for value in iterable_data]
         self.sync()
         return self
 
     def insert(self, index, item):
+        self._validate(item)
         self.load()
         with self._suspend_sync():
             self._data.insert(index, self.from_base(data=item, parent=self))
         self.sync()
 
     def append(self, item):
+        self._validate(item)
         self.load()
         with self._suspend_sync():
             self._data.append(self.from_base(data=item, parent=self))
         self.sync()
 
     def extend(self, iterable):
+        # Convert iterable to a list to ensure generators are exhausted only once
+        iterable_data = list(iterable)
+        self._validate(iterable_data)
         self.load()
         with self._suspend_sync():
-            self._data.extend([self.from_base(data=value, parent=self) for value in iterable])
+            self._data.extend([self.from_base(data=value, parent=self) for value in iterable_data])
         self.sync()
 
     def remove(self, item):
diff --git a/signac/core/syncedattrdict.py b/signac/core/syncedattrdict.py
index 20665045..88fea769 100644
--- a/signac/core/syncedattrdict.py
+++ b/signac/core/syncedattrdict.py
@@ -4,7 +4,7 @@
 """Implements the SyncedAttrDict class.
 
 This implements the dict data-structure for SyncedCollection API by
-implementing the convert methods (`to_base`, `from_base`) for dictionaries.
+implementing the convert method `to_base` for dictionaries.
 This class also allows access to values through key indexing or attributes
 named by keys, including nested keys.
 """
@@ -13,41 +13,39 @@ from collections.abc import Mapping
 from collections.abc import MutableMapping
 
 from .synced_collection import SyncedCollection
-from ..errors import InvalidKeyError
-from ..errors import KeyTypeError
+from .validators import no_dot_in_key
 
 
 class SyncedAttrDict(SyncedCollection, MutableMapping):
     """Implement the dict data structure along with values access through attributes named as keys.
 
-    The SyncedAttrDict inherits from :class:`~core.collection_api.SyncedCollection`
+    The SyncedAttrDict inherits from :class:`~core.synced_collection.SyncedCollection`
     and :class:`~collections.abc.MutableMapping`. Therefore, it behaves similar to
     a :class:`dict`. This class also allows access to values through key indexing or
     attributes named by keys, including nested keys.
 
     .. warning::
 
-        While the SyncedAttrDict object behaves like a dictionary, there are
+        While the SyncedAttrDict object behaves like a :class:`dict`, there are
         important distinctions to remember. In particular, because operations
         are reflected as changes to an underlying backend, copying (even deep
         copying) a SyncedAttrDict instance may exhibit unexpected behavior. If a
         true copy is required, you should use the `to_base()` method to get a
-        dictionary representation, and if necessary construct a new SyncedAttrDict.
+        :class:`dict` representation, and if necessary construct a new SyncedAttrDict.
     """
 
-    _PROTECTED_KEYS = ('_data', '_name', '_suspend_sync_', '_load', '_sync', '_parent')
-
-    VALID_KEY_TYPES = (str, int, bool, type(None))
+    _PROTECTED_KEYS = ('_data', '_name', '_suspend_sync_', '_load', '_sync', '_parent',
+                       '_validators')
 
     def __init__(self, data=None, **kwargs):
         super().__init__(**kwargs)
         if data is None:
             self._data = {}
         else:
+            self._validate(data)
             with self._suspend_sync():
                 self._data = {
-                    self._validate_key(key): self.from_base(data=value, parent=self)
-                    for key, value in data.items()
+                    key: self.from_base(data=value, parent=self) for key, value in data.items()
                 }
             self.sync()
 
@@ -89,6 +87,7 @@ class SyncedAttrDict(SyncedCollection, MutableMapping):
         if data is None:
             data = {}
         if isinstance(data, Mapping):
+            self._validate(data)
             with self._suspend_sync():
                 # This loop avoids rebuilding existing synced collections for performance.
                 for key in data:
@@ -101,7 +100,7 @@ class SyncedAttrDict(SyncedCollection, MutableMapping):
                                 continue
                             except ValueError:
                                 pass
-                    self._data[self._validate_key(key)] = self.from_base(data[key], parent=self)
+                    self._data[key] = self.from_base(data[key], parent=self)
                 remove = set()
                 for key in self._data:
                     if key not in data:
@@ -112,24 +111,11 @@ class SyncedAttrDict(SyncedCollection, MutableMapping):
             raise ValueError(
                 "Unsupported type: {}. The data must be a mapping or None.".format(type(data)))
 
-    @staticmethod
-    def _validate_key(key):
-        """Raise an exception if key is invalid. Returns key."""
-        if isinstance(key, SyncedAttrDict.VALID_KEY_TYPES):
-            key = str(key)
-            if '.' in key:
-                raise InvalidKeyError(
-                    "SyncedDict keys may not contain dots ('.'): {}".format(key))
-            else:
-                return key
-        else:
-            raise KeyTypeError(
-                "SyncedDict keys must be str, int, bool or None, not {}".format(type(key).__name__))
-
     def __setitem__(self, key, value):
+        self._validate({key: value})
         self.load()
         with self._suspend_sync():
-            self._data[self._validate_key(key)] = self.from_base(data=value, parent=self)
+            self._data[key] = self.from_base(value, parent=self)
         self.sync()
 
     def reset(self, data=None):
@@ -147,13 +133,11 @@ class SyncedAttrDict(SyncedCollection, MutableMapping):
         """
         if data is None:
             data = {}
-
         if isinstance(data, Mapping):
-            self.load()
+            self._validate(data)
             with self._suspend_sync():
                 self._data = {
-                    self._validate_key(key): self.from_base(data=value, parent=self)
-                    for key, value in data.items()
+                    key: self.from_base(data=value, parent=self) for key, value in data.items()
                 }
             self.sync()
         else:
@@ -193,19 +177,33 @@ class SyncedAttrDict(SyncedCollection, MutableMapping):
         self._data = {}
         self.sync()
 
-    def update(self, mapping):
+    def update(self, other=None, **kwargs):
+        if other is not None:
+            # Convert sequence of key, value pairs to dict before validation
+            if not isinstance(other, Mapping):
+                other = dict(other)
+            self._validate(other)
+        if kwargs:
+            self._validate(kwargs)
         self.load()
         with self._suspend_sync():
-            for key, value in mapping.items():
-                self._data[self._validate_key(key)] = self.from_base(data=value, parent=self)
+            if other:
+                for key, value in other.items():
+                    self._data[key] = self.from_base(data=value, parent=self)
+            for key, value in kwargs.items():
+                self._data[key] = self.from_base(data=value, parent=self)
         self.sync()
 
     def setdefault(self, key, default=None):
         self.load()
-        with self._suspend_sync():
-            ret = self._data.setdefault(self._validate_key(key),
-                                        self.from_base(data=default, parent=self))
-        self.sync()
+        if key in self._data:
+            ret = self._data[key]
+        else:
+            self._validate({key: default})
+            ret = self.from_base(default, parent=self)
+            with self._suspend_sync():
+                self._data[key] = ret
+            self.sync()
         return ret
 
     def __getattr__(self, name):
@@ -232,3 +230,6 @@ class SyncedAttrDict(SyncedCollection, MutableMapping):
             super().__delattr__(key)
         else:
             self.__delitem__(key)
+
+
+SyncedAttrDict.add_validator(no_dot_in_key)
diff --git a/signac/core/validators.py b/signac/core/validators.py
new file mode 100644
index 00000000..6a483c27
--- /dev/null
+++ b/signac/core/validators.py
@@ -0,0 +1,89 @@
+# Copyright (c) 2020 The Regents of the University of Michigan
+# All rights reserved.
+# This software is licensed under the BSD 3-Clause License.
+"""Validators for SyncedCollection API.
+
+A validator is any callable that raises Exceptions when called with invalid data.
+Validators should act recursively for nested data structures and should not
+return any values, only raise errors.
+"""
+
+from collections.abc import Mapping
+from collections.abc import Sequence
+
+from ..errors import InvalidKeyError
+from ..errors import KeyTypeError
+
+try:
+    import numpy
+    NUMPY = True
+except ImportError:
+    NUMPY = False
+
+
+def no_dot_in_key(data):
+    """Raise an exception if there is a dot (``.``) in a mapping's key.
+
+    Parameters
+    ----------
+    data
+        Data to validate.
+
+    Raises
+    ------
+    KeyTypeError
+        If key data type is not supported.
+    InvalidKeyError
+        If the key contains invalid characters or is otherwise malformed.
+    """
+    VALID_KEY_TYPES = (str, int, bool, type(None))
+
+    if isinstance(data, Mapping):
+        for key, value in data.items():
+            if isinstance(key, str):
+                if '.' in key:
+                    raise InvalidKeyError(
+                        f"Mapping keys may not contain dots ('.'): {key}")
+            elif not isinstance(key, VALID_KEY_TYPES):
+                raise KeyTypeError(
+                    f"Mapping keys must be str, int, bool or None, not {type(key).__name__}")
+            no_dot_in_key(value)
+    elif isinstance(data, Sequence) and not isinstance(data, str):
+        for value in data:
+            no_dot_in_key(value)
+
+
+def json_format_validator(data):
+    """Validate input data can be serialized to JSON.
+
+    Parameters
+    ----------
+    data
+        Data to validate.
+
+    Raises
+    ------
+    KeyTypeError
+        If key data type is not supported.
+    TypeError
+        If the data type of ``data`` is not supported.
+    """
+
+    if isinstance(data, (str, int, float, bool, type(None))):
+        return
+    elif isinstance(data, Mapping):
+        for key, value in data.items():
+            # Support for non-str keys will be removed in version 2.0.
+            # See issue: https://github.com/glotzerlab/signac/issues/316.
+            if not isinstance(key, (str, int, bool, type(None))):
+                raise KeyTypeError(
+                    f"Keys must be str, int, bool or None, not {type(key).__name__}")
+            json_format_validator(value)
+    elif isinstance(data, Sequence):
+        for value in data:
+            json_format_validator(value)
+    elif NUMPY and isinstance(data, (numpy.ndarray, numpy.number)):
+        if numpy.iscomplex(data).any():
+            raise TypeError("NumPy object with complex value(s) is not JSON serializable")
+    else:
+        raise TypeError(f"Object of type {type(data).__name__} is not JSON serializable")


Test patch:
diff --git a/tests/test_synced_collection.py b/tests/test_synced_collection.py
index 0ba22c43..594d09d9 100644
--- a/tests/test_synced_collection.py
+++ b/tests/test_synced_collection.py
@@ -154,6 +154,23 @@ class TestJSONDict:
         synced_dict.update(d)
         assert len(synced_dict) == 1
         assert synced_dict[key] == d[key]
+        # upadte with no argument
+        synced_dict.update()
+        assert len(synced_dict) == 1
+        assert synced_dict[key] == d[key]
+        # update using key as kwarg
+        synced_dict.update(update2=testdata)
+        assert len(synced_dict) == 2
+        assert synced_dict['update2'] == testdata
+        # same key in other dict and as kwarg with different values
+        synced_dict.update({key: 1}, update=2)  # here key is 'update'
+        assert len(synced_dict) == 2
+        assert synced_dict[key] == 2
+        # update using list of key and value pair
+        synced_dict.update([('update2', 1), ('update3', 2)])
+        assert len(synced_dict) == 3
+        assert synced_dict['update2'] == 1
+        assert synced_dict['update3'] == 2
 
     def test_pop(self, synced_dict, testdata):
         key = 'pop'
@@ -200,6 +217,16 @@ class TestJSONDict:
             assert not isinstance(val, type(synced_dict))
             assert (key, val) in data.items()
 
+    def test_setdefault(self, synced_dict, testdata):
+        key = 'setdefault'
+        ret = synced_dict.setdefault(key, testdata)
+        assert ret == testdata
+        assert key in synced_dict
+        assert synced_dict[key] == testdata
+        ret = synced_dict.setdefault(key, 1)
+        assert ret == testdata
+        assert synced_dict[key] == testdata
+
     def test_reset(self, synced_dict, testdata):
         key = 'reset'
         synced_dict[key] = testdata
@@ -345,14 +372,25 @@ class TestJSONDict:
         with pytest.raises(InvalidKeyError):
             synced_dict['a.b'] = None
 
-    def test_keys_valid_type(self, synced_dict, testdata):
+    def test_keys_str_type(self, synced_dict, testdata):
 
         class MyStr(str):
             pass
-        for key in ('key', MyStr('key'), 0, None, True):
+        for key in ('key', MyStr('key')):
             synced_dict[key] = testdata
-            assert str(key) in synced_dict
-            assert synced_dict[str(key)] == testdata
+            assert key in synced_dict
+            assert synced_dict[key] == testdata
+
+    # The following test tests the support for non-str keys
+    # for JSON backend which will be removed in version 2.0.
+    # See issue: https://github.com/glotzerlab/signac/issues/316.
+    def test_keys_non_str_valid_type(self, synced_dict, testdata):
+        if isinstance(synced_dict, JSONDict):
+            for key in (0, None, True):
+                with pytest.deprecated_call(match="Use of.+as key is deprecated"):
+                    synced_dict[key] = testdata
+                assert str(key) in synced_dict
+                assert synced_dict[str(key)] == testdata
 
     def test_keys_invalid_type(self, synced_dict, testdata):
 
@@ -450,6 +488,25 @@ class TestJSONList:
         assert synced_list[0] == d[0]
         assert synced_list[1] == d1
 
+        # Ensure generators are exhausted only once by extend
+        def data_generator():
+            yield testdata
+        synced_list.extend(data_generator())
+        assert len(synced_list) == 3
+        assert synced_list[0] == d[0]
+        assert synced_list[1] == d1
+        assert synced_list[2] == testdata
+
+        # Ensure generators are exhausted only once by __iadd__
+        def data_generator():
+            yield testdata
+        synced_list += data_generator()
+        assert len(synced_list) == 4
+        assert synced_list[0] == d[0]
+        assert synced_list[1] == d1
+        assert synced_list[2] == testdata
+        assert synced_list[3] == testdata
+
     def test_clear(self, synced_list, testdata):
         synced_list.append(testdata)
         assert len(synced_list) == 1
diff --git a/tests/test_validators.py b/tests/test_validators.py
new file mode 100644
index 00000000..12f85f31
--- /dev/null
+++ b/tests/test_validators.py
@@ -0,0 +1,96 @@
+# Copyright (c) 2020 The Regents of the University of Michigan
+# All rights reserved.
+# This software is licensed under the BSD 3-Clause License.
+import pytest
+
+from signac.core.validators import no_dot_in_key
+from signac.core.validators import json_format_validator
+from signac.errors import KeyTypeError
+from signac.errors import InvalidKeyError
+
+try:
+    import numpy
+    NUMPY = True
+except ImportError:
+    NUMPY = False
+
+
+class TestNoDotInKey:
+
+    def test_valid_data(self, testdata):
+        test_dict = {}
+        # valid data
+        for key in ('valid_str', 1, False, None):
+            test_dict[key] = testdata
+            no_dot_in_key(test_dict)  # should not raise any error.
+            assert key in test_dict
+            assert test_dict[key] == testdata
+
+    def test_invalid_data(self, testdata):
+        # dict key containing dot
+        with pytest.raises(InvalidKeyError):
+            no_dot_in_key({'a.b': testdata})
+        # nested dict key containing dot
+        with pytest.raises(InvalidKeyError):
+            no_dot_in_key({'nested': {'a.b': 1}})
+        # list containing dict
+        with pytest.raises(InvalidKeyError):
+            no_dot_in_key([{'a.b': 1}])
+        # invalid key types
+        for key in (0.0, 1.0 + 2.0j, (1, 2, 3)):
+            with pytest.raises(KeyTypeError):
+                no_dot_in_key({key: testdata})
+
+
+class TestJSONFormatValidator:
+
+    def test_valid_data(self):
+        for data in ('foo', 1, 1.0, True, None, {}, []):
+            json_format_validator(data)  # should not raise any error
+            json_format_validator({'test_key': data})  # should not raise any error
+        json_format_validator(('foo', 1, 1.0, True, None, {}, []))  # should not raise any error
+
+    def test_dict_data(self, testdata):
+        for data in ('foo', 1, 1.0, True, None):
+            json_format_validator({'test_key': data})  # should not raise any error
+        for key in (0.0, (1, 2, 3)):
+            with pytest.raises(KeyTypeError):
+                json_format_validator({key: testdata})
+
+    @pytest.mark.skipif(not NUMPY, reason='test requires the numpy package')
+    def test_numpy_data(self):
+        data = numpy.random.rand(3, 4)
+        json_format_validator(data)  # should not raise any error
+        json_format_validator(numpy.float_(3.14))  # should not raise any error
+        # numpy data as dict value
+        json_format_validator({'test': data})
+        json_format_validator({'test': numpy.float_(1.0)})
+        # numpy data in list
+        json_format_validator([data, numpy.float_(1.0), 1, 'test'])
+
+    def test_invalid_data(self):
+
+        class A:
+            pass
+        invalid_data = (1.0 + 2.0j, A())
+        for data in invalid_data:
+            with pytest.raises(TypeError):
+                json_format_validator(data)
+        # invalid data as dict value
+        for data in invalid_data:
+            with pytest.raises(TypeError):
+                json_format_validator({'test': data})
+        # invalid data in tuple
+        with pytest.raises(TypeError):
+            json_format_validator(invalid_data)
+
+    @pytest.mark.skipif(not NUMPY, reason='test requires the numpy package')
+    def test_numpy_invalid_data(self):
+        # complex data
+        data = numpy.complex(1 + 2j)
+        with pytest.raises(TypeError):
+            json_format_validator(data)
+        # complex data in ndarray
+        data = numpy.asarray([1, 2, 1j, 1 + 2j])
+        with pytest.raises(TypeError):
+            json_format_validator(data)