0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

さよなら、argparse(コマンドライン引数をPydantic-Settingsで定義する)

0
Posted at

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で書き換えるとこのようになります。

example.py
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の挙動と近い形になります。

example2.py
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

dualtoggle の違い

この 2 つは似ていますが少し違います。
ざっくり理解すると

  • toggle: 反転
  • dual: 明示的に両方定義 (自動で--no-**が付与)

たとえば以下のようにcli_implicit_flags="dual"とした場合

example3.py
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_settingsBaseSettings を継承しているため、型情報がそのままコードに反映されます。

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 に不正値が来た、ネストした設定が欠けていた、というケースで、起動直後に検証エラーとなります。

pydanticFieldクラスを使えば、数値を正の値に制限する、正規表現を用いて文字列を制限するなど、ある程度のバリデーションが可能です。

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"

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?