Skip to content

Custom Validators

PyValidX allows creating custom validators for specific use cases that are not covered by predefined validators.

Creating Custom Validators

Basic Validation Function

A custom validator is a function that: - Receives two parameters: value and context (optional) - Returns True if validation is successful, False if it fails - Has a __message__ attribute with the error message

from pyvalidx import ValidatedModel, field_validated
from pyvalidx.core import custom

def is_even(value, context=None) -> bool:
    '''
    Validates that the number is even

    Args:
        value (Any): Value to validate
        context (Dict[str, Any], optional): Context containing other field values

    Returns:
        bool: True if validation passes, False otherwise
    '''
    if value is None:
        return True
    try:
        return int(value) % 2 == 0
    except (ValueError, TypeError):
        return False

# Add error message
is_even.__message__ = 'Number must be even'

class NumberModel(ValidatedModel):
    even_number: int = field_validated(
        custom(is_even, 'Number must be even')
    )

# Usage
valid_model = NumberModel(even_number=4)  # Valid
# invalid_model = NumberModel(even_number=3)  # Raises ValidationException

Validador como Función Factory

Para validadores más flexibles, crea una función que retorne el validador:

def min_words(word_count: int, message: str = None) -> Callable[[Any, Optional[Dict[str, Any]]], bool]:
    '''
    Factory para validar cantidad mínima de palabras

    Args:
        word_count (int): Minimum number of words
        message (str, optional): Error message if validation fails

    Returns:
        Callable[[Any, Optional[Dict[str, Any]]], bool]: Validator function
    '''
    message = message or f'Must contain at least {word_count} words'

    def validator(value, context=None):
        if value is None:
            return True
        word_list = str(value).strip().split()
        return len(word_list) >= word_count

    validator.__message__ = message
    return validator

class ArticleModel(ValidatedModel):
    title: str = field_validated(min_words(2, 'Title must have at least 2 words'))
    content: str = field_validated(min_words(10, 'Content must have at least 10 words'))

# Uso
article = ArticleModel(
    title='Python Validation Guide',
    content='This is a comprehensive guide about creating custom validators in Python...'
)

Contextual Validators

Validators can access other model fields through the context parameter:

def password_not_contains_username(value, context=None) -> bool:
    '''
    Validates that password does not contain username

    Args:
        value (Any): Value to validate
        context (Dict[str, Any], optional): Context containing other field values

    Returns:
        bool: True if validation passes, False otherwise
    '''
    if value is None or context is None:
        return True

    username = context.get('username', '').lower()
    password = str(value).lower()

    return username not in password

class UserRegistrationModel(ValidatedModel):
    username: str = field_validated(is_required())
    password: str = field_validated(
        is_required(),
        custom(password_not_contains_username, 'Password cannot contain username')
    )

# Valid usage
user = UserRegistrationModel(
    username='johndoe',
    password='SecurePass123!'
)

# Invalid usage
try:
    invalid_user = UserRegistrationModel(
        username='johndoe',
        password='johndoe123'
    )
except ValidationException as e:
    print(e.validations)  # {'password': 'Password cannot contain username'}

Complex Validation Examples

Credit Card and ISBN Validators

def is_credit_card(value, context=None) -> bool:
    '''
    Validates credit card number using Luhn algorithm

    Args:
        value (Any): Value to validate
        context (Dict[str, Any], optional): Context containing other field values

    Returns:
        bool: True if validation passes, False otherwise
    '''
    if value is None:
        return True

    # Remove spaces and hyphens
    card_number = str(value).replace(' ', '').replace('-', '')

    # Check if all digits
    if not card_number.isdigit():
        return False

    # Luhn algorithm
    def luhn_checksum(card_num):
        def digits_of(n):
            return [int(d) for d in str(n)]

        digits = digits_of(card_num)
        odd_digits = digits[-1::-2]
        even_digits = digits[-2::-2]
        checksum = sum(odd_digits)
        for d in even_digits:
            checksum += sum(digits_of(d*2))
        return checksum % 10

    return luhn_checksum(card_number) == 0

def is_isbn(value, context=None) -> bool:
    '''
    Validates ISBN-10 or ISBN-13

    Args:
        value (Any): Value to validate
        context (Dict[str, Any], optional): Context containing other field values

    Returns:
        bool: True if validation passes, False otherwise
    '''
    if value is None:
        return True

    isbn = str(value).replace('-', '').replace(' ', '')

    if len(isbn) == 10:
        return _validate_isbn10(isbn)
    elif len(isbn) == 13:
        return _validate_isbn13(isbn)
    else:
        return False

def _validate_isbn10(isbn: str) -> bool:
    '''
    Validates ISBN-10

    Args:
        isbn (str): ISBN-10 number

    Returns:
        bool: True if validation passes, False otherwise
    '''
    if not (isbn[:-1].isdigit() and (isbn[-1].isdigit() or isbn[-1].upper() == 'X')):
        return False

    total = 0
    for i, digit in enumerate(isbn[:-1]):
        total += int(digit) * (10 - i)

    check_digit = isbn[-1]
    if check_digit.upper() == 'X':
        total += 10
    else:
        total += int(check_digit)

    return total % 11 == 0

def _validate_isbn13(isbn: str) -> bool:
    """Validates ISBN-13"""
    if not isbn.isdigit():
        return False

    total = 0
    for i, digit in enumerate(isbn[:-1]):
        multiplier = 1 if i % 2 == 0 else 3
        total += int(digit) * multiplier

    check_digit = (10 - (total % 10)) % 10
    return check_digit == int(isbn[-1])

# Usage of custom validators
class PaymentModel(ValidatedModel):
    card_number: str = field_validated(
        is_required(),
        custom(is_credit_card, 'Invalid credit card number')
    )

class BookModel(ValidatedModel):
    title: str = field_validated(is_required())
    isbn: str = field_validated(
        custom(is_isbn, 'Invalid ISBN format')
    )

Validators with External Dependencies

Database Validation

def username_not_exists(value, context=None) -> bool:
    '''
    Validates that username does not exist in database


    Args:
        value (Any): Value to validate
        context (Dict[str, Any], optional): Context containing other field values

    Returns:
        bool: True if validation passes, False otherwise
    '''
    if value is None:
        return True

    # Database query simulation
    existing_usernames = ['admin', 'root', 'test', 'demo']  # In practice, this would come from DB

    return str(value).lower() not in existing_usernames

class NewUserModel(ValidatedModel):
    username: str = field_validated(
        is_required(),
        min_length(3),
        custom(username_not_exists, 'Username already exists')
    )
    email: str = field_validated(is_required(), is_email())

External API Validation

def is_valid_country_code(value, context=None) -> bool:
    '''
    Validates country code using a predefined list

    Args:
        value (Any): Value to validate
        context (Dict[str, Any], optional): Context containing other field values

    Returns:
        bool: True if validation passes, False otherwise
    '''
    if value is None:
        return True

    # ISO 3166-1 alpha-2 codes list (simulated)
    valid_codes = {
        'US', 'CA', 'GB', 'FR', 'DE', 'IT', 'ES', 'BR', 'AR', 'CO',
        'MX', 'JP', 'CN', 'IN', 'AU', 'NZ', 'ZA', 'EG', 'NG', 'KE'
    }

    return str(value).upper() in valid_codes

class InternationalOrderModel(ValidatedModel):
    customer_name: str = field_validated(is_required())
    country_code: str = field_validated(
        is_required(),
        custom(is_valid_country_code, 'Invalid country code')
    )

Validators with Advanced Configuration

Configurable File Validator

def file_extension_validator(allowed_extensions: list) -> Callable[[Any, Optional[Dict[str, Any]]], bool]:
    '''
    Creates a validator for file extensions

    Args:
        allowed_extensions (list): List of allowed file extensions

    Returns:
        Callable[[Any, Optional[Dict[str, Any]]], bool]: Validator function
    '''
    def validator(value, context=None):
        if value is None:
            return True

        file_path = str(value)
        if '.' not in file_path:
            return False

        extension = file_path.split('.')[-1].lower()
        return extension in [ext.lower() for ext in allowed_extensions]

    validator.__message__ = f"File must have one of these extensions: {', '.join(allowed_extensions)}"
    return validator

class DocumentModel(ValidatedModel):
    document_name: str = field_validated(is_required())
    file_path: str = field_validated(
        is_required(),
        custom(
            file_extension_validator(['pdf', 'doc', 'docx', 'txt']),
            'Document must be PDF, DOC, DOCX, or TXT'
        )
    )

# Usage
document = DocumentModel(
    document_name='User Manual',
    file_path='manual.pdf'
)

Best Practices

1. Error Handling

def safe_validator(validation_func) -> Callable[[Any, Optional[Dict[str, Any]]], bool]:
    '''
    Decorator to handle errors in validators

    Args:
        validation_func (Callable[[Any, Optional[Dict[str, Any]]], bool]): Validator function

    Returns:
        Callable[[Any, Optional[Dict[str, Any]]], bool]: Wrapped validator function
    '''
    def wrapper(value, context=None):
        try:
            return validation_func(value, context)
        except Exception:
            return False
    return wrapper

@safe_validator
def complex_validation(value, context=None):
    # Validation code that might throw exceptions
    result = some_complex_operation(value)
    return result.is_valid()

2. Composable Validators

def compose_validators(*validators) -> Callable[[Any, Optional[Dict[str, Any]]], bool]:
    '''
    Combines multiple validators into one

    Args:
        *validators (Callable[[Any, Optional[Dict[str, Any]]], bool]): Validator functions

    Returns:
        Callable[[Any, Optional[Dict[str, Any]]], bool]: Composed validator function
    '''
    def composed_validator(value, context=None):
        return all(validator(value, context) for validator in validators)

    return composed_validator

# Usage
def is_valid_product_code(value, context=None):
    # Specific product code validations
    return True

def is_unique_product_code(value, context=None):
    # Check uniqueness in database
    return True

class ProductModel(ValidatedModel):
    product_code: str = field_validated(
        custom(
            compose_validators(is_valid_product_code, is_unique_product_code),
            'Invalid or duplicate product code'
        )
    )

3. Testing Validators

import pytest

def test_custom_validator():
    def is_palindrome(value, context=None):
        if value is None:
            return True
        clean_value = str(value).replace(' ', '').lower()
        return clean_value == clean_value[::-1]

    # Tests
    assert is_palindrome('racecar') == True
    assert is_palindrome('race a car') == False
    assert is_palindrome('A man a plan a canal Panama') == False
    assert is_palindrome(None) == True

def test_validator_with_model():
    class TestModel(ValidatedModel):
        palindrome_text: str = field_validated(
            custom(is_palindrome, 'Text must be a palindrome')
        )

    # Valid case
    model = TestModel(palindrome_text='racecar')
    assert model.palindrome_text == 'racecar'

    # Invalid case
    with pytest.raises(ValidationException):
        TestModel(palindrome_text='not a palindrome')

# Run: pytest test_custom_validators.py -v

4. Performance Optimization

from functools import lru_cache

@lru_cache(maxsize=1000)
def expensive_validation(value) -> bool:
    '''
    Cached expensive validation

    Args:
        value (Any): Value to validate

    Returns:
        bool: True if validation passes, False otherwise
    '''
    # Expensive operation here
    import time
    time.sleep(0.1)  # Simulate expensive operation
    return len(str(value)) > 5

class OptimizedModel(ValidatedModel):
    data: str = field_validated(
        custom(expensive_validation, 'Data validation failed')
    )

Custom validators provide the flexibility to implement any validation logic specific to your business requirements while maintaining consistency with PyValidX's validation framework.