Architecture of coola.validation¶
This page describes the design of the
coola.validation package for contributors: the invariants, the contracts between the parts, and the trade-offs of the main design choices. The rules that users rely on are in the user guide; the API is in the reference.
Overview¶
coola.validation is a core package: it imports the standard library only (NumPy scalars are
supported through the numbers ABCs they register with). It has eleven functions in three modules,
named by what the function checks: type.py (validate_isinstance, validate_int,
validate_number), comparison.py (validate_ge/gt/le/lt, validate_range, validate_finite)
and container.py (validate_not_empty, validate_in). Two private helpers own the invariants:
_type_error builds every TypeError and _compare evaluates every comparison and builds the
TypeError (incomparable value) or ValueError (comparison false), so all the messages have one
form, <name> must be <requirement>, got <value!r>. The success path is inline (a plain comparison
or an exact type test) and the helpers run on the failure path only, which keeps a call as cheap as
the code it replaces (tests/benchmarks/test_validation_benchmark.py).
The rule for the whole project: validate an argument with coola.validation; do not write
raise ValueError for a type, range or membership check, and do not add a validator that is one
isinstance or one comparison (validate_str(x) is validate_isinstance(x, str),
validate_positive(x) is validate_gt(x, 0)). See the
user guide for the properties of the package.
Design constraints¶
- Simple and easy to maintain. A function is kept only if it adds behaviour that the caller would
otherwise have to write. A wrapper such as
validate_str(x)(which isvalidate_isinstance(x, str)) orvalidate_positive(x)(which isvalidate_gt(x, 0)) would cost a docstring, an export, a doc entry and tests each, and the list of names would grow one wrapper at a time. - Core package.
coola.validationimports only the standard library (import-linter contract "Core packages must not import feature packages"). It imports NumPy or any optional library never; NumPy scalars are supported through thenumbersABCs they register with. - Cheap. Validators run in constructors and in the entry points of
hash_object,round_values,to_jsonable, ... A call costs 70 to 240 ns, and the success path must not be slower. - One home for argument checks. The checks that sit next to their caller (
validate_seed,validate_hash_length,rounding.common.validate_arguments,_validate_on_duplicate,_validate_sep) use the validators of this package where they can, so the same mistake gives the same message everywhere.
Architecture in detail¶
Two helpers own the invariants; a validator only states its constraint.
validate_xxx(value, ..., *, name="value")
|
|-- type validators -> _type_error(name, expected, value) builds the TypeError
|-- comparison validators-> _compare(test, value, bounds, name, req) evaluates, converts, builds
|-- container validators -> _type_error / ValueError with a bounded listing
v
None, or TypeError / ValueError with the message
"<name> must be <requirement>, got <value!r>"
The message format is one sentence, the same for every validator:
| Case | Message |
|---|---|
| wrong type | <name> must be <expected>, got <type name> (count must be an int, got str) |
| wrong value | <name> must be <requirement>, got <value!r> (count must be greater than 0, got -1) |
| not comparable | <name> cannot be compared with <bound>, got <value!r> |
| bounds make an empty range | <name>: the range [<low>, <high>] is empty (a ValueError) |
The expected type is written once, by _type_name: a class gives its __qualname__ (int), a tuple
or a union gives int or str, an ABC gives its name (Mapping). validate_int and
validate_number pass a phrase (an int, a real number) because the article matters.
The helpers¶
def _type_error(value, expected: str, name: str) -> NoReturn:
msg = f"{name} must be {expected}, got {type(value).__qualname__}"
raise TypeError(msg)
def _compare(test: Callable[[Any], Any], value, bounds: str, name: str, requirement: str) -> None:
try:
is_valid = bool(test(value))
except (TypeError, ValueError) as exc: # incomparable, or the truth of an array is ambiguous
msg = f"{name} cannot be compared with {bounds}, got {value!r}"
raise TypeError(msg) from exc
if not is_valid:
msg = f"{name} must be {requirement}, got {value!r}"
raise ValueError(msg)
_compare is the one evaluation. The four bound checks, the range and the finiteness check call it
with a lambda/operator.partial test; the closure costs about 40 ns, below the margin of the gate.
Both helpers are private to coola.validation: they are an implementation detail, and a package that
wants the same format writes a call to a validator.
The functions¶
from coola.validation import ... (14 names):
| Family | Function | Raises | Notes |
|---|---|---|---|
| Type | validate_isinstance(value, cls, *, name) |
TypeError |
general; cls is a type, a tuple of types or a union; bool is an int here (documented) |
| Type | validate_int(value, *, name) |
TypeError |
the only shortcut with its own rule: bool rejected; numpy.int64 rejected (strict) |
| Type | validate_number(value, *, name) |
TypeError |
numbers.Real, bool rejected; NumPy scalars, Fraction accepted; Decimal rejected (not a Real) |
| Bound | validate_ge, validate_gt, validate_le, validate_lt (value, bound, *, name) |
TypeError (incomparable), ValueError |
duck-typed; the sign checks are validate_ge(x, 0) ... |
| Range | validate_range(value, low, high, *, low_inclusive=True, high_inclusive=True, name) |
TypeError, ValueError |
one function for open, closed and half-open ranges; closed by default (the form most call sites use) |
| Finite | validate_finite(value, *, name) |
TypeError (not a number), ValueError |
calls validate_number first: bool, None, str, arrays give a named TypeError |
| Container | validate_not_empty(value, *, name) |
TypeError (no len), ValueError |
len(value) == 0; an iterator or a generator has no len and is a TypeError, not consumed |
| Container | validate_in(value, valid, *, name) |
ValueError |
an unhashable value is not in a set: ValueError, not a raw TypeError; listing bounded |
There are no type shortcuts such as validate_str or validate_list (use validate_isinstance(value,
X)), no sign shortcuts such as validate_positive (use validate_gt(value, 0)), and no separate
open, closed and half_open range functions (use validate_range). The synonyms are the part of
a package that grows one name at a time; without them the list of names can be read in ten seconds and
the rule to add one is written down. Keeping validate_positive and validate_non_negative only would
need an explanation for the missing negative.
The rule to add a function (written in the module docstring and in the guide): 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; validate_finite for the overflow of math.isfinite and the
NaN. validate_str does not: validate_isinstance(x, str) is the same line.
Modules¶
Three modules, named by what the function checks:
| Module | Contains | Why |
|---|---|---|
type.py |
_type_error, validate_isinstance, validate_int, validate_number |
what a value is |
comparison.py |
_compare, validate_ge/gt/le/lt, validate_range, validate_finite |
what a value compares to (finite = -inf < x < inf) |
container.py |
validate_not_empty, validate_in |
what a value holds or belongs to |
__init__.py re-exports the 14 names; __all__ is alphabetical. docs/docs/refs/validation.md lists
the three submodules.
Dependents¶
The messages have one format, so the tests of the callers that match a message match this format.
| Package | Use |
|---|---|
utils (bloom_filter, batching, text_diff, format, secret, lru) |
validate_gt(x, 0), validate_ge(x, 0). |
equality.config, equality.tolerance, summary.config |
The same, and Tolerance uses validate_number, a bound and validate_finite. |
identifier (objectid, ulid, uuid7, snowflake, checksummed) |
validate_range(...), validate_isinstance(x, str), validate_gt(x, 0). _validate_sep has its own checks (the Crockford rule) and calls validate_isinstance(sep, str). |
nested.keys, nested.mapping |
validate_isinstance(x, Mapping); validate_in(on_duplicate, ("raise", "first", "last", "prefix"), name="on_duplicate") (the valid values are a tuple, so the message is stable). |
random.seed.validate_seed |
validate_int(seed, name="seed") then validate_range(seed, 0, MAX_SEED, high_inclusive=False, name="seed"); it lives in coola.random, which owns the 2**63 bound. |
hashing.engine.validate_hash_length |
validate_int and validate_range(length, 2, 128); the parity rule (length % 2) is local, and the hot-path fast test comes first (it is measured). |
rounding.common.validate_arguments |
validate_in(mode, MODES, name="mode"), validate_int(decimals), validate_ge(decimals, 0). |
registry.registry, registry.type (_validate_key) |
Not migrated: the message Expected a type as key is part of its own contract and tests. A candidate for a later item. |
No dependent imports anything private from coola.validation.
Design choices and trade-offs¶
numpy.int64 and numpy.float32 are not ints and floats for the type validators, but numbers for
validate_number. A type annotation int rejects numpy.int64 for a type checker, validate_int
follows the annotation, and a caller that accepts NumPy scalars writes validate_isinstance(x, (int,
np.integer)) or uses validate_number. numbers.Integral for validate_int would accept NumPy ints
(still rejecting bool by the explicit test) with a one-line change, but it makes the strictness
depend on the registration of the ABCs, and an int argument that is later used as range(n) or a
shift accepts numpy.int64 anyway. The strict rule can be revisited if a user reports a real case.
"Type then bound" is two calls. validate_int(x, min=0) would remove 8 lines at the call sites and
add options to the most used function, with a second message path. Two calls are explicit, and the
order of the errors (a TypeError for "a" before a ValueError for -1) is visible. Revisit if a
call site needs three constraints on one argument.
An empty range raises. validate_range(x, 1, 0) cannot succeed; it is a programming error, and
blaming the value would send the reader to the wrong argument. The ValueError about the bounds costs
one comparison.
validate_in with an unhashable value is a ValueError. From the point of view of the caller, []
is not one of the valid modes; the fact that the container is a set is an implementation detail. A
TypeError would be right only if valid were not a container, and that case still raises (the bug of
the caller). A valid that is a string with a non-string value (1 in "abc" raises TypeError) is
reported as a value error, and the message shows both.
The project-wide rule lives in this package's docstring, the guide and CLAUDE.md: "Validate an
argument with coola.validation; do not write raise ValueError for a type, range or membership check,
and do not add a validator that is one isinstance or one comparison." Without the rule, each package
grows private copies with their own messages.
No decorator or Annotated metadata. A schema layer (pydantic, attrs validators, beartype) does it
better, and none of the call sites needs it. The package is a set of functions that a constructor
calls.
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 (lazy import). - Coercion (
ensure_int,to_float). Validators never convert (property "silent on success"). registry._validate_key: local toregistry.- A
validate_callable,validate_path,validate_enum, ...: each isvalidate_isinstance(x, Callable)orvalidate_in(x, tuple(Enum)); they are added only if they carry a rule of their own (see the rule above).