Repository: yukinarit/pyserde
Pull request #6 (2019-11-20 14:08:23)

Support Decimal
Regarding #4, I've implemented custom serializer/deserializer facility. 

You still can't pass their own custom serializer/deserializer because it's still experimental. I am planning to support more types in python's standard modules.


Patch:
diff --git a/README.md b/README.md
index 7e977f3..1d9a489 100644
--- a/README.md
+++ b/README.md
@@ -57,7 +57,7 @@ Serialize and Deserialize a [struct](https://github.com/yukinarit/pyserde/blob/m
 
 | Serialize | Deserialize |
 |-----------|-------------|
-| <img src="./bench/charts/serialize_small.png"> | <img src="./bench/charts/deserialize_small.png"> |
+| <img src="https://raw.githubusercontent.com/yukinarit/pyserde/master/bench/charts/serialize_small.png"> | <img src="https://raw.githubusercontent.com/yukinarit/pyserde/master/bench/charts/deserialize_small.png"> |
 
 * `raw`: Manual serialize and deserialize. Fastest in theory.
 * [`dacite`](https://github.com/konradhalas/dacite): Library to crate data class from dictionary.
diff --git a/serde/compat.py b/serde/compat.py
index c011c52..2724aeb 100644
--- a/serde/compat.py
+++ b/serde/compat.py
@@ -144,6 +144,28 @@ def is_none(typ: Type) -> bool:
     return typ is type(None)
 
 
+PRIMITIVES = [int, float, bool, str]
+
+
+def is_primitive(typ: Type) -> bool:
+    """
+    Test if the type is primitive.
+
+    >>> is_primitive(int)
+    True
+    >>> is_primitive(float)
+    True
+    >>> class CustomInt(int):
+    ...     pass
+    >>> is_primitive(CustomInt)
+    True
+    """
+    try:
+        return any(issubclass(typ, ty) for ty in PRIMITIVES)
+    except TypeError:
+        return any(isinstance(typ, ty) for ty in PRIMITIVES)
+
+
 def assert_type(typ: Type, obj, throw=False) -> None:
     if not isinstance(obj, typ):
         if throw:
diff --git a/serde/core.py b/serde/core.py
index 6f9670a..5071720 100644
--- a/serde/core.py
+++ b/serde/core.py
@@ -53,7 +53,7 @@ def gen(code: str, globals: Dict = None, locals: Dict = None, cls: Type = None)
         from black import format_str, FileMode
 
         code = format_str(code, mode=FileMode(line_length=100))
-    except:
+    except Exception:
         pass
     for_class = 'for ' + cls.__name__ if cls else ''
     logger.debug(f'Generating {for_class} ...\n{code}')
diff --git a/serde/de.py b/serde/de.py
index e975a31..66a3092 100644
--- a/serde/de.py
+++ b/serde/de.py
@@ -13,14 +13,19 @@ parts of pyserde.
 """
 import abc
 import functools
-from dataclasses import dataclass, is_dataclass
-from typing import Any, Dict, List, Optional, Tuple, Type
+from dataclasses import dataclass
+from dataclasses import fields as dataclass_fields
+from dataclasses import is_dataclass
+from typing import Any, Callable, Dict, List, Optional, Tuple, Type
 
 import jinja2
 import stringcase
 
-from .compat import assert_type, is_dict, is_list, is_opt, is_tuple, is_union, iter_types, type_args
+from .compat import assert_type, is_dict, is_list, is_opt, is_primitive, is_tuple, is_union, iter_types, type_args
 from .core import FROM_DICT, FROM_ITER, HIDDEN_NAME, SETTINGS, Field, Hidden, SerdeError, T, fields, gen
+from .more_types import deserialize as custom
+
+Custom = Optional[Callable[['DeField', Any], Any]]
 
 
 def deserialize(_cls=None, rename_all: Optional[str] = None) -> Type:
@@ -60,10 +65,12 @@ def deserialize(_cls=None, rename_all: Optional[str] = None) -> Type:
     """
 
     def wrap(cls):
+        g: Dict[str, Any] = globals().copy()
         if not hasattr(cls, HIDDEN_NAME):
             setattr(cls, HIDDEN_NAME, Hidden())
-        cls = de_func(cls, FROM_ITER, render_from_iter(cls))
-        cls = de_func(cls, FROM_DICT, render_from_dict(cls, rename_all))
+        g['__custom_deserializer__'] = custom
+        cls = de_func(cls, FROM_ITER, render_from_iter(cls, custom), g)
+        cls = de_func(cls, FROM_DICT, render_from_dict(cls, rename_all, custom), g)
         return cls
 
     if _cls is None:
@@ -269,6 +276,7 @@ class Renderer:
     """
 
     func: str
+    custom: Custom = None
 
     def render(self, arg: DeField) -> str:
         """
@@ -284,8 +292,10 @@ class Renderer:
             return self.dict(arg)
         elif is_tuple(arg.type):
             return self.tuple(arg)
-        else:
+        elif any(f(arg.type) for f in (is_primitive, is_union)):
             return self.primitive(arg)
+        else:
+            return f'__custom_deserializer__(fs[{arg.index}], {arg.data})'
 
     def dataclass(self, arg: DeField) -> str:
         """ Render rvalue for dataclass. """
@@ -396,21 +406,22 @@ class DictRenderer(Renderer):
         return arg.data
 
 
-def to_arg(f: Field, rename_all: Optional[str] = None) -> DeField:
+def to_arg(f: Field, index, rename_all: Optional[str] = None) -> DeField:
+    f.index = index
     f.data = 'data'
     f.parent = DeField(None, 'data', datavar='data', case=f.case or rename_all)
     f.case = f.case or rename_all
     return f
 
 
-def render_from_iter(cls: Type) -> str:
+def render_from_iter(cls: Type, custom: Custom = None) -> str:
     template = """
 def {{func}}(data):
   if data is None:
     return None
   return cls(
   {% for f in cls|fields %}
-  {{f|arg|rvalue}},
+  {{f|arg(loop.index-1)|rvalue}},
   {% endfor %}
   )
     """
@@ -423,19 +434,20 @@ def {{func}}(data):
     return env.get_template('iter').render(func=FROM_ITER, cls=cls)
 
 
-def render_from_dict(cls: Type, rename_all: Optional[str] = None) -> str:
+def render_from_dict(cls: Type, rename_all: Optional[str] = None, custom: Custom = None) -> str:
     template = """
 def {{func}}(data):
   if data is None:
     return None
+  fs = fields(cls)
   return cls(
   {% for f in cls|fields %}
-  {{f|arg|rvalue}},
+  {{f|arg(loop.index-1)|rvalue}},
   {% endfor %}
   )
     """
 
-    renderer = DictRenderer(FROM_DICT)
+    renderer = DictRenderer(FROM_DICT, custom)
     env = jinja2.Environment(loader=jinja2.DictLoader({'dict': template}))
     env.filters.update({'rvalue': renderer.render})
     env.filters.update({'fields': defields})
@@ -443,12 +455,16 @@ def {{func}}(data):
     return env.get_template('dict').render(func=FROM_DICT, cls=cls)
 
 
-def de_func(cls: Type[T], func: str, code: str) -> Type[T]:
+def de_func(cls: Type[T], func: str, code: str, g: Dict = None, local: Dict = None) -> Type[T]:
     """
     Generate function to deserialize into an instance of `deserialize` class.
     """
+    if not g:
+        g = globals().copy()
+    if not local:
+        local = locals().copy()
+
     # Collect types to be used in the `exec` scope.
-    g: Dict[str, Any] = globals().copy()
     for typ in iter_types(cls):
         if is_dataclass(typ):
             g[typ.__name__] = typ
@@ -457,6 +473,7 @@ def de_func(cls: Type[T], func: str, code: str) -> Type[T]:
 
     g['typing'] = typing
     g['NoneType'] = type(None)
+    g['fields'] = dataclass_fields
 
     # Generate deserialize function.
     code = gen(code, g, cls=cls)
diff --git a/serde/more_types.py b/serde/more_types.py
new file mode 100644
index 0000000..931b35f
--- /dev/null
+++ b/serde/more_types.py
@@ -0,0 +1,31 @@
+from dataclasses import Field
+from decimal import Decimal
+from typing import Any
+
+from .core import SerdeError
+
+
+def serialize(data: Any) -> Any:
+    """
+    Custom serializer for extended types such as Decimal.
+
+    `data` shall be other than List, Dict, Tuple, Optional
+    Union, dataclass or primitives.
+    """
+    if isinstance(data, Decimal):
+        return str(data)
+    else:
+        raise SerdeError(f'Unsupported type: {type(data)}')
+
+
+def deserialize(field: Field, data: Any) -> Any:
+    """
+    Custom deserializer for extended types such as Decimal.
+
+    `data` shall be other than List, Dict, Tuple, Optional
+    Union, dataclass or primitives.
+    """
+    if issubclass(field.type, Decimal):
+        return Decimal(data)
+    else:
+        raise SerdeError(f'Unsupported type: {type(data)}')
diff --git a/serde/msgpack.py b/serde/msgpack.py
index 13ae42c..be9422b 100644
--- a/serde/msgpack.py
+++ b/serde/msgpack.py
@@ -1,11 +1,10 @@
-from dataclasses import asdict
 from typing import Any, Type
 
 import msgpack
 
 from .core import T, logger
 from .de import Deserializer, from_dict
-from .se import Serializer
+from .se import Serializer, asdict
 
 
 class MsgPackSerializer(Serializer):
diff --git a/serde/se.py b/serde/se.py
index f481299..b5e2515 100644
--- a/serde/se.py
+++ b/serde/se.py
@@ -11,13 +11,16 @@ from dataclasses import astuple as _astuple
 from dataclasses import dataclass
 from dataclasses import fields as dataclass_fields  # noqa
 from dataclasses import is_dataclass
-from typing import Any, Dict, List, Optional, Tuple, Type
+from typing import Any, Callable, Dict, List, Optional, Tuple, Type
 
 import jinja2
 import stringcase
 
-from .compat import is_dict, is_list, is_tuple, is_opt, type_args
+from .compat import is_dict, is_list, is_opt, is_primitive, is_tuple, is_union, type_args
 from .core import HIDDEN_NAME, SE_NAME, SETTINGS, TO_DICT, TO_ITER, Field, Hidden, T, fields, gen
+from .more_types import serialize as custom
+
+Custom = Optional[Callable[[Any], Any]]
 
 
 class Serializer(metaclass=abc.ABCMeta):
@@ -81,8 +84,9 @@ def serialize(_cls=None, rename_all: Optional[str] = None) -> Type:
         for f in sefields(cls):
             if f.skip_if:
                 g[f.skip_if.mangled] = f.skip_if
-        cls = se_func(cls, TO_ITER, render_astuple(cls), g)
-        cls = se_func(cls, TO_DICT, render_asdict(cls, rename_all), g)
+        g['__custom_serializer__'] = custom
+        cls = se_func(cls, TO_ITER, render_astuple(cls, custom), g)
+        cls = se_func(cls, TO_DICT, render_asdict(cls, rename_all, custom), g)
         return cls
 
     if _cls is None:
@@ -208,7 +212,7 @@ def to_arg(f: SeField) -> SeField:
     return f
 
 
-def render_astuple(cls: Type) -> str:
+def render_astuple(cls: Type, custom: Custom = None) -> str:
     template = """
 def {{func}}(obj):
   if not is_dataclass(obj):
@@ -225,7 +229,7 @@ def {{func}}(obj):
   {% endif %}
     """
 
-    renderer = Renderer(TO_ITER)
+    renderer = Renderer(TO_ITER, custom)
     env = jinja2.Environment(loader=jinja2.DictLoader({'iter': template}))
     env.filters.update({'fields': sefields})
     env.filters.update({'is_dataclass': is_dataclass})
@@ -234,7 +238,7 @@ def {{func}}(obj):
     return env.get_template('iter').render(func=TO_ITER, cls=cls)
 
 
-def render_asdict(cls: Type, case: Optional[str] = None) -> str:
+def render_asdict(cls: Type, case: Optional[str] = None, custom: Custom = None) -> str:
     template = """
 def {{func}}(obj):
   if not is_dataclass(obj):
@@ -270,7 +274,7 @@ def {{func}}(obj):
             name = f.rename
         return name
 
-    renderer = Renderer(TO_DICT)
+    renderer = Renderer(TO_DICT, custom)
     env = jinja2.Environment(loader=jinja2.DictLoader({'dict': template}))
     env.filters.update({'fields': sefields})
     env.filters.update({'is_dataclass': is_dataclass})
@@ -287,6 +291,7 @@ class Renderer:
     """
 
     func: str
+    custom: Custom = None
 
     def render(self, arg: SeField) -> str:
         """
@@ -328,8 +333,10 @@ class Renderer:
             return self.dict(arg)
         elif is_tuple(arg.type):
             return self.tuple(arg)
-        else:
+        elif any(f(arg.type) for f in (is_primitive, is_union)):
             return self.primitive(arg)
+        else:
+            return f'__custom_serializer__({arg.varname})'
 
     def dataclass(self, arg: SeField) -> str:
         """
diff --git a/serde/toml.py b/serde/toml.py
index 24b1b8a..298f6f8 100644
--- a/serde/toml.py
+++ b/serde/toml.py
@@ -1,11 +1,10 @@
-from dataclasses import asdict
 from typing import List, Type  # noqa
 
 import toml
 
 from .core import T
 from .de import Deserializer, from_obj
-from .se import Serializer, is_serializable
+from .se import Serializer, asdict, is_serializable
 
 
 class TomlSerializer(Serializer):
@@ -30,16 +29,16 @@ def to_toml(obj, se: Type[TomlSerializer] = TomlSerializer) -> str:
     >>>
     >>> @serialize
     ... @dataclass
-    ... class Settings:
-    ...     general: 'General'
-    >>>
-    >>> @serialize
-    ... @dataclass
     ... class General:
     ...     host: str
     ...     port: int
     ...     upstream: List[str]
     >>>
+    >>> @serialize
+    ... @dataclass
+    ... class Settings:
+    ...     general: General
+    >>>
     >>> to_toml(Settings(General(host='localhost', port=8080, upstream=['localhost:8081', 'localhost:8082'])))
     '[general]\\nhost = \"localhost\"\\nport = 8080\\nupstream = [ \"localhost:8081\", \"localhost:8082\",]\\n'
     >>>
diff --git a/serde/yaml.py b/serde/yaml.py
index d5e2d8b..3cee96c 100644
--- a/serde/yaml.py
+++ b/serde/yaml.py
@@ -1,11 +1,10 @@
-from dataclasses import asdict
 from typing import List, Type  # noqa
 
 import yaml
 
 from .core import T
 from .de import Deserializer, from_obj
-from .se import Serializer, is_serializable
+from .se import Serializer, asdict, is_serializable
 
 
 class YamlSerializer(Serializer):


Test patch:
diff --git a/tests/test_more_types.py b/tests/test_more_types.py
new file mode 100644
index 0000000..64d6f0f
--- /dev/null
+++ b/tests/test_more_types.py
@@ -0,0 +1,23 @@
+from dataclasses import dataclass
+from decimal import Decimal
+
+import pytest
+
+from serde import deserialize, serialize
+from tests.test_basics import all_formats
+
+
+@deserialize
+@serialize
+@dataclass
+class Foo:
+    d: Decimal
+
+
+@pytest.mark.parametrize('se,de', all_formats)
+def test_decimal(se, de):
+    f = Foo(d=Decimal('0.1'))
+    assert f == de(Foo, se(f))
+
+    f = Foo(d=Decimal(0.1))
+    assert f == de(Foo, se(f))