アプリケーションの設定を環境変数経由で渡すし、個別の設定にはデフォルト値がある、というシチュエーションを考える。
素朴には 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でも動く。
2022/4/24 追記: 同様のライブラリを探した
myenvというライブラリがまさに同じようなことを実現できる作りになっていそう。実装もだいたい似たようなことをやっているようだった。