Repository: evennia/evennia

Issue:
[Feature Request] (Develop) Attribute `defaultdict` value does not serialize/deserialize correctly
#### Describe the bug
Storing a `defaultdict` to an Attribute results in the values being lost.

#### To Reproduce
```
>>> from collections import defaultdict
>>> d = defaultdict(list)
>>> c.db.lists = d
>>> c.db.lists
[]
>>> d['name'].append('bob')
>>> c.db.lists = d
>>> c.db.lists
['name']
>>> c.db.lists['name']
Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File "/home/ubuntu/workspace/evennia/evennia/utils/dbserialize.py", line 224, in __getitem__
    return self._data.__getitem__(key)
TypeError: list indices must be integers or slices, not str
```

#### Expected behavior
Was mainly curious to see if it works.

Store the object correctly or raise an error about it being unserializable.

#### Environment, Evennia version, OS etc
Develop


Discussion:
This is not a bug. https://www.evennia.com/docs/latest/Attributes.html#storing-multiple-objects outlines which iterables can be stored in an Attribute; this does not include defaultdict. This could be a feature request by all means.
> This is not a bug. https://www.evennia.com/docs/latest/Attributes.html#storing-multiple-objects outlines which iterables can be stored in an Attribute; this does not include defaultdict. This could be a feature request by all means.

Fair enough; the issue here is that data *appears* to be stored but is instead silently _lost_. I was surprised to find this was allowed without raising an error.
It should be simple enough to add support for defaultdict serialization in `evennia/utils/dbserialize.py`

Resolving patch:
diff --git a/evennia/utils/dbserialize.py b/evennia/utils/dbserialize.py
--- a/evennia/utils/dbserialize.py
+++ b/evennia/utils/dbserialize.py
@@ -203,6 +203,10 @@ def process_tree(item, parent):
                 dat = _SaverDict(_parent=parent)
                 dat._data.update((key, process_tree(val, dat)) for key, val in item.items())
                 return dat
+            elif dtype == defaultdict:
+                dat = _SaverDefaultDict(item.default_factory, _parent=parent)
+                dat._data.update((key, process_tree(val, dat)) for key, val in item.items())
+                return dat
             elif dtype == set:
                 dat = _SaverSet(_parent=parent)
                 dat._data.update(process_tree(val, dat) for val in item)
@@ -221,6 +225,7 @@ def __iter__(self):
         return self._data.__iter__()
 
     def __getitem__(self, key):
+        # breakpoint()
         return self._data.__getitem__(key)
 
     def __eq__(self, other):
@@ -305,6 +310,22 @@ def update(self, *args, **kwargs):
         self._data.update(*args, **kwargs)
 
 
+class _SaverDefaultDict(_SaverDict):
+    """
+    A defaultdict that stores changes to an attribute when updated
+    """
+
+    def __init__(self, factory, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._data = defaultdict(factory)
+        self.default_factory = factory
+
+    # @_save
+    def __getitem__(self, key):
+        # breakpoint()
+        return self._data[key]
+
+
 class _SaverSet(_SaverMutable, MutableSet):
     """
     A set that saves to an Attribute when updated
@@ -399,6 +420,7 @@ def rotate(self, *args):
     _SaverSet.__name__: set,
     _SaverOrderedDict.__name__: OrderedDict,
     _SaverDeque.__name__: deque,
+    _SaverDefaultDict.__name__: defaultdict,
 }
 
 
@@ -410,10 +432,13 @@ def deserialize(obj):
     """
 
     def _iter(obj):
+        # breakpoint()
         typ = type(obj)
         tname = typ.__name__
         if tname in ("_SaverDict", "dict"):
             return {_iter(key): _iter(val) for key, val in obj.items()}
+        if tname in ("_SaverDefaultDict", "defaultdict"):
+            return defaultdict(obj.default_factory, {_iter(key): _iter(val) for key, val in obj.items()})
         elif tname in _DESERIALIZE_MAPPING:
             return _DESERIALIZE_MAPPING[tname](_iter(val) for val in obj)
         elif is_iter(obj):
@@ -565,6 +590,7 @@ def to_pickle(data):
 
     def process_item(item):
         """Recursive processor and identification of data"""
+        # breakpoint()
         dtype = type(item)
         if dtype in (str, int, float, bool, bytes, SafeString):
             return item
@@ -574,6 +600,8 @@ def process_item(item):
             return [process_item(val) for val in item]
         elif dtype in (dict, _SaverDict):
             return dict((process_item(key), process_item(val)) for key, val in item.items())
+        elif dtype in (defaultdict, _SaverDefaultDict):
+            return defaultdict(item.default_factory, ((process_item(key), process_item(val)) for key, val in item.items()))
         elif dtype in (set, _SaverSet):
             return set(process_item(val) for val in item)
         elif dtype in (OrderedDict, _SaverOrderedDict):
@@ -625,6 +653,7 @@ def from_pickle(data, db_obj=None):
 
     def process_item(item):
         """Recursive processor and identification of data"""
+        # breakpoint()
         dtype = type(item)
         if dtype in (str, int, float, bool, bytes, SafeString):
             return item
@@ -637,6 +666,8 @@ def process_item(item):
             return tuple(process_item(val) for val in item)
         elif dtype == dict:
             return dict((process_item(key), process_item(val)) for key, val in item.items())
+        elif dtype == defaultdict:
+            return defaultdict(item.default_factory, ((process_item(key), process_item(val)) for key, val in item.items()))
         elif dtype == set:
             return set(process_item(val) for val in item)
         elif dtype == OrderedDict:
@@ -654,6 +685,7 @@ def process_item(item):
 
     def process_tree(item, parent):
         """Recursive processor, building a parent-tree from iterable data"""
+        # breakpoint()
         dtype = type(item)
         if dtype in (str, int, float, bool, bytes, SafeString):
             return item
@@ -672,6 +704,12 @@ def process_tree(item, parent):
                 (process_item(key), process_tree(val, dat)) for key, val in item.items()
             )
             return dat
+        elif dtype == defaultdict:
+            dat = _SaverDefaultDict(item.default_factory, _parent=parent)
+            dat._data.update(
+                (process_item(key), process_tree(val, dat)) for key, val in item.items()
+            )
+            return dat
         elif dtype == set:
             dat = _SaverSet(_parent=parent)
             dat._data.update(set(process_tree(val, dat) for val in item))
@@ -711,6 +749,12 @@ def process_tree(item, parent):
                 (process_item(key), process_tree(val, dat)) for key, val in data.items()
             )
             return dat
+        elif dtype == defaultdict:
+            dat = _SaverDefaultDict(data.default_factory, _db_obj=db_obj)
+            dat._data.update(
+                (process_item(key), process_tree(val, dat)) for key, val in data.items()
+            )
+            return dat
         elif dtype == set:
             dat = _SaverSet(_db_obj=db_obj)
             dat._data.update(process_tree(val, dat) for val in data)


Test patch:
diff --git a/evennia/utils/tests/test_dbserialize.py b/evennia/utils/tests/test_dbserialize.py
--- a/evennia/utils/tests/test_dbserialize.py
+++ b/evennia/utils/tests/test_dbserialize.py
@@ -57,3 +57,30 @@ def test_saverlist__sort(self):
         self.assertEqual(self.obj.db.test, [{1: 0}, {0: 1}])
         self.obj.db.test.sort(key=lambda d: str(d))
         self.assertEqual(self.obj.db.test, [{0: 1}, {1: 0}])
+
+    def test_dict(self):
+        self.obj.db.test = {'a': True}
+        self.obj.db.test.update({'b': False})
+        self.assertEqual(self.obj.db.test, {'a': True, 'b': False})
+
+    def test_defaultdict(self):
+        from collections import defaultdict
+        # baseline behavior for a defaultdict
+        _dd = defaultdict(list)
+        _dd['a']
+        self.assertTrue('a' in _dd)
+        self.assertEqual(_dd, {'a': []})
+        dd = defaultdict(list)
+        # behavior after defaultdict is set as attribute
+        self.obj.db.test = dd
+        self.obj.db.test['a']
+        self.assertTrue('a' in self.obj.db.test)
+        self.assertEqual(self.obj.db.test, {'a': []})
+
+        # none of the following matter as test failed by now
+        self.obj.db.test['a'].append(1)
+        self.assertEqual(self.obj.db.test, {'a': [1]})
+        self.obj.db.test['a'].append(2)
+        self.assertEqual(self.obj.db.test, {'a': [1, 2]})
+        self.obj.db.test['a'].append(3)
+        self.assertEqual(self.obj.db.test, {'a': [1, 2, 3]})