"""
valify.validators
~~~~~~~~~~~~~~~~~
Built-in validators for common Python types and patterns.
Each validator is a class that accepts configuration on instantiation
and exposes a single `validate(value)` method that either returns the
(possibly coerced) value or raises ValidationError.
"""
from __future__ import annotations
from typing import Any
import re
from .exceptions import ValidationError
class Validator:
"""Base class for all valify validators.
All built-in and custom validators inherit from this class.
Subclasses must implement the `validate` method.
Example
-------
Creating a custom validator::
class PositiveInt(Validator):
def validate(self, value: object) -> object:
if not isinstance(value, int) or value <= 0:
raise ValidationError(
"Value must be a positive integer.",
value=value,
)
return value
"""
def validate(self, value: Any) -> Any:
"""
Parameters
----------
value : Any
The value to validate.
Returns
-------
Any
The validated (and possibly coerced) value.
Raises
------
ValidationError
If the value fails validation.
"""
raise NotImplementedError(
f"{type(self).__name__} must implement validate()"
)
def to_json_schema(self) -> dict[str, Any]:
"""
Returns a JSON Schema representation of this validator.
Returns
-------
dict
JSON schema fragment describing the validator.
"""
raise NotImplementedError(
f"{type(self).__name__} must implement to_json_schema()"
)
def __repr__(self) -> str:
return f"{type(self).__name__}()"
[docs]
class StringValidator(Validator):
"""Validates that a value is a string, with optional length constraints.
Parameters
----------
min_length : int or None
Minimum allowed length. None means no minimum.
max_length : int or None
Maximum allowed length. None means no maximum.
strip : bool
If True, strip leading/trailing whitespace before validating.
Defaults to True.
Example
-------
v = StringValidator(min_length=2, max_length=50)
v.validate("Alice") # returns "Alice"
v.validate("A") # raises ValidationError
"""
def __init__(
self,
*,
min_length: int | None = None,
max_length: int | None = None,
strip: bool = True
) -> None:
self.min_length: int | None = min_length
self.max_length: int | None = max_length
self.strip: bool = strip
[docs]
def validate(self, value: Any) -> str:
if not isinstance(value, str):
raise ValidationError(
f"Expected a string, got {type(value).__name__}",
value=value
)
if self.strip:
value = value.strip()
if self.min_length is not None and len(value) < self.min_length:
raise ValidationError(
f"Must be at least {self.min_length} characters long.",
value=value,
)
if self.max_length is not None and len(value) > self.max_length:
raise ValidationError(
f"Must be at most {self.max_length} characters long.",
value=value,
)
return value
[docs]
def to_json_schema(self) -> dict[str,Any]:
schema: dict[str, Any] = {
"type": "string"
}
if self.min_length is not None:
schema["minLength"] = self.min_length
if self.max_length is not None:
schema["maxLength"] = self.max_length
return schema
def __repr__(self) -> str:
return (
f"StringValidator("
f"min_length={self.min_length!r}, "
f"max_length={self.max_length!r}, "
f"strip={self.strip!r})"
)
[docs]
class IntValidator(Validator):
"""Validates that a value is an integer, with optional range constraints.
Parameters
----------
min_value : int or None
Minimum allowed value. None means no minimum.
max_value : int or None
Maximum allowed value. None means no maximum.
coerce : bool
If True, attempt to convert strings to int before validating.
Defaults to False.
Example
-------
v = IntValidator(min_value=0, max_value=120)
v.validate(25) # returns 25
v.validate(-1) # raises ValidationError
"""
def __init__(
self,
*,
min_value: int | None = None,
max_value: int | None = None,
coerce: bool = False
) -> None:
self.min_value: int | None = min_value
self.max_value: int | None = max_value
self.coerce: bool = coerce
[docs]
def validate(self, value: Any) -> int:
if self.coerce and not isinstance(value,int):
try:
value = int(value)
except(ValueError, TypeError):
raise ValidationError(
f"Could not convert {value!r} to int",
value=value
)
if not isinstance(value, int) or isinstance(value,bool):
raise ValidationError(
f"Expected an integer, got {type(value).__name__}.",
value=value
)
if self.min_value is not None and value < self.min_value:
raise ValidationError(
f"Must be at least {self.min_value}.",
value=value,
)
if self.max_value is not None and value > self.max_value:
raise ValidationError(
f"Must be at most {self.max_value}.",
value=value,
)
return value
[docs]
def to_json_schema(self) -> dict[str,Any]:
schema: dict[str, Any] = {
"type": "integer"
}
if self.min_value is not None:
schema["minimum"] = self.min_value
if self.max_value is not None:
schema["maximum"] = self.max_value
return schema
def __repr__(self) -> str:
return (
f"IntValidator("
f"min_value={self.min_value!r}, "
f"max_value={self.max_value!r}, "
f"coerce={self.coerce!r})"
)
[docs]
class FloatValidator(Validator):
""" Validates that a value is float, with optional range values.
Parameters
----------
min_value : float or None
Minimum allowed value. None means no minimum.
max_value : float or None
Maximum allowed value. None means no maximum.
coerce : bool
If True, attempt to convert strings and ints to float.
Defaults to False.
Example
-------
v = FloatValidator(min_value=0.0, max_value=1.0)
v.validate(0.5) # returns 0.5
v.validate(1.5) # raises ValidationError
"""
def __init__(
self,
*,
min_value: float | None = None,
max_value: float | None = None,
coerce: bool = False
) -> None:
self.min_value: float | None = min_value
self.max_value: float | None = max_value
self.coerce: bool = coerce
[docs]
def validate(self,value: Any) -> float:
if self.coerce and not isinstance(value,float):
try:
value = float(value)
except(ValueError, TypeError):
raise ValidationError(
f"Could not convert {value!r} to float",
value=value
)
if not isinstance(value, (int,float)) or isinstance(value,bool):
raise ValidationError(
f"Expected a float, got {type(value).__name__}.",
value=value
)
value= float(value)
if self.min_value is not None and value<self.min_value:
raise ValidationError(
f"Must be at least {self.min_value}",
value=value,
)
if self.max_value is not None and value>self.max_value:
raise ValidationError(
f"Must be at most {self.max_value}",
value=value
)
return value
[docs]
def to_json_schema(self) -> dict[str,Any]:
schema: dict[str, Any] = {
"type": "number"
}
if self.min_value is not None:
schema["minimum"] = self.min_value
if self.max_value is not None:
schema["maximum"] = self.max_value
return schema
def __repr__(self) -> str:
return (
f"FloatValidator("
f"min_value={self.min_value!r}, "
f"max_value={self.max_value!r}, "
f"coerce={self.coerce!r})"
)
[docs]
class BoolValidator(Validator):
""" Validates that a value is boolean.
Parameters
----------
coerce : bool
If True, accept truthy strings like 'true', 'false', '1', '0'.
Defaults to False.
Example
-------
v = BoolValidator()
v.validate(True) # returns True
v.validate("true") # raises ValidationError unless coerce=True
"""
# Accepted string values while coercing -
_TRUTH_VALUES: set[str] = {"true","1","yes"}
_FALSE_VALUES: set[str] = {"false","0","no"}
def __init__(self,*,coerce: bool = False) -> None:
self.coerce: bool = coerce
[docs]
def validate(self, value: Any)-> bool:
if self.coerce and isinstance(value,str):
lowered: str = value.lower()
if lowered in self._TRUTH_VALUES:
return True
if lowered in self._FALSE_VALUES:
return False
raise ValidationError(
f"Cannot coerce {value!r} to boolean",
value=value,
)
if not isinstance(value,bool):
raise ValidationError(
F"Expected a boolean, got {type(value).__name__}.",
value=value,
)
return value
[docs]
def to_json_schema(self) -> dict[str,Any]:
schema: dict[str, Any] = {
"type": "boolean"
}
return schema
def __repr__(self) -> str:
return f"BoolValidator(coerce={self.coerce!r})"
[docs]
class EmailValidator(Validator):
""" Validates that a value is valid email address.
This validator checks format only — it does not send a confirmation
email or verify the address exists. This is intentional: full email
verification requires network access, which a validator should never do.
Example
-------
v = EmailValidator()
v.validate("alice@example.com") # returns "alice@example.com"
v.validate("not-an-email") # raises ValidationError
"""
# Email regex -
_EMAIL_REGEX: re.Pattern[str] = re.compile(
r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$"
)
[docs]
def validate(self,value: Any) -> str:
if not isinstance(value,str):
raise ValidationError(
f"Expected a string, got {type(value).__name__}",
value=value,
)
value = value.strip().lower()
if not self._EMAIL_REGEX.match(value):
raise ValidationError(
f"{value!r} is not a valid email address",
value=value,
)
return value
[docs]
def to_json_schema(self) -> dict[str,Any]:
schema: dict[str, Any] = {
"type": "string",
"format": "email"
}
return schema
def __repr__(self) -> str:
return "EmailValidator()"
[docs]
class OptionalValidator(Validator):
"""Wraps any validator and makes its field optional.
If the value is None or the field is missing, returns the default
value instead of raising ValidationError.
Parameters
----------
validator : Validator
The validator to apply if a value is present.
default : Any
Value to return when the field is absent or None.
Defaults to None.
Example
-------
v = OptionalValidator(StringValidator(min_length=2), default="")
v.validate("Alice") # returns "Alice"
v.validate(None) # returns ""
"""
def __init__(
self,
validator: Validator,
*,
default: Any = None
) -> None:
self.validator: Validator = validator
self.default: Any = default
[docs]
def validate(self, value: Any) -> Any:
if value is None:
return self.default
return self.validator.validate(value)
[docs]
def to_json_schema(self) -> dict[str, Any]:
return {
"anyOf": [
self.validator.to_json_schema(),
{"type": "null"},
]
}
def __repr__(self) -> str:
return (
f"OptionalValidator("
f"validator={self.validator!r}, "
f"default={self.default!r})"
)
[docs]
class ListValidator(Validator):
"""
Validates that a value is a list, with each item passing a validator.
Parameters
----------
item_validator : Validator
The validator applied to every item in the list.
min_items : int or None
Minimum number of items. None means no minimum.
max_items : int or None
Maximum number of items. None means no maximum.
Example
-------
v = ListValidator(StringValidator(), min_items=1, max_items=5)
v.validate(["alice", "bob"]) # returns ["alice", "bob"]
v.validate([]) # raises ValidationError
"""
def __init__(
self,
item_validator: Validator,
*,
min_items: int | None = None,
max_items: int | None = None,
) -> None:
self.item_validator: Validator = item_validator
self.min_items: int | None = min_items
self.max_items: int | None = max_items
[docs]
def validate(self, value: Any) -> list[Any]:
if not isinstance(value, list):
raise ValidationError(
f"Expected a list, got {type(value).__name__}.",
value = value,
)
if self.min_items is not None and len(value) < self.min_items:
raise ValidationError(
f"Must have atleast {self.min_items} items.",
value = value,
)
if self.max_items is not None and len(value) > self.max_items:
raise ValidationError(
f"Must have at most {self.max_items} items.",
value = value,
)
validated_items = []
item_errors: dict[int, str] = {}
for index, item in enumerate(value):
try:
validated_items.append(self.item_validator.validate(item))
except ValidationError as e:
item_errors[index] = e.message
if item_errors:
error_lines = "\n".join(
f" [{index}]: {msg}" for index, msg in item_errors.items()
)
raise ValidationError(
f"List Validation Failed:\n{error_lines}",
value = value,
)
return validated_items
[docs]
def to_json_schema(self) -> dict[str, Any]:
schema: dict[str, Any] = {
"type": "array",
"items": self.item_validator.to_json_schema(),
}
if self.min_items is not None:
schema["minItems"] = self.min_items
if self.max_items is not None:
schema["maxItems"] = self.max_items
return schema
def __repr__(self) -> str:
return (
f"ListValidator("
f"item_validator={self.item_validator!r}, "
f"min_items={self.min_items!r}, "
f"max_items={self.max_items!r})"
)
[docs]
class EnumValidator(Validator):
"""
Validates that a value is one of a fixed set of allowed choices.
Parameters
----------
choices : list or set
The collection of allowed values.
case_sensitive : bool
If False, string comparisons are case-insensitive.
Defaults to True.
Example
-------
v = EnumValidator(choices=["admin", "user", "guest"])
v.validate("admin") # returns "admin"
v.validate("root") # raises ValidationError
"""
def __init__(
self,
choices: list[Any] | set[Any],
*,
case_sensitive: bool = True,
) -> None:
self.choices : list[Any] = list(choices)
self.case_sensitive: bool = case_sensitive
if not case_sensitive:
self._lookup : set[Any] = {
c.lower() if isinstance(c, str) else c
for c in choices
}
else:
self._lookup = set(choices)
[docs]
def validate(self, value: Any) -> Any:
comparable = (
value.lower()
if not self.case_sensitive and isinstance(value, str)
else value
)
if comparable not in self._lookup:
raise ValidationError(
f"{value!r} is not a valid choice."
f"Must be one of: {self.choices},",
value = value,
)
return value
[docs]
def to_json_schema(self) -> dict[str, Any]:
schema: dict[str, Any] = {
"enum": list(self.choices)
}
return schema
def __repr__(self) -> str:
return (
f"EnumValidator("
f"choices={self.choices!r}, "
f"case_sensitive={self.case_sensitive!r})"
)