今pythonでCLIの設定ファイルと付き合うならpydanticを使うかも?
argparse関連の記事を最近色々書いてきたけれど。そういえば設定ファイルとの付き合い方については書いていなかった。今ならどうやって付き合うかなーということのメモ。
ここでの設定ファイルとはファイル名がコマンドライン引数として渡されて、それを読み込むという形のもの。
典型的なCLIは以下のようなもの。
$ python 00dict_args.py -h usage: 00dict_args.py [-h] [--config CONFIG] optional arguments: -h, --help show this help message and exit --config CONFIG
ここでのconfigが設定ファイル。
辞書を引数に取る関数への値の受け渡し
コマンドライン引数で設定ファイルを受け取るコマンドというのは、ちょっと見方を変えると、引数として辞書を持つ関数の実行とみなすことができるかもしれない。 (実際には辞書ではなく辞書のリストなどの可能性もあり得る)
例えば以下の様な関数。このような関数への情報の受け渡しをどうするかと言うような話。
import typing as t def use(*, config: t.Dict[str, t.Any]) -> None: do_something(config)
とりあえずシリアライズするためのフォーマットはJSONということにしよう。するとargparseのadd_argument()
にtype引数の形で渡してあげると良いかもしれない。
例えば、渡される引数をintに限定したりファイルを取るというようなときに以下の様にして使う。
parser.add_argument("-n", type=int) parser.add_argument("--file", type=argparse.FileType("r"))
JSONDictType
今回は設定ファイル(と設定用のJSON)を以下の様な形で扱うことにする。
- オプション引数に
file://
というprefix付きで渡されることがある。これはファイル名として扱う。 - オプション引数にJSON文字列として渡されることがある。この場合は
json.loads()
でparseする。
コードはこんな形になりそう。関数なのに大文字始まりの名前をつけているのはargparse.FileType
などに似せているため。
(追記: argparse.FileType
はインスタンスを引数に渡す形なので、素直にsnake_case
の名前で良かったかもしれない)
import typing as t import argparse import json def JSONDictType( filename_or_content: str, *, prefix: str = "file://" ) -> t.Dict[str, t.Any]: try: if filename_or_content.startswith(prefix): with open(filename_or_content[len(prefix) :]) as rf: d: t.Dict[str, t.Any] = json.load(rf) else: d = json.loads(filename_or_content) return d except (ValueError, FileNotFoundError) as e: raise argparse.ArgumentTypeError(str(e))
定義した関数は add_argument(..., type=JSONDictType)
のような形で使う。
import sys import argparse parser = argparse.ArgumentParser() parser.add_argument("--config", type=JSONDictType) args = parser.parse_args() json.dump(args.config, sys.stdout, indent=2) print("")
これをコマンドとして使うと以下のような形で動作する。
# JSON文字列として $ python 00dict_args.py --config='{"main": {"db": "sqlite://:memory:"}}' { "main": { "db": "sqlite://:memory:" } } # ファイル名として $ python 00dict_args.py --config=file://config.json { "main": { "db": "sqlite://:memory" }, "thirdparty": { "xxx": { "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" }, "zzz": { "clientId": "xxxxxxxxxx", "clientSecret": "xxxxxxxxxxxxxxxxxxxx" } } }
まぁふつうに動く。これで何が問題かというとvalidationができない点が問題。 例えばmainはrequiredでthirdpartyはoptionalといったことが表せない。
例えばこういう設定ファイルはエラーにしたい。。のだけれど、通ってしまう。
$ python 00dict_args.py --config=file://config-ng.json { "thirdparty": { "xxx": { "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" }, "zzz": { "clientId": "xxxxxxxxxx", "clientSecret": "xxxxxxxxxxxxxxxxxxxx" } } }
まぁJSONとしてvalidかどうかしか見ていないので当然の話。
エラー
ちなみにエラーが出た場合には以下の様に表示される。
# ファイルが見つからない場合 $ python 00dict_args.py --config=file://missing.json usage: 00dict_args.py [-h] [--config CONFIG] 00dict_args.py: error: argument --config: [Errno 2] No such file or directory: 'missing.json' # JSONとしてinvalidな場合 $ python 00dict_args.py --config=file://config-ng.json usage: 00dict_args.py [-h] [--config CONFIG] 00dict_args.py: error: argument --config: Expecting property name enclosed in double quotes: line 5 column 5 (char 96)
validationがしたい
先程の例を考えてみるとJSONとしてvalidまでが関の山。もう少し安心感を得たい。まぁつまりvalidationがしたいというところ。
いろいろと方法を考えてみる。
dataclasses.dataclass
設定を持つのにdataclassesを使っても良いかもしれない?
これはネストした構造のロードは自前で頑張らなければいけない。なので設定値がフラットなものに限定される。
typing.TypedDict
あるいは設定を読み込む関数の型をTypedDictにしても良いかもしれない?
しかしこれはmypy時にしか検証されないので。おかしな設定が渡された場合については無力。
pydantic
素直に外部パッケージに頼った方が良いかもしれない。フィールドの型定義でschemaオブジェクトを形状を定義できる手軽さと、型ヒントを尊重する構成のために補完が効くということからpydantic辺りを選ぶのが良いかもしれない。
$ pip install pydantic
先程の設定ファイルをschemaとして書き下すと以下のようなコードになる。
config.py
import typing as t from pydantic.main import BaseModel class XXXConfig(BaseModel): token: str class ZZZConfig(BaseModel): clientId: str clientSecret: str class ThirdpartyConfig(BaseModel): xxx: XXXConfig zzz: ZZZConfig class MainConfig(BaseModel): db: str class Config(BaseModel): main: MainConfig thirdparty: t.Optional[ThirdpartyConfig] = None
これを使った引数のparseは以下の様なコードを書く。
01pydantic.py
import typing as t import json import argparse from pydantic.error_wrappers import ValidationError from config import Config def ConfigType(filename_or_content: str, *, prefix: str = "file://") -> Config: try: if filename_or_content.startswith(prefix): with open(filename_or_content[len(prefix) :]) as rf: d = json.load(rf) else: d = json.loads(filename_or_content) except (ValueError, FileNotFoundError) as e: raise argparse.ArgumentTypeError(str(e)) try: return Config(**d) except ValidationError as e: raise argparse.ArgumentTypeError(str(e)) parser = argparse.ArgumentParser() parser.add_argument("--config", type=ConfigType) args = parser.parse_args() def use(config: Config) -> None: # ここでconfigがConfigと認識されるのは地味に便利 # t.TYPE_CHECKING and reveal_type(config) print(args) use(args.config)
mypyによる型チェックも通る。
$ mypy --strict 01pydantic.py Success: no issues found in 1 source file
ちなみに args.config
の型がしっかりと自分で定義した Config
になるのは地味に便利ですね。。
01pydantic.py:31: note: Revealed type is 'config.Config'
jsonschemaで形状をお知らせ
pydanticを使うともう一つ便利な機能がついてくる。定義したスキーマをjsonschemaに変換して出力することができる。詳しくは以下のドキュメントを参照。
これを取り込んで --show-schema
というオプションを追加してみる。
--- 01pydantic.py 2020-01-18 19:53:30.000000000 +0900 +++ 02pydantic-with-schema.py 2020-01-18 19:57:50.000000000 +0900 @@ -22,15 +22,21 @@ parser = argparse.ArgumentParser() -parser.add_argument("--config", type=ConfigType) - +parser.add_argument("--config", type=ConfigType, required=False) +parser.add_argument("--show-schema", action="store_true") args = parser.parse_args() -def use(config: Config) -> None: - # ここでconfigがConfigと認識されるのは地味に便利 - # t.TYPE_CHECKING and reveal_type(config) - print(args) +if args.show_schema: + from pydantic.schema import schema + + toplevel_schema = schema([Config]) + print(json.dumps(toplevel_schema, indent=2, ensure_ascii=False)) + parser.exit() + + +def use(config: "Config") -> None: + print(config) use(args.config)
以下のようにschemaが出力できる様になる。
$ python 02*.py --show-schema > schema.json
schema.json
{ "definitions": { "MainConfig": { "title": "MainConfig", "type": "object", "properties": { "db": { "title": "Db", "type": "string" } }, "required": [ "db" ] }, "XXXConfig": { "title": "XXXConfig", "type": "object", "properties": { "token": { "title": "Token", "type": "string" } }, "required": [ "token" ] }, "ZZZConfig": { "title": "ZZZConfig", "type": "object", "properties": { "clientId": { "title": "Clientid", "type": "string" }, "clientSecret": { "title": "Clientsecret", "type": "string" } }, "required": [ "clientId", "clientSecret" ] }, "ThirdpartyConfig": { "title": "ThirdpartyConfig", "type": "object", "properties": { "xxx": { "$ref": "#/definitions/XXXConfig" }, "zzz": { "$ref": "#/definitions/ZZZConfig" } }, "required": [ "xxx", "zzz" ] }, "Config": { "title": "Config", "type": "object", "properties": { "main": { "$ref": "#/definitions/MainConfig" }, "thirdparty": { "$ref": "#/definitions/ThirdpartyConfig" } }, "required": [ "main" ] } } }
ちょっとだけ -h
を早くしたい
ところでpydanticを使うと少しだけ-h
によるヘルプメッセージがもっさりするような気がする。実際時間を測ってみると0.2秒くらい増えている。まぁこれは貧弱な環境で実行しているせいかもしれない。
argparse + jsonだけの場合
$ time python 00*.py -h usage: 00dict_args.py [-h] [--config CONFIG] optional arguments: -h, --help show this help message and exit --config CONFIG real 0m0.133s user 0m0.093s sys 0m0.024s
pydanticを利用した場合
$ python 02*.py -h usage: 01pydantic.py [-h] [--config CONFIG] optional arguments: -h, --help show this help message and exit --config CONFIG real 0m0.382s user 0m0.272s sys 0m0.060s
基本的に増えた時間はimport timeのはず。というわけでtyping.TYPE_CHECKING
を使ってimportを遅延させてみる。
戻り値の型の部分だけ悩ましい。それ以外は基本的に関数の中にimportを隠すだけ。
これは--show-schema
を追加したコードとの差分。
--- 02pydantic-with-schema.py 2020-01-18 19:57:50.000000000 +0900 +++ 03pydantic-with-schema.py 2020-01-18 20:06:29.000000000 +0900 @@ -1,11 +1,15 @@ import typing as t import json import argparse -from pydantic.error_wrappers import ValidationError -from config import Config +if t.TYPE_CHECKING: + from config import Config + + +def ConfigType(filename_or_content: str, *, prefix: str = "file://") -> "Config": + from pydantic.error_wrappers import ValidationError + from config import Config -def ConfigType(filename_or_content: str, *, prefix: str = "file://") -> Config: try: if filename_or_content.startswith(prefix): with open(filename_or_content[len(prefix) :]) as rf: @@ -21,6 +25,14 @@ raise argparse.ArgumentTypeError(str(e)) +def show_schema() -> None: + from config import Config + from pydantic.schema import schema + + toplevel_schema = schema([Config]) + print(json.dumps(toplevel_schema, indent=2, ensure_ascii=False)) + + parser = argparse.ArgumentParser() parser.add_argument("--config", type=ConfigType, required=False) parser.add_argument("--show-schema", action="store_true") @@ -28,10 +40,7 @@ if args.show_schema: - from pydantic.schema import schema - - toplevel_schema = schema([Config]) - print(json.dumps(toplevel_schema, indent=2, ensure_ascii=False)) + show_schema() parser.exit()
今度はどうかというとargparse+jsonだけのときと時間がおんなじ感じになった。
$ time python 03*.py -h usage: 03pydantic-with-schema.py [-h] [--config CONFIG] [--show-schema] optional arguments: -h, --help show this help message and exit --config CONFIG --show-schema real 0m0.113s user 0m0.090s sys 0m0.018s
argparse + jsonだけを再掲
$ time python 00*.py -h usage: 00dict_args.py [-h] [--config CONFIG] optional arguments: -h, --help show this help message and exit --config CONFIG real 0m0.133s user 0m0.093s sys 0m0.024s
むしろほんのちょっとだけ早くなっているのはjsonモジュールのロードなどもなくなったせいですね(追記: 嘘です。ただの誤差でした。読み込むモジュールの量は変わりません。詳細は追記に)。
追記
↑のようなimportの違いを確認するには -X importtime
付きで実行してみるのが便利です。
$ python -X importtime 03*.py -h
実際にどれくらいのモジュールがスキップされているかというと以下の様な感じです。手抜きでプロセス置換のワンライナーを使っていますが特に意味はありません。
$ diff -u <(python -X importtime 02*.py -h 2>&1 | cut -d '|' -f 3 | sed 's/^ *//g' | sort -u) <(python -X importtime 03*.py -h 2>&1 | cut -d '|' -f 3 | sed 's/^ *//g' | sort -u)
-
がimportをlazyにしなかったもの。+
がimportをlazyにしたものです。
--- /dev/fd/63 2020-01-19 07:03:40.000000000 +0900 +++ /dev/fd/62 2020-01-19 07:03:40.000000000 +0900 @@ -71,80 +71,8 @@ locale gettext argparse -weakref -org -org.python -org.python.core -copy -_opcode -opcode -dis -token -tokenize -linecache -inspect -dataclasses -numbers -_decimal -decimal -nt -nt -nt -nt -ntpath -urllib -urllib.parse -pathlib -pydantic.typing -pydantic.errors -platform -pydantic.utils -pydantic.class_validators -math -_datetime -datetime -ipaddress -signal -msvcrt -_posixsubprocess -select -selectors -subprocess -_uuid -uuid -colorsys -pydantic.color -pydantic.datetime_parse -pydantic.validators -pydantic.types -pydantic.json -pydantic.error_wrappers -pydantic.fields -_struct -struct -_compat_pickle -_pickle -org -org.python -org.python.core -pickle -pydantic.parse -email_validator -pydantic.networks -pydantic.schema -cython -pydantic.main -pydantic.dataclasses -pydantic.env_settings -pydantic.tools -distutils -distutils.version -pydantic.version -pydantic -pydantic.error_wrappers -config textwrap -usage: 02pydantic-with-schema.py [-h] [--config CONFIG] [--show-schema] +usage: 03pydantic-with-schema.py [-h] [--config CONFIG] [--show-schema]
argparse + json だけとの比較
ついでにjsonだけのときの場合とも比較してみるとこんな感じです。
diff -u <(python -X importtime 00*.py -h 2>&1 | cut -d '|' -f 3 | sed 's/^ *//g' | sort -u) <(python -X importtime 03*.py -h 2>&1 | cut -d '|' -f 3 | sed 's/^ *//g' | sort -u)
特に減っていないので途中の訳知り顔で語った文章は。嘘ですね。
むしろほんのちょっとだけ早くなっているのはjsonモジュールのロードなどもなくなったせいですね
どうやってJSONファイルからdictを取り出していると思っているのか。。
--- /dev/fd/63 2020-01-19 07:07:00.000000000 +0900 +++ /dev/fd/62 2020-01-19 07:07:00.000000000 +0900 @@ -1,5 +1,6 @@ --config CONFIG +--show-schema -h, --help show this help message and exit _abc _bootlocale @@ -73,7 +74,7 @@ time types typing -usage: 00dict_args.py [-h] [--config CONFIG] +usage: 03pydantic-with-schema.py [-h] [--config CONFIG] [--show-schema] warnings zipimport zlib