Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support DefaultDict #286

Merged
merged 1 commit into from
Nov 29, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
39 changes: 39 additions & 0 deletions examples/default_dict.py
Original file line number Diff line number Diff line change
@@ -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()
23 changes: 23 additions & 0 deletions examples/frozen_set.py
Original file line number Diff line number Diff line change
@@ -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()
4 changes: 4 additions & 0 deletions examples/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
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
Expand Down Expand Up @@ -39,9 +41,11 @@
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)
Expand Down
25 changes: 22 additions & 3 deletions serde/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@
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
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
26 changes: 25 additions & 1 deletion serde/de.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"""

import abc
import collections
import dataclasses
import functools
import typing
Expand All @@ -29,6 +30,7 @@
is_bare_set,
is_bare_tuple,
is_datetime,
is_default_dict,
is_dict,
is_enum,
is_frozen_set,
Expand Down Expand Up @@ -236,6 +238,7 @@ def wrap(cls: Type):
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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -748,6 +759,19 @@ def dict(self, arg: DeField) -> str:
"""
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()
Expand Down
6 changes: 5 additions & 1 deletion tests/common.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import collections
import datetime
import decimal
import ipaddress
Expand All @@ -6,7 +7,7 @@
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

Expand Down Expand Up @@ -85,6 +86,9 @@ def toml_not_supported(se, de, opt) -> bool:
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),
Expand Down
31 changes: 30 additions & 1 deletion tests/test_basics.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
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

Expand Down Expand Up @@ -932,3 +932,32 @@ class Foo:

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]}')