Question: How to add custom encoder/decoder ?
solveretur opened this issue · comments
I'd like to use your library but I can't find how to use a custom json encoder/decoder for a field that is not a standard class. I have a class like
from dataclass_wizard import JSONWizard
from money import Money
from phonenumbers import PhoneNumber, parse, PhoneNumberFormat, format_number
@dataclass
class BaseInfo:
website: Website
href: str
@dataclass
class Listing(JSONWizard):
class _(JSONWizard.Meta):
# Sets the target key transform to use for serialization;
# defaults to `camelCase` if not specified.
key_transform_with_dump = 'SNAKE'
base_info: BaseInfo
price: Money
phone_number: PhoneNumber
extra_info: defaultdict[dict] = field(default_factory=lambda: defaultdict(dict))
and for the fields which are of type Money/PhoneNumber
I have my custom json.JSONDecoder/Encoder
like
def to_e164_format(phone_number: PhoneNumber):
return format_number(phone_number, PhoneNumberFormat.E164)
class MoneyJsonEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, Money):
return str(obj).replace(",", "")
return json.JSONEncoder.default(self, obj)
class MoneyJsonDecoder(json.JSONDecoder):
def decode(self, s: str, _w: Callable[..., Any] = ...) -> Any:
sp = s.replace('"', '').split(" ")
return Money(amount=sp[1], currency=sp[0])
class PhoneNumberJsonEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, PhoneNumber):
return to_e164_format(obj)
if isinstance(obj, list):
return list([to_e164_format(o) for o in obj])
return json.JSONEncoder.default(self, obj)
class PhoneNumberJsonDecoder(json.JSONDecoder):
_chars = ["[", '"', "]"]
def decode(self, s: str, _w: Callable[..., Any] = ...) -> Any:
def_copy = s
for c in self._chars:
def_copy = s.replace(c, "")
numbers = def_copy.split(", ")
return [parse(n) for n in numbers]
I'd like to set the dataclass-wizard
so that when I do to_json()
the Money/PhoneNumber
field will be serialized the way I wanted not the default way. I couldn't find in your docs whether it's possbile and if so how to do it.
Kind regards
To the best of my knowledge, it is currently not possible to set up custom json encoder/decoders for each field. Per-field hooks for de/serialization is something that I currently have added on the roadmap to add support for, however currently it is not something that can be enabled, at least not on a per-field level granularity.
In fact, I was rather surprised that converting to JSON is working without any errors being raised, since the custom classes are not JSON serializable. After looking into this for a short while, I realized this happens because to_json()
calls json.dumps
on the result of to_dict()
, rather than dumping the class instance directly.
In to_dict()
, if there is a class that is not json serializable, the default behavior is to call str()
on the custom object, thus this calls the __str__()
method on both Money and PhoneNumber by default, which I understand could not be the desired behavior.
You can confirm the default str()
is called in the dump process by inspecting the log output, in the case when no custom dumper is used:
logging.basicConfig(level='DEBUG')
logging.getLogger('dataclass_wizard').setLevel('DEBUG')
The good news, is that it is possible to set up custom loaders/dumpers for custom or user defined types. This can be achieved by subclassing from LoadMixin
for deserialization or DumpMixin
for serialization, as mentioned in the docs.
A brief example:
from __future__ import annotations
from collections import defaultdict
from dataclasses import field, dataclass
from pprint import pprint
# pip install dataclass-wizard money phonenumbers
from dataclass_wizard import JSONWizard, DumpMixin, LoadMixin
from money import Money
from phonenumbers import PhoneNumber, parse, PhoneNumberFormat, format_number
class CustomDumper(DumpMixin):
def __init_subclass__(cls):
super().__init_subclass__()
# register dump hooks for custom types - used when `to_dict()` is called
cls.register_dump_hook(Money, cls.dump_with_money)
cls.register_dump_hook(PhoneNumber, cls.dump_with_phone_number)
@staticmethod
def dump_with_money(o: Money, *_):
return f'{o!s}-TEST'.replace(",", "")
@staticmethod
def dump_with_phone_number(o: PhoneNumber, *_):
# to_e164_format
return format_number(o, PhoneNumberFormat.E164)
class CustomLoader(LoadMixin):
def __init_subclass__(cls):
super().__init_subclass__()
# register load hooks for custom types - used when `from_dict()` is called
cls.register_load_hook(Money, cls.load_to_money)
cls.register_load_hook(PhoneNumber, cls.load_to_phone_number)
@staticmethod
def load_to_money(o: str | Money, base_type: type[Money]) -> Money:
if isinstance(o, base_type):
return o
# Money.loads(s)
if isinstance(o, str):
return base_type.loads(o)
# int, float, or another number
return base_type((str(o)), currency='USD')
@staticmethod
def load_to_phone_number(o: str | PhoneNumber, base_type: type[PhoneNumber]) -> PhoneNumber:
if isinstance(o, base_type):
return o
if not isinstance(o, str):
o = f'+{o!s}'
return parse(o)
@dataclass
class Listing(JSONWizard, CustomLoader, CustomDumper):
class _(JSONWizard.Meta):
# Sets the target key transform to use for serialization;
# defaults to `camelCase` if not specified.
key_transform_with_dump = 'SNAKE'
price: Money
phone_number: PhoneNumber
extra_info: defaultdict[str, dict] = field(default_factory=lambda: defaultdict(dict))
input_dict = {
'price': 'USD 3.215', # or: 3.215
'phone_number': 11234567890, # or: '+11234567890'
}
# load the dict as a `Listing` object
L = Listing.from_dict(input_dict)
# L = Listing(Money('3.215', 'USD'), PhoneNumber(1, 1234567890))
pprint(L)
print()
print('to_dict():', L.to_dict())
print('to_json():', L.to_json())