私が歌川です

@utgwkk が書いている

環境変数から設定値を読み取るPythonのメタクラス

アプリケーションの設定を環境変数経由で渡すし、個別の設定にはデフォルト値がある、というシチュエーションを考える。

素朴には os.environ.get("FOO", "default_value") みたいなのを書けば設定値を取得できる。が、これにはいくつか問題がある。

まず、あまりに素朴に実装すると os.environ.get("FOO", "default_value") がコピペされまくることになる。これでは環境変数の名前やデフォルト値を修正したくなったときに確認すべき箇所が多くなる。

設定値を書く用のファイルを用意して foo = os.environ.get("FOO", "default_value") みたいに書きまくると多少はましになる。が、まだ環境変数名とPythonでの変数名を重複して書いているのでもうちょっと短く書きたい。また、たとえばテストなどで環境変数の値を書き換えるときも、importの順序次第でうまく書き換えられないことがある。

環境変数から値を読んで返す関数を定義すれば値を書き換えられるようになるけど、設定値を読み取るためのコードが長くなってしまう。

ということで、表題のソリューションを用意した。まずはこちらをご覧ください*1

import os
import re
from typing import List

from app.util import bool_from_env_var

VARIABLE_PATTERN = re.compile(r"[a-z][0-9a-z_]*")


def _create_property(env_var_name, default_value, var_type="str"):
    def getter(self):
        if var_type == "bool":
            # 環境変数の値 (文字列) をいい感じにboolに変換する
            return bool_from_env_var(os.environ.get(env_var_name, ""))
        elif var_type == "int":
            return int(os.environ.get(env_var_name, default_value))
        else:
            return os.environ.get(env_var_name, default_value)

    def setter(self, new_value):
        if isinstance(new_value, bool):
            os.environ[env_var_name] = "1" if new_value else None
        else:
            os.environ[env_var_name] = str(new_value)

    return property(getter, setter)


class ConfigFromEnvironmentVariableMeta(type):
    def __new__(cls, classname, bases, dic):
        variables: List[str] = [k for k in dic if VARIABLE_PATTERN.match(k)]
        new_dic = {k: v for k, v in dic.items() if k.startswith("__")}

        for variable in variables:
            default_value = dic[variable]
            if callable(default_value):
                new_dic[variable] = default_value
                continue

            env_var_name = variable.upper()
            var_type = type(default_value).__name__

            new_dic[variable] = _create_property(env_var_name, default_value, var_type)

        return type.__new__(cls, classname, bases, new_dic)


class ConfigFromEnvironmentVariable(metaclass=ConfigFromEnvironmentVariableMeta):
    pass

ConfigFromEnvironmentVariable クラスを継承した設定値用のクラスを作って、以下のように使うことができる。直接 metaclass を指定してもよいけど、メタクラスよりは継承のほうが馴染みのある概念だと思うのでラップした。

import os


class AppConfig(ConfigFromEnvironmentVariable):
    # 環境変数が設定されていない場合のデフォルト値を指定できる
    base_url = "http://localhost:3000/"
    debug = False


app_config = AppConfig()

app_config.base_url # => "http://localhost:3000/"

# 値をセットすると設定値も書き換えられる (裏では環境変数を書き換えている)
app_config.base_url = "http://localhost:3000/test/"
app_config.base_url # => "http://localhost:3000/test/"

# 型をよしなに見て空気を読んでくれる
app_config.debug # => False

app_config.debug = True
app_config.debug # => True

原理

__new____init__ の前に呼び出されるクラスメソッドで、いろいろ引数を受け取るけど注意すべきは第3引数の dic である。

dic にクラス変数とその値が入っているので、デフォルト値と型を読み取ったあとpropertyに置き換えている。クラス変数以外も入っているので正規表現マッチでフィルタリングしている。ただしメソッドや __ で始まるフィールドは保持している。

property クラスでアクセサを定義している。デコレータとして使うことが多いかもしれないけどこういう使い方もできる。

原理を完全に把握するにはCPythonのコード*2に立ち入るしかなさそうだった。公式のドキュメントを読んでみたけど __new__ メソッドの引数についての言及を見つけられなかった。

参考

以下の記事を見つけて大いに参考にさせてもらった。紹介されているのはPython 2のコードだけど、ちょっと雰囲気を揃えたらPython 3でも動く。

www.yunabe.jp

2022/4/24 追記: 同様のライブラリを探した

myenvというライブラリがまさに同じようなことを実現できる作りになっていそう。実装もだいたい似たようなことをやっているようだった。