JSON Schema
A Probatio schema is Python data. JSON Schema is the lingua franca other tools
speak. The two codecs translate between them, for the constructs that map
cleanly. They are exported from the top level, so
from probatio import to_json_schema, from_json_schema is all you need.
That unlocks the places JSON Schema already lives: editor validation of config
files, form generation in a frontend, and LLM tool definitions (worked through
in the LLM tool recipe). Decoded input is treated as
untrusted by default; the
guards are covered at the bottom
of this page.
The same decoder backs OpenAPI (see OpenAPI), and a third codec renders the flat shape config frontends consume (see Field lists).
Both directions
Section titled “Both directions”to_json_schema(schema) renders a schema as a JSON Schema dictionary.
from_json_schema(dict) is the inverse: it builds a Schema back. Together they
round trip the parts that have a clean mapping.
from probatio import Schema, Required, Optional, to_json_schema
schema = Schema({Required("name"): str, Optional("port", default=8080): int})to_json_schema(schema)# {'type': 'object', 'properties': {'name': {'type': 'string'}, 'port': {'type': 'integer', 'default': 8080}}, 'additionalProperties': False, 'required': ['name']}Going the other way, a JSON Schema becomes a working validator. required
controls which keys must be present, minimum becomes a Range, and so on:
from probatio import from_json_schema
document = { "type": "object", "properties": { "name": {"type": "string"}, "age": {"type": "integer", "minimum": 0}, }, "required": ["name"],}schema = from_json_schema(document)schema({"name": "Ada", "age": 37}) # {'name': 'Ada', 'age': 37}A small round trip stays intact. Render a schema, build it back, validate:
from probatio import Schema, Required, to_json_schema, from_json_schema
original = Schema({Required("name"): str, Required("age"): int})rebuilt = from_json_schema(to_json_schema(original))rebuilt({"name": "Ada", "age": 37}) # {'name': 'Ada', 'age': 37}Widening the encoder, and overriding it
Section titled “Widening the encoder, and overriding it”By default to_json_schema never rejects an input the schema accepts: where it
cannot express a construct exactly it widens (an open {}, or a looser keyword),
so the emitted schema stays a superset of what Probatio validates. Two options
tune that:
strict=TrueraisesSchemaErrorfor a construct with no JSON Schema form (an unknown validator, aCoercewith a non-type target, anenummember with no JSON representation), instead of widening to{}.custom_serializeris called first for each node and may return a dict to override the rendering, or theUNSUPPORTEDsentinel to defer to the default, the same hookto_openapitakes.
from probatio import Schema, to_json_schemafrom probatio.codecs import UNSUPPORTED
def as_password(node): if node is str.strip: return {"type": "string", "writeOnly": True} return UNSUPPORTED
to_json_schema(Schema({"token": str.strip}), custom_serializer=as_password)# {'type': 'object', 'properties': {'token': {'type': 'string', 'writeOnly': True}}, 'additionalProperties': False}Supported keywords
Section titled “Supported keywords”from_json_schema understands the keywords below. A purely descriptive keyword
it does not read (title, examples) is ignored, so a partial schema still
yields a usable validator. A restrictive keyword it cannot honor
(if/then/else, propertyNames, patternProperties, dependentSchemas,
dependencies, unevaluatedProperties, unevaluatedItems, $dynamicRef,
$recursiveRef) is refused with a SchemaError rather than silently dropped, so
an untrusted schema is never quietly widened to accept what its author meant to
forbid. dependentRequired is honored only in the symmetric all-or-none form
that maps to an Inclusive group (see below); its asymmetric form is refused the
same way. The object and array keywords apply even on a node without a type
(scoped to instances of their type, as the spec says). to_json_schema emits the
same constructs in the other direction.
| Area | Keywords |
|---|---|
| Objects | properties, required, additionalProperties, minProperties, maxProperties, dependentRequired (symmetric all-or-none only) |
| Arrays | items, minItems, maxItems, prefixItems, uniqueItems, contains, minContains, maxContains |
| Strings | minLength, maxLength, pattern, format (date, date-time, time, email, uri, ipv4, ipv6, uuid, hostname, byte), writeOnly, contentEncoding (Base64) |
| Numbers | minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf |
| Values | enum, const, not, type (including a type array like ["string", "null"]) |
| Compose | anyOf, oneOf, allOf, $ref (resolved against $defs/definitions) |
The named validators map to JSON Schema like this, and decode back into the
matching validator (a writeOnly property decodes to a Secret key), so they
round-trip:
| Probatio construct | JSON Schema output |
|---|---|
ExactSequence | prefixItems (with items: false, matching minItems/maxItems) |
Unique | uniqueItems |
Contains | contains |
Equal / Literal | const |
NotIn | not over an enum |
Email / Email() | format: email |
Url / Url() / FqdnUrl() | format: uri |
Datetime / Date / Time | format: date-time / date / time (a custom strptime format has no JSON Schema equivalent, so it exports as a plain string) |
IPv4Address / IPv6Address | format: ipv4 / ipv6 |
UUID | format: uuid |
Hostname / Fqdn | format: hostname |
Port | a bounded integer |
MultipleOf | multipleOf |
Secret key | its property with writeOnly: true |
Base64 | contentEncoding: base64 |
Inclusive group | dependentRequired (all-or-none); the symmetric map decodes back to an Inclusive group |
Combinators and a few more constructs also render, though most have no inverse so they do not round-trip:
| Probatio construct | JSON Schema output |
|---|---|
Any / Or | anyOf |
Union / Switch | anyOf (the discriminant is an optimization, so any branch is allowed) |
All / And | one merged object, or allOf when two validators emit the same keyword |
Maybe | anyOf with {"type": "null"} |
SomeOf | oneOf (exactly one), anyOf (at least one), or allOf (all) |
Msg | the wrapped validator’s shape (the message has no JSON Schema equivalent) |
An enum.Enum class | enum of the member values |
Self | {"$ref": "#"} (a recursive reference to the document root) |
Alias | one property per accepted name (plus anyOf of required when required) |
Exclusive group | at-most-one (not over the pairs), or oneOf when the group is required |
Duration / AsTimedelta | format: duration, which has no decoder, so it decodes to a plain string |
The known widener: JSON Schema has a single hostname format, so both
Hostname and Fqdn export to it and decode back as Hostname, meaning a
round-tripped Fqdn accepts a dotless host the original would reject. Pin it
with a pattern or an explicit check if the distinction matters.
An Inclusive group round-trips through dependentRequired. to_json_schema
emits the symmetric all-or-none map (every member requires every other), and
from_json_schema reads that shape back into an Inclusive group per connected
set of mutually dependent properties. The general dependentRequired is broader:
an asymmetric dependency (a requires b but not the reverse) has no Inclusive
equivalent, so it is refused rather than silently widened.
oneOf decodes with its exact semantics (a value must match exactly one branch,
so one matching two or more is rejected), unlike the looser anyOf.
from_openapi adds the OpenAPI nullable keyword, covered in
OpenAPI.
Untrusted input is the default assumption
Section titled “Untrusted input is the default assumption”from_json_schema and from_openapi treat their input as untrusted. A JSON
Schema can arrive from anywhere, and two of its constructs can wreck a naive
decoder: a pattern that backtracks catastrophically, and a document nested deep
enough to overflow the Python stack. Both are refused with SchemaError rather
than hanging or crashing.
A nested unbounded quantifier like (a+)+ is the classic catastrophic regular
expression. Probatio refuses to compile it:
from probatio import from_json_schema
from_json_schema({"type": "string", "pattern": "(a+)+$"})A pathologically deep document hits the same wall: past a generous depth limit,
the decoder raises SchemaError instead of recursing into a RecursionError.