標準ライブラリだけで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を使うのが手軽かもしれない。

ネストした辞書(JSON)のマージについて考えるときに気にするのはリスト(Array)に対する操作かもしれない

github.com

JSONのマージ

dictknifeにはcatというサブコマンドがあり、これに複数のファイルを指定すると良い感じにマージしてくれる。

例えば以下のような2つのjsonをマージしてみる(mkdictはjoのようなコマンドラインJSONをつくるための便利コマンド。ファイルにするのが面倒だったので使っているだけ。特に意味はない)。

$ dictknife mkdict name=foo age=20 | tee person.json
{
  "name": "foo",
  "age": 20
}
$ dictknife mkdict nickname=F | tee nickname.json
{
  "nickname": "F"
}
$ dictknife cat person.json nickname.json -o json
{
  "name": "foo",
  "age": 20,
  "nickname": "F"
}

キーに衝突があった場合には後勝ちで後ろにあるものが優先される。これは設定ファイルを一部変更して使うことなどを考えると自然な形(だと思う)。

$ dictknife mkdict name=foo age=20 | tee person.json
{
  "name": "foo",
  "age": 20
}
$ dictknife mkdict name=FOO nickname=F | tee nickname2.json
{
  "name": "FOO",
  "nickname": "F"
}
$ dictknife cat person.json nickname2.json -o json
{
  "name": "FOO",
  "age": 20,
  "nickname": "F"
}

ネストしたJSONのマージ

もちろんネストしたJSONにも使える。

$ dictknife mkdict father/name=bar mother/name=boo | tee parents.json
{
  "father": {
    "name": "bar"
  },
  "mother": {
    "name": "boo"
  }
}
$ dictknife mkdict mother/name=xyz | tee parents2.json
{
  "mother": {
    "name": "xyz"
  }
}
$ dictknife cat person.json parents.json parents2.json -o json
{
  "name": "foo",
  "age": 20,
  "father": {
    "name": "bar"
  },
  "mother": {
    "name": "xyz"
  }
}

内部的にはcollections.ChainMapを使うと値の欠損がないだとか、元の値を壊さないようにするべきかとか色々考えることはあるけれど。こうやってコマンドとして使う分には気にする必要はなさそう。

悩みどころはJSONのArray(list)

内部にリストを持っている時に何が自然か?というのが結構シチュエーション毎に違う。例えば

  • [1,2,3,4,5]
  • [2,4,6]

を値の要素に持つ辞書をマージしたときにはどの様になるのが自然と言えるんだろうか?

addtoset(default)

集合の様に考えてマージすると [1,2,3,4,5,6] になる。これがデフォルトの形。設定ファイルなどをいじっていて追加で設定を加えたい場合などにはこの形が便利。

$ dictknife --log=WARNING --compact  mkdict vs/=1 vs/=2 vs/=3 vs/=4 vs/=5 | tee xs.json
{"vs": [1, 2, 3, 4, 5]}
$ dictknife --log=WARNING --compact  mkdict vs/=2 vs/=4 vs/=6 | tee ys.json
{"vs": [2, 4, 6]}
$ dictknife --log=WARNING --compact cat xs.json ys.json -o json
{"vs": [1, 2, 3, 4, 5, 6]}

replace

衝突したときの値を完全に置き換えたい場合もある。元の値から要素を取り除いて完全に新しい値で置き換えたい場合はもある。このときは[2,4,6]になる。

例えばテンプレートエンジン(jinja2)に渡す設定値のところではこれがデフォルトだと嬉しかった(ちなみにkamidanaのデフォルト)。

他の方法でマージするには--merge-methodを指定する(まだmaster branchでしか動かない)。

$ dictknife --log=WARNING --compact mkdict vs/=1 vs/=2 vs/=3 vs/=4 vs/=5 | tee xs.json
{"vs": [1, 2, 3, 4, 5]}
$ dictknife --log=WARNING --compact mkdict vs/=2 vs/=4 vs/=6 | tee ys.json
{"vs": [2, 4, 6]}
$ dictknife --log=WARNING --compact cat --merge-method=replace xs.json ys.json -o json
{"vs": [2, 4, 6]}

append

特に置き換えたりせず全ての値を取り込んで返したい場合もある。リストにおけるappendというかextend的な操作。このときは[1, 2, 3, 4, 5, 2, 4, 6]になる。

$ dictknife --log=WARNING --compact mkdict vs/=1 vs/=2 vs/=3 vs/=4 vs/=5 | tee xs.json
{"vs": [1, 2, 3, 4, 5]}
$ dictknife --log=WARNING --compact mkdict vs/=2 vs/=4 vs/=6 | tee ys.json
{"vs": [2, 4, 6]}
$ dictknife --log=WARNING --compact cat --merge-method=merge xs.json ys.json -o json
{"vs": [1, 2, 3, 4, 5, 2, 4, 6]}

merge

2つの値をそのまま繋げたいのだけれど。先程のappendが縦方向(?)の結合であったとしたら横方向の結合(?)をしたくなることもある。この操作に関しては先程までの要素が数値だけの例だと不適切かもしれない。例えば同時に指定できるフィールドの数に制限があり、N回に分けたrequestのresponseをマージしたいような状況等で使う。

$ cat values.json
[
  {"name": "damage", "value": 100},
  {"name": "heal", "value": 200},
  {"name": "damage", "value": 100}
]
$ cat values2.json
[
  {"name": "damage", "id": 1},
  {"name": "heal", "id": 2},
  {"name": "damage", "id": 3}
]
$ dictknife cat --merge-method=merge values.json values2.json -o json
[
  {
    "name": "damage",
    "value": 100,
    "id": 1
  },
  {
    "name": "heal",
    "value": 200,
    "id": 2
  },
  {
    "name": "damage",
    "value": 100,
    "id": 3
  }
]

今の所これに対して重複を取り除きたい(addtoset的なふるまいをさせたい)という気持ちになったことはない。

ところで

どこかのタイミングでjoinだとかpandas的な操作だとかが欲しくなってくるような気がしている(とはいえカジュアルに使うにはpandasのimportは重いし。こちらは主にデータのハンドリングというよりは設定ファイルのような小さめの設定を取り扱うことをイメージしている)。