CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: TorchAO で MXFP4 / MXFP8 の動作を確認する

2023 年に Open Compute Project (OCP) が規格を策定した Microscaling Formats (MX) Specification Version 1.0 には MXFP4, 6, 8 および MXINT8 の仕様が定義されている。 AI の学習と推論向けに用意されたデータフォーマットで、前回扱った NVFP4 は MXFP4 の課題を改善する目的で作られた。

blog.amedama.jp

今回は、その中でも MXFP4 と MXFP8 について TorchAO 1 を使って実際に動作を確認してみることにした。

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

$ sw_vers
ProductName:            macOS
ProductVersion:         26.6.2
BuildVersion:           25G83
$ uv --version
uv 0.12.13 (0ebbd9274 2026-09-10 aarch64-apple-darwin)
$ uv run python -V
Python 3.12.14
$ uv pip list | egrep "(torch|numpy)"
numpy             2.5.2
torch             2.14.0
torchao           0.18.0

もくじ

MXFP について

前述したとおり MXFP の仕様は Microscaling Formats (MX) Specification Version 1.0 に記載されている。

https://www.opencompute.org/documents/ocp-microscaling-formats-mx-v1-0-spec-final-pdfwww.opencompute.org

あるいは MXFP8 については Transformer Engine のドキュメントが分かりやすい。

docs.nvidia.com

MXFP8 は、あるテンソルを FP8 の要素と、FP8 のスケールで表現したフォーマットになっている。 上記のドキュメントでは、下記の式がそれを表現している。

x = x_fp8 * s_block
  • x_fp8
    • FP8 (E4M3 または E5M2) の要素
  • s_block
    • 32 要素 (ブロック) ごとに用意される FP8 (E8M0) のスケール

NVFP4 にはテンソル単位のスケールがあった一方で MXFP にはそれがない。 また、ブロック単位のスケールのデータ型も NVFP4 では E4M3 だった一方で MXFP では E8M0 になっている。 E8M0 には指数部のビットしかないため 2 のべき乗しか表現できない。

MXFP4 についても同様で、次のようになっている。 データの要素が FP4 (E2M1) になっている点しか違いがない。

x = x_fp4 * s_block
  • x_fp4
    • FP4 (E2M1) の要素
  • s_block
    • 32 要素 (ブロック) ごとに用意される FP8 (E8M0) のスケール

NVFP4 と比較すると、次のような点からハードウェアがシンプルに実装できる所がメリットのようだ。

  • スケールが一段階と少ない
  • ブロック単位のスケールが 2 のべき乗しか扱えない E8M0 になっている

なお、仕様では MXFP6 も定義されている。 ただし、後述するとおり PyTorch では現状でサポートが限られている。

下準備

ここからは TorchAO を使う準備をしていく。

まずは uv で Python の仮想環境を初期化する。

$ uv venv --clear

必要なパッケージを仮想環境にインストールする。

$ uv pip install torch --torch-backend=auto
$ uv pip install torchao numpy

インストールが終わったら Python のインタプリタを起動する。

$ uv run python

MXFP に変換する

PyTorch をインポートして、MXFP4 / MXFP8 に変換するベクトルを適当に用意する。 このとき、要素数は 32 で割り切れる必要がある。 使用するデータ型は BF16 にした。

>>> import torch
>>> x = torch.randn(64, dtype=torch.bfloat16)
>>> x
tensor([-5.4297e-01,  1.0938e+00, -1.7578e+00, -8.6719e-01, -1.9043e-01,
        -8.0469e-01,  7.4609e-01, -1.4496e-03, -1.0391e+00, -1.4258e-01,
         2.7188e+00, -1.1719e-01,  5.1172e-01,  9.2188e-01, -4.5312e-01,
         1.9141e+00,  1.2500e+00, -1.1172e+00,  6.6797e-01, -5.6250e-01,
         3.8672e-01, -1.2578e+00,  2.3281e+00,  4.7607e-03,  4.0820e-01,
         4.7070e-01,  1.4551e-01,  1.5938e+00,  4.8242e-01,  2.1289e-01,
        -1.3438e+00, -5.5908e-02, -9.5215e-02, -5.3125e-01,  5.8203e-01,
         8.0078e-01,  1.7578e+00, -5.2490e-02,  1.8750e+00,  1.1172e+00,
        -7.0703e-01, -1.4219e+00,  8.6328e-01,  4.8828e-01, -4.8242e-01,
        -3.8477e-01,  6.4062e-01, -1.0703e+00, -2.2031e+00,  1.5547e+00,
         1.6602e-01, -6.0547e-01, -1.2109e+00, -5.1172e-01,  3.4375e-01,
         5.6641e-01,  9.5312e-01,  6.4844e-01, -1.0000e+00, -2.8125e-01,
        -1.9434e-01, -1.5078e+00,  1.5781e+00,  2.1406e+00],
       dtype=torch.bfloat16)

MXFP に変換するときは torchao.prototype.mx_formats.mx_tensor.MXTensor を使うためインポートする。

>>> from torchao.prototype.mx_formats.mx_tensor import MXTensor

元のデータを渡しつつ elem_dtype で要素のデータ型を指定する。 このときデータ型に FP4 (E2M1) を指定することで MXFP4 になる。

>> x_mxfp4 = MXTensor.to_mx(
...     x,
...     elem_dtype=torch.float4_e2m1fn_x2,
... )

同様に、FP8 の E4M3 あるいは E5M2 を指定することで MXFP8 になる。

>> x_mxfp8_e4m3 = MXTensor.to_mx(
...     x,
...     elem_dtype=torch.float8_e4m3fn,
... )
>>> x_mxfp8_e5m2 = MXTensor.to_mx(
...     x,
...     elem_dtype=torch.float8_e5m2,
... )

これで MXTensor のオブジェクトが得られる。

>>> type(x_mxfp4)
<class 'torchao.prototype.mx_formats.mx_tensor.MXTensor'>
>>> x_mxfp4.shape
torch.Size([64])

なお、MXFP6 に対応する FP6 は PyTorch にデータ型の定義がない。

docs.pytorch.org

そのため MXFP6 は素直に扱うことができない。

元の Tensor と比べる

MXTensor#dequantize() メソッドを使うと元の Tensor オブジェクトに復元できる。

>>> x_mxfp4.dequantize()
tensor([-0.5000,  1.0000, -2.0000, -0.7500, -0.2500, -0.7500,  0.7500, -0.0000,
        -1.0000, -0.2500,  3.0000, -0.0000,  0.5000,  1.0000, -0.5000,  2.0000,
         1.0000, -1.0000,  0.7500, -0.5000,  0.5000, -1.5000,  2.0000,  0.0000,
         0.5000,  0.5000,  0.2500,  1.5000,  0.5000,  0.2500, -1.5000, -0.0000,
        -0.0000, -0.5000,  0.5000,  0.7500,  2.0000, -0.0000,  2.0000,  1.0000,
        -0.7500, -1.5000,  0.7500,  0.5000, -0.5000, -0.5000,  0.7500, -1.0000,
        -2.0000,  1.5000,  0.2500, -0.5000, -1.0000, -0.5000,  0.2500,  0.5000,
         1.0000,  0.7500, -1.0000, -0.2500, -0.2500, -1.5000,  1.5000,  2.0000],
       dtype=torch.bfloat16)

今回の場合、MXFP4 の要素の値は 0.25 刻みになっていることが分かる。 元の要素と比べたときの差が低精度化によって生じた誤差ということになる。

>>> x_restored = x_mxfp4.dequantize()
>>> x_error = x - x_restored
>>> x_error
tensor([-0.0430,  0.0938,  0.2422, -0.1172,  0.0596, -0.0547, -0.0039, -0.0014,
        -0.0391,  0.1074, -0.2812, -0.1172,  0.0117, -0.0781,  0.0469, -0.0859,
         0.2500, -0.1172, -0.0820, -0.0625, -0.1133,  0.2422,  0.3281,  0.0048,
        -0.0918, -0.0293, -0.1045,  0.0938, -0.0176, -0.0371,  0.1562, -0.0559,
        -0.0952, -0.0312,  0.0820,  0.0508, -0.2422, -0.0525, -0.1250,  0.1172,
         0.0430,  0.0781,  0.1133, -0.0117,  0.0176,  0.1152, -0.1094, -0.0703,
        -0.2031,  0.0547, -0.0840, -0.1055, -0.2109, -0.0117,  0.0938,  0.0664,
        -0.0469, -0.1016,  0.0000, -0.0312,  0.0557, -0.0078,  0.0781,  0.1406],
       dtype=torch.bfloat16)

今回の場合は MXFP4 で最も誤差の大きな要素は絶対値で 0.3281 の差があった。 平均絶対誤差 (MAE) は 0.0908 だった。

>>> x_error.abs().max()
tensor(0.3281, dtype=torch.bfloat16)
>>> x_error.abs().mean()
tensor(0.0908, dtype=torch.bfloat16)

MXFP8 (E4M3) では、最も誤差の大きな要素は絶対値で 0.1094 で平均絶対誤差は 0.0173 だった。 MXFP4 と比較して、前者は約 1/3、後者は約 1/5 になっている。

>>> x_restored = x_mxfp8_e4m3.dequantize()
>>> x_error = x - x_restored
>>> x_error.abs().max()
tensor(0.1094, dtype=torch.bfloat16)
>>> x_error.abs().mean()
tensor(0.0173, dtype=torch.bfloat16)

これを見ると、要素のビット数が倍 (4 -> 8) になることで得られる精度上の恩恵は小さくないと感じられる。

中身を見ていく

続いては MXTensor の中身を見ていく。

ブロック単位のスケールは scale に入っている。 今回は 64 要素を変換したので、スケールの要素数は 2 になる。 前述したとおり、データ型は FP8 (E8M0) になっている。

>>> x_mxfp4.scale
tensor([0.5000, 0.5000], dtype=torch.float8_e8m0fnu)

データの要素は qdata に入っている。

>>> x_mxfp4.qdata
tensor([ 74, 190, 185, 131, 156, 135,  66, 106, 196, 163, 210,   6,  34,  81,
         18, 141, 168,  50, 134,  70, 219,  35, 170, 195,  94, 161, 172,  33,
         52, 156, 217, 101], dtype=torch.uint8)
>>> x_mxfp4.qdata.shape
torch.Size([32])

MXFP4 の要素のデータ型は NVFP4 と同じように Unsigned INT8 になっている。 上位ビットと下位ビットで E2M1 が 2 要素ずつ入っている点も変わらない。

一方で MXFP8 については FP8 がそのまま入っている。

>>> x_mxfp8_e4m3.qdata
tensor([-7.2000e+01,  1.4400e+02, -2.2400e+02, -1.1200e+02, -2.4000e+01,
        -1.0400e+02,  9.6000e+01, -1.8750e-01, -1.2800e+02, -1.8000e+01,
         3.5200e+02, -1.5000e+01,  6.4000e+01,  1.2000e+02, -5.6000e+01,
         2.4000e+02,  1.6000e+02, -1.4400e+02,  8.8000e+01, -7.2000e+01,
         4.8000e+01, -1.6000e+02,  2.8800e+02,  6.2500e-01,  5.2000e+01,
         6.0000e+01,  1.8000e+01,  2.0800e+02,  6.0000e+01,  2.8000e+01,
        -1.7600e+02, -7.0000e+00, -1.2000e+01, -6.4000e+01,  7.2000e+01,
         1.0400e+02,  2.2400e+02, -6.5000e+00,  2.4000e+02,  1.4400e+02,
        -8.8000e+01, -1.7600e+02,  1.1200e+02,  6.4000e+01, -6.0000e+01,
        -4.8000e+01,  8.0000e+01, -1.4400e+02, -2.8800e+02,  1.9200e+02,
         2.2000e+01, -8.0000e+01, -1.6000e+02, -6.4000e+01,  4.4000e+01,
         7.2000e+01,  1.2000e+02,  8.0000e+01, -1.2800e+02, -3.6000e+01,
        -2.4000e+01, -1.9200e+02,  2.0800e+02,  2.8800e+02],
       dtype=torch.float8_e4m3fn)
>> x_mxfp8_e4m3.qdata.shape
torch.Size([64])

手動で復元してみる

最後に MXTensor#dequantize() メソッドを使わずに Tensor オブジェクトを復元してみよう。

MXFP4 に関しては NVFP4 とほとんど変わらない。 操作における差はテンソル単位のグローバルなスケールがないだけ。 まずは unpack_uint4() で上位ビットと下位ビットを取り出してから f4_unpacked_to_f32() で FP4 として解釈する。

>>> from torchao.prototype.mx_formats.kernels import unpack_uint4
>>> from torchao.prototype.mx_formats.kernels import f4_unpacked_to_f32
>>> unpacked_f32_qdata = f4_unpacked_to_f32(unpack_uint4(x_mxfp4.qdata))
>>> unpacked_f32_qdata
tensor([-1.0000,  2.0000, -4.0000, -1.5000, -0.5000, -1.5000,  1.5000, -0.0000,
        -2.0000, -0.5000,  6.0000, -0.0000,  1.0000,  2.0000, -1.0000,  4.0000,
         2.0000, -2.0000,  1.5000, -1.0000,  1.0000, -3.0000,  4.0000,  0.0000,
         1.0000,  1.0000,  0.5000,  3.0000,  1.0000,  0.5000, -3.0000, -0.0000,
        -0.0000, -1.0000,  1.0000,  1.5000,  4.0000, -0.0000,  4.0000,  2.0000,
        -1.5000, -3.0000,  1.5000,  1.0000, -1.0000, -1.0000,  1.5000, -2.0000,
        -4.0000,  3.0000,  0.5000, -1.0000, -2.0000, -1.0000,  0.5000,  1.0000,
         2.0000,  1.5000, -2.0000, -0.5000, -0.5000, -3.0000,  3.0000,  4.0000])

スケールは元の要素数と揃うように引き延ばす。

>>> interleaved_f32_scale = x_mxfp4.scale.float().repeat_interleave(32, dim=-1)
>>> interleaved_f32_scale
tensor([0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000,
        0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000,
        0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000,
        0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000,
        0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000,
        0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000,
        0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000,
        0.5000])

あとはかけるだけ。

>>> dequantized = interleaved_f32_scale * unpacked_f32_qdata
>>> dequantized
tensor([-0.5000,  1.0000, -2.0000, -0.7500, -0.2500, -0.7500,  0.7500, -0.0000,
        -1.0000, -0.2500,  3.0000, -0.0000,  0.5000,  1.0000, -0.5000,  2.0000,
         1.0000, -1.0000,  0.7500, -0.5000,  0.5000, -1.5000,  2.0000,  0.0000,
         0.5000,  0.5000,  0.2500,  1.5000,  0.5000,  0.2500, -1.5000, -0.0000,
        -0.0000, -0.5000,  0.5000,  0.7500,  2.0000, -0.0000,  2.0000,  1.0000,
        -0.7500, -1.5000,  0.7500,  0.5000, -0.5000, -0.5000,  0.7500, -1.0000,
        -2.0000,  1.5000,  0.2500, -0.5000, -1.0000, -0.5000,  0.2500,  0.5000,
         1.0000,  0.7500, -1.0000, -0.2500, -0.2500, -1.5000,  1.5000,  2.0000])

MXTensor#dequantize() した結果と見比べても結果が一致している。

>>> x_mxfp4.dequantize()
tensor([-0.5000,  1.0000, -2.0000, -0.7500, -0.2500, -0.7500,  0.7500, -0.0000,
        -1.0000, -0.2500,  3.0000, -0.0000,  0.5000,  1.0000, -0.5000,  2.0000,
         1.0000, -1.0000,  0.7500, -0.5000,  0.5000, -1.5000,  2.0000,  0.0000,
         0.5000,  0.5000,  0.2500,  1.5000,  0.5000,  0.2500, -1.5000, -0.0000,
        -0.0000, -0.5000,  0.5000,  0.7500,  2.0000, -0.0000,  2.0000,  1.0000,
        -0.7500, -1.5000,  0.7500,  0.5000, -0.5000, -0.5000,  0.7500, -1.0000,
        -2.0000,  1.5000,  0.2500, -0.5000, -1.0000, -0.5000,  0.2500,  0.5000,
         1.0000,  0.7500, -1.0000, -0.2500, -0.2500, -1.5000,  1.5000,  2.0000],
       dtype=torch.bfloat16)

MXFP8 の場合は、要素がそのまま FP8 なので MXFP4 よりもやることが少ない。 ただし、FP8 はそのままだと演算できないので要素を FP32 にキャストしておく。

>>> interleaved_f32_scale = x_mxfp8_e4m3.scale.float().repeat_interleave(32, dim=-1)
>>> dequantized = interleaved_f32_scale * x_mxfp8_e4m3.qdata.to(torch.float32)
>>> dequantized
tensor([-5.6250e-01,  1.1250e+00, -1.7500e+00, -8.7500e-01, -1.8750e-01,
        -8.1250e-01,  7.5000e-01, -1.4648e-03, -1.0000e+00, -1.4062e-01,
         2.7500e+00, -1.1719e-01,  5.0000e-01,  9.3750e-01, -4.3750e-01,
         1.8750e+00,  1.2500e+00, -1.1250e+00,  6.8750e-01, -5.6250e-01,
         3.7500e-01, -1.2500e+00,  2.2500e+00,  4.8828e-03,  4.0625e-01,
         4.6875e-01,  1.4062e-01,  1.6250e+00,  4.6875e-01,  2.1875e-01,
        -1.3750e+00, -5.4688e-02, -9.3750e-02, -5.0000e-01,  5.6250e-01,
         8.1250e-01,  1.7500e+00, -5.0781e-02,  1.8750e+00,  1.1250e+00,
        -6.8750e-01, -1.3750e+00,  8.7500e-01,  5.0000e-01, -4.6875e-01,
        -3.7500e-01,  6.2500e-01, -1.1250e+00, -2.2500e+00,  1.5000e+00,
         1.7188e-01, -6.2500e-01, -1.2500e+00, -5.0000e-01,  3.4375e-01,
         5.6250e-01,  9.3750e-01,  6.2500e-01, -1.0000e+00, -2.8125e-01,
        -1.8750e-01, -1.5000e+00,  1.6250e+00,  2.2500e+00])

これで MXTensor#dequantize() の結果と揃う。

>>> x_mxfp8_e4m3.dequantize()
tensor([-5.6250e-01,  1.1250e+00, -1.7500e+00, -8.7500e-01, -1.8750e-01,
        -8.1250e-01,  7.5000e-01, -1.4648e-03, -1.0000e+00, -1.4062e-01,
         2.7500e+00, -1.1719e-01,  5.0000e-01,  9.3750e-01, -4.3750e-01,
         1.8750e+00,  1.2500e+00, -1.1250e+00,  6.8750e-01, -5.6250e-01,
         3.7500e-01, -1.2500e+00,  2.2500e+00,  4.8828e-03,  4.0625e-01,
         4.6875e-01,  1.4062e-01,  1.6250e+00,  4.6875e-01,  2.1875e-01,
        -1.3750e+00, -5.4688e-02, -9.3750e-02, -5.0000e-01,  5.6250e-01,
         8.1250e-01,  1.7500e+00, -5.0781e-02,  1.8750e+00,  1.1250e+00,
        -6.8750e-01, -1.3750e+00,  8.7500e-01,  5.0000e-01, -4.6875e-01,
        -3.7500e-01,  6.2500e-01, -1.1250e+00, -2.2500e+00,  1.5000e+00,
         1.7188e-01, -6.2500e-01, -1.2500e+00, -5.0000e-01,  3.4375e-01,
         5.6250e-01,  9.3750e-01,  6.2500e-01, -1.0000e+00, -2.8125e-01,
        -1.8750e-01, -1.5000e+00,  1.6250e+00,  2.2500e+00],
       dtype=torch.bfloat16)

めでたしめでたし。

まとめ

今回は TorchAO を使って MXFP4 と MXFP8 の振る舞いを確認した。


Python: TorchAO で NVFP4 の動作を確認する

NVIDIA が提案しているテンソルのデータ形式 (フォーマット) に NVFP4 がある。 NVFP4 を使うと、テンソルを表現するのに必要なビット数を小さく抑えられる。 なおかつ、テンソルの要素として FP32 や FP16/BF16 をそのまま使う場合と比べたときの性能の劣化を抑えられる。 また、NVIDIA の Blackwell アーキテクチャ以降ではハードウェアが NVFP4 をサポートしているため高速化も望める。

developer.nvidia.com

そんな NVFP4 について、ドキュメントを読むだけではイマイチ実感が沸かなかった。 そこで、TorchAO 1 を使って実際に動作を確認してみることにした。

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

$ sw_vers
ProductName:            macOS
ProductVersion:         26.6.2
BuildVersion:           25G83
$ uv self version
uv 0.12.9 (9f9286029 2026-09-01 aarch64-apple-darwin)
$ uv run python -V
Python 3.12.14
$ uv pip list | egrep "(torch|numpy)"
numpy             2.5.2
torch             2.13.0
torchao           0.18.0

もくじ

NVFP4 について

NVFP4 の仕様は、下記の Transformer Engine のドキュメントが分かりやすい。

docs.nvidia.com

NVFP4 は、あるテンソルを低精度 (FP4) の要素と二段階のスケール (FP8 / FP32) で表現したフォーマットになっている。 上記のドキュメントでは、下記の式がそれを表現している。

x = x_e2m1 * s_block * s_global

上記は、NVFP4 におけるテンソル x の表現が、下記のアダマール積になることを表している。

  • x_e2m1
    • FP4 (E2M1) の要素
  • s_block
    • 16 要素 (ブロック) ごとに用意される FP8 (E4M3) のスケール
  • s_global
    • 全要素 (グローバル) に対して用意される FP32 のスケール

通常のテンソルであれば、それぞれの要素が FP32 や FP16/BF16 になる。 これを単純に FP4 や FP8 にすると、仮数部や指数部のビット数が少ないためダイナミックレンジや精度が不足してしまう。 そこで、二段階のスケールを使って不足を補うのが NVFP4 の考え方になっている。

それぞれの要素が FP32 や FP16/BF16 のテンソルは、ある要素を表現するのに必要なビット数がそのまま 32 や 16 ビットになる。 一方で NVFP4 の場合は、ある要素を表現するのに必要なビット数は 4.5 ビットになる。

考え方を説明する。 まず、あるブロックの要素は 16 個で、それぞれが 4 ビットの FP4 (E2M1) になっている。 これを合計すると 16 x 4 = 64 (bit) になる。 そこに、ブロック単位で 8 ビットの FP8 (E4M3) がスケールとして 加わるため 64 + 8 = 72 (bit) になる。 これを要素数の 16 で割ると 72 / 16 = 4.5 (bit) で 4.5 ビットになる。 グローバルのスケールを計算に含めないのは、必要になるのがテンソル単位なので要素数が多いときに影響がほぼ無視できるため。

下準備

ここからは TorchAO を使う準備をしていく。

まずは uv で Python の仮想環境を初期化する。

$ uv venv --clear

仮想環境に必要なパッケージをインストールする。

$ uv pip install torch --torch-backend=auto
$ uv pip install torchao numpy

インストールが終わったら Python のインタプリタを起動する。

$ uv run python

NVFP4 に変換する

PyTorch をインポートして、NVFP4 に変換するベクトルを適当に用意する。 このとき、要素数は 16 の倍数になっている必要がある。 使用するデータ型は BF16 にした。

>>> import torch
>>> x = torch.randn(32, dtype=torch.bfloat16)
>>> x
tensor([ 0.3320,  1.2031,  0.1504,  0.2461,  1.2891,  0.7617,  0.8047,  1.4219,
         1.1719, -0.3184, -1.5859,  0.9414, -0.9180, -0.7539, -0.3223,  1.3047,
        -0.9609, -1.2734, -0.9453,  1.0156, -0.5039,  0.1611,  1.0312, -0.4180,
        -0.3516,  0.3613, -0.2754, -1.0391,  0.5195,  1.5000, -0.3125,  0.4082],
       dtype=torch.bfloat16)

TorchAO で NVFP4 に変換するときは行列かテンソルになっている必要があるようだ。 そのため、ここでは行列に変換する。

>>> x.shape
torch.Size([32])
>>> x_2d = x.reshape(1, -1)
>>> x_2d.shape
torch.Size([1, 32])

グローバルのスケールを計算するために絶対値が最も大きい要素の値を求める。

>>> tensor_amax = x_2d.abs().amax()
>>> tensor_amax
tensor(1.5859, dtype=torch.bfloat16)

求めた値から torchao.prototype.mx_formats.nvfp4_tensor.per_tensor_amax_to_scale() でグローバルのスケールを計算する。

>>> from torchao.prototype.mx_formats.nvfp4_tensor import per_tensor_amax_to_scale
>>> global_scale = per_tensor_amax_to_scale(tensor_amax)
>>> global_scale
tensor(0.0006)

ただし、この計算は単に E2M1 の最大値と E4M3 の最大値の積で元の値を割っているだけらしい。

>>> tensor_amax / (6 * 448)
tensor(0.0006, dtype=torch.bfloat16)

そして、元の行列とグローバルのスケールを使って肝心の NVFP4 を作る。 これには torchao.prototype.mx_formats.nvfp4_tensor.NVFP4Tensor.to_nvfp4() を使う。

>>> from torchao.prototype.mx_formats.nvfp4_tensor import NVFP4Tensor
>>> x_nvfp4 = NVFP4Tensor.to_nvfp4(x_2d, per_tensor_scale=global_scale)

これで NVFP4Tensor のオブジェクトが得られる。

>>> x_nvfp4
NVFP4Tensor: scale: tensor([[448., 416.]], dtype=torch.float8_e4m3fn), per_tensor_scale: 0.0005900065298192203, d: tensor([[ 99,  33,  86, 117, 166, 111, 221, 106, 254, 110,  28, 182,  59, 234,
         116,  59]], dtype=torch.uint8), d_hp: tensor([[ 0.3965,  1.0547,  0.1318,  0.2637,  1.0547,  0.7930,  0.7930,  1.5859,
          1.0547, -0.2637, -1.5859,  1.0547, -0.7930, -0.7930, -0.2637,  1.0547,
         -0.9805, -1.4688, -0.9805,  0.9805, -0.4902,  0.1226,  0.9805, -0.3672,
         -0.3672,  0.3672, -0.2451, -0.9805,  0.4902,  1.4688, -0.3672,  0.3672]],
       dtype=torch.bfloat16)
>> type(x_nvfp4)
<class 'torchao.prototype.mx_formats.nvfp4_tensor.NVFP4Tensor'>
>>> x_nvfp4.shape
torch.Size([1, 32])

元の Tensor と比べる

NVFP4Tensor#dequantize() メソッドを使うと Tensor オブジェクトを復元できる。

>>> x_nvfp4.dequantize()
tensor([[ 0.3965,  1.0547,  0.1318,  0.2637,  1.0547,  0.7930,  0.7930,  1.5859,
          1.0547, -0.2637, -1.5859,  1.0547, -0.7930, -0.7930, -0.2637,  1.0547,
         -0.9805, -1.4688, -0.9805,  0.9805, -0.4902,  0.1226,  0.9805, -0.3672,
         -0.3672,  0.3672, -0.2451, -0.9805,  0.4902,  1.4688, -0.3672,  0.3672]],
       dtype=torch.bfloat16)

元になった Tensor オブジェクトと見比べると、要素の値が微妙に異なっていることが確認できる。 この違いが、量子化 (低精度化) によって生じた誤差ということになる。

>>> x
tensor([ 0.3320,  1.2031,  0.1504,  0.2461,  1.2891,  0.7617,  0.8047,  1.4219,
         1.1719, -0.3184, -1.5859,  0.9414, -0.9180, -0.7539, -0.3223,  1.3047,
        -0.9609, -1.2734, -0.9453,  1.0156, -0.5039,  0.1611,  1.0312, -0.4180,
        -0.3516,  0.3613, -0.2754, -1.0391,  0.5195,  1.5000, -0.3125,  0.4082],
       dtype=torch.bfloat16)

今回はに関しては、最も大きい要素の誤差は 0.25 で、絶対平均誤差 (MAE) は 0.0674 だった。

>>> x_error = x - x_restored
>>> x_error
tensor([[-0.0645,  0.1484,  0.0186, -0.0176,  0.2344, -0.0312,  0.0117, -0.1641,
          0.1172, -0.0547,  0.0000, -0.1133, -0.1250,  0.0391, -0.0586,  0.2500,
          0.0195,  0.1953,  0.0352,  0.0352, -0.0137,  0.0386,  0.0508, -0.0508,
          0.0156, -0.0059, -0.0303, -0.0586,  0.0293,  0.0312,  0.0547,  0.0410]],
       dtype=torch.bfloat16)
>>> x_error.abs().max()
tensor(0.2500, dtype=torch.bfloat16)
>>> x_error.abs().mean()
tensor(0.0674, dtype=torch.bfloat16)

中身を見ていく

続いては NVFP4Tensor の中身を見ていこう。

まず、テンソル単位で用意されるグローバルなスケールは per_tensor_scale に入っている。

>>> x_nvfp4.per_tensor_scale
tensor(0.0006)

データ型は FP32 だ。

続いて 16 要素のブロック単位で用意されるスケールは scale に入っている。

>> x_nvfp4.scale
tensor([[448., 416.]], dtype=torch.float8_e4m3fn)

データ型は FP8 (E4M3) になっている。 今回使った Tensor には 32 の要素が入っていたのでブロックのスケールは 2 つある。

そして、要素の値は qdata に入っている。

>>> x_nvfp4.qdata
tensor([[ 99,  33,  86, 117, 166, 111, 221, 106, 254, 110,  28, 182,  59, 234,
         116,  59]], dtype=torch.uint8)

ちょっと分かりにくいのは、データ型が Unsigned INT8 になっているところ。 てっきり FP4 (E2M1) かと思いきや、そうではないらしい。

実は、この qdata は 4 ビットの E2M1 を 2 つ束ねて 8 ビットの Unsigned INT8 として表現している。 そのため要素数が半分になっている。

>>> x_nvfp4.qdata.shape
torch.Size([1, 16])

E2M1 は、要するに符号ビット (S)、指数ビット (E)、仮数ビット (M) が S E E M と並んだもの。 それを 8 ビットの中で上位 4 ビットと下位 4 ビットに分けて 2 つ並べて Unsigned INT8 として見せている。

手動で復元してみる

最後に、NVFP4Tensor#dequantize() メソッドを使わずに Tensor オブジェクトを復元してみよう。

まず、Unsigned INT8 で表現された要素は torchao.prototype.mx_formats.kernels.unpack_uint4() を使うことで上位ビットと下位ビットを取り出せる。

>>> from torchao.prototype.mx_formats.kernels import unpack_uint4
>>> unpack_uint4(x_nvfp4.qdata)
tensor([[ 3,  6,  1,  2,  6,  5,  5,  7,  6, 10, 15,  6, 13, 13, 10,  6, 14, 15,
         14,  6, 12,  1,  6, 11, 11,  3, 10, 14,  4,  7, 11,  3]],
       dtype=torch.uint8)
>>> unpack_uint4(x_nvfp4.qdata).shape
torch.Size([1, 32])

これで Tensor の形状が元に戻った。 ただし、データ型は Unsigned INT8 のままなので整数として解釈されている。

ここへさらに torchao.prototype.mx_formats.kernels.f4_unpacked_to_f32() にかけると、ビットを FP4 の浮動小数点として解釈できる。

>>> from torchao.prototype.mx_formats.kernels import f4_unpacked_to_f32
>>> unpacked_f32_qdata = f4_unpacked_to_f32(unpack_uint4(x_nvfp4.qdata))
>>> unpacked_f32_qdata
tensor([[ 1.5000,  4.0000,  0.5000,  1.0000,  4.0000,  3.0000,  3.0000,  6.0000,
          4.0000, -1.0000, -6.0000,  4.0000, -3.0000, -3.0000, -1.0000,  4.0000,
         -4.0000, -6.0000, -4.0000,  4.0000, -2.0000,  0.5000,  4.0000, -1.5000,
         -1.5000,  1.5000, -1.0000, -4.0000,  2.0000,  6.0000, -1.5000,  1.5000]])

次はブロック単位のスケールを準備する。 まずはスケールの要素を元の形状と揃うように複製する。

>>> interleaved_f32_scale = x_nvfp4.scale.float().repeat_interleave(16, dim=-1)
>>> interleaved_f32_scale
tensor([[448., 448., 448., 448., 448., 448., 448., 448., 448., 448., 448., 448.,
         448., 448., 448., 448., 416., 416., 416., 416., 416., 416., 416., 416.,
         416., 416., 416., 416., 416., 416., 416., 416.]])

テンソル単位のグローバルなスケールは、ブロードキャストの演算が効くので特にすることはない。

>>> global_f32_scale = x_nvfp4.per_tensor_scale
>>> global_f32_scale
tensor(0.0006)

あとは用意したオブジェクトをそれぞれかけるだけ。

>>> dequantized = global_f32_scale * interleaved_f32_scale * unpacked_f32_qdata
>>> dequantized
tensor([[ 0.3965,  1.0573,  0.1322,  0.2643,  1.0573,  0.7930,  0.7930,  1.5859,
          1.0573, -0.2643, -1.5859,  1.0573, -0.7930, -0.7930, -0.2643,  1.0573,
         -0.9818, -1.4727, -0.9818,  0.9818, -0.4909,  0.1227,  0.9818, -0.3682,
         -0.3682,  0.3682, -0.2454, -0.9818,  0.4909,  1.4727, -0.3682,  0.3682]])

これで NVFP4Tensor#dequantize() した内容と揃う・・・かと思いきや、微妙に数値が揃わないところがある。

>>> x_nvfp4.dequantize()
tensor([[ 0.3965,  1.0547,  0.1318,  0.2637,  1.0547,  0.7930,  0.7930,  1.5859,
          1.0547, -0.2637, -1.5859,  1.0547, -0.7930, -0.7930, -0.2637,  1.0547,
         -0.9805, -1.4688, -0.9805,  0.9805, -0.4902,  0.1226,  0.9805, -0.3672,
         -0.3672,  0.3672, -0.2451, -0.9805,  0.4902,  1.4688, -0.3672,  0.3672]],
       dtype=torch.bfloat16)

原因は FP32 で計算されたからなのでデータ型を BF16 にするだけで揃う。

>>> dequantized.bfloat16()
tensor([[ 0.3965,  1.0547,  0.1318,  0.2637,  1.0547,  0.7930,  0.7930,  1.5859,
          1.0547, -0.2637, -1.5859,  1.0547, -0.7930, -0.7930, -0.2637,  1.0547,
         -0.9805, -1.4688, -0.9805,  0.9805, -0.4902,  0.1226,  0.9805, -0.3672,
         -0.3672,  0.3672, -0.2451, -0.9805,  0.4902,  1.4688, -0.3672,  0.3672]],
       dtype=torch.bfloat16)

めでたしめでたし。

まとめ

今回は NVFP4 の動作を TorchAO を使って確認してみた。


Python: uv audit で脆弱性のある依存パッケージを見つける

Python でソフトウェアを開発するメリットのひとつは、パッケージのエコシステムが発達していることだろう。 自分でゼロから機能を実装する代わりに、PyPI などで公開されている多種多様なパッケージが利用できる。

一方で、ソフトウェアが依存しているパッケージに脆弱性が見つかることもある。 見つかった脆弱性の性質によっては、ソフトウェアを利用するシステムの侵害につながりかねない。 近年は新たに発見される脆弱性が増加し続けており 1、脆弱性が発見されてから悪用に至るまでの期間も短くなっている 2。

ソフトウェアが依存しているパッケージの既知の脆弱性に対処するには、まず脆弱性が見つかったことに気づく必要がある。 パッケージマネージャに pip を使っている場合には PyPA がメンテナンスしている pip-audit を使うのが定石のひとつだった。 ただ、pip-audit はパッケージマネージャに uv を使っているプロジェクトとの相性がそこまで良くない。

そんな折、uv のバージョン 0.10.12 から uv audit の機能がプレビュー版として追加された。 これにより、uv を使ったプロジェクトで脆弱性のある依存ハッケージを見つけやすくなった。 今回は、追加された uv audit の機能を紹介する。

docs.astral.sh

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

$ sw_vers
ProductName:        macOS
ProductVersion:     26.5
BuildVersion:       25F71
$ uv --version               
uv 0.11.16 (Homebrew 2026-05-21 aarch64-apple-darwin)

もくじ

下準備

下準備として、あらかじめ uv をインストールしておく。

$ brew install uv

そして、適当なディレクトリで uv を使ったプロジェクトを初期化する。

$ uv init --name helloworld --vcs none --bare .

これで pyproject.toml ができる。

$ cat pyproject.toml 
[project]
name = "helloworld"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []

uv audit コマンド

プロジェクトが依存しているパッケージから脆弱性を見つけるには uv audit コマンドを実行する。 この時点ではプロジェクトに依存パッケージが何もないので脆弱性は見つからない。

$ uv audit                                     
warning: `uv audit` is experimental and may change without warning. Pass `--preview-features audit` to disable this warning.
Resolved 1 package in 21ms
Found no known vulnerabilities and no adverse project statuses in 0 packages

なお、この記事を書いている時点で uv audit は実験的な機能という位置づけになっている。 そのため、実行するとその旨の警告が表示される。

警告を表示したくないときは --preview-features audit オプションをつけて実行しよう。

$ uv audit --preview-features audit
Resolved 1 package in 3ms
Found no known vulnerabilities and no adverse project statuses in 0 packages

脆弱性が見つからなかったときにはコマンドの返り値はゼロになる。

$ echo $?                          
0

脆弱性のあるパッケージを依存関係に追加する

では、次に脆弱性のあるパッケージを依存関係に追加してみよう。 試しに requests のバージョン 2.33 未満を依存関係に追加する。

$ uv add "requests<2.33"

バージョン 2.33 未満の requests には CVE-2026-25645 が見つかっている。

これで pyproject.toml に依存パッケージが追加された。

$ cat pyproject.toml
[project]
name = "helloworld"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
    "requests<2.33",
]
$ uv tree                           
Resolved 6 packages in 3ms
helloworld v0.1.0
└── requests v2.32.5
    ├── certifi v2026.4.22
    ├── charset-normalizer v3.4.7
    ├── idna v3.15
    └── urllib3 v2.7.0

この状態で uv audit コマンドを実行してみよう。

$ uv audit --preview-features audit
Resolved 6 packages in 3ms
Found 1 known vulnerability and no adverse project statuses in 5 packages

Vulnerabilities:

requests 2.32.5 has 1 known vulnerability:

- GHSA-gc5v-m9x4-r6x2: Requests has Insecure Temp File Reuse in its extract_zipped_paths() utility function

  Fixed in: 2.33.0

  Advisory information: https://nvd.nist.gov/vuln/detail/CVE-2026-25645

すると、依存パッケージの requests 2.32.5 に脆弱性が見つかっている旨の表示がされる。

コマンドの返り値も非ゼロになる。

$ echo $?                                                                
1

依存パッケージのバージョンを更新する

次は依存パッケージのバージョンを脆弱性が解決されたバージョンに更新してみよう。

今回はパッケージを追加する時点で、バージョンの制約を <2.33 と指定してしまっている。 そこで、更新する場合には改めてより新しいバージョンに依存するよう指定し直す必要がある。

$ uv add "requests>=2.33" --exclude-newer "1 week"

また、昨今のサプライチェーン攻撃のリスクを軽減したいときは --exclude-newer でクールダウン期間を設ける。

これで requests のバージョンが 2.33 以降に更新された。

$ uv tree
Resolved 6 packages in 3ms
helloworld v0.1.0
└── requests v2.34.2
    ├── certifi v2026.4.22
    ├── charset-normalizer v3.4.7
    ├── idna v3.15
    └── urllib3 v2.7.0

もし、バージョン制約がない状態であれば uv lock コマンドを使ってバージョンの Pinning を更新できる。

$ uv lock --upgrade-package requests --exclude-newer "1week"

あるいは、依存しているパッケージを一括で更新する場合には次のようにしても良い。

$ uv lock --upgrade --exclude-newer "1week"

バージョンを更新した上で、改めて uv audit コマンドを実行する。

$ uv audit --preview-features audit               
Resolved 6 packages in 3ms
Found no known vulnerabilities and no adverse project statuses in 5 packages
$ echo $?
0

バージョンを更新したことで、先ほどまで表示されていた脆弱性が表示されなくなった。

開発用の依存関係に脆弱性のあるパッケージを追加する

次は開発用グループに脆弱性のあるパッケージを追加して発見できるか試してみよう。 開発用グループは、ソフトウェアの動作時には不要だが開発時には利用したいパッケージを追加する。

一旦、先ほど追加した requests は依存関係から削除する。

$ uv remove requests

その上で、開発用グループ (dev) に urllib3 のバージョン 2.7 未満を追加する。 開発用グループに依存パッケージを追加するときは --dev か --group dev オプションを使用する。

$ uv add --dev "urllib3<2.7"

これで開発用グループに脆弱性のあるパッケージが追加された。 urllib3 のバージョン 2.7 未満には CVE-2026-44431 と CVE-2026-44432 が見つかっている。

$ cat pyproject.toml
[project]
name = "helloworld"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []

[dependency-groups]
dev = [
    "urllib3<2.7",
]

この状態で uv audit を実行してみよう。

$ uv audit --preview-features audit
Resolved 2 packages in 3ms
Found 4 known vulnerabilities and no adverse project statuses in 1 package

Vulnerabilities:

urllib3 2.6.3 has 4 known vulnerabilities:

- PYSEC-2026-142: No summary provided

  Fixed in: 2.7.0

  Advisory information: https://github.com/urllib3/urllib3/security/advisories/GHSA-mf9v-mfxr-j63j

- PYSEC-2026-141: No summary provided

  Fixed in: 2.7.0

  Advisory information: https://github.com/urllib3/urllib3/security/advisories/GHSA-qccp-gfcp-xxvc

- GHSA-mf9v-mfxr-j63j: urllib3: Decompression-bomb safeguards bypassed in parts of the streaming API

  Fixed in: 2.7.0

  Advisory information: https://nvd.nist.gov/vuln/detail/CVE-2026-44432

- GHSA-qccp-gfcp-xxvc: urllib3: Sensitive headers forwarded across origins in proxied low-level redirects

  Fixed in: 2.7.0

  Advisory information: https://nvd.nist.gov/vuln/detail/CVE-2026-44431

ちゃんと既知の脆弱性の情報が表示された。

まとめ

今回は uv のプロジェクトで依存しているパッケージに既知の脆弱性があるか調べる機能を紹介した。


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.toml か uv.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 を作成する。 ここで rules と rulesConfig を含むオブジェクトを export する。 rules がルールで、rulesConfig がルールを動作させる設定になる。 rulesConfig に true を指定するとデフォルトでルールが有効になる。

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-scripts と textlint-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.json の main と対応するように 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 という名前でクラスを定義して foo と bar という項目を追加している。 実際に使用するときはクラスをインスタンス化してメンバ変数を参照する。

# /// 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 を継承したクラスのクラス変数として追加すれば良い。 サンプルコードでは Settings に nested という名前で追加している。

# /// 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_config で env_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_file や env_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_args に True を指定する。

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