How can I json-serialize a custom iterable?

Viewed 549

I'd like to create a type that behaves as a named tuple except that it has a custom representation, which is also respected when serialized as JSON.

The naive by-the-books approach would be something like this:

from typing import NamedTuple
import json


class MyPair(NamedTuple):
    left: str
    right: str

    def __repr__(self):
        return self.left + ':' + self.right


class MyJSONEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, MyPair):
            return str(obj)
        return json.JSONEncoder.default(self, obj)

Now print(MyPair('a', 'b')) will output a:b as intended, but print(json.dumps([MyPair('a', 'b')], cls=MyJSONEncoder)) will produce [["a", "b"]] because default() is only called if an object is not primitively serializable as JSON. Since my own type is a tuple, it will be serialized before I get a chance to intervene.

Is there any nice or not-so-nice way of achieving this without making MyPair not a Tuple or iterating over the entire document in a preprocessing step that replaces all MyPair objects by strings?

Edit: To address Joran's answer, I still want to retain the ability to serialize complex trees that just contain the occasional MyPairs. My minimal example might not have made that clear, sorry.

2 Answers

just include the default parameter

def my_class_encoder(o):
    if isinstance(o,MyClass):
       return repr(o)

json.dumps(myClassInstance,default=my_class_encoder)

its easier to deal with than a real encoder

....

but really just add a def to your class

class MyPair(NamedTuple):
    left: str
    right: str
    def serialize(self):
        return list(self)
    def __repr__(self):
        return self.left + ':' + self.right

and then just

 json.dumps(myClassInstance.serialize())

this has the benefit of being more clear in what its doing (at least imho)

So I ended up reimplementing a JSONEncoder more or less from scratch. Since I don't need any fancy pretty-printing, this is fairly straightforward:

class MyJSONEncoder(json.JSONEncoder):

    def __init__(self, *, skipkeys=False, ensure_ascii=True, check_circular=True, allow_nan=True, sort_keys=False,
                 indent=None, separators=None, default=None):
        super().__init__(skipkeys=skipkeys, ensure_ascii=ensure_ascii, check_circular=check_circular,
                         allow_nan=allow_nan, sort_keys=sort_keys, indent=indent, separators=separators,
                         default=default)
        self._serializers: Set[Tuple[Type, Callable]] = {
            (MyPair, lambda pair: '"' + str(pair) + '"',)
        }

    def default(self, o):
        return super().default(o)

    def encode(self, o):
        return ''.join(self.iterencode(o))

    def iterencode(self, o, _one_shot=False):
        for t, serializer in self._serializers:
            if isinstance(o, t):
                yield serializer(o)
                break
        else:
            if isinstance(o, bool):
                yield "true" if o else "false"
            elif isinstance(o, str):
                yield '"' + o + '"'
            elif isinstance(o, bytes):
                yield '"' + o.decode("utf-8") + '"'
            elif isinstance(o, int) or isinstance(o, float) or isinstance(o, Decimal):
                yield str(o)
            elif isinstance(o, Dict):
                yield '{'
                for num, (key, value) in enumerate(o.items()):
                    yield bool(num) * ', ' + '"' + str(key) + '": '
                    yield from self.iterencode(value)
                yield '}'
            elif isinstance(o, Sequence):
                yield '['
                for num, value in enumerate(o):
                    yield bool(num) * ', '
                    yield from self.iterencode(value)
                yield ']'
            else:
                yield self.default(o)

For custom types, adding the type name and the function that stringifies it to self._serializers and you should be good. The iterencode() behaves differently from the normal one (mainly in that it yields the brackets separately and not alongside the first or last element) but I couldn't see where this would break anything.

Related