# Architecture
pytypehintweb is a rendering adapter, not an application server. The library
ends at plan generation, widget construction and transport reading: plan_of()
returns a dictionary, compileForm() returns widgets, form.read() returns a
value. HTTP routing, static-file delivery, authentication, submission handling
and function execution belong to the host application. The bundled
pytypehintweb.demo is such an application (FastAPI, mounted static dir,
/plans and /build/{id}); it is a showcase, not an API of the library.
# Layers
pytypehint compiles types, decodes the portable form, validates, builds
plan.py Python adapter: schema -> expanded plan
decode.py Python adapter: schema.decode() plus file-reference resolution
static/defaults.js official runtime defaults and known property names
static/slider.js shared, dependency-free slider position arithmetic
static/normalize.js structural validation and normalization
static/contract.js semantic validation of the normalized plan
static/form.js orchestration: widgets, initial values, transport
static/inputs.js scalar widgets
static/fields.js the widget contract and the containers
static/widgets.css optional presentation
static/icons/*.svg the icons that stylesheet references
Each layer depends only on the ones above it: the core knows nothing about the
web layer, and the widgets know nothing about the plan protocol. slider.js is
a dependency-free leaf whose position arithmetic (firstSliderValue,
sliderReaches) is shared by contract.js (validation) and inputs.js
(IntWidget initialization), so the congruence is implemented once.
forward: function/dataclass -> pytypehint schema -> plan_of() -> expanded plan
-> normalize/check -> compileForm() -> widgets
reverse: widgets -> form.read() -> transport object -> (host carries it)
-> decode() = schema.decode() + file references
-> schema.build() or another consumer
decode() is the reverse-pipeline counterpart of plan_of(): the forward path
turns a schema into a plan, and the reverse path turns the transport object back
into something schema.build() accepts by exact type. It does not read the
portable representation itself — schema.decode() does, because that
representation is the core's. The int where the shape is Float, the ISO
string where it is Date/Time, the member name where it is an enum and the
$type/$value wrapper are all restored there, by the shape and never by a
value's content, and anything wrong is left for the core to reject. What
decode() adds on top is the one reading the core has no opinion about: when a
file_resolver is given it walks the already decoded tree and hands every file
reference to it. Storage is not a type question, so it cannot be the core's.
value() belongs to the components (the plain value they represent); read()
belongs to the orchestration (the transport shape a consumer expects, wrapping
union branches). A widget never decides whether its value travels plain,
inline or wrapped.
# Source of truth
pytypehint is the source of truth for types, constraints, defaults, unions,
the portable representation of a value, construction and final validation.
plan.py is the only adapter of that contract: it translates a compiled schema
into the flat, serializable plan contract, and when a guarantee the
core makes cannot be preserved in the browser it raises TypeError — no rule is
ever degraded silently. The browser may anticipate
errors to improve the experience, but it does not replace the core's validation
and is not a second source of truth.
That extends to the wire. The $type/$value wrapper, and the branch names
inside it, are not this library's invention: they are the same portable value
format the core writes in to_dict() and reads back in schema.decode(), named
by the core's own option_id(). plan.py still computes each branch's plain /
inline / wrapped mode, because the core does not publish that rule as public
API; what it computes is the same reading the core applies when it decodes. That
agreement is pinned end to end rather than branch by branch: the round-trip tests
drive a real browser transport object through decode() into schema.build(),
so a mode that stopped matching the core's reading would fail there. So the
transport is one format with one reader, not a web dialect someone has to
translate back.
That is why Python vocabulary stops at plan.py: Shape, Struct,
option_id, MISSING and the $type rules never surface in the widget API.
form.js consumes a hand-written plan and a plan from another backend
identically.
# Validation
structural validation and normalization
-> semantic validation of the normalized plan
-> form construction
The structural pass runs on the plan as received (types of present properties,
required and unknown properties, valid kind and mode, the hasDefault /
default pair). The semantic pass runs on the normalized plan, where every
option holds a real value. Checking types before anything else, and never
filling absences, keeps every rule free of "if absent, assume this": an omitted
property is <path>: is required, not an implied default.
A plan default is producer configuration, not a live user value, so it is
checked against the complete normalized node — both structurally compatible
and constraint-valid. A default that breaks a constraint fails checkPlan()
and never mounts; a value the user later types may stay representable while
invalid, and the widget reports that through hasError() / isReady().
A file node is a node like any other, and the compiler treats it that way.
There is no list of allowed file positions and no predicate asking whether a
shape "contains a file": a shape is representable when each of its nodes is, and
_options_node / compileNode() recurse over lists, optionals, choices and
structs without knowing what is at the bottom. The single file-specific decision
is a shortcut — a bare list[File] becomes one multiple file node instead of
a list of single-file widgets — and it is chosen by an exact shape match, so it
cannot capture anything wider.
A file default is the one case where the check stops short of the value on
purpose. checkPlan() owns the shape a browser can see — a str for a single
node, a list[str] for a multiple one, non-empty, extension-filtered, within
the file-count bounds — and stops there, because existence, regular-file and byte
size are not observable from a page. They are not observable from the core
either: FileHint reads the extension off the text and opens nothing, so those
questions have no answer anywhere in this stack. They belong to the host,
which is the only layer that knows where the bytes went, and it answers them in
decode(..., file_resolver=...). The compiler applies the default by calling the
widget's public setValue(), so a default and a runtime assignment are one
implementation rather than two.
checkPlan() validates everything a hand-written expanded plan needs to be
buildable — structure, the network boundary, and the semantics of the normalized
document including its defaults — but it does not restate schema-compiler
invariants that cannot affect runtime integrity (e.g. ordinary-range
multipleOf reachability). New semantic rules are still written once, in Python.
# Who owns which rule
Three layers, and the line between them is the same one everywhere:
pytypehint the typed contract: schema semantics, the portable
representation, reading it back into exact Python
(decode), resolve, build
pytypehintweb the contract adapted to a browser: the web plan, what
JavaScript and the page restrict, the widgets, the web
transport, and the resolution of file references
the host storage itself: existence, authorization, lifecycle, and
the authoritative size of bytes it owns
Each layer owns what only it can answer. The core cannot know that a 2⁵⁴
integer stops being an integer in JavaScript; the adapter cannot know whether a
reference still names bytes; the host cannot be asked what a Float means. The
table below is that split in detail.
| Layer | Owns |
|---|---|
pytypehint |
core schema validity: positive Rows and Step, non-empty and non-repeating Choices, non-empty ranges, ordinary and slider ranges that admit a valid multiple of MultipleOf, choices consistent with their constraints, unions without repeated option types or homonym discriminators (dataclasses or enums that would share a $type name) — and the portable representation of a value in both directions: what to_dict() writes, and what schema.decode() reads back into exact Python (the int under a Float, the ISO text under a Date/Time, the name under an enum, the $type/$value wrapper) |
plan_of() |
web representability and the exact converted browser contract it emits: every value a JavaScript safe integer, portable patterns, control combinations that ask for different widgets, unique branch option ids (defense in depth — the core compiler rejects that collision on every path it compiles, a field's union and a list's items alike, so this one only ever fires on a shape assembled by hand), nested objects with at least one field, exclusive integer bounds that still leave a value after integer conversion, sliders with both converted limits, sliders with a reachable valid position, converted defaults valid against their node including the slider Step grid |
normalizePlan() |
structural validity: every non-conditional property present (a missing one is <path>: is required), no unknown keys, types of values, the hasDefault / default pair, canonical scalar forms (a date value is a real calendar date, not merely the YYYY-MM-DD shape), and the structural shape invariants — non-empty field names, optional nodes only in list-item position, and optional / enabled field coherence |
checkPlan() |
everything a hand-written expanded plan needs to be buildable — structure, network boundary, and the semantics of the normalized document: coherent ranges, choices against their constraints, reachable slider positions, unique and non-empty field names within each scope, inline transport only on object branches, optional nodes only as list items, unique branch values, and every plan default validated against the full constraints of its node. It does not restate schema-compiler invariants that cannot affect runtime integrity (e.g. ordinary-range multipleOf reachability) |
decode() |
nothing about types: it hands the transport object to schema.decode() and adds only the reading the core has no opinion about — every file reference goes to the host's file_resolver, wherever a file node sits in the decoded tree |
schema.decode() and schema.build() |
the portable form as it arrives, and the values actually submitted |
| the host application | everything about stored bytes: where an upload went, whether a reference still stands for something, whether it has expired, whether it is this caller's to redeem, and any byte-size guarantee that has to hold authoritatively. decode(..., file_resolver=...) is the seam, and an exception raised there propagates unchanged |
The adapter does not restate the core's schema semantics for their own sake; it
re-checks only the parts that become concrete once the plan is converted for the
browser (exclusive bounds collapse after integer conversion, a slider's
reachability depends on the converted limits, safe-integer representability is a
browser property the core need not know). These are defense in depth checked in
Python before the plan leaves the process — the core validates its schema
semantics, and the adapter additionally verifies the exact converted browser
contract it is about to emit. The normalized semantic layer is broader still,
because a hand-written plan never passed through pytypehint; for a generated
plan those invariants were guaranteed upstream and the browser only confirms
them.
# Widget contract
Widget requires isEmpty() and value(); the base provides onChange(),
error() (default null), hasError() and isReady(). Scalar widgets add
_check()/_apply() and inherit setValue() (validate-and-apply); containers
are built from a plan, not reassigned, so they have no setValue(). Containers
use only that contract on their children — they never inspect a child's concrete
class. The full public API is in the JavaScript API.
| Container | Role |
|---|---|
Field |
label, description, optional toggle |
GroupWidget |
several named widgets travelling as one object |
ListWidget |
rows created by a factory, with minItems / maxItems |
ChoiceWidget |
one branch active at a time, selected by an opaque value (Shape.option_id() when the plan comes from Python) |
# Styling
widgets.css gives a polished appearance and is not part of the contract. Its
pth-* classes are a technical namespace with no global selectors, driven by
--pth-* design tokens; semantic behaviour and keyboard accessibility do not
depend on it.
Presentation stops at the stylesheet — including its icons, which are .svg
files under static/icons/ that the sheet addresses relative to itself. Nothing
is embedded as a data URI, so a host needs no img-src data:, and the runtime
builds no SVG of its own; it only has to serve the whole static directory.
Every rule starts at the .pth-root
container the host mounts the widgets in, colours resolve through
--pth-<name>-light / --pth-<name>-dark palette pairs into the active
--pth-<name> tokens the widgets read, and the theme is chosen by
prefers-color-scheme or by a data-pth-theme="light|dark" override on the
root or any ancestor. That is the whole theme API: it is not in the plan, not
in compileForm(), not in the transport and not in validation, and the runtime
carries no theme JavaScript at all — which is also why the automatic mode
cannot flash. See the JavaScript API for the tokens
and the theme contract.