rnag / dataclass-wizard

A simple, yet elegant, set of wizarding tools for interacting with Python dataclasses.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

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())