Python でコマンドライン引数を書くとき、長らく argparse を使ってきましたが、引数が増えてくると定義が散らばったり、設定との整合性が取りづらくなったりして、少し扱いづらさを感じていました。
そんな中で pydantic-settings を試してみたところ、
- 見た目が整理される
- 型がわかりやすくなる
- Config用のファイルや環境変数等と同居しやすい
- ネストも扱えて整理しやすい
といった点で使いやすかったので、以下で紹介したいと思います。
見た目が整理される
いままでargparseで書いていたものを書き換えてみましょう。
例えば、以下のようなプログラムがあったとします。
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--input_path", type=str, required=True)
parser.add_argument("--dry_run", action="store_true")
parser.add_argument("--retry_count", type=int, default=3)
args = parser.parse_args()
print(vars(args))
これをpydantic_settingsで書き換えるとこのようになります。
from pydantic_settings import BaseSettings
class Arguments(BaseSettings, cli_parse_args=True):
input_path: str
dry_run: bool = False
retry_count: int = 3
args = Arguments()
print(args.model_dump())
cli_parse_args=True を使うことで、クラスのインスタンスを作成した時点で CLI 引数が読まれるようになります。
引数パーサを別途組まなくても、設定をまとめたデータクラスのようなものを作るだけで CLI 入力まで拾えます。便利!
bool の挙動について
ただし、boolの挙動はちょと特殊です。
以下のように、先程の設定だと明示的にboolを指定する必要があります。
# 先程の設定だと以下のように明示的にboolを指定する必要がある
python example.py --dry_run true
# こちらだとエラーになる
python example.py --dry_run
argparseでの store_true 設定のように、わざわざtrueを明示せずとも動いて欲しいものですが、cli_implicit_flagsを指定しない限りエラーになります。
cli_implicit_flags='toggle' を指定すると、store_true store_falseの挙動と近い形になります。
from pydantic_settings import BaseSettings
class Arguments(BaseSettings, cli_parse_args=True, cli_implicit_flags="toggle"):
dry_run: bool = False
use_cache: bool = True
この場合の挙動はこうなります。
python example2.py
# dry_run=False, use_cache=True
python example2.py --dry_run
# dry_run=True, use_cache=True
python example2.py --no-use_cache
# dry_run=False, use_cache=False
dual と toggle の違い
この 2 つは似ていますが少し違います。
ざっくり理解すると
-
toggle: 反転 -
dual: 明示的に両方定義 (自動で--no-**が付与)
たとえば以下のようにcli_implicit_flags="dual"とした場合
from pydantic_settings import BaseSettings
class Arguments(BaseSettings, cli_parse_args=True, cli_implicit_flags="dual"):
dry_run: bool = False
use_cache: bool = True
次のような動作になります。
python example3.py
# dry_run=False, use_cache=True
python example3.py --dry_run
# dry_run=True, use_cache=True
python example3.py --no-dry_run
# dry_run=False, use_cache=True
python example3.py --use_cache
# dry_run=False, use_cache=True
python example3.py --no-use_cache
# dry_run=False, use_cache=False
なんだか--no-**が自動で足されるのは直感的じゃない気もするので私は toggle のほうが好みですが、用途に合わせて使い分けることもあるかもしれません。
型がわかりやすい
pydantic_settings の BaseSettings を継承しているため、型情報がそのままコードに反映されます。
argparse でも型は指定できますが、取得した値は単なる属性アクセスになるため、IDEや型チェッカーとの連携は弱めです。一方で pydantic のモデルとして扱えることで、型に基づいた補完や検証が効くようになります。
例えば次のようなコードを考えます。
def run(args: Arguments) -> None:
reveal_type(args.input_path) # str
reveal_type(args.retry_count) # int
print(args.input_path.upper()) # 型安全に扱える
ここで retry_count に文字列が渡された場合、argparse では実行時まで気づけないことがありますが、pydantic-settings では起動時にバリデーションエラーとなります。
python app.py --retry_count abc
# -> ValidationError
このように不正な入力を早い段階で検出できるので、かなり便利に感じています。
例えば argparse の場合、型指定を忘れると以下のようなバグが入り込みます。
parser.add_argument("--retry_count") # 型指定なし
# 実行時に文字列として扱われる
print(args.retry_count + 1) # TypeError
入力規則のバリデーション
型指定の他にも、入力規則についてのバリデーションも可能です。
たとえば port: int に文字列が来たなどの型のチェックだけでなく、timeout: float に不正値が来た、ネストした設定が欠けていた、というケースで、起動直後に検証エラーとなります。
pydanticのFieldクラスを使えば、数値を正の値に制限する、正規表現を用いて文字列を制限するなど、ある程度のバリデーションが可能です。
from pydantic import Field
from pydantic_settings import BaseSettings
class Arguments(BaseSettings, cli_parse_args=True):
port: int = Field(..., ge=1, le=65535)
timeout: float = Field(1.0, gt=0)
# 不正値例:
# python app.py --port -1
# -> ValidationError
設定ファイル類 と CLI を共存
いちばん実務的な利点は、設定の入り口が増えてもモデルが増えないことです。(ML関連のプロジェクトだと肥大しがち!)
BaseSettings は、環境変数(.env)、その他設定ファイル類、そして CLI 引数を同じ型付きモデルで受けられます。
例えば環境変数との共存は以下のように扱えます。
from pydantic_settings import BaseSettings
class Arguments(BaseSettings):
input_path: str
retry_count: int = 3
class Config:
env_prefix = "APP_"
# 環境変数
# export APP_INPUT_PATH=/tmp/data.txt
args = Arguments()
print(args.input_path)
以下のようにjsonやyamlをパースすることもできます。
from pydantic_settings import BaseSettings
import json
import yaml
class Arguments(BaseSettings):
input_path: str
retry_count: int
@classmethod
def from_json(cls, path: str):
with open(path) as f:
data = json.load(f)
return cls(**data)
@classmethod
def from_yaml(cls, path: str):
with open(path) as f:
data = yaml.safe_load(f)
return cls(**data)
ネストした設定もそのまま持てる
設定が増えると、平坦な引数列はすぐ読みにくくなります。pydantic-settings では、ネストした BaseModel を持てるので、設定の構造を保ったまま CLI に出せます。
例えばargparseで書くと平坦に書くしかなかったこのような構造も…
parser = argparse.ArgumentParser()
parser.add_argument("--db_host", type=str, required=True)
parser.add_argument("--db_port", type=int, default=5432)
parser.add_argument("--log_level", type=str, default="info")
args = parser.parse_args()
以下のようにスッキリと書くことができます。
from pydantic import BaseModel
from pydantic_settings import BaseSettings
class DbSettings(BaseModel):
host: str
port: int = 5432
class Arguments(BaseSettings, cli_parse_args=True):
db: DbSettings
log_level: str = "info"
実行時は次のようになります。
python app.py --db.host localhost --db.port 5432 --log_level debug
この方式のよいところは、設定の意味がクラス構造に残ることです。
db_host, db_port, db_user, db_password と平たく並べるより、読むときに負担が小さいので、コードからすぐにどこに引数があるか分かりますよね。
Subcommandを扱う
一応、複雑なCLIの設計においてはargparseに劣るかなとは思っています。
例えばサブコマンドを扱うには以下のように定義する必要があります。
from pydantic import BaseModel
from pydantic_settings import CliApp, CliPositionalArg, CliSubCommand
class Init(BaseModel):
directory: CliPositionalArg[str]
def cli_cmd(self) -> None:
print(f'init: {self.directory}')
class Clone(BaseModel):
repository: CliPositionalArg[str]
directory: CliPositionalArg[str]
def cli_cmd(self) -> None:
print(f'clone: {self.repository} -> {self.directory}')
class Git(BaseModel):
init: CliSubCommand[Init]
clone: CliSubCommand[Clone]
def cli_cmd(self) -> None:
CliApp.run_subcommand(self)
if __name__ == "__main__":
CliApp.run(Git)
これも悪くないですが、CliApp / CliSubCommand / cli_cmd() などを利用して書いており、せっかくのシンプルさが無くなった気もします。
一方で、ArgumentParserを使うと、比較的直感的に書くことができます。(好みもあると思いますが…)
import argparse
parser = argparse.ArgumentParser(prog="git")
subparsers = parser.add_subparsers(dest="command", required=True)
# init コマンド
init_parser = subparsers.add_parser("init")
init_parser.add_argument("directory", type=str)
# clone コマンド
clone_parser = subparsers.add_parser("clone")
clone_parser.add_argument("repository", type=str)
clone_parser.add_argument("directory", type=str)
args = parser.parse_args()
if args.command == "init":
print(f"init: {args.directory}")
elif args.command == "clone":
print(f"clone: {args.repository} -> {args.directory}")
まぁ苦労してでも型が付いていた方が良いとは思いますし、良し悪しかなと思います。
最後に
自分はあまり複雑なCLIを扱わないので、Pydantic-Settingsで十分だなと思ったので勉強ついでに記事を書きました。
最近色んな拡散モデルやFlow系のモデルのコードを扱いますが、研究者の書くコードって色んなところに設定が散らばっていたり、引数も膨大な数になっていたりするので、Pydantic-Settingsで整理されたコードが広まると良いなと思います。
参考文献
[1]: https://docs.pydantic.dev/latest/concepts/pydantic_settings/ "Settings Management | Pydantic Docs"
[2]: https://docs.pydantic.dev/latest/api/pydantic_settings/ "Pydantic Settings | Pydantic Docs"