Parsing Forms
Access form data from POST requests:
@app.route("/submit", methods=["POST"])
async def submit(request: Request):
form = await request.form()
name = form.get("name", "")
email = form.get("email", "")
# ... process form data
Note
Multipart form parsing requires theforms extra: pip install bengal-chirp[forms]. URL-encoded forms work without extras.
File Uploads
For multipart forms with file uploads:
@app.route("/upload", methods=["POST"])
async def upload(request: Request):
form = await request.form()
file = form["avatar"]
content = await file.read()
filename = file.filename
# ... save file
Dataclass Binding
form_from() binds form data to a frozen dataclass. Define the shape, and Chirp handles type coercion for str, int, float, and bool:
from dataclasses import dataclass
from chirp import form_from, FormBindingError
@dataclass(frozen=True, slots=True)
class TaskForm:
title: str
description: str = ""
priority: str = "medium"
@app.route("/tasks", methods=["POST"])
async def add_task(request: Request):
try:
form = await form_from(request, TaskForm)
except FormBindingError as e:
# e.errors is dict[str, list[str]]
return ValidationError("tasks.html", "form", errors=e.errors)
# form.title, form.description, form.priority are populated
Fields without defaults are required — if missing, FormBindingErroris raised with structured errors.
Bind or Error
form_or_errors() combines form_from() and ValidationErrorinto a single call, eliminating the try/except boilerplate:
from chirp import form_or_errors, ValidationError
@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
# result is TaskForm — proceed with business logic
save_task(result.title, result.description)
On binding failure, it returns a ValidationErrorpre-loaded with the errors and the raw form values for re-population. Extra template context is passed through:
result = await form_or_errors(
request, TaskForm, "board.html", "add_form",
retarget="#errors", # optional HX-Retarget header
columns=COLUMNS, # extra template context
)
Re-populating Forms
When validation fails, useform_values() to convert a dataclass back to a dict[str, str]for template re-population:
from chirp import form_values
# After binding succeeds but business validation fails:
errors = validate_task(result)
if errors:
return ValidationError(
"tasks.html", "task_form",
errors=errors,
form=form_values(result), # {"title": "...", "description": "...", ...}
)
form_values() also accepts a Mapping (dict, FormData, etc.) and converts all values to strings. None becomes "".
Form Field Macros
Chirp ships template macros for common form fields. Import them fromchirp/forms.html:
{% from "chirp/forms.html" import text_field, textarea_field, select_field %}
<form hx-post="/tasks" hx-target="#task-form">
{{ 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>
Each macro renders a labelled field with automatic error display. When errors contains messages for that field, a field--error CSS class is added and <span class="field-error">elements are rendered.
Available macros:
| Macro | Description |
|---|---|
text_field(name, value, label, errors, type, required, placeholder, attrs) |
Text input (also works forpassword, email, etc. via type) |
textarea_field(name, value, label, errors, rows, required, placeholder) |
Multi-line text |
select_field(name, options, selected, label, errors, required) |
Dropdown (options need.value and .label) |
checkbox_field(name, checked, label, errors) |
Checkbox with label |
hidden_field(name, value) |
Hidden input |
The macros work with thefield_errors filter and the errors dict shape from FormBindingError and ValidationResult.
Validation
Chirp includes a validation module for checking form data:
from chirp.validation import ValidationResult
from chirp.validation.rules import required, min_length, email
@app.route("/register", methods=["POST"])
async def register(request: Request):
form = await request.form()
result = ValidationResult()
result.check("name", form.get("name", ""), [required, min_length(2)])
result.check("email", form.get("email", ""), [required, email])
result.check("password", form.get("password", ""), [required, min_length(8)])
if not result.is_valid:
return ValidationError("register.html", "form_errors",
errors=result.errors,
form=form,
)
# ... create user
return Redirect("/welcome")
Validation Rules
Built-in validation rules:
| Rule | Description |
|---|---|
required |
Field must not be empty |
min_length(n) |
Minimum string length |
max_length(n) |
Maximum string length |
email |
Valid email format |
matches(pattern) |
Regex pattern match |
Custom Rules
A validation rule is any callable that returns an error string orNone:
def must_be_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
result.check("amount", form.get("amount", ""), [required, must_be_positive])
ValidationError Return Type
ValidationErrorrenders a fragment with a 422 status code, designed for htmx form error handling:
return ValidationError("register.html", "form_errors",
errors=result.errors,
form=form,
)
The template:
{% block form_errors %}
<div id="errors" class="error-list">
{% for field, messages in errors.items() %}
{% for msg in messages %}
<p class="error">{{ field }}: {{ msg }}</p>
{% endfor %}
{% endfor %}
</div>
{% endblock %}
With htmx, you can target the error block specifically:
<form hx-post="/register" hx-target="#errors" hx-swap="outerHTML">
<input name="name" placeholder="Name">
<input name="email" placeholder="Email">
<input name="password" type="password" placeholder="Password">
<button type="submit">Register</button>
<div id="errors"></div>
</form>
Next Steps
- Database -- Async database access
- Fragments -- Fragment rendering for forms
- Return Values -- ValidationError and other types