見出し画像

Pydantic V2: Essentials: 特殊な型 (セクション5/13)

  • 特殊な型の利点:Pydanticが提供する PositiveInt・conlist・UUID4・PastDatetime などを使うと、カスタムバリデータを書かずとも厳密な検証が可能になる。

  • 代表例と注意点:正の整数(PositiveInt)、リスト要素数制限(conlist)、UUIDのデフォルト生成(default_factory)、日時のタイムゾーン扱い(Aware/Naive)などに注意が必要。

  • ネットワーク型:EmailStr・AnyUrl・IPvAnyAddress などが入力データのフォーマットを自動的にパース・検証し、コードの簡素化と信頼性向上に寄与する。

こんにちは! ここではSection 5: Specialized Pydantic Typesに焦点を当て、基本的なフィールド検証やエイリアス設定から、ドメイン固有の検証を手間なく行うためのツールを見ていきます。Pydantic V2 は、独自のカスタム・バリデータを書かなくても、「正の整数」や「妥当なURL」などを簡単に保証できる組み込み型を数多く提供しています。以下では、それらがどのように機能し、どのような場面で活躍し、注意点は何かを紹介します。


概要:なぜ「特殊な型」を使うのか?

Pydantic は、標準的な Python の型システムだけでは対処しきれない厳密なバリデーションを実現するため、ビルトインあるいは「制約付き」型を多数用意しています。例えば、以下のようにカスタム・バリデータを書く代わりに:

class Circle(BaseModel):
    center: tuple[int, int]
    radius: int

    @field_validator("radius")
    def positive_radius(cls, v):
        if v <= 0:
            raise ValueError("Radius must be > 0")
        return v

…次のように書けば済むわけです:

from pydantic import PositiveInt

class Circle(BaseModel):
    center: tuple[int, int]
    radius: PositiveInt

これだけで、格段にシンプルですね! 以下では代表的な専門型を見ていきます:

  1. PositiveInt, NegativeInt, NonNegativeInt など

  2. 制約付きリスト(`conlist`)

  3. UUID 型(例:`UUID4`)

  4. 日付/日時の制約(`PastDate`, `PastDatetime`, `NaiveDatetime`など)

  5. ネットワーク型(`EmailStr`, `AnyUrl`, `IPvAnyAddress`など)


1. PositiveIntとその仲間

`PositiveInt` は厳密に正の整数(つまり `> 0`)を強制します。類似の派生型として:

  • `NegativeInt`:0より小さい

  • `NonNegativeInt`:0以上

  • `NonPositiveInt`:0以下

from pydantic import BaseModel, PositiveInt

class Circle(BaseModel):
    center: tuple[int, int] = (0, 0)
    radius: PositiveInt = 1  # 0より大きい必要がある

Circle(center=(10, 10), radius=5)   # 有効
Circle(center=(1, 2))              # radius=1(デフォルト値)を使う

もし `radius` に `0` を指定したり、`center` に浮動小数を与えたりすると、次のようなエラーが起こります:

radius
  Input should be greater than 0 [type=greater_than, ...]
center.0
  Input should be a valid integer [type=int_from_float, ...]

仕組みの詳細

Pydantic は内部的に「`radius > 0`」というメタデータを設定します。カスタム・バリデータで同じことを実装する代わりに、`PositiveInt` を使えば短く表現できるわけです。


2. 制約付きリスト(`conlist`)

リスト内の要素数を「2~3個に限定する」など、正確な範囲の要素数を求めたいことがあります。その場合、`conlist`(constrained list)を使います。

from pydantic import BaseModel, conlist, PositiveInt

class Sphere(BaseModel):
    # centerはint型のリストで、要素数は2~3個
    center: conlist(int, min_length=2, max_length=3) = [0, 0]
    radius: PositiveInt = 1
  • `min_length=2` → 最低2要素

  • `max_length=3` → 最大3要素

検証例

  • `[1, 2]` → OK (2次元の中心)

  • `[1, 2, 3]` → OK (3次元の中心)

  • `[1]` → too_short エラー

  • `[1, 2, 3, 4]` → too_long エラー

注意:Pydantic v1 で存在した `unique_items` は v2 で削除されました。リストで重複を許さないようにする場合、カスタム・バリデータを作るか、順序を気にしないなら `set[...]` を使うしかありません。


3. UUID 型

`UUID4` は「バージョン4 UUID」(乱数ベース)を保証します。Pydantic v2 には `UUID1`, `UUID3`, `UUID5` などもあり、受け取った文字列(またはPythonのUUIDオブジェクト)をデコードして適切に保持します。

from pydantic import BaseModel, Field, UUID4

class Person(BaseModel):
    id: UUID4

from uuid import uuid4
p = Person(id=uuid4())          # 直接UUIDオブジェクト
p2 = Person(id="ea123456-...")  # 文字列でもOK

Default設定時の注意

class Person(BaseModel):
    # こう書くと、一度だけ評価されて同じUUIDが使われ続ける
    id: UUID4 = uuid4()

この書き方だと、すべてのインスタンスが同じUUIDを使ってしまいます。代わりに**`default_factory=uuid4`**を使いましょう:

class Person(BaseModel):
    id: UUID4 = Field(default_factory=uuid4)

これなら、インスタンス生成ごとに `uuid4()` が呼ばれます。


4. 日付/日時の制約

Pydantic は以下のような型を備えています:

  • `PastDate` / `FutureDate`:日付が過去/未来か

  • `PastDatetime` / `FutureDatetime`:日時が過去/未来か

  • `AwareDatetime` / `NaiveDatetime`:タイムゾーン情報を持つか/持たないかを強制

from pydantic import BaseModel, PastDatetime, NaiveDatetime
from datetime import datetime, timedelta
import pytz

class Event(BaseModel):
    dt: PastDatetime  # "現在時刻より前" であることをローカル時間でチェック
local_1h_ago = datetime.now() - timedelta(hours=1)
Event(dt=local_1h_ago)  # OK
Event(dt=datetime.now()) # 未来とみなされるならエラー

Aware vs. Naive

class Model(BaseModel):
    dt: NaiveDatetime

ここでは、タイムゾーン情報が付与されていると次のエラーになる:

Input should not have timezone info [type=timezone_naive]
class Model(BaseModel):
    dt: AwareDatetime

こちらは逆にtzinfoを持たないとエラーです:

Input should have timezone info [type=timezone_aware]

たいていは「すべてUTCに変換し、内部ではナイーブな状態で保持し、最終的に表示時にtzinfoを付ける」などのカスタム処理を行う場合が多いでしょう。これはカスタムバリデータやカスタムシリアライザで実装できます。


5. ネットワーク型:URL、メール、IPアドレス

Emails

`EmailStr` は一般的なメールアドレス形式をチェックします。`NameEmail` は「`名前 <[email protected]>`」形式を扱うための型です。

from pydantic import BaseModel, EmailStr, NameEmail

class Contact(BaseModel):
    email: EmailStr

contact = Contact(email="[email protected]")  # OK
Contact(email="bad-email")  # ValidationError

# 名前+メールの複合
class ContactName(BaseModel):
    email: NameEmail

c = ContactName(email="John Smith <[email protected]>")
print(c.email.name)   # "John Smith"
print(c.email.email)  # "[email protected]"

`NameEmail` はJSON化すると `"John Smith <[email protected]>"` のような文字列表現になります。

補足:メール検証には email-validator パッケージを別途インストールする必要があります。

URLs

`AnyUrl` はスキーム、ホスト、ポート、パス、クエリ、認証情報なども自動的に解析してくれます:

from pydantic import AnyUrl

url = AnyUrl("https://www.example.com/search?q=python")
print(url.scheme)  # "https"
print(url.host)    # "www.example.com"
print(url.path)    # "/search"
print(url.query)   # "q=python"
print(url.port)    # 443

`HttpUrl`, `FtpUrl` など特化版もあり、接頭辞(スキーム)を制限できます。データベース接続文字列(DSN)用に `PostgresDsn`, `MySQLDsn` なども存在。

末尾スラッシュに注意

ドメインだけのURL(`"https://api.example.com"`など)を Pydantic v2 が受け取ると、勝手に `"/"` を補う仕様になりました。連結でパスを作るときに二重スラッシュにならないよう注意が必要。

from pydantic import HttpUrl

class ExternalAPI(BaseModel):
    root_url: HttpUrl

api = ExternalAPI(root_url="https://api.myserver.com")
api.root_url  # "https://api.myserver.com/"

endpoint = f"{api.root_url}/users"  # -> "https://api.myserver.com//users"

二重スラッシュを避けるなら `str(api.root_url).rstrip("/") + "/users"` のようにする手があります。

IPアドレス

`IPvAnyAddress` はIPv4/6のアドレス形式を検証:

from pydantic import BaseModel, IPvAnyAddress

class Connection(BaseModel):
    ip: IPvAnyAddress

conn = Connection(ip="127.0.0.1")
conn.ip.version    # 4
conn.ip.is_loopback  # True

conn2 = Connection(ip="::1")
conn2.ip.version   # 6
conn2.ip.exploded  # "0000:0000:0000:0000:0000:0000:0000:0001"

IPv4は`IPv4Address`、IPv6は`IPv6Address`オブジェクトとして扱われ、ループバック判定や拡張表記への変換が可能です。

もしこれらネットワーク系の検証が必要ないなら、無理に覚える必要はありません。必要になった際に思い出しましょう!


セクションのプロジェクト:Automobileモデルの拡張

下記は Section 4 の最終プロジェクトを、`UUID4` という専門型を追加する形で拡張したものです。新しいフィールド:

  • モデルの最初に追加

  • エイリアスを `"id"` にする

  • 未指定の場合は `None`(省略可)

  • Pydanticの `UUID4` でバリデーション

コード

from datetime import date
from enum import Enum
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_serializer, UUID4
from pydantic.alias_generators import to_camel

class AutomobileType(Enum):
    sedan = "Sedan"
    coupe = "Coupe"
    convertible = "Convertible"
    suv = "SUV"
    truck = "Truck"

class Automobile(BaseModel):
    model_config = ConfigDict(
        extra="forbid",
        str_strip_whitespace=True,
        validate_default=True,
        validate_assignment=True,
        alias_generator=to_camel,
    )

    id_: UUID4 | None = Field(alias="id", default=None)

    manufacturer: str
    series_name: str
    type_: AutomobileType = Field(alias="type")
    is_electric: bool = False
    manufactured_date: date = Field(validation_alias="completionDate")
    base_msrp_usd: float = Field(validation_alias="msrpUSD", serialization_alias="baseMSRPUSD")
    vin: str
    number_of_doors: int = Field(default=4, validation_alias="doors")
    registration_country: str | None = None
    license_plate: str | None = None

    @field_serializer("manufactured_date", when_used="json-unless-none")
    def serialize_date(self, value: date) -> str:
        return value.strftime("%Y/%m/%d")

テスト

# "id"付きデータ
data = {
    "id": "c4e60f4a-3c7f-4da5-9b3f-07aee50b23e7",
    "manufacturer": "BMW",
    "seriesName": "M4",
    "type": "Convertible",
    "isElectric": False,
    "completionDate": "2023-01-01",
    "msrpUSD": 93300,
    "vin": "1234567890",
    "doors": 2,
    "registrationCountry": "France",
    "licensePlate": "AAA-BBB"
}
auto = Automobile(**data)
print(auto.id_)  # UUID('c4e60f4a-3c7f-4da5-9b3f-07aee50b23e7')
print(auto.model_dump(by_alias=True))

エイリアス(`by_alias=True`)を使ってdictにすると、例えば:

{
  'id': UUID('c4e60f4a-3c7f-4da5-9b3f-07aee50b23e7'),
  'manufacturer': 'BMW',
  'seriesName': 'M4',
  'type': <AutomobileType.convertible: 'Convertible'>,
  'isElectric': False,
  'manufacturedDate': datetime.date(2023, 1, 1),
  'baseMSRPUSD': 93300.0,
  'vin': '1234567890',
  'numberOfDoors': 2,
  'registrationCountry': 'France',
  'licensePlate': 'AAA-BBB'
}

`"id"` が与えられていない場合:

data_no_id = {
  "manufacturer": "BMW",
  "seriesName": "M4",
  "type": "Convertible",
  "isElectric": False,
  "completionDate": "2023-01-01",
  "msrpUSD": 93300,
  "vin": "1234567890",
  "doors": 2,
  "registrationCountry": "France",
  "licensePlate": "AAA-BBB"
}
auto_no_id = Automobile(**data_no_id)
auto_no_id.id_  # None

シリアライズ時も `{'id': None, ...}` が出力されます。


まとめ

Pydanticの専門型を使うと、(日時やネットワーク関連、ユニークな制約など)深い領域でのバリデーションを簡単に行えます。「ほぼカスタムバリデータ無し」ですませることも多いでしょう。主なポイント:

  • Positive/Negative/Nonnegative:数値の符号を強制

  • 制約付きコンテナ:`conlist()`でリストの要素数の範囲を指定

  • UUID4:ユニークキーに便利。`default_factory`で乱数生成を都度行う

  • 日付型:`PastDatetime` や `AwareDatetime` などで詳細な時間制約

  • ネットワーク:`EmailStr`, `AnyUrl`, `IPvAnyAddress` がフォーマット検証とパースを同時にこなす

いずれも、専用の型があればコードがずっとシンプルになります。Pydanticドキュメントを一度確認し、カスタムバリデータを書く前に**「こういう型はもう存在するか?」**を確かめるとよいでしょう。次に同じような要件が出たときは、Pydanticの専門型を積極的に利用してみてください!

「超温和なパイソン」へ

いいなと思ったら応援しよう!