├── tests ├── __init__.py ├── test_serialization │ └── __init__.py ├── test_311_plus.py ├── test_bytes.py ├── utils.py ├── test_typedefs.py ├── test_313_plus.py ├── test_39_plus.py ├── test_lazy.py ├── test_is_type.py ├── test_maybe.py ├── test_boolean.py ├── test_integer.py ├── test_time.py ├── test_float.py ├── test_uuid.py ├── test_cache.py ├── test_union.py ├── test_none.py ├── test_valid.py ├── test_decimal.py ├── test_typehints.py └── test_string.py ├── koda_validate ├── py.typed ├── serialization │ ├── base.py │ └── __init__.py ├── _generics.py ├── integer.py ├── boolean.py ├── float.py ├── is_type.py ├── coerce.py ├── uuid.py ├── decimal.py ├── bytes.py ├── string.py ├── maybe.py ├── time.py ├── none.py ├── errors.py └── __init__.py ├── examples ├── django_example │ ├── django_tests │ │ ├── __init__.py │ │ └── test_views │ │ │ ├── __init__.py │ │ │ ├── test_contact_simple.py │ │ │ └── test_contact_async.py │ ├── django_example │ │ ├── __init__.py │ │ ├── views │ │ │ ├── __init__.py │ │ │ ├── contact_simple.py │ │ │ └── contact_async.py │ │ ├── asgi.py │ │ ├── wsgi.py │ │ ├── urls.py │ │ └── settings.py │ └── manage.py ├── tour_any_validator.py ├── tour_optional.py ├── string_validator.py ├── to_serializable_errors_1.py ├── tour_is_dict.py ├── int_predicates.py ├── tour_tuple2.py ├── async_string.py ├── exact_item_count.py ├── one_of_2.py ├── typeddict_basic.py ├── person_dict_any.py ├── person_dict_callable.py ├── maybe_key.py ├── to_serializable_errs.py ├── float_validator_simple.py ├── simple_validation.py ├── tour_lazy.py ├── is_close_predicate.py ├── tour_record_2.py ├── tour_list.py ├── tour_record_1.py ├── async_simple_float_validator.py ├── map_validator.py ├── flask_examples │ ├── basic.py │ ├── test_basic.py │ ├── test_async_captcha.py │ └── async_captcha.py ├── metadata.py ├── async_username.py ├── person.py ├── tour_record_3.py ├── tour_dict_validator_any.py ├── complex_nested.py ├── flat_errors.py └── extension_float_validator_predicates.py ├── docs ├── requirements.txt ├── api │ ├── koda_validate.signature.rst │ ├── koda_validate.rst │ └── koda_validate.serialization.rst ├── setup │ ├── installation.rst │ └── type-checking.rst ├── how_to │ ├── dictionaries.rst │ ├── rest_apis.rst │ ├── dictionaries │ │ ├── is_dict.rst │ │ ├── map.rst │ │ └── records.rst │ ├── type-checking.rst │ ├── metadata.rst │ ├── rest_apis │ │ ├── django.rst │ │ └── flask.rst │ └── results.rst ├── Makefile ├── faq │ └── pydantic.rst ├── make.bat ├── philosophy │ ├── processors.rst │ ├── overview.rst │ ├── coercion.rst │ ├── predicates.rst │ ├── validators.rst │ └── async.rst └── conf.py ├── run_all_examples.sh ├── setup.cfg ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── push.yml ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── bench ├── string_valid.py ├── one_key_invalid_types.py ├── two_keys_valid.py ├── list_none.py ├── two_keys_invalid_types.py ├── min_max.py ├── run.py └── nested_object_list.py ├── LICENSE ├── .gitignore ├── README.md ├── pyproject.toml └── CHANGELOG.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /koda_validate/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_serialization/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/django_example/django_tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/django_example/django_example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/django_example/django_example/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/django_example/django_tests/test_views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx==7.4.7 2 | furo==2024.8.6 3 | sphinx-autodoc-typehints==1.25.3 4 | -------------------------------------------------------------------------------- /run_all_examples.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | for f in examples/*.py 4 | do 5 | python "$f" || exit 1 6 | done 7 | -------------------------------------------------------------------------------- /examples/tour_any_validator.py: -------------------------------------------------------------------------------- 1 | from koda_validate import * 2 | 3 | assert always_valid(123) == Valid(123) 4 | assert always_valid("abc") == Valid("abc") 5 | -------------------------------------------------------------------------------- /docs/api/koda_validate.signature.rst: -------------------------------------------------------------------------------- 1 | koda\_validate.signature 2 | ============================ 3 | 4 | 5 | .. automodule:: koda_validate.signature 6 | :members: 7 | :undoc-members: 8 | :show-inheritance: 9 | 10 | -------------------------------------------------------------------------------- /examples/tour_optional.py: -------------------------------------------------------------------------------- 1 | from koda_validate import * 2 | 3 | optional_int_validator = OptionalValidator(IntValidator()) 4 | 5 | assert optional_int_validator(5) == Valid(5) 6 | assert optional_int_validator(None) == Valid(None) 7 | -------------------------------------------------------------------------------- /examples/string_validator.py: -------------------------------------------------------------------------------- 1 | from koda_validate import * 2 | 3 | string_validator = StringValidator(MinLength(5)) 4 | 5 | string_validator("hello world") 6 | # > Valid('hello world') 7 | 8 | string_validator(5) 9 | # > Invalid(...) 10 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 90 3 | ignore = E721,E741,W503,F405,F403,DAR101,DAR201,DAR401 4 | exclude = build,codegen,tests/test_examples.py 5 | 6 | [darglint] 7 | docstring_style = SPHINX 8 | ignore_raise = NotImplementedError 9 | ignore = DAR401 -------------------------------------------------------------------------------- /docs/api/koda_validate.rst: -------------------------------------------------------------------------------- 1 | koda\_validate 2 | ============== 3 | 4 | 5 | .. automodule:: koda_validate 6 | :members: 7 | :undoc-members: 8 | :special-members: __call__ 9 | 10 | .. autodata:: ValidationResult 11 | 12 | .. autodata:: ErrType 13 | 14 | -------------------------------------------------------------------------------- /docs/api/koda_validate.serialization.rst: -------------------------------------------------------------------------------- 1 | koda\_validate.serialization 2 | ============================ 3 | 4 | 5 | .. automodule:: koda_validate.serialization 6 | :members: 7 | :undoc-members: 8 | :show-inheritance: 9 | 10 | 11 | .. autodata:: Serializable -------------------------------------------------------------------------------- /koda_validate/serialization/base.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | Serializable = Union[ 4 | None, 5 | int, 6 | str, 7 | bool, 8 | float, 9 | list["Serializable"], 10 | tuple["Serializable", ...], 11 | dict[str, "Serializable"], 12 | ] 13 | -------------------------------------------------------------------------------- /examples/to_serializable_errors_1.py: -------------------------------------------------------------------------------- 1 | from koda_validate import * 2 | from koda_validate.serialization import to_serializable_errs 3 | 4 | validator = StringValidator() 5 | 6 | result = validator(123) 7 | assert isinstance(result, Invalid) 8 | 9 | print(to_serializable_errs(result)) 10 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Issue or goal of PR: 2 | 3 | ## What I did 4 | 5 | ## How to test 6 | 7 | 8 | - [ ] unit tests were written / updated 9 | 10 | ## Documentation 11 | Check one: 12 | 13 | - [ ] I updated documentation 14 | OR 15 | - [ ] No documentation updates required 16 | -------------------------------------------------------------------------------- /docs/setup/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | Koda Validate is compatible with Python 3.8+ 4 | 5 | pip 6 | --- 7 | 8 | .. code-block:: bash 9 | 10 | pip install koda-validate 11 | 12 | Poetry 13 | ------ 14 | 15 | .. code-block:: bash 16 | 17 | poetry add koda-validate 18 | -------------------------------------------------------------------------------- /examples/tour_is_dict.py: -------------------------------------------------------------------------------- 1 | from koda_validate import * 2 | from koda_validate import TypeErr 3 | 4 | assert is_dict_validator({}) == Valid({}) 5 | assert is_dict_validator(None) == Invalid(TypeErr(dict), None, is_dict_validator) 6 | assert is_dict_validator({"a": 1, "b": 2, 5: "xyz"}) == Valid({"a": 1, "b": 2, 5: "xyz"}) 7 | -------------------------------------------------------------------------------- /docs/how_to/dictionaries.rst: -------------------------------------------------------------------------------- 1 | Validating Dictionaries 2 | ======================= 3 | 4 | Koda Validate has a wealth of options when it comes to validating dictionaries. 5 | 6 | .. toctree:: 7 | :maxdepth: 3 8 | 9 | dictionaries/derived 10 | dictionaries/map 11 | dictionaries/records 12 | dictionaries/is_dict 13 | -------------------------------------------------------------------------------- /examples/int_predicates.py: -------------------------------------------------------------------------------- 1 | from koda_validate import * 2 | from koda_validate import PredicateErrs 3 | 4 | int_validator = IntValidator(Min(5), Max(20), MultipleOf(4)) 5 | 6 | assert int_validator(12) == Valid(12) 7 | 8 | assert int_validator(23) == Invalid( 9 | PredicateErrs([Max(20), MultipleOf(4)]), 23, int_validator 10 | ) 11 | -------------------------------------------------------------------------------- /examples/tour_tuple2.py: -------------------------------------------------------------------------------- 1 | from koda_validate import IntValidator, NTupleValidator, StringValidator, Valid 2 | 3 | string_int_validator = NTupleValidator.typed(fields=(StringValidator(), IntValidator())) 4 | 5 | assert string_int_validator(("ok", 100)) == Valid(("ok", 100)) 6 | 7 | # also ok with lists 8 | assert string_int_validator(["ok", 100]) == Valid(("ok", 100)) 9 | -------------------------------------------------------------------------------- /examples/async_string.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from koda_validate import * 4 | 5 | short_string_validator = StringValidator(MaxLength(10)) 6 | 7 | assert short_string_validator("sync") == Valid("sync") 8 | 9 | # we're not in an async context, so we can't use `await` here 10 | assert asyncio.run(short_string_validator.validate_async("async")) == Valid("async") 11 | -------------------------------------------------------------------------------- /examples/exact_item_count.py: -------------------------------------------------------------------------------- 1 | from koda_validate import ( 2 | ExactItemCount, 3 | ListValidator, 4 | StringValidator, 5 | UniformTupleValidator, 6 | ) 7 | 8 | list_validator = ListValidator(StringValidator(), predicates=[ExactItemCount(1)]) 9 | u_tuple_validator = UniformTupleValidator( 10 | StringValidator(), predicates=[ExactItemCount(1)] 11 | ) 12 | -------------------------------------------------------------------------------- /examples/one_of_2.py: -------------------------------------------------------------------------------- 1 | from koda_validate import * 2 | from koda_validate.union import UnionValidator 3 | 4 | string_or_list_string_validator = UnionValidator.typed( 5 | StringValidator(), ListValidator(StringValidator()) 6 | ) 7 | 8 | assert string_or_list_string_validator("ok") == Valid("ok") 9 | assert string_or_list_string_validator(["list", "of", "strings"]) == Valid( 10 | ["list", "of", "strings"] 11 | ) 12 | -------------------------------------------------------------------------------- /examples/typeddict_basic.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict, List 2 | 3 | from koda_validate import TypedDictValidator 4 | 5 | 6 | class Person(TypedDict): 7 | name: str 8 | hobbies: List[str] 9 | 10 | 11 | person_validator = TypedDictValidator(Person) 12 | person_validator({"name": "Bob", "hobbies": ["eating", "coding", "sleeping"]}) 13 | # > Valid({'name': 'Bob', 'hobbies': ['eating', 'coding', 'sleeping']}) 14 | -------------------------------------------------------------------------------- /examples/person_dict_any.py: -------------------------------------------------------------------------------- 1 | from koda_validate import * 2 | 3 | person_validator = DictValidatorAny( 4 | { 5 | "name": StringValidator(), 6 | "age": IntValidator(), 7 | } 8 | ) 9 | 10 | result = person_validator({"name": "John Doe", "age": 30}) 11 | if isinstance(result, Valid): 12 | print(f"{result.val['name']} is {result.val['age']} years old") 13 | else: 14 | print(result) 15 | 16 | # prints: "John Doe is 30 years old" 17 | -------------------------------------------------------------------------------- /examples/person_dict_callable.py: -------------------------------------------------------------------------------- 1 | from koda_validate import * 2 | from typing import Tuple 3 | 4 | 5 | def reverse_person_args_tuple(a: str, b: int) -> Tuple[int, str]: 6 | return b, a 7 | 8 | 9 | person_validator_2 = RecordValidator( 10 | into=reverse_person_args_tuple, 11 | keys=(("name", StringValidator(MinLength(1))), ("age", IntValidator(Min(0)))), 12 | ) 13 | 14 | assert person_validator_2({"name": "John Doe", "age": 30}) == Valid((30, "John Doe")) 15 | -------------------------------------------------------------------------------- /koda_validate/serialization/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ( 2 | # serialization.py 3 | "Serializable", 4 | "SerializableErr", 5 | "to_serializable_errs", 6 | "to_json_schema", 7 | "to_named_json_schema", 8 | ) 9 | 10 | from koda_validate.serialization.base import Serializable 11 | from koda_validate.serialization.errors import SerializableErr, to_serializable_errs 12 | from koda_validate.serialization.json_schema import to_json_schema, to_named_json_schema 13 | -------------------------------------------------------------------------------- /examples/django_example/django_example/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for django_example project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_example.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /examples/django_example/django_example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for django_example project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_example.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 23.3.0 4 | hooks: 5 | - id: black 6 | 7 | - repo: https://github.com/pycqa/isort 8 | rev: 5.12.0 9 | hooks: 10 | - id: isort 11 | name: isort (python) 12 | # disabled (for now?) because it messes with docstring placement -- this breaks instance 13 | # variable documentation on classes 14 | # - repo: https://github.com/bwhmather/ssort 15 | # rev: v0.11.6 16 | # hooks: 17 | # - id: ssort 18 | -------------------------------------------------------------------------------- /docs/how_to/rest_apis.rst: -------------------------------------------------------------------------------- 1 | REST APIs 2 | ========= 3 | 4 | Koda Validate is not tightly coupled with specific web frameworks, serialization formats, 5 | or types of APIs. Nonetheless, Koda Validate does not exist in a vacuum, and some thought 6 | has put into how to integrate into common API setups. 7 | 8 | Here we'll explore the example of a Contact Form that is posted to a REST endpoint, and 9 | see how it could be implemented in several web frameworks. 10 | 11 | .. toctree:: 12 | :maxdepth: 3 13 | 14 | rest_apis/flask 15 | rest_apis/django 16 | -------------------------------------------------------------------------------- /examples/maybe_key.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from koda import Just, Maybe, nothing 4 | 5 | from koda_validate import * 6 | 7 | 8 | @dataclass 9 | class Person: 10 | name: str 11 | age: Maybe[int] 12 | 13 | 14 | person_validator = RecordValidator( 15 | into=Person, 16 | keys=(("name", StringValidator()), ("age", KeyNotRequired(IntValidator()))), 17 | ) 18 | assert person_validator({"name": "Bob"}) == Valid(Person("Bob", nothing)) 19 | assert person_validator({"name": "Bob", "age": 42}) == Valid(Person("Bob", Just(42))) 20 | -------------------------------------------------------------------------------- /tests/test_311_plus.py: -------------------------------------------------------------------------------- 1 | from typing import NotRequired, Required, TypedDict 2 | 3 | from koda_validate import IntValidator, StringValidator, TypedDictValidator 4 | 5 | 6 | def test_not_required_typeddict_annotation() -> None: 7 | class A(TypedDict): 8 | x: NotRequired[str] 9 | 10 | v = TypedDictValidator(A) 11 | assert v.schema["x"] == StringValidator() 12 | 13 | 14 | def test_required_typeddict_annotation() -> None: 15 | class A(TypedDict): 16 | x: Required[int] 17 | 18 | v = TypedDictValidator(A) 19 | assert v.schema["x"] == IntValidator() 20 | -------------------------------------------------------------------------------- /examples/to_serializable_errs.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict 2 | 3 | from koda_validate import Invalid, TypedDictValidator 4 | from koda_validate.serialization import to_serializable_errs 5 | 6 | 7 | class Person(TypedDict): 8 | name: str 9 | age: int 10 | 11 | 12 | validator = TypedDictValidator(Person) 13 | 14 | result = validator({"age": False}) 15 | assert isinstance(result, Invalid) 16 | 17 | to_serializable_errs(result) 18 | # > {'age': ['expected an integer'], 'name': ['key missing']} 19 | 20 | # you can write something like 21 | # to_some_other_error_format(result) 22 | -------------------------------------------------------------------------------- /examples/float_validator_simple.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from koda_validate import * 4 | from koda_validate import TypeErr, ValidationResult 5 | 6 | 7 | class SimpleFloatValidator(Validator[float]): 8 | def __call__(self, val: Any) -> ValidationResult[float]: 9 | if isinstance(val, float): 10 | return Valid(val) 11 | else: 12 | return Invalid(TypeErr(float), val, self) 13 | 14 | 15 | float_validator = SimpleFloatValidator() 16 | 17 | test_val = 5.5 18 | 19 | assert float_validator(test_val) == Valid(test_val) 20 | 21 | assert float_validator(5) == Invalid(TypeErr(float), 5, float_validator) 22 | -------------------------------------------------------------------------------- /examples/simple_validation.py: -------------------------------------------------------------------------------- 1 | from koda_validate import ( 2 | EqualsValidator, 3 | Invalid, 4 | MinLength, 5 | PredicateErrs, 6 | StringValidator, 7 | TypeErr, 8 | Valid, 9 | ) 10 | 11 | min_length_3_validator = StringValidator(MinLength(4)) 12 | assert min_length_3_validator("good") == Valid("good") 13 | assert min_length_3_validator("bad") == Invalid( 14 | PredicateErrs([MinLength(4)]), "bad", min_length_3_validator 15 | ) 16 | 17 | exactly_5_validator = EqualsValidator(5) 18 | 19 | assert exactly_5_validator(5) == Valid(5) 20 | assert exactly_5_validator("hmm") == Invalid(TypeErr(int), "hmm", exactly_5_validator) 21 | -------------------------------------------------------------------------------- /tests/test_bytes.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from koda_validate import Invalid, TypeErr, Valid 4 | from koda_validate.bytes import BytesValidator 5 | 6 | 7 | def test_bytes() -> None: 8 | b_v = BytesValidator() 9 | assert b_v(b"okokok") == Valid(b"okokok") 10 | assert b_v("wrong type!") == Invalid(TypeErr(bytes), "wrong type!", b_v) 11 | 12 | 13 | @pytest.mark.asyncio 14 | async def test_bytes_async() -> None: 15 | b_v = BytesValidator() 16 | assert await b_v.validate_async(b"okokok") == Valid(b"okokok") 17 | assert await b_v.validate_async("wrong type!") == Invalid( 18 | TypeErr(bytes), "wrong type!", b_v 19 | ) 20 | -------------------------------------------------------------------------------- /examples/tour_lazy.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional, Tuple 2 | 3 | from koda_validate import * 4 | 5 | # if enable_recursive_aliases = true in mypy 6 | # NonEmptyList = Tuple[int, Optional["NonEmptyList"]] 7 | NonEmptyList = Tuple[int, Optional[Any]] 8 | 9 | 10 | def recur_non_empty_list() -> NTupleValidator[Tuple[int, Optional[NonEmptyList]]]: 11 | return non_empty_list_validator 12 | 13 | 14 | non_empty_list_validator = NTupleValidator.typed( 15 | fields=(IntValidator(), OptionalValidator(Lazy(recur_non_empty_list))) 16 | ) 17 | 18 | assert non_empty_list_validator((1, (1, (2, (3, (5, None)))))) == Valid( 19 | (1, (1, (2, (3, (5, None))))) 20 | ) 21 | -------------------------------------------------------------------------------- /examples/is_close_predicate.py: -------------------------------------------------------------------------------- 1 | import math 2 | from dataclasses import dataclass 3 | 4 | from koda_validate import * 5 | from koda_validate import PredicateErrs 6 | 7 | 8 | @dataclass 9 | class IsClose(Predicate[float]): 10 | compare_to: float 11 | tolerance: float 12 | 13 | def __call__(self, val: float) -> bool: 14 | return math.isclose(self.compare_to, val, abs_tol=self.tolerance) 15 | 16 | 17 | # let's use it 18 | close_to_validator = FloatValidator(IsClose(0.05, 0.02)) 19 | a = 0.06 20 | assert close_to_validator(a) == Valid(a) 21 | assert close_to_validator(0.01) == Invalid( 22 | PredicateErrs([IsClose(0.05, 0.02)]), 0.01, close_to_validator 23 | ) 24 | -------------------------------------------------------------------------------- /koda_validate/_generics.py: -------------------------------------------------------------------------------- 1 | """ 2 | Generics are defined here to avoid repetition throughout the library 3 | """ 4 | 5 | from typing import TypeVar 6 | 7 | A = TypeVar("A") 8 | B = TypeVar("B") 9 | C = TypeVar("C") 10 | 11 | T1 = TypeVar("T1") 12 | T2 = TypeVar("T2") 13 | T3 = TypeVar("T3") 14 | T4 = TypeVar("T4") 15 | T5 = TypeVar("T5") 16 | T6 = TypeVar("T6") 17 | T7 = TypeVar("T7") 18 | T8 = TypeVar("T8") 19 | T9 = TypeVar("T9") 20 | T10 = TypeVar("T10") 21 | T11 = TypeVar("T11") 22 | T12 = TypeVar("T12") 23 | T13 = TypeVar("T13") 24 | T14 = TypeVar("T14") 25 | T15 = TypeVar("T15") 26 | T16 = TypeVar("T16") 27 | 28 | Ret = TypeVar("Ret") 29 | 30 | SuccessT = TypeVar("SuccessT") 31 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from koda_validate import Invalid, TypeErr, Valid, ValidationResult, Validator 4 | 5 | 6 | class BasicNoneValidator(Validator[None]): 7 | """ 8 | Since most validators are _ToTuplevalidatorUnsafe*, this gives us a 9 | way to make sure we are still exercising the normal `Validator` paths 10 | """ 11 | 12 | async def validate_async(self, val: Any) -> ValidationResult[None]: 13 | return self(val) 14 | 15 | def __call__(self, val: Any) -> ValidationResult[None]: 16 | if val is None: 17 | return Valid(None) 18 | else: 19 | return Invalid(TypeErr(type(None)), val, self) 20 | -------------------------------------------------------------------------------- /tests/test_typedefs.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from dataclasses import dataclass 3 | 4 | import pytest 5 | 6 | from koda_validate.base import PredicateAsync 7 | 8 | 9 | @pytest.mark.asyncio 10 | async def test_async_predicate_works_as_expected() -> None: 11 | @dataclass 12 | class ExampleStartsWithPredicate(PredicateAsync[str]): 13 | prefix: str 14 | 15 | async def validate_async(self, val: str) -> bool: 16 | await asyncio.sleep(0.001) 17 | return val.startswith(self.prefix) 18 | 19 | obj = ExampleStartsWithPredicate("abc") 20 | assert await obj.validate_async("def") is False 21 | assert await obj.validate_async("abc123") is True 22 | -------------------------------------------------------------------------------- /examples/tour_record_2.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List 3 | 4 | from koda import Just, Maybe 5 | 6 | from koda_validate import * 7 | 8 | 9 | @dataclass 10 | class Person: 11 | name: str 12 | age: Maybe[int] 13 | hobbies: List[str] 14 | 15 | 16 | person_validator = RecordValidator( 17 | into=Person, 18 | keys=( 19 | (1, StringValidator()), 20 | (False, KeyNotRequired(IntValidator())), 21 | (("abc", 123), ListValidator(StringValidator())), 22 | ), 23 | ) 24 | 25 | assert person_validator( 26 | {1: "John Doe", False: 30, ("abc", 123): ["reading", "cooking"]} 27 | ) == Valid(Person("John Doe", Just(30), ["reading", "cooking"])) 28 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/how_to/dictionaries/is_dict.rst: -------------------------------------------------------------------------------- 1 | IsDictValidator 2 | =============== 3 | 4 | .. module:: koda_validate 5 | :noindex: 6 | 7 | All :class:`IsDictValidator` does is check if an object is a dictionary. You 8 | don't need to initialize it, you can just load :data:`is_dict_validator`. 9 | 10 | .. doctest:: 11 | 12 | >>> from koda_validate import is_dict_validator 13 | 14 | >>> is_dict_validator({}) 15 | Valid(val={}) 16 | 17 | >>> is_dict_validator({"a": 1, "b": None}) 18 | Valid(val={'a': 1, 'b': None}) 19 | 20 | >>> is_dict_validator(None) 21 | Invalid( 22 | err_type=TypeErr(expected_type=), 23 | value=None, 24 | validator=IsDictValidator() 25 | ) 26 | 27 | 28 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.11" 13 | 14 | # Build documentation in the docs/ directory with Sphinx 15 | sphinx: 16 | configuration: docs/conf.py 17 | 18 | # If using Sphinx, optionally build your docs in additional formats such as PDF 19 | # formats: 20 | # - pdf 21 | 22 | # Optionally declare the Python requirements required to build your docs 23 | python: 24 | install: 25 | - requirements: docs/requirements.txt 26 | - method: pip 27 | path: . -------------------------------------------------------------------------------- /koda_validate/integer.py: -------------------------------------------------------------------------------- 1 | from koda_validate._internal import _ToTupleStandardValidator 2 | 3 | 4 | class IntValidator(_ToTupleStandardValidator[int]): 5 | r""" 6 | Validate a value is a ``int``, and any extra refinement. 7 | 8 | If ``predicates_async`` is supplied, the ``__call__`` method should not be 9 | called -- only ``.validate_async`` should be used. 10 | 11 | :param predicates: any number of ``Predicate[int]`` instances 12 | :param predicates_async: any number of ``PredicateAsync[int]`` instances 13 | :param preprocessors: any number of ``Processor[int]``, which will be run before 14 | :class:`Predicate`\s and :class:`PredicateAsync`\s are checked. 15 | :param coerce: a function that can control coercion 16 | """ 17 | 18 | _TYPE = int 19 | -------------------------------------------------------------------------------- /examples/django_example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main() -> None: 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_example.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /examples/tour_list.py: -------------------------------------------------------------------------------- 1 | from koda_validate import * 2 | from koda_validate import IndexErrs, PredicateErrs, TypeErr 3 | 4 | binary_int_validator = IntValidator(Choices({0, 1})) 5 | binary_list_validator = ListValidator(binary_int_validator, predicates=[MinItems(2)]) 6 | 7 | assert binary_list_validator([1, 0, 0, 1, 0]) == Valid([1, 0, 0, 1, 0]) 8 | 9 | assert binary_list_validator([1]) == Invalid( 10 | PredicateErrs([MinItems(2)]), [1], binary_list_validator 11 | ) 12 | 13 | assert binary_list_validator([0, 1.0, "0"]) == Invalid( 14 | IndexErrs( 15 | { 16 | 1: Invalid(TypeErr(int), 1.0, binary_int_validator), 17 | 2: Invalid(TypeErr(int), "0", binary_int_validator), 18 | }, 19 | ), 20 | [0, 1.0, "0"], 21 | binary_list_validator, 22 | ) 23 | -------------------------------------------------------------------------------- /koda_validate/boolean.py: -------------------------------------------------------------------------------- 1 | from koda_validate._internal import _ToTupleStandardValidator 2 | 3 | 4 | class BoolValidator(_ToTupleStandardValidator[bool]): 5 | r""" 6 | Validate a value is a ``bool``, and any extra refinement. 7 | 8 | If ``predicates_async`` is supplied, the ``__call__`` method should not be 9 | called -- only ``.validate_async`` should be used. 10 | 11 | :param predicates: any number of ``Predicate[bool]`` instances 12 | :param predicates_async: any number of ``PredicateAsync[bool]`` instances 13 | :param preprocessors: any number of ``Processor[bool]``, which will be run before 14 | :class:`Predicate`\s and :class:`PredicateAsync`\s are checked. 15 | :param coerce: a function that can control coercion 16 | """ 17 | 18 | _TYPE = bool 19 | -------------------------------------------------------------------------------- /bench/string_valid.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List 2 | 3 | from pydantic import BaseModel, ValidationError, constr 4 | 5 | from koda_validate import StringValidator, Valid 6 | 7 | string_validator = StringValidator() 8 | 9 | 10 | def run_kv(objs: List[Any]) -> None: 11 | for obj in objs: 12 | if isinstance(result := string_validator(obj), Valid): 13 | _ = result.val 14 | else: 15 | _ = result.val 16 | 17 | 18 | class BasicString(BaseModel): 19 | val_1: constr(strict=True) 20 | 21 | 22 | def run_pyd(objs: List[Any]) -> None: 23 | for obj in objs: 24 | try: 25 | _ = BasicString(val_1=obj) 26 | except ValidationError as e: 27 | _ = e 28 | 29 | 30 | def get_str(i: int) -> str: 31 | return f"the_str_{i}" 32 | -------------------------------------------------------------------------------- /koda_validate/float.py: -------------------------------------------------------------------------------- 1 | from koda_validate._internal import _ToTupleStandardValidator 2 | 3 | 4 | class FloatValidator(_ToTupleStandardValidator[float]): 5 | r""" 6 | Validate a value is a ``float``, and any extra refinement. 7 | 8 | If ``predicates_async`` is supplied, the ``__call__`` method should not be 9 | called -- only ``.validate_async`` should be used. 10 | 11 | :param predicates: any number of ``Predicate[float]`` instances 12 | :param predicates_async: any number of ``PredicateAsync[float]`` instances 13 | :param preprocessors: any number of ``Processor[float]``, which will be run before 14 | :class:`Predicate`\s and :class:`PredicateAsync`\s are checked. 15 | :param coerce: a function that can control coercion 16 | """ 17 | 18 | _TYPE = float 19 | -------------------------------------------------------------------------------- /examples/tour_record_1.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from koda import Just, Maybe 4 | 5 | from koda_validate import * 6 | 7 | 8 | @dataclass 9 | class Person: 10 | name: str 11 | age: Maybe[int] 12 | 13 | 14 | person_validator = RecordValidator( 15 | into=Person, 16 | keys=( 17 | ("full name", StringValidator()), 18 | ("age", KeyNotRequired(IntValidator())), 19 | ), 20 | ) 21 | 22 | match person_validator({"full name": "John Doe", "age": 30}): 23 | case Valid(person): 24 | match person.age: 25 | case Just(age): 26 | age_message = f"{age} years old" 27 | case nothing: 28 | age_message = "ageless" 29 | print(f"{person.name} is {age_message}") 30 | case Invalid(_, errs): 31 | print(errs) 32 | -------------------------------------------------------------------------------- /docs/faq/pydantic.rst: -------------------------------------------------------------------------------- 1 | Pydantic Comparison 2 | =================== 3 | 4 | .. module:: koda_validate 5 | :noindex: 6 | 7 | Comparing Koda Validate and Pydantic is not exactly apples-to-apples, since Koda Validate is more narrowly 8 | aimed at *just* validation. Nonetheless, this is one of the most common questions, and there are a number of noteworthy differences: 9 | 10 | - Koda Validate is built around a simple, composable definition of validation. 11 | - Koda Validate is fully asyncio-compatible. 12 | - Koda Validate allows the user to control coercion. 13 | - Koda Validate requires no plugins for mypy compatibility. 14 | - Koda Validate is competitive with rust-compiled Pydantic 2.x in speed of synchronous validation (despite being pure Python). You can run the benchmark suite on your system with ``python -m bench.run`` 15 | -------------------------------------------------------------------------------- /tests/test_313_plus.py: -------------------------------------------------------------------------------- 1 | from koda_validate.typehints import get_typehint_validator 2 | from koda_validate import Valid 3 | 4 | 5 | def test_get_typehint_validator_readonly_typeddict() -> None: 6 | from typing import ReadOnly, TypedDict 7 | 8 | class UserInfo(TypedDict): 9 | name: ReadOnly[str] # Read-only field 10 | age: int # Mutable field 11 | email: ReadOnly[str] # Another read-only field 12 | 13 | validator = get_typehint_validator(UserInfo) 14 | 15 | # Should create a TypedDictValidator that handles ReadOnly fields 16 | from koda_validate.typeddict import TypedDictValidator 17 | assert isinstance(validator, TypedDictValidator) 18 | 19 | # Test validation works normally 20 | result = validator({ 21 | "name": "John", 22 | "age": 30, 23 | "email": "john@example.com" 24 | }) 25 | assert isinstance(result, Valid) 26 | -------------------------------------------------------------------------------- /bench/one_key_invalid_types.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any, List 3 | 4 | from pydantic import BaseModel, ValidationError, constr 5 | 6 | from koda_validate import RecordValidator, StringValidator, Valid 7 | 8 | 9 | @dataclass 10 | class SimpleStr: 11 | val_1: str 12 | 13 | 14 | string_validator = RecordValidator(into=SimpleStr, keys=(("val_1", StringValidator()),)) 15 | 16 | 17 | def run_kv(objs: Any) -> None: 18 | for obj in objs: 19 | if isinstance(result := string_validator(obj), Valid): 20 | _ = result.val 21 | else: 22 | _ = result.validator 23 | 24 | 25 | class BasicString(BaseModel): 26 | val_1: constr(strict=True) 27 | 28 | 29 | def run_pyd(objs: List[Any]) -> None: 30 | for obj in objs: 31 | try: 32 | _ = BasicString(**obj) 33 | except ValidationError as e: 34 | _ = e 35 | -------------------------------------------------------------------------------- /examples/async_simple_float_validator.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Any 3 | 4 | from koda_validate import * 5 | from koda_validate import TypeErr, ValidationResult 6 | 7 | 8 | class SimpleFloatValidator(Validator[float]): 9 | 10 | # this validator doesn't do any IO, so we can just use the `__call__` method 11 | async def validate_async(self, val: Any) -> ValidationResult[float]: 12 | return self(val) 13 | 14 | def __call__(self, val: Any) -> ValidationResult[float]: 15 | if isinstance(val, float): 16 | return Valid(val) 17 | else: 18 | return Invalid(TypeErr(float), val, self) 19 | 20 | 21 | float_validator = SimpleFloatValidator() 22 | 23 | test_val = 5.5 24 | 25 | assert asyncio.run(float_validator.validate_async(test_val)) == Valid(test_val) 26 | 27 | assert asyncio.run(float_validator.validate_async(5)) == Invalid( 28 | TypeErr(float), 5, float_validator 29 | ) 30 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /tests/test_39_plus.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | import pytest 4 | 5 | from koda_validate import MinLength, StringValidator 6 | from koda_validate.signature import ( 7 | InvalidArgsError, 8 | InvalidReturnError, 9 | validate_signature, 10 | ) 11 | 12 | 13 | def test_annotated() -> None: 14 | @validate_signature 15 | def something( 16 | a: Annotated[str, StringValidator(MinLength(2))] 17 | ) -> Annotated[str, StringValidator(MinLength(2))]: 18 | # the function signature here can be regarded as somewhat of a mistake 19 | # because the return minlength should be 1, since we remove one character. It just 20 | # serves to allow us to test both the argument and return validators. 21 | return a[:-1] 22 | 23 | assert something("abc") == "ab" 24 | with pytest.raises(InvalidArgsError): 25 | something("") 26 | 27 | with pytest.raises(InvalidReturnError): 28 | something("ab") 29 | -------------------------------------------------------------------------------- /examples/django_example/django_example/urls.py: -------------------------------------------------------------------------------- 1 | """django_example URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/4.1/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.urls import path 17 | 18 | from .views.contact_async import contact_async 19 | from .views.contact_simple import contact 20 | 21 | urlpatterns = [ 22 | path("contact", contact), 23 | path("contact-async", contact_async), 24 | ] 25 | -------------------------------------------------------------------------------- /examples/map_validator.py: -------------------------------------------------------------------------------- 1 | from koda_validate import * 2 | from koda_validate import MapErr, TypeErr 3 | from koda_validate.errors import KeyValErrs 4 | 5 | str_validator = StringValidator() 6 | int_validator = IntValidator() 7 | str_to_int_validator = MapValidator(key=str_validator, value=int_validator) 8 | 9 | assert str_to_int_validator({"a": 1, "b": 25, "xyz": 900}) == Valid( 10 | {"a": 1, "b": 25, "xyz": 900} 11 | ) 12 | 13 | assert str_to_int_validator({3.14: "pi!"}) == Invalid( 14 | MapErr( 15 | { 16 | 3.14: KeyValErrs( 17 | Invalid( 18 | TypeErr(str), 19 | 3.14, 20 | str_validator, 21 | ), 22 | Invalid( 23 | TypeErr(int), 24 | "pi!", 25 | int_validator, 26 | ), 27 | ) 28 | }, 29 | ), 30 | {3.14: "pi!"}, 31 | str_to_int_validator, 32 | ) 33 | -------------------------------------------------------------------------------- /koda_validate/is_type.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Type 2 | 3 | from koda_validate import Predicate, PredicateAsync, Processor 4 | from koda_validate._generics import SuccessT 5 | from koda_validate._internal import _ToTupleStandardValidator 6 | from koda_validate.coerce import Coercer 7 | 8 | 9 | class TypeValidator(_ToTupleStandardValidator[SuccessT]): 10 | def __init__( 11 | self, 12 | type_: Type[SuccessT], 13 | *, 14 | predicates: Optional[list[Predicate[SuccessT]]] = None, 15 | predicates_async: Optional[list[PredicateAsync[SuccessT]]] = None, 16 | preprocessors: Optional[list[Processor[SuccessT]]] = None, 17 | coerce: Optional[Coercer[SuccessT]] = None, 18 | ) -> None: 19 | self._TYPE = type_ 20 | super().__init__( 21 | *(predicates or []), 22 | predicates_async=predicates_async, 23 | preprocessors=preprocessors, 24 | coerce=coerce, 25 | ) 26 | -------------------------------------------------------------------------------- /bench/two_keys_valid.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any, List 3 | 4 | from pydantic import BaseModel 5 | from voluptuous import Schema 6 | 7 | from koda_validate import IntValidator, RecordValidator, StringValidator 8 | 9 | 10 | @dataclass 11 | class SimpleStr: 12 | val_1: str 13 | val_2: int 14 | 15 | 16 | string_validator = RecordValidator( 17 | into=SimpleStr, keys=(("val_1", StringValidator()), ("val_2", IntValidator())) 18 | ) 19 | 20 | 21 | def run_kv(objs: List[Any]) -> None: 22 | for obj in objs: 23 | string_validator(obj) 24 | 25 | 26 | class BasicString(BaseModel): 27 | val_1: str 28 | val_2: int 29 | 30 | 31 | def run_pyd(objs: List[Any]) -> None: 32 | for obj in objs: 33 | BasicString(**obj) 34 | 35 | 36 | v_schema = Schema( 37 | { 38 | "val_1": str, 39 | "val_2": int, 40 | } 41 | ) 42 | 43 | 44 | def run_v(objs: List[Any]) -> None: 45 | for obj in objs: 46 | v_schema(obj) 47 | -------------------------------------------------------------------------------- /bench/list_none.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List 2 | 3 | from pydantic import BaseModel, ValidationError 4 | 5 | from koda_validate import ListValidator, Valid, none_validator 6 | 7 | kv_list_none = ListValidator(none_validator) 8 | 9 | 10 | def run_kv(objs: List[Any]) -> None: 11 | for obj in objs: 12 | if isinstance(result := kv_list_none(obj), Valid): 13 | _ = result.val 14 | else: 15 | _ = result 16 | 17 | 18 | class BasicString(BaseModel): 19 | val_1: List[None] 20 | 21 | 22 | def run_pyd(objs: List[Any]) -> None: 23 | for obj in objs: 24 | try: 25 | _ = BasicString(val_1=obj) 26 | except ValidationError as e: 27 | _ = e 28 | 29 | 30 | def get_obj(i: int) -> Any: 31 | modded = i % 4 32 | if modded == 0: 33 | return [None] * 5 34 | elif modded == 1: 35 | return [None, None, None, False] * 2 36 | elif modded == 2: 37 | return f"blabla{i}" 38 | else: 39 | return [4.423452, "ok", None, False, {"a": 123, "b": "def"}, [1, 2, 3, 4]] 40 | -------------------------------------------------------------------------------- /examples/flask_examples/basic.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Annotated, Optional, Tuple 3 | 4 | from flask import Flask, jsonify, request 5 | from flask.typing import ResponseValue 6 | 7 | from koda_validate import * 8 | from koda_validate.serialization import to_serializable_errs 9 | 10 | app = Flask(__name__) 11 | 12 | 13 | @dataclass 14 | class ContactForm: 15 | name: str 16 | message: str 17 | # `Annotated` `Validator`s are used if found 18 | email: Annotated[str, StringValidator(EmailPredicate())] 19 | subject: Optional[str] = None 20 | 21 | 22 | @app.route("/contact", methods=["POST"]) 23 | def contact_api() -> Tuple[ResponseValue, int]: 24 | result = DataclassValidator(ContactForm)(request.json) 25 | match result: 26 | case Valid(contact_form): 27 | print(contact_form) # do something with the valid data 28 | return {"success": True}, 200 29 | case Invalid() as inv: 30 | return jsonify(to_serializable_errs(inv)), 400 31 | 32 | 33 | if __name__ == "__main__": 34 | app.run() 35 | -------------------------------------------------------------------------------- /docs/setup/type-checking.rst: -------------------------------------------------------------------------------- 1 | Type Checking 2 | ============= 3 | Koda Validate is built with typehint compatibility at its core. It is meant to aid 4 | developers by leveraging type hints to increase readability and reliability. 5 | 6 | It is recommended to use the most up-to-date version of `mypy `_ 7 | to take full advantage of type hints. Koda Validate can work with other type checkers, but it is 8 | tested against the most recent stable version of mypy. 9 | 10 | .. note:: 11 | 12 | Whether or not you are using a specific type checker with Koda Validate, the functionality will remain the same, as type hints have no runtime effects in Python. 13 | 14 | pip 15 | --- 16 | 17 | .. code-block:: 18 | 19 | pip install mypy 20 | 21 | Poetry 22 | ------ 23 | 24 | .. code-block:: 25 | 26 | poetry add mypy 27 | 28 | Dependency Group 29 | ^^^^^^^^^^^^^^^^ 30 | Because mypy doesn't do anything at runtime, it's common to only install it for specific 31 | non-production groups, such as "test" or "dev": 32 | 33 | .. code-block:: 34 | 35 | poetry add mypy --group test 36 | 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Keith Philpott 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /docs/how_to/dictionaries/map.rst: -------------------------------------------------------------------------------- 1 | Maps 2 | ===================== 3 | 4 | .. module:: koda_validate 5 | :noindex: 6 | 7 | For dictionaries with consistent key/value types, you can use :class:`MapValidator`. 8 | 9 | .. testcode:: mapv 10 | 11 | from koda_validate import (MapValidator, StringValidator, IntValidator, KeyValErrs, MapErr, 12 | MinKeys, MaxKeys, Valid, Invalid, PredicateErrs, TypeErr) 13 | 14 | validator = MapValidator(key=StringValidator(), 15 | value=IntValidator(), 16 | predicates=[MinKeys(1), MaxKeys(3)]) 17 | 18 | 19 | assert validator({"a": 1, "b": 2}) == Valid({'a': 1, 'b': 2}) 20 | 21 | assert validator({}) == Invalid(PredicateErrs([MinKeys(1)]), {}, validator) 22 | 23 | assert validator({"a": "not an int"}) == Invalid( 24 | MapErr({'a': KeyValErrs(key=None, 25 | val=Invalid(TypeErr(int), 'not an int', IntValidator()))}), 26 | {'a': 'not an int'}, 27 | validator 28 | ) 29 | 30 | Mypy will infer that the type of valid data returned from ``validator`` above will be 31 | ``dict[str, int]``. 32 | -------------------------------------------------------------------------------- /bench/two_keys_invalid_types.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any, List 3 | 4 | from pydantic import BaseModel, ValidationError 5 | from voluptuous import MultipleInvalid, Schema 6 | 7 | from koda_validate import IntValidator, RecordValidator, StringValidator 8 | 9 | 10 | @dataclass 11 | class SimpleStr: 12 | val_1: str 13 | val_2: int 14 | 15 | 16 | string_validator = RecordValidator( 17 | into=SimpleStr, keys=(("val_1", StringValidator()), ("val_2", IntValidator())) 18 | ) 19 | 20 | 21 | def run_kv(objs: List[Any]) -> None: 22 | for obj in objs: 23 | string_validator(obj) 24 | 25 | 26 | class BasicString(BaseModel): 27 | val_1: str 28 | val_2: int 29 | 30 | 31 | def run_pyd(objs: List[Any]) -> None: 32 | for obj in objs: 33 | try: 34 | BasicString(**obj) 35 | except ValidationError: 36 | pass 37 | 38 | 39 | v_schema = Schema( 40 | { 41 | "val_1": str, 42 | "val_2": int, 43 | } 44 | ) 45 | 46 | 47 | def run_v(objs: List[Any]) -> None: 48 | for obj in objs: 49 | try: 50 | v_schema(obj) 51 | except MultipleInvalid: 52 | pass 53 | -------------------------------------------------------------------------------- /koda_validate/coerce.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any, Callable, Generic, Type 3 | 4 | from koda import Maybe 5 | 6 | from koda_validate._generics import A 7 | 8 | 9 | @dataclass 10 | class Coercer(Generic[A]): 11 | coerce: Callable[[Any], Maybe[A]] 12 | """ 13 | The function which handles the coercion. 14 | """ 15 | compatible_types: set[Type[Any]] 16 | """ 17 | All the types which can potentially be coerced. 18 | """ 19 | 20 | def __call__(self, val: Any) -> Maybe[A]: 21 | return self.coerce(val) 22 | 23 | 24 | def coercer( 25 | *compatible_types: Type[Any], 26 | ) -> Callable[[Callable[[Any], Maybe[A]]], Coercer[A]]: 27 | """ 28 | This is purely a convenience constructor for :class:`Coercer` objects. 29 | 30 | :param compatible_types: the types the coercer can take to produce an 31 | the return type 32 | :return: A callable which accepts a function that should be congruent 33 | with the ``compatible_types`` param. 34 | 35 | """ 36 | 37 | def inner(func: Callable[[Any], Maybe[A]]) -> Coercer[A]: 38 | return Coercer(func, set(compatible_types)) 39 | 40 | return inner 41 | -------------------------------------------------------------------------------- /docs/philosophy/processors.rst: -------------------------------------------------------------------------------- 1 | Processors 2 | ========== 3 | .. module:: koda_validate 4 | :noindex: 5 | 6 | :class:`Processor`\s allow us to take a value of a given type and transform it into another value of that type. :class:`Processor`\s are most useful 7 | *after* type validation, but *before* predicates are checked. They tend to be more common on strings than any other type. Perhaps the 8 | most obvious use cases would be trimming whitespace or adjusting the case of a string: 9 | 10 | .. testcode:: python 11 | 12 | from koda_validate import StringValidator, MaxLength, strip, upper_case, Valid 13 | 14 | max_length_3_validator = StringValidator( 15 | MaxLength(3), 16 | preprocessors=[strip, upper_case] 17 | ) 18 | 19 | assert max_length_3_validator(" hmm ") == Valid("HMM") 20 | 21 | We see that the ``preprocessors`` stripped the whitespace from ``" hmm "`` and then transformed it to upper-case *before* 22 | the resulting value was checked against the ``MaxLength(3)`` :class:`Predicate`. 23 | 24 | :class:`Processor`\s are very simple to write -- see :ref:`how_to/extension:Extension` for more details. 25 | -------------------------------------------------------------------------------- /examples/metadata.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from koda_validate import MaxLength, MinLength, Predicate, StringValidator, Validator 4 | 5 | 6 | def describe_validator(validator: Validator[Any] | Predicate[Any]) -> str: 7 | # use `isinstance(...)` in python <= 3.10 8 | match validator: 9 | 10 | case StringValidator(predicates): 11 | predicate_descriptions = [ 12 | f"- {describe_validator(pred)}" for pred in predicates 13 | ] 14 | return "\n".join(["validates a string"] + predicate_descriptions) 15 | case MinLength(length): 16 | return f"minimum length {length}" 17 | case MaxLength(length): 18 | return f"maximum length {length}" 19 | # ...etc 20 | case _: 21 | raise TypeError(f"unhandled validator type. got {type(validator)}") 22 | 23 | 24 | print(describe_validator(StringValidator())) 25 | # validates a string 26 | print(describe_validator(StringValidator(MinLength(5)))) 27 | # validates a string 28 | # - minimum length 5 29 | print(describe_validator(StringValidator(MinLength(3), MaxLength(8)))) 30 | # validates a string 31 | # - minimum length 3 32 | # - maximum length 8 33 | -------------------------------------------------------------------------------- /examples/async_username.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from dataclasses import dataclass 3 | 4 | from koda_validate import * 5 | from koda_validate import PredicateErrs 6 | 7 | 8 | @dataclass 9 | class IsActiveUsername(PredicateAsync[str]): 10 | async def validate_async(self, val: str) -> bool: 11 | # add some latency to pretend we're calling the db 12 | await asyncio.sleep(0.01) 13 | 14 | return val in {"michael", "gob", "lindsay", "buster"} 15 | 16 | 17 | username_validator = StringValidator(MinLength(1), predicates_async=[IsActiveUsername()]) 18 | 19 | assert asyncio.run(username_validator.validate_async("michael")) == Valid("michael") 20 | assert asyncio.run(username_validator.validate_async("tobias")) == Invalid( 21 | PredicateErrs([IsActiveUsername()]), "tobias", username_validator 22 | ) 23 | 24 | # calling in sync mode raises an AssertionError! 25 | try: 26 | username_validator("michael") 27 | except AssertionError as e: 28 | print(e) 29 | 30 | username_list_validator = ListValidator(username_validator) 31 | 32 | assert asyncio.run( 33 | username_list_validator.validate_async(["michael", "gob", "lindsay", "buster"]) 34 | ) == Valid(["michael", "gob", "lindsay", "buster"]) 35 | -------------------------------------------------------------------------------- /koda_validate/uuid.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | from uuid import UUID 3 | 4 | from koda import Just, Maybe, nothing 5 | 6 | from koda_validate import Predicate, PredicateAsync, Processor 7 | from koda_validate._internal import _ToTupleStandardValidator 8 | from koda_validate.coerce import Coercer, coercer 9 | 10 | 11 | @coercer(str, UUID) 12 | def coerce_uuid(val: Any) -> Maybe[UUID]: 13 | if type(val) is UUID: 14 | return Just(val) 15 | 16 | elif type(val) is str: 17 | try: 18 | return Just(UUID(val)) 19 | except ValueError: 20 | pass 21 | 22 | return nothing 23 | 24 | 25 | class UUIDValidator(_ToTupleStandardValidator[UUID]): 26 | _TYPE = UUID 27 | 28 | def __init__( 29 | self, 30 | *predicates: Predicate[UUID], 31 | predicates_async: Optional[list[PredicateAsync[UUID]]] = None, 32 | preprocessors: Optional[list[Processor[UUID]]] = None, 33 | coerce: Optional[Coercer[UUID]] = coerce_uuid, 34 | ) -> None: 35 | super().__init__( 36 | *predicates, 37 | predicates_async=predicates_async, 38 | preprocessors=preprocessors, 39 | coerce=coerce, 40 | ) 41 | -------------------------------------------------------------------------------- /koda_validate/decimal.py: -------------------------------------------------------------------------------- 1 | import decimal 2 | from decimal import Decimal 3 | from typing import Any, Optional 4 | 5 | from koda import Just, Maybe, nothing 6 | 7 | from koda_validate._internal import _ToTupleStandardValidator 8 | from koda_validate.base import Predicate, PredicateAsync, Processor 9 | from koda_validate.coerce import Coercer, coercer 10 | 11 | 12 | @coercer(str, int, Decimal) 13 | def coerce_decimal(val: Any) -> Maybe[Decimal]: 14 | if type(val) is Decimal: 15 | return Just(val) 16 | elif isinstance(val, (str, int)): 17 | try: 18 | return Just(Decimal(val)) 19 | except decimal.InvalidOperation: 20 | pass 21 | 22 | return nothing 23 | 24 | 25 | class DecimalValidator(_ToTupleStandardValidator[Decimal]): 26 | _TYPE = Decimal 27 | 28 | def __init__( 29 | self, 30 | *predicates: Predicate[Decimal], 31 | predicates_async: Optional[list[PredicateAsync[Decimal]]] = None, 32 | preprocessors: Optional[list[Processor[Decimal]]] = None, 33 | coerce: Optional[Coercer[Decimal]] = coerce_decimal, 34 | ) -> None: 35 | super().__init__( 36 | *predicates, 37 | predicates_async=predicates_async, 38 | preprocessors=preprocessors, 39 | coerce=coerce, 40 | ) 41 | -------------------------------------------------------------------------------- /docs/how_to/type-checking.rst: -------------------------------------------------------------------------------- 1 | Typehint Troubleshooting 2 | ======================== 3 | 4 | If you happen to run into a problem with type checking Koda Validate, take a minute read through the documentation to 5 | make sure there really is a problem -- some :class:`Validator`\s require specific ``.typed`` methods to be called for proper 6 | type inference at initialization. 7 | 8 | If you run into a *bug*, there a few common workarounds to be aware of: 9 | 10 | type: ignore 11 | ^^^^^^^^^^^^ 12 | .. code-block:: python 13 | 14 | x = some_function() # type: ignore 15 | 16 | This simply tells the type checker to ignore this line. 17 | 18 | 19 | typing.Any 20 | ^^^^^^^^^^ 21 | 22 | .. code-block:: python 23 | 24 | from typing import Any 25 | 26 | x: Any = some_function() 27 | 28 | The Python docs about ``Any`` say: 29 | 30 | - Every type is compatible with ``Any``. 31 | - ``Any`` is compatible with every type. 32 | 33 | typing.cast 34 | ^^^^^^^^^^^ 35 | 36 | .. code-block:: python 37 | 38 | from typing import cast 39 | 40 | x = cast(str, some_function()) # `str` could be any type you wish to cast to 41 | 42 | ``cast`` just tells the type checker the value is of the specified type. 43 | 44 | 45 | The Python typehint ecosystem is still evolving rapidly, and you can expect guidance here to be updated over time. -------------------------------------------------------------------------------- /examples/django_example/django_example/views/contact_simple.py: -------------------------------------------------------------------------------- 1 | import json 2 | from dataclasses import dataclass 3 | from typing import Annotated, Optional 4 | 5 | from django.http import HttpRequest, HttpResponse, JsonResponse 6 | 7 | from koda_validate import * 8 | from koda_validate.serialization import to_serializable_errs 9 | 10 | 11 | @dataclass 12 | class ContactForm: 13 | name: str 14 | message: str 15 | # Annotated `Validator`s are used if defined -- instead 16 | # of Koda Validate's default for the type) 17 | email: Annotated[str, StringValidator(EmailPredicate())] 18 | subject: Optional[str] = None 19 | 20 | 21 | def contact(request: HttpRequest) -> HttpResponse: 22 | if request.method != "POST": 23 | return HttpResponse("HTTP method not allowed", status=405) 24 | 25 | try: 26 | posted_json = json.loads(request.body) 27 | except json.JSONDecodeError: 28 | return JsonResponse({"_root_": "expected json"}, status=400) 29 | else: 30 | result = DataclassValidator(ContactForm)(posted_json) 31 | match result: 32 | case Valid(contact_form): 33 | print(contact_form) 34 | return JsonResponse({"success": True}) 35 | case Invalid() as inv: 36 | return JsonResponse(to_serializable_errs(inv), status=400, safe=False) 37 | -------------------------------------------------------------------------------- /examples/person.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List 3 | from koda_validate import * 4 | 5 | 6 | @dataclass 7 | class Person: 8 | name: str 9 | age: int 10 | 11 | 12 | person_validator = RecordValidator( 13 | into=Person, 14 | keys=( 15 | ("name", StringValidator()), 16 | ("age", IntValidator()), 17 | ), 18 | ) 19 | 20 | result = person_validator({"name": "John Doe", "age": 30}) 21 | if isinstance(result, Valid): 22 | print(f"{result.val.name} is {result.val.age} years old") 23 | else: 24 | print(result) 25 | 26 | people_validator = ListValidator(person_validator) 27 | 28 | 29 | @dataclass 30 | class Group: 31 | name: str 32 | people: List[Person] 33 | 34 | 35 | group_validator = RecordValidator( 36 | into=Group, 37 | keys=( 38 | ("name", StringValidator()), 39 | ("people", people_validator), 40 | ), 41 | ) 42 | 43 | data = { 44 | "name": "Arrested Development Characters", 45 | "people": [{"name": "George Bluth", "age": 70}, {"name": "Michael Bluth", "age": 35}], 46 | } 47 | 48 | assert group_validator(data) == Valid( 49 | Group( 50 | name="Arrested Development Characters", 51 | people=[ 52 | Person(name="George Bluth", age=70), 53 | Person(name="Michael Bluth", age=35), 54 | ], 55 | ) 56 | ) 57 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | project = "Koda Validate" 10 | copyright = "2025, Keith Philpott" 11 | author = "Keith Philpott" 12 | release = "5.1.0" 13 | 14 | # -- General configuration --------------------------------------------------- 15 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 16 | 17 | extensions: list[str] = [ 18 | "sphinx.ext.autodoc", 19 | "sphinx_autodoc_typehints", 20 | "sphinx.ext.doctest", 21 | "sphinx.ext.autosectionlabel", 22 | ] 23 | 24 | # Make sure the target is unique 25 | autosectionlabel_prefix_document = True 26 | autodoc_typehints = "none" 27 | typehints_defaults = "comma" 28 | add_module_names = False 29 | 30 | templates_path = ["_templates"] 31 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 32 | 33 | 34 | # -- Options for HTML output ------------------------------------------------- 35 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 36 | 37 | html_theme = "furo" 38 | html_static_path = ["_static"] 39 | -------------------------------------------------------------------------------- /examples/tour_record_3.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional 3 | 4 | from koda_validate import * 5 | from koda_validate.serialization import SerializableErr 6 | 7 | 8 | @dataclass 9 | class Employee: 10 | title: str 11 | name: str 12 | 13 | 14 | def no_dwight_regional_manager(employee: Employee) -> Optional[ErrType]: 15 | if ( 16 | "schrute" in employee.name.lower() 17 | and employee.title.lower() == "assistant regional manager" 18 | ): 19 | return SerializableErr("Assistant TO THE Regional Manager!") 20 | else: 21 | return None 22 | 23 | 24 | employee_validator = RecordValidator( 25 | into=Employee, 26 | keys=( 27 | ("title", StringValidator(not_blank, MaxLength(100), preprocessors=[strip])), 28 | ("name", StringValidator(not_blank, preprocessors=[strip])), 29 | ), 30 | # After we've validated individual fields, we may want to validate them as a whole 31 | validate_object=no_dwight_regional_manager, 32 | ) 33 | 34 | 35 | # The fields are valid but the object as a whole is not. 36 | assert employee_validator( 37 | { 38 | "title": "Assistant Regional Manager", 39 | "name": "Dwight Schrute", 40 | } 41 | ) == Invalid( 42 | SerializableErr("Assistant TO THE Regional Manager!"), 43 | Employee( 44 | "Assistant Regional Manager", 45 | "Dwight Schrute", 46 | ), 47 | employee_validator, 48 | ) 49 | -------------------------------------------------------------------------------- /examples/tour_dict_validator_any.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Hashable, Optional 2 | 3 | from koda_validate import * 4 | from koda_validate.serialization import SerializableErr 5 | 6 | 7 | def no_dwight_regional_manager(employee: Dict[Hashable, Any]) -> Optional[ErrType]: 8 | if ( 9 | "schrute" in employee["name"].lower() 10 | and employee["title"].lower() == "assistant regional manager" 11 | ): 12 | return SerializableErr("Assistant TO THE Regional Manager!") 13 | else: 14 | return None 15 | 16 | 17 | employee_validator = DictValidatorAny( 18 | { 19 | "title": StringValidator(not_blank, MaxLength(100), preprocessors=[strip]), 20 | "name": StringValidator(not_blank, preprocessors=[strip]), 21 | }, 22 | # After we've validated individual fields, we may want to validate them as a whole 23 | validate_object=no_dwight_regional_manager, 24 | ) 25 | 26 | assert employee_validator( 27 | {"name": "Jim Halpert", "title": "Sales Representative"} 28 | ) == Valid({"name": "Jim Halpert", "title": "Sales Representative"}) 29 | 30 | assert employee_validator( 31 | { 32 | "title": "Assistant Regional Manager", 33 | "name": "Dwight Schrute", 34 | } 35 | ) == Invalid( 36 | SerializableErr("Assistant TO THE Regional Manager!"), 37 | { 38 | "title": "Assistant Regional Manager", 39 | "name": "Dwight Schrute", 40 | }, 41 | employee_validator, 42 | ) 43 | -------------------------------------------------------------------------------- /examples/complex_nested.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List, Literal, Optional, TypedDict, Union 3 | 4 | from koda_validate import TypedDictValidator, Valid 5 | 6 | 7 | @dataclass 8 | class Ingredient: 9 | quantity: Union[int, float] 10 | unit: Optional[Literal["teaspoon", "tablespoon"]] # etc... 11 | name: str 12 | 13 | 14 | class Recipe(TypedDict): 15 | title: str 16 | ingredients: List[Ingredient] 17 | instructions: str 18 | 19 | 20 | recipe_validator = TypedDictValidator(Recipe) 21 | 22 | result = recipe_validator( 23 | { 24 | "title": "Peanut Butter and Jelly Sandwich", 25 | "ingredients": [ 26 | {"quantity": 2, "unit": None, "name": "slices of bread"}, 27 | {"quantity": 2, "unit": "tablespoon", "name": "peanut butter"}, 28 | {"quantity": 4.5, "unit": "teaspoon", "name": "jelly"}, 29 | ], 30 | "instructions": "spread the peanut butter and jelly onto the bread", 31 | } 32 | ) 33 | 34 | assert result == Valid( 35 | { 36 | "title": "Peanut Butter and Jelly Sandwich", 37 | "ingredients": [ 38 | Ingredient(quantity=2, unit=None, name="slices of bread"), 39 | Ingredient(quantity=2, unit="tablespoon", name="peanut butter"), 40 | Ingredient(quantity=4.5, unit="teaspoon", name="jelly"), 41 | ], 42 | "instructions": "spread the peanut butter and jelly onto the bread", 43 | } 44 | ) 45 | -------------------------------------------------------------------------------- /docs/philosophy/overview.rst: -------------------------------------------------------------------------------- 1 | Overview 2 | ======== 3 | 4 | .. module:: koda_validate 5 | :noindex: 6 | 7 | At its core, Koda Validate is little more than a few function signatures (see 8 | :ref:`philosophy/validators:Validators`, :ref:`philosophy/predicates:Predicates`, 9 | :ref:`philosophy/coercion:Coercion`, and :ref:`philosophy/processors:Processors`), which 10 | can be combined to build validators of arbitrary complexity. This simplicity also 11 | provides straightforward paths for: 12 | 13 | - optimization: Koda Validate tends to be fast (for Python) 14 | - extension: Koda Validate can be extended to Validate essentially anything, even asynchronously. 15 | 16 | .. note:: 17 | 18 | If you've run into edge cases that you can't work around in other validation libraries, please 19 | take a look at Extension. The simplest way to work around :class:`Validator` quirks in Koda Validate 20 | is often to write your own. 21 | 22 | Flexible 23 | -------- 24 | :class:`Validator`\s, :class:`Predicate`\s, :class:`Coercer`\s, and :class:`Processor`\s in Koda Validate are not coupled with 25 | any specific framework, serialization format, or language. Instead Koda Validate aims to make it 26 | straightforward to contextualize validation outputs and artifacts -- by writing *interpreters* that 27 | consume a :class:`Validator` and produce some output. This effectively makes Koda Validate just as easy to 28 | work with in any framework, format or language. More info is available at :ref:`how_to/metadata:Metadata`. 29 | -------------------------------------------------------------------------------- /koda_validate/bytes.py: -------------------------------------------------------------------------------- 1 | from koda_validate._internal import _ToTupleStandardValidator 2 | 3 | 4 | class BytesValidator(_ToTupleStandardValidator[bytes]): 5 | r""" 6 | Validate a value is a ``bytes``, and any extra refinement. 7 | 8 | If ``predicates_async`` is supplied, the ``__call__`` method should not be 9 | called -- only ``.validate_async`` should be used. 10 | 11 | Example: 12 | 13 | >>> from koda_validate import * 14 | >>> validator = BytesValidator(not_blank, MaxLength(100), preprocessors=[strip]) 15 | >>> validator(b"") 16 | Invalid( 17 | err_type=PredicateErrs(predicates=[ 18 | NotBlank(), 19 | ]), 20 | value=b'', 21 | validator=BytesValidator(NotBlank(), MaxLength(length=100), preprocessors=[Strip()]) 22 | ) 23 | >>> validator("") 24 | Invalid( 25 | err_type=TypeErr(expected_type=), 26 | value='', 27 | validator=BytesValidator(NotBlank(), MaxLength(length=100), preprocessors=[Strip()]) 28 | ) 29 | >>> validator(b' ok ') 30 | Valid(val=b'ok') 31 | 32 | :param predicates: any number of ``Predicate[bytes]`` instances 33 | :param predicates_async: any number of ``PredicateAsync[bytes]`` instances 34 | :param preprocessors: any number of ``Processor[bytes]``, which will be run before 35 | :class:`Predicate`\s and :class:`PredicateAsync`\s are checked. 36 | :param coerce: a function that can control coercion 37 | """ # noqa: E501 38 | 39 | _TYPE = bytes 40 | -------------------------------------------------------------------------------- /examples/django_example/django_tests/test_views/test_contact_simple.py: -------------------------------------------------------------------------------- 1 | from django.test import Client 2 | 3 | 4 | def test_request_example_empty() -> None: 5 | response = Client().post("/contact", data={}, content_type="application/json") 6 | assert response.status_code == 400 7 | assert response.json() == { 8 | "email": ["key missing"], 9 | "message": ["key missing"], 10 | "name": ["key missing"], 11 | } 12 | 13 | 14 | def test_request_example_bad_vals() -> None: 15 | response = Client().post( 16 | "/contact", 17 | data={"email": "invalidemail", "message": 5, "name": 3.14}, 18 | content_type="application/json", 19 | ) 20 | assert response.status_code == 400 21 | assert response.json() == { 22 | "email": ["expected a valid email address"], 23 | "message": ["expected a string"], 24 | "name": ["expected a string"], 25 | } 26 | 27 | 28 | def test_request_example_successful() -> None: 29 | for valid_dict in [ 30 | {"email": "abc@def.com", "message": "something cool", "name": "bob"}, 31 | { 32 | "email": "abc@def.com", 33 | "message": "something cool", 34 | "name": "bob", 35 | "subject": "my subject", 36 | }, 37 | ]: 38 | response = Client().post( 39 | "/contact", 40 | data=valid_dict, 41 | content_type="application/json", 42 | ) 43 | assert response.status_code == 200 44 | assert response.json() == {"success": True} 45 | -------------------------------------------------------------------------------- /examples/flask_examples/test_basic.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from basic import app 4 | 5 | 6 | def test_request_example_empty() -> None: 7 | response = app.test_client().post( 8 | "/contact", data=json.dumps({}), content_type="application/json" 9 | ) 10 | assert response.status_code == 400 11 | assert response.json == { 12 | "email": ["key missing"], 13 | "message": ["key missing"], 14 | "name": ["key missing"], 15 | } 16 | 17 | 18 | def test_request_example_bad_vals() -> None: 19 | response = app.test_client().post( 20 | "/contact", 21 | data=json.dumps({"email": "invalidemail", "message": 5, "name": 3.14}), 22 | content_type="application/json", 23 | ) 24 | assert response.status_code == 400 25 | assert response.json == { 26 | "email": ["expected a valid email address"], 27 | "message": ["expected a string"], 28 | "name": ["expected a string"], 29 | } 30 | 31 | 32 | def test_request_example_successful() -> None: 33 | for valid_dict in [ 34 | {"email": "abc@def.com", "message": "something cool", "name": "bob"}, 35 | { 36 | "email": "abc@def.com", 37 | "message": "something cool", 38 | "name": "bob", 39 | "subject": "my subject", 40 | }, 41 | ]: 42 | response = app.test_client().post( 43 | "/contact", 44 | data=json.dumps(valid_dict), 45 | content_type="application/json", 46 | ) 47 | assert response.status_code == 200 48 | assert response.json == {"success": True} 49 | -------------------------------------------------------------------------------- /examples/flask_examples/test_async_captcha.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from async_captcha import app 4 | 5 | 6 | def test_request_example_empty() -> None: 7 | response = app.test_client().post( 8 | "/contact", data=json.dumps({}), content_type="application/json" 9 | ) 10 | assert response.status_code == 400 11 | assert response.json == { 12 | "email": ["key missing"], 13 | "message": ["key missing"], 14 | "captcha": ["key missing"], 15 | } 16 | 17 | 18 | def test_request_example_bad_vals() -> None: 19 | response = app.test_client().post( 20 | "/contact", 21 | data=json.dumps( 22 | { 23 | "email": "invalidemail", 24 | "message": "short", 25 | "captcha": {"seed": "0123456789abcdef", "response": "invalid:("}, 26 | } 27 | ), 28 | content_type="application/json", 29 | ) 30 | assert response.status_code == 400 31 | assert response.json == { 32 | "email": ["expected a valid email address"], 33 | "message": ["minimum allowed length is 10"], 34 | "captcha": {"response": "bad captcha response"}, 35 | } 36 | 37 | 38 | def test_request_example_successful() -> None: 39 | response = app.test_client().post( 40 | "/contact", 41 | data=json.dumps( 42 | { 43 | "email": "abc@xyz.com", 44 | "message": "long enough message", 45 | "captcha": {"seed": "0123456789abcdef", "response": "fedcba9876543210"}, 46 | } 47 | ), 48 | content_type="application/json", 49 | ) 50 | assert response.status_code == 200 51 | assert response.json == {"success": True} 52 | -------------------------------------------------------------------------------- /examples/django_example/django_tests/test_views/test_contact_async.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.test import AsyncClient 3 | 4 | 5 | @pytest.mark.asyncio 6 | async def test_request_example_empty() -> None: 7 | response = await AsyncClient().post( 8 | "/contact-async", data={}, content_type="application/json" 9 | ) 10 | assert response.status_code == 400 11 | assert response.json() == { 12 | "email": ["key missing"], 13 | "message": ["key missing"], 14 | "captcha": ["key missing"], 15 | } 16 | 17 | 18 | @pytest.mark.asyncio 19 | async def test_request_example_bad_vals() -> None: 20 | response = await AsyncClient().post( 21 | "/contact-async", 22 | data={ 23 | "email": "invalidemail", 24 | "message": "short", 25 | "captcha": {"seed": "0123456789abcdef", "response": "invalid:("}, 26 | }, 27 | content_type="application/json", 28 | ) 29 | assert response.status_code == 400 30 | assert response.json() == { 31 | "email": ["expected a valid email address"], 32 | "message": ["minimum allowed length is 10"], 33 | "captcha": {"response": "bad captcha response"}, 34 | } 35 | 36 | 37 | @pytest.mark.asyncio 38 | async def test_request_example_successful() -> None: 39 | response = await AsyncClient().post( 40 | "/contact-async", 41 | data={ 42 | "email": "abc@xyz.com", 43 | "message": "long enough message", 44 | "captcha": {"seed": "0123456789abcdef", "response": "fedcba9876543210"}, 45 | }, 46 | content_type="application/json", 47 | ) 48 | assert response.status_code == 200 49 | assert response.json() == {"success": True} 50 | -------------------------------------------------------------------------------- /tests/test_lazy.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | import pytest 4 | from koda import Just, Maybe, nothing 5 | 6 | from koda_validate import IntValidator, Lazy, Valid 7 | from koda_validate.dictionary import KeyNotRequired, RecordValidator 8 | 9 | 10 | def test_lazy() -> None: 11 | @dataclass 12 | class TestNonEmptyList: 13 | val: int 14 | next: Maybe["TestNonEmptyList"] # noqa: F821 15 | 16 | def recur_tnel() -> RecordValidator[TestNonEmptyList]: 17 | return nel_validator 18 | 19 | lazy_v = Lazy(recur_tnel) 20 | 21 | nel_validator: RecordValidator[TestNonEmptyList] = RecordValidator( 22 | into=TestNonEmptyList, 23 | keys=(("val", IntValidator()), ("next", KeyNotRequired(lazy_v))), 24 | ) 25 | 26 | assert nel_validator({"val": 5, "next": {"val": 6, "next": {"val": 7}}}) == Valid( 27 | TestNonEmptyList(5, Just(TestNonEmptyList(6, Just(TestNonEmptyList(7, nothing))))) 28 | ) 29 | assert repr(lazy_v) == f"Lazy({repr(recur_tnel)}, recurrent=True)" 30 | assert lazy_v == Lazy(recur_tnel) 31 | 32 | 33 | @pytest.mark.asyncio 34 | async def test_lazy_async() -> None: 35 | @dataclass 36 | class TestNonEmptyList: 37 | val: int 38 | next: Maybe["TestNonEmptyList"] # noqa: F821 39 | 40 | def recur_tnel() -> RecordValidator[TestNonEmptyList]: 41 | return nel_validator 42 | 43 | nel_validator: RecordValidator[TestNonEmptyList] = RecordValidator( 44 | into=TestNonEmptyList, 45 | keys=(("val", IntValidator()), ("next", KeyNotRequired(Lazy(recur_tnel)))), 46 | ) 47 | 48 | assert await nel_validator.validate_async( 49 | {"val": 5, "next": {"val": 6, "next": {"val": 7}}} 50 | ) == Valid( 51 | TestNonEmptyList(5, Just(TestNonEmptyList(6, Just(TestNonEmptyList(7, nothing))))) 52 | ) 53 | -------------------------------------------------------------------------------- /tests/test_is_type.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | import pytest 4 | 5 | from koda_validate import Invalid, Predicate, PredicateErrs, Processor, TypeErr, Valid 6 | from koda_validate.is_type import TypeValidator 7 | 8 | 9 | def test_type_simple() -> None: 10 | class Person: 11 | def __init__(self, name: str) -> None: 12 | self.name = name 13 | 14 | validator = TypeValidator(Person) 15 | 16 | assert validator(p := Person("bob")) == Valid(p) 17 | 18 | assert validator("bob") == Invalid(TypeErr(Person), "bob", validator) 19 | 20 | 21 | def test_type_complex() -> None: 22 | @dataclass 23 | class Dog: 24 | name: str 25 | breed: str 26 | 27 | @dataclass 28 | class IsBeagle(Predicate[Dog]): 29 | def __call__(self, val: Dog) -> bool: 30 | return val.breed == "beagle" 31 | 32 | class LowerDogBreed(Processor[Dog]): 33 | def __call__(self, val: Dog) -> Dog: 34 | return Dog( 35 | val.name, 36 | val.breed.lower(), 37 | ) 38 | 39 | validator = TypeValidator( 40 | Dog, predicates=[IsBeagle()], preprocessors=[LowerDogBreed()] 41 | ) 42 | 43 | assert validator(Dog("sparky", "Beagle")) == Valid(Dog("sparky", "beagle")) 44 | 45 | assert validator(Dog("spot", "terrier")) == Invalid( 46 | PredicateErrs([IsBeagle()]), Dog("spot", "terrier"), validator 47 | ) 48 | 49 | 50 | @pytest.mark.asyncio 51 | async def test_type_simple_async() -> None: 52 | class Person: 53 | def __init__(self, name: str) -> None: 54 | self.name = name 55 | 56 | validator = TypeValidator(Person) 57 | 58 | assert await validator.validate_async(p := Person("bob")) == Valid(p) 59 | 60 | assert await validator.validate_async("bob") == Invalid( 61 | TypeErr(Person), "bob", validator 62 | ) 63 | -------------------------------------------------------------------------------- /koda_validate/string.py: -------------------------------------------------------------------------------- 1 | import re 2 | from dataclasses import dataclass 3 | from typing import Pattern 4 | 5 | from koda_validate._internal import _ToTupleStandardValidator 6 | from koda_validate.base import Predicate 7 | 8 | 9 | class StringValidator(_ToTupleStandardValidator[str]): 10 | r""" 11 | Validate a value is a ``str``, and any extra refinement. 12 | 13 | If ``predicates_async`` is supplied, the ``__call__`` method should not be 14 | called -- only ``.validate_async`` should be used. 15 | 16 | Example: 17 | 18 | >>> from koda_validate import * 19 | >>> validator = StringValidator(not_blank, MaxLength(100), preprocessors=[strip]) 20 | >>> validator("") 21 | Invalid( 22 | err_type=PredicateErrs(predicates=[ 23 | NotBlank(), 24 | ]), 25 | value='', 26 | validator=StringValidator(NotBlank(), MaxLength(length=100), preprocessors=[Strip()]) 27 | ) 28 | >>> validator(None) 29 | Invalid( 30 | err_type=TypeErr(expected_type=), 31 | value=None, 32 | validator=StringValidator(NotBlank(), MaxLength(length=100), preprocessors=[Strip()]) 33 | ) 34 | >>> validator(" ok ") 35 | Valid(val='ok') 36 | 37 | :param predicates: any number of ``Predicate[str]`` instances 38 | :param predicates_async: any number of ``PredicateAsync[str]`` instances 39 | :param preprocessors: any number of ``Processor[str]``, which will be run before 40 | :class:`Predicate`\s and :class:`PredicateAsync`\s are checked. 41 | """ # noqa: E501 42 | 43 | _TYPE = str 44 | 45 | 46 | @dataclass 47 | class RegexPredicate(Predicate[str]): 48 | pattern: Pattern[str] 49 | 50 | def __call__(self, val: str) -> bool: 51 | return self.pattern.match(val) is not None 52 | 53 | 54 | @dataclass 55 | class EmailPredicate(Predicate[str]): 56 | pattern: Pattern[str] = re.compile("[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+") 57 | 58 | def __call__(self, val: str) -> bool: 59 | return self.pattern.match(val) is not None 60 | -------------------------------------------------------------------------------- /examples/flat_errors.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List, Optional, TypedDict, Union 3 | 4 | from koda_validate import * 5 | 6 | 7 | @dataclass 8 | class FlatError: 9 | location: List[Union[int, str]] 10 | message: str 11 | 12 | 13 | def to_flat_errs( 14 | invalid: Invalid, location: Optional[List[Union[str, int]]] = None 15 | ) -> List[FlatError]: 16 | """ 17 | recursively add errors to a flat list 18 | """ 19 | loc = location or [] 20 | err_type = invalid.err_type 21 | 22 | if isinstance(err_type, TypeErr): 23 | return [FlatError(loc, f"expected type {err_type.expected_type}")] 24 | 25 | elif isinstance(err_type, MissingKeyErr): 26 | return [FlatError(loc, "missing key!")] 27 | 28 | elif isinstance(err_type, KeyErrs): 29 | errs = [] 30 | for k, inv_v in err_type.keys.items(): 31 | errs.extend(to_flat_errs(inv_v, loc + [k])) 32 | return errs 33 | 34 | elif isinstance(err_type, IndexErrs): 35 | errs = [] 36 | for i, inv_item in err_type.indexes.items(): 37 | errs.extend(to_flat_errs(inv_item, loc + [i])) 38 | return errs 39 | 40 | else: 41 | raise TypeError(f"unhandled type {err_type}") 42 | 43 | 44 | class Person(TypedDict): 45 | name: str 46 | age: int 47 | 48 | 49 | validator = ListValidator(TypedDictValidator(Person)) 50 | 51 | simple_result = validator({}) 52 | assert isinstance(simple_result, Invalid) 53 | assert to_flat_errs(simple_result) == [ 54 | FlatError(location=[], message="expected type ") 55 | ] 56 | 57 | complex_result = validator([None, {}, {"name": "Bob", "age": "not an int"}]) 58 | assert isinstance(complex_result, Invalid) 59 | assert to_flat_errs(complex_result) == [ 60 | FlatError(location=[0], message="expected type "), 61 | FlatError(location=[1, "name"], message="missing key!"), 62 | FlatError(location=[1, "age"], message="missing key!"), 63 | FlatError(location=[2, "age"], message="expected type "), 64 | ] 65 | -------------------------------------------------------------------------------- /koda_validate/maybe.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from koda import Just, Maybe, nothing 4 | 5 | from koda_validate._generics import A 6 | from koda_validate._internal import ( 7 | _ResultTuple, 8 | _ToTupleValidator, 9 | _wrap_async_validator, 10 | _wrap_sync_validator, 11 | ) 12 | from koda_validate.base import Validator 13 | from koda_validate.errors import ContainerErr, TypeErr 14 | from koda_validate.valid import Invalid 15 | 16 | 17 | class MaybeValidator(_ToTupleValidator[Maybe[A]]): 18 | __match_args__ = ("validator",) 19 | 20 | def __init__(self, validator: Validator[A]): 21 | self.validator = validator 22 | self._validator_sync = _wrap_sync_validator(self.validator) 23 | self._validator_async = _wrap_async_validator(self.validator) 24 | 25 | async def _validate_to_tuple_async(self, val: Any) -> _ResultTuple[Maybe[A]]: 26 | if val is nothing: 27 | return True, nothing 28 | elif type(val) is Just: 29 | result = await self._validator_async(val.val) 30 | if result[0]: 31 | return True, Just(result[1]) 32 | else: 33 | return False, Invalid(ContainerErr(result[1]), val, self) 34 | else: 35 | return False, Invalid(TypeErr(Maybe[Any]), val, self) # type: ignore[misc] 36 | 37 | def _validate_to_tuple(self, val: Any) -> _ResultTuple[Maybe[A]]: 38 | if val is nothing: 39 | return True, nothing 40 | elif type(val) is Just: 41 | result = self._validator_sync(val.val) 42 | if result[0]: 43 | return True, Just(result[1]) 44 | else: 45 | return False, Invalid(ContainerErr(result[1]), val, self) 46 | else: 47 | return False, Invalid(TypeErr(Maybe[Any]), val, self) # type: ignore[misc] 48 | 49 | def __eq__(self, other: Any) -> bool: 50 | return type(self) == type(other) and self.validator == other.validator 51 | 52 | def __repr__(self) -> str: 53 | return f"{self.__class__.__name__}({repr(self.validator)})" 54 | -------------------------------------------------------------------------------- /docs/philosophy/coercion.rst: -------------------------------------------------------------------------------- 1 | Coercion 2 | ======== 3 | 4 | .. module:: koda_validate 5 | :noindex: 6 | 7 | Coercion is a fundamental part of validation that happens at the start. In Koda Validate 8 | coercion is expressed through the function signature: 9 | 10 | .. testsetup:: coercer 11 | 12 | from typing import TypeVar, Callable, Any 13 | from koda import Maybe 14 | 15 | .. testcode:: coercer 16 | 17 | A = TypeVar('A') 18 | 19 | Callable[[Any], Maybe[A]] 20 | 21 | Where ``A`` corresponds to the generic parameter of a some ``Validator[A]``. Koda Validate 22 | allows users to customize how coercion works. For example, to allow an :class:`IntValidator` 23 | instance to coerce strings into integers, we can simply define a :class:`Coercer` to 24 | do this. 25 | 26 | .. testcode:: coerce 27 | 28 | from typing import Any 29 | from koda_validate import coercer, Valid, Invalid, IntValidator 30 | from koda.maybe import Maybe, Just, nothing 31 | 32 | @coercer(int, str) 33 | def allow_coerce_str_to_int(val: Any) -> Maybe[int]: 34 | if type(val) is int: 35 | return Just(val) 36 | elif type(val) is str: 37 | try: 38 | return Just(int(val)) 39 | except ValueError: 40 | return nothing 41 | else: 42 | return nothing 43 | 44 | 45 | validator = IntValidator(coerce=allow_coerce_str_to_int) 46 | 47 | # ints work 48 | assert validator(5) == Valid(5) 49 | 50 | # valid int strings work 51 | assert validator("5") == Valid(5) 52 | 53 | # invalid strings fail 54 | assert isinstance(validator("abc"), Invalid) 55 | 56 | .. note:: 57 | 58 | :data:`coercer` is a convenience wrapper for :class:`Coercer` 59 | 60 | The decorator :data:`coercer` accepts the types that are potentially valid. In this example, 61 | all ``int``\s are and some ``str``\s can be coerced, so the two types passed as arguments 62 | are ``str`` and ``int``. These types are just metadata: they allow us to be able to communicate 63 | which types can be coerced; they have no effect on validation. 64 | -------------------------------------------------------------------------------- /koda_validate/time.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime 2 | from typing import Any, Optional 3 | 4 | from koda import Just, Maybe, nothing 5 | 6 | from koda_validate import Predicate, PredicateAsync, Processor 7 | from koda_validate._internal import _ToTupleStandardValidator 8 | from koda_validate.coerce import Coercer, coercer 9 | 10 | 11 | @coercer(str, date) 12 | def coerce_date(val: Any) -> Maybe[date]: 13 | if type(val) is date: 14 | return Just(val) 15 | else: 16 | try: 17 | return Just(date.fromisoformat(val)) 18 | except (ValueError, TypeError): 19 | return nothing 20 | 21 | 22 | class DateValidator(_ToTupleStandardValidator[date]): 23 | _TYPE = date 24 | 25 | def __init__( 26 | self, 27 | *predicates: Predicate[date], 28 | predicates_async: Optional[list[PredicateAsync[date]]] = None, 29 | preprocessors: Optional[list[Processor[date]]] = None, 30 | coerce: Optional[Coercer[date]] = coerce_date, 31 | ) -> None: 32 | super().__init__( 33 | *predicates, 34 | predicates_async=predicates_async, 35 | preprocessors=preprocessors, 36 | coerce=coerce, 37 | ) 38 | 39 | 40 | @coercer(str, datetime) 41 | def coerce_datetime(val: Any) -> Maybe[datetime]: 42 | if type(val) is datetime: 43 | return Just(val) 44 | else: 45 | try: 46 | return Just(datetime.fromisoformat(val)) 47 | except (ValueError, TypeError): 48 | return nothing 49 | 50 | 51 | class DatetimeValidator(_ToTupleStandardValidator[datetime]): 52 | _TYPE = datetime 53 | 54 | def __init__( 55 | self, 56 | *predicates: Predicate[datetime], 57 | predicates_async: Optional[list[PredicateAsync[datetime]]] = None, 58 | preprocessors: Optional[list[Processor[datetime]]] = None, 59 | coerce: Optional[Coercer[datetime]] = coerce_datetime, 60 | ) -> None: 61 | super().__init__( 62 | *predicates, 63 | predicates_async=predicates_async, 64 | preprocessors=preprocessors, 65 | coerce=coerce, 66 | ) 67 | -------------------------------------------------------------------------------- /examples/flask_examples/async_captcha.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Annotated, Optional, Tuple, TypedDict 3 | 4 | from flask import Flask, jsonify, request 5 | from flask.typing import ResponseValue 6 | 7 | from koda_validate import * 8 | from koda_validate.serialization import SerializableErr, to_serializable_errs 9 | 10 | app = Flask(__name__) 11 | 12 | 13 | class Captcha(TypedDict): 14 | seed: Annotated[str, StringValidator(ExactLength(16))] 15 | response: Annotated[str, StringValidator(MaxLength(16))] 16 | 17 | 18 | async def validate_captcha(captcha: Captcha) -> Optional[ErrType]: 19 | """ 20 | after we validate that the seed and response on their own, 21 | we need to check our database to make sure the response is correct 22 | """ 23 | 24 | async def pretend_check_captcha_service(seed: str, response: str) -> bool: 25 | await asyncio.sleep(0.01) # pretend to call 26 | return seed == response[::-1] 27 | 28 | if await pretend_check_captcha_service(captcha["seed"], captcha["response"]): 29 | # everything's valid 30 | return None 31 | else: 32 | return SerializableErr({"response": "bad captcha response"}) 33 | 34 | 35 | class ContactForm(TypedDict): 36 | email: Annotated[str, StringValidator(EmailPredicate())] 37 | message: Annotated[str, StringValidator(MaxLength(500), MinLength(10))] 38 | captcha: Annotated[ 39 | Captcha, 40 | # explicitly adding some extra validation 41 | TypedDictValidator(Captcha, validate_object_async=validate_captcha), 42 | ] 43 | 44 | 45 | contact_validator = TypedDictValidator(ContactForm) 46 | 47 | 48 | @app.route("/contact", methods=["POST"]) 49 | async def contact_api() -> Tuple[ResponseValue, int]: 50 | result = await contact_validator.validate_async(request.json) 51 | match result: 52 | case Valid(contact_form): 53 | print(contact_form) 54 | return {"success": True}, 200 55 | case Invalid() as inv: 56 | return jsonify(to_serializable_errs(inv)), 400 57 | 58 | 59 | # if you want a JSON Schema from a ``Validator``, there's `to_json_schema()` 60 | # schema = to_json_schema(contact_validator) 61 | # hook_into_some_api_definition(schema) 62 | 63 | 64 | if __name__ == "__main__": 65 | app.run() 66 | -------------------------------------------------------------------------------- /bench/min_max.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any, Dict, List 3 | 4 | from pydantic import BaseModel, ValidationError, conint, constr 5 | from voluptuous import All, Length, MultipleInvalid, Range, Schema 6 | 7 | from koda_validate import ( 8 | DictValidatorAny, 9 | IntValidator, 10 | Max, 11 | MaxLength, 12 | Min, 13 | MinLength, 14 | RecordValidator, 15 | StringValidator, 16 | ) 17 | 18 | 19 | @dataclass 20 | class SimpleStr: 21 | val_1: str 22 | val_2: int 23 | 24 | 25 | simple_str_validator = RecordValidator( 26 | into=SimpleStr, 27 | keys=( 28 | ("val_1", StringValidator(MinLength(2), MaxLength(5))), 29 | ("val_2", IntValidator(Min(1), Max(10))), 30 | ), 31 | ) 32 | 33 | simple_str_validator_dict_any = DictValidatorAny( 34 | { 35 | "val_1": StringValidator(MinLength(2), MaxLength(5)), 36 | "val_2": IntValidator(Min(1), Max(10)), 37 | } 38 | ) 39 | 40 | 41 | def run_kv(objs: List[Any]) -> None: 42 | for obj in objs: 43 | if (result := simple_str_validator(obj)).is_valid: 44 | _ = result.val 45 | else: 46 | pass 47 | 48 | 49 | def run_kv_dict_any(objs: List[Any]) -> None: 50 | for obj in objs: 51 | if (result := simple_str_validator_dict_any(obj)).is_valid: 52 | _ = result.val 53 | else: 54 | pass 55 | 56 | 57 | class ConstrainedModel(BaseModel): 58 | val_1: constr(strict=True, min_length=2, max_length=5) 59 | val_2: conint(strict=True, ge=1, le=10) 60 | 61 | 62 | def run_pyd(objs: List[Any]) -> None: 63 | for obj in objs: 64 | try: 65 | _ = ConstrainedModel(**obj) 66 | except ValidationError: 67 | pass 68 | 69 | 70 | v_schema = Schema( 71 | { 72 | "val_1": All(str, Length(min=2, max=5)), 73 | "val_2": All(int, Range(min=1, max=10)), 74 | } 75 | ) 76 | 77 | 78 | def run_v(objs: List[Any]) -> None: 79 | for obj in objs: 80 | try: 81 | v_schema(obj) 82 | except MultipleInvalid: 83 | pass 84 | 85 | 86 | def gen_valid(i: int) -> Dict[str, Any]: 87 | return {"val_1": f"ok{i}", "val_2": (i % 6) + 2} 88 | 89 | 90 | def gen_invalid(i: int) -> Dict[str, Any]: 91 | if i % 2 == 0: 92 | # too low 93 | return {"val_1": str(i % 8), "val_2": 0 - i} 94 | else: 95 | return {"val_1": f"toolongggg{i}", "val_2": 11 + i} 96 | -------------------------------------------------------------------------------- /examples/django_example/django_example/views/contact_async.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | from typing import Annotated, Optional, TypedDict 4 | 5 | from django.http import HttpRequest, HttpResponse, JsonResponse 6 | 7 | from koda_validate import * 8 | from koda_validate.serialization import SerializableErr, to_serializable_errs 9 | 10 | 11 | class Captcha(TypedDict): 12 | seed: Annotated[str, StringValidator(ExactLength(16))] 13 | response: Annotated[str, StringValidator(MaxLength(16))] 14 | 15 | 16 | async def validate_captcha(captcha: Captcha) -> Optional[ErrType]: 17 | """ 18 | after we validate that the seed and response both conform to the types/shapes we want, 19 | we need to check our database to make sure the response is correct 20 | """ 21 | await asyncio.sleep(0.01) # pretend to ask db 22 | if captcha["seed"] != captcha["response"][::-1]: 23 | return SerializableErr({"response": "bad captcha response"}) 24 | else: 25 | return None 26 | 27 | 28 | class ContactForm(TypedDict): 29 | email: Annotated[str, StringValidator(EmailPredicate())] 30 | message: Annotated[str, StringValidator(MaxLength(500), MinLength(10))] 31 | # we only need to explicitly define the TypedDictValidator here because we want 32 | # to include additional validation in validate_captcha 33 | captcha: Annotated[ 34 | Captcha, TypedDictValidator(Captcha, validate_object_async=validate_captcha) 35 | ] 36 | 37 | 38 | contact_validator = TypedDictValidator(ContactForm) 39 | 40 | 41 | async def contact_async(request: HttpRequest) -> HttpResponse: 42 | if request.method != "POST": 43 | return HttpResponse("HTTP method not allowed", status=405) 44 | 45 | try: 46 | posted_json = json.loads(request.body) 47 | except json.JSONDecodeError: 48 | return JsonResponse({"__container__": "expected json"}, status=400) 49 | else: 50 | result = await TypedDictValidator(ContactForm).validate_async(posted_json) 51 | match result: 52 | case Valid(contact_form): 53 | print(contact_form) 54 | return JsonResponse({"success": True}) 55 | case Invalid() as inv: 56 | return JsonResponse(to_serializable_errs(inv), status=400, safe=False) 57 | 58 | 59 | # if you want a JSON Schema from a ``Validator``, there's `to_json_schema()` 60 | # schema = to_json_schema(contact_validator) 61 | # hook_into_some_api_definition(schema) 62 | -------------------------------------------------------------------------------- /tests/test_maybe.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import pytest 4 | from koda import Just, Maybe, nothing 5 | 6 | from koda_validate import IntValidator, Invalid, StringValidator, TypeErr, Valid 7 | from koda_validate.errors import ContainerErr 8 | from koda_validate.maybe import MaybeValidator 9 | 10 | 11 | def test_works_with_nothing() -> None: 12 | mi_v = MaybeValidator(IntValidator()) 13 | 14 | assert mi_v(nothing) == Valid(nothing) 15 | 16 | 17 | def test_works_with_valid_just() -> None: 18 | mi_v = MaybeValidator(IntValidator()) 19 | 20 | assert mi_v(Just(5)) == Valid(Just(5)) 21 | 22 | 23 | def test_invalid_with_invalid_just() -> None: 24 | mi_v = MaybeValidator(IntValidator()) 25 | 26 | assert mi_v(Just("abc")) == Invalid( 27 | ContainerErr( 28 | Invalid(TypeErr(int), "abc", mi_v.validator), 29 | ), 30 | Just("abc"), 31 | mi_v, 32 | ) 33 | 34 | 35 | def test_not_maybe_is_invalid() -> None: 36 | mi_v = MaybeValidator(IntValidator()) 37 | 38 | assert mi_v("abc") == Invalid(TypeErr(Maybe[Any]), "abc", mi_v) # type: ignore[misc] 39 | 40 | 41 | @pytest.mark.asyncio 42 | async def test_works_with_nothing_async() -> None: 43 | mi_v = MaybeValidator(IntValidator()) 44 | 45 | assert await mi_v.validate_async(nothing) == Valid(nothing) 46 | 47 | 48 | @pytest.mark.asyncio 49 | async def test_works_with_valid_just_async() -> None: 50 | mi_v = MaybeValidator(IntValidator()) 51 | 52 | assert await mi_v.validate_async(Just(5)) == Valid(Just(5)) 53 | 54 | 55 | @pytest.mark.asyncio 56 | async def test_invalid_with_invalid_just_async() -> None: 57 | mi_v = MaybeValidator(IntValidator()) 58 | 59 | assert await mi_v.validate_async(Just("abc")) == Invalid( 60 | ContainerErr( 61 | Invalid(TypeErr(int), "abc", mi_v.validator), 62 | ), 63 | Just("abc"), 64 | mi_v, 65 | ) 66 | 67 | 68 | @pytest.mark.asyncio 69 | async def test_not_maybe_is_invalid_async() -> None: 70 | mi_v = MaybeValidator(IntValidator()) 71 | 72 | assert await mi_v.validate_async("abc") == Invalid( 73 | TypeErr(Maybe[Any]), "abc", mi_v # type: ignore[misc] 74 | ) 75 | 76 | 77 | def test_eq() -> None: 78 | assert MaybeValidator(StringValidator()) == MaybeValidator(StringValidator()) 79 | assert MaybeValidator(StringValidator()) != MaybeValidator(IntValidator()) 80 | 81 | 82 | def test_repr() -> None: 83 | assert repr(MaybeValidator(StringValidator())) == "MaybeValidator(StringValidator())" 84 | -------------------------------------------------------------------------------- /koda_validate/none.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any, Optional 3 | 4 | from koda_validate._generics import A 5 | from koda_validate._internal import ( 6 | _ResultTuple, 7 | _ToTupleValidator, 8 | _union_validator, 9 | _union_validator_async, 10 | ) 11 | from koda_validate.base import Validator 12 | from koda_validate.coerce import Coercer 13 | from koda_validate.errors import CoercionErr, TypeErr 14 | from koda_validate.valid import Invalid 15 | 16 | 17 | @dataclass 18 | class NoneValidator(_ToTupleValidator[None]): 19 | coerce: Optional[Coercer[None]] = None 20 | 21 | def _validate_to_tuple(self, val: Any) -> _ResultTuple[None]: 22 | if self.coerce: 23 | if self.coerce(val).is_just: 24 | return True, None 25 | else: 26 | return False, Invalid( 27 | CoercionErr(self.coerce.compatible_types, type(None)), val, self 28 | ) 29 | 30 | if val is None: 31 | return True, None 32 | else: 33 | return False, Invalid(TypeErr(type(None)), val, self) 34 | 35 | async def _validate_to_tuple_async(self, val: Any) -> _ResultTuple[None]: 36 | return self._validate_to_tuple(val) 37 | 38 | 39 | none_validator = NoneValidator() 40 | 41 | 42 | class OptionalValidator(_ToTupleValidator[Optional[A]]): 43 | """ 44 | We have a value for a key, but it can be null (None) 45 | """ 46 | 47 | __match_args__ = ("non_none_validator", "none_validator") 48 | 49 | def __init__( 50 | self, 51 | validator: Validator[A], 52 | *, 53 | none_validator: Validator[None] = NoneValidator(), 54 | ) -> None: 55 | self.non_none_validator = validator 56 | self.none_validator = none_validator 57 | self.validators = (none_validator, validator) 58 | 59 | async def _validate_to_tuple_async(self, val: Any) -> _ResultTuple[Optional[A]]: 60 | return await _union_validator_async(self, self.validators, val) 61 | 62 | def _validate_to_tuple(self, val: Any) -> _ResultTuple[Optional[A]]: 63 | return _union_validator(self, self.validators, val) 64 | 65 | def __eq__(self, other: Any) -> bool: 66 | return ( 67 | type(self) == type(other) 68 | and other.non_none_validator == self.non_none_validator 69 | and other.none_validator == self.none_validator 70 | ) 71 | 72 | def __repr__(self) -> str: 73 | return f"OptionalValidator({repr(self.non_none_validator)})" 74 | -------------------------------------------------------------------------------- /examples/extension_float_validator_predicates.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any, Optional 3 | 4 | from koda_validate import * 5 | from koda_validate import PredicateErrs, TypeErr, ValidationResult 6 | 7 | 8 | @dataclass 9 | class SimpleFloatValidator2(Validator[float]): 10 | predicate: Optional[Predicate[float]] = None 11 | 12 | def __call__(self, val: Any) -> ValidationResult[float]: 13 | if isinstance(val, float): 14 | if self.predicate: 15 | return ( 16 | Valid(val) 17 | if self.predicate(val) 18 | else Invalid(PredicateErrs([self.predicate]), val, self) 19 | ) 20 | else: 21 | return Valid(val) 22 | else: 23 | return Invalid(TypeErr(float), val, self) 24 | 25 | 26 | @dataclass 27 | class Range(Predicate[float]): 28 | minimum: float 29 | maximum: float 30 | 31 | def __call__(self, val: float) -> bool: 32 | return self.minimum <= val <= self.maximum 33 | 34 | 35 | range_validator = SimpleFloatValidator2(Range(0.5, 1.0)) 36 | test_val = 0.7 37 | 38 | assert range_validator(test_val) == Valid(test_val) 39 | 40 | assert range_validator(0.01) == Invalid( 41 | PredicateErrs([Range(0.5, 1.0)]), 0.01, range_validator 42 | ) 43 | 44 | 45 | @dataclass 46 | class SimpleFloatValidator3(Validator[float]): 47 | predicate: Optional[Predicate[float]] = None 48 | preprocessor: Optional[Processor[float]] = None 49 | 50 | def __call__(self, val: Any) -> ValidationResult[float]: 51 | if isinstance(val, float): 52 | if self.preprocessor: 53 | val = self.preprocessor(val) 54 | 55 | if self.predicate: 56 | return ( 57 | Valid(val) 58 | if self.predicate(val) 59 | else Invalid(PredicateErrs([self.predicate]), val, self) 60 | ) 61 | else: 62 | return Valid(val) 63 | else: 64 | return Invalid(TypeErr(float), val, self) 65 | 66 | 67 | class AbsValue(Processor[float]): 68 | def __call__(self, val: float) -> float: 69 | return abs(val) 70 | 71 | 72 | range_validator_2 = SimpleFloatValidator3( 73 | predicate=Range(0.5, 1.0), preprocessor=AbsValue() 74 | ) 75 | 76 | test_val = -0.7 77 | 78 | assert range_validator_2(test_val) == Valid(abs(test_val)) 79 | 80 | assert range_validator_2(-0.01) == Invalid( 81 | PredicateErrs([Range(0.5, 1.0)]), 0.01, range_validator_2 82 | ) 83 | -------------------------------------------------------------------------------- /docs/how_to/metadata.rst: -------------------------------------------------------------------------------- 1 | Metadata 2 | ======== 3 | 4 | .. module:: koda_validate 5 | :noindex: 6 | 7 | :class:`Validator`\s in Koda Validate naturally forms into graph structures, usually 8 | trees (a notable exception would be the use of :class:`Lazy`, which can create cycles). It is an 9 | aim of this library to facilitate the re-purposing of such validation structures for other 10 | purposes, such as schemas, HTML, documentation, customized error types, and so on. 11 | 12 | Because it's easy to branch on the type of a :class:`Validator`, it's straightforward to 13 | write functions that transform arbitrary :class:`Validator`\s into other structures. 14 | Some examples of this exist within Koda Validate: 15 | 16 | - :data:`to_json_schema` converts :class:`Validator`\s into JSON Schema objects 17 | - :data:`to_serializable_errs` converts :class:`Invalid` objects in human-readable serializable structures (discussed in :ref:`Errors `) 18 | - :data:`koda_validate.signature._get_arg_fail_message` converts ``Invalid`` objects to human-readable traceback messages. 19 | 20 | Pattern Matching 21 | ---------------- 22 | 23 | The recommended approach to branching on validation graphs is to use pattern matching 24 | against the type of :class:`Validator`. For example, if you wanted to generate a markdown description 25 | from a :class:`Validator`, you could start with something like this: 26 | 27 | .. testcode:: markdown 28 | 29 | from typing import Union, Any 30 | from koda_validate import (Validator, Predicate, PredicateAsync, 31 | ListValidator, StringValidator) 32 | 33 | def to_markdown_description(obj: Union[Validator[Any], 34 | Predicate[Any], 35 | PredicateAsync[Any]]) -> str: 36 | match obj: 37 | case StringValidator(): 38 | return "string validator" 39 | case ListValidator(item_validator): 40 | return f"list validator\n- {to_markdown_description(item_validator)}" 41 | case _: 42 | ... 43 | 44 | print(to_markdown_description(ListValidator(StringValidator()))) 45 | 46 | Outputs: 47 | 48 | .. testoutput:: markdown 49 | 50 | list validator 51 | - string validator 52 | 53 | Here we generated a very simple output with code that supports a tiny subset of 54 | :class:`Validator`\s, but it's easy to expand the same approach to produce arbitrary 55 | outputs for a wide range of validators. -------------------------------------------------------------------------------- /tests/test_boolean.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from dataclasses import dataclass 3 | 4 | import pytest 5 | 6 | from koda_validate import ( 7 | BoolValidator, 8 | Invalid, 9 | Predicate, 10 | PredicateAsync, 11 | PredicateErrs, 12 | Processor, 13 | TypeErr, 14 | Valid, 15 | ) 16 | from koda_validate._generics import A 17 | 18 | 19 | class Flip(Processor[bool]): 20 | def __call__(self, val: bool) -> bool: 21 | return not val 22 | 23 | 24 | def test_boolean() -> None: 25 | b_v = BoolValidator() 26 | assert b_v("a string") == Invalid(TypeErr(bool), "a string", b_v) 27 | 28 | assert b_v(True) == Valid(True) 29 | 30 | assert b_v(False) == Valid(False) 31 | 32 | @dataclass 33 | class RequireTrue(Predicate[bool]): 34 | def __call__(self, val: bool) -> bool: 35 | return val is True 36 | 37 | assert (true_bool := BoolValidator(RequireTrue()))(False) == Invalid( 38 | PredicateErrs([RequireTrue()]), False, true_bool 39 | ) 40 | 41 | assert b_v(1) == Invalid(TypeErr(bool), 1, b_v) 42 | 43 | @dataclass 44 | class IsTrue(Predicate[bool]): 45 | def __call__(self, val: bool) -> bool: 46 | return val is True 47 | 48 | assert BoolValidator(IsTrue(), preprocessors=[Flip()])(False) == Valid(True) 49 | 50 | assert (req_true_v := BoolValidator(IsTrue()))(False) == Invalid( 51 | PredicateErrs([IsTrue()]), False, req_true_v 52 | ) 53 | 54 | 55 | @pytest.mark.asyncio 56 | async def test_boolean_validator_async() -> None: 57 | @dataclass 58 | class IsTrue(PredicateAsync[bool]): 59 | async def validate_async(self, val: bool) -> bool: 60 | await asyncio.sleep(0.001) 61 | return val is True 62 | 63 | result = await ( 64 | require_true_v := BoolValidator( 65 | preprocessors=[Flip()], predicates_async=[IsTrue()] 66 | ) 67 | ).validate_async(True) 68 | 69 | assert result == Invalid(PredicateErrs([IsTrue()]), False, require_true_v) 70 | assert await BoolValidator( 71 | preprocessors=[Flip()], predicates_async=[IsTrue()] 72 | ).validate_async(False) == Valid(True) 73 | 74 | b_v = BoolValidator() 75 | 76 | assert await b_v.validate_async("abc") == Invalid(TypeErr(bool), "abc", b_v) 77 | 78 | 79 | def test_sync_call_with_async_predicates_raises_assertion_error() -> None: 80 | @dataclass 81 | class AsyncWait(PredicateAsync[A]): 82 | async def validate_async(self, val: A) -> bool: 83 | await asyncio.sleep(0.001) 84 | return True 85 | 86 | bool_validator = BoolValidator(predicates_async=[AsyncWait()]) 87 | with pytest.raises(AssertionError): 88 | bool_validator(True) 89 | -------------------------------------------------------------------------------- /tests/test_integer.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from dataclasses import dataclass 3 | 4 | import pytest 5 | 6 | from koda_validate import ( 7 | IntValidator, 8 | Invalid, 9 | Max, 10 | Min, 11 | Predicate, 12 | PredicateAsync, 13 | PredicateErrs, 14 | Processor, 15 | TypeErr, 16 | Valid, 17 | ) 18 | from koda_validate._generics import A 19 | 20 | 21 | class Add1Int(Processor[int]): 22 | def __call__(self, val: int) -> int: 23 | return val + 1 24 | 25 | 26 | def test_integer() -> None: 27 | i_v = IntValidator() 28 | assert i_v("a string") == Invalid(TypeErr(int), "a string", i_v) 29 | 30 | assert i_v(5) == Valid(5) 31 | 32 | assert i_v(True) == Invalid(TypeErr(int), True, i_v), ( 33 | "even though `bool`s are subclasses of ints in python, we wouldn't " 34 | "want to validate incoming data as ints if they are bools" 35 | ) 36 | 37 | assert i_v("5") == Invalid(TypeErr(int), "5", i_v) 38 | 39 | assert i_v(5.0) == Invalid(TypeErr(int), 5.0, i_v) 40 | 41 | @dataclass 42 | class DivisibleBy2(Predicate[int]): 43 | def __call__(self, val: int) -> bool: 44 | return val % 2 == 0 45 | 46 | i_v = IntValidator(Min(2), Max(10), DivisibleBy2()) 47 | assert i_v(11) == Invalid(PredicateErrs([Max(10), DivisibleBy2()]), 11, i_v) 48 | 49 | assert IntValidator(Min(2), preprocessors=[Add1Int()])(1) == Valid(2) 50 | 51 | i_v_add_1 = IntValidator(Min(3), preprocessors=[Add1Int()]) 52 | assert i_v_add_1(1) == Invalid(PredicateErrs([Min(3)]), 2, i_v_add_1) 53 | 54 | 55 | @pytest.mark.asyncio 56 | async def test_float_async() -> None: 57 | class Add1Int(Processor[int]): 58 | def __call__(self, val: int) -> int: 59 | return val + 1 60 | 61 | @dataclass 62 | class LessThan4(PredicateAsync[int]): 63 | async def validate_async(self, val: int) -> bool: 64 | await asyncio.sleep(0.001) 65 | return val < 4.0 66 | 67 | validator = IntValidator(preprocessors=[Add1Int()], predicates_async=[LessThan4()]) 68 | result = await validator.validate_async(3) 69 | assert result == Invalid(PredicateErrs([LessThan4()]), 4, validator) 70 | assert await IntValidator( 71 | preprocessors=[Add1Int()], predicates_async=[LessThan4()] 72 | ).validate_async(2) == Valid(3) 73 | 74 | 75 | def test_sync_call_with_async_predicates_raises_assertion_error() -> None: 76 | @dataclass 77 | class AsyncWait(PredicateAsync[A]): 78 | async def validate_async(self, val: A) -> bool: 79 | await asyncio.sleep(0.001) 80 | return True 81 | 82 | int_validator = IntValidator(predicates_async=[AsyncWait()]) 83 | with pytest.raises(AssertionError): 84 | int_validator(5) 85 | -------------------------------------------------------------------------------- /tests/test_time.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from dataclasses import dataclass 3 | from datetime import date, datetime 4 | 5 | import pytest 6 | 7 | from koda_validate import ( 8 | CoercionErr, 9 | DatetimeValidator, 10 | DateValidator, 11 | Invalid, 12 | PredicateAsync, 13 | Valid, 14 | ) 15 | from koda_validate._generics import A 16 | 17 | 18 | def test_date_validator() -> None: 19 | d_v = DateValidator() 20 | assert d_v("2021-03-21") == Valid(date(2021, 3, 21)) 21 | assert d_v("2021-3-21") == Invalid(CoercionErr({str, date}, date), "2021-3-21", d_v) 22 | 23 | assert d_v(date(2022, 10, 1)) == Valid(date(2022, 10, 1)) 24 | 25 | 26 | @pytest.mark.asyncio 27 | async def test_date_validator_async() -> None: 28 | d_v = DateValidator() 29 | assert await d_v.validate_async("2021-03-21") == Valid(date(2021, 3, 21)) 30 | assert await d_v.validate_async("2021-3-21") == Invalid( 31 | CoercionErr( 32 | {str, date}, 33 | date, 34 | ), 35 | "2021-3-21", 36 | d_v, 37 | ) 38 | 39 | 40 | def test_datetime_validator() -> None: 41 | dt_v = DatetimeValidator() 42 | assert dt_v("") == Invalid(CoercionErr({str, datetime}, datetime), "", dt_v) 43 | assert dt_v("2011-11-04") == Valid(datetime(2011, 11, 4, 0, 0)) 44 | assert dt_v("2011-11-04T00:05:23") == Valid(datetime(2011, 11, 4, 0, 5, 23)) 45 | 46 | now_ = datetime.now() 47 | assert dt_v(now_) == Valid(now_) 48 | 49 | 50 | @pytest.mark.asyncio 51 | async def test_datetime_validator_async() -> None: 52 | dt_v = DatetimeValidator() 53 | assert await dt_v.validate_async("") == Invalid( 54 | CoercionErr({str, datetime}, datetime), "", dt_v 55 | ) 56 | assert await dt_v.validate_async("2011-11-04") == Valid(datetime(2011, 11, 4, 0, 0)) 57 | assert await dt_v.validate_async("2011-11-04T00:05:23") == Valid( 58 | datetime(2011, 11, 4, 0, 5, 23) 59 | ) 60 | 61 | 62 | def test_sync_call_with_async_predicates_raises_assertion_error_date() -> None: 63 | @dataclass 64 | class AsyncWait(PredicateAsync[A]): 65 | async def validate_async(self, val: A) -> bool: 66 | await asyncio.sleep(0.001) 67 | return True 68 | 69 | date_validator = DateValidator(predicates_async=[AsyncWait()]) 70 | with pytest.raises(AssertionError): 71 | date_validator("123") 72 | 73 | 74 | def test_sync_call_with_async_predicates_raises_assertion_error_datetime() -> None: 75 | @dataclass 76 | class AsyncWait(PredicateAsync[A]): 77 | async def validate_async(self, val: A) -> bool: 78 | await asyncio.sleep(0.001) 79 | return True 80 | 81 | datetime_validator = DatetimeValidator(predicates_async=[AsyncWait()]) 82 | with pytest.raises(AssertionError): 83 | datetime_validator("123") 84 | -------------------------------------------------------------------------------- /docs/philosophy/predicates.rst: -------------------------------------------------------------------------------- 1 | Predicates 2 | ---------- 3 | 4 | .. module:: koda_validate 5 | :noindex: 6 | 7 | In the world of validation, predicates are simply expressions that return a ``True`` or ``False`` for a given condition. 8 | In Python type hints, predicates can be expressed like this: 9 | 10 | .. code-block:: python 11 | 12 | A = TypeVar('A') 13 | 14 | PredicateFunc = Callable[[A], bool] 15 | 16 | Koda Validate has a :class:`Predicate` class based on this concept. In Koda Validate, :class:`Predicate`\s are used to *enrich* :class:`Validator`\s 17 | by performing additional validation *after* the data has been verified to be of a specific type or shape. 18 | 19 | .. testcode:: intpred 20 | 21 | from koda_validate import IntValidator, Min 22 | 23 | int_validator = IntValidator(Min(5)) # `Min` is a `Predicate` 24 | 25 | Usage: 26 | 27 | .. doctest:: intpred 28 | 29 | >>> int_validator(6) 30 | Valid(val=6) 31 | 32 | >>> int_validator("a string") 33 | Invalid( 34 | err_type=TypeErr(expected_type=), 35 | value='a string', 36 | validator=IntValidator(Min(minimum=5, exclusive_minimum=False)) 37 | ) 38 | 39 | >>> int_validator(4) 40 | Invalid( 41 | err_type=PredicateErrs(predicates=[ 42 | Min(minimum=5, exclusive_minimum=False), 43 | ]), 44 | value=4, 45 | validator=IntValidator(Min(minimum=5, exclusive_minimum=False)) 46 | ) 47 | 48 | As you can see the value ``4`` passes the ``int`` type check but fails to pass the ``Min(5)`` predicate. 49 | 50 | Because we know that predicates don't change the type or value of their inputs, we can 51 | sequence an arbitrary number of :class:`Predicate`\s together, and validate them all. 52 | 53 | .. testcode:: intpred2 54 | 55 | from koda_validate import (IntValidator, Min, Max, MultipleOf, Invalid, 56 | Valid, PredicateErrs) 57 | 58 | int_validator = IntValidator(Min(5), Max(20), MultipleOf(4)) 59 | 60 | assert int_validator(12) == Valid(12) 61 | 62 | invalid_result = int_validator(23) 63 | assert isinstance(invalid_result, Invalid) 64 | assert invalid_result.err_type == PredicateErrs([Max(20), MultipleOf(4)]) 65 | 66 | 67 | Here ``int_validator`` has 3 :class:`Predicate`\s, but we could have as many as we want. Note 68 | that failing :class:`Predicate`\s are returned within a :class:`PredicateErrs` instance. We are only able 69 | to return all the failing :class:`Predicate`\s because we know that each :class:`Predicate` will not be able to change the value. 70 | 71 | :class:`Predicate`\s are easy to write -- take a look at :ref:`how_to/extension:Extension` for more details. 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | ### Python template 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cover/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | # For a library or package, you might want to ignore these files since the code is 90 | # intended to run in multiple environments; otherwise, check them in: 91 | # .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 101 | __pypackages__/ 102 | 103 | # Celery stuff 104 | celerybeat-schedule 105 | celerybeat.pid 106 | 107 | # SageMath parsed files 108 | *.sage.py 109 | 110 | # Environments 111 | .env 112 | .venv 113 | env/ 114 | venv/ 115 | ENV/ 116 | env.bak/ 117 | venv.bak/ 118 | 119 | # Spyder project settings 120 | .spyderproject 121 | .spyproject 122 | 123 | # Rope project settings 124 | .ropeproject 125 | 126 | # mkdocs documentation 127 | /site 128 | 129 | # mypy 130 | .mypy_cache/ 131 | .dmypy.json 132 | dmypy.json 133 | 134 | # Pyre type checker 135 | .pyre/ 136 | 137 | # pytype static type analyzer 138 | .pytype/ 139 | 140 | # Cython debug symbols 141 | cython_debug/ 142 | 143 | !/docs/how_to/results.rst 144 | -------------------------------------------------------------------------------- /tests/test_float.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from dataclasses import dataclass 3 | 4 | import pytest 5 | 6 | from koda_validate import Invalid, PredicateErrs, TypeErr, Valid 7 | from koda_validate._generics import A 8 | from koda_validate.base import Predicate, PredicateAsync, Processor 9 | from koda_validate.float import FloatValidator 10 | from koda_validate.generic import Max, Min 11 | 12 | 13 | class Add1Float(Processor[float]): 14 | def __call__(self, val: float) -> float: 15 | return val + 1.0 16 | 17 | 18 | def test_float() -> None: 19 | f_v = FloatValidator() 20 | assert f_v("a string") == Invalid(TypeErr(float), "a string", f_v) 21 | 22 | assert f_v(5.5) == Valid(5.5) 23 | 24 | assert f_v(4) == Invalid(TypeErr(float), 4, f_v) 25 | 26 | f_max_500_v = FloatValidator(Max(500.0)) 27 | assert f_max_500_v(503.0) == Invalid(PredicateErrs([Max(500.0)]), 503.0, f_max_500_v) 28 | 29 | assert FloatValidator(Max(500.0))(3.5) == Valid(3.5) 30 | 31 | f_max_5_v = FloatValidator(Min(5.0)) 32 | assert f_max_5_v(4.999) == Invalid(PredicateErrs([Min(5.0)]), 4.999, f_max_5_v) 33 | 34 | assert FloatValidator(Min(5.0))(5.0) == Valid(5.0) 35 | 36 | @dataclass 37 | class MustHaveAZeroSomewhere(Predicate[float]): 38 | def __call__(self, val: float) -> bool: 39 | for char in str(val): 40 | if char == "0": 41 | return True 42 | else: 43 | return False 44 | 45 | f_min_max_v = FloatValidator(Min(2.5), Max(4.0), MustHaveAZeroSomewhere()) 46 | assert f_min_max_v(5.5) == Invalid( 47 | PredicateErrs([Max(4.0), MustHaveAZeroSomewhere()]), 5.5, f_min_max_v 48 | ) 49 | 50 | f_min_25_v = FloatValidator(Min(2.5), preprocessors=[Add1Float()]) 51 | assert f_min_25_v(1.0) == Invalid(PredicateErrs([Min(2.5)]), 2.0, f_min_25_v) 52 | 53 | 54 | @pytest.mark.asyncio 55 | async def test_float_async() -> None: 56 | @dataclass 57 | class LessThan4(PredicateAsync[float]): 58 | async def validate_async(self, val: float) -> bool: 59 | await asyncio.sleep(0.001) 60 | return val < 4.0 61 | 62 | f_v = FloatValidator(preprocessors=[Add1Float()], predicates_async=[LessThan4()]) 63 | result = await f_v.validate_async(3.5) 64 | assert result == Invalid(PredicateErrs([LessThan4()]), 4.5, f_v) 65 | assert await FloatValidator( 66 | preprocessors=[Add1Float()], predicates_async=[LessThan4()] 67 | ).validate_async(2.5) == Valid(3.5) 68 | 69 | 70 | def test_sync_call_with_async_predicates_raises_assertion_error() -> None: 71 | @dataclass 72 | class AsyncWait(PredicateAsync[A]): 73 | async def validate_async(self, val: A) -> bool: 74 | await asyncio.sleep(0.001) 75 | return True 76 | 77 | float_validator = FloatValidator(predicates_async=[AsyncWait()]) 78 | with pytest.raises(AssertionError): 79 | float_validator(5) 80 | -------------------------------------------------------------------------------- /docs/philosophy/validators.rst: -------------------------------------------------------------------------------- 1 | Validators 2 | ========== 3 | .. module:: koda_validate 4 | :noindex: 5 | 6 | In Koda Validate, :class:`Validator` is the fundamental validation building block. It's based on the idea that 7 | any kind of data validation can be represented by a ``Callable`` signature like this: 8 | 9 | 10 | .. code-block:: python 11 | 12 | ValidType = TypeVar("ValidType") 13 | 14 | Callable[[Any], Union[Valid[ValidType], Invalid]] 15 | 16 | 17 | In Koda Validate we can abbreviate this with the :data:`ValidationResult` type alias to: 18 | 19 | .. code-block:: python 20 | 21 | Callable[[Any], ValidationResult[ValidType]] 22 | 23 | The way we actually represent this concept in Koda Validate is even simpler: 24 | 25 | .. code-block:: python 26 | 27 | Validator[ValidType] 28 | 29 | 30 | .. note:: 31 | 32 | There are a few differences between ``Validator[ValidType]`` and ``Callable[[Any], ValidationResult[ValidType]]`` that make them not exactly equivalent: 33 | 34 | - :class:`Validator`\s are always subclasses of :class:`Validator`. Using callable ``class``\es (instead of ``function``\s) makes it easy to branch on validators based on their class name. 35 | - :class:`Validator`\s have a ``validate_async`` method, which allows them to be used in both sync and async contexts. 36 | 37 | In sum, a ``Validator[ValidType]`` can be used in places where ``Callable[[Any], ValidationResult[ValidType]]`` is required, but 38 | ``Validator[ValidType]`` is fundamentally a richer type. 39 | 40 | We can see in the following example how :class:`Validator`\s act like simple functions 41 | which return either ``Valid[ValidType]`` or ``Invalid``: 42 | 43 | .. testcode:: callable 44 | 45 | from koda_validate import IntValidator 46 | 47 | int_validator = IntValidator() 48 | 49 | Usage: 50 | 51 | .. doctest:: callable 52 | 53 | >>> int_validator(5) 54 | Valid(val=5) 55 | 56 | >>> int_validator("not an integer") 57 | Invalid( 58 | err_type=TypeErr(expected_type=), 59 | value='not an integer', 60 | validator=IntValidator() 61 | ) 62 | 63 | Having this simple function signature-based definition for validation is useful, because it means we can *compose* 64 | validators. Perhaps the simplest example of this is how ``ListValidator`` accepts a validator for the items of the ``list``: 65 | 66 | .. testcode:: callable 67 | 68 | from koda_validate import ListValidator, StringValidator 69 | 70 | list_str_validator = ListValidator(StringValidator()) 71 | 72 | 73 | Usage: 74 | 75 | .. doctest:: callable 76 | 77 | >>> list_str_validator(["ok", "nice"]) 78 | Valid(val=['ok', 'nice']) 79 | 80 | >>> list_str_validator([1,2,3]) 81 | Invalid(...) 82 | 83 | Since :class:`Validator`\s are essentially functions (packaged as classes), 84 | they are flexible and easy to write. Take a look at :ref:`how_to/extension:Extension` to see how. 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Koda Validate 2 | 3 | Koda Validate is a library and toolkit for building composable and typesafe validators. In many cases, 4 | validators can be derived from typehints (e.g. TypedDicts, dataclasses, and NamedTuples). For everything else, you can 5 | combine existing validation logic, or write your own. At its heart, Koda Validate is just a few kinds of 6 | callables that fit together, so the possibilities are endless. It is async-friendly and comparable in performance to Pydantic 2. 7 | 8 | Koda Validate can be used in normal control flow or as a runtime type checker. 9 | 10 | Docs: [https://koda-validate.readthedocs.io/en/stable/](https://koda-validate.readthedocs.io/en/stable/) 11 | 12 | ## At a Glance 13 | 14 | ```python 15 | from koda_validate import StringValidator 16 | 17 | my_string_validator = StringValidator() 18 | 19 | my_string_validator("a string!") 20 | #> Valid("a string!") 21 | 22 | my_string_validator(5) 23 | #> Invalid(...) 24 | ``` 25 | 26 | #### Additional Validation 27 | ```python 28 | from koda_validate import MaxLength, MinLength 29 | 30 | str_len_validator = StringValidator(MinLength(1), MaxLength(20)) 31 | 32 | str_len_validator("abc") 33 | #> Valid("abc") 34 | 35 | str_len_validator("") 36 | #> Invalid(...) 37 | 38 | str_len_validator("abcdefghijklmnopqrstuvwxyz") 39 | #> Invalid(...) 40 | ``` 41 | 42 | #### Combining Validators 43 | 44 | ```python 45 | from koda_validate import ListValidator, StringValidator 46 | 47 | list_string_validator = ListValidator(StringValidator()) 48 | 49 | list_string_validator(["a", "b", "c"]) 50 | # > Valid(["a", "b", "c"]) 51 | 52 | list_string_validator([1, 2, 3]) 53 | # > Invalid(...) 54 | ``` 55 | 56 | #### Derived Validators 57 | 58 | ```python 59 | from typing import TypedDict 60 | from koda_validate import (TypedDictValidator, Valid, Invalid) 61 | from koda_validate.serialization import to_serializable_errs 62 | 63 | class Person(TypedDict): 64 | name: str 65 | hobbies: list[str] 66 | 67 | 68 | person_validator = TypedDictValidator(Person) 69 | 70 | match person_validator({"name": "Guido"}): 71 | case Valid(string_list): 72 | print(f"woohoo, valid!") 73 | case Invalid() as invalid: 74 | # readable errors 75 | print(to_serializable_errs(invalid)) 76 | 77 | #> {'hobbies': ['key missing']} 78 | ``` 79 | 80 | #### Runtime Type Checking 81 | 82 | ```python 83 | from koda_validate.signature import validate_signature 84 | 85 | @validate_signature 86 | def add(a: int, b: int) -> int: 87 | return a + b 88 | 89 | 90 | add(1, 2) # returns 3 91 | 92 | add(1, "2") # raises `InvalidArgsError` 93 | # koda_validate.signature.InvalidArgsError: 94 | # Invalid Argument Values 95 | # ----------------------- 96 | # b='2' 97 | # expected 98 | ``` 99 | 100 | There's much, much more in the [Docs](https://koda-validate.readthedocs.io/en/stable/). 101 | 102 | 103 | ## Something's Missing or Wrong 104 | Open an [issue on GitHub](https://github.com/keithasaurus/koda-validate/issues) please! 105 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "koda-validate" 3 | version = "5.1.0" 4 | readme = "README.md" 5 | description = "Typesafe, composable validation" 6 | documentation = "https://koda-validate.readthedocs.io/en/stable/" 7 | authors = ["Keith Philpott"] 8 | license = "MIT" 9 | homepage = "https://github.com/keithasaurus/koda-validate" 10 | keywords = ["validation", "type hints", "asyncio", "serialization", "typesafe", "validate", "validators", "predicate", "processor"] 11 | classifiers = [ 12 | 'Development Status :: 5 - Production/Stable', 13 | 'Environment :: MacOS X', 14 | 'Environment :: Web Environment', 15 | 'Intended Audience :: Developers', 16 | 'Operating System :: MacOS', 17 | 'Operating System :: Microsoft :: Windows', 18 | 'Operating System :: POSIX :: Linux', 19 | 'Operating System :: Unix', 20 | 'Topic :: Software Development :: Libraries :: Python Modules', 21 | 'Topic :: Utilities', 22 | 'Typing :: Typed' 23 | ] 24 | 25 | [tool.poetry.dependencies] 26 | python = "^3.9" 27 | koda = "1.4.0" 28 | 29 | [tool.poetry.group.test.dependencies] 30 | pytest = "8.3.5" 31 | pytest-cov = "5.0.0" 32 | pytest-asyncio = "0.26.0" 33 | jsonschema = "^4.17.0" 34 | 35 | 36 | [tool.poetry.group.dev.dependencies] 37 | isort = "6.0.1" 38 | flake8 = "7.2.0" 39 | pre-commit = "3.8.0" 40 | mypy = "1.15.0" 41 | pydantic = "2.11.4" 42 | voluptuous = "0.15.2" 43 | ssort = "0.14.0" 44 | types-jsonschema = "4.23.0.20240712" 45 | flask = {version = "3.1.0", extras = ["async"]} 46 | django = "4.2.21" 47 | django-stubs = "5.0.4" 48 | sphinx-autodoc-typehints = "1.25.3" 49 | darglint = "1.8.1" 50 | black = "25.1.0" 51 | 52 | [tool.mypy] 53 | exclude = ["build", "bench"] 54 | allow_redefinition = false 55 | check_untyped_defs = true 56 | disallow_any_generics = true 57 | disallow_incomplete_defs = true 58 | disallow_subclassing_any = true 59 | disallow_untyped_calls = true 60 | disallow_untyped_decorators = true 61 | disallow_untyped_defs = true 62 | # we need to wait for this to be up-to-date 63 | #enable_recursive_aliases = true 64 | ignore_errors = false 65 | ignore_missing_imports = false 66 | local_partial_types = true 67 | no_implicit_optional = true 68 | no_implicit_reexport = true 69 | strict_optional = true 70 | strict_equality = true 71 | warn_no_return = true 72 | warn_redundant_casts = true 73 | warn_return_any = true 74 | warn_unused_configs = true 75 | warn_unused_ignores = true 76 | warn_unreachable = true 77 | 78 | 79 | [tool.pytest.ini_options] 80 | addopts =""" 81 | --cov=koda_validate 82 | --cov-report=term:skip-covered 83 | --cov-report=html 84 | --cov-branch 85 | --cov-fail-under=94 86 | """ 87 | # ^^ --ds = "django settings module" 88 | 89 | 90 | [tool.coverage.run] 91 | omit = [ 92 | "koda_validate/validate_and_map.py", 93 | "tests/*" 94 | ] 95 | 96 | [tool.isort] 97 | line_length = 90 98 | profile = "black" 99 | 100 | [tool.black] 101 | line_length = 90 102 | target_version = ['py310'] 103 | 104 | [tool.pyright] 105 | include = ["koda_validate", "tests", "examples"] 106 | 107 | [build-system] 108 | requires = ["poetry-core>=1.0.0"] 109 | build-backend = "poetry.core.masonry.api" 110 | -------------------------------------------------------------------------------- /docs/philosophy/async.rst: -------------------------------------------------------------------------------- 1 | Async 2 | ===== 3 | Koda Validate aims to allow any kind of validation, including validation that requires IO. 4 | For IO, Koda Validate supports, and encourages, the use of ``asyncio``. Async validation in Koda 5 | Validate is designed with several ergonomics goals: 6 | 7 | - minimal changes should be required when switching from synchronous to asynchronous validation 8 | - usage of ``asyncio`` should be explicit and obvious 9 | - the user should be alerted when illegal states are encountered 10 | 11 | Minimal Changes 12 | --------------- 13 | 14 | All built-in :class:`Validator`\s in Koda Validate allow for async validation, so 15 | the same :class:`Validator` can be called in both contexts. 16 | 17 | .. testcode:: syncandasync 18 | 19 | import asyncio 20 | from koda_validate import StringValidator 21 | 22 | str_validator = StringValidator() 23 | 24 | Synchronous: 25 | 26 | .. doctest:: syncandasync 27 | 28 | >>> str_validator("a string") 29 | Valid(val='a string') 30 | 31 | Asynchronous: 32 | 33 | .. doctest:: syncandasync 34 | 35 | >>> asyncio.run(str_validator.validate_async("a string")) 36 | Valid(val='a string') 37 | 38 | 39 | .. note:: 40 | 41 | Synchronous validation logic can be called in both synchronous and asynchronous contexts, but async-only 42 | validation can only be called in an async context -- see Alerting below. 43 | 44 | 45 | Making Async Explicit and Obvious 46 | --------------------------------- 47 | It should be difficult to accidentally define or invoke async validation. In places where 48 | ``asyncio`` is supported in Koda Validate, there is always "async" in the naming. Some examples: 49 | 50 | - ``some_validator.validate_async(...)`` 51 | - :class:`PredicateAsync` 52 | - ``StringValidator(predicates_async=[...])`` 53 | - ``TypedDictValidator(SomeTypedDict, validate_object_async=some_async_function)`` 54 | 55 | 56 | 57 | Alerting on Illegal States 58 | -------------------------- 59 | While :class:`Validator`\s can be defined to work in both sync and async contexts, once a 60 | :class:`Validator` is initialized, Koda Validate will raise an exception if the both of the 61 | following are true: 62 | 63 | 1. it is initialized with async-only validation (e.g. defining ``predicates_async`` or ``validate_object_async`` on applicable :class:`Validator`\s) 64 | 2. it is invoked synchronously -- i.e. ``some_async_validator(123)`` instead of ``await some_async_validator.validate_async(123)`` 65 | 66 | 67 | The reason Koda Validate alerts in this context is because it does not want to guess at what the intended behavior is. It does 68 | neither of the following: 69 | 70 | - implicitly skip async-only validation 71 | - try to run async validation synchronously 72 | 73 | Here you can see how async-only validators alert: 74 | 75 | .. code-block:: python 76 | 77 | from koda_validate import StringValidator 78 | 79 | async_only_str_validator = StringValidator(predicates_async=[SomeAsyncCheck()]) 80 | 81 | asyncio.run(async_only_str_validator.validate_async("hmmm")) # runs normally 82 | 83 | async_only_str_validator("hmmm") # raises an AssertionError 84 | -------------------------------------------------------------------------------- /docs/how_to/dictionaries/records.rst: -------------------------------------------------------------------------------- 1 | RecordValidator 2 | =============== 3 | 4 | .. module:: koda_validate 5 | :noindex: 6 | 7 | 8 | In many cases :class:`RecordValidator` is more verbose than the :ref:`Derived Validators`, but 9 | it comes with greater flexibility. It can handle any kind of ``Hashable`` key. Optional keys are 10 | handled explicitly with :class:`KeyNotRequired`, which returns ``Maybe`` values. The ``into`` parameter 11 | can be any ``Callable`` with the correct type signature -- ``dataclass``\es are convenient, but it could 12 | just as well be an arbitrary function. 13 | 14 | 15 | 16 | .. testcode:: recordvalidator 17 | 18 | from dataclasses import dataclass 19 | from koda import Maybe, Just 20 | from koda_validate import (RecordValidator, StringValidator, not_blank, MaxLength, 21 | Min, Max, IntValidator, KeyNotRequired, Invalid, Valid) 22 | 23 | 24 | @dataclass 25 | class Person: 26 | name: str 27 | age: Maybe[int] 28 | 29 | 30 | person_validator = RecordValidator( 31 | into=Person, 32 | keys=( 33 | ("full name", StringValidator(not_blank, MaxLength(50))), 34 | ("age", KeyNotRequired(IntValidator(Min(0), Max(130)))), 35 | ), 36 | ) 37 | 38 | 39 | match person_validator({"full name": "John Doe", "age": 30}): 40 | case Valid(person): 41 | match person.age: 42 | case Just(age): 43 | age_message = f"{age} years old" 44 | case nothing: 45 | age_message = "ageless" 46 | print(f"{person.name} is {age_message}") 47 | case Invalid(errs): 48 | print(errs) 49 | 50 | Output: 51 | 52 | .. testoutput:: recordvalidator 53 | 54 | John Doe is 30 years old 55 | 56 | Here's a more complex example of mixing and matching different kinds of keys. 57 | 58 | .. testcode:: 59 | 60 | from dataclasses import dataclass 61 | from koda import Maybe, Just 62 | from koda_validate import ( 63 | RecordValidator, StringValidator, KeyNotRequired, IntValidator, Valid, ListValidator 64 | ) 65 | 66 | 67 | @dataclass 68 | class Person: 69 | name: str 70 | age: Maybe[int] 71 | hobbies: list[str] 72 | 73 | 74 | person_validator = RecordValidator( 75 | into=Person, 76 | keys=( 77 | (1, StringValidator()), 78 | (False, KeyNotRequired(IntValidator())), 79 | (("abc", 123), ListValidator(StringValidator())) 80 | ), 81 | ) 82 | 83 | assert person_validator({ 84 | 1: "John Doe", 85 | False: 30, 86 | ("abc", 123): ["reading", "cooking"] 87 | }) == Valid(Person( 88 | "John Doe", 89 | Just(30), 90 | ["reading", "cooking"] 91 | )) 92 | 93 | 94 | Caveats 95 | ^^^^^^^ 96 | The main caveats with :class:`RecordValidator` are: 97 | 98 | - it works on a maximum of 16 keys 99 | - type checkers don't always produce the most readable hints and errors for :class:`RecordValidator`, as it uses ``@overload``\s. 100 | - the target of validation must be defined outside the :class:`RecordValidator`, and the order of arguments matters 101 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: push actions 2 | on: [push] 3 | 4 | jobs: 5 | tests: 6 | strategy: 7 | fail-fast: false 8 | matrix: 9 | python-version: ['3.13', '3.12', '3.11', '3.10', '3.9'] 10 | poetry-version: ['2.1.3'] 11 | os: [ubuntu-22.04, macos-latest, windows-latest] 12 | runs-on: ${{ matrix.os }} 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-python@v4 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | - name: Run image 19 | uses: abatilo/actions-poetry@v2.0.0 20 | with: 21 | poetry-version: ${{ matrix.poetry-version }} 22 | # run just tests first to make sure no external packages are needed 23 | - name: install pytest only 24 | run: poetry install --only main,test 25 | - name: run 3.9- tests 26 | if: matrix.python-version == 3.9 27 | run: poetry run pytest tests --ignore tests/test_310_plus.py --ignore tests/test_311_plus.py --ignore examples --ignore tests/test_313_plus.py 28 | - name: run 3.10 tests 29 | if: matrix.python-version == 3.10 30 | run: poetry run pytest tests --ignore tests/test_311_plus.py --ignore tests/test_313_plus.py 31 | - name: run 3.11/3.12 tests 32 | if: matrix.python-version == 3.11 || matrix.python-version == 3.12 33 | run: poetry run pytest tests --ignore tests/test_313_plus.py 34 | - name: run 3.13 tests 35 | if: matrix.python-version == 3.13 36 | run: poetry run pytest tests 37 | - name: poetry install 38 | run: poetry install 39 | # not running mypy against 3.11 (yet) because it's like 10x slower 40 | - name: mypy 3.9- 41 | if: matrix.python-version == 3.9 42 | # exclude examples in CI because some 3.10 features are used 43 | run: poetry run mypy . --exclude examples --exclude tests/test_310_plus.py --exclude tests/test_311_plus.py --exclude tests/test_39_plus.py --exclude tests/test_313_plus.py 44 | - name: mypy 3.11 and 3.12 45 | if: matrix.python-version == 3.11 || matrix.python-version == 3.12 46 | run: poetry run mypy . --exclude tests/test_313_plus.py 47 | - name: mypy 3.13 48 | if: matrix.python-version == 3.13 49 | run: poetry run mypy . 50 | - name: mypy 3.10 51 | if: matrix.python-version == 3.10 52 | run: poetry run mypy . --exclude tests/test_311_plus.py --exclude tests/test_313_plus.py 53 | - name: run all examples 54 | if: matrix.python-version == 3.10 55 | run: poetry run bash run_all_examples.sh 56 | - name: linting 3.9- 57 | if: matrix.python-version == 3.9 58 | run: poetry run flake8 --exclude codegen,bench,examples,tests/test_310_plus.py,tests/test_311_plus.py --ignore E721,E741,W503,F405,F403,F401 59 | - name: linting 3.10+ 60 | if: matrix.python-version == 3.10 || matrix.python-version == 3.11 || matrix.python-version == 3.12 || matrix.python-version == 3.13 61 | run: poetry run flake8 --exclude codegen --ignore E721,E741,W503,F405,F403,F401,DAR101,DAR201,DAR401 62 | - name: darglint 63 | run: poetry run darglint koda_validate 64 | - name: doctests 65 | if: matrix.python-version == 3.10 || matrix.python-version == 3.11 || matrix.python-version == 3.12 || matrix.python-version == 3.13 66 | run: cd docs && poetry run make doctest 67 | - name: benchmarks 68 | run: poetry run python -m bench.run 69 | -------------------------------------------------------------------------------- /koda_validate/errors.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import TYPE_CHECKING, Any, ClassVar, Generic, Hashable, Optional, Type, Union 3 | 4 | from koda_validate._generics import A 5 | from koda_validate.base import Predicate, PredicateAsync 6 | 7 | if TYPE_CHECKING: 8 | from koda_validate.valid import Invalid 9 | 10 | 11 | @dataclass 12 | class CoercionErr: 13 | """ 14 | Similar to a TypeErr, but when one or more types can be 15 | coerced to a destination type 16 | """ 17 | 18 | compatible_types: set[Type[Any]] 19 | dest_type: Type[Any] 20 | 21 | 22 | @dataclass 23 | class ContainerErr: 24 | """ 25 | This is for simple containers like `Maybe` or `Result` 26 | """ 27 | 28 | child: "Invalid" 29 | 30 | 31 | @dataclass 32 | class ExtraKeysErr: 33 | """ 34 | extra keys were present in a dictionary 35 | """ 36 | 37 | expected_keys: set[Hashable] 38 | 39 | 40 | @dataclass 41 | class KeyErrs: 42 | """ 43 | validation failures for key/value pairs on a record-like 44 | dictionary 45 | """ 46 | 47 | keys: dict[Any, "Invalid"] 48 | 49 | 50 | @dataclass 51 | class KeyValErrs: 52 | """ 53 | Key and/or value errors from a single key/value pair. This 54 | is useful for mapping collections. 55 | """ 56 | 57 | key: Optional["Invalid"] 58 | val: Optional["Invalid"] 59 | 60 | 61 | @dataclass 62 | class MapErr: 63 | """ 64 | errors from key/value pairs of a map-like dictionary 65 | """ 66 | 67 | keys: dict[Any, KeyValErrs] 68 | 69 | 70 | class MissingKeyErr: 71 | """ 72 | A key is missing from a dictionary 73 | """ 74 | 75 | _instance: ClassVar[Optional["MissingKeyErr"]] = None 76 | 77 | def __new__(cls) -> "MissingKeyErr": 78 | # make a singleton 79 | if cls._instance is None: 80 | cls._instance = super(MissingKeyErr, cls).__new__(cls) 81 | return cls._instance 82 | 83 | def __repr__(self) -> str: 84 | return f"{self.__class__.__name__}()" 85 | 86 | 87 | missing_key_err = MissingKeyErr() 88 | 89 | 90 | @dataclass 91 | class IndexErrs: 92 | """ 93 | dictionary of validation errors by index 94 | """ 95 | 96 | indexes: dict[int, "Invalid"] 97 | 98 | 99 | @dataclass 100 | class SetErrs: 101 | """ 102 | Errors from items in a set. 103 | """ 104 | 105 | item_errs: list["Invalid"] 106 | 107 | 108 | @dataclass 109 | class UnionErrs: 110 | """ 111 | Errors from each variant of a union. 112 | """ 113 | 114 | variants: list["Invalid"] 115 | 116 | 117 | @dataclass 118 | class PredicateErrs(Generic[A]): 119 | """ 120 | A grouping of failed Predicates 121 | """ 122 | 123 | predicates: list[Union["Predicate[A]", "PredicateAsync[A]"]] 124 | 125 | 126 | class ValidationErrBase: 127 | """ 128 | This class exists only to provide a class to subclass 129 | for custom error types 130 | """ 131 | 132 | pass 133 | 134 | 135 | @dataclass 136 | class TypeErr: 137 | """ 138 | A specific type was required but not found 139 | """ 140 | 141 | expected_type: Type[Any] 142 | 143 | 144 | ErrType = Union[ 145 | CoercionErr, 146 | ContainerErr, 147 | ExtraKeysErr, 148 | IndexErrs, 149 | KeyErrs, 150 | MapErr, 151 | MissingKeyErr, 152 | # This seems like a type exception worth making..., but that might change in the 153 | # future. This is backwards compatible with existing code 154 | PredicateErrs[Any], 155 | SetErrs, 156 | TypeErr, 157 | ValidationErrBase, 158 | UnionErrs, 159 | ] 160 | -------------------------------------------------------------------------------- /tests/test_uuid.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from dataclasses import dataclass 3 | from uuid import UUID 4 | 5 | import pytest 6 | 7 | from koda_validate import ( 8 | CoercionErr, 9 | Invalid, 10 | Predicate, 11 | PredicateAsync, 12 | PredicateErrs, 13 | Processor, 14 | Valid, 15 | ) 16 | from koda_validate._generics import A 17 | from koda_validate.uuid import UUIDValidator 18 | 19 | 20 | class ReverseUUID(Processor[UUID]): 21 | def __call__(self, val: UUID) -> UUID: 22 | return UUID(val.hex[::-1]) 23 | 24 | 25 | def test_UUID() -> None: 26 | uuid_validator = UUIDValidator() 27 | assert uuid_validator("a string") == Invalid( 28 | CoercionErr({str, UUID}, UUID), "a string", uuid_validator 29 | ) 30 | 31 | assert uuid_validator(5.5) == Invalid( 32 | CoercionErr({str, UUID}, UUID), 5.5, uuid_validator 33 | ) 34 | 35 | assert uuid_validator(UUID("e348c1b4-60bd-11ed-a6e9-6ffb14046222")) == Valid( 36 | UUID("e348c1b4-60bd-11ed-a6e9-6ffb14046222") 37 | ) 38 | 39 | assert uuid_validator("a22acebe-60ba-11ed-9c95-4f52af693eb2") == Valid( 40 | UUID("a22acebe-60ba-11ed-9c95-4f52af693eb2") 41 | ) 42 | 43 | assert UUIDValidator(preprocessors=[ReverseUUID()])( 44 | UUID("37fb0187-0f45-45f3-a352-a8277b7b9038") 45 | ) == Valid(UUID("8309b7b7-728a-253a-3f54-54f07810bf73")) 46 | 47 | @dataclass 48 | class HexStartsWithD(Predicate[UUID]): 49 | def __call__(self, val: UUID) -> bool: 50 | return val.hex.startswith("d") 51 | 52 | validator = UUIDValidator(HexStartsWithD(), preprocessors=[ReverseUUID()]) 53 | assert validator(UUID("8309b7b7-728a-253a-3f54-54f07810bf73")) == Invalid( 54 | PredicateErrs([HexStartsWithD()]), 55 | UUID("37fb0187-0f45-45f3-a352-a8277b7b9038"), 56 | validator, 57 | ) 58 | 59 | assert UUIDValidator(HexStartsWithD(), preprocessors=[ReverseUUID()])( 60 | UUID("f309b7b7-728a-253a-3f54-54f07810bf7d") 61 | ) == Valid(UUID("d7fb0187-0f45-45f3-a352-a8277b7b903f")) 62 | 63 | 64 | @pytest.mark.asyncio 65 | async def test_UUID_async() -> None: 66 | uuid_validator = UUIDValidator() 67 | assert await uuid_validator.validate_async("abc") == Invalid( 68 | CoercionErr( 69 | {str, UUID}, 70 | UUID, 71 | ), 72 | "abc", 73 | uuid_validator, 74 | ) 75 | 76 | assert await uuid_validator.validate_async(5.5) == Invalid( 77 | CoercionErr({str, UUID}, UUID), 5.5, uuid_validator 78 | ) 79 | 80 | assert await uuid_validator.validate_async( 81 | UUID("e348c1b4-60bd-11ed-a6e9-6ffb14046222") 82 | ) == Valid(UUID("e348c1b4-60bd-11ed-a6e9-6ffb14046222")) 83 | 84 | @dataclass 85 | class HexStartsWithF(PredicateAsync[UUID]): 86 | async def validate_async(self, val: UUID) -> bool: 87 | await asyncio.sleep(0.001) 88 | return val.hex.startswith("f") 89 | 90 | v = UUIDValidator(preprocessors=[ReverseUUID()], predicates_async=[HexStartsWithF()]) 91 | result = await v.validate_async("e348c1b4-60bd-11ed-a6e9-6ffb14046222") 92 | assert result == Invalid( 93 | PredicateErrs([HexStartsWithF()]), UUID("22264041-bff6-9e6a-de11-db064b1c843e"), v 94 | ) 95 | 96 | 97 | def test_sync_call_with_async_predicates_raises_assertion_error() -> None: 98 | @dataclass 99 | class AsyncWait(PredicateAsync[A]): 100 | async def validate_async(self, val: A) -> bool: 101 | await asyncio.sleep(0.001) 102 | return True 103 | 104 | uu_validator = UUIDValidator(predicates_async=[AsyncWait()]) 105 | with pytest.raises(AssertionError): 106 | uu_validator("whatever") 107 | -------------------------------------------------------------------------------- /examples/django_example/django_example/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for django_example project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.1.4. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.1/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | from typing import List 15 | 16 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 17 | BASE_DIR = Path(__file__).resolve().parent.parent 18 | 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ 22 | 23 | # SECURITY WARNING: keep the secret key used in production secret! 24 | SECRET_KEY = "django-insecure-(b2y2=v31ze46#yxc%pp=zmis@!!wjg0$=_*$%r-k)1ud-mio8" 25 | 26 | # SECURITY WARNING: don't run with debug turned on in production! 27 | DEBUG = True 28 | 29 | ALLOWED_HOSTS: List[str] = [] 30 | 31 | 32 | # Application definition 33 | 34 | INSTALLED_APPS = [ 35 | "django.contrib.admin", 36 | "django.contrib.auth", 37 | "django.contrib.contenttypes", 38 | "django.contrib.sessions", 39 | "django.contrib.messages", 40 | "django.contrib.staticfiles", 41 | ] 42 | 43 | MIDDLEWARE = [ 44 | "django.middleware.security.SecurityMiddleware", 45 | "django.contrib.sessions.middleware.SessionMiddleware", 46 | "django.middleware.common.CommonMiddleware", 47 | "django.middleware.csrf.CsrfViewMiddleware", 48 | "django.contrib.auth.middleware.AuthenticationMiddleware", 49 | "django.contrib.messages.middleware.MessageMiddleware", 50 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 51 | ] 52 | 53 | ROOT_URLCONF = "django_example.urls" 54 | 55 | TEMPLATES = [ 56 | { 57 | "BACKEND": "django.template.backends.django.DjangoTemplates", 58 | "DIRS": [], 59 | "APP_DIRS": True, 60 | "OPTIONS": { 61 | "context_processors": [ 62 | "django.template.context_processors.debug", 63 | "django.template.context_processors.request", 64 | "django.contrib.auth.context_processors.auth", 65 | "django.contrib.messages.context_processors.messages", 66 | ], 67 | }, 68 | }, 69 | ] 70 | 71 | WSGI_APPLICATION = "django_example.wsgi.application" 72 | 73 | 74 | # Database 75 | # https://docs.djangoproject.com/en/4.1/ref/settings/#databases 76 | 77 | DATABASES = { 78 | "default": { 79 | "ENGINE": "django.db.backends.sqlite3", 80 | "NAME": BASE_DIR / "db.sqlite3", 81 | } 82 | } 83 | 84 | 85 | # Password validation 86 | # https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators 87 | 88 | AUTH_PASSWORD_VALIDATORS = [ 89 | { 90 | "NAME": "django.contrib.auth.password_validation" 91 | ".UserAttributeSimilarityValidator", 92 | }, 93 | { 94 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 95 | }, 96 | { 97 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 98 | }, 99 | { 100 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 101 | }, 102 | ] 103 | 104 | 105 | # Internationalization 106 | # https://docs.djangoproject.com/en/4.1/topics/i18n/ 107 | 108 | LANGUAGE_CODE = "en-us" 109 | 110 | TIME_ZONE = "UTC" 111 | 112 | USE_I18N = True 113 | 114 | USE_TZ = True 115 | 116 | 117 | # Static files (CSS, JavaScript, Images) 118 | # https://docs.djangoproject.com/en/4.1/howto/static-files/ 119 | 120 | STATIC_URL = "static/" 121 | 122 | # Default primary key field type 123 | # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field 124 | 125 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 126 | -------------------------------------------------------------------------------- /tests/test_cache.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Any, Dict, List, Tuple 3 | 4 | import pytest 5 | from koda import Just, Maybe, nothing 6 | 7 | from koda_validate import ( 8 | Invalid, 9 | StringValidator, 10 | TypeErr, 11 | Valid, 12 | ValidationResult, 13 | not_blank, 14 | ) 15 | from koda_validate._generics import A 16 | from koda_validate.base import CacheValidatorBase 17 | 18 | 19 | def test_cache_validator_sync() -> None: 20 | hits: List[Tuple[Any, Any]] = [] 21 | 22 | @dataclass 23 | class DictCacheValidator(CacheValidatorBase[A]): 24 | _dict_cache: Dict[Any, ValidationResult[A]] = field(default_factory=dict) 25 | 26 | def cache_get_sync(self, val: Any) -> Maybe[ValidationResult[A]]: 27 | if val in self._dict_cache: 28 | cached_val = self._dict_cache[val] 29 | hits.append((val, cached_val)) 30 | return Just(cached_val) 31 | else: 32 | return nothing 33 | 34 | def cache_set_sync(self, val: Any, cache_val: ValidationResult[A]) -> None: 35 | self._dict_cache[val] = cache_val 36 | 37 | cache_str_validator = DictCacheValidator(StringValidator(not_blank)) 38 | 39 | assert cache_str_validator("ok") == Valid("ok") 40 | assert hits == [] 41 | 42 | # should hit 43 | assert cache_str_validator("ok") == Valid("ok") 44 | assert hits == [("ok", Valid("ok"))] 45 | 46 | assert cache_str_validator("ok") == Valid("ok") 47 | assert hits == [ 48 | ("ok", Valid("ok")), 49 | ("ok", Valid("ok")), 50 | ] 51 | 52 | assert cache_str_validator(5) == Invalid( 53 | TypeErr(str), 5, cache_str_validator.validator 54 | ) 55 | 56 | assert hits == [ 57 | ("ok", Valid("ok")), 58 | ("ok", Valid("ok")), 59 | ] 60 | 61 | assert cache_str_validator(5) == Invalid( 62 | TypeErr(str), 5, cache_str_validator.validator 63 | ) 64 | 65 | assert hits == [ 66 | ("ok", Valid("ok")), 67 | ("ok", Valid("ok")), 68 | (5, Invalid(TypeErr(str), 5, cache_str_validator.validator)), 69 | ] 70 | 71 | 72 | @pytest.mark.asyncio 73 | async def test_cache_validator_async() -> None: 74 | hits: List[Tuple[Any, Any]] = [] 75 | 76 | @dataclass 77 | class DictCacheValidator(CacheValidatorBase[A]): 78 | _dict_cache: Dict[Any, ValidationResult[A]] = field(default_factory=dict) 79 | 80 | async def cache_get_async(self, val: Any) -> Maybe[ValidationResult[A]]: 81 | if val in self._dict_cache: 82 | cached_val = self._dict_cache[val] 83 | hits.append((val, cached_val)) 84 | return Just(cached_val) 85 | else: 86 | return nothing 87 | 88 | async def cache_set_async(self, val: Any, cache_val: ValidationResult[A]) -> None: 89 | self._dict_cache[val] = cache_val 90 | 91 | cache_str_validator = DictCacheValidator(StringValidator(not_blank)) 92 | 93 | assert await cache_str_validator.validate_async("ok") == Valid("ok") 94 | assert hits == [] 95 | 96 | # should hit 97 | assert await cache_str_validator.validate_async("ok") == Valid("ok") 98 | assert hits == [("ok", Valid("ok"))] 99 | 100 | assert await cache_str_validator.validate_async("ok") == Valid("ok") 101 | assert hits == [ 102 | ("ok", Valid("ok")), 103 | ("ok", Valid("ok")), 104 | ] 105 | 106 | assert await cache_str_validator.validate_async(5) == Invalid( 107 | TypeErr(str), 5, cache_str_validator.validator 108 | ) 109 | 110 | assert hits == [ 111 | ("ok", Valid("ok")), 112 | ("ok", Valid("ok")), 113 | ] 114 | 115 | assert await cache_str_validator.validate_async(5) == Invalid( 116 | TypeErr(str), 5, cache_str_validator.validator 117 | ) 118 | 119 | assert hits == [ 120 | ("ok", Valid("ok")), 121 | ("ok", Valid("ok")), 122 | (5, Invalid(TypeErr(str), 5, cache_str_validator.validator)), 123 | ] 124 | -------------------------------------------------------------------------------- /tests/test_union.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import pytest 4 | 5 | from koda_validate import ( 6 | FloatValidator, 7 | IntValidator, 8 | Invalid, 9 | StringValidator, 10 | TypeErr, 11 | UnionErrs, 12 | Valid, 13 | ValidationResult, 14 | ) 15 | from koda_validate.base import Validator 16 | from koda_validate.union import UnionValidator 17 | 18 | 19 | def test_union_validator_typed() -> None: 20 | s_v = StringValidator() 21 | i_v = IntValidator() 22 | f_v = FloatValidator() 23 | str_int_float_validator = UnionValidator.typed(s_v, i_v, f_v) 24 | 25 | assert str_int_float_validator("abc") == Valid("abc") 26 | assert str_int_float_validator(5) == Valid(5) 27 | assert str_int_float_validator(5.5) == Valid(5.5) 28 | assert str_int_float_validator(None) == Invalid( 29 | UnionErrs( 30 | [ 31 | Invalid(TypeErr(str), None, s_v), 32 | Invalid(TypeErr(int), None, i_v), 33 | Invalid(TypeErr(float), None, f_v), 34 | ], 35 | ), 36 | None, 37 | str_int_float_validator, 38 | ) 39 | 40 | assert str_int_float_validator(False) == Invalid( 41 | UnionErrs( 42 | [ 43 | Invalid(TypeErr(str), False, s_v), 44 | Invalid(TypeErr(int), False, i_v), 45 | Invalid(TypeErr(float), False, f_v), 46 | ], 47 | ), 48 | False, 49 | str_int_float_validator, 50 | ) 51 | 52 | 53 | @pytest.mark.asyncio 54 | async def test_union_validator_any_async() -> None: 55 | class TestNoneValidator(Validator[None]): 56 | async def validate_async(self, val: Any) -> ValidationResult[None]: 57 | return self(val) 58 | 59 | def __call__(self, val: Any) -> ValidationResult[None]: 60 | if val is None: 61 | return Valid(None) 62 | else: 63 | return Invalid(TypeErr(type(None)), val, self) 64 | 65 | s_v = StringValidator() 66 | i_v = IntValidator() 67 | f_v = FloatValidator() 68 | n_v = TestNoneValidator() 69 | str_int_float_validator = UnionValidator.untyped(s_v, i_v, f_v, n_v) 70 | 71 | assert await str_int_float_validator.validate_async("abc") == Valid("abc") 72 | assert await str_int_float_validator.validate_async(5) == Valid(5) 73 | assert await str_int_float_validator.validate_async(None) == Valid(None) 74 | result = await str_int_float_validator.validate_async([]) 75 | assert result == Invalid( 76 | UnionErrs( 77 | [ 78 | Invalid(TypeErr(str), [], s_v), 79 | Invalid(TypeErr(int), [], i_v), 80 | Invalid(TypeErr(float), [], f_v), 81 | Invalid(TypeErr(type(None)), [], n_v), 82 | ] 83 | ), 84 | [], 85 | str_int_float_validator, 86 | ) 87 | 88 | assert await str_int_float_validator.validate_async(False) == Invalid( 89 | UnionErrs( 90 | [ 91 | Invalid(TypeErr(str), False, s_v), 92 | Invalid(TypeErr(int), False, i_v), 93 | Invalid(TypeErr(float), False, f_v), 94 | Invalid(TypeErr(type(None)), False, n_v), 95 | ], 96 | ), 97 | False, 98 | str_int_float_validator, 99 | ) 100 | 101 | 102 | def test_union_repr() -> None: 103 | assert repr(UnionValidator(StringValidator())) == "UnionValidator(StringValidator())" 104 | assert ( 105 | repr(UnionValidator(IntValidator(), StringValidator(), FloatValidator())) 106 | == "UnionValidator(IntValidator(), StringValidator(), FloatValidator())" 107 | ) 108 | 109 | 110 | def test_union_eq() -> None: 111 | assert UnionValidator(StringValidator()) == UnionValidator(StringValidator()) 112 | assert UnionValidator(StringValidator(), IntValidator()) != UnionValidator( 113 | StringValidator() 114 | ) 115 | assert UnionValidator(StringValidator(), IntValidator()) == UnionValidator( 116 | StringValidator(), IntValidator() 117 | ) 118 | -------------------------------------------------------------------------------- /tests/test_none.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import pytest 4 | from koda import Just, Maybe, nothing 5 | 6 | from koda_validate import ( 7 | IntValidator, 8 | Invalid, 9 | OptionalValidator, 10 | StringValidator, 11 | TypeErr, 12 | UnionErrs, 13 | Valid, 14 | ) 15 | from koda_validate.coerce import coercer 16 | from koda_validate.none import NoneValidator, none_validator 17 | 18 | 19 | def test_none() -> None: 20 | assert none_validator("a string") == Invalid( 21 | TypeErr(type(None)), "a string", none_validator 22 | ) 23 | 24 | assert none_validator(None) == Valid(None) 25 | 26 | assert none_validator(False) == Invalid(TypeErr(type(None)), False, none_validator) 27 | 28 | 29 | def test_none_repr() -> None: 30 | assert repr(NoneValidator()) == repr(none_validator) == "NoneValidator(coerce=None)" 31 | 32 | 33 | def test_none_eq() -> None: 34 | @coercer(bool) 35 | def coerce_false_to_none(val: Any) -> Maybe[None]: 36 | if val is False: 37 | return Just(None) 38 | return nothing 39 | 40 | assert NoneValidator() == NoneValidator() == none_validator 41 | assert NoneValidator() != NoneValidator(coerce=coerce_false_to_none) 42 | 43 | 44 | def test_none_coerce() -> None: 45 | @coercer(bool) 46 | def coerce_false_to_none(val: Any) -> Maybe[None]: 47 | if val is False: 48 | return Just(None) 49 | return nothing 50 | 51 | validator = NoneValidator(coerce=coerce_false_to_none) 52 | assert validator(False) == Valid(None) 53 | assert isinstance(validator(None), Invalid) 54 | 55 | 56 | @pytest.mark.asyncio 57 | async def test_none_coerce_async() -> None: 58 | @coercer(bool) 59 | def coerce_false_to_none(val: Any) -> Maybe[None]: 60 | if val is False: 61 | return Just(None) 62 | return nothing 63 | 64 | validator = NoneValidator(coerce=coerce_false_to_none) 65 | assert await validator.validate_async(False) == Valid(None) 66 | assert isinstance(await validator.validate_async(None), Invalid) 67 | 68 | 69 | @pytest.mark.asyncio 70 | async def test_none_async() -> None: 71 | assert await none_validator.validate_async("a string") == Invalid( 72 | TypeErr(type(None)), "a string", none_validator 73 | ) 74 | 75 | assert await none_validator.validate_async(None) == Valid(None) 76 | 77 | assert await none_validator.validate_async(False) == Invalid( 78 | TypeErr(type(None)), False, none_validator 79 | ) 80 | 81 | 82 | def test_optional_validator() -> None: 83 | o_v = OptionalValidator(StringValidator()) 84 | assert o_v(None) == Valid(None) 85 | assert o_v(5) == Invalid( 86 | UnionErrs( 87 | [ 88 | Invalid(TypeErr(type(None)), 5, none_validator), 89 | Invalid(TypeErr(str), 5, o_v.non_none_validator), 90 | ], 91 | ), 92 | 5, 93 | o_v, 94 | ) 95 | assert o_v("okok") == Valid("okok") 96 | 97 | 98 | @pytest.mark.asyncio 99 | async def test_optional_validator_async() -> None: 100 | o_v = OptionalValidator(StringValidator()) 101 | assert await o_v.validate_async(None) == Valid(None) 102 | assert await o_v.validate_async(5) == Invalid( 103 | UnionErrs( 104 | [ 105 | Invalid(TypeErr(type(None)), 5, none_validator), 106 | Invalid(TypeErr(str), 5, o_v.non_none_validator), 107 | ], 108 | ), 109 | 5, 110 | o_v, 111 | ) 112 | assert await o_v.validate_async("okok") == Valid("okok") 113 | 114 | 115 | def test_optional_repr() -> None: 116 | assert ( 117 | repr(OptionalValidator(StringValidator())) 118 | == "OptionalValidator(StringValidator())" 119 | ) 120 | assert repr(OptionalValidator(IntValidator())) == "OptionalValidator(IntValidator())" 121 | 122 | 123 | def test_optional_eq() -> None: 124 | assert OptionalValidator(StringValidator()) == OptionalValidator(StringValidator()) 125 | 126 | assert OptionalValidator(IntValidator()) != OptionalValidator(StringValidator()) 127 | -------------------------------------------------------------------------------- /docs/how_to/rest_apis/django.rst: -------------------------------------------------------------------------------- 1 | Django 2 | ====== 3 | 4 | Basic 5 | ^^^^^ 6 | 7 | .. code-block:: python 8 | 9 | import json 10 | from dataclasses import dataclass 11 | from typing import Annotated, Optional 12 | 13 | from django.http import HttpRequest, HttpResponse, JsonResponse 14 | 15 | from koda_validate import * 16 | from koda_validate.serialization import to_serializable_errs 17 | 18 | 19 | @dataclass 20 | class ContactForm: 21 | name: str 22 | message: str 23 | # Annotated `Validator`s are used if defined -- instead 24 | # of Koda Validate's default for the type) 25 | email: Annotated[str, StringValidator(EmailPredicate())] 26 | subject: Optional[str] = None 27 | 28 | 29 | def contact(request: HttpRequest) -> HttpResponse: 30 | if request.method != "POST": 31 | return HttpResponse("HTTP method not allowed", status=405) 32 | 33 | try: 34 | posted_json = json.loads(request.body) 35 | except json.JSONDecodeError: 36 | return JsonResponse({"_root_": "expected json"}, status=400) 37 | else: 38 | result = DataclassValidator(ContactForm)(posted_json) 39 | match result: 40 | case Valid(contact_form): 41 | print(contact_form) 42 | return JsonResponse({"success": True}) 43 | case Invalid() as inv: 44 | return JsonResponse(to_serializable_errs(inv), status=400, safe=False) 45 | 46 | 47 | Fuller Example (with Async) 48 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ 49 | 50 | .. code-block:: python 51 | 52 | import asyncio 53 | import json 54 | from typing import Annotated, Optional, TypedDict 55 | 56 | from django.http import HttpRequest, HttpResponse, JsonResponse 57 | 58 | from koda_validate import * 59 | from koda_validate.serialization import SerializableErr, to_serializable_errs 60 | 61 | 62 | class Captcha(TypedDict): 63 | seed: Annotated[str, StringValidator(ExactLength(16))] 64 | response: Annotated[str, StringValidator(MaxLength(16))] 65 | 66 | 67 | async def validate_captcha(captcha: Captcha) -> Optional[ErrType]: 68 | """ 69 | after we validate that the seed and response both conform to the types/shapes we want, 70 | we need to check our database to make sure the response is correct 71 | """ 72 | await asyncio.sleep(0.01) # pretend to ask db 73 | if captcha["seed"] != captcha["response"][::-1]: 74 | return SerializableErr({"response": "bad captcha response"}) 75 | else: 76 | return None 77 | 78 | 79 | class ContactForm(TypedDict): 80 | email: Annotated[str, StringValidator(EmailPredicate())] 81 | message: Annotated[str, StringValidator(MaxLength(500), MinLength(10))] 82 | # we only need to explicitly define the TypedDictValidator here because we want 83 | # to include additional validation in validate_captcha 84 | captcha: Annotated[ 85 | Captcha, TypedDictValidator(Captcha, validate_object_async=validate_captcha) 86 | ] 87 | 88 | 89 | contact_validator = TypedDictValidator(ContactForm) 90 | 91 | 92 | async def contact_async(request: HttpRequest) -> HttpResponse: 93 | if request.method != "POST": 94 | return HttpResponse("HTTP method not allowed", status=405) 95 | 96 | try: 97 | posted_json = json.loads(request.body) 98 | except json.JSONDecodeError: 99 | return JsonResponse({"__container__": "expected json"}, status=400) 100 | else: 101 | result = await TypedDictValidator(ContactForm).validate_async(posted_json) 102 | match result: 103 | case Valid(contact_form): 104 | print(contact_form) 105 | return JsonResponse({"success": True}) 106 | case Invalid() as inv: 107 | return JsonResponse(to_serializable_errs(inv), status=400, safe=False) 108 | 109 | 110 | # if you want a JSON Schema from a ``Validator``, there's `to_json_schema()` 111 | # schema = to_json_schema(contact_validator) 112 | # hook_into_some_api_definition(schema) 113 | -------------------------------------------------------------------------------- /docs/how_to/rest_apis/flask.rst: -------------------------------------------------------------------------------- 1 | Flask 2 | ===== 3 | 4 | Basic 5 | ^^^^^ 6 | 7 | .. code-block:: python 8 | 9 | from dataclasses import dataclass 10 | from typing import Annotated, Optional 11 | 12 | from flask import Flask, jsonify, request 13 | from flask.typing import ResponseValue 14 | 15 | from koda_validate import StringValidator, DataclassValidator, EmailPredicate 16 | from koda_validate.serialization import to_serializable_errs 17 | 18 | app = Flask(__name__) 19 | 20 | 21 | @dataclass 22 | class ContactForm: 23 | name: str 24 | message: str 25 | # `Annotated` `Validator`s are used if found 26 | email: Annotated[str, StringValidator(EmailPredicate())] 27 | subject: Optional[str] = None 28 | 29 | 30 | @app.route("/contact", methods=["POST"]) 31 | def contact_api() -> tuple[ResponseValue, int]: 32 | result = DataclassValidator(ContactForm)(request.json) 33 | match result: 34 | case Valid(contact_form): 35 | print(contact_form) # do something with the valid data 36 | return {"success": True}, 200 37 | case Invalid() as inv: 38 | return jsonify(to_serializable_errs(inv)), 400 39 | 40 | 41 | if __name__ == "__main__": 42 | app.run() 43 | 44 | 45 | .. note:: 46 | 47 | In the example above, ``ContactForm`` is a ``dataclass``, so we use a 48 | :class:`DataclassValidator`. We could have used a 49 | ``TypedDict`` and :class:`TypedDictValidator`, or a 50 | ``NamedTuple`` and :class:`NamedTupleValidator`, 51 | and the code would have been essentially the same. 52 | 53 | Fuller Example (with Async) 54 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ 55 | 56 | .. code-block:: python 57 | 58 | import asyncio 59 | from typing import Annotated, Optional, TypedDict 60 | 61 | from flask import Flask, jsonify, request 62 | from flask.typing import ResponseValue 63 | 64 | from koda_validate import * 65 | from koda_validate.serialization import SerializableErr, to_serializable_errs 66 | 67 | app = Flask(__name__) 68 | 69 | 70 | class Captcha(TypedDict): 71 | seed: Annotated[str, StringValidator(ExactLength(16))] 72 | response: Annotated[str, StringValidator(MaxLength(16))] 73 | 74 | 75 | async def validate_captcha(captcha: Captcha) -> Optional[ErrType]: 76 | """ 77 | after we validate that the seed and response on their own, 78 | we need to check our database to make sure the response is correct 79 | """ 80 | 81 | async def pretend_check_captcha_service(seed: str, response: str) -> bool: 82 | await asyncio.sleep(0.01) # pretend to call 83 | return seed == response[::-1] 84 | 85 | if await pretend_check_captcha_service(captcha["seed"], captcha["response"]): 86 | # everything's valid 87 | return None 88 | else: 89 | return SerializableErr({"response": "bad captcha response"}) 90 | 91 | 92 | class ContactForm(TypedDict): 93 | email: Annotated[str, StringValidator(EmailPredicate())] 94 | message: Annotated[str, StringValidator(MaxLength(500), MinLength(10))] 95 | captcha: Annotated[ 96 | Captcha, 97 | # explicitly adding some extra validation 98 | TypedDictValidator(Captcha, validate_object_async=validate_captcha), 99 | ] 100 | 101 | 102 | contact_validator = TypedDictValidator(ContactForm) 103 | 104 | 105 | @app.route("/contact", methods=["POST"]) 106 | async def contact_api() -> tuple[ResponseValue, int]: 107 | result = await contact_validator.validate_async(request.json) 108 | match result: 109 | case Valid(contact_form): 110 | print(contact_form) 111 | return {"success": True}, 200 112 | case Invalid() as inv: 113 | return jsonify(to_serializable_errs(inv)), 400 114 | 115 | 116 | # if you want a JSON Schema from a ``Validator``, there's `to_json_schema()` 117 | # schema = to_json_schema(contact_validator) 118 | # hook_into_some_api_definition(schema) 119 | 120 | 121 | if __name__ == "__main__": 122 | app.run() 123 | -------------------------------------------------------------------------------- /bench/run.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser 2 | from dataclasses import dataclass 3 | from time import perf_counter 4 | from typing import Callable, Dict, Generic, List 5 | 6 | from bench import ( 7 | list_none, 8 | min_max, 9 | nested_object_list, 10 | one_key_invalid_types, 11 | string_valid, 12 | two_keys_invalid_types, 13 | two_keys_valid, 14 | ) 15 | from koda_validate._generics import A 16 | 17 | 18 | @dataclass 19 | class BenchCompare(Generic[A]): 20 | gen: Callable[[int], A] 21 | comparisons: Dict[str, Callable[[List[A]], None]] 22 | 23 | 24 | KODA_VALIDATE = "KODA VALIDATE" 25 | KV_RECORD_VALIDATOR = f"{KODA_VALIDATE} - RecordValidator" 26 | KV_DATACLASS_VALIDATOR = f"{KODA_VALIDATE} - DataclassValidator" 27 | KV_NAMEDTUPLE_VALIDATOR = f"{KODA_VALIDATE} - NamedTupleValidator" 28 | KV_DICT_VALIDATOR_ANY = f"{KODA_VALIDATE} - DictValidatorAny" 29 | KV_TYPED_DICT_VALIDATOR = f"{KODA_VALIDATE} - TypedDictValidator" 30 | 31 | 32 | PYDANTIC = "PYDANTIC" 33 | VOLUPTUOUS = "VOLUPTUOUS" 34 | 35 | benches = { 36 | "one_key_invalid_types": BenchCompare( 37 | lambda i: {"val_1": i}, 38 | { 39 | KODA_VALIDATE: one_key_invalid_types.run_kv, 40 | PYDANTIC: one_key_invalid_types.run_pyd, 41 | }, 42 | ), 43 | "two_keys_invalid_types": BenchCompare( 44 | lambda i: {"val_1": i, "val_2": str(i)}, 45 | { 46 | KODA_VALIDATE: two_keys_invalid_types.run_kv, 47 | PYDANTIC: two_keys_invalid_types.run_pyd, 48 | VOLUPTUOUS: two_keys_invalid_types.run_v, 49 | }, 50 | ), 51 | "two_keys_valid": BenchCompare( 52 | lambda i: {"val_1": str(i), "val_2": i}, 53 | { 54 | KODA_VALIDATE: two_keys_valid.run_kv, 55 | PYDANTIC: two_keys_valid.run_pyd, 56 | VOLUPTUOUS: two_keys_valid.run_v, 57 | }, 58 | ), 59 | "string_valid": BenchCompare( 60 | string_valid.get_str, 61 | {KODA_VALIDATE: string_valid.run_kv, PYDANTIC: string_valid.run_pyd}, 62 | ), 63 | "list_none": BenchCompare( 64 | list_none.get_obj, {KODA_VALIDATE: list_none.run_kv, PYDANTIC: list_none.run_pyd} 65 | ), 66 | "min_max_all_valid": BenchCompare( 67 | min_max.gen_valid, 68 | { 69 | KV_RECORD_VALIDATOR: min_max.run_kv, 70 | KV_DICT_VALIDATOR_ANY: min_max.run_kv_dict_any, 71 | PYDANTIC: min_max.run_pyd, 72 | VOLUPTUOUS: min_max.run_v, 73 | }, 74 | ), 75 | "min_max_all_invalid": BenchCompare( 76 | min_max.gen_invalid, 77 | { 78 | KV_RECORD_VALIDATOR: min_max.run_kv, 79 | KV_DICT_VALIDATOR_ANY: min_max.run_kv_dict_any, 80 | PYDANTIC: min_max.run_pyd, 81 | VOLUPTUOUS: min_max.run_v, 82 | }, 83 | ), 84 | "nested_object_list": BenchCompare( 85 | nested_object_list.get_data, 86 | { 87 | KV_RECORD_VALIDATOR: nested_object_list.run_kv, 88 | KV_DATACLASS_VALIDATOR: nested_object_list.run_kv_dc, 89 | KV_DICT_VALIDATOR_ANY: nested_object_list.run_kv_dict_any, 90 | KV_NAMEDTUPLE_VALIDATOR: nested_object_list.run_kv_nt, 91 | KV_TYPED_DICT_VALIDATOR: nested_object_list.run_kv_td, 92 | PYDANTIC: nested_object_list.run_pyd, 93 | }, 94 | ), 95 | } 96 | 97 | 98 | def run_bench( 99 | chunks: int, chunk_size: int, gen: Callable[[int], A], fn: Callable[[List[A]], None] 100 | ) -> None: 101 | total_time: float = 0.0 102 | for i in range(chunks): 103 | # generate in chunks so generation isn't included in the 104 | # measured time 105 | objs = [gen((i * chunk_size) + j + 1) for j in range(chunk_size)] 106 | start = perf_counter() 107 | fn(objs) 108 | total_time += perf_counter() - start 109 | 110 | print(f"Execution time: {total_time:.4f} secs\n") 111 | 112 | 113 | if __name__ == "__main__": 114 | parser = ArgumentParser() 115 | parser.add_argument( 116 | "tests", 117 | type=str, 118 | nargs="*", 119 | help="which tests to run", 120 | ) 121 | parser.add_argument( 122 | "--iterations", 123 | type=int, 124 | default=50, 125 | ) 126 | parser.add_argument( 127 | "--chunk-size", 128 | type=int, 129 | default=1_000, 130 | ) 131 | 132 | args = parser.parse_args() 133 | 134 | print(f"{args.iterations} ITERATIONS of {args.chunk_size}") 135 | 136 | for name, compare_bench in benches.items(): 137 | if args.tests == [] or name in args.tests: 138 | print(f"----- BEGIN {name} -----\n") 139 | for subject_name, test in compare_bench.comparisons.items(): 140 | print(subject_name) 141 | run_bench(args.iterations, args.chunk_size, compare_bench.gen, test) 142 | 143 | print(f"----- END {name} -----\n") 144 | -------------------------------------------------------------------------------- /koda_validate/__init__.py: -------------------------------------------------------------------------------- 1 | import importlib.metadata 2 | 3 | __version__ = importlib.metadata.version("koda_validate") 4 | 5 | __all__ = ( 6 | # base.py 7 | "Validator", 8 | "Predicate", 9 | "PredicateAsync", 10 | "Processor", 11 | # boolean.py 12 | "BoolValidator", 13 | # bytes.py 14 | # coerce.py 15 | "Coercer", 16 | "coercer", 17 | "BytesValidator", 18 | # dataclasses.py 19 | "DataclassValidator", 20 | # decimal.py 21 | "DecimalValidator", 22 | # dictionary.py 23 | "KeyNotRequired", 24 | "MapValidator", 25 | "is_dict_validator", 26 | "IsDictValidator", 27 | "MinKeys", 28 | "MaxKeys", 29 | "RecordValidator", 30 | "DictValidatorAny", 31 | # errors.py 32 | "CoercionErr", 33 | "ContainerErr", 34 | "ExtraKeysErr", 35 | "ErrType", 36 | "IndexErrs", 37 | "KeyErrs", 38 | "KeyValErrs", 39 | "MapErr", 40 | "MissingKeyErr", 41 | "missing_key_err", 42 | "PredicateErrs", 43 | "SetErrs", 44 | "TypeErr", 45 | "UnionErrs", 46 | "ValidationErrBase", 47 | # float.py 48 | "FloatValidator", 49 | # generic.py 50 | "Lazy", 51 | "Choices", 52 | "Min", 53 | "Max", 54 | "MinItems", 55 | "MaxItems", 56 | "ExactItemCount", 57 | "unique_items", 58 | "UniqueItems", 59 | "MultipleOf", 60 | "EqualsValidator", 61 | "EqualTo", 62 | "always_valid", 63 | "AlwaysValid", 64 | "MinLength", 65 | "MaxLength", 66 | "ExactLength", 67 | "StartsWith", 68 | "EndsWith", 69 | "strip", 70 | "not_blank", 71 | "NotBlank", 72 | "upper_case", 73 | "UpperCase", 74 | "lower_case", 75 | "LowerCase", 76 | "CacheValidatorBase", 77 | # integer.py 78 | "IntValidator", 79 | # list.py 80 | "ListValidator", 81 | # namedtuple.py 82 | "NamedTupleValidator", 83 | # none.py 84 | "OptionalValidator", 85 | "NoneValidator", 86 | "none_validator", 87 | # set.py 88 | "SetValidator", 89 | # string.py 90 | "StringValidator", 91 | "RegexPredicate", 92 | "EmailPredicate", 93 | # time.py 94 | "DateValidator", 95 | "DatetimeValidator", 96 | # tuple.py 97 | "NTupleValidator", 98 | "UniformTupleValidator", 99 | # typeddict.py 100 | "TypedDictValidator", 101 | # uuid.py 102 | "UUIDValidator", 103 | # union.py 104 | "UnionValidator", 105 | # valid.py 106 | "Valid", 107 | "Invalid", 108 | "ValidationResult", 109 | ) 110 | 111 | from koda_validate.base import ( 112 | CacheValidatorBase, 113 | Predicate, 114 | PredicateAsync, 115 | Processor, 116 | Validator, 117 | ) 118 | from koda_validate.boolean import BoolValidator 119 | from koda_validate.bytes import BytesValidator 120 | from koda_validate.coerce import Coercer, coercer 121 | from koda_validate.dataclasses import DataclassValidator 122 | from koda_validate.decimal import DecimalValidator 123 | from koda_validate.dictionary import ( 124 | DictValidatorAny, 125 | IsDictValidator, 126 | KeyNotRequired, 127 | MapValidator, 128 | MaxKeys, 129 | MinKeys, 130 | RecordValidator, 131 | is_dict_validator, 132 | ) 133 | from koda_validate.errors import ( 134 | CoercionErr, 135 | ContainerErr, 136 | ErrType, 137 | ExtraKeysErr, 138 | IndexErrs, 139 | KeyErrs, 140 | KeyValErrs, 141 | MapErr, 142 | MissingKeyErr, 143 | PredicateErrs, 144 | SetErrs, 145 | TypeErr, 146 | UnionErrs, 147 | ValidationErrBase, 148 | missing_key_err, 149 | ) 150 | from koda_validate.float import FloatValidator 151 | from koda_validate.generic import ( 152 | AlwaysValid, 153 | Choices, 154 | EndsWith, 155 | EqualsValidator, 156 | EqualTo, 157 | ExactItemCount, 158 | ExactLength, 159 | Lazy, 160 | LowerCase, 161 | Max, 162 | MaxItems, 163 | MaxLength, 164 | Min, 165 | MinItems, 166 | MinLength, 167 | MultipleOf, 168 | NotBlank, 169 | StartsWith, 170 | UniqueItems, 171 | UpperCase, 172 | always_valid, 173 | lower_case, 174 | not_blank, 175 | strip, 176 | unique_items, 177 | upper_case, 178 | ) 179 | from koda_validate.integer import IntValidator 180 | from koda_validate.list import ListValidator 181 | from koda_validate.namedtuple import NamedTupleValidator 182 | from koda_validate.none import NoneValidator, OptionalValidator, none_validator 183 | from koda_validate.set import SetValidator 184 | from koda_validate.string import EmailPredicate, RegexPredicate, StringValidator 185 | from koda_validate.time import DatetimeValidator, DateValidator 186 | from koda_validate.tuple import NTupleValidator, UniformTupleValidator 187 | from koda_validate.typeddict import TypedDictValidator 188 | from koda_validate.union import UnionValidator 189 | from koda_validate.uuid import UUIDValidator 190 | from koda_validate.valid import Invalid, Valid, ValidationResult 191 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 5.1.0 (Sep 28, 2025) 2 | - make `Invalid.__repr__` human-readable 3 | 4 | 5.0.2 (Sep 22, 2025) 5 | - remove implicit dependency on typing-extensions 6 | 7 | 5.0.1 (Sep 16, 2025) 8 | - Add support for ReadOnly type annotation 9 | 10 | 5.0.0 (May 11, 2025) 11 | **Breaking Changes** 12 | - Remove support for Python 3.8 13 | 14 | 4.1.1 (Apr 9, 2024) 15 | **Optimization** 16 | - Use `pattern.match(...)` instead of `re.match(pattern, ...)` in `RegexPredicate` and `EmailPredicate` 17 | 18 | 4.1.0 (Feb 29, 2024) 19 | **Features** 20 | - `ValidationResult.map()` can be used to succinctly convert data contained within `Valid` objects to some other type or value 21 | 22 | 4.0.0 (Sep 27, 2023) 23 | **Breaking Changes** 24 | - `to_serializable_errs` produces different text, removing assumption that validation input was deserialized from json 25 | 26 | **Improvements** 27 | - Allow `to_serializable_errs` to accept callable for next level 28 | 29 | 3.1.2 (May 4, 2023) 30 | **Bug Fixes** 31 | - [#30](https://github.com/keithasaurus/koda-validate/issues/30) validate_signature ignores validators' coercion and processors 32 | 33 | **Maintenance** 34 | - General updates to dev dependencies 35 | 36 | 3.1.1 (Jan. 28, 2023) 37 | **Bug Fixes** 38 | - `UnionValidator.typed`'s `@overloads` specify that arguments should be positional-only 39 | 40 | **Docs** 41 | - extend docs for `UnionValidator` and `NTupleValidator` 42 | 43 | 3.1.0 (Jan. 26, 2023) 44 | **Features** 45 | - Runtime type checking via `koda_validate.signature.validate_signature` 46 | - Coercion customizable via `Coercer` (and helper decorator `coercer`) 47 | 48 | **Maintenance** 49 | - Minor bug fixes in examples/ 50 | 51 | 3.0.0 (Jan. 5, 2023) 52 | **Features** 53 | - Derived Validators: `TypedDictValidator`, `DataclassValidator`, `NamedTupleValidator` 54 | - `UnionValidator` replaces `OneOf2`, `OneOf3` 55 | - `NTupleValidator` replaces `Tuple2Validator`, `Tuple2Validator` 56 | - `UniformTupleValidator` 57 | - `SetValidator` 58 | - `BytesValidator` 59 | - New `Valid`/`Invalid` types 60 | - `MaybeValidator` 61 | - `CacheValidatorBase` 62 | - Errors decoupled from Serialization 63 | - Fewer Generic Arguments needed for `Validator`s, `Predicate`s and `PrediateAsync`s 64 | - `koda_validate.serialization.to_json_schema` 65 | 66 | **Removals** 67 | - `OneOf2` 68 | - `OneOf3` 69 | - `Tuple2Validator` 70 | - `Tuple3Validator` 71 | 72 | **Breaking Changes** 73 | - `UnionValidator` replaces `OneOf2`, `OneOf3` 74 | - `NTupleValidator` replaces `Tuple2Validator`, `Tuple2Validator` 75 | - New `Valid`/`Invalid` types -- need `koda_validate.to_serializable_errs` to produce serializable errors 76 | 77 | **Performance** 78 | - Various optimizations in dictionary `Validator`s 79 | - Optimizations in scalar validators (StringValidator, IntValidator, etc.) for common use cases 80 | - Overall speedups of around 30% for common validation cases 81 | - More benchmarks 82 | 83 | **Maintenance** 84 | - New Docs Site 85 | - Restructure project layout 86 | - _internal.py 87 | - move serialization to koda_validate.serialization 88 | - More benchmarks 89 | 90 | 2.1.0 (Nov. 9, 2022) 91 | **Features** 92 | - `UUIDValidator` 93 | 94 | **Bug Fixes** 95 | - `RecordValidator` allows up-to-16 keys as intended 96 | 97 | **Performance** 98 | - Small optimizations for `RecordValidator`, `DictValidatorAny`, `ListValidator` and `DecimalValidator` 99 | 100 | 2.0.0 (Nov. 8, 2022) 101 | **Features** 102 | - `asyncio` is supported with `.validate_async` method 103 | - `PredicateAsync` is added 104 | - `RecordValidator` (and `DictValidatorAny`) can now handle any kind of dict key. 105 | - `AlwaysValid` is added 106 | - `Predicate`s and `Processor`s are allowed more extensively across built-in validators. 107 | 108 | 109 | **Performance** 110 | - speed has improved as much as 60x (Koda Validate is now significantly faster than Pydantic) 111 | 112 | **Breaking Changes** 113 | - `Ok` / `Err` / `Result` (from `koda`) -> `Valid` / `Invalid` / `Result` (from `koda_validate`) 114 | - `dict_validator` -> `RecordValidator` 115 | - `DictNKeysValidator`s are removed. Instead, `RecordValidator` is used. 116 | - `RecordValidator` requires all arguments as keyword-only 117 | - `RecordValidator` accepts up-to 16 keys 118 | - `key` helper is removed 119 | - `maybe_key` helper is replaced by `KeyNotRequired` 120 | - `MapValidator` requires all arguments as keyword-only 121 | - `ListValidator` requires `Predicate`s to be specified as a keyword argument 122 | - the order and number of some `__match_args__` has changed 123 | - most validators are not longer dataclasses; `__repr__`s may differ 124 | - `validate_and_map` is deprecated and removed from `koda_validate` imports. 125 | - file structure has changed 126 | - typedefs.py -> base.py 127 | - utils.py -> _internals.py 128 | 129 | **Maintenance** 130 | - `bench` folder is added for benchmarks 131 | - `python3.11` added to CI 132 | - `pypy3.9` added to CI 133 | - min coverage bumped to 95% 134 | - lots of tests added 135 | 136 | 1.0.0 - Initial Release 137 | :) -------------------------------------------------------------------------------- /bench/nested_object_list.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any, Dict, List, NamedTuple, TypedDict 3 | 4 | from pydantic import BaseModel 5 | 6 | from koda_validate import ( 7 | DictValidatorAny, 8 | FloatValidator, 9 | IntValidator, 10 | ListValidator, 11 | RecordValidator, 12 | StringValidator, 13 | ) 14 | from koda_validate.dataclasses import DataclassValidator 15 | from koda_validate.namedtuple import NamedTupleValidator 16 | from koda_validate.typeddict import TypedDictValidator 17 | 18 | 19 | @dataclass 20 | class Hobby: 21 | name: str 22 | reason: str 23 | category: str 24 | enjoyment: float 25 | 26 | 27 | @dataclass 28 | class Person: 29 | name: str 30 | age: int 31 | hobbies: List[Hobby] 32 | 33 | 34 | class HobbyNT(NamedTuple): 35 | name: str 36 | reason: str 37 | category: str 38 | enjoyment: float 39 | 40 | 41 | class PersonNT(NamedTuple): 42 | name: str 43 | age: int 44 | hobbies: List[HobbyNT] 45 | 46 | 47 | class HobbyTD(TypedDict): 48 | name: str 49 | reason: str 50 | category: str 51 | enjoyment: float 52 | 53 | 54 | class PersonTD(TypedDict): 55 | name: str 56 | age: int 57 | hobbies: List[HobbyTD] 58 | 59 | 60 | k_validator = RecordValidator( 61 | into=Person, 62 | keys=( 63 | ("name", StringValidator()), 64 | ("age", IntValidator()), 65 | ( 66 | "hobbies", 67 | ListValidator( 68 | RecordValidator( 69 | into=Hobby, 70 | keys=( 71 | ("name", StringValidator()), 72 | ("reason", StringValidator()), 73 | ("category", StringValidator()), 74 | ("enjoyment", FloatValidator()), 75 | ), 76 | ) 77 | ), 78 | ), 79 | ), 80 | ) 81 | 82 | k_dataclass_validator = DataclassValidator(Person) 83 | k_namedtuple_validator = NamedTupleValidator(PersonNT) 84 | k_typeddict_validator = TypedDictValidator(PersonTD) 85 | 86 | k_dict_any_validator = DictValidatorAny( 87 | { 88 | "name": StringValidator(), 89 | "age": IntValidator(), 90 | "hobbies": ListValidator( 91 | DictValidatorAny( 92 | { 93 | "name": StringValidator(), 94 | "reason": StringValidator(), 95 | "category": StringValidator(), 96 | "enjoyment": FloatValidator(), 97 | } 98 | ), 99 | ), 100 | } 101 | ) 102 | 103 | 104 | class PydHobby(BaseModel): 105 | name: str 106 | reason: str 107 | category: str 108 | enjoyment: float 109 | 110 | 111 | # this is not directly equivalent to koda validatel 112 | # because it will implicitly coerce. However it's faster than the 113 | # `con` types, so probably the better to compare against 114 | class PydPerson(BaseModel): 115 | name: str 116 | age: str 117 | hobbies: List[PydHobby] 118 | 119 | 120 | def get_data(i: int) -> Dict[str, Any]: 121 | modded = i % 3 122 | if modded == 0: 123 | return { 124 | "name": f"name{i}", 125 | "age": i, 126 | "hobbies": [ 127 | { 128 | "name": f"hobby{i}", 129 | "reason": f"reason{i}", 130 | "category": f"category{i}", 131 | "enjoyment": float(i), 132 | } 133 | for _ in range(i % 10) 134 | ], 135 | } 136 | elif modded == 1: 137 | return { 138 | "name": None, 139 | "age": i, 140 | "hobbies": [ 141 | { 142 | "name": f"hobby{i}", 143 | "reason": f"reason{i}", 144 | "enjoyment": float(i), 145 | } 146 | for _ in range(i % 10) 147 | ], 148 | } 149 | else: 150 | return { 151 | "name": f"name{i}", 152 | "age": i, 153 | "hobbies": [5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5], 154 | } 155 | 156 | 157 | def run_kv(objs: List[Any]) -> None: 158 | for obj in objs: 159 | _ = k_validator(obj) 160 | 161 | 162 | def run_kv_dc(objs: List[Any]) -> None: 163 | for obj in objs: 164 | _ = k_dataclass_validator(obj) 165 | 166 | 167 | def run_kv_dict_any(objs: List[Any]) -> None: 168 | for obj in objs: 169 | _ = k_dict_any_validator(obj) 170 | 171 | 172 | def run_kv_nt(objs: List[Any]) -> None: 173 | for obj in objs: 174 | _ = k_namedtuple_validator(obj) 175 | 176 | 177 | def run_kv_td(objs: List[Any]) -> None: 178 | for obj in objs: 179 | _ = k_typeddict_validator(obj) 180 | 181 | 182 | def run_pyd(objs: List[Any]) -> None: 183 | for obj in objs: 184 | try: 185 | _ = PydPerson(**obj) 186 | except: # noqa: E722 187 | pass 188 | 189 | 190 | if __name__ == "__main__": 191 | print(k_validator(get_data(2))) 192 | print(PydPerson(**get_data(2))) 193 | -------------------------------------------------------------------------------- /tests/test_valid.py: -------------------------------------------------------------------------------- 1 | from copy import copy 2 | from dataclasses import dataclass 3 | 4 | from koda import Just 5 | 6 | from koda_validate import (Invalid, StringValidator, TypeErr, Valid, ListValidator, 7 | DataclassValidator, MapValidator, IntValidator, SetValidator, 8 | MaxLength, MinLength, ExactLength) 9 | from koda_validate.maybe import MaybeValidator 10 | from koda_validate.typehints import get_typehint_validator 11 | 12 | 13 | def test_valid_map() -> None: 14 | assert Valid("something").map(lambda x: x.replace("some", "no")) == Valid("nothing") 15 | assert Valid(5).map(str) == Valid("5") 16 | 17 | inv = Invalid( 18 | err_type=TypeErr(str), 19 | value=5, 20 | validator=StringValidator(), 21 | ) 22 | 23 | mapped = copy(inv).map(lambda x: x.replace("some", "no")) 24 | assert isinstance(mapped, Invalid) 25 | assert mapped.value == inv.value 26 | assert mapped.err_type == inv.err_type 27 | assert mapped.validator == inv.validator 28 | 29 | 30 | def test_invalid_repr() -> None: 31 | sv = StringValidator() 32 | 33 | assert repr(sv(5)) == """ 34 | Invalid( 35 | err_type=TypeErr(expected_type=), 36 | value=5, 37 | validator=StringValidator() 38 | ) 39 | """.strip() 40 | 41 | lv = get_typehint_validator(list[str]) 42 | 43 | assert repr(lv([4])) == """ 44 | Invalid( 45 | err_type=IndexErrs(index_errs={ 46 | 0: Invalid( 47 | err_type=TypeErr(expected_type=), 48 | value=4, 49 | validator=StringValidator() 50 | ), 51 | }), 52 | value=[4], 53 | validator=ListValidator(StringValidator()) 54 | ) 55 | """.strip() 56 | 57 | @dataclass 58 | class Person: 59 | name: str 60 | age: int 61 | 62 | v1 = DataclassValidator(Person, fail_on_unknown_keys=True) 63 | 64 | assert repr(v1({})) == """ 65 | Invalid( 66 | err_type=KeyErrs(keys={ 67 | 'name': Invalid( 68 | err_type=MissingKeyErr(), 69 | value={}, 70 | validator=DataclassValidator(.Person'>, fail_on_unknown_keys=True) 71 | ), 72 | 'age': Invalid( 73 | err_type=MissingKeyErr(), 74 | value={}, 75 | validator=DataclassValidator(.Person'>, fail_on_unknown_keys=True) 76 | ), 77 | }), 78 | value={}, 79 | validator=DataclassValidator(.Person'>, fail_on_unknown_keys=True) 80 | ) 81 | """.strip() # noqa: 501 82 | 83 | mayv = MaybeValidator(ListValidator(StringValidator())) 84 | assert repr(mayv(Just([4]))) == """ 85 | Invalid( 86 | err_type=ContainerErr( 87 | child=Invalid( 88 | err_type=IndexErrs(index_errs={ 89 | 0: Invalid( 90 | err_type=TypeErr(expected_type=), 91 | value=4, 92 | validator=StringValidator() 93 | ), 94 | }), 95 | value=[4], 96 | validator=ListValidator(StringValidator()) 97 | ) 98 | ), 99 | value=Just([4]), 100 | validator=MaybeValidator(ListValidator(StringValidator())) 101 | ) 102 | """.strip() 103 | 104 | assert repr(v1({"name": "John", "age": 10, "favorite_color": "blue"})) == """ 105 | Invalid( 106 | err_type=ExtraKeysErr( 107 | expected_keys={'age', 'name'},} 108 | ), 109 | value={'name': 'John', 'age': 10, 'favorite_color': 'blue'}, 110 | validator=DataclassValidator(.Person'>, fail_on_unknown_keys=True) 111 | ) 112 | """.strip() # noqa: 501 113 | 114 | mv = MapValidator( 115 | key=StringValidator(), 116 | value=IntValidator() 117 | ) 118 | 119 | assert repr(mv({"ok": "not ok"})) == """ 120 | Invalid( 121 | err_type=MapErr(keys={ 122 | 'ok': KeyValErrs( 123 | key=None, 124 | val=Invalid( 125 | err_type=TypeErr(expected_type=), 126 | value='not ok', 127 | validator=IntValidator() 128 | ) 129 | ), 130 | }), 131 | value={'ok': 'not ok'}, 132 | validator=MapValidator(key=StringValidator(), value=IntValidator()) 133 | ) 134 | """.strip() 135 | 136 | setv = SetValidator(IntValidator()) 137 | 138 | assert repr(setv({"not ok"})) == """ 139 | Invalid( 140 | err_type=SetErrs(item_errs=[ 141 | Invalid( 142 | err_type=TypeErr(expected_type=), 143 | value='not ok', 144 | validator=IntValidator() 145 | ), 146 | ]), 147 | value={'not ok'}, 148 | validator=SetValidator(IntValidator()) 149 | ) 150 | """.strip() 151 | 152 | pred_v = StringValidator( 153 | MaxLength(1), 154 | MinLength(1), 155 | ExactLength(1), 156 | ) 157 | assert repr(pred_v("")) == """ 158 | Invalid( 159 | err_type=PredicateErrs(predicates=[ 160 | MinLength(length=1), 161 | ExactLength(length=1), 162 | ]), 163 | value='', 164 | validator=StringValidator(MaxLength(length=1), MinLength(length=1), ExactLength(length=1)) 165 | ) 166 | """.strip() # noqa: 501 167 | -------------------------------------------------------------------------------- /docs/how_to/results.rst: -------------------------------------------------------------------------------- 1 | Validation Results 2 | ================== 3 | 4 | .. module:: koda_validate 5 | :noindex: 6 | 7 | In Koda Validate, :class:`Validator`\s express validation success or failure by returning 8 | a :data:`ValidationResult`. To be more specific it requires one generic parameter: the 9 | valid data type. Likewise, :class:`Validator`\s take the same 10 | generic parameter for the same purpose. So, a ``Validator[int]`` will always return a 11 | ``ValidationResult[int]``: 12 | 13 | .. testsetup:: 1 14 | 15 | from koda_validate import * 16 | 17 | .. testcode:: 1 18 | 19 | validator: Validator[int] = IntValidator() 20 | 21 | result = validator(5) 22 | assert result.is_valid 23 | assert isinstance(result.val, int) # mypy also knows ``result.val`` is an ``int`` 24 | 25 | .. note:: 26 | 27 | ``ValidationResult[int]`` is a more concise way to express ``Union[Valid[int], Invalid]``, 28 | to which it is exactly equivalent. 29 | 30 | Branching on Validity 31 | --------------------- 32 | 33 | As you can see, to do something useful with :data:`ValidationResult`\s, we need to 34 | distinguish between the :class:`Valid` and :class:`Invalid` variants, as each 35 | has different attributes. 36 | 37 | ``if`` Statements 38 | ^^^^^^^^^^^^^^^^^ 39 | Perhaps the easiest way is to just branch on ``.is_valid``: 40 | 41 | .. testcode:: if-statements 42 | 43 | from koda_validate import ValidationResult, StringValidator 44 | 45 | def result_to_str(result: ValidationResult[str]) -> str: 46 | if result.is_valid: 47 | # mypy understands result is Valid[str] 48 | return result.val 49 | else: 50 | # mypy understands result is Invalid 51 | err_type_cls = result.err_type.__class__.__name__ 52 | return ( 53 | f"Error of type {err_type_cls}, " 54 | f"while validating {result.value} with {result.validator}" 55 | ) 56 | 57 | Let's see how it works 58 | 59 | .. doctest:: if-statements 60 | 61 | >>> validator = StringValidator() 62 | >>> result_to_str(validator("abc123")) 63 | 'abc123' 64 | 65 | >>> result_to_str(validator(0)) 66 | 'Error of type TypeErr, while validating 0 with StringValidator()' 67 | 68 | 69 | Pattern Matching 70 | ^^^^^^^^^^^^^^^^ 71 | Pattern matching can make this more concise in Python 3.10+: 72 | 73 | .. testcode:: pattern-matching 74 | 75 | from koda_validate import ValidationResult, Valid, Invalid, IntValidator 76 | 77 | def result_to_val(result: ValidationResult[str]) -> int | str: 78 | match result: 79 | case Valid(valid_val): 80 | return valid_val 81 | case Invalid(err_type, val, validator_): 82 | return ( 83 | f"Error of type {err_type.__class__.__name__}, " 84 | f"while validating {val} with {validator_}" 85 | ) 86 | 87 | Let's try it 88 | 89 | .. doctest:: pattern-matching 90 | 91 | >>> validator = IntValidator() 92 | >>> result_to_val(validator(123)) 93 | 123 94 | 95 | >>> result_to_val(validator("abc")) 96 | 'Error of type TypeErr, while validating abc with IntValidator()' 97 | 98 | 99 | ValidationResult.map() 100 | ^^^^^^^^^^^^^^^^^^^^^^ 101 | Sometimes you might want to convert the data contained by :class:`Valid` into another 102 | type. ``.map`` allows you to do that without a lot of boilerplate: 103 | 104 | .. testsetup:: valid-map 105 | 106 | from koda_validate import IntValidator 107 | 108 | .. doctest:: valid-map 109 | 110 | >>> IntValidator()(5).map(str) 111 | Valid(val='5') 112 | 113 | 114 | Working with ``Invalid`` 115 | ------------------------ 116 | :class:`Invalid` instances provide machine-readable validation failure data. 117 | In most cases you'll want to transform these data in some way before sending it somewhere else. The expectation is that 118 | built-in, or custom, utility functions should handle this. One such built-in function is :data:`to_serializable_errs`. It 119 | takes an :class:`Invalid` instance and produces errors objects suitable for JSON / YAML serialization. 120 | 121 | .. testcode:: 3 122 | 123 | from koda_validate import StringValidator, Invalid 124 | from koda_validate.serialization import to_serializable_errs 125 | 126 | validator = StringValidator() 127 | 128 | result = validator(123) 129 | assert isinstance(result, Invalid) 130 | 131 | print(to_serializable_errs(result)) 132 | 133 | Outputs 134 | 135 | .. testoutput:: 3 136 | 137 | ['expected a string'] 138 | 139 | Even if it doesn't suit your ultimate purpose, :data:`to_serializable_errs` can be useful during 140 | development because the error messages tend to be more readable than the printed representation of 141 | :class:`Invalid` instances. 142 | 143 | .. note:: 144 | :data:`to_serializable_errs` is only meant to be a basic effort at a general English-language serializable 145 | utility function. It may be convenient to work with, but please do not feel that you are in any way 146 | limited to its functionality. Koda Validate's intention is that users should be able to build whatever 147 | error objects they need by consuming the :class:`Invalid` data. 148 | 149 | -------------------------------------------------------------------------------- /tests/test_decimal.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from dataclasses import dataclass 3 | from decimal import Decimal 4 | 5 | import pytest 6 | 7 | from koda_validate import ( 8 | CoercionErr, 9 | DecimalValidator, 10 | Invalid, 11 | Max, 12 | Min, 13 | PredicateAsync, 14 | PredicateErrs, 15 | Processor, 16 | Valid, 17 | ) 18 | from koda_validate._generics import A 19 | 20 | 21 | @dataclass 22 | class Add1Decimal(Processor[Decimal]): 23 | def __call__(self, val: Decimal) -> Decimal: 24 | return val + 1 25 | 26 | 27 | def test_decimal() -> None: 28 | d_v = DecimalValidator() 29 | assert d_v("a string") == Invalid( 30 | CoercionErr( 31 | {str, int, Decimal}, 32 | Decimal, 33 | ), 34 | "a string", 35 | d_v, 36 | ) 37 | 38 | assert d_v(5.5) == Invalid( 39 | CoercionErr( 40 | {str, int, Decimal}, 41 | Decimal, 42 | ), 43 | 5.5, 44 | d_v, 45 | ) 46 | 47 | assert DecimalValidator()(Decimal("5.5")) == Valid(Decimal("5.5")) 48 | 49 | assert DecimalValidator()(5) == Valid(Decimal(5)) 50 | 51 | assert DecimalValidator(Min(Decimal(4)), Max(Decimal("5.5")))(5) == Valid(Decimal(5)) 52 | dec_min_max_v = DecimalValidator(Min(Decimal(4)), Max(Decimal("5.5"))) 53 | assert dec_min_max_v(Decimal(1)) == Invalid( 54 | PredicateErrs([Min(Decimal(4))]), Decimal(1), dec_min_max_v 55 | ) 56 | assert DecimalValidator(preprocessors=[Add1Decimal()])(Decimal("5.0")) == Valid( 57 | Decimal("6.0") 58 | ) 59 | 60 | 61 | @pytest.mark.asyncio 62 | async def test_decimal_async() -> None: 63 | d_v = DecimalValidator() 64 | assert await d_v.validate_async("abc") == Invalid( 65 | CoercionErr( 66 | {str, int, Decimal}, 67 | Decimal, 68 | ), 69 | "abc", 70 | d_v, 71 | ) 72 | 73 | assert await d_v.validate_async(5.5) == Invalid( 74 | CoercionErr( 75 | {str, int, Decimal}, 76 | Decimal, 77 | ), 78 | 5.5, 79 | d_v, 80 | ) 81 | 82 | @dataclass 83 | class LessThan4(PredicateAsync[Decimal]): 84 | async def validate_async(self, val: Decimal) -> bool: 85 | await asyncio.sleep(0.001) 86 | return val < Decimal(4) 87 | 88 | add_1_dec_v = DecimalValidator( 89 | preprocessors=[Add1Decimal()], predicates_async=[LessThan4()] 90 | ) 91 | result = await add_1_dec_v.validate_async(3) 92 | assert result == Invalid(PredicateErrs([LessThan4()]), Decimal(4), add_1_dec_v) 93 | assert await DecimalValidator( 94 | preprocessors=[Add1Decimal()], predicates_async=[LessThan4()] 95 | ).validate_async(2) == Valid(Decimal(3)) 96 | 97 | assert await DecimalValidator( 98 | preprocessors=[Add1Decimal()], predicates_async=[LessThan4()] 99 | ).validate_async(Decimal("2.75")) == Valid(Decimal("3.75")) 100 | 101 | assert await add_1_dec_v.validate_async(Decimal("3.75")) == Invalid( 102 | PredicateErrs([LessThan4()]), Decimal("4.75"), add_1_dec_v 103 | ) 104 | 105 | 106 | def test_sync_call_with_async_predicates_raises_assertion_error() -> None: 107 | @dataclass 108 | class AsyncWait(PredicateAsync[A]): 109 | async def validate_async(self, val: A) -> bool: 110 | await asyncio.sleep(0.001) 111 | return True 112 | 113 | dec_validator = DecimalValidator(predicates_async=[AsyncWait()]) 114 | with pytest.raises(AssertionError): 115 | dec_validator("whatever") 116 | 117 | 118 | @dataclass 119 | class DecAsyncPred(PredicateAsync[Decimal]): 120 | async def validate_async(self, val: Decimal) -> bool: 121 | return True 122 | 123 | 124 | def test_repr() -> None: 125 | s = DecimalValidator() 126 | assert repr(s) == "DecimalValidator()" 127 | 128 | s_len = DecimalValidator(Min(Decimal(1)), Max(Decimal(5))) 129 | assert ( 130 | repr(s_len) 131 | == "DecimalValidator(Min(minimum=Decimal('1'), exclusive_minimum=False), " 132 | "Max(maximum=Decimal('5'), exclusive_maximum=False))" 133 | ) 134 | 135 | s_all = DecimalValidator( 136 | Min(Decimal(1)), predicates_async=[DecAsyncPred()], preprocessors=[Add1Decimal()] 137 | ) 138 | 139 | assert ( 140 | repr(s_all) 141 | == "DecimalValidator(Min(minimum=Decimal('1'), exclusive_minimum=False), " 142 | "predicates_async=[DecAsyncPred()], preprocessors=[Add1Decimal()])" 143 | ) 144 | 145 | 146 | def test_equivalence() -> None: 147 | d_1 = DecimalValidator() 148 | d_2 = DecimalValidator() 149 | assert d_1 == d_2 150 | 151 | d_pred_1 = DecimalValidator(Max(Decimal(1))) 152 | assert d_pred_1 != d_1 153 | d_pred_2 = DecimalValidator(Max(Decimal(1))) 154 | assert d_pred_2 == d_pred_1 155 | 156 | d_pred_async_1 = DecimalValidator(Max(Decimal(1)), predicates_async=[DecAsyncPred()]) 157 | assert d_pred_async_1 != d_pred_1 158 | d_pred_async_2 = DecimalValidator(Max(Decimal(1)), predicates_async=[DecAsyncPred()]) 159 | assert d_pred_async_1 == d_pred_async_2 160 | 161 | d_preproc_1 = DecimalValidator( 162 | Max(Decimal(1)), predicates_async=[DecAsyncPred()], preprocessors=[Add1Decimal()] 163 | ) 164 | assert d_preproc_1 != d_pred_async_1 165 | 166 | d_preproc_2 = DecimalValidator( 167 | Max(Decimal(1)), predicates_async=[DecAsyncPred()], preprocessors=[Add1Decimal()] 168 | ) 169 | assert d_preproc_1 == d_preproc_2 170 | -------------------------------------------------------------------------------- /tests/test_typehints.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime 2 | from decimal import Decimal 3 | from typing import Literal, NamedTuple, Tuple, TypeVar, Union, Dict, List 4 | 5 | from koda_validate import ( 6 | AlwaysValid, 7 | BoolValidator, 8 | BytesValidator, 9 | Choices, 10 | DatetimeValidator, 11 | DateValidator, 12 | EqualsValidator, 13 | EqualTo, 14 | IntValidator, 15 | Invalid, 16 | PredicateErrs, 17 | StringValidator, 18 | TypeErr, 19 | UniformTupleValidator, 20 | UnionErrs, 21 | UnionValidator, 22 | Valid, 23 | none_validator, ListValidator, MapValidator, 24 | ) 25 | from koda_validate.namedtuple import NamedTupleValidator 26 | from koda_validate.typehints import get_typehint_validator 27 | 28 | 29 | def test_get_typehint_validator_bare_tuple() -> None: 30 | for t_validator in [get_typehint_validator(tuple), get_typehint_validator(Tuple)]: 31 | assert isinstance(t_validator, UniformTupleValidator) 32 | assert isinstance(t_validator.item_validator, AlwaysValid) 33 | 34 | 35 | def test_get_type_hint_for_literal_for_multiple_types() -> None: 36 | abc_validator = get_typehint_validator(Literal["abc", 1]) 37 | assert isinstance(abc_validator, UnionValidator) 38 | 39 | assert len(abc_validator.validators) == 2 40 | assert isinstance(abc_validator.validators[0], EqualsValidator) 41 | assert isinstance(abc_validator.validators[1], EqualsValidator) 42 | assert abc_validator("abc") == Valid("abc") 43 | assert abc_validator(1) == Valid(1) 44 | assert abc_validator("a") == Invalid( 45 | UnionErrs( 46 | [ 47 | Invalid( 48 | PredicateErrs([EqualTo("abc")]), "a", abc_validator.validators[0] 49 | ), 50 | Invalid(TypeErr(int), "a", abc_validator.validators[1]), 51 | ] 52 | ), 53 | "a", 54 | abc_validator, 55 | ) 56 | 57 | int_str_bool_validator = get_typehint_validator(Literal[123, "abc", False]) 58 | assert isinstance(int_str_bool_validator, UnionValidator) 59 | 60 | assert len(int_str_bool_validator.validators) == 3 61 | for v in int_str_bool_validator.validators: 62 | assert isinstance(v, EqualsValidator) 63 | 64 | assert int_str_bool_validator(123) == Valid(123) 65 | assert int_str_bool_validator("abc") == Valid("abc") 66 | assert int_str_bool_validator(False) == Valid(False) 67 | 68 | assert int_str_bool_validator("a") == Invalid( 69 | UnionErrs( 70 | [ 71 | Invalid(TypeErr(int), "a", int_str_bool_validator.validators[0]), 72 | Invalid( 73 | PredicateErrs([EqualTo("abc")]), 74 | "a", 75 | int_str_bool_validator.validators[1], 76 | ), 77 | Invalid(TypeErr(bool), "a", int_str_bool_validator.validators[2]), 78 | ] 79 | ), 80 | "a", 81 | int_str_bool_validator, 82 | ) 83 | 84 | 85 | def test_get_literal_validator_all_strings() -> None: 86 | v = get_typehint_validator(Literal["a", "b", "c"]) 87 | assert v == StringValidator(Choices({"a", "b", "c"})) 88 | 89 | 90 | def test_get_literal_validator_none() -> None: 91 | v = get_typehint_validator(Literal[None]) 92 | assert v == none_validator 93 | 94 | 95 | def test_get_literal_validator_all_bools() -> None: 96 | v = get_typehint_validator(Literal[True]) 97 | assert v == BoolValidator(Choices({True})) 98 | 99 | v_1 = get_typehint_validator(Literal[False]) 100 | assert v_1 == BoolValidator(Choices({False})) 101 | 102 | 103 | def test_get_literal_validator_all_bytes() -> None: 104 | v = get_typehint_validator(Literal[b"a", b"b", b"c"]) 105 | assert v == BytesValidator(Choices({b"a", b"b", b"c"})) 106 | 107 | 108 | def test_get_literal_validator_all_ints() -> None: 109 | v = get_typehint_validator(Literal[1, 12, 123]) 110 | assert v == IntValidator(Choices({1, 12, 123})) 111 | 112 | 113 | def test_get_typehint_validator_named_tuple() -> None: 114 | class X(NamedTuple): 115 | x: str 116 | 117 | v = get_typehint_validator(X) 118 | 119 | assert isinstance(v, NamedTupleValidator) 120 | assert v == NamedTupleValidator(X) 121 | 122 | 123 | def test_datetime_handled_properly() -> None: 124 | assert get_typehint_validator(datetime) == DatetimeValidator() 125 | 126 | 127 | def test_date_handled_properly() -> None: 128 | assert get_typehint_validator(date) == DateValidator() 129 | 130 | 131 | def test_unhandled_message() -> None: 132 | A1 = TypeVar("A1") 133 | for obj in ["just a string", 1, Decimal(5), A1]: 134 | try: 135 | get_typehint_validator(obj) 136 | except TypeError as e: 137 | assert str(e) == f"Got unhandled annotation: {repr(obj)}." 138 | else: 139 | raise AssertionError("should have raised a TypeError") 140 | 141 | 142 | def test_get_typehint_validator_type_aliases() -> None: 143 | StringList = List[str] 144 | UserDict = Dict[str, Union[str, int]] 145 | 146 | # Test that aliases resolve to their underlying types 147 | string_list_validator = get_typehint_validator(StringList) 148 | assert isinstance(string_list_validator, ListValidator) 149 | assert isinstance(string_list_validator.item_validator, StringValidator) 150 | 151 | user_dict_validator = get_typehint_validator(UserDict) 152 | assert isinstance(user_dict_validator, MapValidator) 153 | assert isinstance(user_dict_validator.key_validator, StringValidator) 154 | assert isinstance(user_dict_validator.value_validator, UnionValidator) 155 | -------------------------------------------------------------------------------- /tests/test_string.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import re 3 | from dataclasses import dataclass 4 | 5 | import pytest 6 | 7 | from koda_validate import ( 8 | EmailPredicate, 9 | Invalid, 10 | MaxLength, 11 | MinLength, 12 | NotBlank, 13 | PredicateAsync, 14 | PredicateErrs, 15 | RegexPredicate, 16 | TypeErr, 17 | Valid, 18 | lower_case, 19 | not_blank, 20 | strip, 21 | upper_case, 22 | ) 23 | from koda_validate._generics import A 24 | from koda_validate.string import StringValidator 25 | 26 | 27 | def test_strip() -> None: 28 | assert strip(" x ") == "x" 29 | assert strip("ok") == "ok" 30 | 31 | 32 | def test_upper_case() -> None: 33 | assert upper_case("AbCdE") == "ABCDE" 34 | 35 | 36 | def test_lower_case() -> None: 37 | assert lower_case("ZyXwV") == "zyxwv" 38 | 39 | 40 | def test_string_validator() -> None: 41 | s_v = StringValidator() 42 | assert s_v(False) == Invalid(TypeErr(str), False, s_v) 43 | 44 | assert StringValidator()("abc") == Valid("abc") 45 | 46 | s_min_3_v = StringValidator(MaxLength(3)) 47 | assert s_min_3_v("something") == Invalid( 48 | PredicateErrs([MaxLength(3)]), "something", s_min_3_v 49 | ) 50 | 51 | min_len_3_not_blank_validator = StringValidator(MinLength(3), NotBlank()) 52 | 53 | assert min_len_3_not_blank_validator("") == Invalid( 54 | PredicateErrs([MinLength(3), not_blank]), "", min_len_3_not_blank_validator 55 | ) 56 | 57 | assert min_len_3_not_blank_validator(" ") == Invalid( 58 | PredicateErrs([not_blank]), " ", min_len_3_not_blank_validator 59 | ) 60 | 61 | assert min_len_3_not_blank_validator("something") == Valid("something") 62 | 63 | assert StringValidator(not_blank, preprocessors=[strip])(" strip me! ") == Valid( 64 | "strip me!" 65 | ) 66 | 67 | 68 | def test_max_string_length() -> None: 69 | assert MaxLength(0)("") is True 70 | 71 | assert MaxLength(5)("abc") is True 72 | 73 | assert MaxLength(5)("something") is False 74 | 75 | 76 | def test_min_string_length() -> None: 77 | assert MinLength(0)("") is True 78 | 79 | assert MinLength(3)("abc") is True 80 | 81 | assert MinLength(3)("zz") is False 82 | 83 | 84 | def test_regex_validator() -> None: 85 | v = RegexPredicate(re.compile(r".+")) 86 | assert v("something") is True 87 | assert v("") is False 88 | 89 | 90 | def test_not_blank() -> None: 91 | assert NotBlank()("a") is True 92 | assert NotBlank()("") is False 93 | assert NotBlank()(" ") is False 94 | assert NotBlank()("\t") is False 95 | assert NotBlank()("\n") is False 96 | 97 | 98 | def test_email() -> None: 99 | assert EmailPredicate()("notanemail") is False 100 | assert EmailPredicate()("a@b.com") is True 101 | 102 | 103 | @pytest.mark.asyncio 104 | async def test_validate_fake_db_async() -> None: 105 | test_valid_username = "valid_username" 106 | 107 | hit = [] 108 | 109 | @dataclass 110 | class CheckUsername(PredicateAsync[str]): 111 | async def validate_async(self, val: str) -> bool: 112 | hit.append("ok") 113 | # fake db call 114 | await asyncio.sleep(0.001) 115 | return val == test_valid_username 116 | 117 | validator = StringValidator(predicates_async=[CheckUsername()]) 118 | result = await validator.validate_async("bad username") 119 | assert hit == ["ok"] 120 | assert result == Invalid(PredicateErrs([CheckUsername()]), "bad username", validator) 121 | s_v = StringValidator() 122 | assert await s_v.validate_async(5) == Invalid(TypeErr(str), 5, s_v) 123 | 124 | 125 | def test_sync_call_with_async_predicates_raises_assertion_error() -> None: 126 | class AsyncWait(PredicateAsync[A]): 127 | async def validate_async(self, val: A) -> bool: 128 | await asyncio.sleep(0.001) 129 | return True 130 | 131 | str_validator = StringValidator(predicates_async=[AsyncWait()]) 132 | with pytest.raises(AssertionError): 133 | str_validator(5) 134 | 135 | 136 | @dataclass 137 | class StrAsyncPred(PredicateAsync[str]): 138 | async def validate_async(self, val: str) -> bool: 139 | return True 140 | 141 | 142 | def test_repr() -> None: 143 | s = StringValidator() 144 | assert repr(s) == "StringValidator()" 145 | 146 | s_len = StringValidator(MinLength(1), MaxLength(5)) 147 | assert repr(s_len) == "StringValidator(MinLength(length=1), MaxLength(length=5))" 148 | 149 | s_all = StringValidator( 150 | MinLength(1), predicates_async=[StrAsyncPred()], preprocessors=[strip] 151 | ) 152 | 153 | assert ( 154 | repr(s_all) == "StringValidator(MinLength(length=1), " 155 | "predicates_async=[StrAsyncPred()], preprocessors=[Strip()])" 156 | ) 157 | 158 | 159 | def test_equivalence() -> None: 160 | s_1 = StringValidator() 161 | s_2 = StringValidator() 162 | assert s_1 == s_2 163 | 164 | s_pred_1 = StringValidator(MaxLength(1)) 165 | assert s_pred_1 != s_1 166 | s_pred_2 = StringValidator(MaxLength(1)) 167 | assert s_pred_2 == s_pred_1 168 | 169 | s_pred_async_1 = StringValidator(MaxLength(1), predicates_async=[StrAsyncPred()]) 170 | assert s_pred_async_1 != s_pred_1 171 | s_pred_async_2 = StringValidator(MaxLength(1), predicates_async=[StrAsyncPred()]) 172 | assert s_pred_async_1 == s_pred_async_2 173 | 174 | s_preproc_1 = StringValidator( 175 | MaxLength(1), predicates_async=[StrAsyncPred()], preprocessors=[strip] 176 | ) 177 | assert s_preproc_1 != s_pred_async_1 178 | 179 | s_preproc_2 = StringValidator( 180 | MaxLength(1), predicates_async=[StrAsyncPred()], preprocessors=[strip] 181 | ) 182 | assert s_preproc_1 == s_preproc_2 183 | --------------------------------------------------------------------------------