pydantic-settingsで環境変数とコマンドライン引数を同時管理



# 概要

[pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/)を使って、環境変数とコマンドライン引数の両方を一元管理する方法を紹介します。

# 想定する読者
- PythonのCUIをよくつくる人
- 環境変数とCLI引数をどちらも使えるようにしたい人

# 背景
Pythonでライブラリやアプリケーションを開発していると、環境変数とコマンドライン引数の両方から設定値を受け取りたいというケースがよくあります。

たとえば、Streamlitでは環境変数とCLI引数の両方に対応していて、両者が重複する場合はCLI引数を優先するという設計になっています。こうした仕組みは便利ですが、これを自分で実装しようとすると、一般的には以下のような処理が必要になります。
- .envファイルやos.environを使って環境変数を読み込む
- argparseなどのライブラリを使ってCLI引数を処理する
- 両方の値をマージして、優先順位を制御するロジックを実装する

しかし、この方法では設定項目を環境変数用とCLI引数用に2回書く必要があり、冗長になりがちです。

そこで登場するのが、pydantic-settingsです。
このライブラリを使うと、ひとつのクラス定義で環境変数とCLI引数の両方を取り込むことができ、さらに優先順位の制御も簡単に行えます。
これにより、設定管理をよりシンプルかつ統一的に行えるようになります。

pydantic-settingsでCLI引数を取得する方法は公式ドキュメントに載っていますが、CLI引数についてはあまり詳しくはないです。また、Qiitaなどで実際に使ってみた事例もさほど見かけなかったので、今回記事を書いてみました。

# 使い方

必要なパッケージをインストールします。

```
uv pip install pydantic-settings
```

スクリプトを書きます。

```Python:test.py
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
import sys


class AppSettings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        # 大文字小文字を区別しない
        case_sensitive=False,
        # コマンドライン引数も受け入れるようにする
        cli_parse_args=True,
    )

    host: str = Field(default="localhost", description="APIホスト名")
    port: int = Field(default=8000, description="APIポート番号")


if __name__ == "__main__":
    settings = AppSettings()

    print(f"Host: {settings.host}")
    print(f"Port: {settings.port}")
```

上記のコードで、CLI引数 > コンストラクタ引数 > 環境変数 > envファイル > 初期値の優先順位で値を取得できます（参考：[Field value priority](https://docs.pydantic.dev/latest/concepts/pydantic_settings/#field-value-priority)）。

```
% HOST=ENVHOST uv run test.py --port 0
Host: ENVHOST
Port: 0
```

環境変数やCLI引数の値を取得できていることがわかります。
環境変数名やCLI引数名は原則、クラス変数名と同じです。

# よく使う設定

## 変数のprefix
環境変数、CLI引数それぞれにprefixをつけることで、他のライブラリとの変数の干渉を回避できます。

```Python
class AppSettings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        case_sensitive=False,
        cli_parse_args=True,
        env_prefix="APP_", #環境変数のprefix
        cli_prefix="app"
    )

    host: str = Field(default="localhost", description="APIホスト名")
    port: int = Field(default=8000, description="APIポート番号")
```

```
% APP_HOST=ENVHOST uv run test.py --app.port 0
Host: ENVHOST
Port: 0
```

環境変数とCLI引数でprefixのルールが異なるので注意してください。
環境変数（env_prefix）ではセパレータを含めprefixの文字列をすべて書きます。
CLI引数（cli_prefix）ではセパレータを除いた部分のみを書きます。セパレータはドットに固定されているようです。環境変数と統一したいのでアンダースコアなどに変えられたら嬉しいのですが、そういうオプションは軽く探した感じなさそうでした（調べきれていないだけで方法がありそうな気がしますけれども）

## 未知の引数を無視
一つのプログラムで複数のBaseSettingsを使用する場合などは`extra="ignore"`、`cli_ignore_unknown_args=True`を指定して未知の変数を無視するとよいです。

```Python
class AppSettings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        case_sensitive=False,
        cli_parse_args=True,
        extra="ignore", # 未知の環境変数を無視
        cli_ignore_unknown_args=True # 未知のCLI引数を無視
    )

    host: str = Field(default="localhost", description="APIホスト名")
    port: int = Field(default=8000, description="APIポート番号")
```

## 短縮引数

argparseに使い心地を近づけるための設定です。
validation_aliasにAliasChoicesを与えます。

```Python
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict


class AppSettings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        # 大文字小文字を区別しない
        case_sensitive=False,
        # コマンドライン引数も受け入れるようにする
        cli_parse_args=True,
    )

    port: int = Field(
        default=8000,
        description="APIポート番号",
        validation_alias=AliasChoices("p", "port"),
    )


if __name__ == "__main__":
    settings = AppSettings(p=5555)

    print(f"Port: {settings.port}")
```

```
% uv run python test.py -p 0
Port: 0
```

なおAliasChoicesを使わずに単にalias="p"とすると`-p`は有効になりますが、`--port`が無効になります。

## フラグ引数
同じくargparseに使い心地を近づけるための設定です。
cli_implicit_flags=Trueを設定します。

```Python
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict


class AppSettings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        # 大文字小文字を区別しない
        case_sensitive=False,
        # コマンドライン引数も受け入れるようにする
        cli_parse_args=True,
        cli_implicit_flags=True,
    )

    debug: bool = Field(
        default=False,
        description="デバッグモード",
    )


if __name__ == "__main__":
    settings = AppSettings()

    print(f"Debug: {settings.debug}")

```
```
% uv run python test.py --debug
Debug: True
```


## 優先順位の入れ替え

個人的にはコンストラクタ引数はCLI引数よりも、envファイルは環境変数よりも優先されてほしいことがあります。
その時はクラスメソッドsettings_customise_sourcesをオーバーライドして、優先したい順序でsetting sourceをreturnするようにします。

以下ではinit_settingsをCliSettingsSourceより先に、dotenv_settingsをenv_settingsより先にreturnしています。

```Python
    @classmethod
    def settings_customise_sources(
        cls,
        settings_cls: Type[BaseSettings],
        init_settings: PydanticBaseSettingsSource,
        env_settings: PydanticBaseSettingsSource,
        dotenv_settings: PydanticBaseSettingsSource,
        file_secret_settings: PydanticBaseSettingsSource,
    ) -> Tuple[PydanticBaseSettingsSource, ...]:
        return init_settings, CliSettingsSource(settings_cls, cli_parse_args=True),dotenv_settings, env_settings, file_secret_settings
```

使用例です。

```Python
from typing import Tuple, Type

from pydantic import Field
from pydantic_settings import (
    BaseSettings,
    CliSettingsSource,
    PydanticBaseSettingsSource,
    SettingsConfigDict,
)


class AppSettings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file="tmpenvfile",
        env_file_encoding="utf-8",
        # 大文字小文字を区別しない
        case_sensitive=False,
        # コマンドライン引数も受け入れるようにする
        cli_parse_args=True,
    )

    host: str = Field(default="localhost", description="APIホスト名")
    port: int = Field(default=8000, description="APIポート番号")

    @classmethod
    def settings_customise_sources(
        cls,
        settings_cls: Type[BaseSettings],
        init_settings: PydanticBaseSettingsSource,
        env_settings: PydanticBaseSettingsSource,
        dotenv_settings: PydanticBaseSettingsSource,
        file_secret_settings: PydanticBaseSettingsSource,
    ) -> Tuple[PydanticBaseSettingsSource, ...]:
        return (
            init_settings,
            CliSettingsSource(settings_cls, cli_parse_args=True),
            dotenv_settings,
            env_settings,
            file_secret_settings,
        )


if __name__ == "__main__":
    with open("tmpenvfile", "w") as f:
        f.write("HOST=DOTENVHOST")
    settings = AppSettings(port=5555)

    print(f"Host: {settings.host}")
    print(f"Port: {settings.port}")
```

```
% HOST=ENV uv run python test.py --port 0
Host: DOTENVHOST # 環境変数ファイルが優先 
Port: 5555 # コンストラクタ引数が優先
```

## その他
- [pyproject.tomlを読めます](https://docs.pydantic.dev/latest/concepts/pydantic_settings/#pyprojecttoml)。uvなど他の機能のためにpyproject.tomlを使用していれば便利かもしれません。

# おわりに
pydantic-settingsの紹介を兼ねて、個人的によくつかう設定をまとめました。
より詳細な情報はドキュメントにありますのでご参照ください。

# 参考
- https://docs.pydantic.dev/latest/concepts/pydantic_settings/
- https://docs.pydantic.dev/latest/api/pydantic_settings/