datacalssesのインスタンスを含んだ値をJSONにするときのメモ

いつものやつです。対応していない値をjson.dumps()に与えるとTypeErrorが発生するやつです。

TypeError

例えばこういうdataclassを定義して。

from dataclasses import dataclass, asdict


@dataclass
class Point:
    x: int
    y: int

json.dumpsするとTypeError

import json
p = Point(x=1, y=2)

# TypeError: Object of type Point is not JSON serializable
print(json.dumps(p))

default

いつもどおりにdefaultを渡してあげる必要があります。ちょっとめんどくさいですが、is_dataclassという関数で調べられるのでどうにかなります。

import json


def custom_default(o):
    if is_dataclass(o):
        return asdict(o)
    raise TypeError(f"{o!r} is not JSON serializable")


p = Point(x=1, y=2)
print(json.dumps(p, default=custom_default))
# {"x": 1, "y": 2}
print(json.dumps([p, p, p], default=custom_default))
# [{"x": 1, "y": 2}, {"x": 1, "y": 2}, {"x": 1, "y": 2}]

もっと手軽に

自作のjsonモジュールを手元のライブラリあるいは手元のアプリケーションに持っておくと便利です。例えばextjsonのような名前で。 (あるいは自分自身のserialize/deserializeのためのライブラリという形にしても良いかもしれません。jsonという名前を付けずに)

extjson.py

from functools import partial
from dataclasses import is_dataclass, asdict
import json
from json import *  # noqa

# from extjson import *のときに、json moduleと同じsymbolしかexportしない
__all__ = json.__all__


def _custom_default(o):
    if is_dataclass(o):
        return asdict(o)
    raise TypeError(f"{o!r} is not JSON serializable")


dumps = partial(json.dumps, default=_custom_default)
dump = partial(json.dump, default=_custom_default)

今度はjsonとおんなじ感じに使えばdumpは大丈夫。

import extjson as json

p = Point(x=1, y=2)
print(json.dumps(p))
# {"x": 1, "y": 2}
print(json.dumps([p, p, p]))
# [{"x": 1, "y": 2}, {"x": 1, "y": 2}, {"x": 1, "y": 2}]

loadに対応したい場合には何かもう少し特殊な機構が必要です(受け取ったJSONのデータは変換後のpythonオブジェクトのtypeの情報自体は持っていないので)。

他の型にも対応したものが欲しい

例えばdatetimeなど他の型にも対応したものが欲しくなるかもしれません。その都度defaultを作ったりdefaultミドルウェアみたいな形でclosureをwrapしまくるみたいなコードを書いても良いですが。もう少し手軽にしたいですね。

2つ方法があります。1つはjson.dumpsにはplainな値のdict,listしか渡さないという方法(json.dumpのdefaultの否定)。もう一つは良い感じにdispatcherのような形で振る舞いを追加できるような構造にすること。こういうときにはfunctoolsのsingledispatchが便利です。

このsingledispatchを使ってdefault関数を作ってあげると良さそうです。

from functools import singledispatch, partial

@singledispatch
def default(o):
    raise TypeError(f"{o!r} is not JSON serializable")

dumps = partial(json.dumps, default=default)
dump = partial(json.dump, default=default)

これに対してdatetimeなどの型を登録しておきます。

import datetime

default.register(datetime.datetime, lambda o: o.isoformat())

このようにするとdatetimeに対するdefaultをサポートできます。

import extjson2 as json
from datetime import datetime

print(json.dumps(datetime.now()))
# "2018-09-29T22:17:55.296232"

それではこの仕組みをそのまま他の型にも(e.g. dataclasses, iterator)と考えてみると実は無理です。

dataclassesもsingledispatchで

dataclassesの値もsingledispatchで対応するためにはちょっと工夫が必要です。failback(失敗したときの処理)を登録できるように機能を追加してみましょう。 register()に似たようなregister_callback()という関数も持つようにしてみます。

from dataclasses import is_dataclass, asdict

# dataclassesのサポート
default.register_failback(predicate=is_dataclass, emit=asdict)

今度は大丈夫です(ちなみにjson.dumpsはiterableなオブジェクトを渡したときもエラーになるって知っていました?)。

import extjson2 as json

p = Point(x=1, y=2)
print(json.dumps(p))
# {"x": 1, "y": 2}
print(json.dumps([p, p, p]))
print(json.dumps(iter([p, p, p])))
# [{"x": 1, "y": 2}, {"x": 1, "y": 2}, {"x": 1, "y": 2}]

実装は以下の様な感じです。

extjson2.py

from functools import partial, singledispatch
from typing import Iterable
from dataclasses import is_dataclass, asdict
import json
from json import *  # noqa

__all__ = json.__all__


@singledispatch
def default(o):
    for p, fn in default.failbacks:
        if p(o):
            default.register(type(o), fn)
            return fn(o)
    raise TypeError(f"{o!r} is not JSON serializable")


default.failbacks = []  # (predicate, action)
default._seen = set()


def _register_failback(*, predicate, emit, overwrite=False):
    if overwrite or predicate not in default._seen:
        pair = (predicate, emit)
        default.failbacks.append(pair)
        default._seen.add(predicate)
        return pair


default.register_failback = _register_failback

dumps = partial(json.dumps, default=default)
dump = partial(json.dump, default=default)


########################################
## setup
########################################

def _predicate_for_iterable(o):
    return isinstance(o, Iterable)


def _emit_for_iterable(o):
    return list(o)


import datetime
default.register(datetime.datetime, lambda o: o.isoformat())
default.register_failback(predicate=_predicate_for_iterable, emit=_emit_for_iterable)
default.register_failback(predicate=is_dataclass, emit=asdict)

補足的な事柄

singledispatchを使った最後の実装は、対応するemit関数を見つけたら、対応関係を新たに追加してfailbackを無駄に呼ばないようにする処理が入っているのですが。これがclosureのなかで動的に生成される型のようなものが何度もやって来る場合にはメモリーリークの原因になるかもしれません。そのへんを上手く取り扱うならLRUみたいな仕組みを入れる必要があるかもしれません。

追記

似たような話を既に書いてました(記憶にない)。