Skip to content

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

環境

バージョン
MacVentura 13.2.1
uv0.5.1
Python3.12.2

環境構築

環境構築には uv を使用し、pydanticpydantic-settings をインストールします。

Terminal window
uv init pydantic-cli
uv add pydantic pydantic-settings

pydantic-settings は、環境変数やシークレットファイルから設定を読み込むためのライブラリです。Pydantic と組み合わせて使用することで、機能を拡張できます。

Pydantic CLI アプリケーションの作成

Pydantic を使った CLI アプリケーションのユースケースは主に以下の2つです。

  1. Pydantic モデルのフィールドをオーバーライドするため
  2. Pydantic モデルを使ってCLIを定義するため

引用:https://docs.pydantic.dev/latest/concepts/pydantic_settings/#command-line-support

今回は2番目にある CLI を定義するために、Pydantic モデルを使用します。

サンプルコード

CliApp クラスは CliApp.runCliApp.run_subcommand の2つのユーティリティメソッドを提供し、BaseSettingsBaseModelpydantic.dataclasses.dataclass を CLI アプリケーションとして実行できます。

paydantic.dataclasses を使用してデータモデルを定義し、CliApp.run メソッドを使用して CLI アプリケーションを実行します。このときデータモデルに cli_cmd メソッドを定義することで、コマンドライン引数を受け取り、処理を実行できます。

example.py
import sys
from pydantic.dataclasses import dataclass
from pydantic_settings import CliApp
@dataclass
class 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:])
Terminal window
$ uv run example.py --name foo.bar --age 20
Hello foo.bar you are 20 years old

Lists

引数をリストとして解析する際、以下の3つのスタイルを任意に組み合わせることができます。

  • JSONスタイル --field='[1,2]'
  • Argparseスタイル --field 1 --field 2
  • Lazyスタイル --field=1,2
list.py
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)
Terminal window
$ uv run list.py --my_list a --my_list b
List: ['a', 'b']
$ uv run list.py --my_list [a,b]
List: ['a', 'b']
$ uv run list.py --my_list a,b
List: ['a', 'b']

Dictionaries

引数を辞書型として解析する際、以下の2つのスタイルを任意に組み合わせることができます。

  • JSONスタイル --field='{"k1": 1, "k2": 2}'
  • 環境変数スタイル --field k1=1 --field k2=2
dictionary.py
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)
Terminal window
$ 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=2
Dict: {'a': 1, 'b': 2}
$ uv run dictionary.py --my_dict a=1 --my_dict '{"b": 2}'
Dict: {'a': 1, 'b': 2}

Literals and Enums

LiteralEnum は、特定の値のみを受け入れる型を定義するために使用されます。

literal_enum.py
from enum import IntEnum
from 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)
Terminal window
$ uv run literal_enum.py --fruit kiwi --pet dog
Fruit: kiwi
Pet: dog

たとえば、--fruitapple を指定すると以下のエラーが発生します

Terminal window
$ uv run literal_enum.py --fruit apple --pet dog
Traceback (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 Settings
fruit
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 にはフィールドにエイリアスを設定するための AliasChoicesField クラスが用意されています。エイリアスは、フィールドに別名を付けることができます。

alias.py
from pydantic import AliasChoices, AliasPath, Field
from 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)
Terminal window
$ uv run alias.py --fname a --lname b
First name: a
Last name: b
$ uv run alias.py -f a -l b
First name: a
Last name: b
$ uv run alias.py --name a,b
First name: a
Last name: b

サブコマンドと位置引数

Pydantic Settings は CliSubCommandCliPositionalArgument という型があります。これらを使用することでサブコマンドと位置引数を定義できます。

注意として、必須フィールド(デフォルト値を持たないフィールド)にのみ適用でき、サブコマンドは pydantic BaseModel または pydantic.dataclasses データクラスから派生した有効な型でなけば使用できません。

subcommand.py
from pydantic.dataclasses import dataclass
from pydantic_settings import BaseSettings, CliPositionalArg, CliSubCommand, CliApp
@dataclass
class Init:
directory: CliPositionalArg[str]
def cli_cmd(self) -> None:
print(f'git init "{self.directory}"')
self.directory = "ran the git init cli cmd"
@dataclass
class 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 で実行できるようにします。

boolean.py
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つずつ減らしてみます。

Terminal window
$ uv run boolean.py explicit --explicit_req=true --implicit_req --explicit_opt=True --implicit_opt
explicit_req: True
implicit_req: True
explicit_opt: True
implicit_opt: True
$ uv run boolean.py explicit --explicit_req=true --implicit_req --explicit_opt=True
explicit_req: True
implicit_req: True
explicit_opt: True
implicit_opt: False
$ uv run boolean.py explicit --explicit_req=true --implicit_req
explicit_req: True
implicit_req: True
explicit_opt: False
implicit_opt: False
# 必須のフィールドを取り除くとエラーが発生する
$ uv run boolean.py explicit --explicit_req=true
Traceback (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 Settings
explicit.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 アプリケーションを作成できるかもしれません。