Skip to content

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.

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})

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") # True
flag("off") # False

Coerce(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")

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}

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.

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>]

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)]

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 max

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}]})

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, Invalid
from 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, Range
from 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