Schema language¶
A schema denotes a set of Python values. This page lists every form valgebra reads and the set it denotes. The primary notation is standard typing; compact native forms and the combinators are alternatives for the same sets.
A form this page does not list¶
The list is exhaustive, so a typing form absent from it is one valgebra does not
read. It raises NotImplementedError when the validator is built, not when a
value arrives — a schema that cannot be read never becomes one that quietly
admits everything.
from collections.abc import Mapping
from valgebra import Validator
try:
Validator(Mapping[str, int])
except NotImplementedError as error:
assert "unsupported typing form" in str(error)
The abstract-collection generics are the ones a reader is most likely to
reach for: Mapping[K, V], Sequence[T], Set[T], Iterable[T], and the
subscripted concrete classes such as deque[T] and OrderedDict[K, V]. These
are not built. Whether they should be is open; nothing here rules them out.
type[T] is refused as well, and is a different question: it constrains a value
that is itself a class, where every form above constrains a container's
contents.
Where the value really is a dict or a list, the builtin form denotes the set
you want: dict[K, V] for Mapping[K, V], list[T] for Sequence[T]. Where it
is not — a UserDict, a deque — no form here denotes it, and a
predicate refinement over the class is the available
expression.
Scalars¶
| Schema | Denotes |
|---|---|
int |
every int instance |
float |
every float instance |
str |
every str instance |
bytes |
every bytes instance |
bool |
{True, False} |
None |
{None} |
The set relationships follow Python's own, exactly:
from valgebra import Validator
# bool is a subclass of int, so True and False are ints
assert Validator(int).is_valid(True)
# int does not subclass float, so an int is not a float
assert not Validator(float).is_valid(1)
assert Validator(float).is_valid(1.0)
Any versus object¶
Both are the top of the lattice (anything): every value. They build the
same schema, and the only difference is the one repr gives back, which is what
you wrote (the algebra).
from typing import Any
from valgebra import Validator
assert Validator(object).is_valid(["anything", 1, None])
assert Validator(Any).is_valid(object())
assert Validator(Any) == Validator(object)
assert repr(Validator(Any)) == "Any"
Collections¶
| Schema | Denotes |
|---|---|
list[T] |
lists whose every element is in T |
set[T] |
sets whose every element is in T |
frozenset[T] |
frozensets whose every element is in T |
dict[K, V] |
dicts whose keys are in K and values in V |
tuple[A, B] |
length-2 tuples with A then B |
tuple[T, ...] |
tuples of any length, every element in T |
tuple[A, B, ...] |
a fixed prefix A, then zero or more B (see below) |
tuple[A, *tuple[B, ...]] |
the same, spelled by unpacking (3.11+) |
typing.List, typing.Tuple, ... |
the class each aliases, bare or parametrized |
from valgebra import Validator
assert Validator(list[int]).is_valid([1, 2, 3])
assert Validator(dict[str, int]).is_valid({"a": 1})
assert Validator(tuple[int, str]).is_valid((1, "a"))
assert Validator(tuple[int, ...]).is_valid((1, 2, 3))
assert Validator(tuple[str, int, ...]).is_valid(("x", 1, 2))
Native forms¶
A native form exists only where standard typing cannot spell the set: the
list literal carries the sequence shapes typing has no syntax for. Everything a
typing annotation already expresses is written that way — set[T], not {T};
tuple[A, B], not (A, B); both literals are rejected with a message pointing
to the typing spelling.
| Native form | Denotes |
|---|---|
[T] |
list[T] — a homogeneous list (the single-element idiom) |
[T, ...] |
list[T] — homogeneous, written with the tail marker |
[A, B] |
a fixed-length list, matched positionally (list[A, B] is illegal typing) |
[A, B, ...] |
a fixed prefix, then a repeated tail (see below) |
{K: V} |
dict[K, V] |
{"key": T, "key2?": T} |
a record (see below) |
any constant c |
Literal[c] |
from valgebra import Validator
assert Validator([int]).is_valid([1, 2]) # homogeneous list[int]
assert Validator([int, str]).is_valid([1, "a"]) # fixed-length list
assert not Validator([int, str]).is_valid([1]) # wrong length
assert Validator({str: int}).is_valid({"a": 1}) # dict[str, int]
assert Validator("active").is_valid("active") # the literal "active"
A fixed-length list is matched positionally: element i must satisfy the
ith schema and the length must match. typing cannot spell it (list[A, B] is
illegal), which is the reason the list literal carries the shape; a fixed-length
tuple is the typing tuple[A, B], and the container is part of the type, so a
list is never a member of the tuple form and vice versa.
Prefix and repeated tail¶
A sequence schema is, in general, a regular expression over element types: a
fixed positional prefix followed by an optional repeated tail. A trailing ...
repeats the element just before it, so [T, ...] (any number of T) is the
prefix-free case. The same shape is available for tuples with tuple[A, B, ...];
the container is part of the type, so a tuple is never a member of the list form
and vice versa.
| Form | Denotes |
|---|---|
[A, B, ...] |
a list: an A, then zero or more B |
[T, T, ...] |
a non-empty list of T (at least one) |
tuple[A, B, ...] |
a tuple: an A, then zero or more B |
tuple[A, *tuple[B, ...]] |
the same tuple, spelled by unpacking (3.11+) |
An unpacked variadic tuple says the prefix-and-tail shape the way PEP 646
spells it, and Unpack[tuple[B, ...]] is the same thing written out. An unpacked
fixed tuple splices its elements in, so tuple[A, *tuple[B, C]] is
tuple[A, B, C]. What a sequence cannot carry is an element after the
repeating tail — tuple[*tuple[int, ...], str] names a set this algebra does not
spell — so that form is rejected rather than read as something else.
from valgebra import Validator
prefixed = Validator([str, int, ...]) # a str, then zero or more ints
assert prefixed.is_valid(["x"])
assert prefixed.is_valid(["x", 1, 2])
assert not prefixed.is_valid([1]) # the prefix must be a str
non_empty = Validator([int, int, ...]) # at least one int
assert non_empty.is_valid([1])
assert not non_empty.is_valid([])
tup = Validator(tuple[str, int, ...]) # the same shape, as a tuple
assert tup.is_valid(("x", 1, 2))
assert not tup.is_valid(["x", 1, 2]) # a list is not a member of the tuple form
unpacked = Validator(tuple[str, *tuple[int, ...]]) # the PEP 646 spelling
assert unpacked.is_valid(("x", 1, 2))
assert repr(unpacked) == "tuple[str, int, ...]"
Literals¶
Literal[...] denotes a typed singleton: a value is a member iff it has the
same type as the literal and is equal to it. The same-type rule keeps
Literal[1], Literal[True], and Literal[1.0] distinct, even though Python's
== conflates them:
from typing import Literal
from valgebra import Validator
assert Validator(Literal[1]).is_valid(1)
assert not Validator(Literal[1]).is_valid(True)
assert not Validator(Literal[1]).is_valid(1.0)
Literal[nan] denotes nothing. Membership is equality, and a nan is equal
to nothing at all — itself included — so the set has no member. That is not a
gap in the check: the set is decided empty, and a float still admits nan
as it always did. Write float and a predicate if what you mean is "the not-a-
number value".
from typing import Literal
from valgebra import Validator
nan = float("nan")
assert Validator(Literal[nan]).is_empty() # equality admits nothing
assert not Validator(Literal[nan]).is_valid(nan)
assert Validator(float).is_valid(nan) # the kind still holds it
A string inside a generic is a forward reference, and is refused¶
The fallback reads a bare value as a literal, and that reading stops at the
argument of a typing form. list["Account"] is a forward reference to a type
named Account, which the typing spec resolves against the namespace the
annotation was written in — a namespace valgebra does not have, because it is
handed the runtime object rather than the source. Reading the string as a literal
instead would build a list of the word "Account", a schema that refuses what
the annotation admits, so the position is refused:
from valgebra import Validator
try:
Validator(list["Account"])
except NotImplementedError as error:
assert "forward reference" in str(error)
# Say the type, or say the value.
assert Validator(list[int]).is_valid([1])
assert Validator(["active"]).is_valid(["active"]) # a list of that literal
A class's own annotations are a different matter: Validator(SomeClass) resolves
their strings for you (see classes).
Anything unrecognized is a literal¶
The literal form is also the fallback: an object the frontend does not read
as one of the forms above becomes Literal[that object], so Validator(x)
denotes {x} for any x valgebra has no other reading for. That is what makes
Validator("active") mean the string rather than an error, and it applies to a
function, a module or an instance just the same:
from valgebra import Validator
def positive(value):
return value > 0
schema = Validator(positive)
assert repr(schema).startswith("Literal[")
assert schema.is_valid(positive) # the function object itself
assert not schema.is_valid(1) # not a predicate: 1 is not that function
A callable is the case worth naming, because the same callable is a
predicate one position inward, as Annotated metadata — see
refinements. At the top
level there is no base for it to narrow, so the fallback applies and the schema
denotes the single function object.
The forms that are refused rather than read as a literal¶
The fallback is for an object with no other reading. A typing form the spec does give a meaning to is a different case: read as a literal it would denote the form object itself, which no value a caller has belongs to, so the schema would refuse everything without saying why. Each of these is refused at build with a message instead:
| Form | Why it has no set |
|---|---|
Self |
names the enclosing class, which a schema is built without |
LiteralString |
a property of where a string came from, which a value does not carry |
TypeVar, ParamSpec, TypeVarTuple |
a variable stands for a type and is not one |
Final, ClassVar |
a declaration about a name, not about a value |
Unpack[X] |
binds element types into a tuple[...], so it has no meaning alone |
a user Generic[T] parametrisation |
the parameter is erased at runtime, so the type argument narrows nothing |
bare Protocol, and a Protocol without @runtime_checkable |
membership is isinstance, which such a class refuses to answer |
| a set or frozen set literal | {int} and frozenset({int}) name containers, which are set[T] and frozenset[T] |
| a tuple literal | (A, B) is tuple[A, B]; the list literal [A, B] is the fixed-length list |
| a frozen dict literal | frozendict(a=int) names a record, which the dict literal {"a": int} spells (Python 3.15+) |
A qualifier is the exception that is read rather than refused:
Required[X], NotRequired[X] and ReadOnly[X] survive hint resolution
because field metadata is kept, so the frontend unwraps them and compiles the
type they qualify. Written outside a record they are unwrapped just the same,
which makes Validator(Required[int]) the same schema as Validator(int).
Unions and Optional¶
X | Y and Optional[X] denote the union of the member sets:
from typing import Optional
from valgebra import Validator
assert Validator(int | str).is_valid("x")
assert Validator(Optional[int]).is_valid(None)
Records¶
A dict literal with all-string keys is a record: named fields, closed by
default. A required field's key must be present with a matching value; a trailing
? on the key name marks it optional. A closed record admits no key outside the
declared names.
from valgebra import Validator
user = Validator({"name": str, "age?": int})
assert user.is_valid({"name": "Ada"}) # optional key absent
assert user.is_valid({"name": "Ada", "age": 36})
assert not user.is_valid({"name": "Ada", "x": 1}) # closed: no extra keys
A key name that ends in a question mark¶
The suffix is stripped once, so a name that ends in ? is written with one
more: {"page??": int} is the optional key page?.
from valgebra import Validator
assert Validator({"page??": int}).is_valid({"page?": 1})
assert Validator({"page??": int}).is_valid({}) # still optional
A field is named once. {"a": int, "a?": str} is two dict keys and one field
name, which asks the key to be required and optional at once, so it is refused
rather than built into a record admitting nothing:
from valgebra import Validator
try:
Validator({"a": int, "a?": str})
raise AssertionError("expected a rejection")
except ValueError as error:
assert "declared twice" in str(error)
A required key ending in ? has no dict-literal spelling: every trailing
? is the marker, so there is no string that reads back as one. Write it as a
TypedDict through the functional syntax, where the key is taken literally and
required-ness comes from the class:
from typing import TypedDict
from valgebra import Validator
Query = TypedDict("Query", {"page?": int})
assert Validator(Query).is_valid({"page?": 1})
assert not Validator(Query).is_valid({}) # required
That is the one form repr cannot round-trip: it renders the field as
{'page?': int, ...}, which reads back as the optional key page
(boundaries).
Open the record with open (undeclared keys admitted) or re-close it with
close:
from valgebra import Validator
closed = Validator({"name": str})
assert not closed.is_valid({"name": "Ada", "extra": 1})
assert closed.open().is_valid({"name": "Ada", "extra": 1})
Openness is the default of the keys no clause claims. A clause names a
region of the key space — str: int claims the str keys — and the two
transforms decide the keys the clauses leave over and nothing else. A record
claims no region, which is why opening one frees every key and closing one
refuses every key; that is the common case, not the general rule. A mapping
claims one, so opening dict[str, int] keeps what a str key maps to and frees
only the key-types beside it.
They apply at any depth, including inside a recursive definition. They are
projections rather than inverses: applying either twice changes nothing the
second time, and close after open is at most close -- it returns the
regions open freed wherever opening leaves the term's own names and clauses
standing, which is everywhere but the two shapes below.
from valgebra import Validator
# A mapping: the region its clause claims is not the transform's to touch.
mapping = Validator(dict[str, int])
assert not mapping.is_valid({1: "x"}) # no clause claims an int key
assert mapping.open().is_valid({1: "x"}) # opened, that region is free
assert not mapping.open().is_valid({"a": "x"}) # and a str key still maps to int
assert mapping.open().close() == mapping
# `{}` declares no field and claims no region: closed it admits the empty dict
# alone, opened it admits every dict.
assert Validator({}).is_valid({}) and not Validator({}).is_valid({"x": 1})
assert Validator({}).open().is_valid({"x": 1})
# And a clause reads the same whether or not a field is declared beside it.
beside_a_field = Validator({"name": str, str: int})
assert beside_a_field.close().is_equivalent(beside_a_field)
assert beside_a_field.open().is_valid({"name": "Ada", 7: "free"})
assert not beside_a_field.open().is_valid({"name": "Ada", "count": "not an int"})
Where it is not a round trip, the reason is that open normalises, and
both normalisations are forced. A declared name admitting everything says what
the catch-all an opening writes already says, so it goes -- and it has to, or
{"a?": anything} and {}, one set once opened, would close to two. Two
clauses carrying one value are one clause over the union of their keys, so
opening dict[str, anything] gives a single clause over every key -- and that
has to be so too, since the long spelling is keyed by a complement, a shape
the set representation declines. Neither is recoverable,
so closing afterwards lands on the smaller term:
from valgebra import Validator
record = Validator({"a?": object})
assert record.close().is_valid({"a": 1}) # closing it alone keeps the name
assert record.open().is_valid({"a": 1})
assert not record.open().close().is_valid({"a": 1}) # the name is gone
assert record.open().close() == Validator({})
mapping = Validator(dict[str, object])
assert repr(mapping.open()) == "dict[anything, anything]" # two clauses, folded
assert not mapping.open().close().is_valid({"a": 1})
dict[str, int] above is the ordinary case: its clause carries a value that is
not the top, nothing folds, and the round trip is exact.
To free some key-types and constrain others without open, write the
permissive clause yourself as the complement of the keys you constrained —
which is what open writes when the regions it frees are not the whole of what
is left:
from valgebra import Validator, anything, complement
partly_open = Validator({"name": str, str: int, complement(Validator(str)): anything})
assert partly_open.is_valid({"name": "a", "count": 1})
assert partly_open.is_valid({"name": "a", 7: object()}) # no clause claims it
assert not partly_open.is_valid({"name": "a", "count": "not an int"})
Under a complement, the direction reverses¶
A record reached through a complement is rewritten like any other. Opening it
makes the complemented set larger, which makes the complement — and so the whole
schema — smaller:
from valgebra import Validator, anything, complement
not_a_k_record = complement(Validator({"k": anything}))
schema = Validator({"x": not_a_k_record})
value = {"x": {"k": 1, "z": 2}}
# `{"k": 1, "z": 2}` is not a closed `{k: …}` record, so it is in the complement.
assert schema.is_valid(value)
# Opening admits it to the inner record, so it leaves the complement.
assert not schema.open().is_valid(value)
assert repr(schema.open()) == (
"{'x': complement({'k': anything, anything: anything}), anything: anything}"
)
close reverses under a complement for the same reason: it narrows the inner
record, which widens the schema.
So "open admits more" holds of the record the transform rewrites, and of the
whole schema only where no complement stands between them. Under a negation the
two swap. The practical consequence is that adding or removing an .open()
inside a complement changes the schema in the direction opposite to the one
the name suggests, and the change is invisible from outside unless you check —
repr shows it, as above.
Heterogeneous maps and catch-alls¶
A dict schema's string keys are named fields; any other key is a schema that keys a default clause for the rest. One form therefore expresses records, mappings, and their combination: several schema keys give a heterogeneous map whose value type depends on which key schema matches, and named fields plus a schema key give a record with a typed catch-all. Named fields take precedence over the catch-all.
Clauses are a disjunction, not a precedence list: a key that is not a named field is admitted when some clause matches both it and its value. So overlapping key schemas widen what the map admits rather than the earlier one winning, and writing them in a different order does not change the schema's meaning.
from valgebra import Validator
# str keys map to ints, int keys map to strs
hetero = Validator({str: int, int: str})
assert hetero.is_valid({"a": 1, 2: "b"})
assert not hetero.is_valid({"a": "x"}) # a str key needs an int value
# a record whose every other key must be an int
extensible = Validator({"name": str, str: int})
assert extensible.is_valid({"name": "Ada", "age": 36})
assert not extensible.is_valid({"name": "Ada", "age": "old"})
The one shape that separates a clause from a field is a literal-keyed clause
beside a kind clause that covers the same key. Both clauses read "k", so
"k" may carry what either admits, and the schema is the same set as the
record whose optional field takes both; the field spelling is a different set,
since a field is read instead of the clauses:
from typing import Literal
from valgebra import Validator
both = Validator({Literal["k"]: str, str: int})
assert both.is_valid({"k": "x"}) # the literal clause reads it
assert both.is_valid({"k": 1}) # and so does the str clause
assert not both.is_valid({"k": 1.5}) # neither does
assert both.is_equivalent({"k?": str | int, str: int})
field = Validator({"k": str, str: int})
assert not field.is_valid({"k": 1}) # the field is read, not the clauses
assert field.relation_to(both) == "subset"
A key schema names whole types, not narrowed ones¶
A clause's key says which keys it governs, and that must be a type —
str, int, a union of them, Any — or a Literal, which names the keys one
by one. A key narrowed by a constraint is refused where it is written:
from typing import Annotated
import annotated_types as at
import pytest
from valgebra import Validator
with pytest.raises(NotImplementedError):
Validator({Annotated[str, at.MinLen(2)]: int})
# The two spellings that remain: every key of a type, or one key by name.
assert Validator(dict[str, int]).is_valid({"ab": 1})
assert Validator({"ab": int}).is_valid({"ab": 1})
A narrowed key names part of a type, and two such clauses can overlap without either containing the other — which is a question this map model does not answer the same way twice. To constrain the keys themselves, check them beside the mapping rather than inside it:
from typing import Annotated
import annotated_types as at
from valgebra import Validator
key_shape = Validator(Annotated[str, at.MinLen(2)])
short_codes = Validator(
Annotated[dict[str, int], at.Predicate(lambda d: all(map(key_shape.is_valid, d)))]
)
assert short_codes.is_valid({"ab": 1})
assert not short_codes.is_valid({"a": 1})
The mapping is still a dict[str, int] to every relation — the predicate is
opaque, as every predicate is (refinements), so the keys
are checked on membership and say nothing about inclusion. That is the whole
trade, and it is why the narrowed key is refused rather than compiled into
something that looks decided and is not.
Constraining some keys and freeing the rest¶
Because the clauses are a disjunction, a clause that matches every key subsumes every narrower one. So freeing the keys beside a typed clause means a clause over the complement of the keys that one claims: disjoint clauses cannot widen each other, and the constrained keys stay constrained.
That is what open writes. Written by hand it is how a caller frees
some of what is left rather than all of it:
from valgebra import Validator, anything, complement
# str keys must be ints; any key that is not a str is unconstrained.
partly_open = Validator({"name": str, str: int, complement(Validator(str)): anything})
assert partly_open.is_valid({"name": "Ada", "age": 36})
assert partly_open.is_valid({"name": "Ada", 7: object()}) # no clause claims it
assert not partly_open.is_valid({"name": "Ada", "age": "old"})
# And `open` frees the same region, so it writes the same schema.
assert Validator({"name": str, str: int}).open().is_equivalent(partly_open)
A schema written this way leaves the decided fragment: a clause keyed by a complement is one the set representation declines, so relations about it fall back to the rules. Membership is unaffected — the walk reads the value.
Classes¶
| Form | How it validates |
|---|---|
TypedDict |
a record, open as the typing spec defines one; Required/NotRequired/ReadOnly honored, closed=True/extra_items obeyed |
| dataclass | isinstance plus a deep check of each declared field |
NamedTuple |
isinstance plus the tuple its fields lay out, checked by position |
Enum |
an instance of the enumeration (any member) |
runtime-checkable Protocol |
isinstance against the protocol |
NewType |
validates the supertype it wraps |
PEP 695 type alias |
validates the aliased type, and ties the fixpoint where the alias names itself (recursion) |
import enum
from dataclasses import dataclass
from valgebra import Validator
class Color(enum.Enum):
RED = 1
GREEN = 2
@dataclass
class Point:
x: int
y: int
assert Validator(Color).is_valid(Color.RED)
assert Validator(Point).is_valid(Point(1, 2))
assert not Validator(Point).is_valid(Point(1, "y"))
What a class declares is not every annotation on it. A ClassVar annotates
the class and an InitVar names a constructor parameter, so neither is an
attribute of an instance and neither is checked; a field declared
init=False is on the instance and is.
That last one has an edge, and it is the check-only semantics showing through:
a field with init=False and no default is not set by the constructor, so
unless __post_init__ assigns it the attribute is absent from the instance —
and an absent attribute is not a value of any type. valgebra reads the object it
is given rather than the declaration, so such an instance is not a member until
something assigns the field.
from dataclasses import dataclass, field
from valgebra import Validator
@dataclass
class Row:
key: int
seen: bool = field(init=False) # no default: the constructor sets nothing
def touch(self) -> None:
self.seen = True
row = Row(1)
assert not Validator(Row).is_valid(row) # `seen` is not there yet
row.touch()
assert Validator(Row).is_valid(row)
On a TypedDict, Required,
NotRequired and ReadOnly qualify the key rather than narrowing its type:
required-ness is read from the qualifier where the field carries one and from
the class otherwise, and read-only-ness is about writing the key back rather
than about which values belong. Reading the qualifier is what makes a class mean
the same thing under from __future__ import annotations, where the class's own
key sets are computed from strings and cannot see it.
A TypedDict is open, a dict literal is closed¶
They denote different sets, and each denotes what its own author's spec says.
from typing import TypedDict
from valgebra import Validator
class User(TypedDict):
name: str
assert Validator(User).is_valid({"name": "Ada", "note": "extra"})
assert not Validator({"name": str}).is_valid({"name": "Ada", "note": "extra"})
Validator(TD) reads an annotation whose meaning is fixed by the typing spec,
and the spec makes a TypedDict open — reading it as a narrower set would be a
deviation the class carries no mark of. The dict-literal form is this library's
own spelling, and a schema written as a shape means that shape. Both sets are
spellable both ways: write closed=True (PEP 728) for a closed TypedDict, and
{"name": str, anything: anything} for an open shape -- or .open(), which
does the same to every record in a schema at once.
Pass the class, not its annotations¶
Give Validator the class itself. It reads the annotations, resolves the string
forms, and keeps the Annotated metadata — which is where every refinement
lives.
Extracting the hints yourself and passing the mapping is the path that goes
wrong, because typing.get_type_hints drops Annotated metadata by default.
The schema still builds and still validates; it has quietly lost its
constraints, and nothing raises:
from typing import Annotated, TypedDict, get_type_hints
import annotated_types as at
from valgebra import Validator
class Account(TypedDict):
balance: Annotated[int, at.Ge(0)]
assert not Validator(Account).is_valid({"balance": -5}) # the class: constrained
stripped = get_type_hints(Account) # {'balance': <class 'int'>}
assert Validator(stripped).is_valid({"balance": -5}) # the bound is gone
kept = get_type_hints(Account, include_extras=True)
assert not Validator(kept).is_valid({"balance": -5}) # include_extras keeps it
If you must derive the hints — building a schema for a class chosen at runtime,
say — pass include_extras=True. Passing the class is the supported path and has
no such footgun.
Recursive classes
A class whose own type appears in a field (a tree node, a linked list) is
recursive and cannot compile directly — express it with
recursive, which ties the fixpoint explicitly.
Bare classes, callables, and the runtime boundary
A bare class is an isinstance check: Validator(complex) admits any
complex, and any user class admits its instances. Callable (and
Callable[...]) checks only that the value is callable — the argument and
return types cannot be inspected at runtime, so they are not enforced. Any
is admitted unchecked. Everything else is decided structurally: a list[int]
schema does check each element.
Refinements¶
Annotated[T, ...markers] narrows T with constraints — bounds, lengths,
multiples, and predicates. See the refinements guide.
Stable repr¶
A compiled validator prints back as an expression that builds it, which makes schemas inspectable — and makes what is printed something you can paste into a session and get the same schema from:
from valgebra import Validator, anything, recursive
assert repr(Validator(list[dict[str, int]])) == "list[dict[str, int]]"
# A record's fields print in name order, whatever order they were written in:
# they are a *map*, so the order is not part of the schema, and two spellings of
# one record are one schema.
assert repr(Validator({"name": str, "age?": int})) == "{'age?': int, 'name': str}"
assert Validator({"name": str, "age?": int}) == Validator({"age?": int, "name": str})
# A recursive schema prints as the call that builds it, the back edge as the
# lambda's own parameter.
tree = recursive(lambda t: {"value": int, "left?": t})
assert repr(tree) == "recursive(lambda X: {'left?': X, 'value': int})"
assert recursive(lambda X: {"value": int, "left?": X}) == tree
# An open record prints the catch-all it carries, so it reads back as itself.
opened = Validator({"name": str}).open()
assert repr(opened) == "{'name': str, anything: anything}"
assert Validator({"name": str, anything: anything}) == opened
It is a rendering, not a serialization. Four forms cannot be written as an
expression and do not read back: a class, which prints as its name; a predicate,
which prints as Predicate(...); a constant too long to print, which is cut;
and a schema deeper than the renderer's own bound, which gives up and prints
<...> — a mark chosen to be a syntax error, so a render that lost something
cannot be read back as a schema that kept it. The API page has each
with the reason. Do not parse a repr to recover structure — see
inspection for asking a schema questions instead.