Forms & Validation

Parse POST bodies into typed dataclasses, validate them in one pass, and re-render the form with errors.

Page actions AI-ready formats and sharing
Open LLM text
Share with AI
Ask Claude Ask ChatGPT Ask Gemini Ask Copilot

Overview

Chirp parses a POST body into form data, binds it to a frozen dataclass with type coercion, and validates it in one pass. You declare a dataclass, attach validation rules to its fields withAnnotated, and call form_or_errors(). On bad input it returns aValidationError— a 422 fragment that re-renders your form with per-field messages. On good input it returns your typed instance.

If you come from Flask or Django, this replaces therequest.formdict-juggling and theForm/ModelFormclass with one declarative schema.

The happy path: declare, bind, validate

Declare a frozen dataclass with frozen=True, slots=True, attach rules with Annotated, and let form_or_errors() do binding and validation in the same call.

New in 0.8
from dataclasses import dataclass
from typing import Annotated

from chirp import App, Request, form_or_errors, FormAction, ValidationError
from chirp.validation import required, max_length

app = App()


@dataclass(frozen=True, slots=True)
class TaskForm:
    title: Annotated[str, required, max_length(100)]
    description: str = ""


@app.route("/tasks", methods=["POST"])
async def add_task(request: Request):
    result = await form_or_errors(request, TaskForm, "tasks.html", "task_form")
    if isinstance(result, ValidationError):
        return result  # 422 fragment with per-field errors + raw values

    # result is a TaskForm — title is bound and validated.
    save_task(result.title, result.description)
    return FormAction("/tasks")

A rule in the Annotated metadata is any callable matching (str) -> str | None. Non-callable metadata is ignored, so the same annotation can carry doc strings or sentinels for other tooling. A plain dataclass with noAnnotatedrules runs only binding — behavior is unchanged.

Rule errors and binding (type-coercion) errors merge per field: a badint on one field and a failed rule on another both land inresult.context["errors"], and the raw submitted values come back inresult.context["form"]for re-population. Falsy-but-valid values stay valid —"0" satisfies required, and an omittedlist[str] binds to []without being flagged.

Coerced field types

form_or_errors() (via form_from()) coerces each field to its declared type. Fields without a default are required; a missing required field yields an error.

Annotation Coerced from a form string to
str stripped string
int / float number (ValueError→ field error)
bool True for "true"/"1"/"yes"/"on"
datetime.date / datetime.datetime ISO 8601
decimal.Decimal / uuid.UUID parsed value
enum.Enumsubclass member by value, then by name
list[T] repeated fields (checkbox groups, multi-selects); missing →[]

Extra template context and retargeting

form_or_errors() passes extra keyword arguments straight to the ValidationError template context, andretarget sets an HX-Retargetheader so htmx swaps errors into a different element than the trigger.

result = await form_or_errors(
    request, TaskForm, "board.html", "add_form",
    retarget="#errors",   # optional HX-Retarget header
    columns=COLUMNS,      # extra template context for the re-render
)

Built-in validation rules

Import rules fromchirp.validation. Each returns an error message string on failure orNoneon success.

Rule Field must…
required be present and non-empty ("0"counts as present)
min_length(n) / max_length(n) be at least / at most n characters
email match a basic email structure
url be anhttp/httpsURL
matches(pattern, message=None) match a regex pattern
one_of(*choices) be one of the given choices
integer / number parse as a whole number / any number

Custom rules

A rule is any callable returning an error string orNone. Attach it the same way as a built-in:

def positive(value: str) -> str | None:
    try:
        if float(value) <= 0:
            return "Must be a positive number"
    except ValueError:
        return "Must be a number"
    return None


@dataclass(frozen=True, slots=True)
class PaymentForm:
    amount: Annotated[str, required, positive]

File uploads

Multipart forms expose uploaded files viaform.files. Read the bytes, or stream to disk withsave()(which sanitizes the destination basename against path traversal). String accessors (form["avatar"], form.get("avatar")) raise FormFileFieldErrorfor file-only names — they never report a present upload as missing.

@app.route("/upload", methods=["POST"])
async def upload(request: Request):
    form = await request.form()
    avatar = form.files.get("avatar")  # UploadFile or None
    if avatar is not None:
        await avatar.save(UPLOAD_DIR / avatar.filename)
    return FormAction("/profile")

Form-field macros

Chirp ships Kida macros for labelled fields with automatic error display. They read the sameerrors dict that ValidationError carries, add a field--errorclass to the wrapper, and emit<span class="field-error">plus ARIA attributes (aria-invalid, aria-describedby, aria-required) when a field has messages.

{% from "chirp/forms.html" import text_field, textarea_field %}

<form hx-post="/tasks" hx-target="#task_form" hx-swap="outerHTML">
    {{ text_field("title", form.title ?? "", label="Title",
                  errors=errors, required=true) }}
    {{ textarea_field("description", form.description ?? "",
                      label="Description", errors=errors) }}
    <button type="submit">Create</button>
</form>
Macro Renders
text_field(name, value, label, errors, type, required, placeholder, attrs) Text input — settype for password, email, etc.
textarea_field(name, value, label, errors, rows, required, placeholder) Multi-line text
select_field(name, options, selected, label, errors, required) Dropdown — each option needs.value and .label
checkbox_field(name, checked, label, errors) Checkbox with label
hidden_field(name, value) Hidden input

Gotchas

Advanced & alternate flows

The happy path above covers most forms. Open these when you need the lower-level pieces.

Experimental WebMCP form projection

An existing typed form can opt into the declarative WebMCP preview while keeping its normal browser and htmx submission path:

from dataclasses import dataclass, field

from chirp import WebMCPForm
from chirp.contracts import FormContract, contract

@dataclass(frozen=True, slots=True)
class SearchForm:
    query: str = field(metadata={
        "webmcp_control": "search",
        "webmcp_description": "Words to search for",
        "webmcp_min_length": 2,
    })

@app.route("/search", methods=["POST"])
@contract(form=FormContract(
    SearchForm,
    "search.html",
    "search_form",
    webmcp=WebMCPForm("search.run", "Search the catalog"),
))
async def search(request):
    form = await form_from(request, SearchForm)
    ...
<form method="post" action="/search"{{ webmcp_form_attrs("search.run") }}>
  {{ csrf_field() }}
  <input{{ webmcp_control_attrs("search.run", "query") }}>
  <button type="submit">Search</button>
</form>

The helpers emit escaped toolname, tooldescription, toolparamdescription, and native input attributes from the frozen contract. They do not register JavaScript tools or bypass the server. Mutation forms cannot enabletoolautosubmit, unsupported controls fail during startup, and browsers without WebMCP continue to use the same complete form.

app.check()reports malformed or duplicate operation metadata, control/helper drift, missing native submission fallback, missing route/template/block, and mutation CSRF/security gaps aswebmcpERRORs. Each error names the operation and source location plus the concrete repair; there are no WebMCP style-only warnings. Runchirp check myapp:app --json --coverageto include declared and compiled projection/parameter counters in CI baselines and contract diffs.

Compatibility and security boundary

WebMCP remains experimental. Chrome documents an origin trial starting in Chrome 149 and local development through chrome://flags/#enable-webmcp-testing; origin-trial tokens belong to the application origin and are not shipped by Chirp. The browser smoke lane pins Playwright 1.61.0 / Chrome for Testing 149.0.7827.55 and proves that flag-off or Permissions-Policy: tools=()leaves the native form complete. Chrome requires a visible browsing context for tool calls, so Chirp does not claim headless agent invocation coverage.

Chirp stays pinned to proposal commit 0b676d27a08aafd3b4f8a709756eeeab342fd9bd. Newer preview additions such as <select> synthesis and SubmitEvent.agentInvoked/respondWith()are not in the supported contract. Browser discovery never bypasses route authentication, authorization, session expiry, CSRF, or server validation. All mutation tools omittoolautosubmit; normal and htmx submissions still reach the same handler.