Repository: glotzerlab/signac

Issue:
Calling `_SyncedDict.values()` gives `dict` objects
<!-- Please replace the text in the individual sections below. -->

### Description

Calling standard `MutableMapping` methods (`keys`, `values`, etc) on `_SyncedAttrDict` objects modifies the underlying dictionary in place. This behavior is unexpected, such an operation should only affect the returned value.

### To reproduce

```
>> import signac
>> project = signac.init_project('test')
>> job = project.open_job({'a': {'b': 'c'}}).init()
>> print(type(job.sp.a))
<class 'signac.core.attrdict.SyncedAttrDict'>
>> job.sp.values()
>> print(type(job.sp.a))
<class 'dict'>
```

There is an open question of whether `job.sp.values()` should return nested dictionaries as `SyncedDict` objects or normal `dict` objects. I prefer that `SyncedDict` objects be what it returns so that it's still going to sync, as does @bdice, but that is a separate question. Either way, calling values in the manner in this PR should not modify the object in place.

### System configuration

Linux-4.19.61-1-lts-x86_64-with-arch
3.7.4 (default, Oct  4 2019, 06:57:26)
[GCC 9.2.0]
1.2.0



Comments:

Patch:
diff --git a/changelog.txt b/changelog.txt
index d099ad1a..bbd04bb9 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -19,6 +19,7 @@ Fixed
 +++++
 
  - Fixed issues on Windows with ``H5Store``, project import/export, and operations that move files (#264, #266).
+ - Calling ``items`` or ``values`` on _SyncedDict objects does not mutate nested dictionaries (#234, #269).
 
 
 [1.3.0] -- 2019-12-20
diff --git a/signac/core/synceddict.py b/signac/core/synceddict.py
index e3eb1393..48c74555 100644
--- a/signac/core/synceddict.py
+++ b/signac/core/synceddict.py
@@ -265,11 +265,11 @@ class _SyncedDict(MutableMapping):
 
     def values(self):
         self._synced_load()
-        return self._convert_to_dict(self._data).values()
+        return self._convert_to_dict(self).values()
 
     def items(self):
         self._synced_load()
-        return self._convert_to_dict(self._data).items()
+        return self._convert_to_dict(self).items()
 
     def __repr__(self):
         return repr(self())
@@ -279,7 +279,7 @@ class _SyncedDict(MutableMapping):
 
     def _as_dict(self):
         with self._suspend_sync():
-            return self._convert_to_dict(self._data.copy())
+            return self._convert_to_dict(self)
 
     def __call__(self):
         self._synced_load()


Test patch:
diff --git a/tests/test_synced_attrdict.py b/tests/test_synced_attrdict.py
index 503c758d..6509e363 100644
--- a/tests/test_synced_attrdict.py
+++ b/tests/test_synced_attrdict.py
@@ -508,6 +508,18 @@ class SyncedAttrDictTest(unittest.TestCase):
         self.assertEqual(len(sad), 1)
         self.assert_only_read()
 
+    def test_nested_types_dict_conversion(self):
+        """Ensure that calling methods like items and values does not
+        change the type of nested dictionaries."""
+        sad = self.get_sad({'a': {'b': 1}})
+        assert type(sad['a']) is SAD
+        sad.items()
+        assert type(sad['a']) is SAD
+        sad.values()
+        assert type(sad['a']) is SAD
+        sad._as_dict()
+        assert type(sad['a']) is SAD
+
 
 if __name__ == '__main__':
     unittest.main()