今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辺りを選ぶのが良いかもしれない。

pydantic-docs.helpmanual.io

$ 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

gist