Skip to content

Pydantic

Source code in OpAgentsOlympus/practice/pydantic.py
OpAgentsOlympus/practice/pydantic.py
from datetime import datetime, date
from decimal import Decimal
from typing import Optional, Union, Annotated, Any
from enum import Enum

from pydantic import (
    BaseModel,
    Field,
    ConfigDict,
    field_validator,
    model_validator,
    field_serializer,
    model_serializer,
    ValidationError,
    PositiveInt,
    EmailStr,
    HttpUrl,
    SecretStr
)

class UserRole(str, Enum):
    ADMIN = "admin"
    USER = "user"
    GUEST = "guest"

class Address(BaseModel):
    model_config = ConfigDict(
        str_strip_whitespace=True,  # Automatically remove leading/trailing spaces from string inputs
        validate_assignment=True,   # Validate field values when attributes are set after model creation
        extra='forbid'              # Raise ValidationError when unknown fields are provided in input data
    )

    street: str = Field(
        min_length=1, max_length=100
    )  # Ensure street is between 1-100 characters
    city: str = Field(
        min_length=1, max_length=50
    )  # Ensure city is between 1-50 characters
    postal_code: str = Field(
        pattern=r'^\d{5}(-\d{4})?$'
    )  # US postal code validation (5 digits or 5+4 format)
    country: str = Field(
        default="USA", frozen=True
    )  # Default "USA", immutable after creation

class User(BaseModel):
    model_config = ConfigDict(
        validate_assignment=True,      # Validate new values when fields are modified after instantiation
        validate_default=True,         # Validate default values on every model creation for safety
        strict=False,                  # Enable lax mode allowing type coercion (string "123" -> int 123)
        coerce_numbers_to_str=True,    # Allow number-to-string conversion (123 -> "123")
        extra='allow',                 # Permit additional fields not defined in model schema
        str_strip_whitespace=True,     # Automatically trim whitespace from all string inputs
        str_to_lower=False,            # Preserve original string casing (set True for lowercase conversion)
        str_max_length=1000,           # Global string length limit for all string fields
        ser_json_timedelta='iso8601',  # Serialize timedelta objects in ISO8601 format
        ser_json_bytes='base64',       # Serialize bytes objects as base64 encoded strings
        title="User Model",            # Human-readable model name in generated schemas
        use_attribute_docstrings=True, # Include docstrings in generated JSON schema
        frozen=False,                  # Allow field modification after model creation (True makes immutable)
        populate_by_name=True,         # Accept both field names and aliases during validation
        from_attributes=True           # Create models from objects with attributes (replaces orm_mode)
    )

    id: PositiveInt = Field(
        description="Unique user identifier",
        examples=[1, 42, 123],
        gt=0, le=999999
    )  # PositiveInt type with constraints

    name: str = Field(
        min_length=2, max_length=50,
        description="User's full name",
        alias="full_name"
    )  # String field with length constraints and alias

    email: EmailStr = Field(
        description="User's email address",
        validation_alias="email_address"
    )  # EmailStr validation with input-only alias

    password: SecretStr = Field(
        min_length=8,
        description="User password (will be hidden in output)"
    )  # SecretStr field with min_length=8

    role: UserRole = Field(
        default=UserRole.USER
    )  # Enum field with default value

    is_active: bool = Field(
        default=True
    )  # Boolean field with default=True

    age: Optional[int] = Field(
        default=None, ge=0, le=150,
        description="User's age in years"
    )  # Optional integer with age constraints

    balance: Decimal = Field(
        default=Decimal('0.00'),
        max_digits=10, decimal_places=2,
        description="Account balance"
    )  # Decimal field with precision constraints

    created_at: datetime = Field(
        default_factory=datetime.now
    )  # Datetime field with factory function

    birth_date: Optional[date] = None  # Optional date field for birth date tracking

    website: Optional[HttpUrl] = None  # Optional HttpUrl field for website validation

    address: Optional[Address] = None  # Optional nested model field for address composition

    tags: list[str] = Field(
        default_factory=list,
        max_length=10,
        description="User tags"
    )  # List field with max_length=10 constraint

    metadata: dict[str, Any] = Field(
        default_factory=dict,
        description="Additional user metadata"
    )  # Flexible metadata dict field

    phone: Union[str, int] = Field(
        description="Phone number as string or int",
        union_mode='left_to_right'
    )  # Union field for phone number flexibility

    score: Annotated[float, Field(ge=0.0, le=100.0, multiple_of=0.1)] = 0.0  # Float field with range and precision constraints

    @field_validator('name')
    @classmethod
    def validate_name(cls, v: str) -> str:
        if not v.replace(' ', '').isalpha():
            raise ValueError('Name must contain only letters and spaces')
        return v.title()  # Auto-title case

    @field_validator('tags')
    @classmethod
    def validate_tags(cls, v: list[str]) -> list[str]:
        return [tag.lower().strip() for tag in v if tag.strip()]  # Clean and normalize tag list

    @model_validator(mode='after')
    def validate_age_birth_date(self) -> 'User':
        if self.age is not None and self.birth_date is not None:
            calculated_age = (date.today() - self.birth_date).days // 365
            if abs(calculated_age - self.age) > 1:
                raise ValueError('Age and birth date do not match')
        return self  # Cross-field  validation between age and birth_date

    @field_serializer('password')
    def serialize_password(self, value: SecretStr) -> str:
        return "***HIDDEN***"  # Hide password content in output

    @model_serializer(mode='wrap')
    def serialize_model(self, serializer, info):
        data = serializer(self)
        data['display_name'] = f"{self.name} ({self.role.value})"
        return data  # Inject computed display_name field in output

if __name__ == "__main__":
    user_data = {
        "id": "123",  # String to int coercion
        "full_name": "  john doe  ",  # Whitespace stripping + title casing
        "email_address": "john@example.com",  # Email validation
        "password": "secretpassword123",  # Secret string handling
        "age": "25",  # String to int coercion
        "balance": "1234.56",  # String to Decimal coercion
        "birth_date": "1998-01-01",  # String to date coercion
        "website": "https://johndoe.com",  # String to HttpUrl coercion
        "phone": 1234567890,  # Union type handling
        "tags": ["  Developer  ", "Python", "  "],  # List cleaning
        "score": "85.5",  # String to float coercion
        "metadata": {"department": "engineering", "level": 3},  # Dict handling
        "extra_field": "This will be allowed due to extra='allow'"  # Extra field allowance
    }

    try:
        user = User(**user_data)
        print("User created successfully!")
        print(f"Serialized: {user.model_dump()}")
        print(f"JSON: {user.model_dump_json(indent=2)}")

    except ValidationError as e:
        print(f"Validation error: {e}")