Runtime Type Checking#
Koda Validate supports runtime type-checking via validate_signature
, a decorator
that can validate function arguments and return values at
runtime. By default, it will infer the validation logic from typehints.
from koda_validate.signature import validate_signature
@validate_signature
def add(x: int, y: int) -> int:
return x + y
Usage
>>> add(1,2)
3
>>> add("not", "ints") # bad types
Traceback (most recent call last):
...
koda_validate.signature.InvalidArgsError:
Invalid Argument Values
-----------------------
x='not'
expected <class 'int'>
y='ints'
expected <class 'int'>
validate_signature
raises an InvalidArgsError
when arguments
are invalid or InvalidReturnError
when the returned value is invalid.
Note
While the main interface for the developer is usually the formatted output in the traceback,
InvalidArgsError
and InvalidReturnError
both contain all relevant
Invalid
objects on InvalidArgsError.errs
or
InvalidReturnError.err
validate_signature
works on class methods as well, and with many different kinds of arguments.
from koda_validate.signature import *
class Obj:
@validate_signature
def some_method(self, a: int, *, b: int = 5) -> int:
return a + b
Usage
>>> Obj().some_method(1, b=2)
3
>>> Obj().some_method("oops", b=3)
Traceback (most recent call last):
...
koda_validate.signature.InvalidArgsError:
Invalid Argument Values
-----------------------
a='oops'
expected <class 'int'>
Note
You can simply decorate the __init__
method of a class with validate_signature
if you want to disable object creation for invalid arguments.
Customization#
validate_signature
is wholly customizable, so it can fit practically any use case.
Ignoring Arguments and Return Values#
Perhaps the simplest customization to
make is to tell validate_signature
what to ignore. For that you can use ignore_args
and
ignore_return
.
from koda_validate.signature import *
@validate_signature(ignore_args={"b"}, ignore_return=True)
def add_float_to_int(a: int, b: float) -> float:
return a + b
assert add_float_to_int(1, 2) == 3
validate_signature
did not raise an Exception
even though
the argument for b
and the return type were both invalid. ignore_args should
work for any parameter in a function signature.
Note
ignore_args
will even work for parameters defined in **kwargs
(not in the signature)
from koda_validate.signature import validate_signature
@validate_signature(ignore_args={"violets"})
def some_func(**descriptions: str) -> None:
return None
# didn't raise even though violets is not a string
assert some_func(roses="red", violets=2) is None
Annotated Validators#
You can use typing.Annotated
to customize how the arguments and / or return value are
validated – using the same kinds of Validator
s used in data validation.
from koda_validate import StringValidator, MinLength, MaxLength
from koda_validate.signature import validate_signature
from typing import Annotated
@validate_signature
def reverse_name(
name: Annotated[str, StringValidator(MinLength(1), MaxLength(20))]
) -> Annotated[str, StringValidator(MinLength(1), MaxLength(20))]:
return name[::-1]
Let’s try it.
>>> reverse_name("Jen") # a valid name
'neJ'
>>> reverse_name("") # too short
Traceback (most recent call last):
...
koda_validate.signature.InvalidArgsError:
Invalid Argument Values
-----------------------
name=''
PredicateErrs
MinLength(length=1)
>>> reverse_name("areallylongnametohave") # too long
Traceback (most recent call last):
...
koda_validate.signature.InvalidArgsError:
Invalid Argument Values
-----------------------
name='areallylongnametohave'
PredicateErrs
MaxLength(length=20)
Overrides#
If you are using Python 3.8, or if you don’t like the Annotated
syntax, you can achieve
the same thing with overrides
. This is equivalent to the Annotated
example above`:
from koda_validate import StringValidator, MinLength, MaxLength
from koda_validate.signature import validate_signature, RETURN_OVERRIDE_KEY
@validate_signature(overrides={
"name": StringValidator(MinLength(1), MaxLength(20)),
RETURN_OVERRIDE_KEY: StringValidator(MinLength(1), MaxLength(20))
})
def reverse_name(name: str) -> str:
return name[::-1]
Note
RETURN_OVERRIDE_KEY
is a special key that allows us to override the default
Validator
for the return value. It’s the only
non-string key allowed in overrides
.
Typehint Resolution#
You can define your own typehint resolution logic by passing a function as the argument
for typehint_resolver
. One situation in which this can be useful is when defining NewType
s.
from typing import NewType, Any
from koda_validate import Validator, StringValidator, EmailPredicate
from koda_validate.signature import validate_signature, resolve_signature_typehint_default
Email = NewType('Email', str)
def custom_resolve_typehint(annotation: Any) -> Validator[Any]:
if annotation is Email:
return StringValidator(EmailPredicate())
else:
return resolve_signature_typehint_default(annotation)
@validate_signature(typehint_resolver=custom_resolve_typehint)
def message_someone(email: Email, message: str) -> str:
# send the message
return f"sent {message} to {email}"
Usage
>>> message_someone(Email("abc@example.com"), "hi!")
'sent hi! to abc@example.com'
>>> message_someone(Email("abc"), "hello!")
Traceback (most recent call last):
...
koda_validate.signature.InvalidArgsError:
Invalid Argument Values
-----------------------
email='abc'
PredicateErrs
EmailPredicate(pattern=re.compile('[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+'))
Overriding typehint resolution can also be helpful in places where Koda Validate cannot fully infer the correct resolver, such as with Generics.
Async#
Remaining consistent with the rest of Koda Validate validate_signature
also
supports async
functions.
from koda_validate.signature import *
@validate_signature
async def save_data(version: int, data: dict[str, str]) -> None:
# do some async saving logic
return None
When used on async functions, the validators assigned by validate_signature
run asynchronously. This means you can have any kind of async validation taking place. For instance,
if we want to change this code to check an external service to make sure we’re using the latest
version, we could do something like this:
from typing import Annotated
from koda_validate import *
from koda_validate.signature import *
class CheckLatestVersion(PredicateAsync[int]):
async def validate_async(self, val: int) -> bool:
# should be something like
# latest_version = await get_latest_version(val)
# for simplicity, we'll pretend the service returned 5
latest_version = 5
return val == latest_version
@validate_signature
async def save_data(
version: Annotated[int,
IntValidator(predicates_async=[CheckLatestVersion()])],
data: dict[str, str]
) -> None:
# do some async saving logic
return None
Usage:
>>> import asyncio
>>> asyncio.run(save_data(5, {"name": "Bob Loblaw"})) # returns None
>>> asyncio.run(save_data(4, {"name": "Bob Loblaw"}))
Traceback (most recent call last):
...
koda_validate.signature.InvalidArgsError:
Invalid Argument Values
-----------------------
version=4
PredicateErrs
<CheckLatestVersion object at 0x1059a2e90>
Caveats#
As with data validation, type inference is limited. If Koda Validate cannot
infer a specific validator for a type, it will fallback to a class instance check
– if the type is a class. The most obvious cases this fails to cover are generics. In these
cases, it’s usually best to provide your own Validator
through
Annotated Validators or
Overrides.