enum.py を使った定数管理

こんにちは。いろいろ開発していると、システムに用いる定数を管理したいことがあります。ファイルに保存したりデータベースで管理したりすることも多いかと思いますが、今回は標準モジュール enum を使って Python ファイル内で Python から呼びやすい形で管理する方法を考えます。

まず enum については公式の説明が詳しいので、初見の方はぜひ読んでみてください↓(正直公式ドキュメントを読むだけでいろいろな使い方ができるので、この記事の存在意義は微妙かもしれません(小声))
docs.python.org

定数管理のベースとなるのは、公式ドキュメントでも紹介されている Planet の使い方です。

class Planet(Enum):
    MERCURY = (3.303e+23, 2.4397e6)
    VENUS   = (4.869e+24, 6.0518e6)
    EARTH   = (5.976e+24, 6.37814e6)
    MARS    = (6.421e+23, 3.3972e6)
    JUPITER = (1.9e+27,   7.1492e7)
    SATURN  = (5.688e+26, 6.0268e7)
    URANUS  = (8.686e+25, 2.5559e7)
    NEPTUNE = (1.024e+26, 2.4746e7)

    def __init__(self, mass, radius):
        self.mass = mass       # in kilograms
        self.radius = radius   # in meters

    @property
    def surface_gravity(self):
        # universal gravitational constant  (m3 kg-1 s-2)
        G = 6.67300E-11
        return G * self.mass / (self.radius * self.radius)

if __name__ == "__main__":
    print(Planet.EARTH.value)  # (5.976e+24, 6378140.0)
    print(Planet.EARTH.surface_gravity)  # 9.802652743337129

このクラスが持つ特徴はこんな感じです:

  • "Planet" という名前のもとに集めることで集まりに意味を持たせられる
  • 各惑星の特徴付ける定数を値に持たせている
  • 各惑星ごとに値を得るためのメソッドを用意している

さらに、enum の値 (右辺の値) は後から変更することが禁止されています:

Planet.EARTH = (1.3, 2.5)  # SyntaxError
Planet.EARTH.value = (1.3, 2.5)  # SyntaxError

あとから enum の要素を増やすこともできません。強制的に一箇所でまとめて定義することになります。
これらの特徴からも、enum は定数管理にぴったりですね。
Planet を真似て、架空の友人たちの誕生日を管理するクラスを作ってみます。

from datetime import date
from enum import Enum

class Birthday(Enum):
    JOHN  = date(1995, 10, 12)
    MIKE  = date(1992, 5, 18)
    ALICE = date(1985, 2, 2)

    @classmethod
    def from_name(cls, name: str) -> date:
        return getattr(cls, name.upper()).value

if __name__ == "__main__":
    print(Birthday.JOHN.value)  # 1995-10-12
    print(Birthday.from_name("Alice"))  # 1985-02-02

ここではクラスメソッド "from_name" を実装して、必ずしも大文字でなくても呼び出せるようにしてみました。
より多くの定数を同時に管理することも可能です。例として性別(これも enum 管理!)も要素に含めるようにしてみましょう。クラス名もやや一般的に「Friends」にしてみます。

from datetime import date
from enum import Enum

class Sex(Enum):
    M = "Male"
    F = "Female"

class Friends(Enum):
    JOHN  = (Sex.M, date(1995, 10, 12))
    MIKE  = (Sex.M, date(1992, 5, 18))
    ALICE = (Sex.F, date(1985, 2, 2))

    def __init__(self, sex: Sex, birthday: date):
        self.sex = sex
        self.birthday = birthday

    @classmethod
    def sex_of(cls, name: str) -> Sex:
        return getattr(cls, name.upper()).sex
        # 別の書き方↓
        # return cls[name.upper()].sex

    @classmethod
    def birthday_of(cls, name: str) -> date:
        return getattr(cls, name.upper()).birthday
        # 別の書き方↓
        # return cls[name.upper()].birthday

if __name__ == "__main__":
    print(Friends.JOHN.sex)  # Sex.M
    print(Friends.MIKE.birthday)  # 1992-05-18
    print(Friends.sex_of("Alice"))  # Sex.F
    print(Friends.birthday_of("John"))  # 1995-10-12

例えば上のクラスを定義したファイル const.py をどこかにおいておいて、別ファイルで以下のような使い方もできます。

from datetime import date
from const import Friends

if __name__ == "__main__":
    today = date(2021, 10, 12)
    for friend in Friends:
        name = friend.name  # "JOHN"
        birthday = friend.birthday
        if (today.month, today.day) == (birthday.month, birthday.day):
            print(f"Happy Birthday, {name.capitalize()}!")  # Happy Birthday, John!

John の誕生日が知りたいときは以下のように書けます:

from const import Friends

if __name__ == "__main__":
    print(Friends.JOHN.birthday)  # 1995-10-12
    print(Friends["JOHN"].birthday)  # 1995-10-12
    print(Friends.birthday_of("John"))  # 1995-10-12

ところで・・・John のあだ名で呼び出したい場合もあるかもしれませんが、今のままではエラーになります*1:

from datetime import date
from const import Friends

if __name__ == "__main__":
    print(Friends.birthday_of("Johnny"))  # AttributeError: JOHNNY

コードの中で定義していないので当然の結果といえば当然の結果です。
この書き方をできるようにするために、alias=別名を設定できるようにしてみましょう。具体的には、enum を定義するときの値に alias を含めておき、birthday_of や sex_of を呼び出すときにその中から検索するようにします:

from datetime import date
from enum import Enum

class Sex(Enum):
    M = "Male"
    F = "Female"

class Friends(Enum):
    JOHN  = ({"JOHNNY", "WALKER"}, Sex.M, date(1995, 10, 12))
    MIKE  = ({"MICKY"}, Sex.M, date(1992, 5, 18))
    ALICE = ({"PRINCESS", "WHITE"}, Sex.F, date(1985, 2, 2))

    def __init__(self, aliases, sex: Sex, birthday: date):
        self.aliases = aliases
        self.sex = sex
        self.birthday = birthday

    @classmethod
    def sex_of(cls, name: str) -> Sex:
        try:
            return getattr(cls, name.upper()).sex
        except AttributeError as e: # 名前がなければ alias を検索
            for friend in cls:
                if name.upper() in friend.aliases:
                    return friend.sex
            raise e  # 見つからなければそのまま raise

    @classmethod
    def birthday_of(cls, name: str) -> date:
        try:
            return getattr(cls, name.upper()).birthday
        except AttributeError as e: # 名前がなければ alias を検索
            for friend in cls:
                if name.upper() in friend.aliases:
                    return friend.birthday
            raise e  # 見つからなければそのまま raise

if __name__ == "__main__":
    print(Friends.birthday_of("Johnny"))  # 1995-10-12
    print(Friends.birthday_of("Walker"))  # 1995-10-12
    print(Friends.birthday_of("Princess"))  # 1985-02-02

処理が似ている部分が出てきたので、関数でまとめてしまいましょう。また「名前を探してなかったら別名から探す」というのが二度手間なので、全部 alias に入れてしまう実装に直してみます。

from datetime import date
from enum import Enum

class Sex(Enum):
    M = "Male"
    F = "Female"

class Friends(Enum):
    JOHN  = ({"JOHN", "JOHNNY", "WALKER"}, Sex.M, date(1995, 10, 12))
    MIKE  = ({"MIKE", "MICKY"}, Sex.M, date(1992, 5, 18))
    ALICE = ({"ALICE", "PRINCESS", "WHITE"}, Sex.F, date(1985, 2, 2))

    def __init__(self, aliases, sex: Sex, birthday: date):
        self.aliases = aliases
        self.sex = sex
        self.birthday = birthday

    @classmethod
    def alias_to_member(cls, name: str):
        for friend in cls:
            if name.upper() in friend.aliases:
                return friend
        raise KeyError(name.upper())

    @classmethod
    def sex_of(cls, name: str) -> Sex:
        return cls.alias_to_member(name).sex

    @classmethod
    def birthday_of(cls, name: str) -> date:
        return cls.alias_to_member(name).birthday

if __name__ == "__main__":
    print(Friends.birthday_of("Johnny"))  # 1995-10-12
    print(Friends.birthday_of("Walker"))  # 1995-10-12
    print(Friends.birthday_of("Princess"))  # 1985-02-02
    print(Friends.birthday_of("Henry"))  # KeyError: 'HENRY'

かなり利便性が出てきました。この書き方は汎用性がありそうなので、もうすこし一般化したクラス "AliasEnum" を作っておいて、使い回せるようにしておきます。

from enum import Enum
from typing import Iterable

# *** 一般的なクラス ***
class FrozenValueDict(dict):
    """値の上書き禁止辞書"""
    def __setitem__(self, key, value):
        if key in self.keys():
            raise ValueError(f"key '{key}' already exists")
        super().__setitem__(key, value)

class AliasEnum(Enum):
    def __new__(cls, aliases: Iterable[str], value):
        obj = object.__new__(cls)
        obj._value_ = value
        if "_alias2member_map_" not in cls.__dict__.keys():
            cls._alias2member_map_ = FrozenValueDict()
        for alias in aliases:
            try:
                cls._alias2member_map_[alias] = obj
            except ValueError:
                raise ValueError(f"duplicated alias '{alias}'")
        return obj

    @classmethod
    def alias_to_member(cls, alias: str):
        filtered_alias = cls._alias_filter(alias)
        if filtered_alias in cls._alias2member_map_:
            return cls._alias2member_map_[filtered_alias]
        else:
            raise KeyError(filtered_alias)

    @classmethod
    def alias_to_value(cls, alias: str):
        filtered_alias = cls._alias_filter(alias)
        if filtered_alias in cls._alias2member_map_:
            return cls._alias2member_map_[filtered_alias].value
        else:
            return cls._default_value(alias)

    @classmethod
    def _alias_filter(cls, alias: str):
        """overwrite it in sub-class if necessary"""
        return alias

    @classmethod
    def _default_value(cls, alias: str):
        """overwrite it in sub-class if necessary"""
        filtered_alias = cls._alias_filter(alias)
        raise KeyError(filtered_alias)

このクラスの特徴はこんな感じです↓

  • 内部に _alias2member_map_ を持っておき、alias に該当するものが来たら Enum オブジェクトを返すように定義しておく(__new__ 内に記述)
  • _alias2member_map_ 作成の際に alias が重複したら raise ValueError する(__new__ 内に記述, FrozenValueDict を利用)
  • value は単一の値に固定。その後の扱いは子クラスでよしなに定義する(複数値を取りたい場合は NamedTuple を使うとか)
  • Enum オブジェクトの .value のところは value として与えた部分のみを持つようにする
  • value を取得する際、alias が無いときの既定値を設定する方法を提供する(子クラスで _default_value をオーバーライド)
  • alias の登録や使い方のルールを自分で決められるようにする。例えば大文字のものを alias として登録し、検索する差異には大文字に変換してから探しに行くようにするためには、_default_value メソッドを子クラスで x -> x.upper() という関数でオーバーライドする。

このクラスを例えば mylib.py に入れておき、import した上で継承して先ほどの Friends クラスを定義してみると、↓のようになります。

from enum import Enum
from typing import NamedTuple
from mylib import AliasEnum

# *** 具体的なクラス ***
class Sex(Enum):
    M = "Male"
    F = "Female"

class FriendValue(NamedTuple):
    sex: Sex
    birthday: date

class Friends(AliasEnum):
    JOHN  = ({"JOHN", "JOHNNY", "WALKER"},
             FriendValue(Sex.M, date(1995, 10, 12)))
    MIKE  = ({"MIKE", "MICKY"},  # "JOHN" を alias に足すと ValueError
             FriendValue(Sex.M, date(1992, 5, 18)))
    ALICE = ({"ALICE", "PRINCESS", "WHITE"},
             FriendValue(Sex.F, date(1985, 2, 2)))

    def __init__(self, _, value):
        self.sex = value.sex
        self.birthday = value.birthday

    @classmethod
    def _alias_filter(cls, alias: str):
        return alias.upper()

    @classmethod
    def sex_of(cls, name: str):
        return cls.alias_to_value(name).sex

    @classmethod
    def birthday_of(cls, name: str):
        return cls.alias_to_value(name).birthday

_alias_filter を設定することで、alias は必ず大文字という制約を設定しています。
このクラスを const.py に入れておけば、以下のような使い方ができます。

from datetime import date
from const import Friends

if __name__ == "__main__":
    print(Friends.alias_to_member("John").value)
    # FriendValue(sex=<Sex.M: 'Male'>, birthday=datetime.date(1995, 10, 12))
    print(Friends.birthday_of("John"))  # 1995-10-12
    print(Friends.birthday_of("Micky"))  # 1992-05-18
    print(Friends.birthday_of("Princess"))  # 1985-02-02

    today = date(2021, 10, 12)
    for friend in Friends:
        birthday = friend.birthday
        if (birthday.month, birthday.day) == (today.month, today.day):
            print(f"Happy Birthday, {friend.name.capitalize()}!")  # John

AliasEnum は結構汎用性が高そうなので、いろいろ使ってみてください。

追記(2021/10/11):
AliasEnum の機能をいろいろ整備して doc-string も適当に付けてみたものを github で公開しました↓
github.com

*1:birthday_of を「別の書き方」でリターンしている場合は KeyError が出ます。