Forms#
Asok provides a declarative form system that generates HTML and handles validation automatically.
Basic usage#
# src/pages/contact/page.py
from asok import Request, Form
def render(request: Request):
form = Form({
'name': Form.text('Name', 'required|min:2', placeholder='Your name'),
'email': Form.email('Email', 'required|email', placeholder='you@example.com'),
'message': Form.textarea('Message', 'required|min:10'),
}, request)
if form.validate():
# All fields are valid — process the data
request.flash('success', 'Sent!')
request.redirect('/contact')
return request.html('page.html', form=form)
Form constructor#
Form(fields_dict: dict, request: Request = None)
fields_dict is required. request is optional — if omitted, the form acts as a template that you bind later. There are four equivalent ways to wire a form to a request:
# 1. Bound at construction (most common in pages)
form = Form({'name': Form.text('Name', 'required')}, request)
# 2. Bind later with .bind(request)
form = Form({'name': Form.text('Name', 'required')})
form.bind(request)
if form.validate(): ...
# 3. Pass the request directly to .validate()
form = Form({'name': Form.text('Name', 'required')})
if form.validate(request): ...
# 4. Share globally and let asok auto-bind per request
# (see "Reusable forms" below)
app.share(my_form=Form({'name': Form.text('Name', 'required')}))
Calling form.validate() without ever binding a request raises RuntimeError.
<!-- src/pages/contact/page.html -->
<form method="POST">
{{ request.csrf_input() }}
{{ form.name }}
{{ form.email }}
{{ form.message }}
<button type="submit">Send</button>
</form>
{{ form.name }} generates the full block: label + input + error message.
Generated HTML#
<!-- Without error -->
<div class="form-group">
<label for="name">Name</label>
<input type="text" id="name" name="name" value="" placeholder="Your name">
</div>
<!-- With error -->
<div class="form-group">
<label for="name">Name</label>
<input type="text" id="name" name="name" value="J" class="input-error" placeholder="Your name">
<div class="form-error">Minimum 2 characters.</div>
</div>
Render parts separately#
For more control over the HTML:
{{ form.name.label }} → <label for="name">Name</label>
{{ form.name.input }} → <input type="text" ...>
{{ form.name.error }} → <div class="form-error">...</div> (or empty)
Custom classes (Tailwind, etc.)#
Pass attributes when rendering to override defaults:
<!-- Full field with custom input class -->
{{ form.name(class_="mb-6") }}
<!-- Individual parts -->
{{ form.name.label(class_="text-sm font-bold text-gray-700") }}
{{ form.name.input(class_="border rounded-lg px-4 py-2 w-full") }}
{{ form.name.error(class_="text-red-500 text-xs mt-1") }}
Since class is a Python reserved word, use class_ — the trailing underscore is stripped automatically. This works for any attribute: for_ becomes for, etc.
Field types#
Form.text('Label', 'rules', placeholder='...')
Form.email('Label', 'rules')
Form.password('Label', 'rules')
Form.textarea('Label', 'rules')
Form.number('Label', 'rules')
Form.file('Label', 'rules')
Form.hidden('rules')
Form.checkbox('Label', 'rules')
Form.select('Label', [('val', 'Display'), ...], 'rules')
Form.dropdown('Label', items, searchable=True)
Form.radio('Label', [('val', 'Display'), ...], 'rules')
Form.title('Section Name') # Renders <h3>, no input
Dropdown example#
A premium, searchable dropdown that handles lists of strings, dicts, or Models.
form = Form({
'fruit': Form.dropdown('Select a fruit', ['Apple', 'Banana', 'Mango'], searchable=True),
}, request)
Select example#
form = Form({
'country': Form.select('Country', [
('fr', 'France'),
('us', 'United States'),
('uk', 'United Kingdom'),
], 'required'),
}, request)
Radio example#
form = Form({
'plan': Form.radio('Plan', [
('free', 'Free'),
('pro', 'Pro — $9/mo'),
], 'required'),
}, request)
Checkbox example#
form = Form({
'agree': Form.checkbox('I agree to the terms', 'required'),
}, request)
Title (section divider)#
form = Form({
'section1': Form.title('Personal Information'),
'name': Form.text('Name', 'required'),
'section2': Form.title('Account'),
'email': Form.email('Email', 'required|email'),
}, request)
Generate a form from a Model#
For CRUD pages, you usually don't want to repeat the field schema that's already in your Model. Form.from_model() builds the form schema by inspecting the Model's Field definitions:
from asok import Form
from models.contact import Contact
def render(request):
form = Form.from_model(Contact, request)
if form.validate():
Contact.create(**form.data)
request.flash('success', 'Saved!')
request.redirect('/contacts')
return request.html('page.html', form=form)
For an edit page:
def render(request: Request):
contact = Contact.find(id=request.params.get('id'))
form = Form.from_model(Contact, request).fill(contact)
if form.validate():
contact.update(**form.data)
request.flash('success', 'Updated!')
request.redirect(f'/contacts/{contact.id}')
return request.html('page.html', form=form)
Signature#
Form.from_model(
model,
request: Request = None,
include_fields: list = None,
exclude_fields: list = None,
)
include_fields=['name', 'email']— only generate these fieldsexclude_fields=['internal_notes']— generate all fields except these
Auto-mapping rules#
| Model field | Form field |
|---|---|
Field.String(max_length=N) | text input with maxlength=N and max:N rule |
Field.Text() | textarea |
Field.Email() | email input with email rule |
Field.Integer() | number input |
Field.Float(precision=N) | number input with step="0.01" (per precision) |
Field.Boolean() | checkbox |
Field.Date() | date input (when name matches conventions) |
Field.ForeignKey(Other) | select with all rows from the related model |
Field.File() | file input |
Field.Password() | password input (rules cleared so edits don't force re-entry) |
Validation rules are derived automatically: nullable=False adds required, Email adds email, max_length adds max:N.
Custom labels, rules and messages#
Fields can define custom labels, validation rules, and error messages that are automatically used when generating forms:
# src/models/contact.py
class Contact(Model):
__tablename__ = "contacts"
name = Field.String(
max_length=100,
nullable=False,
label="Full Name", # Custom label
rules="min:4|alpha_spaces", # Custom validation rules
messages={
"required": "Please enter your full name",
"min": "Name must be at least 4 characters",
"max": "Name cannot exceed 100 characters",
"alpha_spaces": "Only letters and spaces allowed"
}
)
email = Field.Email(
max_length=100,
nullable=False,
label="Email Address",
messages={
"required": "Email is required",
"email": "Please provide a valid email"
}
)
message = Field.Text(
nullable=False,
label="Your Message",
rules="min:10",
messages={
"required": "Message is required",
"min": "Message must be at least 10 characters"
}
)
When you generate a form from this model:
form = Form.from_model(Contact, request)
The form automatically: - Uses custom labels ("Full Name" instead of "Name") - Combines auto-generated + custom rules (required|max:100|min:4|alpha_spaces) - Displays custom error messages when validation fails
Without custom label:
name = Field.String(max_length=100) # Label will be "Name" (auto-generated from field name)
With custom label:
name = Field.String(max_length=100, label="Full Name") # Label will be "Full Name"
See ORM Basics for more details on defining these at the model level.
Auto-excluded fields#
These are skipped automatically (you don't need to put them in exclude_fields):
idField.CreatedAt()/Field.UpdatedAt()(timestamps)Field.SoftDelete()Field.Slug(populate_from=...)when auto-populated
Customizing further#
Form.from_model() returns a regular Form, so you can mutate the schema after creation if needed:
form = Form.from_model(Contact, request, exclude_fields=['internal_notes'])
form._fields['email'].attrs['autofocus'] = True
Translating labels (i18n)#
Use request.__() to translate form labels:
def render(request: Request):
__ = request.__
form = Form({
'name': Form.text(__('form_name'), 'required|min:2'),
'email': Form.email(__('form_email'), 'required|email'),
'message': Form.textarea(__('form_message'), 'required|min:10'),
}, request)
// src/locales/en.json
{ "form_name": "Your name", "form_email": "Your email address", "form_message": "Your message" }
// src/locales/fr.json
{ "form_name": "Votre nom", "form_email": "Votre adresse mail", "form_message": "Votre message" }
Validation error messages are also translated automatically — see Validation.
Custom error messages#
Form.text('Name', 'required|min:2', messages={
'required': 'Please enter your name.',
'min': 'Name is too short.',
})
Pre-filling for edit forms#
Use form.fill(obj_or_dict) to populate fields from an existing record. It only applies on non-POST requests, so submitted values are preserved when re-rendering after a failed validation.
def render(request):
user = User.find(id=request.params['id'])
form = Form({
'name': Form.text('Name', 'required|min:2'),
'email': Form.email('Email', 'required|email'),
}, request).fill(user)
if form.validate():
user.update(**form.data)
request.flash('success', 'Updated!')
request.redirect(f'/users/{user.id}')
return request.html('page.html', form=form)
fill() accepts a model instance or a dict, and returns self for chaining.
Accessing values after POST#
The cleanest way is form.data — a dict of all field values, ready to pass to a model:
if form.validate():
User.create(**form.data)
Field-by-field access works too:
if form.validate():
email = form.email.value # via the form object
name = request.form['name'] # via the raw POST dict
Checking errors#
form.errors # {'name': 'Minimum 2 characters.', 'email': 'Invalid email address.'}
Reset form#
form.reset() clears all field values and errors. Returns self for chaining.
if form.validate():
request.flash('success', 'Sent!')
form.reset()
After reset(), the rendered form is empty, as if the page was loaded for the first time.
Reusable forms (newsletter, search, etc.)#
To embed the same form on multiple pages — e.g. a newsletter form in the footer of every page — declare it once with app.share() and add a dedicated page to handle the POST.
1. Declare the form globally#
# wsgi.py
from asok import App, Form
app = App()
app.share(
newsletter_form=Form({
'email': Form.email('Email', 'required|email', placeholder='you@example.com'),
}),
)
Form({...}) (without a request) creates a template — Asok auto-bind a fresh instance per request. Now newsletter_form is available in every template.
2. Dedicated page that handles submission#
# src/pages/newsletter/page.py
from asok import Request
from models.subscriber import Subscriber
def render(request: Request):
# Using shared_form instead of shared for full IDE autocompletion
form = request.shared_form('newsletter_form')
if form.validate():
Subscriber.first_or_create(**form.data)
request.flash('success', 'Subscribed!')
form.reset()
return request.html('page.html')
<!-- src/pages/newsletter/page.html -->
{% extends "html/base.html" %}
{% block main %}
{% include "html/newsletter_form.html" %}
{% endblock %}
3. The reusable partial#
<!-- src/partials/html/newsletter_form.html -->
<form method="POST" action="/newsletter" data-block="#newsletter-block">
{{ request.csrf_input() }}
<div id="newsletter-block">
{% for msg in get_flashed_messages() %}
<div class="flash {{ msg.category }}">{{ msg.message }}</div>
{% endfor %}
{{ newsletter_form.email }}
<button type="submit">Subscribe</button>
</div>
</form>
4. Drop it anywhere#
<!-- src/partials/html/footer.html -->
<footer>
<h3>Stay updated</h3>
{% include "html/newsletter_form.html" %}
</footer>
IDE Autocompletion for shared variables#
When using request.shared(name), your IDE usually doesn't know the type of the returned object. Asok provides two ways to get full IntelliSense:
1. Specialized helper (Preferred for Forms)#
Use request.shared_form(name) instead of the generic shared(). It is explicitly typed to return a Form instance.
form = request.shared_form('contact_form')
# Now, form.validate(), form.reset(), etc. are suggested by your IDE
2. Manual type hint#
For other types of objects, pass the expected class as the second argument to request.shared():
from models.user import User
user = request.shared('current_user', User)
# IDE now knows 'user' is an instance of User
Partial update (no full page reload)#
Add data-block on the <form> to submit via fetch and swap only the block content in the DOM:
<!-- page.html -->
{% extends "html/base.html" %}
{% block main %}
{% for msg in get_flashed_messages() %}
<div class="flash {{ msg.category }}">{{ msg.message }}</div>
{% endfor %}
<form method="POST" data-block="main">
{{ request.csrf_input() }}
{{ form.name }}
{{ form.email }}
{{ form.message }}
<button type="submit">Send</button>
</form>
{% endblock %}
# page.py
def render(request: Request):
form = Form({
'name': Form.text('Name', 'required|min:2'),
'email': Form.email('Email', 'required|email'),
'message': Form.textarea('Message', 'required|min:10'),
}, request)
if form.validate():
request.flash('success', 'Sent!')
form.reset()
return request.html('page.html', form=form)
Was this page helpful?