Repository: yukinarit/pyserde
Pull request #286 (2022-11-29 00:05:43)

feat: Support DefaultDict
If value type is invalid (Ellipsis or omitted), it will raise an error. Please use dict if the value type is unknown


Patch:
diff --git a/README.md b/README.md
index 45b4eba..b24b96d 100644
--- a/README.md
+++ b/README.md
@@ -49,7 +49,7 @@ Foo(i=10, s='foo', f=100.0, b=True)
     - Primitives (`int`, `float`, `str`, `bool`)
     - Containers
         - `List`, `Set`, `Tuple`, `Dict`
-        - `FrozenSet`
+        - [`FrozenSet`](https://docs.python.org/3/library/stdtypes.html#frozenset), [`DefaultDict`](https://docs.python.org/3/library/collections.html#collections.defaultdict)
     - [`typing.Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)
     - [`typing.Union`](https://docs.python.org/3/library/typing.html#typing.Union)
     - User defined class with [`@dataclass`](https://docs.python.org/3/library/dataclasses.html)
diff --git a/examples/default_dict.py b/examples/default_dict.py
new file mode 100644
index 0000000..1374b40
--- /dev/null
+++ b/examples/default_dict.py
@@ -0,0 +1,39 @@
+from collections import defaultdict
+from dataclasses import dataclass
+from typing import DefaultDict, List, Optional
+
+from serde import serde
+from serde.json import from_json, to_json
+
+
+@serde
+@dataclass
+class Bar:
+    v: Optional[int] = None
+
+
+@serde
+@dataclass
+class Foo:
+    a: DefaultDict[str, List[int]]
+    b: DefaultDict[str, int]
+    c: DefaultDict[str, Bar]
+
+
+def main():
+    a = defaultdict(list)
+    a["a"].append(1)
+    b = defaultdict(int)
+    b["b"]
+    c = defaultdict(Bar)
+    c["c"].v = 10
+
+    f = Foo(a, b, c)
+    print(f"Into Json: {to_json(f)}")
+
+    s = '{"a": {"a": [1, 2]}, "b": {"b": 10}, "c": {"c": {}}}'
+    print(f"From Json: {from_json(Foo, s)}")
+
+
+if __name__ == '__main__':
+    main()
diff --git a/examples/frozen_set.py b/examples/frozen_set.py
new file mode 100644
index 0000000..8467f44
--- /dev/null
+++ b/examples/frozen_set.py
@@ -0,0 +1,23 @@
+from dataclasses import dataclass
+from typing import FrozenSet
+
+from serde import serde
+from serde.json import from_json, to_json
+
+
+@serde
+@dataclass
+class Foo:
+    i: FrozenSet[int]
+
+
+def main():
+    f = Foo(i={1, 2})
+    print(f"Into Json: {to_json(f)}")
+
+    s = '{"i": [1, 2]}'
+    print(f"From Json: {from_json(Foo, s)}")
+
+
+if __name__ == '__main__':
+    main()
diff --git a/examples/runner.py b/examples/runner.py
index 6116c51..6ccca63 100644
--- a/examples/runner.py
+++ b/examples/runner.py
@@ -7,10 +7,12 @@ import collection
 import custom_class_serializer
 import custom_field_serializer
 import default
+import default_dict
 import ellipsis
 import env
 import flatten
 import forward_reference
+import frozen_set
 import generics
 import generics_nested
 import init_var
@@ -39,9 +41,11 @@ PY310 = sys.version_info[:3] >= (3, 10, 0)
 def run_all():
     run(any)
     run(simple)
+    run(frozen_set)
     run(newtype)
     run(collection)
     run(default)
+    run(default_dict)
     run(env)
     run(flatten)
     run(jsonfile)
diff --git a/serde/compat.py b/serde/compat.py
index 67daf8c..2ddd8f8 100644
--- a/serde/compat.py
+++ b/serde/compat.py
@@ -13,8 +13,9 @@ import sys
 import types
 import typing
 import uuid
+from collections import defaultdict
 from dataclasses import is_dataclass
-from typing import Any, Dict, FrozenSet, Generic, Iterator, List, Optional, Set, Tuple, TypeVar, Union
+from typing import Any, DefaultDict, Dict, FrozenSet, Generic, Iterator, List, Optional, Set, Tuple, TypeVar, Union
 
 import typing_inspect
 from typing_extensions import Type
@@ -580,11 +581,13 @@ def is_dict(typ: Type[Any]) -> bool:
     True
     >>> is_dict(Dict)
     True
+    >>> is_dict(DefaultDict[int, int])
+    True
     """
     try:
-        return issubclass(get_origin(typ), dict)  # type: ignore
+        return issubclass(get_origin(typ), (dict, defaultdict))  # type: ignore
     except TypeError:
-        return typ in (Dict, dict)
+        return typ in (Dict, dict, DefaultDict, defaultdict)
 
 
 def is_bare_dict(typ: Type[Any]) -> bool:
@@ -600,6 +603,22 @@ def is_bare_dict(typ: Type[Any]) -> bool:
     return typ in (Dict, dict)
 
 
+def is_default_dict(typ: Type[Any]) -> bool:
+    """
+    Test if the type is `typing.DefaultDict`.
+
+    >>> from typing import Dict
+    >>> is_default_dict(DefaultDict[int, int])
+    True
+    >>> is_default_dict(Dict[int, int])
+    False
+    """
+    try:
+        return issubclass(get_origin(typ), defaultdict)  # type: ignore
+    except TypeError:
+        return typ in (DefaultDict, defaultdict)
+
+
 def is_none(typ: Type[Any]) -> bool:
     """
     >>> is_none(int)
diff --git a/serde/de.py b/serde/de.py
index 26f4846..dd6988b 100644
--- a/serde/de.py
+++ b/serde/de.py
@@ -4,6 +4,7 @@ associated with deserialization.
 """
 
 import abc
+import collections
 import dataclasses
 import functools
 import typing
@@ -29,6 +30,7 @@ from .compat import (
     is_bare_set,
     is_bare_tuple,
     is_datetime,
+    is_default_dict,
     is_dict,
     is_enum,
     is_frozen_set,
@@ -236,6 +238,7 @@ def deserialize(
         g['typename'] = typename  # used in union functions
         g['ensure'] = ensure
         g['typing'] = typing
+        g['collections'] = collections
         g['Literal'] = Literal
         g['from_obj'] = from_obj
         g['get_generic_arg'] = get_generic_arg
@@ -388,9 +391,17 @@ def from_obj(c: Type, o: Any, named: bool, reuse_instances: bool):
         elif is_dict(c):
             if is_bare_dict(c):
                 return {k: v for k, v in o.items()}
+            elif is_default_dict(c):
+                f = DeField(c, "")
+                v = f.value_field()
+                origin = get_origin(v.type)
+                res = collections.defaultdict(
+                    origin if origin else v.type,
+                    {thisfunc(type_args(c)[0], k): thisfunc(type_args(c)[1], v) for k, v in o.items()},
+                )
             else:
                 res = {thisfunc(type_args(c)[0], k): thisfunc(type_args(c)[1], v) for k, v in o.items()}
-                return res
+            return res
         elif is_numpy_array(c):
             return deserialize_numpy_array_direct(c, o)
         elif is_datetime(c):
@@ -748,6 +759,19 @@ for k, v in data["f"].items()}'
         """
         if is_bare_dict(arg.type):
             return arg.data
+        elif is_default_dict(arg.type):
+            k = arg.key_field()
+            v = arg.value_field()
+            origin = get_origin(v.type)
+            if origin:
+                # When the callable type is of generic type e.g List.
+                # Get origin type "list" from "List[X]".
+                callable = origin.__name__
+            else:
+                # When the callable type is non generic type e.g int, Foo.
+                callable = v.type.__name__
+            return f'collections.defaultdict({callable}, \
+                    {{{self.render(k)}: {self.render(v)} for k, v in {arg.data}.items()}})'
         else:
             k = arg.key_field()
             v = arg.value_field()


Test patch:
diff --git a/tests/common.py b/tests/common.py
index 158b908..bed9115 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -1,3 +1,4 @@
+import collections
 import datetime
 import decimal
 import ipaddress
@@ -6,7 +7,7 @@ import os
 import pathlib
 import sys
 import uuid
-from typing import Any, Callable, Dict, FrozenSet, Generic, List, NewType, Optional, Set, Tuple, TypeVar
+from typing import Any, Callable, DefaultDict, Dict, FrozenSet, Generic, List, NewType, Optional, Set, Tuple, TypeVar
 
 import more_itertools
 
@@ -85,6 +86,9 @@ types: List = [
     param({'a': 1}, Dict),
     param({'a': 1}, dict),
     param({}, Dict[str, int]),
+    param({'a': 1}, Dict[str, int]),
+    param({'a': 1}, DefaultDict[str, int]),
+    param({'a': [1]}, DefaultDict[str, List[int]]),
     param(data.Pri(10, 'foo', 100.0, True), data.Pri),  # dataclass
     param(data.Pri(10, 'foo', 100.0, True), Optional[data.Pri]),
     param(None, Optional[data.Pri], toml_not_supported),
diff --git a/tests/test_basics.py b/tests/test_basics.py
index dfde95b..7346048 100644
--- a/tests/test_basics.py
+++ b/tests/test_basics.py
@@ -4,7 +4,7 @@ import enum
 import logging
 import pathlib
 import uuid
-from typing import Dict, FrozenSet, List, Optional, Set, Tuple, Union
+from typing import DefaultDict, Dict, FrozenSet, List, Optional, Set, Tuple, Union
 
 import pytest
 
@@ -932,3 +932,32 @@ def test_frozenset() -> None:
 
     fs = serde.json.from_json(FrozenSet[int], '[1,2]')
     assert fs == frozenset([1, 2])
+
+
+def test_defaultdict() -> None:
+    from collections import defaultdict
+
+    @serde.serde
+    @dataclasses.dataclass
+    class Foo:
+        v: DefaultDict[str, List[int]]
+
+    f = Foo(defaultdict(list, {"k": [1, 2]}))
+    assert '{"v":{"k":[1,2]}}' == serde.json.to_json(f)
+
+    ff = serde.json.from_json(Foo, '{"v":{"k":[1,2]}}')
+    assert f == ff
+    assert isinstance(f.v, defaultdict)
+    assert isinstance(ff.v, defaultdict)
+
+    dd = serde.json.from_json(DefaultDict[str, List[int]], '{"k":[1,2]}')
+    assert isinstance(dd, defaultdict)
+    assert dd == defaultdict(list, {"k": [1, 2]})
+
+
+def test_defaultdict_invalid_value_type() -> None:
+    with pytest.raises(Exception):
+        serde.json.from_json(DefaultDict[str, ...], '{"k":[1,2]}')
+
+    with pytest.raises(Exception):
+        serde.json.from_json(DefaultDict, '{"k":[1,2]}')