Repository: yukinarit/pyserde

Issue:
Feature request: Custom (De)serialization functions for dates
Some objects have multiple ways they can be serialized. When reading JSON files generated by other programs, dates are not always compatible with `datetime.fromisoformat`. Users should be able to specify how to serialize and deserialize this data on a per-field basis.

This could either be implemented specifically for dates or more generically for other data. For dates, user could specify a string to be used in strftime/strptime.

```py
@deserialize
@serialize
@dataclass
class Foo:
    some_date: datetime.datetime = field(metadata={'serde_date': '%d/%m/%y'})
```

Or in a more generic case where users can supply custom serialization and deserialization functions. 

```py
DATE_FMT = '%d/%m/%y'

@deserialize
@serialize
@dataclass
class Foo:
    some_date: datetime.datetime = field(metadata={
        'serde_serialize': lambda x: x.strftime(DATE_FMT),
        'serde_deserialize': lambda x: datetime.strptime(x, DATE_FMT)
    })
```

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/yukinarit/pyserde/pull/133?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit) Report
> Merging [#133](https://codecov.io/gh/yukinarit/pyserde/pull/133?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit) (bbd195a) into [master](https://codecov.io/gh/yukinarit/pyserde/commit/b70f14ef90efebdf4c41f4f760507cc845fa9a15?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit) (b70f14e) will **decrease** coverage by `1.25%`.
> The diff coverage is `50.00%`.

[![Impacted file tree graph](https://codecov.io/gh/yukinarit/pyserde/pull/133/graphs/tree.svg?width=650&height=150&src=pr&token=fF9S7dP4eH&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit)](https://codecov.io/gh/yukinarit/pyserde/pull/133?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit)

```diff
@@            Coverage Diff             @@
##           master     #133      +/-   ##
==========================================
- Coverage   89.16%   87.91%   -1.26%     
==========================================
  Files          11       11              
  Lines        1034     1059      +25     
  Branches      229      235       +6     
==========================================
+ Hits          922      931       +9     
- Misses         71       81      +10     
- Partials       41       47       +6     
```


| [Impacted Files](https://codecov.io/gh/yukinarit/pyserde/pull/133?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit) | Coverage Δ | |
|---|---|---|
| [serde/de.py](https://codecov.io/gh/yukinarit/pyserde/pull/133/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit#diff-c2VyZGUvZGUucHk=) | `89.88% <25.00%> (-2.12%)` | :arrow_down: |
| [serde/se.py](https://codecov.io/gh/yukinarit/pyserde/pull/133/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit#diff-c2VyZGUvc2UucHk=) | `92.37% <40.00%> (-2.51%)` | :arrow_down: |
| [serde/core.py](https://codecov.io/gh/yukinarit/pyserde/pull/133/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit#diff-c2VyZGUvY29yZS5weQ==) | `89.17% <71.42%> (-1.59%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/yukinarit/pyserde/pull/133?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/yukinarit/pyserde/pull/133?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit). Last update [b70f14e...bbd195a](https://codecov.io/gh/yukinarit/pyserde/pull/133?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit).


Patch:
diff --git a/README.md b/README.md
index 813877e..fbce28d 100644
--- a/README.md
+++ b/README.md
@@ -319,6 +319,27 @@ As you can see below, field is skipped in serialization if `buddy` is "Pikachu".
 
 For complete example, please see [./examples/skip.py](./examples/skip.py)
 
+## Custom serializer/deserializer
+
+If you want to provide a custom function to override the default (de)serialization behaviour of a field, you can pass your function to `serde_serialize` and `serde_deserialize` field attributes.
+
+```python
+@deserialize
+@serialize
+@dataclass
+class Foo:
+    dt1: datetime
+    dt2: datetime = field(
+        metadata={
+            'serde_serialize': lambda x: x.strftime('%d/%m/%y'),
+            'serde_deserialize': lambda x: datetime.strptime(x, '%d/%m/%y'),
+        }
+    )
+```
+`dt1` in the example will serialized into `2021-01-01T00:00:00` because the pyserde's default (de)serializier for datetime is ISO 8601. `dt2` field in the example will be serialized into `01/01/21` by the custom field serializer.
+
+For complete example, please see [./examples/custom_field_serializer.py](./examples/custom_field_serializer.py)
+
 ## FAQ
 
 ### How can I see the code generated by pyserde?
diff --git a/examples/custom_field_serializer.py b/examples/custom_field_serializer.py
new file mode 100644
index 0000000..d3f3586
--- /dev/null
+++ b/examples/custom_field_serializer.py
@@ -0,0 +1,31 @@
+from dataclasses import dataclass, field
+from datetime import datetime
+
+from serde import deserialize, serialize
+from serde.json import from_json, to_json
+
+
+@deserialize
+@serialize
+@dataclass
+class Foo:
+    dt1: datetime
+    dt2: datetime = field(
+        metadata={
+            'serde_serialize': lambda x: x.strftime('%d/%m/%y'),
+            'serde_deserialize': lambda x: datetime.strptime(x, '%d/%m/%y'),
+        }
+    )
+
+
+def main():
+    dt = datetime(2021, 1, 1, 0, 0, 0)
+    f = Foo(dt, dt)
+    print(f"Into Json: {to_json(f)}")
+
+    s = '{"dt1": "2021-01-01T00:00:00", "dt2": "01/01/21"}'
+    print(f"From Json: {from_json(Foo, s)}")
+
+
+if __name__ == '__main__':
+    main()
diff --git a/examples/runner.py b/examples/runner.py
index 4d7073f..0286cf9 100644
--- a/examples/runner.py
+++ b/examples/runner.py
@@ -2,6 +2,7 @@ import sys
 
 import any
 import collection
+import custom_field_serializer
 import default
 import env
 import jsonfile
@@ -29,6 +30,7 @@ def run_all():
     run(tomlfile)
     run(yamlfile)
     run(union)
+    run(custom_field_serializer)
 
 
 def run(module):
diff --git a/serde/core.py b/serde/core.py
index 9e45e1b..1dfa9ee 100644
--- a/serde/core.py
+++ b/serde/core.py
@@ -223,10 +223,10 @@ class Func:
     multiple fields receives `skip_if` attribute.
     """
 
-    inner: Callable[[Any], bool]
+    inner: Callable
     mangeld: str = ""
 
-    def __call__(self, v) -> bool:
+    def __call__(self, v):
         return self.inner(v)  # type: ignore
 
     @property
@@ -253,6 +253,8 @@ class Field:
     skip: Optional[bool] = None
     skip_if: Optional[Func] = None
     skip_if_false: Optional[bool] = None
+    serialize: Optional[Func] = None  # Custom field serializer.
+    deserialize: Optional[Func] = None  # Custom field deserializer.
 
     @classmethod
     def from_dataclass(cls, f: dataclasses.Field) -> 'Field':
@@ -266,6 +268,16 @@ class Field:
             if callable(func):
                 skip_if = Func(func, cls.mangle(f, 'skip_if'))
 
+        serialize: Optional[Func] = None
+        func = f.metadata.get('serde_serialize')
+        if func:
+            serialize = Func(func, cls.mangle(f, 'serialize'))
+
+        deserialize: Optional[Func] = None
+        func = f.metadata.get('serde_deserialize')
+        if func:
+            deserialize = Func(func, cls.mangle(f, 'deserialize'))
+
         return cls(
             f.type,
             f.name,
@@ -274,6 +286,8 @@ class Field:
             rename=f.metadata.get('serde_rename'),
             skip=f.metadata.get('serde_skip'),
             skip_if=skip_if or skip_if_false_func,
+            serialize=serialize,
+            deserialize=deserialize,
         )
 
     @staticmethod
diff --git a/serde/de.py b/serde/de.py
index 07bd6c7..85e58bc 100644
--- a/serde/de.py
+++ b/serde/de.py
@@ -151,6 +151,8 @@ def deserialize(
                 scope.defaults[f.name] = f.default
             elif has_default_factory(f):
                 scope.defaults[f.name] = f.default_factory
+            if f.deserialize:
+                g[f.deserialize.name] = f.deserialize
 
         add_func(scope, FROM_ITER, render_from_iter(cls), g)
         add_func(scope, FROM_DICT, render_from_dict(cls, rename_all), g)
@@ -370,7 +372,9 @@ class Renderer:
         """
         Render rvalue
         """
-        if is_dataclass(arg.type):
+        if arg.serialize:
+            res = self.custom_field_deserializer(arg)
+        elif is_dataclass(arg.type):
             res = self.dataclass(arg)
         elif is_opt(arg.type):
             res = self.opt(arg)
@@ -435,6 +439,13 @@ class Renderer:
 
         return res
 
+    def custom_field_deserializer(self, arg: DeField) -> str:
+        """
+        Render rvalue for the field with custom deserializer.
+        """
+        assert arg.deserialize
+        return f"{arg.deserialize.name}({arg.data})"
+
     def dataclass(self, arg: DeField) -> str:
         return f"{arg.type.__name__}.{SERDE_SCOPE}.funcs['{self.func}']({arg.data}, reuse_instances=reuse_instances)"
 
diff --git a/serde/se.py b/serde/se.py
index 6b264cb..f7f5aa4 100644
--- a/serde/se.py
+++ b/serde/se.py
@@ -154,6 +154,8 @@ def serialize(
         for f in sefields(cls):
             if f.skip_if:
                 g[f.skip_if.name] = f.skip_if
+            if f.serialize:
+                g[f.serialize.name] = f.serialize
 
         add_func(scope, TO_ITER, render_to_tuple(cls), g)
         add_func(scope, TO_DICT, render_to_dict(cls, rename_all), g)
@@ -469,7 +471,9 @@ convert_sets=convert_sets) for k, v in foo.items()}"
         "(foo[0], foo[1].__serde__.funcs['to_iter'](foo[1], reuse_instances=reuse_instances, \
 convert_sets=convert_sets), foo[2],)"
         """
-        if is_dataclass(arg.type):
+        if arg.serialize:
+            return self.custom_field_serializer(arg)
+        elif is_dataclass(arg.type):
             return self.dataclass(arg)
         elif is_opt(arg.type):
             return self.opt(arg)
@@ -513,6 +517,13 @@ convert_sets=convert_sets), foo[2],)"
         else:
             return f"raise_unsupported_type({arg.varname})"
 
+    def custom_field_serializer(self, arg: SeField) -> str:
+        """
+        Render rvalue for the field with custom serializer.
+        """
+        assert arg.serialize
+        return f"{arg.serialize.name}({arg.varname})"
+
     def dataclass(self, arg: SeField) -> str:
         """
         Render rvalue for dataclass.


Test patch:
diff --git a/tests/test_custom.py b/tests/test_custom.py
new file mode 100644
index 0000000..b4f3d63
--- /dev/null
+++ b/tests/test_custom.py
@@ -0,0 +1,63 @@
+"""
+Tests for custom serializer/deserializer.
+"""
+from dataclasses import dataclass, field
+from datetime import datetime
+
+import pytest
+
+from serde import deserialize, serialize
+from serde.json import from_json, to_json
+
+
+def test_custom_field_serializer():
+    @deserialize
+    @serialize
+    @dataclass
+    class Foo:
+        dt1: datetime
+        dt2: datetime = field(
+            metadata={
+                'serde_serialize': lambda x: x.strftime('%d/%m/%y'),
+                'serde_deserialize': lambda x: datetime.strptime(x, '%d/%m/%y'),
+            }
+        )
+
+    dt = datetime(2021, 1, 1, 0, 0, 0)
+    f = Foo(dt, dt)
+
+    assert to_json(f) == '{"dt1": "2021-01-01T00:00:00", "dt2": "01/01/21"}'
+    assert f == from_json(Foo, to_json(f))
+
+
+def test_raise_error():
+    def raise_exception(_):
+        raise Exception()
+
+    @deserialize
+    @serialize
+    @dataclass
+    class Foo:
+        i: int = field(metadata={'serde_serialize': raise_exception, 'serde_deserialize': raise_exception})
+
+    f = Foo(10)
+    with pytest.raises(Exception):
+        to_json(f)
+
+    with pytest.raises(Exception):
+        from_json(Foo, '{"i": 10}')
+
+
+def test_wrong_signature():
+    @deserialize
+    @serialize
+    @dataclass
+    class Foo:
+        i: int = field(metadata={'serde_serialize': lambda: '10', 'serde_deserialize': lambda: 10})
+
+    f = Foo(10)
+    with pytest.raises(TypeError):
+        to_json(f)
+
+    with pytest.raises(TypeError):
+        from_json(Foo, '{"i": 10}')