Repository: glotzerlab/signac (python)

Issue:
Error when storing NumPy arrays in job document.
### Description

Trying to save a NumPy array to the job document fails after loading the job document back (e.g. when adding another key or printing its contents).

### To reproduce

```python
import signac
import numpy as np

project = signac.init_project('dfs-update-bug')
for job in project:
    job.remove()
job = project.open_job({'a': 0})
job.init()
job.doc.my_array = np.array([0, 1, 2])
print(job.doc)
```

### Error output

```python
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-1-4f94befa8a51> in <module>
      7 job = project.open_job({'a': 0}).init()
      8 job.doc.my_array = np.array([0, 1, 2])
----> 9 print(job.doc)

~/code/signac/signac/core/synceddict.py in __str__(self)
    264 
    265     def __str__(self):
--> 266         return str(self())
    267 
    268     def _as_dict(self):

~/code/signac/signac/core/synceddict.py in __call__(self)
    271 
    272     def __call__(self):
--> 273         self._synced_load()
    274         return self._as_dict()
    275 

~/code/signac/signac/core/synceddict.py in _synced_load(self)
    157 
    158     def _synced_load(self):
--> 159         self.load()
    160 
    161     def load(self):

~/code/signac/signac/core/synceddict.py in load(self)
    165                 if data is not None:
    166                     with self._suspend_sync():
--> 167                         self._dfs_update(self._data, data)
    168                     for value in self._data:
    169                         if isinstance(value, Mapping):

~/code/signac/signac/core/synceddict.py in _dfs_update(self, old, new)
    143         for key in new:
    144             if key in old:
--> 145                 if old[key] == new[key]:
    146                     continue
    147                 elif isinstance(new[key], Mapping) and isinstance(old[key], Mapping):

ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()
```

### System configuration

Please complete the following information:

 - Operating System: Linux-4.4.0-17134-Microsoft-x86_64-with-debian-buster-sid
 - Version of Python: 3.6.8 |Anaconda, Inc.| (default, Dec 30 2018, 01:22:34) [GCC 7.3.0]
 - Version of signac: 1.0.0 (current `master`)

Pull request description:
Convert NumPy arrays when updating _SyncedDict.
## Description
When writing a `numpy.ndarray` to a `_SyncedDict`, the actual data is written to JSON by converting arrays to lists in the `CustomJSONEncoder`. However, the function `_dfs_update` checks that `old[key] == new[key]` and this fails when comparing `numpy.ndarray` to a `_SyncedList`. (See #184 for details.)

When re-opening the job later, the type of the array becomes `_SyncedList` and this problem doesn't show up. The current tests did not catch the problematic behavior for this reason - they did not check the data immediately after assigning the value in `job.document`.

## Motivation and Context
This PR resolves #184.

## Types of Changes
<!-- Please select all items that apply either now or after creating the pull request: -->
- [x] Bug fix

## Checklist:
- [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.txt).
- [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 [changelog](https://github.com/glotzerlab/signac/blob/master/changelog.txt).

Patch:
diff --git a/changelog.txt b/changelog.txt
index 3b5d7f38..020aea3e 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -24,6 +24,7 @@ next
  - Fix: Passing an instance of dict to `H5Store.setdefault()` will return an instance of `H5Group` instead of a dict (#180).
  - Improve the representation (return value of `repr()`) of instances of `H5Group`.
  - Improve the representation (return value of `repr()`) of instances of `SyncedAttrDict`.
+ - Fix error with storing numpy arrays and scalars in a synced dictionary (e.g. `job.statepoint`, `job.document`) (#184).
 
 
 [1.0.0] -- 2019-02-28
diff --git a/signac/core/synceddict.py b/signac/core/synceddict.py
index 6dad2f97..37a58748 100644
--- a/signac/core/synceddict.py
+++ b/signac/core/synceddict.py
@@ -15,6 +15,11 @@ if six.PY2:
 else:
     from collections.abc import Mapping
     from collections.abc import MutableMapping
+try:
+    import numpy
+    NUMPY = True
+except ImportError:
+    NUMPY = False
 
 
 logger = logging.getLogger(__name__)
@@ -107,6 +112,11 @@ class _SyncedDict(MutableMapping):
             return ret
         elif type(root) is list:
             return _SyncedList(root, self)
+        elif NUMPY:
+            if isinstance(root, numpy.number):
+                return root.item()
+            elif isinstance(root, numpy.ndarray):
+                return _SyncedList(root.tolist(), self)
         return root
 
     @classmethod


Test patch:
diff --git a/tests/test_numpy_integration.py b/tests/test_numpy_integration.py
index c108560a..99a1bd58 100644
--- a/tests/test_numpy_integration.py
+++ b/tests/test_numpy_integration.py
@@ -5,6 +5,7 @@ import unittest
 from test_project import BaseProjectTest
 try:
     import numpy    # noqa
+    import numpy.testing
     NUMPY = True
 except ImportError:
     NUMPY = False
@@ -19,6 +20,7 @@ class NumpyIntegrationTest(BaseProjectTest):
             b = numpy.float64(i) if i % 2 else numpy.float32(i)
             job = self.project.open_job(dict(a=a))
             job.doc.b = b
+            numpy.testing.assert_equal(job.doc.b, b)
         for i, job in enumerate(sorted(self.project, key=lambda job: job.sp.a)):
             self.assertEqual(job.sp.a, i)
             self.assertEqual(job.doc.b, i)
@@ -34,6 +36,7 @@ class NumpyIntegrationTest(BaseProjectTest):
         for i in range(10):
             job = self.project.open_job(dict(a=i))
             job.doc.array = numpy.ones(3) * i
+            numpy.testing.assert_equal(job.doc.array, numpy.ones(3) * i)
         for i, job in enumerate(sorted(self.project, key=lambda job: job.sp.a)):
             self.assertEqual(i, job.sp.a)
             self.assertTrue(((numpy.array([i, i, i]) == job.doc.array).all()))