TL;DR
- dataclasses.asdictとjson.dumpのdefaultを使うとデータクラスをjsonに簡単に変換できる。
- match文とjson.loadのobject_hookを組み合わせるとjsonのオブジェクトを分かりやすくデータクラスに変換できる。
- 組み合わせればデータクラスとjsonの相互変換が楽!
- 標準ライブラリだけで完結するのでシンプル。
- match文は3.10からなので古い環境だとオブジェクトの型チェックを書くのが面倒なので微妙。
今回使うデータクラスのサンプル
@dataclasses.dataclass(frozen=True)
class Student:
number: int
name: str
birthday: date
@dataclasses.dataclass(frozen=True)
class Classroom:
grade: int # 学年
class_: int # 組 classが使えないので末尾に_を付けています。
students: list[Student]
defaultについて
- defaultはjson.dump(s)の引数で「引数を1つ受け取りJSONで扱える型を返すかTypeErrorを投げる、呼び出し可能オブジェクト」を与える(要約)。
- datetimeをjsonに保存できる形として、タイムスタンプや文字列に変換する処理などで紹介されていることが多いアレ。
- 今回はデータクラスにはdataclasses.asdict、それ以外の固有のクラスには個別にいい感じに対応する関数を作成して使用する。
- defaultで変換する必要のある型がないのであればデータクラスだけがその対象になるので、そのままdefaultにasdictを渡すのもあり。
object_hookについて
- object_hookはjson.dump(s)の引数で「dict[str, Any]を1つ受け取り任意の型を返す、呼び出し可能オブジェクト」を与える(要約)。
- object_pairs_hookでいじった場合は受け取る引数の型が変わるが、こっちを使うことはあまりないと思うので標準のdict[str, Any]として考えておく。
- jsonを読み込むときにオブジェクトをいい感じに変換するための処理を渡すというもの。
- 一旦読み出してから自分で階層を考えながらオブジェクトをデータクラスに置き換えたりするより楽。
- match文との相性が良さそうなので今回使ってみた。
jsonに変換するためのdefault
- データクラスはis_dataclassで判定が行えるので、それを使って行う。
- データクラス以外の変換が必要なものはmatchのクラス指定を使って個別に対応させる。
- このときの変換先は自由だが、object_hookで戻しやすい形(=オブジェクト)に変換すると楽。
- ここで使うオブジェクトが他のデータクラスなどと競合しないようにキーの名前は工夫しておくといいかもしれない。
- pathlib.Pathなどはそのままstrにしたいところだが、その場合はそのPathを持つデータクラス側を都度工夫する必要があるので、オブジェクト化したほうが楽。
- dateとdatetimeが混在する場合など、継承関係のある型が複数出てくるなら変にならないように順番や条件を調整する必要あり。
def default(item: Any):
match item:
case date():
return {"__type__": "date", "args": (item.year, item.month, item.day)}
case _ if dataclasses.is_dataclass(item):
return dataclasses.asdict(item)
case _:
raise TypeError(type(item))
jsonから元に戻すためのobject_hook
- 今回の使い方で一番大事なところ。
- jsonを読み込んだときに出てくるオブジェクト(Python的にはdict)を任意の型に変換できる。
- 入れ子の場合は変換できるものを全部変換してから上の層のオブジェクトを扱うことになるので、順序を考えなくていい。
- 例えばサンプルコードではStudentを返す条件のところでdate型が出ている。
- object_pairs_hookで変なことしない限りはdictが来てくれるのでエラー部分はdict以外が来ることは想定しないでいいと思う。
def object_hook(obj: dict[str, Any]):
match obj:
case {"number": int(), "name": str(), "birthday": date()}:
return Student(**obj)
case {"__type__": "date", "args": list() as args} if len(args) == 3 and all(
isinstance(arg, int) for arg in args
):
return date(*args)
case {"grade": int(), "class_": int(), "students": list() as students} if all(
isinstance(student, Student) for student in students
):
return Classroom(**obj)
case _:
raise ValueError("型とかキーがダメっぽいよ: {}".format({k: type(v) for k, v in obj.items()}))
- birthdayをオブジェクトにせず、isoformatでstrに変換した場合の戻し方。
- オブジェクトではないのでフックの対象にならず、birthdayを持つStudent用のオブジェクトのときにstrとして出てくる。
- なので、strとして捕まえてdate.fromisoformatで変換してから渡すことで対処する。
- この方法だとdateが出てくるすべてのデータクラスで個別に対応が必要になるので実装漏れとか大変そう。
isoformat版
def object_hook(obj: dict[str, Any]):
match obj:
case {"number": int(), "name": str(), "age": int(), "birthday": str() as birthday}:
# birthdayだけ上書きしてから渡す。
return Student(**obj | {"birthday": date.fromisoformat(birthday)})
case {"grade": int(), "class_": int(), "students": list() as students} if all(isinstance(student, Student) for student in students):
return Classroom(**obj)
case _:
raise ValueError("型とかキーがダメっぽいよ: {}".format({k: type(v) for k, v in obj.items()}))
使用イメージ
- データクラスを通常通り作成し、それに作った変換用の関数を渡してjsonで読み書きするだけ。
- assertで同値チェックを行っても通るので、相互に変換できている。
def main():
classroom = Classroom(1, 2, [])
classroom.students.append(Student(1, "taro", 10, date.today()))
filepath = "sample.json"
with open(filepath, "w", encoding="utf-8") as f:
json.dump(classroom, f, default=default)
with open(filepath, "r", encoding="utf-8") as f:
classroom2 = json.load(f, object_hook=object_hook)
assert classroom == classroom2
print(f"{classroom=}", f"{classroom2=}", sep="\n")
if __name__ == "__main__":
main()
出力
classroom=Classroom(grade=1, class_=2, students=[Student(number=1, name='taro', birthday=datetime.date(2023, 7, 10))])
classroom2=Classroom(grade=1, class_=2, students=[Student(number=1, name='taro', birthday=datetime.date(2023, 7, 10))])
全体コード
import dataclasses
import json
from datetime import date
from typing import Any
@dataclasses.dataclass(frozen=True)
class Student:
number: int
name: str
birthday: date
@dataclasses.dataclass(frozen=True)
class Classroom:
grade: int # 学年
class_: int # classが使えないので組は末尾に_を付けています。
students: list[Student]
def default(item: Any):
match item:
case date():
return {"__type__": "date", "args": (item.year, item.month, item.day)}
case _ if dataclasses.is_dataclass(item):
return dataclasses.asdict(item)
case _:
raise TypeError(type(item))
def object_hook(obj: dict[str, Any]):
match obj:
case {"number": int(), "name": str(), "birthday": date()}:
return Student(**obj)
case {"__type__": "date", "args": list() as args} if len(args) == 3 and all(
isinstance(arg, int) for arg in args
):
return date(*args)
case {"grade": int(), "class_": int(), "students": list() as students} if all(
isinstance(student, Student) for student in students
):
return Classroom(**obj)
case _:
raise ValueError("型とかキーがダメっぽいよ: {}".format({k: type(v) for k, v in obj.items()}))
def main():
classroom = Classroom(1, 2, [])
classroom.students.append(Student(1, "taro", date.today()))
filepath = "sample.json"
with open(filepath, "w", encoding="utf-8") as f:
json.dump(classroom, f, default=default)
with open(filepath, "r", encoding="utf-8") as f:
classroom2 = json.load(f, object_hook=object_hook)
assert classroom == classroom2
print(f"{classroom=}", f"{classroom2=}", sep="\n")
if __name__ == "__main__":
main()
その他
- requests.Responseのjsonメソッドの引数のobject_hookでも同じような形でデータクラスにすると型が明示できていい感じに使えるかも。
- 内部でdictをそのまま使っている部分があった場合の対応が面倒。
- object_hookでそれもエラーを出さずにdictのまま返す分岐を作る必要がある。
- もしくは不正なオブジェクトの混入を諦めて、どのパターンにもマッチしないオブジェクトはそのまま返すようにする。
- defaultが働かないのでjson保存時に読み取りやすいように加工することもできない。
- dictっぽいクラスを自作して使わせればdefaultで処理できるが、ちょっと不自由。
- object_hookでそれもエラーを出さずにdictのまま返す分岐を作る必要がある。