Skip to content

Validation

📖 This page describes the coola.validation package, which provides a small set of functions to validate function/method arguments and raise a standard exception with a clear error message when a constraint is not met.

Prerequisites: You'll need to know a bit of Python. For a refresher, see the Python tutorial.

Overview

The package has eleven functions in three families:

Family Functions Checks what a value...
Type validate_isinstance, validate_int, validate_number is
Comparison validate_ge, validate_gt, validate_le, validate_lt, validate_range, validate_finite compares to (a bound, a range, inf)
Container validate_not_empty, validate_in holds, or belongs to

The Properties section lists the rules that every function follows.

Every function takes the value to validate as its first argument, and an optional name keyword argument used to identify the value in the error message. On success, the function returns None; on failure, it raises an exception. The Properties section lists the rules that every function follows.

A validator exists only if it states a constraint that isinstance or one comparison cannot state without a special case. validate_int exists for the bool case, validate_number for the numbers.Real plus bool case, and validate_finite for the overflow of math.isfinite and the NaN. There is no validate_str (it is validate_isinstance(x, str)) and no validate_positive (it is validate_gt(x, 0)).

Validators are not a schema library: one value, one constraint. To combine constraints, call several validators; the first error is raised.

>>> from coola.validation import validate_gt, validate_int
>>> count = 3
>>> validate_int(count, name="count")
>>> validate_gt(count, 0, name="count")

Type

>>> from coola.validation import validate_isinstance
>>> validate_isinstance(1, int)
>>> validate_isinstance(1, (int, float))
>>> validate_isinstance(1, int | float)
>>> validate_isinstance("abc", int, name="count")
Traceback (most recent call last):
    ...
TypeError: count must be an instance of int, got str
>>> validate_isinstance(1.5, int | str, name="count")
Traceback (most recent call last):
    ...
TypeError: count must be an instance of int or str, got float

validate_int rejects bool, and validate_number checks that a value is a real number (a numbers.Real) and rejects bool:

>>> from coola.validation import validate_int, validate_number
>>> validate_int(1)
>>> validate_number(0.5)
>>> validate_int(True, name="count")
Traceback (most recent call last):
    ...
TypeError: count must be an int, got bool
>>> validate_number("abc", name="rtol")
Traceback (most recent call last):
    ...
TypeError: rtol must be a real number, got str

The type checks are strict, they do not convert the value:

  • bool is rejected by validate_int and validate_number, but it is accepted by validate_isinstance(x, int) and by the comparisons (validate_ge(True, 0) is valid).
  • NumPy scalars are accepted by validate_number (they register with numbers.Real, so coola.validation does not import NumPy), and Fraction too. decimal.Decimal is rejected, it is not a numbers.Real.
  • validate_int rejects numpy.int64. Use validate_isinstance(x, (int, numpy.integer)) or validate_number to accept it.
  • validate_isinstance(x, float) accepts a float subclass, hence a numpy.float64 but not a numpy.float32.

Container

>>> from coola.validation import validate_not_empty
>>> validate_not_empty([1, 2, 3])
>>> validate_not_empty([], name="my_list")
Traceback (most recent call last):
    ...
ValueError: my_list must not be empty

An object with no length (an int, a generator) is a TypeError. A generator is never consumed.

>>> from coola.validation import validate_in
>>> validate_in("a", ("a", "b", "c"))
>>> validate_in("d", ("a", "b", "c"), name="mode")
Traceback (most recent call last):
    ...
ValueError: mode must be one of ('a', 'b', 'c'), got 'd'

A value that cannot be tested (an unhashable value against a set) is not valid: it is a ValueError, like any other value that is not in valid. A valid that is not a container is a bug of the caller and its TypeError propagates. The listing of valid in the message is limited to 20 items, so the message stays short. The order of a set is not stable: pass a tuple for a stable message.

Comparison

validate_ge/validate_le check inclusive bounds, and validate_gt/validate_lt check exclusive bounds. The sign checks are comparisons with zero:

>>> from coola.validation import validate_ge, validate_gt, validate_le, validate_lt
>>> validate_ge(1, 0)
>>> validate_gt(1, 0)
>>> validate_le(0, 1)
>>> validate_lt(0, 1)
>>> validate_gt(0, 0, name="count")
Traceback (most recent call last):
    ...
ValueError: count must be greater than 0, got 0

The comparisons are duck-typed: they accept any value that supports the comparison (Decimal, date, ...). A value that cannot be compared (None, a str against a number, an array with several items) raises a TypeError. A NaN is never valid, since every comparison with a NaN is false: it raises a ValueError.

>>> validate_ge(None, 0, name="count")
Traceback (most recent call last):
    ...
TypeError: count cannot be compared with 0, got None

validate_range checks low <= value <= high. The flags low_inclusive and high_inclusive exclude a bound, which gives an open range or a half-open range. A range that no value can satisfy (for example low > high) is a bug of the caller and raises a ValueError about the bounds:

>>> from coola.validation import validate_range
>>> validate_range(1, 0, 1)
>>> validate_range(0.5, 0, 1, low_inclusive=False, high_inclusive=False)
>>> validate_range(0, 0, 1, high_inclusive=False)
>>> validate_range(1, 0, 1, high_inclusive=False, name="ratio")
Traceback (most recent call last):
    ...
ValueError: ratio must be in the range [0, 1), got 1
>>> validate_range(0.5, 1, 0, name="ratio")
Traceback (most recent call last):
    ...
ValueError: ratio: the range [1, 0] is empty

validate_finite rejects infinite values and NaN. It checks the type first, so a value that is not a real number is a TypeError:

>>> from coola.validation import validate_finite
>>> validate_finite(1.5)
>>> validate_finite(float("inf"), name="lr")
Traceback (most recent call last):
    ...
ValueError: lr must be finite, got inf
>>> validate_finite("a", name="lr")
Traceback (most recent call last):
    ...
TypeError: lr must be a real number, got str

Properties

Property Statement
Silent on success Every function returns None when the value is valid. It never returns or converts the value.
Standard exceptions A wrong type raises TypeError. A wrong value raises ValueError. No custom exception class. No other exception type escapes.
Informative message <name> must be <requirement>, got <value>: the value is shown with repr, a type by its name. The size of the message is bounded.
Pure Validators do not modify the value or the container and have no side effect. They never consume an iterator.
Keyword-only name name can only be passed by keyword, so a call cannot mix it up with a bound or a class.
Strict types, duck compare The type checks are strict (no conversion, bool is not accepted by validate_int and validate_number). The comparisons accept whatever supports <, >.
NaN is never valid A comparison with a NaN is false, so the bound and range checks raise ValueError on a NaN, and validate_finite rejects it.
Order of the checks A validator checks the type first, then the value.
Core only The package imports the standard library only.

Not guaranteed:

  • Validators are not a schema library. One value, one constraint. Combine constraints by composing calls (validate_int(x) then validate_gt(x, 0)): there is no validate_all, no decorator and no error aggregation.
  • The order of the values in a message built from a set (the listing of the valid values of validate_in). Pass a tuple for a stable message.
  • float subclasses are accepted by validate_isinstance(x, float), hence a numpy.float64 too (it subclasses float) while a numpy.float32 is not. Use validate_number to accept both.

Adding a validator

A validator exists only if it states a constraint that isinstance or one comparison cannot state without a special case. validate_int exists for the bool case, validate_number for the numbers.Real plus bool case, and validate_finite for the overflow of math.isfinite and the NaN. There is no validate_str: validate_isinstance(x, str) is the same line. Validate an argument with coola.validation; do not write raise ValueError for a type, range or membership check.

Not Covered

  • Aggregated errors (all the invalid fields of a config in one exception): the call sites raise on the first error, as the standard library does.
  • Custom exception classes and error codes: the two builtin types are the contract.
  • Validation of nested structures and of array shapes or dtypes. validate_isinstance(x, np.ndarray) works; a dtype check belongs to the package that owns the array library.
  • Coercion (ensure_int, to_float): validators never convert.
  • Decorators and Annotated metadata: use a schema library (pydantic, attrs validators, beartype). The package is a set of functions that a constructor calls.
  • validate_int(x, min=0): the type and the bound are two calls, so the order of the errors (a TypeError for "a" before a ValueError for -1) is visible.
  • A validate_callable, validate_path or validate_enum: each is validate_isinstance(x, Callable) or validate_in(x, tuple(Enum)).

Shortcuts that do not exist

The package has no function for a check that isinstance or one comparison already states. Use:

Check Use
a bool, a float, a str, a list, a dict, ... validate_isinstance(x, bool), validate_isinstance(x, float), ...
a Mapping validate_isinstance(x, Mapping)
a positive or a non-negative number validate_gt(x, 0), validate_ge(x, 0)
a negative or a non-positive number validate_lt(x, 0), validate_le(x, 0)
a closed range validate_range(x, low, high)
an open range validate_range(x, low, high, low_inclusive=False, high_inclusive=False)
a half-open range validate_range(x, low, high, high_inclusive=False)

See Also

  • coola.identifier: Uses these validators to check the options of its generators.