# Python API
The Python side of pytypehintweb is an adapter. It reads a compiled
pytypehint schema and produces a fully expanded plan. It renders nothing and
it validates no user input.
See the plan contract for the shape of the produced document, the architecture for where this layer sits, and getting started for a complete end-to-end example.
The final validation and the construction of the resulting objects stay in
pytypehint: an application that receives form.read() passes it to
Signature.build() or Struct.build().
# Public API
Everything the package exports from pytypehintweb:
| Name | Kind | Purpose |
|---|---|---|
plan_of |
function | compiles a schema into a fully expanded plan |
decode |
function | prepares a JSON-parsed transport object for schema.build() |
WebConfig |
dataclass | the configurable texts of the web layer |
STATIC |
Path |
the directory holding the browser runtime files |
PLAIN, INLINE, WRAPPED |
str |
the three union transport modes |
__version__ |
str |
the installed version |
Anything not listed here is an implementation detail and may change without notice.
# plan_of()
plan_of(obj, *, config: WebConfig | None = None) -> dict
Returns a plain dictionary of plain data: dictionaries, lists, strings,
integers, booleans and None. It is not a JSON string, and nothing in it
references core classes.
config must be a WebConfig instance or None. The check is
type(config) is WebConfig: no subclasses, no coercion. False, 0, ""
and {} are errors, not shorthands for the factory configuration.
# Accepted inputs
plan_of() accepts:
- a plain named function accepted by
pytypehint.signature_of(); - a dataclass type accepted by
pytypehint.struct_of(); - an already compiled
Signature; - an already compiled
Struct.
| Input | Result |
|---|---|
| plain named function | compiled with signature_of(); the form name is the function name and the docstring becomes the form description |
| dataclass type | compiled with struct_of(); the form name is the class name and the description is absent |
compiled Signature |
used directly |
compiled Struct |
used directly |
It does not accept arbitrary callables. Lambdas, bound methods,
functools.partial objects and objects implementing __call__ are rejected.
Wrap such behaviour in a plain named function before compiling it.
Passing an already compiled schema avoids recompiling the original function or dataclass, which is useful when the same schema is needed twice — once to build the plan and once to build the submitted values:
from pytypehint import signature_of, struct_of
from pytypehintweb import plan_of
schema = signature_of(create_user)
plan = plan_of(schema)
result = schema.build(transport)
Both forms produce the same document:
plan_of(create_user) == plan_of(signature_of(create_user))
plan_of(User) == plan_of(struct_of(User))
# Rejected inputs
A dataclass instance is rejected, because the plan describes a type, not a value:
plan_of(User(name="ada"))
# TypeError: expected a dataclass type, got an instance of User
These are rejected too. The error usually comes from the core, which explains the specific problem:
| Input | Why |
|---|---|
lambda value: value |
lambdas have no usable name |
functools.partial(fn, x=1) |
not a plain function |
instance.method |
bound methods are not plain functions |
Class.classmethod |
bound to the class, not a plain function |
Class.method |
the leading self parameter is not a form field |
an object with __call__ |
not a plain function |
| an ordinary class | not a plain function |
| anything else | not a supported category |
A @staticmethod accessed through its class is a plain function, so it is
accepted.
Anything the adapter cannot classify raises:
TypeError: expected a plain function, a dataclass type, a Signature or a
Struct, got ...
# Function restrictions
Function inputs inherit the restrictions of pytypehint.signature_of(). Each
parameter becomes a form field, so a parameter that cannot be described as one
is refused:
| Signature | Result |
|---|---|
def f(value: str) |
accepted |
def f(value: str = "x") |
accepted; the default initialises the field |
def f(*, value: str) |
accepted; keyword-only is fine |
def f(value) |
rejected: missing type hint |
def f(value: str, /) |
rejected: positional-only parameters are not supported |
def f(*args: str) |
rejected: variadic parameters are not supported |
def f(**kwargs: str) |
rejected: variadic parameters are not supported |
def f(self, value: str) |
rejected: looks like an unbound method |
To build a form for something that is not a plain function, write one:
service = Service()
def run(query: str) -> Result:
return service.search(query)
plan = plan_of(run)
plan_of() reads the signature of run; it never calls it. Executing the
function is the host application's job.
# Strings
A Str shape maps to a str node. The metadata that reaches the plan:
| Annotation | Plan option |
|---|---|
Min |
minLength |
Max |
maxLength |
Pattern |
pattern, plus patternMessage when the annotation carries a message |
Placeholder |
placeholder |
IsPassword |
password |
Rows |
rows |
Choices |
choices |
Literal[str] |
choices |
Label |
the field label |
Description |
the field description |
minLength and maxLength count Unicode code points, matching Python's
len(str). The browser measures the same way.
Rows, IsPassword and Choices ask for three different controls. Any two
of them together raise TypeError rather than letting the adapter pick one.
Placeholder is likewise rejected next to Choices: a closed select has no
empty prompt, so there is nothing for the placeholder to fill.
A Choices field always represents one of its values. It opens on the explicit
default when there is one, otherwise on the first choice, so it is ready from
the first render with no "choose one" step.
# Files
FileHint on a str turns the field into a file node (kind: "file")
rather than a text box. Its value is a reference string the browser widget
generates locally the moment the user picks a file — the file's name
compressed to bare ASCII (15 characters at most), a UUID, and the file's
lowercased extension. The browser never interprets it beyond the extension:
a lenient value.lower().endswith(ext) filter over the declared list — a guard
against honest mistakes. Nothing about the transport changes: a file field is a
str on the wire.
A reference is not a path, and it carries no bytes. It is an opaque token
that means something to the host and to nobody else. FileHint reads one thing
off it, the extension, and reads it off the text: the core opens nothing, so it
cannot tell you whether the reference stands for stored bytes, whether they are
still there, how many there are or whose they are. Answering that is the host's
job, and the seam the library gives it is decode(..., file_resolver=...):
prepared = decode(schema, body, file_resolver=lambda ref: str(UPLOADS / ref))
resolved = schema.build(prepared)
The resolver is where a host looks the reference up in its own storage and
decides what to hand the pipeline — a path, an object-store key, anything the
function behind the schema understands. It is also where a host refuses: raise
from it and the exception travels out of decode() unchanged. Without a resolver
the reference travels untouched and build() returns it as the plain string it
is. See file_resolver for the full walk it covers.
| Annotation | Plan option |
|---|---|
FileHint(extensions=...) |
extensions — lowercase, dotted, possibly empty (any file), mapped to the input's accept |
FileHint(min_size=...) / max_size=... |
minSize / maxSize — bytes, per file, null when unbounded |
list[Annotated[str, FileHint(...)]] |
one file node with multiple: true; the list's Min/Max become minFiles/maxFiles |
The byte bounds are per file, never a combined total: three 4 MB files under
a 5 MB max_size are three valid files. Counting them is minFiles / maxFiles,
a separate question.
They are a declaration, and the browser is the only place that can act on
it. A local File carries a .size, so the widget refuses one that already
breaks a bound before any upload happens — failing before the bytes move. A
reference carries no bytes at all, so nothing weighs it: not the widget, which
has no size to read, and not the core, which never opens anything. A byte
bound you need to be authoritative belongs beside the storage that holds the
bytes, in the host's own upload endpoint or resolver. The only bound that has
to be a safe JavaScript integer is the one written into the plan; plan_of()
refuses a larger one rather than rounding it.
A file composes like any other node. There is no rule about files in lists,
in structs or in unions: a shape is representable when each of its nodes is, and
the compiler recurses. Writing File where you would write str works at any
depth, with or without a default:
| Shape | Node |
|---|---|
Annotated[str, FileHint()] |
file |
Annotated[str, FileHint()] | None |
file, optional |
list[Annotated[str, FileHint()]] |
one file node with multiple: true |
list[Annotated[str, FileHint()] | None] |
list of optional of file |
list[Annotated[str, FileHint()] | int] |
list of choice |
list[list[Annotated[str, FileHint()]]] |
list of multiple file |
| a dataclass with a file field | object containing a file |
a dataclass with a list[File] field |
object containing a multiple file |
list[SomeDataclassWithAFile] |
list of object |
| a file as one branch of a union whose other branches are not strings | choice |
list[File] has one special case, and it is a shortcut, not a restriction.
A bare list[File] becomes a single multiple file node — one input that
takes several files at once, minting one reference each, with the list's
Min/Max as file-count bounds — because that is what a user expects from it.
Any other list compiles its item recursively like any other list. This is also
why list[list[File]] is a list whose rows are multiple file nodes: the
shortcut applies at the inner level, and the outer list stays a list.
# File defaults and prefills
A file default is an existing reference. A single file takes a str, a
list[File] takes a list[str], and an optional file takes None for its off
state. In the browser it is exactly what FileWidget.setValue() means — shown as
the current file, transported back untouched, carrying no bytes, no local File
and no upload — because compileForm() applies it by calling setValue().
There are two ways a reference can reach a form, and they are the same road seen from two points:
1. A default in the Python schema, including a prefill. A prefill is a temporary default, so it takes the same route:
value → schema default → pytypehint checks the extension
→ plan_of() → plan → compileForm() → setValue()
The core checks what it can check about a str: that it is one, and that its
text ends in an accepted extension. A list[File] default is checked element by
element — [1]: not an accepted file type names the offender — and the list's
Min/Max are checked too. Nothing else is inspected: the value is not opened,
so a reference that names no local file is a perfectly good default, and so is an
object-store key. That is deliberate — most hosts do not keep uploads as local
paths, and a reference is exactly the token they do have.
2. A reference applied at runtime with setValue().
widget.setValue("bucket/key.pdf") → frontend state
form.read() → decode(..., file_resolver=…) → the host's own value → schema.build()
Both roads land the widget in the same observable state, and neither of them proves the bytes exist, and nothing in this library or in the core claims to: the core opens no files. A reference that has expired, that was never uploaded or that belongs to somebody else shows fine in the form and travels back intact. The place to catch it is the resolver, which is the one point where code that knows the storage sees the reference:
def resolve(reference: str) -> str:
record = uploads.get(reference, owner=current_user) # the host's own rules
if record is None:
raise LookupError(f"unknown or expired upload: {reference}")
return record.path
decode() does not catch that exception; it propagates unchanged, with its own
type, to whoever called decode().
A struct with an internal path round-trips through an edit form. Create and
edit are the same form: a host builds it from a Struct (struct_of(User)),
setValue()s each field from an existing record — including the file field, whose
string is an existing reference shown as the current file — the user edits what
they edit, and schema.build(decode(schema, form.read(), file_resolver=...))
returns the whole User. An untouched avatar comes back as the same reference it
went in as, byte-identical, and it is that reference the resolver is handed; a
replaced one comes back as the fresh reference the new local choice minted; no
bytes move for the files left alone. A plan default is the same thing declared
one step earlier: setValue() plants the current file at mount, a default has
the plan carry it, and the widget cannot tell the two apart because the default
reaches it through setValue().
Storage is the host's, entirely. The library mints and transports a
reference and knows nothing about where bytes live — not where they are, not
whether they arrived, not how many there are. A host that mints a reference and
never uploads the bytes ships a string that points at nothing, and the only code
in a position to notice is its own: at the upload endpoint, or in the
file_resolver when the reference comes back. One caveat worth knowing: the
resolver's answer is what continues down the pipeline, so it faces the
extension check too. A host resolving report.pdf to a bare key
s3://bucket/9f3a1c fails at build() with not an accepted file type — keep
the extension, or declare FileHint() without one. func-to-web (or any wrapper)
owns that mapping; see
Values completed outside the browser.
Only a bare FileHint is emitted. The other Str atoms — Min, Max,
Pattern, Choices, IsPassword, Rows, Placeholder — describe a text box
and have no meaning on a file control, so any of them alongside FileHint
raises TypeError ("not supported yet"), exactly as Float.slider does. FileHint's own min_size and max_size are
not among them: they describe the file, not a text box, and they travel.
Label and Description are the field's, not the Str's, so a labelled file
field is fine.
# Patterns
Pattern support is intentionally conservative. The core validates with
Python's re and the browser runs RegExp; the two are different engines,
so only the subset that behaves identically in both is accepted. A pattern
that merely compiles in JavaScript is not enough.
The browser wraps the pattern as new RegExp(`^(?:${pattern})$`, "u"),
which reproduces re.fullmatch(). Plans never carry flags.
Patterns outside the supported subset are rejected during plan generation, not at render time. The rejected categories are listed in the plan contract.
# Types
pytypehintweb exports two convenience aliases, importable from the top level:
from pytypehintweb import Color, Email, COLOR_PATTERN, EMAIL_PATTERN
They are not new types. Each is an Annotated[str, Pattern(...)], so the plan
they produce is an ordinary str node — the same node the equivalent hand-written
annotation produces — and nothing in the contract, transport or decode() knows
they exist. Each alias does set the pattern's message, so the node's
patternMessage carries that wording rather than the generic default; it is not
identical to a bare Pattern(...) with no message. They exist only to save every
caller from repeating the pattern.
ColorisAnnotated[str, Pattern(COLOR_PATTERN, message="Hex color like #ff5733")], whereCOLOR_PATTERNis#[0-9a-fA-F]{6}— a hex colour like#ff5733. This exact string is also the opt-in for the browser's colour assistant: aStrWidgetwhose pattern equals it mounts a picker beside the text field.EmailisAnnotated[str, Pattern(EMAIL_PATTERN, message=...)], whereEMAIL_PATTERNis[^@ ]+@[^@ ]+\.[a-z]{2,}— a run of non-space, non-@characters, an@, another such run, a dot and a suffix of two or more lowercase letters. It is a format filter, not an email validator: it rejects plenty of RFC-valid addresses and accepts plenty of nonsense, exactly the humility limitations records for the pattern subset.Emailcarries noPlaceholder, so it composes cleanly with a caller's own metadata.
Both compose the usual way — typing flattens the nested Annotated, so
Annotated[Color, Label("Fondo")] reaches the core as the pattern on the node and
the label on the field.
# Integers
Every integer that reaches a plan is a JavaScript safe integer: bounds,
multipleOf, step, choices, list lengths and defaults at any depth go
through the same check. If the core accepts a larger one, plan_of() fails
and says where. This does not restrict pytypehint itself; it only prevents
sending a silently rounded value to the browser.
| Annotation | Plan option |
|---|---|
Min |
min |
Max |
max |
MultipleOf |
multipleOf |
Step |
step |
Slider |
slider, plus showValue |
Choices |
choices |
Literal[int] |
choices |
Exclusive bounds are converted to inclusive browser limits: an exclusive
minimum becomes value + 1 and an exclusive maximum becomes value - 1.
When that conversion leaves the safe range, the error reports the original
exclusive bound and the inclusive value it would have needed.
Step controls presentation, MultipleOf controls validity. They can
coexist: Step(25) moves the arrows in steps of 25 but still lets someone
type 37, and MultipleOf(25) is what makes that 37 invalid. On an ordinary
number input the ▲/▼ arrows step by step, else by multipleOf, else by
1; that is a widget-local increment only — MultipleOf is never copied into
the plan's step.
Slider is another presentation of the same integer and needs both bounds.
Choices and Slider together are rejected.
For a slider, Step decides where the control can land and MultipleOf
decides whether the represented integer is valid; MultipleOf is not copied
into the slider stride. Usually
Annotated[int, Slider(), Step(5), MultipleOf(5)]
is what you want: the slider only stops on multiples of 5. Without the matching
Step,
Annotated[int, Slider(), MultipleOf(5)]
leaves the default stride of 1, so the slider can visit intermediate positions that are not multiples of 5 and therefore invalid. The adapter still requires at least one reachable valid position, but it will not silently align the two for you.
# Floats
A Float shape maps to a float node. The metadata that reaches the plan:
| Annotation | Plan option |
|---|---|
Min |
min, plus minExclusive |
Max |
max, plus maxExclusive |
Step |
step |
Choices |
choices |
Placeholder |
placeholder |
Label |
the field label |
Description |
the field description |
Unlike an integer, a float bound is not converted. The core compares a
float directly against its bound and an exclusivity flag (value <= min when
exclusive, value < min when not), so the adapter emits the bound value
verbatim and carries minExclusive / maxExclusive beside it. There is no ±1
neighbour, because a float has no next representable step. An empty range —
min > max, or min == max with either side exclusive — is rejected by the
core when it compiles the schema, so plan_of() never re-checks it.
Every finite double is representable in the browser, so there is no "safe float"
check the way there is for integers: min, max, choices and defaults travel
as they are. Step is presentation only — the stride of the ▲/▼ arrows —
never a validation grid.
Slider on a Float is rejected with TypeError: Float.slider is not supported yet: the min + k*step grid a slider needs has no exact float
arithmetic, so a legitimate default could be refused or the control would have
to silently correct a value, and the doctrine forbids both (see
limitations). Choices and Placeholder
together are rejected, as for the other scalars: a closed select has no empty
prompt.
The float stepper reuses the integer stepper's accessible labels
(int_increase_label / int_decrease_label): the arrows do the same job, so
their names do not vary by type. The float-specific texts —
float_invalid_message, float_finite_message, float_min_message,
float_max_message — are on WebConfig.
# Dates and times
A Date maps to a date node and a Time to a time node, each a native
picker. Both travel as ISO text — a date as YYYY-MM-DD, a time as HH:MM:SS
(the native control shows seconds) — so the metadata that reaches the plan is
the same for both:
| Annotation | Plan option |
|---|---|
Min |
min (date: plus the ±1-day conversion; time: plus minExclusive) |
Max |
max (date: plus the ±1-day conversion; time: plus maxExclusive) |
Choices |
choices |
Placeholder |
placeholder |
Label |
the field label |
Description |
the field description |
Bounds, choices and defaults are emitted in the canonical ISO form
isoformat() produces, which is fixed-width, so the browser compares them
lexicographically. A time is always HH:MM:SS: the core pins it to whole seconds
and rejects any microsecond, so no sub-second fraction is ever emitted. A date is
always a real calendar date as YYYY-MM-DD — it starts as a datetime.date, so
an impossible date cannot exist here; the browser enforces the same rule for
hand-written plans and direct widget use.
The one asymmetry is deliberate, and each type copies its own core: a date
bound is converted like an integer — an exclusive bound becomes the neighbouring
inclusive one by ±1 day, so the node carries no flag — while a time bound is
kept like a float — the core compares it directly, so the node carries
minExclusive/maxExclusive and no conversion happens. Neither has a slider,
step or multiple_of; Choices with Placeholder is rejected, as for the
other scalars. The date/time texts — date_invalid_message, date_min_message,
date_max_message, time_invalid_message, time_min_message,
time_max_message — are on WebConfig.
# Booleans
A bool maps to a bool node, rendered as a native checkbox. It has no
metadata of its own — no bounds, choices, placeholder or slider — so the node's
options is an empty object. Label and Description still apply, as on any
field.
A checkbox always represents a value: unchecked is false, checked is true.
A bool default of True or False travels certified by the core; bool | None makes the field optional (the toggle expresses None, the checkbox never
does). In a union, a bool branch carries option_id() "bool" and travels
plain (true/false), since it never collides with another bool.
# Enums
An EnumShape maps to an enum node, rendered as a select over its members'
names. An enum carries no constraint metadata — the closed set of members is
the constraint — so the node's options is just choices (the member names, in
declaration order), with placeholder and labels always null. Label and
Description still apply, as on any field. Flag enums and empty enums are
rejected by the core, so an enum node always has at least one choice.
A member travels as its name (.name), never its value: a value can be
anything, repeat across aliases, or not serialize, while a name is a stable JSON
string. An enum default travels as the member name, certified by the core, and
decode() restores the exact member — the lookup is the core's, on the enum
class the shape carries.
Aliases. When two members share a value (A = 1; B = 1), B is an alias of
A: list(cls) yields only canonical members, so an alias name never appears in
choices. If an external producer sends an alias name, the lookup reads through
__members__ and answers with the canonical member, so decode() returns the
member the alias points at and build() accepts it (its type is the enum class).
Extra on the core's enum is stored but not interpreted by the adapter: the
widget shows the member names, and the node's labels slot is always null
(see limitations).
In a union, a member is a JSON string, so an enum shares the string transport
with str, date, time and other enums: every such branch travels wrapped,
with the enum's class name (option_id()) as its $type.
# Lists
List constraints and item constraints are independent: Min on the list is
minItems, Min on the item is the item's own constraint.
- A list with no default starts empty, even when it has a minimum.
minItemsdoes not create rows. The interface never invents values to satisfy a constraint; it only reports that the list is not ready and refuses to remove below the limit.maxItemsprevents adding above the limit.- A default materializes exactly its elements and no others.
- Nested lists are supported.
- Union items are supported:
list[A | B]produces one choice per row.
list[A] | list[B] is a different question: the choice covers the whole
list, so rows cannot mix types.
# Optional values
A union containing None makes the field optional. Disabling the toggle
means None.
The initial toggle state is resolved in Python and travels in the plan:
OptionalTogglewins when present;- otherwise a missing default leaves the field enabled;
- otherwise the field is enabled when the default is not
None.
Turning a field off hides its widget without destroying it. Whatever was typed is still there when it is turned back on.
Inside a list, an optional item becomes an explicit optional node wrapping
the item node. An optional field carries its own optional flag instead.
# Unions
A union with two or more non-None branches becomes a choice node. A
single branch is emitted as its own node, with no choice around it.
Each branch carries Shape.option_id() from the core as its value, and the
adapter assigns each a transport mode — plain, inline or wrapped — by
the rules in the plan contract. The core compiler
rejects the homonym enums or dataclasses a union's normal path could produce;
plan_of() still fails if two branches reach it sharing an option id — defense in
depth, because nothing downstream could tell them apart.
The three mode names are also exported as constants, so that code inspecting a generated plan does not have to repeat string literals:
from pytypehintweb import PLAIN, plan_of
plan = plan_of(order)
branch = plan["fields"][0]["node"]["branches"][0]
if branch["mode"] == PLAIN:
...
They are plain strings — "plain", "inline" and "wrapped" — identical to
the values that appear in the plan, so comparing with == is correct and a
plan that has crossed JSON compares the same way.
# Defaults
Defaults come from the compiled schema, not from re-reading the original dataclass, and they initialize the entire tree: scalars, optionals, lists, choices and nested objects.
Five states that are easy to confuse:
| Value | Meaning |
|---|---|
MISSING |
no initial value; the plan emits hasDefault: false and omits default |
None |
an explicit value; the optional starts disabled |
"" |
an explicit string; the field is not empty |
0 |
an explicit integer; the field is not empty |
[] |
an explicit empty list; no rows are created |
For a union, the plan records which branch the default selected:
{"branch": index, "value": ...}. When the value fits more than one branch,
the first declared branch that accepts it wins — the same ordered selection
the core performs. list[str] | list[int] = [] opens on list[str]; with
the branches swapped it opens on list[int]. It only fails when no branch
accepts the value at all.
# decode()
decode(schema, data, *, file_resolver=None) -> Any
The answer is a dict for the transport decode() is written for. The annotation
is Any because it is not one for every input: decode() prepares and does not
validate, so a data that is not a dict at all is handed back as it came, for
build() to report rather than for this function to refuse.
decode() is the reverse counterpart of plan_of(): where plan_of() turns a
schema into a plan, decode() prepares a JSON-parsed transport object for the
schema to build. It takes the compiled schema (a Signature or a Struct) and
the raw dictionary that came out of the JSON parser, and returns a new
dictionary — the input is never mutated, and nothing in the result is shared
with it. Every container the answer holds is freshly built, a subtree nothing
could route included: that one is copied rather than aliased, so writing into
the result can never reach back into the caller's document.
from pytypehintweb import decode
resolved = schema.build(decode(schema, data))
It exists because the transport cannot express, by type, everything the core
demands. JSON has no way to tell 3 from 3.0, so a float typed in the browser
arrives as an int; a date or a time travels as an ISO string where the core
wants a date/time object; an enum member travels as the name of that member;
and where two options of a union share a portable spelling, the value travels
inside a $type/$value wrapper that names the one meant. build() validates
by exact type and would reject every one of those.
Restoring that representation is the core's work, and decode() delegates
it. The portable form is written by pytypehint — to_dict() spells a date
the way decode reads one — and read back by pytypehint, in
Signature.decode() / Struct.decode(). This layer calls that method and adds
nothing to it. One reading of the transport is what keeps the browser and the
core agreeing on what a value means; a second reader here would be a second
answer, differing exactly where it is most expensive to notice — in a value the
two layers file under different options.
What the core deliberately does not own is storage. A file reference is a
str whose meaning lives in a host's uploads directory or object store, so the
core reads an extension off the text and stops. That is the one thing this layer
adds of its own:
| Step | Owner |
|---|---|
the portable representation: int → float, ISO text → date/time, member name → enum member, and the $type/$value wrapper |
schema.decode(), in the core |
| the file references, at every file node of the decoded tree | file_resolver, in this layer |
that the schema is a compiled Signature or Struct |
this layer, with a TypeError |
| every rule about the value itself | schema.build() |
So decode(schema, data) without a resolver is schema.decode(data) with
that schema check in front of it, and decode(schema, data, file_resolver=...)
is the same answer with one further pass over the decoded tree, described in
file_resolver below.
What arrives, and what it is restored to:
| Shape at the path | Transport value | Restored into |
|---|---|---|
Float |
an int (3) |
a float (3.0) |
Date |
canonical ISO text ("2026-07-22") |
a date |
Time |
canonical ISO text ("14:30:00") |
a time |
EnumShape |
a member name ("ACTIVO") |
that member, an alias resolving to the member it aliases |
Str with FileHint |
a reference ("informe-<uuid>.pdf") |
file_resolver(reference), only when a resolver is given (see below) |
The reading is made only where the shape at the path is the single possible one.
Root fields, nested objects, lists (and nested lists), optionals (a None
passes through), and union branches are all covered. Everything else passes
through untouched: an enum name that is not a member, a date that is spelled
correctly but is not a real calendar day (2026-02-31), a number where a string
belongs — each reaches build() as it arrived, and is reported there.
decode() never guesses from a value's content. A string becomes a date
because the shape (or an explicit $type) says so, never because the string
"looks like" a date. A str field carrying "2026-07-22" stays a string. This
is the rule that keeps the reading honest, it is inviolable, and it is what the
two paragraphs below are strict for.
The spelling has to be canonical. A date is read from YYYY-MM-DD, and a
time from HH:MM with optional seconds, an optional fraction of up to six
digits and an optional offset — the forms isoformat() writes and the native
pickers produce. Nothing else is a date or a time here. date.fromisoformat()
and time.fromisoformat() accept far more than that, and their grammars overlap:
"20200101" reads as a date and as a time, and "2020" reads as 20:20.
Letting them decide would let the text of a value select an option, which is
the one thing this pipeline must never do, so a non-canonical spelling travels
intact and build() reports it. (The fraction and the offset are read even
though Time accepts neither, so the refusal comes from the shape's own rule —
whole seconds, naive — rather than from the value falling through as "not a time
at all".)
An integer becomes a float only where the two are the same number. 3
becomes 3.0 because that is one value written twice. 2**53 + 1 is not: above
2**53 the floats thin out, so converting it answers with a neighbour, and an
integer too large to convert has no answer at all. Both travel intact, because
restoring either would hand build() a number the transport never carried — and
build() would accept it, which is the one payload this layer must not invent.
The test is exactness, not size.
decode() prepares, it does not validate, and it never raises on a value
of its own accord. A value the core will reject — "abc" in a float field,
"not-a-date" in a date field, an integer of four hundred digits where a float
is wanted — passes through unchanged, and build() reports it with its own
error and its own path. The promise is total: there is no value, of any type,
size or spelling, for which decode() raises. (It does raise on a schema that is
neither a Signature nor a Struct: that is a programming error, not a value.
And a file_resolver the host supplies may raise — its exception travels out
untouched.)
# Unions and the wrapper
In a union, the shape alone cannot fix the reading of a bare number or string,
so the transport $type does. Two kinds of union collide on the wire: int | float (both a JSON number) and any mix of str, date, time and enums (all a
JSON string). Their branches travel wrapped
(plan contract): {"$type": "float", "$value": 3},
{"$type": "date", "$value": "2026-07-22"}, {"$type": "str", "$value": "2026-07-22"}, {"$type": "Estado", "$value": "ACTIVO"}. The core reads
$type, restores $value as the branch it names, and consumes the wrapper: the
float branch yields a float, a date/time branch a date/time, an enum
branch the member, a str branch its string, and each unwraps to the bare value
the core routes by exact Python type. The $type is not discarded — it is
answered: the ambiguity it named exists only on the wire.
Two wrappers are not consumed, and both refusals are load-bearing.
A wrapper the core genuinely needs is kept. list[str] | list[int] has
branches of one runtime type, so the discriminator is still what routes them
when build() runs; the wrapper stands and only what is inside $value is
restored.
A wrapper whose payload never read as the branch it names is kept too. In
str | date, {"$type": "date", "$value": "not-a-date"} names the date
branch, and "not-a-date" is not a date. Consuming the wrapper there would file
the string under the str beside it — silently, and build() would accept it,
which is the worst of the available outcomes: the transport said date and the
pipeline answered str without anyone being told. So the wrapper survives and
build() reports it against the branch that was actually named. That refusal
also closes a door on the file side: a wrapper naming a branch its payload did
not reach can no longer deposit a reference into a FileHint field behind
file_resolver's back.
Anything that merely resembles a wrapper — an extra key beside the two reserved
ones, a missing $value, a $type naming no branch of the path, a wrapper
where the path is not a union at all — is malformed transport rather than a
wrapper, and travels whole so build() still sees the evidence.
The general rule under all of it: the reading is fixed by the shape, in a union
the $type fixes which shape, and the value's content is never consulted.
For the file pass it adds, decode() reaches into the core through its public
surface only (Signature.params, Struct.fields, Field.shape, List.item,
Str.file_hint, Struct.cls, option_id()): it never touches the core
internals, and it names no shape whose portable spelling the core restores —
importing Float, Date, Time or EnumShape here would mean something in
this layer had started deciding what such a value means.
# file_resolver
A file reference is a str the widget minted locally, and the library has no
opinion on what it names. file_resolver is the hook that lets the host give it
one:
def resolve_file(reference: str) -> str:
return str(uploads_dir / reference)
values = decode(signature, data, file_resolver=resolve_file)
without file_resolver
→ references come back as they arrived
with file_resolver
→ every file value is the string the resolver returned
It is Callable[[str], str], keyword-only and optional. The reference goes in,
whatever the host returns comes out and continues down the pipeline to
build(). There is nothing to register and no type to subclass: the callable is
the whole contract.
The resolver is the pass this layer adds, and it runs over the tree the core
has already decoded, so it reaches every position a file node can occupy — a
root field, a list[File] (once per reference, in order), a struct field, a list
item, or the selected branch of a union in any of the three transport modes;
where the core kept a wrapper, the pass descends into its $value by the same
option id the core matched. What decides the call is the shape — a Str
carrying FileHint, and unambiguously so — never what the string looks like,
which is the same rule that governs the reading before it. An ordinary str
field holding something that resembles a reference is untouched.
Absence stays absence: a field the transport does not carry, an explicit None,
a switched-off optional and an empty list never reach the resolver. A reference
planted with setValue() is an ordinary file value, so it does pass through. So
is a value the core declined to route — a surviving wrapper, a dict where a
dataclass was expected: the pass descends nothing it cannot name, and build()
reports what is left.
The library learns nothing about storage in exchange. It knows the value belongs to a file node; whether that reference becomes a local path, an object-store key or any other string is the host's business, and so is the failure:
def resolve_file(reference: str) -> str:
target = uploads_dir / reference
if not target.is_file():
raise FileNotFoundError(f"File not found: {reference}")
return str(target)
An exception the resolver raises propagates unchanged — decode() neither
wraps it nor invents an error of its own. It is the one way decode() raises on
a value rather than leaving it for build(), and only because the host asked for
it.
func-to-web is one such host: it resolves references against its uploads directory
so the function it calls receives a plain path. Nothing about decode() requires
that, or any other particular storage.
# WebConfig
@dataclass(frozen=True, kw_only=True)
class WebConfig:
list_add_label: str = "Add"
list_remove_label: str = "Remove"
list_item_label: str = "Item"
str_min_message: str = "Must contain at least {value} characters"
str_max_message: str = "Must contain at most {value} characters"
str_pattern_message: str = "Invalid format"
int_safe_message: str = "Must be a safe integer"
int_invalid_message: str = "Enter a valid integer"
int_min_message: str = "Must be at least {value}"
int_max_message: str = "Must be at most {value}"
int_multiple_of_message: str = "Must be a multiple of {value}"
int_increase_label: str = "Increase"
int_decrease_label: str = "Decrease"
float_invalid_message: str = "Enter a valid number"
float_finite_message: str = "Must be a finite number"
float_min_message: str = "Must be at least {value}"
float_max_message: str = "Must be at most {value}"
date_invalid_message: str = "Enter a valid date"
date_min_message: str = "Must be on or after {value}"
date_max_message: str = "Must be on or before {value}"
time_invalid_message: str = "Enter a valid time"
time_min_message: str = "Must be at or after {value}"
time_max_message: str = "Must be at or before {value}"
list_min_message: str = "Add at least {value} items"
list_max_message: str = "Keep at most {value} items"
mode_previous_label: str = "Previous mode"
mode_next_label: str = "Next mode"
mode_position_label: str = "Mode {current} of {total}"
file_invalid_message: str = "Not an accepted file type"
file_min_message: str = "Add at least {value} files"
file_max_message: str = "Keep at most {value} files"
file_min_size_message: str = "File is too small; minimum {value}"
file_max_size_message: str = "File is too large; maximum {value}"
file_current_label: str = "Current file: {value}"
file_current_remove_label: str = "Remove current file"
file_current_replace_label: str = "Replace file"
file_current_restore_label: str = "Restore current file"
Every visible text the web layer generates is configurable. WebConfig
applies to a whole plan, never to individual fields through type hints, and
each plan_of() call may use a different one: there is no global state.
Message templates are deliberately minimal. The messages that insert a value
take exactly one {value} placeholder and nothing else; the ones that do
not insert a value accept no placeholders at all; literal braces are not
supported. A template that breaks these rules raises TypeError when the
WebConfig is constructed.
A Pattern(..., message=...) beats str_pattern_message. The priority is
resolved during plan generation, so a single decided text reaches the
browser.
Structural elements — kinds, transport modes, $type, $value, property
names — are not configurable, and neither are technical errors: those are
contract violations seen by programmers, not interface text.
Every configured text travels in every plan plan_of() emits: the plan is
fully expanded, so a WebConfig message or label is written on each node it
applies to, whether or not it differs from the standard text. Each side then
decides whether a given control renders — a slider and a closed choice show no
stepper, so they simply ignore the stepper labels they still carry.
# Errors
plan_of() raises TypeError at plan-generation time whenever browser
semantics cannot preserve a guarantee the core makes. Nothing is degraded
silently.
It does not independently recreate the core's validation. The layers divide the work:
pytypehint core schema semantics
plan_of() the exact converted browser contract it emits
JavaScript manual and generated plan input
schema.build() the values actually submitted
A schema that reaches plan_of() has already passed the core model, so the
adapter does not restate the core's schema semantics for their own sake. It
verifies the parts of the contract that only become concrete once the plan is
converted for the browser, and rejects:
- JavaScript-unsafe integers, anywhere they appear;
- non-portable regular-expression patterns;
- incompatible control metadata (control combinations asking for different widgets);
- duplicate branch identifiers;
- empty nested objects;
- exclusive integer bounds that leave no integer after conversion;
- sliders without both converted bounds;
- sliders with no reachable valid position;
- converted defaults the browser could not represent or validate — including a
slider default that lands off the
Stepgrid (the convertedmaxis on that grid whether or not the stride divides the range).
The full split is in the architecture.
Every message is prefixed with the path of the field that caused it, because in a composed form knowing what failed is useless without knowing where:
fields.lines.item
fields.address.postal_code
fields.value[int]
The paths follow the plan structure: .name for a field, .item for a list
item, [index] for a default element and [option_id] for a union branch.
Exact error strings are not part of any compatibility guarantee.
plan_of() rejects unsupported types, incompatible control metadata, unsafe
integers, unportable patterns, ambiguous transport identifiers, empty nested
objects, unreachable or unbounded sliders, converted defaults the browser could
not accept, and an invalid WebConfig. See
limitations for the full list.