【Python】シリアライズ可能なまま型安全に Enum にプロパティを追加する方法
Web API を作るとき、セレクトボックスやラジオボタンなどで選択される選択肢は、表示されている値そのものではなく、番号やIDなどでやり取りされることが多いです。例えば都道府県の選択肢は表示されている "北海道" ではなく、都道府県コード "01" でやりとりすることが多いです。
データを受け取るサーバーを実装するとき、Python であれば FastAPI や Pydantic を用いれば、都道府県の Enum を定義しておけば、勝手に値のバリデーションをしてくれて便利です。
ただし、Enum にして欲しいことはバリデーションだけではありません。API は JSON でのやり取りが多いので、辞書型のJSONへのシリアライズのときに自動で変換されて欲しいです。
class PrefCode(Enum):
HOKKAID = "01"
... # その他の都道府県
json.dumps({"pref": PrefCode.HOKKAIDO})
# '{"pref": "01"}' になって欲しい
サーバーの仕事は UI とのやり取り以外にもあります。データの集計のために CSV を出したり、連携のためにCSVを出したり、、。その際、あるデータでは都道府県コードではなく都道府県名を出力したい、別のデータでは県庁所在地も出したい、など都道府県に付随する情報が必要な場合があります。そんな時、コード以外の情報も Enum が持ってくれていると便利です。
print(PrefCode.HOKKAIDO.value) # -> 01
print(PrefCode.HOKKAIDO.pref_name) # -> 北海道
print(PrefCode.HOKKAIDO.capital) # -> 札幌市
整理すると、Enum に要求する性質として、以下の性質が挙げられます。
- Pydantic がうまくバリデーションしてくれる。
- JSON へのシリアライズの際に不便がない。
- プロパティを追加できる。
Enum のコンストラクタ __new__ や初期化 __init__ をオーバーライドすればこれらを実現できます。この記事ではその方法を紹介します。Pydantic についてはよほど変なことをしなければうまく動いてくれるので、2 と 3 について解説します。
Enum の JSON へのシリアライズ
Python の標準モジュール json は辞書型から JSON へのデコードの際に、値の型を見て処理をしています。対応している型はあまり多くなく、dict, list とプリミティブ型ぐらいで[1]、それ以外の型をデコードすると TypeError が発生します。
class PrefCode(Enum):
HOKKAID = "01"
... # その他の都道府県
json.dumps({"pref": PrefCode.HOKKAIDO}) # <- TypeError が発生
Python の標準モジュール enum には StrEnum, IntEnum というものが定義されており、StrEnum では Enum のメンバーが str のサブクラス、IntEnum では Enum のメンバーが int のサブクラスになるので、問題なくデコードできます。
from enum import StrEnum
class PrefCode(StrEnum):
HOKKAID = "01"
... # その他の都道府県
json.dumps({"pref": PrefCode.HOKKAIDO}) # {"pref": "01"}
print(PrefCode.HOKKAIDO) # 01
from enum import IntEnum
class PrefCode(IntEnum):
HOKKAID = 1
... # その他の都道府県
json.dumps({"pref": PrefCode.HOKKAIDO}) # {"pref": 1}
print(PrefCode.HOKKAIDO) # 1
実用上、str と int 以外使いたい場合が思いつかないので、この二つのうちのどちらかを使えば良いと思います。
Enum のプロパティを追加する
Python ではメンバーの値の定義に tuple を渡すことができ、その値は __init__ に渡されます。__init__ の引数の型定義に合わせて tuple の型を静的にチェックしてくれます。
class PrefCode(Enum):
HOKKAIDO = ("01", "北海道")
... # その他都道府県
def __init__(self, _: str, name: str) -> None:
self.pref_name = name
print(PrefCode.HOKKAIDO.pref_name) # 北海道
しかしこれでは PrefCode.HOKKAIDO が str のサブクラスにならないため、JSON へのシリアライズに対応させると以下のようになります。
class PrefCode(StrEnum):
HOKKAIDO = ("01", "北海道")
... # その他都道府県
def __new__(cls, code: str, _: str) -> "PrefCode":
obj = str.__new__(cls, code)
obj._value_ = code
return obj
def __init__(self, _: str, pref_name: str) -> None:
self.pref_name = pref_name
print(PrefCode.HOKKAIDO.pref_name) # 北海道
json.dumps({"pref": PrefCode.HOKKAIDO}) # {"pref": "01"}
結論
- JSON のシリアライズに対応させるなら
StrEnumまたはIntEnumを使う - プロパティを追加したいならメンバーに
tupleを渡して__init__でプロバティをセットする - コンストラクタ
__new__をいじると両方に対応できる。
以下のようなベースクラスを作っても良いです。
class StrEnumWithProperty(StrEnum):
def __new__(cls, value: str, *_) -> "StrEnumWithProperty":
obj = str.__new__(cls, value)
obj._value_ = value
return obj
def __init__(self, _: str, *__) -> None:
raise NotImplementedError()
class PrefCode(StrEnumWithProperty):
HOKKAIDO = ("01", "北海道")
...
def __init__(self, _: str, pref_name: str) -> None:
self.pref_name = pref_name
print(PrefCode.HOKKAIDO.pref_name) # 北海道
print(json.dumps({"pref": PrefCode.HOKKAIDO})) # {"pref": "01"}
class IntEnumWithProperty(IntEnum):
def __new__(cls, value: int, *_) -> "IntEnumWithProperty":
obj = int.__new__(cls, value)
obj._value_ = value
return obj
def __init__(self, _: int, *__) -> None:
raise NotImplementedError()
Discussion