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-settings
pydantic-settings は、環境変数やシークレットファイルから設定を読み込むためのライブラリです。Pydantic と組み合わせて使用することで、機能を拡張できます。
Pydantic CLI アプリケーションの作成
Pydantic を使った CLI アプリケーションのユースケースは主に以下の2つです。
- Pydantic モデルのフィールドをオーバーライドするため
- Pydantic モデルを使ってCLIを定義するため
引用:https://docs.pydantic.dev/latest/concepts/pydantic_settings/#command-line-support
今回は2番目にある CLI を定義するために、Pydantic モデルを使用します。
サンプルコード
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
Lists
引数をリストとして解析する際、以下の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']
Dictionaries
引数を辞書型として解析する際、以下の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}
Literals and Enums
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/enum
Alias
pydantic にはフィールドにエイリアスを設定するための 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: b
サブコマンドと位置引数
Pydantic 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
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 アプリケーションを作成できるかもしれません。