CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: uv でパッケージのインストールにクールダウン期間を設ける

つい先日、著名な LLM API のプロキシサーバである LiteLLM 1 がサイバー攻撃による侵害を受けた。 結果として、攻撃者が不正なコードを挿入したバージョンのパッケージが PyPI 2 にアップロードされた。

github.com

現在は既に当該のバージョンは PyPI から削除されている。 しかし、公開されていたタイミングでユーザがインストールした場合には、不正なコードの実行につながる恐れがあった。 不正なコードが実行された場合には、端末がマルウェアに感染してユーザのクレデンシャルを含む情報が外部に送信 (窃取) されるなどの被害が生じた。

この事例から、Python を利用している環境において、PyPI を経由したサプライチェーン攻撃の被害を受けるリスクが現実となった。 したがって、今後は PyPI のユーザ側でもリスクを低減するための行動が重要になってくると考えられる。

今回は、リスクを低減するための対策のひとつを uv 3 を用いて紹介する。 考え方はシンプルで、リリースされたばかりのバージョンのパッケージをインストールしない、というもの。 新しいバージョンのパッケージは、コミュニティによる一定の検証期間を待った上で利用する。 この考え方は Dependency cooldowns という名前で呼ばれることがあるようだ。

blog.yossarian.net

使った環境は次のとおり。

$ sw_vers
ProductName:        macOS
ProductVersion:     26.4
BuildVersion:       25E246
$ uname -srm
Darwin 25.4.0 arm64
$ uv --version    
uv 0.11.1 (Homebrew 2026-03-24 aarch64-apple-darwin)

もくじ

下準備

あらかじめパッケージマネージャとして uv をインストールしておく。

$ brew install uv

前提

インストールするパッケージとして FastAPI を例にしてみよう。 FastAPI は比較的リリースサイクルが短いので例にしやすい。

pypi.org

この記事を書いている時点 (2026-03-26) で、FastAPI の直近のバージョンは次の日付でリリースされている。

  • 0.135.2: 2026-03-23
  • 0.135.1: 2026-03-02
  • 0.135.0: 2026-03-01

ここで、仮に 1 週間のクールダウン期間を設ける場合を考える。 つまり、今回であれば 1 週間以上前にリリースされたバージョン 0.135.1 をインストールしたい。

コマンドラインのオプションとして利用する

まずは基本となる uv コマンドのオプションとして使う方法について。

適当な作業用のディレクトリを用意して、仮想環境を初期化する。

$ uv venv --clear
Using CPython 3.12.13 interpreter at: /opt/homebrew/opt/python@3.12/bin/python3.12
Creating virtual environment at: .venv
Activate with: source .venv/bin/activate

この時点では仮想環境に何のパッケージも入っていない。

$ uv pip list

この環境に 1 週間のクールダウンを設けて FastAPI をインストールしてみよう。 そのためには、オプションとして --exclude-newer="1 week" を指定すれば良い。

$ uv pip install --exclude-newer="1 week" fastapi
Resolved 10 packages in 309ms
Prepared 1 package in 48ms
Installed 10 packages in 10ms
 + annotated-doc==0.0.4
 + annotated-types==0.7.0
 + anyio==4.12.1
 + fastapi==0.135.1
 + idna==3.11
 + pydantic==2.12.5
 + pydantic-core==2.41.5
 + starlette==0.52.1
 + typing-extensions==4.15.0
 + typing-inspection==0.4.2

すると、ちゃんと FastAPI の 0.135.1 がインストールされていることが分かる。

また、FastAPI の依存パッケージとしてインストールされた anyio のバージョンも見逃せない。 インストールされたバージョンは 4.12.1 になっている。

pypi.org

この記事を書いている時点 (2026-03-26) で、anyio の直近のバージョンは次の日付でリリースされている。

  • 4.13.0: 2026-03-24
  • 4.12.1: 2026-01-06
  • 4.12.0: 2025-11-29

つまり、依存パッケージについてもクールダウンが有効になっている。 このように Dependency cooldowns の考え方は、依存関係のツリー全体に適用される必要がある。

プロジェクトの設定ファイルとして利用する

さて、正直なところ先ほど紹介したコマンドラインのオプションはさほど実用的ではないはず。 というのも、コマンドを実行するときにオプションをつけ忘れる恐れがある。 もし忘れなかったとしても、毎回オプションを意識してつけるのは面倒すぎる。 したがって、設定ファイルにして uv に自動で読んでもらう方がオペレーションとして現実的だろう。

そこで、まずは特定のプロジェクトでポリシーとして Dependency cooldowns を導入する場合を考える。 例えば、チーム開発で複数のメンバーがリポジトリを操作するような場面が想定できる。

このパターンでは、プロジェクトのリポジトリに pyproject.tomluv.toml を置いて対応する。 設定ファイルを置いておくだけなので、普段のオペレーションには影響が小さい。 あらかじめ uv を使ったワークフローを整備しておけば、メンバーによる結果のバラつきも生じにくい。

uv.toml を使う場合

まずは uv.toml の設定を置く場合から。

これは単純に exclude-newer の 1 行が入った uv.toml を置くだけで良い。

$ cat << EOF > uv.toml
exclude-newer = "1 week"
EOF

一旦、先ほど作った仮想環境をリセットしておこう。

$ uv venv --clear           
Using CPython 3.12.13 interpreter at: /opt/homebrew/opt/python@3.12/bin/python3.12
Creating virtual environment at: .venv
Activate with: source .venv/bin/activate

そして、改めてオプションを特につけずに FastAPI をインストールする。

$ uv pip install fastapi
Resolved 10 packages in 6ms
Installed 10 packages in 6ms
 + annotated-doc==0.0.4
 + annotated-types==0.7.0
 + anyio==4.12.1
 + fastapi==0.135.1
 + idna==3.11
 + pydantic==2.12.5
 + pydantic-core==2.41.5
 + starlette==0.52.1
 + typing-extensions==4.15.0
 + typing-inspection==0.4.2

すると、インストールされたバージョンから設定が有効に働いていることが確認できる。

$ uv pip list | grep fastapi
fastapi           0.135.1

確認が終わったら次の実験に向けて設定ファイルを削除しておく。

$ rm uv.toml

pyproject.toml を使う場合

最近の Python を使ったプロジェクトであれば pyproject.toml を用意している場合が多いはず。 その場合は、次のようにして既存のファイルの中に [tool.uv] セクションを追加しても良い。

[project]
name = "example-project"
version = "0.0.1"
requires-python = ">=3.12"

[tool.uv]
exclude-newer = "1 week"

先ほど作った仮想環境をリセットしておこう。

$ uv venv --clear
Using CPython 3.12.13 interpreter at: /opt/homebrew/opt/python@3.12/bin/python3.12
Creating virtual environment at: .venv
Activate with: source .venv/bin/activate

そして、改めて特にオプションを指定せずにプロジェクトの依存パッケージとして FastAPI を追加する。

$ uv add fastapi 
Resolved 11 packages in 10ms
Installed 10 packages in 7ms
 + annotated-doc==0.0.4
 + annotated-types==0.7.0
 + anyio==4.12.1
 + fastapi==0.135.1
 + idna==3.11
 + pydantic==2.12.5
 + pydantic-core==2.41.5
 + starlette==0.52.1
 + typing-extensions==4.15.0
 + typing-inspection==0.4.2

すると、インストールされたバージョンから設定が有効に働いていることが確認できる。

この設定は、依存パッケージのバージョンロックを更新する際にも、もちろん反映される。

$ uv lock --upgrade --dry-run
Resolved 11 packages in 126ms
Lockfile changes detected

通常のポリシーとは外れた条件を特定のパッケージに入れたいときは exclude-newer-package を使う。 例えば、緊急での脆弱性対応リリースなどが想定される。

$ echo 'exclude-newer-package = { fastapi = "0 day" }' >> pyproject.toml
$ tail -n 3 pyproject.toml
[tool.uv]
exclude-newer = "1 week"
exclude-newer-package = { fastapi = "0 day" }

こうすればピンポイントでバージョンを上げられる。

$ uv lock --upgrade --dry-run
Resolved 11 packages in 131ms
Update fastapi v0.135.1 -> v0.135.2

確認が終わったら作成したファイルを削除しておく。

$ rm pyproject.toml
$ rm uv.lock

ユーザの設定ファイルとして利用する

先ほどのやり方は、プロジェクトに紐づいていた。 一方で、普段の何気ない操作でも常に有効にしたいニーズはあるはず。 そんなときは、ユーザの設定ファイルにしておくと良い。

やることは単純で ~/.config/uv/uv.toml に設定を置くだけ。

$ mkdir -p ~/.config/uv
$ cat << EOF >> ~/.config/uv/uv.toml
exclude-newer = "1 week"
EOF

仮想環境をリセットしておく。

$ uv venv --clear            
Using CPython 3.12.13 interpreter at: /opt/homebrew/opt/python@3.12/bin/python3.12
Creating virtual environment at: .venv
Activate with: source .venv/bin/activate

この状態で仮想環境に FastAPI をインストールする。

$ uv pip install fastapi    
Resolved 10 packages in 5ms
Installed 10 packages in 6ms
 + annotated-doc==0.0.4
 + annotated-types==0.7.0
 + anyio==4.12.1
 + fastapi==0.135.1
 + idna==3.11
 + pydantic==2.12.5
 + pydantic-core==2.41.5
 + starlette==0.52.1
 + typing-extensions==4.15.0
 + typing-inspection==0.4.2

ちゃんとバージョン 0.135.1 がインストールされている。

まとめ

今回は PyPI を経由したサプライチェーン攻撃のリスクを低減する手法のひとつとして Dependency cooldowns を扱った。 この手法は導入に必要なコストが小さく、リスクを低減する効果もそれなりに大きい。 そうした背景もあって、最近のパッケージマネージャでは言語を問わず導入が進んでいる。 万全ではないことを理解した上で、ワークフローに組み込んで利用する余地があるだろう。

参考

docs.astral.sh


textlint でシンプルなプリセットを自作する

textlint 1 は自然言語向けの Linter のひとつ。 前回は勉強のために textlint のシンプルなルールを自作した。

blog.amedama.jp

今回は、それに引き続いてシンプルなプリセットを自作してみる。 プリセットは、複数のルールを束ねてチェックする内容を表現するときに実装する。 ただし、簡単のために含めるルールはひとつだけにする。

使った環境は次のとおり。

$ sw_vers
ProductName:        macOS
ProductVersion:     26.3.1
BuildVersion:       25D2128
$ uname -srm      
Darwin 25.3.0 arm64
$ npm --version
11.11.0
$ node --version
v25.8.1

もくじ

下準備

下準備として Node.js をインストールしておく。

$ brew install node

パッケージを作成する

ルールと同様、プリセットの場合もパッケージ名は textlint-rule- のプリセットをつける。 今回は textlint-rule-preset-internet-rojinkai というパッケージ名にした。

$ mkdir -p textlint-rule-preset-internet-rojinkai
$ cd textlint-rule-preset-internet-rojinkai

パッケージのメタデータを記述した最低限の package.json を用意する。 今回はソースコードは src 以下のディレクトリに設置する。

{
  "name": "textlint-rule-preset-internet-rojinkai",
  "version": "0.0.1",
  "description": "hantoshi ROM re",
  "type": "module",
  "main": "src/index.js",
  "scripts": {
    "test": "textlint-scripts test"
  }
}

必要なパッケージを追加する。

$ npm install --save-dev textlint textlint-scripts textlint-tester

ルールを組み込む

続いて、ルールを配置するディレクトリを用意する。

$ mkdir -p src/rules

個別のルールを用意する。 今回は、前回作成したのと全く同じルールにしている。 内容は文章に「ぬるぽ」や「ヌルポ」があるとエラーになるもの。 これを src/rules/no-null-pointer-exception.js に設置する。

// ルールに対応する処理を記述した関数を export する
export default function (context, options = {}) {
    // 処理に必要なオブジェクトは関数の引数として得られる
    const { Syntax, getSource, RuleError, report, locator } = context;
    return {
        // AST のオブジェクト毎にチェックする処理が書ける
        // 文字列の中身を見るだけなら Syntax.Str で良い
        [Syntax.Str](node) {
            // ノードに含まれるテキストを得る
            const text = getSource(node);
            // 特定の文字列が含まれるかチェックする (見つかったら違反)
            const matches = text.matchAll(/ぬるぽ|ヌルポ/g);
            // 特定の文字列が見つかった場合のループ
            for (const match of matches) {
                // テキストの中で見つかった場所を求める
                const index = match.index ?? 0;
                const length = match[0].length;
                const matchRange = [index, index + length];
                // ルールに違反したことを RuleError で伝える
                const ruleError = new RuleError("ガッ!", {
                    padding: locator.range(matchRange),
                });
                // report() 関数を使って違反の詳細を報告する
                report(node, ruleError);
            }
        },
    };
}

続いて、上記を組み込んだエントリポイントとして src/index.js を作成する。 ここで rulesrulesConfig を含むオブジェクトを export する。 rules がルールで、rulesConfig がルールを動作させる設定になる。 rulesConfigtrue を指定するとデフォルトでルールが有効になる。

import noNullPointerException from "./rules/no-null-pointer-exception";

const preset = {
    rules: {
        "no-null-pointer-exception": noNullPointerException,
    },
    rulesConfig: {
        "no-null-pointer-exception": true,
    }
};

export default preset;

ここではパッケージ内のルールを読み込んで使っている。 もちろん外部のパッケージからインポートしたルールを組み込んでも良い。

テストを書く

続いて、ルールをテストするディレクトリを用意する。

$ mkdir -p test/rules

先ほどプリセットに組み込んだルールをテストするコードを用意する。 以下を test/rules/no-null-pointer-exception.test.js として作成する。

import TextLintTester from "textlint-tester";

// テストする対象を import する
import rule from "../../src/rules/no-null-pointer-exception";

// TextLintTester を使ってテストが書ける
const tester = new TextLintTester();

tester.run("no-null-pointer-exception", rule, {
    // 違反していないパターン
    valid: [
        "こんにちは",
        "NullPointerException",
    ],
    // 違反しているパターン
    invalid: [
        {
            // 違反しているテキスト
            text: "ぬるぽ",
            // 違反したときのメッセージと場所
            errors: [
                {
                    message: "ガッ!",
                    range: [0, 3]
                }
            ]
        },
        {
            text: "ヌルポ",
            errors: [
                {
                    message: "ガッ!",
                    range: [0, 3],
                }
            ]
        }
    ]
});

npm run test コマンドを実行してテストを実行する。

$ npm run test

> textlint-rule-preset-internet-rojinkai@0.0.1 test
> textlint-scripts test



  no-null-pointer-exception
    ✔ ぬるぽ
    ✔ ヌルポ
    ✔ こんにちは
    ✔ NullPointerException


  4 passing (8ms)

テストがパスして、ちゃんとルールが動作していることが確認できた。

コマンドラインから動かしてみる

続いて、実際にコマンドラインからプリセットを使ってみる。

チェックする対象のファイルを用意する。

$ cat << EOF > /tmp/nullpo.md 
# 「NullPointerExceptionを「ぬるぽ」と呼ぶスレ

 ∧_∧
( ´∀`)< ぬるぽ
EOF

パッケージをリンクして使えるようにする。

$ npm link
$ npm link textlint-rule-preset-internet-rojinkai

textlint コマンドに --preset オプションを使って今回作成したプリセットを読み込む。 プリセット名を指定するとき textlint-rule- をつける必要はない。

$ ./node_modules/.bin/textlint --preset preset-internet-rojinkai -f pretty-error /tmp/nullpo.md
internet-rojinkai/no-null-pointer-exception: ガッ!
/tmp/nullpo.md:1:26
                                    v
    0. 
    1. # 「NullPointerExceptionを「ぬるぽ」と呼ぶスレ
    2. 
                                    ^

internet-rojinkai/no-null-pointer-exception: ガッ!
/tmp/nullpo.md:4:9
                     v
    3.  ∧_∧
    4. ( ´∀`)< ぬるぽ
    5. 
                     ^

✖ 2 problems (2 errors)

ちゃんとルールに基づいてエラーになっている。

まとめ

今回は勉強のために textlint のシンプルなプリセットを自作してみた。


textlint でシンプルなルールを自作する

textlint 1 は自然言語向けの Linter のひとつ。 textlint はデフォルトではチェックするルールが何も組み込まれていない。 そのため必要に応じてルールや、複数のルールを束ねたプリセットを自分でインストールして使用する。

textlint のルールやプリセットは npm のパッケージとして実装される。 多くのユースケースでは、公開されている既存のルールやプリセットを組み合わせることで実現できる。

一方で、それでは実現が難しいケースも存在する。 そこで、今回は勉強のためにシンプルなルールを自作してみた。 ルールは何かしら単一のチェックする内容を表現するときに実装する。

使った環境は次のとおり。

$ sw_vers
ProductName:        macOS
ProductVersion:     26.3.1
BuildVersion:       25D2128
$ uname -srm      
Darwin 25.3.0 arm64
$ npm --version
11.11.0
$ node --version
v25.8.1

もくじ

下準備

下準備として Node.js をインストールしておく。

$ brew install node

パッケージを作成する

ここからは textlint のルールを作成していく。 textlint のルールとなるパッケージは命名規則が決まっている。 具体的には、パッケージ名にプレフィックスとして textlint-rule- を使用する。 プレフィックス以降に、そのルールを表した名前を入れる。

今回は文章に「ぬるぽ」が含まれているとエラーになるルールを作ってみよう。 命名規則からパッケージ名は textlint-rule-no-null-pointer-exception にする。

パッケージの作業場所となるディレクトリを用意する。

$ mkdir -p textlint-rule-no-null-pointer-exception
$ cd textlint-rule-no-null-pointer-exception

npm のパッケージを作るときはメタデータを記述した package.json が必要になる。 そこで、ほとんど最低限の内容を記述した package.json を次のように用意した。 npm init コマンドなどでインタラクティブに作っても良い。

{
  "name": "textlint-rule-no-null-pointer-exception",
  "version": "0.0.1",
  "description": "ga!",
  "type": "module",
  "main": "index.js",
  "scripts": {
    "test": "textlint-scripts test"
  }
}

scripts 以下にある記述は、後ほどテストで使用する内容になっている。

ルールの開発に必要なパッケージをインストールする。 textlint-scriptstextlint-tester は主にテストで使用する。

$ npm install --save-dev textlint textlint-scripts textlint-tester

これで node_modules ディレクトリに上記のパッケージがインストールされる。

$ ls node_modules | grep textlint
@textlint
textlint
textlint-scripts
textlint-tester

また package.json に依存パッケージとして追加される。

$ cat package.json
{
  "name": "textlint-rule-no-null-pointer-exception",
  "version": "0.0.1",
  "description": "ga!",
  "type": "module",
  "main": "index.js",
  "scripts": {
    "test": "textlint-scripts test"
  },
  "devDependencies": {
    "textlint": "^15.5.2",
    "textlint-scripts": "^15.5.2",
    "textlint-tester": "^15.5.2"
  }
}

ルールを関数として実装する

package.jsonmain と対応するように index.js を作る。 このファイルに、次のようにルールの具体的な処理を記述する。

// ルールに対応する処理を記述した関数を export する
export default function (context, options = {}) {
    // 処理に必要なオブジェクトは関数の引数として得られる
    const { Syntax, getSource, RuleError, report, locator } = context;
    return {
        // AST のオブジェクト毎にチェックする処理が書ける
        // 文字列の中身を見るだけなら Syntax.Str で良い
        [Syntax.Str](node) {
            // ノードに含まれるテキストを得る
            const text = getSource(node);
            // 特定の文字列が含まれるかチェックする (見つかったら違反)
            const matches = text.matchAll(/ぬるぽ|ヌルポ/g);
            // 特定の文字列が見つかった場合のループ
            for (const match of matches) {
                // テキストの中で見つかった場所を求める
                const index = match.index ?? 0;
                const length = match[0].length;
                const matchRange = [index, index + length];
                // ルールに違反したことを RuleError で伝える
                const ruleError = new RuleError("ガッ!", {
                    padding: locator.range(matchRange),
                });
                // report() 関数を使って違反の詳細を報告する
                report(node, ruleError);
            }
        },
    };
}

ルールにテストを記述する

次に、ルールにテストを書いて動作を確かめる。

テスト用のディレクトリを用意する。

$ mkdir -p test

textlint のルールは textlint-tester というパッケージを使うとテストが書きやすい。 次のように test/index.test.js として用意する。

import TextLintTester from "textlint-tester";

// テストする対象を import する
import rule from "textlint-rule-no-null-pointer-exception";

// TextLintTester を使ってテストが書ける
const tester = new TextLintTester();

tester.run("no-null-pointer-exception", rule, {
    // 違反していないパターン
    valid: [
        "こんにちは",
        "NullPointerException",
    ],
    // 違反しているパターン
    invalid: [
        {
            // 違反しているテキスト
            text: "ぬるぽ",
            // 違反したときのメッセージと場所
            errors: [
                {
                    message: "ガッ!",
                    range: [0, 3]
                }
            ]
        },
        {
            text: "ヌルポ",
            errors: [
                {
                    message: "ガッ!",
                    range: [0, 3],
                }
            ]
        }
    ]
});

テストがルールを import できるように、パッケージをリンクする。

$ npm link
$ npm link textlint-rule-no-null-pointer-exception

これでパッケージが開発用にインストールされた状態になる。

$ ls node_modules | grep textlint                 
@textlint
textlint
textlint-rule-no-null-pointer-exception
textlint-scripts
textlint-tester

npm run test コマンドを実行してテストする。

$ npm run test

> textlint-rule-no-null-pointer-exception@0.0.1 test
> textlint-scripts test



  no-null-pointer-exception
    ✔ ぬるぽ
    ✔ ヌルポ
    ✔ こんにちは
    ✔ NullPointerException


  4 passing (8ms)

ちゃんとテストがパスした。 ルールが正常に動作しているようだ。

コマンドラインから動かしてみる

テストで動作が確認できたので、次はコマンドラインから動かしてみよう。

サンプルとなるテキストを適当に用意する。

$ cat << EOF > /tmp/nullpo.md 
# 「NullPointerExceptionを「ぬるぽ」と呼ぶスレ

 ∧_∧
( ´∀`)< ぬるぽ
EOF

インストールされた textlint コマンドに --rule オプションで自作したルール名を指定する。 このときルール名には textlint-rule のプレフィックスはつけない。

$ ./node_modules/.bin/textlint --rule no-null-pointer-exception -f pretty-error /tmp/nullpo.md
no-null-pointer-exception: ガッ!
/tmp/nullpo.md:1:26
                                    v
    0. 
    1. # 「NullPointerExceptionを「ぬるぽ」と呼ぶスレ
    2. 
                                    ^

no-null-pointer-exception: ガッ!
/tmp/nullpo.md:4:9
                     v
    3.  ∧_∧
    4. ( ´∀`)< ぬるぽ
    5. 
                     ^

✖ 2 problems (2 errors)

ちゃんとルールに違反した箇所がエラーになっている。

まとめ

今回は勉強のために textlint のシンプルなルールを自作してみた。 実際に自作したルールを使う際には、必要に応じて Git のリポジトリや npm のパッケージとして公開することになるだろう。


Python: Pydantic Settings でプログラムの設定を扱う

Python でプログラムを書いていると、設定に基づいて動作を変更したくなることがある。 そして、動作を変更する設定の値は、たとえば環境変数や設定ファイルから読み込みたくなる。 ただ、そういった処理を自分で書くと手間がかかるし不具合も作り込みやすい。 そのため、できるだけ専用のライブラリを使いたい。

今回は、そうした場面で役に立つライブラリとして Pydantic Settings を紹介する。 名前から分かるとおり Pydantic 系のライブラリのひとつ。

docs.pydantic.dev

使った環境は次のとおり。

$ sw_vers              
ProductName:        macOS
ProductVersion:     26.3
BuildVersion:       25D125
$ uv --version
uv 0.10.2 (Homebrew 2026-02-10)

もくじ

下準備

以降で紹介するサンプルコードは PEP 723 1 形式で依存ライブラリなどの情報を記載している。 uv 経由で実行するため、あらかじめ uv をインストールしておく。

$ brew install uv

基本的な使い方

早速だけど以下にサンプルコードを示す。 Pydantic Settings では、まず pydantic_settings.BaseSettings を継承したクラスを定義する。 そして、その継承したクラスに設定の項目などを追加していく。 サンプルコードでは Settings という名前でクラスを定義して foobar という項目を追加している。 実際に使用するときはクラスをインスタンス化してメンバ変数を参照する。

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "pydantic-settings>=2",
# ]
# ///

from pydantic import Field
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    """BaseSettings を検証したクラスが設定の入れ物になる"""

    # クラス変数として値を定義していく
    foo: str = Field(default="hoge")
    bar: int = Field(default=42)


def main():
    # 定義したクラスをインスタンス化して使用する
    settings: Settings = Settings()
    # 設定された値は model_dump() メソッドで確認できる
    print(settings.model_dump())
    # 個別にアクセスするときはメンバ変数として参照できる
    print(settings.foo)


if __name__ == "__main__":
    main()

上記に適当な名前をつけて uv で実行する。

$ uv run --script example.py
{'foo': 'hoge', 'bar': 42}
hoge

どうやら、ちゃんと設定された内容を参照できている。

設定の中で特定の項目の値を変更したいときは、同名のシェル変数や環境変数を定義することで上書きできる。 以下はたとえばシェル変数を使った場合の例になる。 FOO というシェル変数に fuga を指定することで foo の値を上書きしている。

$ FOO=fuga uv run --script example.py
{'foo': 'fuga', 'bar': 42}

同じように環境変数を使っても項目の値を上書きできる。 以下では環境変数の BAR を定義することで、bar の値を -1 に上書きしている。

$ export BAR=-1
$ uv run --script example.py
{'foo': 'hoge', 'bar': -1}

後続の実行に影響を与えないように定義した環境変数は消しておく。

$ unset BAR

バリデーション

入力された内容は自動的にバリデーションされる。

試しに先ほどの内容で foo のデフォルト値を消してみよう。 こうすると foo は入力が必須の項目になる。

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "pydantic-settings>=2",
# ]
# ///

from pydantic import Field
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    # デフォルト値がないものは必須になる
    foo: str = Field()
    bar: int = Field(default=42)


def main():
    settings: Settings = Settings()
    print(settings.model_dump())
    print(settings.foo)


if __name__ == "__main__":
    main()

この状態で値を与えずに実行すると、次のように例外になる。 例外がスローされるのはクラスをインスタンス化するタイミング。 そしてスローされる例外は Pydantic の ValidationError だ。

$ uv run --script example.py
Traceback (most recent call last):
  File "/Users/amedama/Documents/example.py", line 25, in <module>
    main()
    ~~~~^^
  File "/Users/amedama/Documents/example.py", line 19, in main
    settings: Settings = Settings()
                         ~~~~~~~~^^
  File "/Users/amedama/.cache/uv/environments-v2/example-099949597fdfdd24/lib/python3.14/site-packages/pydantic_settings/main.py", line 242, in __init__
    super().__init__(**__pydantic_self__.__class__._settings_build_values(sources, init_kwargs))
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/amedama/.cache/uv/environments-v2/example-099949597fdfdd24/lib/python3.14/site-packages/pydantic/main.py", line 250, in __init__
    validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings
foo
  Field required [type=missing, input_value={}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.12/v/missing

シェル変数などで値を与えることで例外にならず実行できるようになる。

$ FOO=fuga uv run --script example.py           
{'foo': 'fuga', 'bar': 42}
fuga

同様に int 型で定義されている bar に文字列を入れようとすると例外になる。

$ FOO=fuga BAR=hello uv run --script example.py
Traceback (most recent call last):
  File "/Users/amedama/Documents/example.py", line 25, in <module>
    main()
    ~~~~^^
  File "/Users/amedama/Documents/example.py", line 19, in main
    settings: Settings = Settings()
                         ~~~~~~~~^^
  File "/Users/amedama/.cache/uv/environments-v2/example-099949597fdfdd24/lib/python3.14/site-packages/pydantic_settings/main.py", line 242, in __init__
    super().__init__(**__pydantic_self__.__class__._settings_build_values(sources, init_kwargs))
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/amedama/.cache/uv/environments-v2/example-099949597fdfdd24/lib/python3.14/site-packages/pydantic/main.py", line 250, in __init__
    validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings
bar
  Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='hello', input_type=str]
    For further information visit https://errors.pydantic.dev/2.12/v/int_parsing

ここらへんは Pydantic を使ったバリデーションの流儀がそのまま通用する。

変数名にプレフィックスをつける

前述したとおり、デフォルトでは設定のメンバ変数と同名のシェル変数や環境変数を使って値を上書きする。 ただ、これだと変数名が散らばって分かりにくい。 そのため、通常はプログラムに固有のプレフィックスをつけたくなるだろう。 そのときは model_config という名前で SettingsConfigDict のクラス変数を BaseSettings を継承したクラスに用意する。 そして、引数の env_prefix で変数のプレフィックスを指定すれば良い。

以下のサンプルコードでは引数のプレフィックスとして example_prefix を指定している。

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "pydantic-settings>=2",
# ]
# ///

from pydantic import Field
from pydantic_settings import BaseSettings
from pydantic_settings import SettingsConfigDict


class Settings(BaseSettings):
    foo: str = Field(default="hoge")
    bar: int = Field(default=42)
    # 基本的な設定は model_config: SettingsConfigDict を使う
    model_config = SettingsConfigDict(
        # たとえば環境変数に特定のプレフィックスをつける
        env_prefix="example_prefix_",
    )


def main():
    settings: Settings = Settings()
    print(settings.model_dump())


if __name__ == "__main__":
    import os

    # プレフィックスのついた環境変数を使って設定を上書きする
    os.environ["EXAMPLE_PREFIX_FOO"] = "fuga"
    main()

なお、上記では os.environ 経由で環境変数を指定している。 このように、クラスをインスタンス化する前であれば値が反映される。

実行すると、ちゃんと値が上書きされていることが確認できる。

$ uv run --script example.py
{'foo': 'fuga', 'bar': 42}

このように Pydantic Settings では model_config: SettingsConfigDict を使うことで設定に関する設定を変更する。

設定をネストする

プログラムによっては設定の項目が多岐にわたる。 数多くの項目を BaseSettings の直下にフラットな構造で扱おうとすると、かなり煩雑になってくる。 そのときは設定のまとまりごとにクラスを分割してメンバ変数をネストさせると良い。

以下にサンプルコードを示す。 ネストさせる設定は pydantic.BaseModel を継承したクラスにする。 そのクラスを BaseSettings を継承したクラスのクラス変数として追加すれば良い。 サンプルコードでは Settingsnested という名前で追加している。

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "pydantic-settings>=2",
# ]
# ///

from pydantic import BaseModel
from pydantic import Field
from pydantic_settings import BaseSettings
from pydantic_settings import SettingsConfigDict


class NestedConfig(BaseModel):
    """設定にネストした階層構造を持たせるときは BaseModel を継承したクラスを定義する"""

    foo: str = Field(default="hoge")
    bar: int = Field(default=42)


class Settings(BaseSettings):
    # 設定をネストさせるときは BaseSettings のクラス変数として持たせる
    nested: NestedConfig
    model_config = SettingsConfigDict(
        env_prefix="example_prefix_",
        # 設定をネストさせるときは env_nested_delimiter を指定すると良い
        env_nested_delimiter="__",
    )


def main():
    settings: Settings = Settings()
    print(settings.model_dump())


if __name__ == "__main__":
    import os

    # env_nested_delimiter で指定したデリミタを使ってネストした構造を表現できるようになる
    os.environ["EXAMPLE_PREFIX_NESTED__FOO"] = "fuga"
    main()

設定を環境変数で変更する場合には model_configenv_nested_delimiter も同時に指定しておくと良い。 この指定があると、デリミタでネストした構造の項目を指定できるようになる。 たとえばサンプルコードでは EXAMPLE_PREFIX_NESTED__FOO という変数名でネストしたメンバ変数の foo の値を変更している。 ちなみにデリミタとしてアンダースコアひとつを指定すると、アンダースコアを含む変数名が使えなくなるので注意すること。

実行すると、ちゃんとネストした foo が上書きされていることが分かる。

$ uv run --script example.py
{'nested': {'foo': 'fuga', 'bar': 42}}

もうひとつのネストした項目を上書きする方法には JSON 文字列を使うやり方もある。 以下では EXAMPLE_PREFIX_NESTED という環境変数に JSON の文字列を渡している。 JSON 文字列が NestedConfig の構造を反映しているため各項目の値を上書きできる。

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "pydantic-settings>=2",
# ]
# ///

from pydantic import BaseModel
from pydantic import Field
from pydantic_settings import BaseSettings
from pydantic_settings import SettingsConfigDict


class NestedConfig(BaseModel):
    foo: str = Field(default="hoge")
    bar: int = Field(default=42)


class Settings(BaseSettings):
    nested: NestedConfig
    model_config = SettingsConfigDict(
        env_prefix="example_prefix_",
    )


def main():
    settings: Settings = Settings()
    print(settings.model_dump())


if __name__ == "__main__":
    import os

    # JSON 構造の文字列を指定することで設定を上書きする方法もある
    os.environ["EXAMPLE_PREFIX_NESTED"] = '{"foo": "fuga", "bar": 12345}'
    main()

実行すると、たしかに値が変更されている。

$ uv run --script example.py
{'nested': {'foo': 'fuga', 'bar': 12345}}

設定ファイル (dotenv) を読み込む

ここまではシェル変数や環境変数をプロセスに直接渡すやり方だった。 とはいえ、項目が増えてくるとそれも煩雑なので設定ファイルに分離したくなる。 その場合の最初の選択肢は dotenv 形式のファイルになるだろう。 要するに環境変数をそのままプレーンテキストに書いたファイルを読み込む。

以下のサンプルコードではカレントディレクトリにある .env ファイルを読み込むようにしている。 といってもやることは model_config の引数に env_fileenv_file_encoding を指定するだけ。

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "pydantic-settings>=2",
# ]
# ///

from pydantic import BaseModel
from pydantic import Field
from pydantic_settings import BaseSettings
from pydantic_settings import SettingsConfigDict


class NestedConfig(BaseModel):
    foo: str = Field(default="hoge")
    bar: int = Field(default=42)


class Settings(BaseSettings):
    nested: NestedConfig
    model_config = SettingsConfigDict(
        env_prefix="example_prefix_",
        env_nested_delimiter="__",
        # dotenv 形式のファイルを読むようにするには env_file 引数でファイル名を指定する
        env_file=".env",
        env_file_encoding="utf-8",
    )


def main():
    settings: Settings = Settings()
    print(settings.model_dump())


if __name__ == "__main__":
    # カレントディレクトリに .env ファイルを作る
    # NOTE: すでに存在するディレクトリで実行すると上書きされてしまう点に注意する
    dotenv_configs = [
        "EXAMPLE_PREFIX_NESTED__FOO=fuga",
        "EXAMPLE_PREFIX_NESTED__BAR=12345",
    ]
    with open(".env", mode="w") as f:
        f.write("\n".join(dotenv_configs))

    main()

上記ではプログラムの中で .env ファイルを作った上で Settings をインスタンス化することで設定を読ませている。

実行すると、ちゃんと設定が反映されていることが確認できる。

$ uv run --script example.py
{'nest': {'foo': 'fuga', 'bar': 12345}}

作成される .env ファイルの中身は次のとおり。

$ cat .env
EXAMPLE_PREFIX_NEST__FOO=fuga
EXAMPLE_PREFIX_NEST__BAR=12345

なお、複数のファイル名に対応させたいときは env_file に渡す値を文字列が複数入ったイテレータにすれば良い。 また、BaseSettings をインスタンス化するタイミングで読み込むファイルを指定することもできる。 ただし、その場合は model_config で指定したファイルが読まれなくなる店に注意が必要になる。 以下では実際にインスタンス化するタイミングで .env.dev というファイルを読むように指定している。

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "pydantic-settings>=2",
# ]
# ///

from pydantic import BaseModel
from pydantic import Field
from pydantic_settings import BaseSettings
from pydantic_settings import SettingsConfigDict


class NestedConfig(BaseModel):
    foo: str = Field(default="hoge")
    bar: int = Field(default=42)


class Settings(BaseSettings):
    nested: NestedConfig
    model_config = SettingsConfigDict(
        env_prefix="example_prefix_",
        env_nested_delimiter="__",
        env_file=(
            ".env",
            # 複数のファイル名に対応させたいときは env_file に渡す値をイテラブルにする
        ),
        env_file_encoding="utf-8",
    )


def main():
    # インスタンス化するタイミングで読ませることもできる
    # ただし、model_config に指定したファイルは無視される点に注意する
    settings: Settings = Settings(
        _env_file=".env.dev",
        _env_file_encoding="utf-8",
    )
    print(settings.model_dump())


if __name__ == "__main__":
    dotenv_configs = [
        "EXAMPLE_PREFIX_NESTED__FOO=fuga",
    ]
    with open(".env", mode="w") as f:
        f.write("\n".join(dotenv_configs))

    dotenv_dev_configs = [
        "EXAMPLE_PREFIX_NESTED__BAR=12345",
    ]
    with open(".env.dev", mode="w") as f:
        f.write("\n".join(dotenv_dev_configs))

    main()

実行すると .env の方は反映されず、.env.dev の方だけが反映されている。

$ uv run --script example.py
{'nest': {'foo': 'hoge', 'bar': 12345}}

終わったら後片付けしておこう。

$ rm .env .env.dev

設定ファイルをホームディレクトリ配下から読み込む

実行する場所に依存しない形でホームディレクトリの配下に設定ファイルを置きたい場合もあるはず。 その場合はパスを pathlib.Path#expanduser() を使って指定すれば良い。 以下のサンプルコードではホームディレクトリの直下から .env.example というファイル名で読み込んでいる。

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "pydantic-settings>=2",
# ]
# ///

from pathlib import Path

from pydantic import BaseModel
from pydantic import Field
from pydantic_settings import BaseSettings
from pydantic_settings import SettingsConfigDict


class NestedConfig(BaseModel):
    foo: str = Field(default="hoge")
    bar: int = Field(default=42)


class Settings(BaseSettings):
    nested: NestedConfig
    model_config = SettingsConfigDict(
        env_prefix="example_prefix_",
        env_nested_delimiter="__",
        env_file=(
            # ホームディレクトリを扱いたい場合には pathlib.Path#expanduser() を使う
            Path("~/.env.example").expanduser(),
        ),
        env_file_encoding="utf-8",
    )


def main():
    settings: Settings = Settings()
    print(settings.model_dump())


if __name__ == "__main__":
    main()

実際にファイルを用意してみよう。

$ cat << 'EOF' > ~/.env.example
EXAMPLE_PREFIX_NESTED__FOO=fuga
EXAMPLE_PREFIX_NESTED__BAR=12345
EOF

実行すると、たしかにファイルから値が読み込まれている。

$ uv run --script example.py
{'nest': {'foo': 'fuga', 'bar': 12345}}

なお、実際にこのイディオムを使うときはホームディレクトリの .config 配下などに置くのが適切だろう。

設定ファイル (TOML) を読み込む

Python のプログラムが参照する設定ファイルとしては TOML 形式もポピュラーだろう。 しかし、TOML 形式のファイルは、デフォルトでは読み込み対象になっていない。 そのため BaseSettings クラスにクラスメソッドの settings_customise_sources を定義して動作をオーバーライドする必要がある。

以下のサンプルコードでは config.toml という名前で TOML 形式のファイルを読み込んでいる。

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "pydantic-settings>=2",
# ]
# ///
from pydantic import BaseModel
from pydantic import Field
from pydantic_settings import BaseSettings
from pydantic_settings import PydanticBaseSettingsSource
from pydantic_settings import SettingsConfigDict
from pydantic_settings import TomlConfigSettingsSource


class NestedConfig(BaseModel):
    bar: int = Field(default=42)


class Settings(BaseSettings):
    nested: NestedConfig = NestedConfig()
    foo: str = Field(default="hoge")
    model_config = SettingsConfigDict(
        env_prefix="example_prefix_",
        env_nested_delimiter="__",
        # toml ファイルを読むときは toml_file を指定する
        toml_file="config.toml",
    )

    @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, ...]:
        """設定を何処からどの順序で読み込むかを指定するフックポイント"""
        # デフォルトでは TOML を読み込む設定がないので追加する
        return (
            # 以下の 4 つはデフォルトで反映される内容
            init_settings,
            env_settings,
            dotenv_settings,
            file_secret_settings,
            # TOML の反映を最も低い優先度で追加している
            TomlConfigSettingsSource(settings_cls),
        )


def main():
    settings: Settings = Settings()
    print(settings.model_dump())


if __name__ == "__main__":
    # config.toml ファイルを作る
    toml_configs = [
        'foo="fuga"',
        "",
        "[nested]",
        "bar=12345",
        "",
    ]
    with open("config.toml", mode="w") as f:
        f.write("\n".join(toml_configs))

    main()

TOML 形式の設定ファイルは先ほどの dotenv 形式のファイルと同様に main() 関数を呼び出す前に作成している。

上記を実行すると、ちゃんと設定ファイルの内容が読まれることが分かる。

$ uv run --script example.py
{'nested': {'bar': 12345}, 'foo': 'fuga'}

終わったら後片付けする。

$ rm config.toml

設定ファイル (シークレット) を読み込む

パスワードやトークンなど公にできない情報を、専用のファイルに書き込んでおいてそこから読み込んで使うようなケースもある。 Pydantic Settings ではシークレットと呼んでいる。 シークレットのファイルは形式としては dotenv に近いものの、中身が = で分割されたキーバリュー形式になっていない。 入っているのは単一のバリューのみで、キーはファイル名で表される。

以下のサンプルコードでは /tmp をシークレットを読み込むディレクトリに指定している。 もちろん、実際にはもっと別のパスを使うことになる。 また、ネストした項目に値を読み込む際にはファイル名をアンダースコア 2 つで区切って識別する指定が入っている。

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "pydantic-settings>=2",
# ]
# ///
from pydantic import BaseModel
from pydantic import Field
from pydantic import SecretStr
from pydantic_settings import BaseSettings
from pydantic_settings import NestedSecretsSettingsSource
from pydantic_settings import SettingsConfigDict


class NestedConfig(BaseModel):
    # シークレットを扱うときは型を Secret* にする
    secret_token: SecretStr = Field()


class Settings(BaseSettings):
    nested: NestedConfig
    # シークレットを扱うときは型を Secret* にする
    secret_password: SecretStr = Field()
    model_config = SettingsConfigDict(
        # シークレットの書かれたファイルを特定のディレクトリから読み込む
        secrets_dir="/tmp",
        # ネストした要素に読み込む場合はいくつかのやり方がある
        # 単純にファイル名で階層を識別するなら secrets_nested_delimiter を使う
        secrets_nested_delimiter="__",
    )

    @classmethod
    def settings_customise_sources(
        cls,
        settings_cls,
        init_settings,
        env_settings,
        dotenv_settings,
        file_secret_settings,
    ):
        return (
            init_settings,
            env_settings,
            dotenv_settings,
            # ネストした項目に値を読み込む場合はソースを変更する必要がある
            # SecretsSettingsSource の代わりに NestedSecretsSettingsSource を使用する
            NestedSecretsSettingsSource(file_secret_settings),
        )


def main():
    settings: Settings = Settings()
    # Secret* はそのままだとマスクして表示される
    print(settings.model_dump())
    # 使うときは get_secret_value() メソッドで参照する
    print(settings.secret_password.get_secret_value())
    print(settings.nested.secret_token.get_secret_value())


if __name__ == "__main__":
    main()

試しにダミーのシークレットを /tmp 以下に用意しよう。

$ cat << 'EOF' >> /tmp/secret_password
super-secret-password
EOF
$ cat << 'EOF' >> /tmp/nested__secret_token
super-secret-token
EOF

実行すると、ちゃんとエラーにならずシークレットのファイルが読まれる。

$ uv run --script example.py
{'nested': {'secret_token': SecretStr('**********')}, 'secret_password': SecretStr('**********')}
super-secret-password
super-secret-token

読み込む先のメンバ変数の型を SecretStr にしているので model_dump() で表示される内容はアスタリスクでマスクされている。 実際に取り出して使う際には get_secret_value() から参照する。 もちろん、実際にはサンプルコードのように標準出力やログに出力してしまうのはまずい。

終わったら後片付けしておこう。

$ rm /tmp/secret_password /tmp/nested__secret_token

CLI から設定を読み込む

また、環境変数や設定ファイルだけでなく CLI から設定を読み込めるようにするオプションもある。 この機能を使うときは model_config の引数 cli_parse_argsTrue を指定する。

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "pydantic-settings>=2",
# ]
# ///
from pydantic import BaseModel
from pydantic import Field
from pydantic_settings import BaseSettings
from pydantic_settings import SettingsConfigDict


class NestedConfig(BaseModel):
    bar: int = Field(default=42)


class Settings(BaseSettings):
    nested: NestedConfig
    foo: str = Field(default="hoge")
    model_config = SettingsConfigDict(
        # CLI から設定を読めるようにするには cli_parse_args を有効にする
        cli_parse_args=True,
    )


def main():
    settings: Settings = Settings()
    print(settings.model_dump())


if __name__ == "__main__":
    main()

これだけでプログラムが CLI の引数を受け取るスクリプトになる。 たとえば --help 引数を受け取って自動的に usage を出力できる。

$ uv run --script example.py --help    
usage: example.py [-h] [--nested [JSON]] [--nested.bar int] [--foo str]

options:
  -h, --help        show this help message and exit
  --foo str         (default: hoge)

nested options:
  --nested [JSON]   set nested from JSON string (default: {})
  --nested.bar int  (default: 42)

usage の内容から指定していくと、ちゃんと動作に反映されることが確認できる。

$ uv run --script example.py --foo fuga --nested.bar -1
{'nested': {'bar': -1}, 'foo': 'fuga'}

まとめ

今回は Pydantic Settings を使って Python のプログラムの設定を扱う方法について紹介した。 特に気に入っているのは、小さくシンプルに始めることができて、なおかつ細かいニーズにも柔軟に対応できるところ。 この記事で使っている設定は全体の一部で、実際にはもっと色々なカスタマイズもできる。


Python: FastAPI の例外ハンドラを pytest でテストする

Web アプリケーションの例外ハンドリングでは、未処理の例外はキャッチして特定の HTTP レスポンスに変換するのが定石だろう。 このとき、未処理の例外をキャッチして HTTP レスポンスに変換する仕組みは例外ハンドラと呼ばれることが多い。 そして、FastAPI にも例外ハンドラの仕組みが用意されている。

fastapi.tiangolo.com

今回は例外ハンドラの振る舞いを pytest を使ってユニットテストする方法について書いてみる。 なお、FastAPI を使ったプロジェクトのユニットテストに関する基本的な内容は以下に書いた。

blog.amedama.jp

使った環境は次のとおり。

$ sw_vers   
ProductName:        macOS
ProductVersion:     26.3
BuildVersion:       25D125
$ uv --version
uv 0.10.6 (Homebrew 2026-02-24)
$ uv tree 2>/dev/null      
helloworld v0.1.0
├── fastapi v0.133.1
│   ├── annotated-doc v0.0.4
│   ├── pydantic v2.12.5
│   │   ├── annotated-types v0.7.0
│   │   ├── pydantic-core v2.41.5
│   │   │   └── typing-extensions v4.15.0
│   │   ├── typing-extensions v4.15.0
│   │   └── typing-inspection v0.4.2
│   │       └── typing-extensions v4.15.0
│   ├── starlette v0.52.1
│   │   └── anyio v4.12.1
│   │       └── idna v3.11
│   ├── typing-extensions v4.15.0
│   └── typing-inspection v0.4.2 (*)
├── uvicorn v0.41.0
│   ├── click v8.3.1
│   └── h11 v0.16.0
├── httpx v0.28.1 (group: dev)
│   ├── anyio v4.12.1 (*)
│   ├── certifi v2026.2.25
│   ├── httpcore v1.0.9
│   │   ├── certifi v2026.2.25
│   │   └── h11 v0.16.0
│   └── idna v3.11
└── pytest v9.0.2 (group: dev)
    ├── iniconfig v2.3.0
    ├── packaging v26.0
    ├── pluggy v1.6.0
    └── pygments v2.19.2
(*) Package tree already displayed

もくじ

例外ハンドラについて

前述したとおり、例外ハンドラは未処理の例外をキャッチして何らかの処理をする。 Web アプリケーションでは HTTP のレスポンスに変換するのが定石だろう。

FastAPI の例外ハンドラは fastapi.FastAPI のインスタンスに exception_handler() デコレータを使ってハンドラを登録する。 以下のサンプルコードでは Exception をキャッチして HTTP ステータスコード 500 のレスポンスに変換する。 Exception は多くの例外の基底クラスとなっているため、デフォルトの例外ハンドラとして動作する。

from fastapi import FastAPI
from fastapi import Request
from fastapi.responses import JSONResponse

app = FastAPI()


@app.exception_handler(Exception)
def exception_handler(request: Request, exc: Exception):
    """アプリケーションで raise された例外をレスポンスに変換するハンドラ"""
    body = {
        "message": str(exc),
    }
    return JSONResponse(
        status_code=500,
        content=body,
    )


@app.get("/")
def root_get():
    raise Exception("Oops!")


if __name__ == "__main__":
    import uvicorn

    uvicorn.run(app)

上記の Python モジュールを helloworld パッケージ以下に main.py として用意する。 ディレクトリの構造は次のようになっている。

$ tree .
.
├── helloworld
│   ├── __init__.py
│   └── main.py
├── pyproject.toml
└── tests
    └── helloworld
        ├── __init__.py
        └── test_main.py

4 directories, 5 files

tests 以下のディレクトリはテスト用で詳しくは後述する。

FastAPI のアプリケーションを Uvicorn から起動する。

$ uv run uvicorn helloworld.main:app --reload

上記で Uvicorn がフォアグラウンドで起動する。

別のターミナルを開いて curl で HTTP API のエンドポイントにアクセスする。

$ curl http://localhost:8000/
{"message":"Oops!"}

レスポンスは例外ハンドラで指定した形式の JSON になっている。 例外ハンドラによって例外が HTTP レスポンスに変換されていることが確認できる。

ユニットテストを書く

例外ハンドラのテストは、通常の FastAPI のテストと同様に fastapi.testclient.TestClient を使えば良い。 ただし、デフォルトでは例外ハンドラで処理された例外が改めてスローされる点に気をつける必要がある。 つまり pytest を使う場合であれば pytest.raises() を使って例外が上がることを確認しないといけない。

以下のサンプルコードでは、先ほどのアプリケーションに対応したテストを書いている。 先ほどのディレクトリ構成の test_main.py に対応する。

import pytest
from fastapi.testclient import TestClient

from helloworld.main import app


def test_root_get():
    """GET / をテストする"""
    # TestClient を使うとデフォルトでは例外ハンドラがあっても改めて例外が上がる
    client = TestClient(app)
    #  pytest.raises() を使ってどのような例外が上がったかまで検証できる
    with pytest.raises(Exception):
        response = client.get("/")
        assert response.status_code == 500
        assert response.json() == {"message": "Oops!"}


if __name__ == "__main__":
    pytest.main([__file__, "-s", "-v"])

上記のテストを実行してみよう。

$ uv run pytest -v                              
============================================= test session starts ==============================================
platform darwin -- Python 3.14.3, pytest-9.0.2, pluggy-1.6.0 -- /Users/amedama/Documents/example/.venv/bin/python
cachedir: .pytest_cache
rootdir: /Users/amedama/Documents/example
configfile: pyproject.toml
plugins: anyio-4.12.1
collected 1 item                                                                                               

tests/helloworld/test_main.py::test_root_get PASSED                                                      [100%]

============================================== 1 passed in 0.13s ===============================================

テストがパスした。 pytest.raises() は例外が上がらないとむしろテストが失敗する。 つまり、例外ハンドラで処理された例外が改めて上がっていることが確認できる。

なお、この振る舞いを変更することもできる。 TestClient をインスタンス化するときに引数の raise_server_exceptionsFalse を指定してみよう。 こうすることで例外ハンドラで処理された例外が改めて上がる振る舞いが抑制できる。

以下のサンプルコードでは実際に引数を指定している。 今度はテストする際に改めて例外が上がらないので pytest.raises() が必要ない。

import pytest
from fastapi.testclient import TestClient

from helloworld.main import app


def test_root_get():
    """GET / をテストする"""
    # どのような例外が上がったかまで検証しなくて良いときは
    # raise_server_exceptions に False を指定する
    client = TestClient(app, raise_server_exceptions=False)
    response = client.get("/")
    assert response.status_code == 500
    assert response.json() == {"message": "Oops!"}


if __name__ == "__main__":
    pytest.main([__file__, "-s", "-v"])

テストを実行すると先ほどと同じようにパスする。

$ uv run pytest -v                              
============================================= test session starts ==============================================
platform darwin -- Python 3.14.3, pytest-9.0.2, pluggy-1.6.0 -- /Users/amedama/Documents/example/.venv/bin/python
cachedir: .pytest_cache
rootdir: /Users/amedama/Documents/example
configfile: pyproject.toml
plugins: anyio-4.12.1
collected 1 item                                                                                               

tests/helloworld/test_main.py::test_root_get PASSED                                                      [100%]

============================================== 1 passed in 0.13s ===============================================

まとめ

今回は FastAPI の例外ハンドラを fastapi.testclient.TestClient でテストする際の注意点について紹介した。 デフォルトの振る舞いは処理したはずの例外が改めて上がってくるため、初見だと少し戸惑うかもしれない。


Python: FastAPI を使ったプロジェクトで HTTP API にユニットテストを書く

信頼性の高いアプリケーションを作る上で、自動化されたテストの整備は重要なプラクティスのひとつ。 その観点において Python の Web アプリケーションフレームワークの FastAPI 1 はユニットテストが書きやすい。 今回は FastAPI を使うプロジェクトで pytest 2 でユニットテストを書く場合にどんな感じになるか書き留めておく。

使った環境は次のとおり。

$ sw_vers   
ProductName:        macOS
ProductVersion:     26.3
BuildVersion:       25D125
$ uv --version
uv 0.10.6 (Homebrew 2026-02-24)
$ uv tree 2>/dev/null
helloworld v0.1.0
├── fastapi v0.133.1
│   ├── annotated-doc v0.0.4
│   ├── pydantic v2.12.5
│   │   ├── annotated-types v0.7.0
│   │   ├── pydantic-core v2.41.5
│   │   │   └── typing-extensions v4.15.0
│   │   ├── typing-extensions v4.15.0
│   │   └── typing-inspection v0.4.2
│   │       └── typing-extensions v4.15.0
│   ├── starlette v0.52.1
│   │   └── anyio v4.12.1
│   │       └── idna v3.11
│   ├── typing-extensions v4.15.0
│   └── typing-inspection v0.4.2 (*)
├── uvicorn v0.41.0
│   ├── click v8.3.1
│   └── h11 v0.16.0
├── httpx v0.28.1 (group: dev)
│   ├── anyio v4.12.1 (*)
│   ├── certifi v2026.2.25
│   ├── httpcore v1.0.9
│   │   ├── certifi v2026.2.25
│   │   └── h11 v0.16.0
│   └── idna v3.11
└── pytest v9.0.2 (group: dev)
    ├── iniconfig v2.3.0
    ├── packaging v26.0
    ├── pluggy v1.6.0
    └── pygments v2.19.2
(*) Package tree already displayed

もくじ

プロジェクトを用意する

まずは FastAPI を使ったプロジェクトを用意する。

プロジェクトは uv 3 で管理することを想定する。 そのためにプロジェクトのメタデータを記述した pyproject.toml を用意する。 プロジェクトのパッケージ名は helloworld にする。

$ cat << 'EOF' > pyproject.toml 
[project]
name = "helloworld"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
    "fastapi",
    "uvicorn",
]

[dependency-groups]
dev = [
    "httpx",
    "pytest",
]

[tool.uv]
package = true
EOF

後ほどテストを書くために、開発用の依存パッケージに pytesthttpx を追加しておく。

次に helloworld パッケージを用意する。 Python のパッケージとは __init__.py という名前のファイルが入ったディレクトリである。

$ mkdir -p helloworld
$ touch helloworld/__init__.py

そして FastAPI のアプリケーションを含んだモジュールの main.py を作る。 このアプリケーションはルートのパスに HTTP で GET すると JSON を返す HTTP API を定義している。

$ cat << 'EOF' > helloworld/main.py
from fastapi import FastAPI

app = FastAPI()


@app.get("/")
def root_get():
    return {"message": "Hello World"}

EOF

典型的には、上記のような HTTP API にアプリケーションのロジックを実装していく。 それをフロントエンドから呼び出す構成になるだろう。

動作を確認する

続いて、上記で準備したアプリケーションの動作を確認していく。

パッケージをインストールする。

$ uv sync

ASGI サーバの Uvicorn 4 から FastAPI のアプリケーションを起動する。

$ uv run uvicorn helloworld.main:app --reload

上記を実行すると Uvicorn のサーバがフォアグラウンドで起動する。

別のターミナルを開いて curl で HTTP API を呼び出す。

$ curl http://localhost:8000
{"message":"Hello World"}

上記のレスポンスが返ってくれば、ちゃんと動作することが確認できた。

ユニットテストを書く

次は動作を確認したアプリケーションに対してユニットテストを書く。

まずは tests ディレクトリを用意する。 その下にテスト対象のパッケージの構造に対応する形でパッケージを作っていく。

$ mkdir -p tests/helloworld
$ touch tests/helloworld/__init__.py

今回の主眼となるユニットテストを書く。 先ほど書いた main.py に対応するユニットテストとして test_main.py を用意する。

$ cat << 'EOF' > tests/helloworld/test_main.py 
from fastapi.testclient import TestClient

from helloworld.main import app


def test_root_get():
    """GET / の振る舞いをテストする"""
    client = TestClient(app)
    response = client.get("/")
    assert response.status_code == 200
    assert response.json() == {"message": "Hello World"}

EOF

上記では、次の行で helloworld パッケージから FastAPI のアプリケーションをインポートしている。

from helloworld.main import app

そして、インポートしたアプリケーションを fastapi.testclient.TestClient をインスタンス化するときに引数として渡している。

client = TestClient(app)

この fastapi.testclient.TestClient のインスタンスを使うことでユニットテストが容易に記述できる。

次の行でルートのパスに HTTP GET したレスポンスを得ている。

response = client.get("/")

そして、得られたレスポンスのステータスコードや中身を検証している。

assert response.status_code == 200
assert response.json() == {"message": "Hello World"}

テストコードは pytest コマンドで実行できる。

$ uv run pytest -v
============================= test session starts ==============================
platform darwin -- Python 3.12.12, pytest-9.0.2, pluggy-1.6.0 -- /Users/amedama/Documents/example/.venv/bin/python3
cachedir: .pytest_cache
rootdir: /Users/amedama/Documents/example
configfile: pyproject.toml
plugins: anyio-4.12.1
collected 1 item                                                               

tests/helloworld/test_main.py::test_root_get PASSED                      [100%]

============================== 1 passed in 0.10s ===============================

テストがパスした。 つまり、アプリケーションからレスポンスが返って、その検証に成功している。

まとめ

今回は FastAPI を使ったプロジェクトで HTTP API に pytest でユニットテストを書く方法を紹介した。 テストで重要なポイントは、アプリケーションのインターフェイスの振る舞いを検証すること。 HTTP API はフロントエンドとのインターフェイスなのでテストを書く対象として相応しい。

参考までに、ディレクトリ構成は次のようになる。

$ tree .
.
├── helloworld
│   ├── __init__.py
│   └── main.py
├── pyproject.toml
└── tests
    └── helloworld
        ├── __init__.py
        └── test_main.py

4 directories, 5 files

macOS: 無線 LAN 接続用の 2 次元バーコードを作る

スマートフォンなどのカメラで読み取ると無線 LAN に接続できる 2 次元バーコードがある。 これは ZXing ("zebra crossing") という OSS のプロジェクトが提案したのが発祥のようだ。 現在では Android や iOS といったプラットフォームが対応しており、デファクトスタンダードになっている。

仕様は ZXing のリポジトリに記載されている。 といっても難しいことはなくて、特定の形式の文字列を 2 次元バーコードにエンコードするだけで作れる。

github.com

今回は macOS で実際に作る方法を紹介する。

もくじ

下準備

まずは Homebrew を使って必要なパッケージをインストールしておく。 これは文字列を 2 次元バーコードにエンコードするのに使う。

$ brew install qrencode

仕様について

仕様では、以下のような形式の文字列が定義されている。 このような文字列を 2 次元バーコードにエンコードすると、読み取ったシステムが解釈した上で無線 LAN に接続できる。

WIFI:T:WPA;S:<SSID>;P:<Password>;;

まず、文字列は必ず WIFI: というプレフィックスで始まる。 これで、無線 LAN に接続するための情報が以降に記載されていることを示している。

プレフィックス以降はコロンを挟んだキーバリュー形式になっている。 それぞれのキーバリューはセミコロンで区切られ、文字列の末尾もセミコロンで終端される。 つまり、次のような形式になる。

WIFI:<key1>:<value1>;<key2>:<value2>;...;<keyN>:<valueN>;;

一般的な環境で使うキーは TSP だろう。 それぞれ T が認証形式 (WPA など)、S が SSID、P がパスワードを示している。

たとえば WPA 認証で、SSID が TEST、パスワードが PASSWD の場合は次のような文字列になる。

WIFI:T:WPA;S:TEST;P:PASSWD;;

T:WPA を指定した場合、WPA のどのバージョンが使われているかはシステム側で判断することになるようだ。

それ以外にも、特定の状況で使われるキーがいくつか定義されている。 詳しくは先述のリポジトリの内容を確認してもらいたい。

作ってみる

仕様が分かったところで、実際に 2 次元バーコードを作ってみよう。

先述の文字列を qrencode コマンドを使って実際にエンコードする。

$ qrencode -o wifi-qr-code.png "WIFI:T:WPA;S:TEST;P:PASSWD;;"

これで wifi-qr-code.png というファイルに 2 次元バーコードが書き出される。

実際に出力された 2 次元バーコードは以下のとおり。

サンプルの 2 次元バーコード

ただし、上記の手順だとパスワードがターミナルのログに残ってしまう。 気になるときは read コマンドを介してシェル変数にパスワードを読み込むと良いだろう。 read コマンドに -s オプションをつけるとエコーバックされないのでログには残らない。 まあ、結局のところ読み取る 2 次元バーコードに平文で書き込まれるんだけどね。

たとえば、以下は WIFI_PASSWORD という名前のシェル変数に入れる場合の例になる。

$ read -s WIFI_PASSWORD

SSID も read コマンドで読み込む場合には -s はつけなくても良いだろう。

$ read WIFI_SSID

シェル変数に読み込んだ内容を元に 2 次元バーコードを作るには以下のようにする。

$ qrencode -o wifi-qr-code.png "WIFI:T:WPA;S:${WIFI_SSID};P:${WIFI_PASSWORD};;"

いじょう。