Repository: mahmoud/boltons

Issue:
Non-empty `dictutils.OMD` cannot be loaded from `pickle`
A non-empty `OrderedMultiDict` can be serialized, but cannot be deserialized.
A empty `OrderedMultiDict` can be both serialized and deserialized.


* Python `3.11.3`
* Version `23.0.0`

```python
In [1]: import pickle

In [2]: import boltons.dictutils

In [3]: pickle.loads(pickle.dumps(boltons.dictutils.OMD()))
Out[3]: OrderedMultiDict([])

In [4]: pickle.loads(pickle.dumps(boltons.dictutils.OMD({'a': 1})))
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[4], line 1
----> 1 pickle.loads(pickle.dumps(boltons.dictutils.OMD({'a': 1})))

File /usr/local/lib/python3.11/site-packages/boltons/dictutils.py:333, in OrderedMultiDict.__setitem__(self, k, v)
    331 if super(OrderedMultiDict, self).__contains__(k):
    332     self._remove_all(k)
--> 333 self._insert(k, v)
    334 super(OrderedMultiDict, self).__setitem__(k, [v])

File /usr/local/lib/python3.11/site-packages/boltons/dictutils.py:198, in OrderedMultiDict._insert(self, k, v)
    197 def _insert(self, k, v):
--> 198     root = self.root
    199     cells = self._map.setdefault(k, [])
    200     last = root[PREV]

AttributeError: 'OrderedMultiDict' object has no attribute 'root'
```


Discussion:
mahmoud: Right you are. Odd that Py2 was so much better behaved than Py3. 

Just a couple implementation notes:

- The `__getnewargs__` approach in the docs almost works, but because OMD inherits from `dict`, the builtin implementation overwrites the values via `__setitem__` calls after `__new__`. That's why only individual values made it through; OMD's `__setitem__` clears previously-added values.
- `__setstate__` apparently runs after the `__setitem__` calls mentioned above, allowing us to reset the multi-value. This does mean that there's some extra work happening during unpickling. Maybe a fancy `__reduce_ex__` could get around this, but the current approach seems fine for now.

Patch:
diff --git a/boltons/dictutils.py b/boltons/dictutils.py
index 31a1951..d74abb9 100644
--- a/boltons/dictutils.py
+++ b/boltons/dictutils.py
@@ -71,8 +71,10 @@ thanks to `Mark Williams`_ for all his help.
 
 try:
     from collections.abc import KeysView, ValuesView, ItemsView
+    PY3 = True
 except ImportError:
     from collections import KeysView, ValuesView, ItemsView
+    PY3 = False
 
 import itertools
 
@@ -173,18 +175,30 @@ class OrderedMultiDict(dict):
        behavior, just use :meth:`~OrderedMultiDict.todict()`.
 
     """
+    def __new__(cls, *a, **kw):
+        ret = super(OrderedMultiDict, cls).__new__(cls)
+        ret._clear_ll()
+        return ret 
+    
     def __init__(self, *args, **kwargs):
         if len(args) > 1:
             raise TypeError('%s expected at most 1 argument, got %s'
                             % (self.__class__.__name__, len(args)))
         super(OrderedMultiDict, self).__init__()
 
-        self._clear_ll()
         if args:
             self.update_extend(args[0])
         if kwargs:
             self.update(kwargs)
 
+    if PY3:
+        def __getstate__(self):
+            return list(self.iteritems(multi=True))
+        
+        def __setstate__(self, state):
+            self.clear()
+            self.update_extend(state)
+
     def _clear_ll(self):
         try:
             _map = self._map


Test patch:
diff --git a/tests/test_dictutils.py b/tests/test_dictutils.py
index ae046f6..076798b 100644
--- a/tests/test_dictutils.py
+++ b/tests/test_dictutils.py
@@ -74,6 +74,22 @@ def test_copy():
     return
 
 
+def test_omd_pickle():
+    import pickle
+
+    empty = OMD()
+    pickled = pickle.dumps(empty)
+    roundtripped = pickle.loads(pickled)
+    assert roundtripped == empty
+
+    nonempty = OMD([('a', 1), ('b', 2), ('b', 3)])
+
+    roundtripped = pickle.loads(pickle.dumps(nonempty)) 
+    assert roundtripped == nonempty
+    assert roundtripped.getlist('b') == [2, 3]
+
+
+
 def test_clear():
     for itemset in _ITEMSETS:
         omd = OMD(itemset)