Typesafe, combinable validation
Project description
Koda Validate
Validate Anything. Faster!
Koda Validate is:
- flexible
- explicit
- fully asyncio-compatible
- type-driven (works with type hints without plugins)
- easily inspected -- build API schemas from validators
Contents
- Installation
- The Basics
- Philosophy
- Extension
- Validation Errors
- Async Validation
- Using Metadata
- Tour
- Comparison to Pydantic
Installation
Python 3.8+
pip
pip install koda-validate
Poetry
poetry add koda-validate
The Basics
Scalars
from koda_validate import *
string_validator = StringValidator()
string_validator("hello world")
# > Valid('hello world')
string_validator(5)
# > Invalid(['expected a string'])
Note that you can pattern match on validated data on python >= 3.10
# continued from above
match string_validator("new string"):
case Valid(valid_val):
print(f"{valid_val} is valid!")
case Invalid(err):
print(f"got error: {err}")
# prints: "new string is valid"
You can also use .is_valid
on python >= 3.8+:
# continued from above
if (result := string_validator("another string")).is_valid:
print(f"{result.val} is valid!")
else:
print(f"got error: {result.val}")
# prints: "another string is valid"
Mypy understands .is_valid
and narrows the Validated
type to Valid
or Invalid
appropriately.
Lists
from koda_validate import *
validator = ListValidator(StringValidator())
validator(["cool"])
# > Valid(['cool'])
validator([5])
# > Invalid({'0': ['expected a string']}))
Record-like Dictionaries
from dataclasses import dataclass
from koda_validate import *
@dataclass
class Person:
name: str
hobbies: list[str]
person_validator = RecordValidator(
keys=(
("name", StringValidator()),
("hobbies", ListValidator(StringValidator())),
),
into=Person
)
print(person_validator({"name": "Bob",
"hobbies": ["eating", "running"]}))
# > Valid(Person(name='Bob', hobbies=['eating', 'running']))
Map-like Dictionaries
from koda_validate import *
str_to_int_validator = MapValidator(key=StringValidator(),
value=IntValidator())
assert str_to_int_validator({"a": 1, "b": 25, "xyz": 900}) == Valid(
{"a": 1, "b": 25, "xyz": 900}
)
Schema-ed Dictionaries
from koda_validate import *
person_validator = DictValidatorAny({
"name": StringValidator(),
"age": IntValidator(),
})
result = person_validator({"name": "John Doe", "age": 30})
if isinstance(result, Valid):
print(f"{result.val['name']} is {result.val['age']} years old")
else:
print(result.val)
# prints: "John Doe is 30 years old"
Note that DictValidatorAny
is not typesafe.
Some of what we've seen so far:
- All validators we've created are simple
Callable
s that return anValid
instance when validation succeeds, or anInvalid
instance when validation fails. - Nesting one validator within another is straightforward
- We have multiple means of validating dictionaries
- RecordValidator requires a separate target for its validated data; more on that here.
It's worth noting that all this code is typesafe (aside from DictValidatorAny, which is explicitly not typesafe). No plugins are required for mypy.
Philosophy
At it's core, Koda Validate is based on a few simple ideas about what validation is. This allows Koda Validate to be extended to validate essentially any kind of data. It also generally allows for less code, and clearer paths to optimization than other approaches.
Validators
In Koda Validate, Validator
is the fundamental validation building block. It's based on the idea that
validation can be universally represented by the function signature (pseudocode):
InputType -> ValidType | InvalidType
In Koda Validate this looks more like:
Callable[[InputType], Validated[ValidType, InvalidType]]
A quick example:
from koda_validate import IntValidator, Valid, Invalid
int_validator = IntValidator()
assert int_validator(5) == Valid(5)
assert int_validator("not an integer") == Invalid(["expected an integer"])
Here, we can tell the type of int_validator
is something like Callable[[Any], Validated[int, List[str]]
(it's not exactly
that in reality, but it isn't far off.) In this case, the InputType
is Any
-- any kind of data can be submitted to validation; if the data is valid it returns Valid[int]
; and
if it's invalid it returns Invalid[List[str]]
.
This is a useful model to have for validation, because it means we can combine validators in different ways (i.e. nesting them), and have our model of validation be consistent throughout.
Take a look at Extension to see how to build custom Validator
s.
Predicates
In the world of validation, predicates are simple expressions that return a True
or False
for a given condition. Koda Validate uses a
class based on this concept, Predicate
, to enrich Validator
s. Because the type and value of a Validator
's valid state may
differ from those of its input, it's difficult to do something like apply a list of Validator
s to a given value:
even if the types all match up, there's no assurance that the values won't change from one validator to the next.
The role of a Predicate
in Koda Validate is to perform additional validation after the data has been verified to be
of a specific type or shape. To this end, Predicate
s in Koda Validate cannot change their input types or values. Let's go further with our IntValidator
:
from koda_validate import *
int_validator = IntValidator(Min(5))
assert int_validator(6) == Valid(6)
assert int_validator(4) == Invalid(["minimum allowed value is 5"])
In this example Min(5)
is a Predicate
. As you can see the value 4
passes the int
check but fails to pass the Min(5)
predicate.
Because we know that predicates don't change the type or value of their inputs, we can sequence an arbitrary number of them together, and validate them all.
from koda_validate import *
int_validator = IntValidator(Min(5), Max(20), MultipleOf(4))
assert int_validator(12) == Valid(12)
assert int_validator(23) == Invalid([
"maximum allowed value is 20",
"must be a multiple of 4"
])
Here we have 3 Predicate
s, but we could easily have dozens. Note that the errors from all invalid
predicates are returned. This is possible because we know that the value should be consistent from one predicate to the next.
Predicate
s are easy to write -- take a look at Extension for more details.
Processors
Processor
s allow us to take a value of a given type and transform it into another value of that type. Processors are most useful
after type validation, but before predicates are checked. Here's an example:
from koda_validate import *
max_length_3_validator = StringValidator(
MaxLength(3),
preprocessors=[strip, upper_case]
)
assert max_length_3_validator(" hmm ") == Valid("HMM")
We see that the preprocessors
stripped the whitespace from " hmm "
and then transformed it to upper-case before
it was checked against the MaxLength(3)
Predicate
.
Processors are very simple to write -- see Extension for more details.
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.
Even though there is an existing FloatValidator
in Koda Validate, we'll build our own. (Extension does not
need to be limited to new functionality; it can also be writing alternatives to the default for custom needs.)
from typing import Any
from koda_validate import *
class SimpleFloatValidator(Validator[Any, float, Serializable]):
def __call__(self, val: Any) -> Validated[float, Serializable]:
if isinstance(val, float):
return Valid(val)
else:
return Invalid("expected a float")
float_validator = SimpleFloatValidator()
test_val = 5.5
assert float_validator(test_val) == Valid(test_val)
assert float_validator(5) == Invalid("expected a float")
What is this doing?
- extending
Validator
, using the following types:Any
: any type of input can be passed in to be validatedfloat
: if the data is valid, a value of typeValid[float]
will be returnedSerializable
: if it's invalid, a value of typeInvalid[Serializable]
will be returned- note that mypy understands the role of all of these types
- the
__call__
method performs any kind of validation needed, so long as the input and output type signatures -- as determined by theValidator
type parameters - are abided
We accept Any
because the type of input may be unknown before submitting to the Validator
. After our
validation in SimpleFloatValidator
succeeds, we know the type must be float
. (Note that we could have coerced the value
to a float
instead of checking its type -- that is 100% OK to do. For simplicity's sake, this validator does not coerce.)
This is all well and good, but we'll probably want to be able to validate against values of the floats, such as min,
max, or rough equality checks. For this we use Predicate
s. For example, if we wanted to allow a single Predicate
in
our SimpleFloatValidator
we could do it like this:
from dataclasses import dataclass
from typing import Any, Optional
from koda_validate import *
@dataclass
class SimpleFloatValidator2(Validator[Any, float, Serializable]):
predicate: Optional[Predicate[float, Serializable]] = None
def __call__(self, val: Any) -> Validated[float, Serializable]:
if isinstance(val, float):
if self.predicate:
return self.predicate(val)
else:
return Valid(val)
else:
return Invalid(["expected a float"])
If predicate
is specified, we'll check it after we've verified the type of the value.
Predicate
s are meant to validate the value of a known type -- as opposed to validating at the type-level (that's what the Validator
does).
For example, this is how you might write and use a Predicate
to validate a range of values:
# (continuing from previous example)
@dataclass
class Range(Predicate[float, Serializable]):
minimum: float
maximum: float
def is_valid(self, val: float) -> bool:
return self.minimum <= val <= self.maximum
def err(self, val: float) -> Serializable:
return f"expected a value in the range of {self.minimum} and {self.maximum}"
range_validator = SimpleFloatValidator2(Range(0.5, 1.0))
test_val = 0.7
assert range_validator(test_val) == Valid(test_val)
assert range_validator(0.01) == Invalid(["expected a value in the range of 0.5 and 1.0"])
Notice that in Predicate
s we define is_valid
and err
methods, while in Validator
s we define the
entire __call__
method. This is because the base Predicate
class is constructed in such a way that we limit how
much it can actually do -- we don't want it to be able to alter the value being validated.
Finally, let's add a Processor
. A Processor
is a function that takes a value of one type and then produces another
value of that type. In our case, we want to preprocess our float
s by converting them to their absolute value.
# (continuing from previous example)
@dataclass
class SimpleFloatValidator3(Validator[Any, float, Serializable]):
predicate: Optional[Predicate[float, Serializable]] = None
preprocessor: Optional[Processor[float]] = None
def __call__(self, val: Any) -> Validated[float, Serializable]:
if isinstance(val, float):
if self.preprocessor:
val = self.preprocessor(val)
if self.predicate:
return self.predicate(val)
else:
return Valid(val)
else:
return Invalid(["expected a float"])
class AbsValue(Processor[float]):
def __call__(self, val: float) -> float:
return abs(val)
range_validator_2 = SimpleFloatValidator3(
predicate=Range(0.5, 1.0),
preprocessor=AbsValue()
)
test_val = -0.7
assert range_validator_2(test_val) == Valid(abs(test_val))
assert range_validator_2(-0.01) == Invalid('expected a value in the range of 0.5 and 1.0')
Note that we pre-process after type checking but before the predicates are run. This is the general approach
Koda Validate takes on built-in validators. More specifically, the built-in validators expect there to be a pipeline of
actions taken within a Validator
: type-check/coerce -> preprocess -> validate predicates
, where it can fail
validation at either the first or last stage.
Note that what we've written are a number of classes that simply conform to some type constraints. It's worth remembering that
there's nothing enforcing the particular arrangement of logic we have in our SimpleFloatValidator
. If you want to have a
post-processing step, you can. If you want to validate an iso8601 string is a datetime, and then convert that to an unix
epoch timestamp, and provide pre-processing, post-processing and predicates for all those steps, you can. It's important
to remember that our Validator
, Predicate
(and PredicateAsync
-- see Async Validation), and
Processor
objects are little more than functions with accessible metadata. You can do whatever you want with them.
Validation Errors
In Koda Validate errors are returned as data as part of normal control flow. Invalid
instances from built-in Validator
s
contain JSON/YAML serializable values. (Should you build your own custom validators, there is no contract enforcing
that constraint.) Here are a few examples of the kinds of errors you can expect to see out of the box.
from dataclasses import dataclass
from koda import Maybe
from koda_validate import *
# Wrong type
assert StringValidator()(None) == Invalid(["expected a string"])
# All failing `Predicate`s are reported (not just the first)
str_choice_validator = StringValidator(MinLength(2),
Choices({"abc", "yz"}))
assert str_choice_validator("") == Invalid(
["minimum allowed length is 2", "expected one of ['abc', 'yz']"]
)
@dataclass
class City:
name: str
region: Maybe[str]
city_validator = RecordValidator(
into=City,
keys=(
("name", StringValidator(not_blank)),
("region", KeyNotRequired(StringValidator(not_blank))),
),
)
# We use the key "__container__" for object-level errors
assert city_validator(None) == Invalid({"__container__": ["expected a dictionary"]})
# Missing keys are errors
print(city_validator({}))
assert city_validator({}) == Invalid({"name": ["key missing"]})
# Extra keys are also errors
assert city_validator(
{"region": "California", "population": 510, "country": "USA"}
) == Invalid(
{"__container__": ["Received unknown keys. Only expected 'name', 'region'."]}
)
@dataclass
class Neighborhood:
name: str
city: City
neighborhood_validator = RecordValidator(
into=Neighborhood,
keys=(("name", StringValidator(not_blank)), ("city", city_validator)),
)
# Errors are nested in predictable manner
assert neighborhood_validator({"name": "Bushwick", "city": {}}) == Invalid(
{"city": {"name": ["key missing"]}}
)
If you have any concerns about being able to handle specific types of key or object requirements, please see
the documentation on specific validators below:
- RecordValidator
- DictValidatorAny
- MapValidator
- OneOf2 / OneOf3
- OptionalValidator
- is_dict_validator
- Lazy
Async Validation
Because Koda Validate is based on simple principles, it's straightforward to make it compatible with asyncio
.
All the built-in Validator
s in Koda are asyncio-compatible -- all you need to do is call a Validator
in this form:
await validator.validate_async("abc")
instead of:
validator("abc")
For example, this is how you could re-use the same StringValidator
in both
sync and async contexts:
import asyncio
from koda_validate import *
short_string_validator = StringValidator(MaxLength(10))
assert short_string_validator("sync") == Valid("sync")
# we're not in an async context, so we can't use `await` here; instead we use asyncio.run
assert asyncio.run(short_string_validator.validate_async("async")) == Valid("async")
Synchronous validators can be used in both async and sync contexts. So while this Validator works in async mode, it isn't yielding any benefit for IO. It would be much more useful if we were doing something like querying a database asynchronously:
import asyncio
from koda_validate import *
class IsActiveUsername(PredicateAsync[str, Serializable]):
async def is_valid_async(self, val: str) -> bool:
# add some latency to pretend we're calling the db
await asyncio.sleep(.01)
return val in {"michael", "gob", "lindsay", "buster"}
async def err_async(self, val: str) -> Serializable:
return "invalid username"
username_validator = StringValidator(MinLength(1),
MaxLength(100),
predicates_async=[IsActiveUsername()])
assert asyncio.run(username_validator.validate_async("michael")) == Valid("michael")
assert asyncio.run(username_validator.validate_async("tobias")) == Invalid(["invalid username"])
# calling in sync mode raises an AssertionError
try:
username_validator("michael")
except AssertionError as e:
print(e)
In this example we are calling the database to verify a username. A few things worth pointing out:
PredicateAsync
s are specified in the predicates_async
keyword argument -- separately from Predicates
. We do this to be
explicit -- we don't want to be confused about whether a validator requires asyncio
. (If you try to run this validator in
synchronous mode, it will raise an AssertionError
-- instead make sure you call it like
await username_validator.validate_async("buster")
.)
Like other validators, you can nest async Validator
s. Again, the only difference needed is to use the .validate_async
method of the outer-most validator.
# continued from previous example
username_list_validator = ListValidator(username_validator)
assert asyncio.run(username_list_validator.validate_async(["michael", "gob", "lindsay", "buster"])) == Valid([
"michael", "gob", "lindsay", "buster"
])
You can run async validation on nested lists, dictionaries, tuples, strings, etc. All Validator
s built into to Koda Validate
understand the .validate_async
method.
Koda Validate makes no assumptions about running async Validator
s or PredicateAsync
s concurrently; it is expected that that is
handled by the surrounding context. That is to say, async validators will not block when performing IO -- as is normal -- but if you had, say, 10 async
predicates, they would not be run in parallel by default. This is simply because that is too much of an assumption for this library to make -- we don't
want to accidentally send N simultaneous requests to some other service without the intent being explicitly defined. If you'd like to have Validator
s
or Predicate
s run in parallel within the validation step, all you should need to do is write a simple wrapper class based on either Validator
or Predicate
, implementing whatever concurrency needs you have.
For custom async Validator
s, all you need to do is implement the validate_async
method on a Validator
class. There is no
separate async-only Validator
class. This is because we might want to re-use synchronous validators in either synchronous
or asynchronous contexts. Here's an example of making a SimpleFloatValidator
async-compatible:
import asyncio
from typing import Any
from koda_validate import *
class SimpleFloatValidator(Validator[Any, float, Serializable]):
def __call__(self, val: Any) -> Validated[float, Serializable]:
if isinstance(val, float):
return Valid(val)
else:
return Invalid("expected a float")
# this validator doesn't do any IO, so we can just use the `__call__` method
async def validate_async(self, val: Any) -> Validated[float, Serializable]:
return self(val)
float_validator = SimpleFloatValidator()
test_val = 5.5
assert asyncio.run(float_validator.validate_async(test_val)) == Valid(test_val)
assert asyncio.run(float_validator.validate_async(5)) == Invalid("expected a float")
If your Validator
only makes sense in an async context, then you probably don't need to implement the __call__
method.
Instead, you'd just implement the .validate_async
method and make sure that validator is always called by await
-ing
the .validate_async
method. A NotImplementedError
will be raised if you try to use the __call__
method on an
async-only Validator
.
Using Metadata
One of Koda Validate's design objectives is to allow reuse of validator metadata. Principally this is useful in generating descriptions of the validator's constraints -- one example could be generating an OpenAPI (or other) schema. Here we'll do something simpler and use validator metadata to build a function which can return plaintext descriptions of validators:
from typing import Any
from koda_validate import *
def describe_validator(validator: Validator[Any, Any, Any] | Predicate[Any, Any]) -> str:
# use `isinstance(...)` in python <= 3.10
match validator:
case StringValidator(predicates):
predicate_descriptions = [
f"- {describe_validator(pred)}" for pred in predicates
]
return "\n".join(["validates a string"] + predicate_descriptions)
case MinLength(length):
return f"minimum length {length}"
case MaxLength(length):
return f"maximum length {length}"
# ...etc
case _:
raise TypeError(f"unhandled validator type. got {type(validator)}")
print(describe_validator(StringValidator()))
# validates a string
print(describe_validator(StringValidator(MinLength(5))))
# validates a string
# - minimum length 5
print(describe_validator(StringValidator(MinLength(3), MaxLength(8))))
# validates a string
# - minimum length 3
# - maximum length 8
All we're doing here, of course, is writing an interpreter. For the sake of brevity this one is very simple, but it's
straightforward to extend the logic. This is easy to do because, while the validators are Callable
s at their
core, they are also classes that can easily be inspected. Interpreters are the recommended way to re-use validator metadata for
non-validation purposes.
Tour
Here are some noteworthy parts of Koda Validate explained in more detail.
RecordValidator
RecordValidator
is a flexible way to validate a dictionary into some other form (or back into a dictionary, if desired). It
is primarily for record-like dictionaries (MapValidator is recommended for map-like dictionaries).
from dataclasses import dataclass
from koda import Maybe, Just
from koda_validate import *
@dataclass
class Person:
name: str
age: Maybe[int]
person_validator = RecordValidator(
into=Person,
keys=(
("full name", StringValidator()),
("age", KeyNotRequired(IntValidator())),
),
)
# you can use `isinstance` in python 3.8 or 3.9 instead of `match`
match person_validator({"full name": "John Doe", "age": 30}):
case Valid(person):
match person.age:
case Just(age):
age_message = f"{age} years old"
case nothing:
age_message = "ageless"
print(f"{person.name} is {age_message}")
case Invalid(errs):
print(errs)
What do we see here?
into=Person
:RecordValidator
is fundamentally de-coupled from it's target -- in this case thePerson
class. It can target anyCallable
that accepts the validated values from the keys, in the same order they are defined -- the names of the keys do not matter to the target. Often it will make sense to target some kind ofdataclass
orNamedTuple
that conforms to the needed args, but you can use any Callable that accepts the correct arguments.KeyNotRequired
results in aMaybe
value. In the example above, age was returned asJust(30)
because the key was present. If the key was not present, the validation could still have succeeded, but the value for age would benothing
. This gives us an explicit indication of whether a key was present or not."full name"
: keys are not bound to any kind of special form. They don't need to be strings that are valid attribute names; they don't even need to be strings.
RecordValidator
is extremely flexible because it can handle any kind of dictionary key, and whether it is required or not. So,
you can validate weird data like this:
from typing import List
from dataclasses import dataclass
from koda import Maybe, Just
from koda_validate import *
@dataclass
class Person:
name: str
age: Maybe[int]
hobbies: List[str]
person_validator = RecordValidator(
into=Person,
keys=(
(1, StringValidator()),
(False, KeyNotRequired(IntValidator())),
(("abc", 123), ListValidator(StringValidator()))
),
)
assert person_validator({
1: "John Doe",
False: 30,
("abc", 123): ["reading", "cooking"]
}) == Valid(Person(
"John Doe",
Just(30),
["reading", "cooking"]
))
In the opinion of this library, yes, dictionaries that use integers, bools, and tuples as keys simultaneously are weird. But we still validate it :sparkles:
In RecordValidator
, you can also validate the entire object after keys are validated by providing a validate_object
argument.
from dataclasses import dataclass
from koda_validate import *
@dataclass
class Employee:
title: str
name: str
def no_dwight_regional_manager(employee: Employee) -> Validated[Employee, Serializable]:
if (
"schrute" in employee.name.lower()
and employee.title.lower() == "assistant regional manager"
):
return Invalid("Assistant TO THE Regional Manager!")
else:
return Valid(employee)
employee_validator = RecordValidator(
into=Employee,
keys=(
("title", StringValidator(not_blank, MaxLength(100), preprocessors=[strip])),
("name", StringValidator(not_blank, preprocessors=[strip])),
),
# After we've validated individual fields, we may want to validate them as a whole
validate_object=no_dwight_regional_manager,
)
# The fields are valid but the object as a whole is not.
assert employee_validator(
{
"title": "Assistant Regional Manager",
"name": "Dwight Schrute",
}
) == Invalid("Assistant TO THE Regional Manager!")
In this case the values of individual keys are valid, but the object as a whole is not.
It's worth noting you can specify validate_object_async
instead if you need to use asyncio in your validation. Remember,
you must use the .validate_async
method when doing any kind of async validation.
Limitations
RecordValidator
is currently limited to at-most 16 keys. This is simply because mypy gets slower and slower
when typechecking against the @overload
s for RecordValidator's __init__
method. In the uncommon case that
you need to validate 16+ fields on a record-like object, you may be able to use DictValidatorAny
, MapValidator
, or, in some cases,
OneOf2
/OneOf3
in combination with RecordValidator
. There is also the possibility to generate the code into your project
if you want more keys:
# allow up to 30 keys
python /path/to/koda-validate/codegen/generate.py /your/target/directory --num-keys 30
DictValidatorAny
DictValidatorAny
is somewhat similar to RecordValidator
, but there are several key differences:
- It accepts a dictionary
schema
instead of a tuple of key / value 2-tuples. - It does not narrow the types on either the key or the value. If valid, the type of returned data will be
Dict[Any, Any]
. (This is why it hasAny
in its name.) - It can allow for arbitrary amounts of keys
- It passes along the keys, so the validated object may appear quite similar to the input. Note that it will always return a new dictionary (if valid), and it is legal for values to differ from the input.
This is an equivalent example to the last RecordValidator
example above.
from typing import Any, Dict, Hashable
from koda_validate import *
def no_dwight_regional_manager(
employee: Dict[Hashable, Any]
) -> Validated[Dict[Hashable, Any], Serializable]:
if (
"schrute" in employee["name"].lower()
and employee["title"].lower() == "assistant regional manager"
):
return Invalid("Assistant TO THE Regional Manager!")
else:
return Valid(employee)
employee_validator = DictValidatorAny(
{
"title": StringValidator(not_blank, MaxLength(100), preprocessors=[strip]),
"name": StringValidator(not_blank, preprocessors=[strip]),
},
# After we've validated individual fields, we may want to validate them as a whole
validate_object=no_dwight_regional_manager,
)
assert employee_validator(
{"name": "Jim Halpert", "title": "Sales Representative"}
) == Valid({"name": "Jim Halpert", "title": "Sales Representative"})
assert employee_validator(
{
"title": "Assistant Regional Manager",
"name": "Dwight Schrute",
}
) == Invalid("Assistant TO THE Regional Manager!")
ListValidator
ListValidator
validates whether some value is a list. It requires a validator to validate each item in the list. It
can have predicates (number of items, etc.), as well as preprocessors.
from koda_validate import *
binary_list_validator = ListValidator(
IntValidator(Choices({0, 1})),
predicates=[MinItems(2)]
)
assert binary_list_validator([1, 0, 0, 1, 0]) == Valid([1, 0, 0, 1, 0])
assert binary_list_validator([1]) == Invalid({'__container__': ['minimum allowed length is 2']})
assert binary_list_validator([0, 1.0, "0"]) == Invalid({'1': ['expected an integer'], '2': ['expected an integer']})
In case you're looking at the last example and wondering why the indexes '1'
and '2'
are strings, it's because all
built-in validators in Koda Validate return JSON serializable data. In JSON, keys in objects are only allowed to
be strings.
MapValidator
MapValidator
allows us to validate dictionaries that are mappings of one type to another type, where we don't
need to be concerned about individual keys or values:
from koda_validate import *
str_to_int_validator = MapValidator(key=StringValidator(),
value=IntValidator())
assert str_to_int_validator({"a": 1, "b": 25, "xyz": 900}) == Valid(
{"a": 1, "b": 25, "xyz": 900}
)
assert str_to_int_validator({3.14: "pi!"}) == Invalid({
'3.14': {'key_error': ['expected a string'],
'value_error': ['expected an integer']}
})
OneOf2 / OneOf3
OneOfN validators are useful when you may have multiple valid shapes of data.
from koda import First, Second
from koda_validate import *
string_or_list_string_validator = OneOf2(
StringValidator(), ListValidator(StringValidator())
)
assert string_or_list_string_validator("ok") == Valid(First("ok"))
assert string_or_list_string_validator(["list", "of", "strings"]) == Valid(
Second(["list", "of", "strings"])
)
Tuple2Validator / Tuple3Validator
These Validator
s work on tuple
s as you might expect:
from koda_validate import *
string_int_validator = Tuple2Validator(StringValidator(), IntValidator())
assert string_int_validator(("ok", 100)) == Valid(("ok", 100))
# also ok with lists
assert string_int_validator(["ok", 100]) == Valid(("ok", 100))
Lazy
Lazy
's main purpose is to allow for the use of recursion in validation. An example use case of this might be replies
in a comment thread. This can be done with mutually recursive functions. For simplicity, here's an example of parsing a
kind of non-empty list.
from typing import Any, Optional, Tuple
from koda_validate import *
# if enable_recursive_aliases = true in mypy
# NonEmptyList = Tuple[int, Optional["NonEmptyList"]]
NonEmptyList = Tuple[int, Optional[Any]]
def recur_non_empty_list() -> Tuple2Validator[int, Optional[NonEmptyList]]:
return non_empty_list_validator
non_empty_list_validator = Tuple2Validator(
IntValidator(),
OptionalValidator(Lazy(recur_non_empty_list)),
)
assert non_empty_list_validator((1, (1, (2, (3, (5, None)))))) == Valid(
(1, (1, (2, (3, (5, None)))))
)
OptionalValidator
OptionalValidator
is very simple. It validates a value is either None
or passes another validator's rules.
from koda_validate import *
optional_int_validator = OptionalValidator(IntValidator())
assert optional_int_validator(5) == Valid(5)
assert optional_int_validator(None) == Valid(None)
is_dict_validator
A very simple validator that only validates that and object is a dict. It doesn't do any validation against keys or values.
from koda_validate import *
assert is_dict_validator({}) == Valid({})
assert is_dict_validator(None) == Invalid({"__container__": ["expected a dictionary"]})
assert is_dict_validator({"a": 1, "b": 2, 5: "xyz"}) == Valid({"a": 1, "b": 2, 5: "xyz"})
AlwaysValid
Will always return Valid
with the given value:
from koda_validate import *
assert always_valid(123) == Valid(123)
assert always_valid("abc") == Valid("abc")
Comparison to Pydantic
Comparing Koda Validate and Pydantic is not exactly apples-to-apples, since Koda Validate is more narrowly aimed at just validation -- Pydantic has a lot of other bells and whistles. Nonetheless, this is one of the most common questions, and there are a number of noteworthy differences:
- Koda Validate is built around a simple, composable definition of what validation is.
- Koda Validate treats validation explicitly. It does not coerce types or mutate values in surprising ways.
- Koda Validate treats validation as part of normal control flow. It does not raise exceptions for invalid data.
- Koda Validate is fully asyncio-compatible.
- Koda Validate is ~1.5 - 12x faster. You will see differences on different versions of Python
(Python3.8 tends to show the least difference) and different systems. You can run the suite on your
system with
python -m bench.run
. Disclaimer that the benchmark suite is not extensive. - Koda Validate is pure Python.
- Koda Validate is intended to empower validator documentation. You can easily produce things like API schemas from
Validator
s,Predicate
s, andProcessor
s - Koda Validate requires no plugins for mypy compatibility.
- Pydantic has a large, mature ecosystem. Lots of documentation, lots of searchable info on the web.
- Pydantic focuses on having a familiar, dataclass-like syntax.
- Pydantic has a lot of features Koda Validate does not. Plugins, ORM tie-ins, etc. There will probably never be feature parity between the two libraries.
Something's Missing Or Wrong
Open an issue on GitHub please!
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Hashes for koda_validate-2.0.0-py3-none-any.whl
Algorithm | Hash digest | |
---|---|---|
SHA256 | 6e61ea61073de9aa0b4ff68162bf27a8db66a8bba28416b1b7f8fa70cc594144 |
|
MD5 | 978f912b28dd3bcf2bdbb8eaf30976d0 |
|
BLAKE2b-256 | 0b992c8523ed52c0b09d7ef7dc5e8eed2b71440be4eea462c344d08097f19600 |