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 field that decides its shape (a type, a platform, a condition), TaggedUnion routes on that field’s value to the one matching schema. The win is the error: a bad branch reports its own field errors, not “matched none of the alternatives”, and an unknown tag is rejected with the valid tags named.

from probatio import Schema, TaggedUnion
schema = Schema(
TaggedUnion(
"type",
{
"point": {"type": "point", "x": int, "y": int},
"label": {"type": "label", "text": str},
},
)
)
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, TaggedUnion
schema = Schema(TaggedUnion("type", {"point": {"type": "point", "x": int, "y": int}}))
schema({"type": "point", "x": "nope", "y": 2}) # expected int at 'x'

Each case can be any schema, so define the branch schemas once and reference them, a plain Schema, a DataclassSchema, or any validator. When a branch already pins its tag as a literal (Required("type"): "point"), pass the branches as a list and the tag is read from each, so it is written once instead of repeated as the mapping key:

from probatio import Schema, TaggedUnion, Required
POINT = Schema({Required("type"): "point", Required("x"): int, Required("y"): int})
LABEL = Schema({Required("type"): "label", Required("text"): str})
schema = Schema(TaggedUnion("type", [POINT, LABEL])) # tag read from each branch
schema({"type": "label", "text": "hi"}) # {'type': 'label', 'text': 'hi'}

The routing table is built once, so the list form validates just as fast as the mapping. Use the {tag: schema} mapping when a branch does not carry the tag itself (its schema never mentions the key, or one branch covers several tags); a list branch that pins no literal is a build-time error pointing you to the mapping form.

Pass default= for a fallback schema when the tag is not listed. For a discriminator that is not a simple key lookup (a computed tag, a subset of branches), drop to the lower-level Union(..., discriminant=fn), where discriminant(value, alternatives) returns the branches to try. TaggedUnion is the common case of that.

Coming from Home Assistant, TaggedUnion is the direct equivalent of cv.key_value_schemas (the mapping form), and its cases take the same reusable Schema objects.

Deep dive: Combinators.

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

Deep dive: Validators.

Raw device and sensor readings usually need a small calculation before they are useful: a milliunit divided down, a byte scaled to a percentage, a temperature converted, a status code named. The arithmetic mutators compose into these without a hand-written Coerce(lambda ...), and because you supply the numbers, probatio never has to bake in a disputed formula.

from probatio import Schema, All, Divide, Scale, Remap, Clamp, Round, Map
# Milliunits to units (millivolts to volts, milliseconds to seconds).
Schema(Divide(1000))(21500) # 21.5
# A 0..255 byte to a 0..100 percentage.
Schema(All(Remap(0, 255, 0, 100), Round(0)))(128) # 50.0
# Celsius to Fahrenheit, the affine transform in one call (value * 9 / 5 + 32).
Schema(Scale(9, divisor=5, offset=32))(20) # 68.0
# A device status code to a name (you own the table).
Schema(Map({0: "off", 1: "on", 2: "auto"}))(2) # 'auto'

Map rejects a key it does not know, which is right for a closed set of codes. When a table exists only to rewrite a few sentinel values ahead of a stricter step, pass default=PASSTHROUGH so an unlisted value flows through untouched. A scraped source that sends "N.v.t." (not applicable) for a missing enum is the common case: fold the sentinel to None, let a real value reach the enum.

from enum import StrEnum
from probatio import Schema, All, Map, Maybe, PASSTHROUGH
class VehicleType(StrEnum):
CAR = "Personenauto"
vehicle_type = Schema(
All(Map({"N.v.t.": None}, default=PASSTHROUGH), Maybe(VehicleType))
)
vehicle_type("N.v.t.") # None
vehicle_type("Personenauto") # <VehicleType.CAR: 'Personenauto'>

Map runs first here and rewrites only what it names, so the enum coerces whatever is left. Order matters: put the Map before the enum in the All, or the enum rejects the sentinel before the table ever sees it.

The same works inside a dataclass field, and the field stays typed as the enum. A coercer in the metadata (Map or Coerce) runs before a producing base like an enum, so the sentinel is folded ahead of the enum without dropping to a str field:

from dataclasses import dataclass
from enum import StrEnum
from typing import Annotated
from probatio import DataclassSchema, Key, Map, PASSTHROUGH
class VehicleType(StrEnum):
CAR = "Personenauto"
@dataclass
class Vehicle:
vehicle_type: Annotated[
VehicleType | None,
Key(alias="voertuigsoort"),
Map({"N.v.t.": None}, default=PASSTHROUGH),
] = None
schema = DataclassSchema(Vehicle)
schema({"voertuigsoort": "N.v.t."}) # Vehicle(vehicle_type=None)
schema(
{"voertuigsoort": "Personenauto"}
) # Vehicle(vehicle_type=<VehicleType.CAR: 'Personenauto'>)

An asserting constraint keeps the opposite order. An In([...]) in the metadata runs after the enum, so it checks the produced member, not the raw string. Only a coercer moves ahead of a producing base.

RSSI to a signal percentage is the classic case with no single agreed formula. Remap lets you pick the input range, and Clamp keeps the result in bounds when a reading runs past it:

from probatio import Schema, All, Remap, Clamp, Round
# Linear dBm -100..-50 to 0..100%, your chosen range, clamped and rounded.
rssi = Schema(All(Remap(-100, -50, 0, 100), Clamp(0, 100), Round(0)))
rssi(-70) # 60.0
rssi(-40) # 100 (past the top of the range, clamped)

Probatio deliberately ships no RSSIToPercentage or CelsiusToFahrenheit: the formula is a policy choice, and unit conversion is bottomless. The primitives keep that choice in your schema, where it is readable and yours to change.

Deep dive: Validators.

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}

Deep dive: Dict schemas and markers.

When the keys themselves are data (device slugs, entity ids, translation keys, a status code), put a validator in the key position. It runs on every key: a format check rejects a bad key pointing at the key, and a coercer or normalizer rewrites the key into the result. This is the same idea as a type key, with any validator.

from probatio import Schema, Slug, Match, Coerce, In
# Keys must be slugs (area or device maps keyed by name).
Schema({Slug(): int})({"living-room": 1, "kitchen": 2})
# {'living-room': 1, 'kitchen': 2}
# Keys must match a pattern (an entity id, a translation key: [a-z0-9_-]).
Schema({Match(r"^[a-z0-9_-]+$"): str})({"already_running": "In progress"})
# {'already_running': 'In progress'}
# Coerce the key: JSON object keys are always strings, take integer ids back.
Schema({Coerce(int): str})({"1": "on", "2": "off"})
# {1: 'on', 2: 'off'}
# Restrict the key to a known set.
Schema({In(["celsius", "fahrenheit"]): float})({"celsius": 21.5})
# {'celsius': 21.5}

A bad key is reported at that key, not swallowed as an unexpected extra:

from probatio import Schema, Slug
Schema({Slug(): int})({"Not A Slug": 1}) # expected a slug at 'Not A Slug'

Coming from Home Assistant, this is the direct form of cv.schema_with_slug_keys: {Slug(): value_schema} (or your own key validator, {key_validator: value_schema}) validates each key and then the values, no wrapper needed. Normalizing keys works the same way, {Lower: value_schema} folds the case of every key.

Deep dive: Dict schemas and markers.

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

Deep dive: Dict schemas and markers.

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' at '<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' at '<server>'

Deep dive: Dict schemas and markers.

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 at '[Any('email', 'phone', msg=None)]'

That default group label is honest but ugly: the path segment renders the Any(...) marker’s repr, because the group has no natural key name. (The repr leak itself is a known library issue, tracked separately.) The production form is a custom msg on the marker, read back through error_message, which carries the message without the path:

from probatio import Schema, Required, Any, Invalid
schema = Schema(
{Required(Any("email", "phone"), msg="provide an email or a phone number"): str}
)
try:
schema({})
except Invalid as err:
print(err.errors[0].error_message) # provide an email or a phone number

Deep dive: Dict schemas and markers.

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

Deep dive: Dict schemas and markers.

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

Deep dive: Combinators.

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

Deep dive: Recursive schemas.

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 at '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

Deep dive: Error handling and Custom error messages.