Pydantic Settings で CLI アプリケーションを作成する
Pydantic は、Python のデータバリデーションと設定管理のためのライブラリとして広く利用されています。とくに、データモデルの定義とバリデーションを簡潔に行うことができるため、多くのプロジェクトで採用されています。
普段 Python で CLI アプリケーションを作成するとき、google/python-fire を選択していることが多いです。
しかし、Pydantic のドキュメントに、CLI アプリケーションの作成方法についても記載されています。
本記事では、Pydantic Settings を使用して CLI アプリケーションを作成する方法について学んだことをまとめます。
https://github.com/kntks/blog-code/tree/main/2024/11/pydantic-cli
| バージョン | |
|---|---|
| Mac | Ventura 13.2.1 | 
| uv | 0.5.1 | 
| Python | 3.12.2 | 
環境構築には uv を使用し、pydantic と pydantic-settings をインストールします。
uv init pydantic-cliuv add pydantic pydantic-settingspydantic-settings は、環境変数やシークレットファイルから設定を読み込むためのライブラリです。Pydantic と組み合わせて使用することで、機能を拡張できます。
Pydantic CLI アプリケーションの作成
Section titled “Pydantic CLI アプリケーションの作成”Pydantic を使った CLI アプリケーションのユースケースは主に以下の2つです。
- Pydantic モデルのフィールドをオーバーライドするため
 - Pydantic モデルを使ってCLIを定義するため
 
引用:https://docs.pydantic.dev/latest/concepts/pydantic_settings/#command-line-support
今回は2番目にある CLI を定義するために、Pydantic モデルを使用します。
サンプルコード
Section titled “サンプルコード”CliApp クラスは CliApp.run と CliApp.run_subcommand の2つのユーティリティメソッドを提供し、BaseSettings、BaseModel、pydantic.dataclasses.dataclass を CLI アプリケーションとして実行できます。
paydantic.dataclasses を使用してデータモデルを定義し、CliApp.run メソッドを使用して CLI アプリケーションを実行します。このときデータモデルに cli_cmd メソッドを定義することで、コマンドライン引数を受け取り、処理を実行できます。
import sysfrom pydantic.dataclasses import dataclassfrom pydantic_settings import CliApp
@dataclassclass Settings:    name: str    age: int
    def cli_cmd(self) -> None:        print(f"Hello {self.name} you are {self.age} years old")
if __name__ == "__main__":    CliApp.run(Settings, sys.argv[1:])$ uv run example.py --name foo.bar --age 20Hello foo.bar you are 20 years old引数をリストとして解析する際、以下の3つのスタイルを任意に組み合わせることができます。
- JSONスタイル 
--field='[1,2]' - Argparseスタイル 
--field 1 --field 2 - Lazyスタイル 
--field=1,2 
from pydantic_settings import CliApp, BaseSettings
class Settings(BaseSettings):    my_list: list[str]
    def cli_cmd(self) -> None:        print(f"List: {self.my_list}")
if __name__ == "__main__":    CliApp.run(Settings)$ uv run list.py --my_list a --my_list bList: ['a', 'b']$ uv run list.py --my_list [a,b]List: ['a', 'b']$ uv run list.py --my_list a,bList: ['a', 'b']引数を辞書型として解析する際、以下の2つのスタイルを任意に組み合わせることができます。
- JSONスタイル 
--field='{"k1": 1, "k2": 2}' - 環境変数スタイル 
--field k1=1 --field k2=2 
from pydantic_settings import CliApp, BaseSettings
class Settings(BaseSettings):    my_dict: dict[str, int]
    def cli_cmd(self) -> None:        print(f"Dict: {self.my_dict}")
if __name__ == "__main__":    CliApp.run(Settings)$ uv run dictionary.py --my_dict '{"a": 1, "b": 2}'Dict: {'a': 1, 'b': 2}
$ uv run dictionary.py --my_dict a=1 --my_dict b=2Dict: {'a': 1, 'b': 2}
$ uv run dictionary.py --my_dict a=1 --my_dict '{"b": 2}'Dict: {'a': 1, 'b': 2}Literal と Enum は、特定の値のみを受け入れる型を定義するために使用されます。
from enum import IntEnumfrom typing import Literal
from pydantic_settings import CliApp, BaseSettings
class Fruit(IntEnum):    pear = 0    kiwi = 1    lime = 2
class Settings(BaseSettings):    fruit: Fruit    pet: Literal["dog", "cat", "bird"]
    def cli_cmd(self) -> None:        print(f"Fruit: {self.fruit.name}")        print(f"Pet: {self.pet}")
CliApp.run(Settings)$ uv run literal_enum.py --fruit kiwi --pet dogFruit: kiwiPet: dogたとえば、--fruit に apple を指定すると以下のエラーが発生します
$ uv run literal_enum.py --fruit apple --pet dogTraceback (most recent call last):  File "/Users/<user-name>/blog-code/pydantic-cli/literal_enum.py", line 22, in <module>    CliApp.run(Settings)  File "/Users/<user-name>/blog-code/pydantic-cli/.venv/lib/python3.12/site-packages/pydantic_settings/main.py", line 514, in run    return CliApp._run_cli_cmd(model_cls(**model_init_data), cli_cmd_method_name, is_required=False)                               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^  File "/Users/<user-name>/blog-code/pydantic-cli/.venv/lib/python3.12/site-packages/pydantic_settings/main.py", line 167, in __init__    super().__init__(  File "/Users/<user-name>/blog-code/pydantic-cli/.venv/lib/python3.12/site-packages/pydantic/main.py", line 212, in __init__    validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^pydantic_core._pydantic_core.ValidationError: 1 validation error for Settingsfruit  Input should be 0, 1 or 2 [type=enum, input_value='apple', input_type=str]    For further information visit https://errors.pydantic.dev/2.9/v/enumpydantic にはフィールドにエイリアスを設定するための AliasChoices、Field クラスが用意されています。エイリアスは、フィールドに別名を付けることができます。
from pydantic import AliasChoices, AliasPath, Fieldfrom pydantic_settings import CliApp, BaseSettings
class User(BaseSettings):    first_name: str = Field(        validation_alias=AliasChoices("f", "fname", AliasPath("name", 0))    )    last_name: str = Field(        validation_alias=AliasChoices("l", "lname", AliasPath("name", 1))    )
    def cli_cmd(self) -> None:        print(f"First name: {self.first_name}")        print(f"Last name: {self.last_name}")
if __name__ == "__main__":    CliApp.run(User)$ uv run alias.py --fname a --lname bFirst name: aLast name: b
$ uv run alias.py -f a -l bFirst name: aLast name: b
$ uv run alias.py --name a,bFirst name: aLast name: bPydantic Settings は CliSubCommand と CliPositionalArgument という型があります。これらを使用することでサブコマンドと位置引数を定義できます。
注意として、必須フィールド(デフォルト値を持たないフィールド)にのみ適用でき、サブコマンドは pydantic BaseModel または pydantic.dataclasses データクラスから派生した有効な型でなけば使用できません。
from pydantic.dataclasses import dataclass
from pydantic_settings import BaseSettings, CliPositionalArg, CliSubCommand, CliApp
@dataclassclass Init:    directory: CliPositionalArg[str]
    def cli_cmd(self) -> None:        print(f'git init "{self.directory}"')        self.directory = "ran the git init cli cmd"
@dataclassclass Clone:    repository: CliPositionalArg[str]    directory: CliPositionalArg[str]
    def cli_cmd(self) -> None:        print(f'git clone from "{self.repository}" into "{self.directory}"')        self.directory = "ran the clone cli cmd"
class Git(BaseSettings):    clone: CliSubCommand[Clone]    init: CliSubCommand[Init]
    def cli_cmd(self) -> None:        CliApp.run_subcommand(self)
if __name__ == "__main__":    CliApp.run(Git)CLI で Boolean のフラグを使用する場合、明示的に指定する方法 --flag=True と、暗黙的に指定する方法 --flag の2つあります。
CLI Boolean Flags に載っているサンプルコードを少し変更して、CliApp.run で実行できるようにします。
from pydantic_settings import (    CliApp,    BaseSettings,    CliExplicitFlag,    CliImplicitFlag,    CliSubCommand,)
class ExplicitSettings(BaseSettings):    """Boolean fields are explicit by default."""
    explicit_req: bool    """    --explicit_req bool   (required)    """
    # Booleans are explicit by default, so must override implicit flags with annotation    implicit_req: CliImplicitFlag[bool]    """    --implicit_req, --no-implicit_req (required)    """
    explicit_opt: bool = False    """    --explicit_opt bool   (default: False)    """
    implicit_opt: CliImplicitFlag[bool] = False    """    --implicit_opt, --no-implicit_opt (default: False)    """
    def cli_cmd(self) -> None:        print(f"explicit_req: {self.explicit_req}")        print(f"implicit_req: {self.implicit_req}")        print(f"explicit_opt: {self.explicit_opt}")        print(f"implicit_opt: {self.implicit_opt}")
class ImplicitSettings(BaseSettings, cli_implicit_flags=True):    """With cli_implicit_flags=True, boolean fields are implicit by default."""
    # Booleans are implicit by default, so must override explicit flags with annotation    explicit_req: CliExplicitFlag[bool]    """    --explicit_req bool   (required)    """
    implicit_req: bool    """    --implicit_req, --no-implicit_req (required)    """
    explicit_opt: CliExplicitFlag[bool] = False    """    --explicit_opt bool   (default: False)    """
    implicit_opt: bool = False    """    --implicit_opt, --no-implicit_opt (default: False)    """
    def cli_cmd(self) -> None:        print(f"explicit_req: {self.explicit_req}")        print(f"implicit_req: {self.implicit_req}")        print(f"explicit_opt: {self.explicit_opt}")        print(f"implicit_opt: {self.implicit_opt}")
class Settings(BaseSettings):    explicit: CliSubCommand[ExplicitSettings]    implicit: CliSubCommand[ImplicitSettings]
    def cli_cmd(self) -> None:        CliApp.run_subcommand(self)
if __name__ == "__main__":    CliApp.run(Settings)実際にコードを実行してみて、引数を1つずつ減らしてみます。
$ uv run boolean.py explicit --explicit_req=true --implicit_req --explicit_opt=True --implicit_optexplicit_req: Trueimplicit_req: Trueexplicit_opt: Trueimplicit_opt: True
$ uv run boolean.py explicit --explicit_req=true --implicit_req --explicit_opt=Trueexplicit_req: Trueimplicit_req: Trueexplicit_opt: Trueimplicit_opt: False
$ uv run boolean.py explicit --explicit_req=true --implicit_reqexplicit_req: Trueimplicit_req: Trueexplicit_opt: Falseimplicit_opt: False
# 必須のフィールドを取り除くとエラーが発生する$ uv run boolean.py explicit --explicit_req=trueTraceback (most recent call last):  File "/Users/<user-name>/blog-code/pydantic-cli/boolean.py", line 81, in <module>    CliApp.run(Settings)  File "/Users/<user-name>/blog-code/pydantic-cli/.venv/lib/python3.12/site-packages/pydantic_settings/main.py", line 514, in run    return CliApp._run_cli_cmd(model_cls(**model_init_data), cli_cmd_method_name, is_required=False)                               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^  File "/Users/<user-name>/blog-code/pydantic-cli/.venv/lib/python3.12/site-packages/pydantic_settings/main.py", line 167, in __init__    super().__init__(  File "/Users/<user-name>/blog-code/pydantic-cli/.venv/lib/python3.12/site-packages/pydantic/main.py", line 212, in __init__    validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^pydantic_core._pydantic_core.ValidationError: 1 validation error for Settingsexplicit.implicit_req  Field required [type=missing, input_value={'explicit_req': 'true'}, input_type=dict]    For further information visit https://errors.pydantic.dev/2.9/v/missing面白そうだったため、とりあえず手を動かして触ってみました。環境変数のバリデーションと CLI のオプションを同時にバリデーションしながら pydantic のデータモデルを使用できるのは便利だと感じました。 (設定ミスかもしれませんが)挙動がおかしいところがあったためユースケースによっては注意が必要です。
https://github.com/fastapi/typer というライブラリもあるため、組み合わせればより柔軟な CLI アプリケーションを作成できるかもしれません。