🐍

【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 に要求する性質として、以下の性質が挙げられます。

  1. Pydantic がうまくバリデーションしてくれる。
  2. JSON へのシリアライズの際に不便がない。
  3. プロパティを追加できる。

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 のサブクラスになるので、問題なくデコードできます。

StrEnum
from enum import StrEnum

class PrefCode(StrEnum):
    HOKKAID = "01"
    ...  # その他の都道府県

json.dumps({"pref": PrefCode.HOKKAIDO})  # {"pref": "01"}
print(PrefCode.HOKKAIDO)  # 01
IntEnum
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()
脚注
  1. JSON エンコーダーとデコーダー (公式) ↩︎

GitHubで編集を提案

Discussion