標準ライブラリだけでpythonモジュールを設定ファイルとして使う方法のメモ

標準ライブラリだけでpythonモジュールを設定ファイルとして使う方法のメモ。importlibの関数を使う方法もあるけれど。たまたまrunpyというモジュールも発見したのでそれのメモ。

pythonモジュールを設定ファイルとして使う

pythonモジュールを設定ファイルとして使うのは幾つかのフレームワークで行われている。代表的なものとしてあげられるのはdjangoやflaskかもしれない。

各々けっこう色々な方法で行っているような印象がある。一番シンプルなものはファイルをopenしてexecだけれど。最近は他の方法が取られていることが多い。

settings.py

import logging

# ログ設定
LOGGER = logging.getLogger("myapp")

# 一番多いのはdatabaseの設定かもしれない
DB = get_something_db_accessor()

app側では以下の様にして使う。慣習的に設定は全て大文字表されることが多いかもしれない。

from app import setting

setting.LOGGER.info("何か")

.jsonや.yamlや.iniなどを設定ファイルとして使うところとは異なりオブジェクトを設定できる所が違う。何より簡易的な間接参照(DI的なもの)に使えるところが便利。

runpyを使った方法

runpyについて

runpyにはrun_path()という関数が用意されていてこれには物理的なファイルパスを指定することもできる。なのでこれを使って設定ファイルとしてpythonモジュールを読み込ますことにすると物理的なファイルパスを指定できる様になるので便利。

読み込んだモジュールは辞書として返される。

import runpy
import pprint

d = runpy.run_path("./settings.py")
print(type(d))
pprint.pprint({k: repr(v)[:100] for k, v in d.items()})
# -- stdout --------------------
# >> <class 'dict'>
# >> {'LOGGER': '<Logger app (INFO)>',
# >>  '__builtins__': '{\'__name__\': \'builtins\', \'__doc__\': "Built-in '
# >>                  'functions, exceptions, and other objects.\\n\\nNoteworth',
# >>  '__cached__': 'None',
# >>  '__doc__': 'None',
# >>  '__file__': "'./settings.py'",
# >>  '__loader__': 'None',
# >>  '__name__': "'<run_path>'",
# >>  '__package__': "''",
# >>  '__spec__': 'None',
# >>  'logging': "<module 'logging' from '/usr/lib/python3.7/logging/__init__.py'>",
# >>  'os': "<module 'os' from '/usr/lib/python3.7/os.py'>"}

runpyを使った設定ファイルとしての利用

以下の様なコードを書いてあげると、設定ファイルとしてpythonモジュールを取れるようなコードが書ける。

import runpy


def run(*, setting: str) -> None:
    setting = runpy.run_path(setting)
    setting["LOGGER"].info("hello")


def main(argv=None):
    import argparse

    parser = argparse.ArgumentParser(description=None)
    parser.print_usage = parser.print_help
    parser.add_argument("--setting", required=True)
    args = parser.parse_args(argv)
    run(**vars(args))


if __name__ == "__main__":
    main()

実行結果は以下。

$ python app.py --setting=./settings.py
INFO:app:hello

このときのsettings.pyは以下。

import logging
import os

# ログ設定(本来はここで直接basicConfigを呼ぶことは少ないけれど)
LOGGER = logging.getLogger("app")
logging.basicConfig(level=getattr(logging, os.environ.get("LOGLEVEL") or "INFO"))

settings.pyで作られたloggerオブジェクトをrunpy経由で読み込んで使うことができている。

importlibを使った方法

ついでなのでimportlibを使った方法も。辞書ではなくこちらは通常のモジュールとして扱うことになる。importlib.utilで提供されている関数を使って、以下の様な関数をつくる。utilモジュールなのでAPIとしてはあまり安定していない気はしている。

module_idで指定された名前のモジュールとしてimportされる。

import sys
from importlib.util import spec_from_file_location
from importlib.util import module_from_spec


def load_module_by_path(module_id, path):
    spec = spec_from_file_location(module_id, path)
    module = module_from_spec(spec)
    spec.loader.exec_module(module)
    sys.modules[module_id] = module
    return module

後は以下のように変更する。

--- app.py   2019-03-10 23:18:46.607199037 +0900
+++ app2.py   2019-03-10 23:29:05.894543006 +0900
@@ -1,12 +1,23 @@
-import runpy
+import sys
+from importlib.util import spec_from_file_location
+from importlib.util import module_from_spec
+
+
+def load_module_by_path(module_id, path):
+    spec = spec_from_file_location(module_id, path)
+    module = module_from_spec(spec)
+    spec.loader.exec_module(module)
+    sys.modules[module_id] = module
+    return module
 
 
 def run(*, setting: str) -> None:
-    setting = runpy.run_path(setting)
-    setting["LOGGER"].info("hello")
+    setting = load_module_by_path("setting", setting)
+    setting.LOGGER.info("hello")

辞書では無くモジュールとしてimportされるのでsetting["LOGGER"]ではなくsetting.LOGGER

実行結果。

$ python app2.py --setting=./settings.py
INFO:app:hello

はじめはimportlib経由でのインターフェイス(つまりgetattrでアクセスできること)が自然だと思い、モジュールではないことに違和感を持っていたこともあったけれど。最近はむしろコンポーネントがDIされているということが明示的になる分辞書的なインターフェイスだけを持っていた方が便利なんじゃないかと思ったりもしている。

そんなわけで、runpyの注意事項が許容できたり、辞書として扱うのが問題ないのであれば、runpyを使うのが手軽かもしれない。