A library to perform runtime validation of Python objects using type hints.
Install the latest release from PyPI:
$ pip install --upgrade typing-validationValidate a value against a type hint. validate returns True on success and raises on failure:
>>> from typing_validation import validate
>>> validate([1, 2, 3], list[int])
TrueThe True return exists so that validation can be gated behind an assertion, and compiled out entirely under -O:
assert validate(val, t)When a value does not conform, the error says where:
>>> validate({"a": [1, "b"]}, dict[str, list[int]])
Traceback (most recent call last):
...
typing_validation.errors.ValidationError: For type dict[str, list[int]]: a component failed
For type list[int] value at key 'a': a component failed
For type <class 'int'> index 1: not an instance, got 'b'The structured explanation is on the exception, for reading programmatically:
>>> try:
... validate([1, "b"], list[int])
... except ValidationError as e:
... print(e.failure.causes[0].location.at)
1validate analyses the type on every call. When you validate many values against one type, validator analyses it once and hands back a function:
>>> from typing_validation import validator
>>> check = validator(list[int])
>>> check([1, 2, 3])
TrueSame contract, same verdict, 2.7× faster per call, and it repays the cost of building it within a handful of values.
And when the values keep coming in very large numbers, compiled_validator emits Python specialised to the type and compiles it:
>>> from typing_validation import compiled_validator
>>> check = compiled_validator(list[int])
>>> check([1, 2, 3])
TrueThat runs at 23 ns per type-node against a hand-written check's 23 — it is, to within the noise, the code you would have written yourself.
It costs more to build, and it only helps where there is structure to unroll: for a recursive alias or a NumPy array it stops unrolling and hands back a validator, and the table says never rather than pretending otherwise.
So: validate for one-off checks, validator when the type is fixed and the values keep coming, compiled_validator when there are very many of them.
| Type | validate |
validator |
compiled_validator |
hand-written |
|---|---|---|---|---|
list[int] (1000 items) |
115.6 µs | 42.9 µs | 22.5 µs | 23.1 µs |
list[int] (20 items) |
2.7 µs | 1.0 µs | 561 ns | 541 ns |
dict[str, int] (20 items) |
5.2 µs | 1.9 µs | 1.0 µs | 1.0 µs |
tuple[int, str] |
724 ns | 362 ns | 150 ns | 127 ns |
int |
95 ns | 92 ns | 89 ns | 56 ns |
benchmark/REPORT.md has the full table — every case, both outcomes, construction costs, and the break-even points that say exactly how many values each mechanism needs before it repays — with the machine it was measured on.
Absolute figures move a long way with that machine; the ratios between the columns move far less, and are what these rows are for.
It also measures the library against seven others, in ten configurations, which benchmark/PEER-COMPARISON.md reads and draws conclusions from.
Run python -m benchmark for your own numbers, or python -m benchmark --write to regenerate the report.
One difference, and it is deliberate. Both validator and compiled_validator analyse the whole type before seeing any value, so they reject an unsupported type immediately:
validator(list[Callable[[int], int]]) # UnsupportedTypeError, at once
validate([], list[Callable[[int], int]]) # True — no value reached the Callablefrom typing_validation import is_valid, validated, validated_iter
is_valid([1, "a"], list[int]) # False — a boolean, at boolean prices
validated(payload, list[int]) # returns payload, for use in an expression
validated_iter(stream, Iterator[int]) # checks each item as it is yieldedis_valid deliberately builds no explanation: a caller who wants one calls validate and catches the exception.
validated_iter is not a convenience wrapper. Determining the items of a one-shot iterator consumes it, so Iterator[int] cannot check its items eagerly without destroying the value — checking them on the way past is the only honest way.
Support is all-or-nothing: if any component of a type is unsupported, the whole type is. can_validate answers up front:
>>> from typing_validation import can_validate, inspect_type
>>> can_validate(list[int])
True
>>> can_validate(tuple[int, Callable[[int], int]]) # poisoned by the Callable
Falseinspect_type returns the whole structure and names precisely what poisoned it, so "unsupported" is never opaque:
>>> node = inspect_type(tuple[int, Callable[[int], int]])
>>> [c.t for c in node.unsupported_components()]
[typing.Callable[[int], int]]NumPy array types are supported by an extension, which you enable by importing:
import typing_validation.numpy # required
from numpy.typing import NDArray
validate(np.array([1, 2], dtype=np.uint8), NDArray[np.uint8])The import is required rather than automatic, so that the supported surface never depends on whether some unrelated dependency happened to import NumPy.
A parametrised class can say how its own type arguments are validated:
class Box[T]:
@classmethod
def __validate__(cls, val, args):
return is_valid(val.item, args[0])For classes you do not own, use register_validator(cls, check).
The full API documentation is available at typing-validation.readthedocs.io.
typing_validation/— the package source.knowledge/— design documents: the architecture, and the catalogue of supported type forms.test/— the conformance suite, with the case corpus intest/cases.py.benchmark/— the benchmark suite; run it withpython -m benchmark. The machinery is inbenchmark/tools/, the generated numbers inbenchmark/REPORT.md, and the written synthesis inbenchmark/PEER-COMPARISON.md.benchmark/marshalling.pyis a separate probe, run withpython -m benchmark.marshalling, which asked whether a persistent cache for compiled validators would be worth building;benchmark/MARSHALLING.mdis its answer.docs/— the Sphinx documentation pipeline.
LGPL-3.0-or-later © Hashberg Ltd