Extension#

Koda Validate aims to provide enough tools to handle most common validation needs; for the cases it doesn’t cover, it aims to allow easy extension.

Validators#

We’ll build a simple Validator for float values to demonstrate how Validators are built.

from typing import Any
from koda_validate import ValidationResult, Invalid, Valid, Validator, TypeErr


class SimpleFloatValidator(Validator[float]):
    # `__call__` allows a `SimpleFloatValidator` instance to be called like a function
    def __call__(self, val: Any) -> ValidationResult[float]:
        if isinstance(val, float):
            return Valid(val)
        else:
            return Invalid(TypeErr(float), val, self)

Some notes:

  • Validator is subclassed and parameterized with float. This just means a value of type Valid[float] must be returned when valid

  • __call__ accepts Any because the type of input may be unknown before submitting to the Validator

  • Invalid is returned with all relevant validation context for downstream use, namely:

    • an error type (TypeErr(float))

    • the value being validated (val)

    • a reference to the validator being used (self)

Here’s how our Validator can be used:

>>> float_validator = SimpleFloatValidator()
>>> float_validator(5.5)
Valid(val=5.5)
>>> float_validator(5)
Invalid(err_type=TypeErr(expected_type=<class 'float'>), value=5, ...)

Predicates#

When we want to perform additional refinement against a value, we use Predicates. In the case of float, we might want to check things like min, max, or fuzzy equality. We’ll make a FloatMin Predicate to validate that float values are above some threshold:

from dataclasses import dataclass
from koda_validate import Predicate


@dataclass
class FloatMin(Predicate[float]):
    min: float

    def __call__(self, val: float) -> bool:
        return val >= self.min

We can use FloatMin on its own, but it’s not terribly useful.

>>> min_5 = FloatMin(5.0)
>>> min_5(5.678)
True
>>> min_5(1.23)
False

Predicates are more useful when we allow them to work with Validators. For simplicity, we’ll allow just one.

from dataclasses import dataclass
from typing import Any, Optional
from koda_validate import Validator, Predicate, ValidationResult, PredicateErrs, Valid, Invalid


class SimpleFloatValidator(Validator[float]):
    def __init__(self, predicate: Optional[Predicate[float]] = None) -> None:
        self.predicate = predicate

    def __call__(self, val: Any) -> ValidationResult[float]:
        if isinstance(val, float):
            if self.predicate(val):
                return Valid(val)
            else:
                return Invalid(PredicateErrs([self.predicate]), val, self)
        else:
            return Invalid(TypeErr(float), val, self)

In the code above, if Predicate is specified, we’ll check it after we’ve verified the type of the value.

>>> validator = SimpleFloatValidator(FloatMin(2.5))
>>> validator(3.14)
Valid(val=3.14)
>>> validator(1.1)
Invalid(err_type=PredicateErrs(predicates=[FloatMin(min=2.5)]), value=1.1, ...)

We limited the Validator to one Predicate for simplicity. In Koda Validate, Validators that accept predicates typically allow of a List of Predicates. Because Predicates cannot alter values, it’s safe to have as many as you want (i.e. SimpleFloatValidator(FloatMin(3.3), FloatMax(4.4), ...)).

Processors#

We can also conforming values using processors. For this example, we’ll say we want to convert floats to their absolute value before we validate it.

from koda_validate import Processor

class FloatAbs(Processor[float]):
    def __call__(self, val: float) -> float:
        return abs(val)

To allow a processor to be this to our Validator, we can change the code similarly to how we did with a Predicate.

from typing import Optional, Any

class SimpleFloatValidator(Validator[float]):
    def __init__(self,
                 predicate: Optional[Predicate[float]] = None,
                 preprocessor: Optional[Processor[float]] = None) -> None:
        self.predicate = predicate
        self.preprocessor = preprocessor

    def __call__(self, val: Any) -> ValidationResult[float]:
        if isinstance(val, float):
            if self.preprocessor:
                val = self.preprocessor(val)

            if self.predicate(val):
                return Valid(val)
            else:
                return Invalid(PredicateErrs([self.predicate]), val, self)
        else:
            return Invalid(TypeErr(float), val, self)

Usage:

>>> validator = SimpleFloatValidator(predicate=FloatMin(2.2), preprocessor=FloatAbs())
>>> validator(-5.5)
Valid(val=5.5)

Async#

There are only a few things to do differently if we want to make this Validator work asynchronously:

  • implement a validate_async method on the Validator (which should be very similar to the existing __call__ method)

  • if desired, allow for PredicateAsync predicates to be passed in

Then when you use the Validator in an async context, you just need to call it like:

validator = SimpleFloatValidator(...)
await validator.validate_async(5.5)

Note

It’s important to mention that you can build Validators, Predicates, and Processors to be initialized with any combination of attributes you want. The only contracts for these kinds of objects are on the __call__ and validate_async methods; otherwise you have complete freedom to structure the logic as you see fit.

This discussion has focused on extension only in terms of what we can validate. To learn more about how we inspect validators to add new capabilities, check out Metadata.