Defining custom parsers
Validobj provides a mechanism to allow supplementing the predefined processing logic, by annotating types with functions that are invoked to validate the input.
Note
The custom module typing.Annotated
under the hood, so the
resulting annotations might be processed by static type checkers. Because of
that, it works only with Python 3.9 onwards.
Custom parsers are created by wrapping a properly annotated validation function with
validobj.custom.Parser()
:
Define a function with one input with the processing logic. For example:
import typing import decimal from validobj import parse_input, ValidationError def to_decimal(inp): try: return decimal.Decimal(inp) except decimal.InvalidOperation as e: raise ValidationError("Invalid decimal") from e
The custom function should raise an instance of
validobj.errors.ValidationError
to indicate validation failure. Other exceptions will be treated as programming errors.
Add type annotations to the input parameter and return type:
def to_decimal(inp: str|float]) -> decimal.Decimal: ...
The Validobj logic is used to cast the input into the type annotated in the input parameter. The custom function will only be called if the cast succeeds. The return annotation of the custom function is not checked or enforced by Validobj, but might be useful for static type checkers.
Wrap the function with
validobj.custom.Parser()
to create a type annotation:MyDecimal = Parser(to_decimal)
MyDecimal
can be used whenever we want to use Validobj obtain a decimal from a string or a number.
Use the type annotation with Validobj:
>>> parse_input(0.5, MyDecimal) Decimal('0.5')
The validators can also be create as closures:
>>> from validobj.custom import Parser
>>> from validobj import parse_input, ValidationError
>>> def make_range(a, b):
... def parser(n: float) -> float:
... if not a <= n < b:
... raise ValidationError(f"Expecting {a} <= n < {b}, but n={n}")
... return n
... return parser
...
>>> RangeFloat = Parser(make_range(0, 10))
>>> parse_input(3.14, RangeFloat)
3.14
>>> parse_input(-0.1, RangeFloat)
Traceback (most recent call last):
...
ValidationError: Expecting 0 <= n < 10, but n=-0.1
The custom logic can be used to add restrictions on the input to some existing type, while taking advantage of the fact that the input is already processed into that type.
>>> import dataclasses
>>> @dataclasses.dataclass
... class Point:
... x: float
... y: float
...
>>> def in_unit_circle_point(p: Point) -> Point:
... if not p.x**2 + p.y**2 < 1:
... raise ValidationError("Point outside unit circle")
... return p
...
>>> UnitCirclePoint = Parser(in_unit_circle_point)
>>> parse_input({"x": 0.4, "y": 0.2}, UnitCirclePoint)
Point(x=0.4, y=0.2)
>>> parse_input({"x": 0.4, "y": "ups"}, UnitCirclePoint)
Traceback (most recent call last):
...
WrongFieldError: Cannot process field 'y' of value into the corresponding field of 'Point'
>>> parse_input({"x": 0.4, "y": 0.98}, UnitCirclePoint)
Traceback (most recent call last):
...
ValidationError: Point outside unit circle
validobj.custom.Parser()
While the result of validobj.custom.Parser()
is an annotation
compatible with static type checkers. Specifically:
typing.Annotated[
<return type>,
validobj.custom.InputType(
<input type of parameter>
),
validobj.custom.Validator(
<function>
)
]
That is, the two metadata parameters
accompanying the type of the processed object should be the type of the input
wrapped in validobj.custom.InputType
and the function doing the
validation, wrapped in validobj.custom.Validator
.
>>> from validobj.custom import InputType, Validator
>>> UnitCirclePoint = typing.Annotated[Point, InputType(Point), Validator(in_unit_circle_point)]
>>> parse_input({"x": 0.4, "y": 0.5}, UnitCirclePoint)
Point(x=0.4, y=0.5)