Skip to content

Conversation

@wangshangsam
Copy link

@wangshangsam wangshangsam commented Nov 7, 2025

NOTE: I vibe-coded this with cursor agents, but I did review/test the changes to make sure that the generated code does make sense.

Summary

This PR fixes critical limitations in pydantic-typer that prevented proper handling of Pydantic validators and boolean fields. This fix enables full support for:

  • Annotated validators: BeforeValidator, AfterValidator, PlainValidator, WrapValidator
  • Class-level decorators: @field_validator, @model_validator
  • Advanced Pydantic types: HttpUrl, EmailStr, IPv4Address, SecretStr, etc.
  • Boolean fields with explicit values: Support --flag False instead of requiring --no-flag syntax

Problems

Problem 1: Validators Were Lost

When using custom validators on Pydantic model fields, the validators were not preserved in the CLI parameter processing. This caused type validation to fail for types that needed custom parsing logic.

Example That Failed Before

from datetime import timedelta
from typing import Annotated, Any
from pydantic import BaseModel, BeforeValidator, Field
from pydantic_typer import run

def parse_timedelta(value: Any) -> timedelta:
    """Parse timedelta from seconds or ISO 8601 format."""
    if isinstance(value, (int, float)):
        return timedelta(seconds=value)
    return value

FlexibleTimedelta = Annotated[timedelta, BeforeValidator(parse_timedelta)]

class Settings(BaseModel):
    min_duration: Annotated[FlexibleTimedelta, Field(...)] = timedelta(seconds=5)

def main(settings: Settings):
    print(f"Duration: {settings.min_duration}")

if __name__ == "__main__":
    run(main)

Before this fix:

$ python script.py --settings.min_duration 5
# Error: Input should be a valid timedelta, "day" identifier in duration not correctly formatted

After this fix:

$ python script.py --settings.min_duration 5
Duration: 0:00:05  # ✓ Works!

$ python script.py --settings.min_duration PT30S
Duration: 0:00:30  # ✓ ISO 8601 still works too!

Problem 2: Boolean Fields Couldn't Accept Explicit Values

Typer automatically converts boolean fields with defaults into flags (--flag/--no-flag), but many CLIs need to accept explicit boolean values like --flag False for clarity and consistency.

Example That Failed Before

from typing import Annotated
from pydantic import BaseModel, Field
from pydantic_typer import Typer

app = Typer()

class Settings(BaseModel):
    use_token_latencies: Annotated[
        bool,
        Field(description="When set to True, LoadGen will track TTFT and TPOT."),
    ] = True

@app.command()
def main(settings: Settings):
    print(f"use_token_latencies = {settings.use_token_latencies}")

if __name__ == "__main__":
    app()

Before this fix:

$ python script.py --settings.use_token_latencies False
# Error: Got unexpected extra argument (False)

After this fix:

$ python script.py --settings.use_token_latencies False
use_token_latencies = False  # ✓ Works!

$ python script.py --settings.use_token_latencies True
use_token_latencies = True  # ✓ Works!

$ python script.py --settings.use_token_latencies 0
use_token_latencies = False  # ✓ Pydantic handles various formats!

Problem 3: Annotated Models with Typer Annotations Failed

When Pydantic models had fields with explicit typer.Option() or typer.Argument() annotations, the code would crash with "multiple Typer annotations" errors.

Before this fix:

$ python script.py --help
# Error: Cannot specify multiple `Annotated` Typer arguments for '_pydantic_user_id'

After this fix:

$ python script.py --help
# ✓ Works correctly!

Root Causes

There were multiple issues preventing proper functionality:

  1. Annotated validators were lost: In _flatten_pydantic_model(), using field.annotation only returned the base type, losing validators attached via Annotated.

  2. Class-level validators were bypassed: The code used TypeAdapter for early type validation, which bypassed @field_validator and @model_validator decorators that only run during full model construction.

  3. Boolean fields became flags: Typer treats bool parameters with defaults as flags, preventing explicit value passing like --flag False.

  4. Duplicate ParameterInfo in metadata: When fields had explicit Typer annotations, they were duplicated in the metadata, causing "multiple annotations" errors.

  5. Wrong type passed to _flatten_pydantic_model: Passing parameter.annotation (which could be Annotated[Model, ArgumentInfo]) instead of the base model type caused type hint errors.

Solutions

Solution 1: Preserve Annotated Validators

  1. Use get_type_hints() with include_extras=True to retrieve the full Annotated type including all validators

  2. Filter out FieldInfo and ParameterInfo from the metadata to avoid duplication (since we add our own)

  3. Update metadata unpacking to handle variable-length metadata (validators + typer_param + qualifier)

Solution 2: Enable Class-Level Validators

  1. Add _is_flattened_model_param() helper to identify parameters from flattened Pydantic models

  2. Skip TypeAdapter validation for flattened model parameters, allowing the full model construction to apply @field_validator and @model_validator decorators

Solution 3: Enable Boolean Fields with Explicit Values

  1. Detect boolean fields in flattened Pydantic models using _is_flattened_model_param()

  2. Replace bool with str type annotation to prevent Typer's automatic flag conversion

  3. Let Pydantic handle parsing - Pydantic naturally converts string values like "False", "True", "0", "1" to boolean

Solution 4: Fix Annotated Type Handling

  1. Pass base_annotation instead of parameter.annotation to _flatten_pydantic_model() to avoid passing Annotated[Model, TyperAnnotation]

  2. Filter ParameterInfo from metadata alongside FieldInfo to prevent duplicate Typer annotations

Changes

1. Import get_type_hints

from typing import Any, Callable, get_args, get_origin, get_type_hints

2. Updated _flatten_pydantic_model()

def _flatten_pydantic_model(
    model: pydantic.BaseModel, ancestors: list[str], ancestor_typer_param=None
) -> dict[str, inspect.Parameter]:
    # Get full type hints to preserve Annotated metadata
    type_hints = get_type_hints(model, include_extras=True)
    
    pydantic_parameters = {}
    for field_name, field in model.model_fields.items():
        # ... existing code ...
        
        # Get the full annotation with validators
        full_field_annotation = type_hints.get(field_name, field.annotation)
        
        # Filter out FieldInfo AND ParameterInfo (we add our own)
        if get_origin(full_field_annotation) is Annotated:
            args = get_args(full_field_annotation)
            filtered_metadata = tuple(
                arg for arg in args[1:]
                if not isinstance(arg, (pydantic.fields.FieldInfo, ParameterInfo))
            )
            if filtered_metadata:
                field_annotation = Annotated.__class_getitem__(
                    (args[0],) + filtered_metadata
                )
            else:
                field_annotation = args[0]
        else:
            field_annotation = full_field_annotation
            
        pydantic_parameters[sub_name] = inspect.Parameter(
            sub_name,
            inspect.Parameter.KEYWORD_ONLY,
            annotation=Annotated[field_annotation, typer_param, qualifier],
            default=default,
        )

3. Fixed enable_pydantic() to pass base annotation

for name, parameter in original_signature.parameters.items():
    base_annotation, typer_annotations = _split_annotation_from_typer_annotations(
        parameter.annotation
    )
    typer_param = typer_annotations[0] if typer_annotations else None
    if lenient_issubclass(base_annotation, pydantic.BaseModel):
        # Pass base_annotation, not parameter.annotation
        params = _flatten_pydantic_model(base_annotation, [name], typer_param)
        pydantic_parameters.update(params)
        pydantic_roots[name] = base_annotation

4. Added _is_flattened_model_param() Helper

def _is_flattened_model_param(annotation: Any) -> bool:
    """
    Check if a parameter is from a flattened Pydantic model.
    
    Flattened model parameters have a qualifier (list) in their metadata,
    added by enable_pydantic(). For these parameters, we should skip TypeAdapter
    validation and let the model construction handle it (including field_validator decorators).
    """
    if get_origin(annotation) is not Annotated:
        return False
    
    metadata = get_args(annotation)[1:]  # Skip the base type
    # Check if any metadata item is a list (the qualifier)
    return any(isinstance(item, list) for item in metadata)

5. Updated enable_pydantic_type_validation() for Boolean Fields

for param_name, param in parameters.items():
    original_parameter = original_signature.parameters[param_name]
    
    # Special handling for boolean fields from flattened pydantic models
    # Typer treats bool with defaults as flags (--flag/--no-flag), but we want to support
    # passing boolean values like: --settings.use_flag False
    if _is_flattened_model_param(original_parameter.annotation):
        # Check if the base type is bool
        annotation = original_parameter.annotation
        if get_origin(annotation) is Annotated:
            base_type, *metadata = get_args(annotation)
            if base_type is bool:
                # Replace bool with str and preserve existing metadata
                # This allows: --settings.use_flag False instead of requiring --no-settings.use_flag
                # The boolean parsing will be handled by Pydantic when the model is constructed
                updated_annotation = Annotated.__class_getitem__(
                    (str,) + tuple(metadata)
                )
                updated_parameters[param_name] = inspect.Parameter(
                    param_name,
                    kind=original_parameter.kind,
                    default=original_parameter.default,
                    annotation=updated_annotation,
                )
                continue
    
    # Skip TypeAdapter validation for flattened model params
    if _is_flattened_model_param(annotation):
        continue
    
    # ... rest of existing logic ...

6. Updated enable_pydantic() wrapper

# The last two items in metadata are always typer_param and qualifier
# There may be additional items before them (validators, etc.)
*_, typer_param, qualifier = annotation.__metadata__

Testing

Comprehensive test suite with 47 tests organized into test classes:

Test File 1: test_010_annotated_validators.py (11 tests)

Tests for validators attached via Annotated:

TestBeforeValidator (4 tests)

  • ✅ Integer string to timedelta conversion
  • ✅ Float string to timedelta conversion
  • ✅ ISO 8601 format passthrough
  • ✅ Default value handling

TestAfterValidator (3 tests)

  • ✅ Valid temperature values
  • ✅ Temperature below absolute zero error
  • ✅ Temperature unreasonably high error

TestCombinedValidators (4 tests)

  • ✅ HttpUrl with BeforeValidator (scheme normalization) + AfterValidator (domain validation)
  • ✅ HttpUrl domain validation error
  • ✅ EmailStr with BeforeValidator (lowercase normalization)
  • ✅ EmailStr validation error

Test File 2: test_011_field_validator.py (13 tests)

Tests for @field_validator decorators:

TestFieldValidatorBefore (4 tests)

  • ✅ Timedelta parsing with mode="before"
  • ✅ Float input handling
  • ✅ ISO 8601 format compatibility
  • ✅ Default value handling

TestFieldValidatorAfter (3 tests)

  • ✅ Temperature range validation with mode="after"
  • ✅ Low temperature error
  • ✅ High temperature error

TestFieldValidatorWithPydanticTypes (6 tests)

  • ✅ HttpUrl with scheme normalization (mode="before")
  • ✅ HttpUrl with port validation (mode="after")
  • ✅ IPv4Address with localhost alias (mode="before")
  • ✅ IPv4Address validation
  • ✅ IPv4Address 0.0.0.0 rejection (mode="after")
  • ✅ IPv4Address invalid format error

Test File 3: test_012_model_validator.py (12 tests)

Tests for @model_validator decorators:

TestModelValidatorBefore (1 test)

  • ✅ Data normalization with mode="before"

TestModelValidatorAfter (2 tests)

  • ✅ Cross-field validation success
  • ✅ Cross-field validation error

TestModelValidatorCombined (4 tests)

  • ✅ Multiple model validators (before + after)
  • ✅ Model validator error handling
  • ✅ Computed defaults based on other fields
  • ✅ Explicit values override computed defaults

TestModelValidatorWithPydanticTypes (5 tests)

  • ✅ HttpUrl with URL normalization
  • ✅ HttpUrl with domain mismatch error
  • ✅ SecretStr with model validation
  • ✅ Production password strength validation
  • ✅ Strong password acceptance

Test File 4: test_006_boolean_fields.py (11 tests) NEW!

Tests for boolean field handling in Pydantic models:

  • ✅ Help text generation for boolean fields
  • ✅ Passing False as explicit value
  • ✅ Passing True as explicit value
  • ✅ Lowercase false support
  • ✅ Lowercase true support
  • ✅ Default value (True) when field not provided
  • ✅ Default value (False) when field not provided
  • ✅ Setting multiple boolean fields simultaneously
  • ✅ Numeric 1 parsed as True
  • ✅ Numeric 0 parsed as False
  • ✅ Invalid value error handling

All 47 tests pass!

Benefits

  1. Preserves Pydantic validators in Annotated types: BeforeValidator, AfterValidator, PlainValidator, WrapValidator

  2. Enables @field_validator decorators: Both mode="before" and mode="after" work correctly

  3. Enables @model_validator decorators: Cross-field validation and data normalization

  4. Works with advanced Pydantic types: HttpUrl, EmailStr, IPv4Address, SecretStr, etc.

  5. Boolean fields accept explicit values: Use --flag False instead of --no-flag for clearer, more consistent CLI

  6. Supports various boolean formats: True, False, true, false, 1, 0 all work via Pydantic's parsing

  7. Fixes Annotated model handling: Models with typer.Option() or typer.Argument() annotations now work correctly

  8. Enables flexible CLI input parsing: Users can define custom parsers for more user-friendly CLI experiences

  9. Backwards compatible: Falls back to existing behavior if no validators are present

  10. Follows Pydantic best practices: Uses the same mechanisms Pydantic uses internally

Validator Support Overview

This fix supports ALL Pydantic validator types:

1. Annotated Validators (Reusable)

FlexibleTimedelta = Annotated[timedelta, BeforeValidator(parse_timedelta)]

class Settings(BaseModel):
    min_duration: Annotated[FlexibleTimedelta, Field(...)]

2. field_validator Decorators (Model-specific)

class Settings(BaseModel):
    min_duration: timedelta
    
    @field_validator("min_duration", mode="before")
    @classmethod
    def parse_min_duration(cls, v): ...

3. model_validator Decorators (Cross-field)

class Settings(BaseModel):
    min_value: int
    max_value: int
    
    @model_validator(mode="after")
    def validate_range(self):
        if self.min_value >= self.max_value:
            raise ValueError("min_value must be less than max_value")
        return self

How it works: The fix preserves Annotated validators through get_type_hints() and enables @field_validator/@model_validator decorators by skipping premature TypeAdapter validation for flattened model fields. See VALIDATOR_COMPATIBILITY.md for detailed guide on when to use each approach.

Use Cases Enabled

This fix enables many powerful use cases:

1. Flexible timedelta parsing

# Accept both numeric seconds and ISO 8601
FlexibleTimedelta = Annotated[timedelta, BeforeValidator(parse_timedelta)]
# CLI: --duration 5  OR  --duration PT5S

2. URL normalization with domain validation

# Add scheme and validate domain
def normalize_url(v):
    return v if v.startswith('http') else f'https://{v}'

def validate_domain(v):
    if v.host not in ['example.com', 'test.com']:
        raise ValueError('Invalid domain')
    return v

FlexibleUrl = Annotated[HttpUrl, BeforeValidator(normalize_url), AfterValidator(validate_domain)]
# CLI: --url example.com  (becomes https://example.com and validates domain)

3. Email normalization

# Normalize email format
def normalize_email(v):
    return v.lower().strip()

NormalizedEmail = Annotated[EmailStr, BeforeValidator(normalize_email)]
# CLI: --email "  [email protected]  "  (becomes "[email protected]")

4. Cross-field validation with model_validator

class RangeConfig(BaseModel):
    min_value: int
    max_value: int
    
    @model_validator(mode="after")
    def validate_range(self):
        if self.min_value >= self.max_value:
            raise ValueError("min_value must be less than max_value")
        return self

# CLI: --min_value 10 --max_value 5  (Error: validation fails)

5. Boolean fields with explicit values NEW!

class Settings(BaseModel):
    debug_mode: bool = False
    use_token_latencies: bool = True

# CLI: --settings.debug_mode True --settings.use_token_latencies False
# Works! Clear and explicit, no need to remember --no-flag syntax

Migration Guide

Existing code continues to work without changes. This fix only adds new functionality.

To take advantage of flexible parsing:

from typing import Annotated, Any
from pydantic import BaseModel, BeforeValidator, Field

def custom_parser(value: Any) -> YourType:
    """Your custom parsing logic."""
    # Convert various input formats to your type
    return converted_value

FlexibleType = Annotated[YourType, BeforeValidator(custom_parser)]

class Config(BaseModel):
    field: Annotated[FlexibleType, Field(...)]

For boolean fields, no changes needed - they now automatically support explicit values:

class Config(BaseModel):
    enable_feature: bool = True  # Works with --enable_feature False

Python Compatibility

Python 3.8+ - Uses Annotated.__class_getitem__() for backward compatibility

References

@wangshangsam wangshangsam changed the title Preserve Pydantic Validators (Annotated, @field_validator, @model_validator) Preserve Pydantic Validators (Annotated, @field_validator, @model_validator) + Enable Boolean Field Support Nov 7, 2025
@pypae
Copy link
Owner

pypae commented Nov 10, 2025

Thanks for your extensive PR. I'll look into it the coming days.

Note: I just got a security alert about a hugging face token being leaked in your comment. I edited it out, but you might still want to revoke it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants