Claude CodeでPydantic v2へ移行する — bump-pydantic後の手直し手順
Pydantic v1からv2への移行を、変換ツールの実行・grepによる置換漏れの検出・テストによる挙動差の確認の順でClaude Codeに任せる手順です。validatorとConfigの手直しを中心に扱います。
Pydantic v1からv2への移行は、変換ツールを一度回せば終わる作業ではありません。ツールが書き換えるのは機械的に置き換えられる部分で、validatorの引数やConfigの設定名、Optionalの意味のように、書き方ではなく挙動が変わる箇所は人が確かめる側に残ります。
Claude Codeに任せるなら、役割を3つに分けると切り分けが保てます。この記事では、その順番と、各段階でClaudeに渡す指示、止めどころの決め方を説明します。
移行を3段階に分けて、確かめる手段も分ける
- 1
変換ツールを実行する
bump-pydanticで機械的に置き換えられる部分を先に片付けます。 - 2
grepで置換漏れを洗い出す
旧い書き方が残っていないかを、検索で機械的に探します。
- 3
テストで挙動差を確かめる
書き方は同じでも結果が変わる箇所を、移行前に固定したテストで押さえます。
変換を始める前に決めておく3つのこと
Pydantic v2は現行の本番リリースで、pip install -U pydanticで入ります。v1の機能はpydantic.v1という名前空間にも残っていて、from pydantic.v1 import BaseModelのように使えます。この逃げ道があるので、一括で全部を直そうとせず、モジュール単位で進める選択肢があります。
作業前に決めておくのは次の3点です。
- 作業ブランチを切り、依存を上げるコミットと、コードを直すコミットを分ける
- 移行前の時点でテストが通る状態を確認し、結果を控える
- 一度に触る範囲をパッケージ単位に区切る
2点目が特に効きます。移行前にすでに落ちているテストがあると、移行後の失敗がどちらの原因か分からなくなります。
Claude Codeには、いきなり編集させず、先に影響範囲を数えさせます。計画を立ててから編集に入る流れは、plan modeで作れます。
claude --permission-mode plan最初の依頼は、たとえば次のような形です。
このリポジトリでPydantic v1の書き方が残っている箇所を、
validator / Config / dict() / parse_obj などの種類別に数えて。
まだ編集はしないで、パッケージ別の件数表を出して。件数表が出れば、どのパッケージから手を付けるかを決められます。plan modeでは、Claudeはファイルを読んで計画を出すだけで、編集は承認後に始まります。
bump-pydanticを回して差分を読む
Pydanticには、コードを自動で書き換える変換ツールbump-pydanticがあります。ベータ版と位置づけられているので、結果は必ず差分で確認します。
pip install bump-pydantic
cd /path/to/repo_folder
bump-pydantic my_packagerepo_folderの直下にパッケージmy_packageがある構成なら、repo_folderに移ってからパッケージ名を渡します。実行後はgit diffで何が変わったかを見て、ここで一度コミットします。
ツールが何を変えたかは、実行してみないと分かりません。変換の網羅性に頼らず、次の節の検出を必ず通すのが安全です。
差分の読み込みは、そのままClaudeに任せられます。
bump-pydantic が書き換えた差分を読んで、変更の種類ごとに要約して。
機械的な置換と、挙動が変わりうる変更を分けて。「挙動が変わりうる」の判断は、次の節以降で扱う項目が基準になります。
置換漏れをgrepで洗い出す
ツールを通したあとに旧い書き方が残っていないかは、grepで機械的に探せます。公式の移行ガイドにある対応表から、検索語を作ります。
| v1の書き方 | v2での扱い |
|---|---|
@validator | v2での扱い@field_validatorに置き換える(非推奨) |
@root_validator | v2での扱い@model_validatorに置き換える(非推奨) |
class Config: | v2での扱いmodel_configという辞書の属性に置き換える |
.dict() | v2での扱い.model_dump() |
.json() | v2での扱い.model_dump_json() |
.parse_obj() | v2での扱い.model_validate() |
.copy() | v2での扱い.model_copy() |
.construct() | v2での扱い.model_construct() |
update_forward_refs() | v2での扱いmodel_rebuild() |
__fields__ | v2での扱いmodel_fields |
from_orm() | v2での扱いmodel_validate()(from_attributes=Trueが前提) |
parse_obj_as | v2での扱いTypeAdapter |
ConstrainedIntなどのConstrained*クラス | v2での扱い削除。Annotated[int, Field(ge=0)]に置き換える |
pydantic.BaseSettings | v2での扱いpydantic_settings.BaseSettings |
この表をそのままスクリプトにして、リポジトリに置きます。
#!/usr/bin/env bash
# scripts/check_pydantic_v1.sh
set -u
patterns=(
'@validator' '@root_validator' 'class Config\b'
'\.dict\(' '\.json\(' '\.parse_obj\(' '\.parse_raw\(' '\.copy\(' '\.construct\('
'update_forward_refs' '__fields__' 'from_orm' 'parse_obj_as'
'orm_mode' 'schema_extra' 'allow_population_by_field_name'
'each_item' 'always=True' 'pydantic import BaseSettings'
'Constrained(Int|Str|Float|List|Set|Bytes|Decimal|Date|FrozenSet)'
)
pattern=$(IFS='|'; echo "${patterns[*]}")
grep -rnE "$pattern" --include='*.py' "${1:-.}" && exit 1
echo "no v1 patterns"注意点が2つあります。
1つ目は誤検出です。.dict(や.copy(は、Pydanticと関係のないdict.copy()や別ライブラリのメソッドにも一致します。件数が0になることを目標にせず、ヒットした行をClaudeに「Pydanticモデルへの呼び出しか」と分類させます。
2つ目は、検索語に載らない箇所です。regex=をFieldに渡している、min_itemsやmax_itemsを使っている、といった引数の変更は、パターンを足せば拾えます。Fieldではregexがpattern、min_itemsとmax_itemsがmin_lengthとmax_lengthに変わっています。
Constrained*クラスは、importした時点でエラーになります。サブクラスで制約を書いていた型は、Annotatedの型エイリアスに置き換えます。
# v1
class MyInt(ConstrainedInt):
ge = 0
# v2
MyInt = Annotated[int, Field(ge=0)]ConstrainedStrの代わりにはStringConstraintsを使います。
ヒットした行の分類と修正は、スクリプトの実行とセットで頼みます。
scripts/check_pydantic_v1.sh を実行して、ヒットした行をモデル由来かどうかで分類して。
モデル由来のものだけ、対応するv2のAPIに直して。
直したら、もう一度スクリプトを実行して結果を見せて。「直したらもう一度実行して結果を見せる」という往復が、置換漏れを減らします。Claudeがスクリプトの出力を読んで次の編集を決める形になるからです。
validatorとConfigを手で直す
grepで見つかるのは書き方の残りです。validatorとConfigは、置き換えるときに設計の判断が要る箇所なので、手直しの中心になります。
validatorの引数を見る
@validatorは@field_validatorに置き換えますが、機械的な改名では済まない点があります。
each_item引数がなくなった。コンテナの要素に対する検証は、型引数に注釈を付ける。list[Annotated[int, Field(ge=0)]]のように書く- 関数の引数に
fieldやconfigを取っていた場合、@field_validatorではinfo(ValidationInfo)を受け取る。設定はinfo.config、フィールド名はinfo.field_nameから得る always=Trueは使えなくなった。v1と同じく既定値にも検証を掛けたいなら、Field(validate_default=True)を指定する。その場合、型の標準検証も既定値に掛かる@root_validatorは@model_validatorに置き換え、許される引数の形が変わっている。非推奨の@root_validatorを使い続ける場合も、skip_on_failure=Trueの明示が必須で、既定のFalseでは動かない。さらに、validate_assignmentがTrueのモデルでは、代入時に@model_validatorがdictではなくモデルのインスタンスを受け取ることがある
each_itemとalwaysは検索語に入れてあるので、grepで拾えます。ヒットしたvalidatorは、Claudeに1つずつ読ませ、上の4点に当てはまるかを判定させます。
# v1
class Order(BaseModel):
quantities: list[int]
@validator("quantities", each_item=True)
def non_negative(cls, v):
if v < 0:
raise ValueError("負の数は指定できません")
return v# v2(公式の説明に沿った書き方の一例)
from typing import Annotated
from pydantic import BaseModel, Field
class Order(BaseModel):
quantities: list[Annotated[int, Field(ge=0)]]この例は、検証ロジックが単純な範囲チェックなのでFieldの制約に置き換えられます。メッセージを独自に出したい場合は、@field_validatorのままにするか、Annotatedに検証関数を載せる方法もあります。どちらを選ぶかは、エラーメッセージをAPIの利用者に見せているかどうかで変わります。
Configの設定名を直す
class Configはmodel_configに置き換えます。設定名にも改名があります。
| v1の設定名 | v2の設定名 |
|---|---|
orm_mode | v2の設定名from_attributes |
allow_population_by_field_name | v2の設定名populate_by_name |
schema_extra | v2の設定名json_schema_extra |
validate_all | v2の設定名validate_default |
anystr_strip_whitespace | v2の設定名str_strip_whitespace |
allow_mutation | v2の設定名削除。frozenを逆の値で使う |
最後の行は向きが逆になる点に注意が必要です。allow_mutation = Falseはfrozen=Trueに相当します。
# v1
class User(BaseModel):
id: int
class Config:
orm_mode = True
allow_mutation = False# v2(公式の対応表に沿った書き方の一例)
from pydantic import BaseModel, ConfigDict
class User(BaseModel):
model_config = ConfigDict(from_attributes=True, frozen=True)
id: intmodel_configは継承されます。共通の設定を基底クラスに置くと、モデルごとのConfigを減らせます。
grepに映らない挙動の変化をテストで押さえる
書き方を直しても、同じ入力に同じ結果が返るとは限りません。公式の移行ガイドが挙げる、挙動が変わる項目のうち、APIレスポンスや永続化に影響しやすいものを選びます。
| 変化 | 影響が出やすい場所 |
|---|---|
Optional[str]は必須になる(既定値Noneは付かない) | 影響が出やすい場所リクエストの任意項目、設定ファイルの読み込み |
int型に小数部のあるfloatを渡すとエラーになる | 影響が出やすい場所外部APIからの数値、CSVの取り込み |
TypeErrorがvalidator内で起きてもValidationErrorに変換されない | 影響が出やすい場所validator内の型エラー、エラーハンドラ |
model_dump_json()の出力は詰めた形式で、json.dumps()と完全一致しない | 影響が出やすい場所JSONを文字列で比較するテスト、スナップショット |
モデルはdictと等しくならない | 影響が出やすい場所assert model == {...}の形のテスト |
どれもgrepでは見つかりません。ただし、既存のテストが十分なら、落ちることで気づけます。足りないところは、テストの追加をClaudeに依頼して補います。
この移行で挙動が変わる項目として、Optionalの必須化、float→intの変換、
モデルとdictの等価比較がある。これらの影響を受けそうなモデルを探して、
移行前後で結果が変わる入力を使ったテストを追加して。
追加したテストは、まずv1のままの状態で通ることを確認して。「v1のままの状態で通ることを確認する」のが要点です。移行前の挙動を固定するテストが先にあれば、移行後に落ちたとき、変わったのは挙動だと断定できます。
落とし穴
Optionalの項目が、省略するとエラーになる
f: Optional[str]のような宣言は、v1では暗黙に既定値Noneが付き、省略できました。v2では省略するとバリデーションエラーになります。省略可能にしたい場合は= Noneを明示します。
検索語に入れにくい変更も一覧にしておく
validatorとConfig以外にも、モデルの定義まわりには破壊的な変更があります。件数は少ないので、検出スクリプトとは別に、Claudeへ「このリストに当たる箇所があるか」と読ませる形が向きます。
Fieldの追加キーワード: JSON Schemaに載せる追加情報は、任意のキーワード引数ではなくjson_schema_extraに辞書で渡す。constとunique_itemsはなくなり、finalはtyping.Finalで表すaliasの戻り値: エイリアス未設定のとき、v1ではフィールド名を返したが、v2ではNoneを返す。field.aliasを文字列として連結しているコードで落ちる- コンテナ内の制約:
list[str] = Field(pattern=".*")のように書いても、要素の一つ一つには効かない。list[Annotated[str, Field(pattern=".*")]]と書く - ジェネリックモデル:
GenericModelは廃止され、class M(BaseModel, Generic[T])と書く。パラメータ付きジェネリックをisinstanceの検査に使うことは勧められていない - Pydanticのdataclass: フィールドとして使うとき、タプルを入力として受け付けなくなった。
__post_init__は検証の後に呼ばれ、extra='allow'は使えない - URL型:
UrlとDsnの型はstrを継承しなくなった。パスがないURLには、検証後に末尾のスラッシュが付く - mypyの設定: v2のmypyプラグインは
pydantic.mypy。v1のモデルが混在する間は、pydantic.v1.mypyも並べて指定する
URL型の変更は、文字列として比較しているテストを静かに壊します。str(url) == "https://example.com"のような比較があれば、末尾のスラッシュを疑います。
探索だけを頼む場合は、次のように伝えます。
次の7項目に当たるコードを、リポジトリ全体から探して、ファイルと行番号で列挙して。
編集はしないで。見つからなかった項目は「該当なし」と書いて。「編集はしないで」と「該当なしと書いて」を入れておくと、探索と修正を分けられます。探索の結果を人が見てから、修正を指示する流れになります。
CLAUDE.mdに規約を残して後戻りを防ぐ
移行の途中で、Claudeが別のファイルを編集するときにv1の書き方を持ち込むことがあります。それを防ぐために、移行中の規約をCLAUDE.mdに書きます。
# Pydanticのv2移行(作業中)
- 新規・修正コードはPydantic v2の書き方で書く
(field_validator / model_validator / model_config / model_dump / model_validate)
- validatorで field や config 引数を使う場合は info 引数に直す
- 編集のたびに scripts/check_pydantic_v1.sh を実行し、モデル由来のヒットを0にする
- 移行済みのパッケージは pytest をパッケージ単位で実行してから次へ進む検出スクリプトを自動で走らせたいなら、PostToolUseフックでEdit|Writeに紐づける方法があります。settings.jsonのフック設定に、matcherとしてEdit|Write、実行コマンドとしてscripts/check_pydantic_v1.shを指定すると、編集のたびに検出が走ります。テストの自動実行まで広げる設定はhooksでテストを自動実行する手順で扱っています。
v1のまま残す範囲を決める
すべてを一度に移さなくても、移行は成立します。
- 変換が重いモジュールは
from pydantic.v1 import BaseModelで一時的にv1のまま動かす pydantic>=1.10.17であれば、v1環境でもpydantic.v1の名前空間を使える。依存を上げる前に、インポートだけ先に書き換えておける
注意が必要なのは、v1のモデルとv2のモデルを混ぜられない点です。ジェネリックモデルの型パラメータにv1のモデルを置くことはできません。境界をまたぐ型は、先にv2側へ寄せます。
BaseSettingsは別パッケージのpydantic-settingsに移っています。環境変数から設定を読むモジュールでは、pip install pydantic-settingsを依存に足してインポートを直します。これはgrepの検索語に入れてあります。
FastAPIのバックエンドをClaude Codeで構築する手順のようにPydanticモデルがAPIの入出力を担う構成では、response_modelの出力が変わっていないかを、移行後のAPIテストで確認します。
同じ「変換ツール後の手直し」という形の移行は、ほかにもあります。設定ファイルの移行を扱ったESLintのflat config移行や、テストの置き換えを扱ったJestからVitestへの移行でも、ツールの出力を検査で確かめる流れは共通です。
まとめ
変換ツールは最初の一歩にすぎません。移行の品質を決めるのは、そのあとの検出と確認の仕組みです。grepは書き方の残りを、テストは挙動の変化を拾います。この2つを分けて持つと、Claude Codeが直した箇所の正しさを、人が全行読まずに確かめられます。
最初に手を付けるなら、移行前のテストを固定し、検出スクリプトをリポジトリに置くところからが無理のない出発点です。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。