"""Pydantic integration for valid8r parsers.
This module provides utilities to convert valid8r parsers (which return Maybe[T])
into Pydantic field validators, enabling seamless integration with FastAPI and
other Pydantic-based frameworks.
The integration supports:
- Simple field validation with type parsing and validation
- Nested model validation with field path error reporting
- List[Model] validation with per-item error reporting
- Dict[K, V] validation with per-value validation
- Optional fields and complex structures
Example - Simple Field Validation:
>>> from pydantic import BaseModel, field_validator
>>> from valid8r.core import parsers, validators
>>> from valid8r.integrations.pydantic import validator_from_parser
>>>
>>> class User(BaseModel):
... age: int
...
... @field_validator('age', mode='before')
... @classmethod
... def validate_age(cls, v):
... return validator_from_parser(
... parsers.parse_int & validators.between(0, 120)
... )(v)
Example - Nested Model Validation:
>>> from valid8r.core.parsers import PhoneNumber
>>>
>>> class Address(BaseModel):
... phone: PhoneNumber
...
... @field_validator('phone', mode='before')
... @classmethod
... def validate_phone(cls, v):
... return validator_from_parser(parsers.parse_phone)(v)
>>>
>>> class User(BaseModel):
... name: str
... address: Address
>>>
>>> # Validation errors include full field path (e.g., 'address.phone')
>>> user = User(name='Alice', address={'phone': '(206) 234-5678'})
Example - List of Models:
>>> class LineItem(BaseModel):
... quantity: int
...
... @field_validator('quantity', mode='before')
... @classmethod
... def validate_quantity(cls, v):
... def parser(value):
... return parsers.parse_int(value).bind(validators.minimum(1))
... return validator_from_parser(parser)(v)
>>>
>>> class Order(BaseModel):
... items: list[LineItem]
>>>
>>> # Validation errors include list index (e.g., 'items[1].quantity')
>>> order = Order(items=[{'quantity': '5'}, {'quantity': '10'}])
Example - Dict Value Validation:
>>> class Config(BaseModel):
... ports: dict[str, int]
...
... @field_validator('ports', mode='before')
... @classmethod
... def validate_ports(cls, v):
... if not isinstance(v, dict):
... raise ValueError('ports must be a dict')
... return {k: validator_from_parser(parsers.parse_int)(val) for k, val in v.items()}
>>>
>>> config = Config(ports={'http': '80', 'https': '443'})
"""
from __future__ import annotations
from typing import (
TYPE_CHECKING,
Any,
TypeVar,
)
if TYPE_CHECKING:
from collections.abc import Callable
from valid8r.core.maybe import Maybe
T = TypeVar('T')
[docs]
def validator_from_parser(
parser: Callable[[Any], Maybe[T]],
*,
error_prefix: str | None = None,
) -> Callable[[Any], T]:
"""Convert a valid8r parser into a Pydantic field validator.
This function takes a valid8r parser (any callable that returns Maybe[T])
and converts it into a function suitable for use with Pydantic's
field_validator decorator.
Works seamlessly with:
- Simple fields (str, int, custom types)
- Nested models (User -> Address -> phone)
- Lists of models (Order with list[LineItem])
- Dicts with validated values (Config with dict[str, int])
- Optional fields (field: Model | None)
Pydantic automatically handles field path reporting for nested structures,
so validation errors will include the full path (e.g., 'address.phone' or
'items[1].quantity').
Args:
parser: A valid8r parser function that returns Maybe[T].
error_prefix: Optional prefix to prepend to error messages.
Returns:
A validator function that returns T on success or raises ValueError
on failure.
Raises:
ValueError: When the parser returns a Failure with the error message.
Example:
>>> from valid8r.core import parsers
>>> validator = validator_from_parser(parsers.parse_int)
>>> validator('42')
42
>>> validator('invalid') # doctest: +SKIP
Traceback (most recent call last):
...
ValueError: ...
>>> # With custom error prefix
>>> validator = validator_from_parser(parsers.parse_int, error_prefix='User ID')
>>> validator('invalid') # doctest: +SKIP
Traceback (most recent call last):
...
ValueError: User ID: ...
>>> # Nested model validation
>>> from pydantic import BaseModel, field_validator
>>> from valid8r.core.parsers import EmailAddress
>>>
>>> class Contact(BaseModel):
... email: EmailAddress
...
... @field_validator('email', mode='before')
... @classmethod
... def validate_email(cls, v):
... return validator_from_parser(parsers.parse_email)(v)
>>>
>>> contact = Contact(email='user@example.com') # doctest: +SKIP
"""
from valid8r.core.maybe import ( # noqa: PLC0415
Failure,
Success,
)
def validate(value: Any) -> T: # noqa: ANN401
"""Validate the value using the parser.
Args:
value: The value to validate.
Returns:
The parsed value if successful.
Raises:
ValueError: If parsing fails.
"""
result = parser(value)
match result:
case Success(parsed_value):
return parsed_value # type: ignore[no-any-return]
case Failure(error_msg):
if error_prefix:
msg = f'{error_prefix}: {error_msg}'
raise ValueError(msg)
raise ValueError(error_msg)
case _: # pragma: no cover
# This should never happen as Maybe only has Success and Failure
msg = f'Unexpected Maybe type: {type(result)}'
raise TypeError(msg)
return validate
[docs]
def make_after_validator(
parser: Callable[[Any], Maybe[T]],
) -> Callable[[Any], T | None]:
"""Create a Pydantic AfterValidator from a valid8r parser.
AfterValidator runs after Pydantic's type conversion, allowing you to use
valid8r parsers with Pydantic's Annotated type system for cleaner model definitions.
This function wraps a valid8r parser (which returns Maybe[T]) into a function
suitable for use with Pydantic's AfterValidator. For optional fields (e.g.,
`str | None`), `None` values are passed through without validation.
Args:
parser: A valid8r parser function that returns Maybe[T].
Returns:
A validator function that returns T on success, None for None inputs,
or raises ValueError on failure.
Raises:
ValueError: When the parser returns a Failure with the error message.
Example:
>>> from pydantic import BaseModel, AfterValidator
>>> from typing_extensions import Annotated
>>> from valid8r.core import parsers
>>>
>>> email_validator = make_after_validator(parsers.parse_email)
>>>
>>> class User(BaseModel):
... email: Annotated[str, AfterValidator(email_validator)]
>>>
>>> user = User(email='alice@example.com') # doctest: +SKIP
>>> # Optional field example
>>> class Contact(BaseModel):
... email: Annotated[str | None, AfterValidator(email_validator)] = None
>>>
>>> contact = Contact(email=None) # doctest: +SKIP
>>> contact.email is None # doctest: +SKIP
True
"""
from valid8r.core.maybe import ( # noqa: PLC0415
Failure,
Success,
)
def validate(value: Any) -> T | None: # noqa: ANN401
"""Validate the value using the parser.
Args:
value: The value to validate. None is passed through for optional fields.
Returns:
The parsed value if successful, or None if value is None.
Raises:
ValueError: If parsing fails.
"""
# Pass through None for optional fields
if value is None:
return None
result = parser(value)
match result:
case Success(parsed_value):
return parsed_value # type: ignore[no-any-return]
case Failure(error_msg):
raise ValueError(error_msg)
case _: # pragma: no cover
# This should never happen as Maybe only has Success and Failure
msg = f'Unexpected Maybe type: {type(result)}'
raise TypeError(msg)
return validate
[docs]
def make_wrap_validator(
parser: Callable[[Any], Maybe[T]],
) -> Callable[[Any, Any], T]:
"""Create a Pydantic WrapValidator from a valid8r parser.
WrapValidator runs before Pydantic's type conversion, receiving raw input
values. This allows full control over validation and pre-processing.
This function wraps a valid8r parser (which returns Maybe[T]) into a function
suitable for use with Pydantic's WrapValidator.
Args:
parser: A valid8r parser function that returns Maybe[T].
Returns:
A wrap validator function that returns T on success or raises ValueError
on failure. The function signature is (value, handler) -> T, though handler
is not used since the parser handles all validation.
Raises:
ValueError: When the parser returns a Failure with the error message.
Example:
>>> from pydantic import BaseModel, WrapValidator
>>> from typing_extensions import Annotated
>>> from valid8r.core import parsers
>>>
>>> int_validator = make_wrap_validator(parsers.parse_int)
>>>
>>> class Data(BaseModel):
... value: Annotated[int, WrapValidator(int_validator)]
>>>
>>> data = Data(value='42') # doctest: +SKIP
"""
from valid8r.core.maybe import ( # noqa: PLC0415
Failure,
Success,
)
def wrap_validate(value: Any, handler: Any) -> T: # noqa: ANN401, ARG001
"""Validate the value using the parser.
Args:
value: The value to validate.
handler: Pydantic's handler function (not used).
Returns:
The parsed value if successful.
Raises:
ValueError: If parsing fails.
"""
result = parser(value)
match result:
case Success(parsed_value):
return parsed_value # type: ignore[no-any-return]
case Failure(error_msg):
raise ValueError(error_msg)
case _: # pragma: no cover
# This should never happen as Maybe only has Success and Failure
msg = f'Unexpected Maybe type: {type(result)}'
raise TypeError(msg)
return wrap_validate
__all__ = ['make_after_validator', 'make_wrap_validator', 'validator_from_parser']