Cookbook
A page of patterns you reach for again and again. Each one is a small, runnable schema with a passing input and, where it helps, a rejected one. Copy a block, run it, adapt it.
Tagged union
Section titled “Tagged union”When your data carries a “type” field that decides its shape, a Union with a
discriminant routes to the matching branch. The discriminant is called as
discriminant(value, alternatives) and returns the subset to try, so a bad
branch reports its own field errors instead of “matched none of the
alternatives”.
from probatio import Schema, Union
def by_type(value, alternatives): return [a for a in alternatives if a["type"] == value.get("type")]
schema = Schema( Union( {"type": "point", "x": int, "y": int}, {"type": "label", "text": str}, discriminant=by_type, ))
schema({"type": "point", "x": 1, "y": 2}) # {'type': 'point', 'x': 1, 'y': 2}schema({"type": "label", "text": "hi"}) # {'type': 'label', 'text': 'hi'}A point with a non-int coordinate fails against the point branch, not the whole union:
from probatio import Schema, Union
def by_type(value, alternatives): return [a for a in alternatives if a["type"] == value.get("type")]
schema = Schema( Union( {"type": "point", "x": int, "y": int}, {"type": "label", "text": str}, discriminant=by_type, ))
schema({"type": "point", "x": "nope", "y": 2})Coercing config and environment strings
Section titled “Coercing config and environment strings”Config files and environment variables hand you strings. Coerce(int) turns a
numeric string into an int, Boolean reads the common truthy and falsy spellings,
and wrapping a coercion in All lets you range-check the result. Order matters:
coerce first, then validate the typed value.
from probatio import Schema, Coerce, Boolean, All, Range
port = Schema(All(Coerce(int), Range(min=1, max=65535)))port("9000") # 9000
flag = Schema(Boolean())flag("yes") # Trueflag("off") # FalseCoerce(int) raises cleanly when the string is not a number, so a typo in a port
does not crash with a raw ValueError:
from probatio import Schema, Coerce, All, Range
port = Schema(All(Coerce(int), Range(min=1, max=65535)))port("eighty")Open mapping with typed keys and values
Section titled “Open mapping with typed keys and values”To describe “any string key, integer value”, use a type as the dict key. A type key validates every key of that type, which is how you accept an open mapping without listing each name.
from probatio import Schema
schema = Schema({str: int})
schema({"a": 1, "b": 2}) # {'a': 1, 'b': 2}For “these known keys, plus anything else”, combine literal keys with the Extra
catch-all. {Extra: validator} validates every otherwise-unmatched key. Use
{Extra: object} to wave anything through:
from probatio import Schema, Extra
schema = Schema({"name": str, Extra: object})
schema({"name": "app", "debug": True, "retries": 3})# {'name': 'app', 'debug': True, 'retries': 3}Nested optional sections with defaults
Section titled “Nested optional sections with defaults”A whole config section can be optional, holding a nested schema with its own
defaults. Give the outer Optional a callable default (here dict) so an
absent section becomes a fresh empty dict. The default runs through the nested
schema like any value, so an absent section comes back fully populated with the
inner defaults, the same as a section provided as an empty dict.
from probatio import Schema, Optional
schema = Schema( { Optional("logging", default=dict): { Optional("level", default="info"): str, Optional("file", default="app.log"): str, }, })
schema({}) # {'logging': {'level': 'info', 'file': 'app.log'}}schema({"logging": {}}) # {'logging': {'level': 'info', 'file': 'app.log'}}schema({"logging": {"level": "debug"}})# {'logging': {'level': 'debug', 'file': 'app.log'}}The outer default (dict(), an empty dict) is validated through the nested
schema like any other value, so it picks up the inner defaults. A missing section
and an empty-dict section therefore come back the same, fully populated.
Mutually exclusive and co-dependent keys
Section titled “Mutually exclusive and co-dependent keys”Exclusive ties keys into a group where at most one may appear. Inclusive ties
keys into a group that must appear together, all or none. Both take the group
name as their second argument.
from probatio import Schema, Exclusive, Inclusive
schema = Schema( { Exclusive("token", "auth"): str, Exclusive("password", "auth"): str, Inclusive("host", "server"): str, Inclusive("port", "server"): int, })
schema({"token": "abc", "host": "localhost", "port": 8080})# {'token': 'abc', 'host': 'localhost', 'port': 8080}Two keys from the same exclusive group is an error:
from probatio import Schema, Exclusive, Invalid
schema = Schema( { Exclusive("token", "auth"): str, Exclusive("password", "auth"): str, })
try: schema({"token": "abc", "password": "hunter2"})except Invalid as err: print(err) # two or more values in the same group of exclusion 'auth' @ data[<auth>]Half of an inclusive group is also an error:
from probatio import Schema, Inclusive, Invalid
schema = Schema( { Inclusive("host", "server"): str, Inclusive("port", "server"): int, })
try: schema({"host": "localhost"})except Invalid as err: print(err) # some but not all values in the same group of inclusion 'server' @ data[<server>]At least one of a group of keys
Section titled “At least one of a group of keys”To require that at least one of several keys is present, while still allowing
more than one, use a Required(Any(...)) key. The Any lists the acceptable
keys, and the mapped value validates each one that appears. This is the “one or
more” counterpart to Exclusive (at most one) and Inclusive (all or none).
from probatio import Schema, Required, Any
schema = Schema( { Required(Any("email", "phone")): str, "name": str, })
schema({"name": "ada", "email": "[email protected]"}) # {'name': 'ada', 'email': '[email protected]'}Providing none of them fails, with the error naming the whole group:
from probatio import Schema, Required, Any, Invalid
schema = Schema({Required(Any("email", "phone")): str})
try: schema({})except Invalid as err: print(err) # at least one of ['email', 'phone'] is required @ data[Any('email', 'phone', msg=None)]Dropping deprecated keys
Section titled “Dropping deprecated keys”Remove drops matching keys from the output. The value is still validated, so a
type error is not hidden; only a value that passes is dropped. Handy for retiring
a setting without breaking configs that still carry it.
from probatio import Schema, Remove
schema = Schema({"name": str, Remove("legacy_mode"): bool})
schema({"name": "app", "legacy_mode": True}) # {'name': 'app'}Extending a base schema with a cross-field rule
Section titled “Extending a base schema with a cross-field rule”To add keys to a shared base and check a rule across the whole mapping, compose
the two: extend merges the new keys, and All layers a whole-mapping validator
on top. There is no need for extend to grow special cases; the combinators
already compose.
from probatio import Schema, All, Invalid, Required
base = Schema({Required("min"): int})
def min_below_max(config): if config["min"] >= config["max"]: raise Invalid("min must be below max") return config
schema = Schema(All(base.extend({Required("max"): int}), min_below_max))
schema({"min": 1, "max": 10}) # {'min': 1, 'max': 10}The whole-mapping rule runs after the keys validate:
from probatio import Schema, All, Invalid, Required
base = Schema({Required("min"): int})
def min_below_max(config): if config["min"] >= config["max"]: raise Invalid("min must be below max") return config
schema = Schema(All(base.extend({Required("max"): int}), min_below_max))
try: schema({"min": 10, "max": 5})except Invalid as err: print(err) # min must be below maxRecursive tree
Section titled “Recursive tree”Self references the schema being defined, which is how you validate tree-shaped
data of unbounded depth. It must sit as a direct mapping value or list element.
from probatio import Schema, Required, Optional, Self
node = Schema( { Required("name"): str, Optional("children", default=list): [Self], })
node({"name": "root", "children": [{"name": "leaf"}]})# {'name': 'root', 'children': [{'name': 'leaf', 'children': []}]}A bad value deep in the tree is rejected with a path that points right at it:
from probatio import Schema, Required, Optional, Self
node = Schema( { Required("name"): str, Optional("children", default=list): [Self], })
node({"name": "root", "children": [{"name": 42}]})Friendly error messages
Section titled “Friendly error messages”The default str(error) is precise but terse. humanize_error from
probatio.humanize renders the failure against the data, naming the path and the
offending value, which is what you want to show whoever wrote the config.
from probatio import Schema, All, Coerce, Range, Invalidfrom probatio.humanize import humanize_error
schema = Schema({"port": All(Coerce(int), Range(min=1, max=65535))})
bad = {"port": "70000"}try: schema(bad)except Invalid as err: print(humanize_error(bad, err)) # value must be at most 65535 for dictionary value @ data['port']. Got '70000'validate_with_humanized_errors does the same in one call: it validates and, on
failure, raises a plain Error carrying the humanized message.
from probatio import Schema, Rangefrom probatio.humanize import validate_with_humanized_errors
schema = Schema({"port": Range(min=1, max=65535)})
validate_with_humanized_errors({"port": 70000}, schema)To replace a single validator’s message with your own wording, wrap it in Msg:
from probatio import Schema, Match, Msg, Invalid
schema = Schema(Msg(Match(r"^[a-z]+$"), "use lowercase letters only"))
try: schema("Nope123")except Invalid as err: print(err) # use lowercase letters only