Forms from Pydantic schemas¶
The schema that validates the request already describes the form: names, types, defaults, bounds, titles and descriptions. Writing that form twice — once in HTML, once in Pydantic — is what this recipe deletes.
When to use
- You have a
*CreateSchema/*UpdateSchemaand need the screen that fills it. - You want real validation (Pydantic's) with per-field messages and the reader's input kept.
- You do not want hand-written
<input>s drifting from the schema.
The whole round trip, in one file¶
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse, Response
from pydantic import BaseModel, EmailStr, Field
from tempest_fastapi_sdk.ssr import html_response
from tempest_fastapi_sdk.ui.forms import form_for, parse_form
app: FastAPI = FastAPI()
class SignupSchema(BaseModel):
"""The signup payload — and the description of the form."""
email: EmailStr
full_name: str = Field(min_length=3, max_length=50, description="Full name")
password: str = Field(min_length=8)
@app.get("/signup")
async def signup_form() -> Response:
"""Show the empty form."""
return html_response(
form_for(SignupSchema, action="/signup"),
title="Sign up",
stylesheets=["/static/app.css"],
)
@app.post("/signup")
async def signup(request: Request) -> Response:
"""Validate the submission, re-rendering the screen when it fails."""
result = await parse_form(SignupSchema, request)
if not result.ok:
return html_response(
form_for(
SignupSchema,
action="/signup",
values=result.values,
errors=result.errors,
form_errors=result.form_errors,
),
title="Sign up",
status_code=422,
stylesheets=["/static/app.css"],
)
user = result.unwrap()
return RedirectResponse(f"/welcome?email={user.email}", status_code=303)
Three calls:
form_forgenerates the<form>widget tree from the schema.parse_formreads the body, fixes what HTML cannot express, and validates.- Failed? The same
form_fortakesvalues=anderrors=from the result, and the screen comes back with the messages in place and the text preserved.
What each type produces¶
The table is the complete rule, in evaluation order:
| Schema field | Control |
|---|---|
ui override in json_schema_extra |
whatever it names |
Enum / Literal |
<select> |
bool |
<input type="checkbox"> |
int |
number with step="1" |
float / Decimal |
number with step="any" |
EmailStr |
email |
HttpUrl / AnyUrl |
url |
SecretStr, or a name containing password/senha |
password |
date / datetime / time |
date / datetime-local / time |
UUID |
text |
str with max_length > 255 |
<textarea> |
str |
text |
list[...] of enumerated values |
<select multiple> |
other list[...] |
<textarea>, one value per line |
Schema bounds become native validation attributes, so the browser stops the obvious before the round trip:
from pydantic import BaseModel, Field
from tempest_fastapi_sdk.ui.forms import fields_for
class ProductSchema(BaseModel):
"""A product with declared bounds."""
name: str = Field(min_length=3, max_length=50)
quantity: int = Field(ge=1, le=99)
specs = fields_for(ProductSchema)
assert specs[0].constraints == {"minlength": "3", "maxlength": "50"}
assert specs[1].constraints == {"min": "1", "max": "99", "step": "1"}
gt and lt become min and max
HTML only has inclusive bounds. A Field(gt=0) emits min="0",
a hint one step looser than the schema. Pydantic still rejects zero
on submit — the real validation was never in the browser.
The HTML is accessible by default¶
Each field renders like this:
<div class="tui-field tui-field--invalid">
<label class="tui-field__label" for="f-email">
<span>Email</span><span class="tui-field__required" aria-hidden="true">*</span>
</label>
<input name="email" id="f-email" class="tui-field__control" required="required"
aria-invalid="true" aria-describedby="f-email-error"
autocomplete="email" type="email" />
<p class="tui-field__error" id="f-email-error">already registered</p>
</div>
What you get for free: a <label for> bound to the control,
aria-invalid on the failing field, aria-describedby pointing at both
the hint and the message, autocomplete where the type implies one, and
the required asterisk marked aria-hidden (the real signal is
required).
Two forms on the same page? Give each its own id_prefix and the
id/for pairs stop colliding:
from pydantic import BaseModel
from tempest_fastapi_sdk.ui.forms import form_for
class SearchSchema(BaseModel):
"""A search filter."""
term: str
widget = form_for(SearchSchema, action="/search", method="get", id_prefix="search")
Tuning a field¶
For what introspection cannot guess, declare it on the schema itself:
from pydantic import BaseModel, Field
from tempest_fastapi_sdk.ui.forms import form_for
class ArticleSchema(BaseModel):
"""An article carrying presentation hints on the schema."""
title: str
body: str = Field(
default="",
json_schema_extra={
"ui": {
"control": "textarea",
"rows": 12,
"label": "Body",
"placeholder": "Write in Markdown…",
"help_text": "Markdown accepted",
},
},
)
owner_id: str = Field(default="", json_schema_extra={"ui": {"hidden": True}})
widget = form_for(ArticleSchema, action="/articles", exclude=["owner_id"])
Keys accepted under ui: control, input_type, label,
placeholder, help_text, autocomplete, rows, hidden, attrs.
When the schema is not enough: edit the specification¶
form_for is sugar over two steps. Split them when you want to patch the
generated form before rendering:
from dataclasses import replace
from pydantic import BaseModel
from tempest_fastapi_sdk.ui.forms import form_spec_for, render_form
class ContactSchema(BaseModel):
"""A contact message."""
email: str
message: str
spec = form_spec_for(ContactSchema, action="/contact", submit_label="Send message")
spec = replace(
spec,
fields=[
replace(field, placeholder="you@example.com") if field.name == "email" else field
for field in spec.fields
],
)
widget = render_form(spec)
FormSpec and FieldSpec are frozen dataclasses: replace() returns an
altered copy and nothing mutates under you.
Reading the submission¶
parse_form handles the three things HTML does differently from your
schema:
from fastapi import Request
from pydantic import BaseModel, Field
from tempest_fastapi_sdk.ui.forms import parse_form
class PreferencesSchema(BaseModel):
"""Account preferences."""
newsletter: bool = True
tags: list[str] = Field(default_factory=list)
nickname: str | None = None
async def save(request: Request) -> str:
"""Read the form and return a summary."""
result = await parse_form(PreferencesSchema, request)
if not result.ok:
return "invalid"
return f"{result.unwrap().newsletter}"
- An unchecked checkbox submits nothing — the absent key means
False, not "missing field". - A key the body never carried stays out of the payload, so the
schema default applies and a required field reports
Field requiredagainst itself. - Empty text in an optional field becomes
None, not"". <select multiple>repeats the key; a listtextareasends lines. Both become the samelist.
Values the server owns should never leave the browser's reach:
from fastapi import Request
from pydantic import BaseModel
from tempest_fastapi_sdk.ui.forms import parse_form
class OrderSchema(BaseModel):
"""An order."""
product: str
owner_id: str
async def create(request: Request, current_user_id: str) -> str:
"""Read the order, ignoring any owner the form submitted."""
result = await parse_form(
OrderSchema,
request,
exclude=["owner_id"],
extra={"owner_id": current_user_id},
)
return "ok" if result.ok else "invalid"
exclude= forbids reading that field from the body and extra= injects
the server-side value. Keys that do not belong to the schema (a CSRF
token, HTMX bookkeeping) are ignored either way.
To reword Pydantic's messages, pass error_message:
from collections.abc import Mapping
from typing import Any
MESSAGES: dict[str, str] = {
"string_too_short": "Too short.",
"value_error": "Invalid value.",
"missing": "This field is required.",
}
def translate(error: Mapping[str, Any]) -> str:
"""Map a raw Pydantic error to the message shown on screen."""
return MESSAGES.get(str(error["type"]), str(error["msg"]))
The look comes along¶
The classes a form emits (tui-form, tui-field, …) already have rules,
written against the design tokens:
from tempest_fastapi_sdk.ui import app_stylesheet
from tempest_fastapi_sdk.ui.css import make_css_router
router = make_css_router(app_stylesheet())
app_stylesheet() includes the form and component rules. If you assemble
the sheet piece by piece, use form_stylesheet(). To plug into a design
system of your own, pass classes=FormClasses(...) to both form_for
and form_stylesheet — the names follow.
Limits, measured¶
Two field kinds stop generation on purpose
- A nested model raises
UnsupportedFieldError: it needs a form of its own, or anexclude=and a server-set value. - A binary field (
bytes) raises too: an upload isUploadFileon the route, not a value coerced from a string.
Failing loudly beats rendering a control that can never complete the round trip.
Why not tempest_core's Input / Dropdown
Measured against the HTML renderer: Form renders as a <div>,
Input renders without a name (so nothing is submitted) and
Dropdown / TextArea render as an empty <div> — those widgets
belong to the reactive client, not to SSR. So ui.forms emits the
elements directly through the documented tag/attrs escape hatch.
The measurement is pinned in tests/ui/test_core_contract.py.
Recap¶
- The schema describes the form;
form_forrenders it andparse_formreads it back. - A validation failure becomes per-field messages with the reader's input preserved — no extra server-side state.
- Schema bounds become native attributes; the real validation stays in Pydantic.
- Fine-tune through
json_schema_extra={"ui": {...}}or by patching theFormSpecwithreplace(). exclude=+extra=keep server-owned values out of the browser's reach.
See also: UI layer » and Typed CSS ».