How to Create Custom Form Widgets in Air: A Complete Guide
Air renders HTML forms from Pydantic models using callable widgets that return HTML strings or air.Raw objects, and you can customize form output by providing your own widget function via Model.to_form(), subclassing AirForm, or setting the widget attribute directly.
Air is a Python web framework that generates HTML forms directly from Pydantic models using a widget-based architecture. When you need to create custom form widgets in Air to modify the generated markup, inject CSS classes, or add JavaScript hooks, you can override the default rendering pipeline while preserving the framework's automatic field generation capabilities.
Understanding the Widget Architecture in Air
Air delegates all HTML form generation to a widget callable defined in src/air/forms.py. By default, the framework uses default_form_widget (defined at line 441), which accepts a Pydantic model class and returns the standard HTML form markup.
A custom widget must implement the signature:
(model: Type[BaseModel], data: dict | None, errors: dict | None, includes: Iterable[str] | None)
The callable must return either a plain HTML string or an air.Raw node to prevent additional escaping. Inside your custom widget, you can call default_form_widget to obtain the base markup and then wrap, filter, or extend it.
Three Methods to Create Custom Form Widgets in Air
Air provides three equivalent injection points for custom widgets, allowing you to choose the approach that best fits your application structure.
Method 1: Passing a Widget to Model.to_form()
The most direct way to create custom form widgets in Air is passing a callable to the widget parameter of to_form(). This approach is ideal for one-off customizations where you do not want to create a permanent subclass.
According to the test suite in tests/test_forms.py (lines 619–632), this pattern validates that the custom callable receives the correct arguments and returns the expected HTML structure.
Method 2: Overriding the Widget Property on an AirForm Subclass
For reusable form components, subclass AirForm and override the widget property. As implemented in src/air/forms.py (lines 822–828), the property injection logic ensures that your custom callable replaces the default renderer for all instances of that form class.
When using this method, the callable receives self (the form instance) as the first argument, followed by the standard four parameters.
Method 3: Dynamically Assigning a Widget to an Instance
You can also modify the widget after instantiation by setting the widget attribute directly on an AirForm instance. This pattern, demonstrated in examples/src/forms__AirForm__widget.py, allows for dynamic alterations based on runtime conditions, such as user roles or feature flags.
Step-by-Step Implementation Guide
To create custom form widgets in Air that integrate cleanly with the framework's validation and error handling, follow these four steps:
-
Define the widget function – Accept the four standard arguments (or
selfplus the four if implementing as a method). Importdefault_form_widgetfromair.formsto access the base renderer. -
Generate base form markup – Call
default_form_widget(model, data, errors, includes)to retrieve the standard HTML. This preserves Air's automatic field generation and error display logic. -
Inject custom markup – Wrap the base HTML in container elements, add CSS classes for styling frameworks like Tailwind or Bootstrap, insert data attributes for JavaScript hooks, or filter specific fields using the
includesparameter. -
Return the result – Output either a plain string or an
air.Rawobject. Usingair.Rawensures Air does not escape your HTML entities during final rendering.
Practical Code Examples
The following examples demonstrate the three injection methods using the actual patterns found in the Air repository.
Example 1: Custom Widget via to_form()
This pattern, validated in tests/test_forms.py (lines 619–632), shows how to pass a widget function directly to the model:
from air.forms import default_form_widget
import air
def custom_widget(model, data=None, errors=None, includes=None):
# Re‑use the standard widget and then wrap it
base_html = default_form_widget(model, data, errors, includes)
return air.Raw(f"""
<section class="my‑custom‑form">
<h2>Contact us</h2>
{base_html}
</section>
""")
class ContactModel(air.AirModel):
name: str
email: str
message: str
# Render the form with the custom widget
html = ContactModel.to_form(widget=custom_widget)
Example 2: Subclassing AirForm with Property Override
As implemented in src/air/forms.py (lines 822–828), you can create a reusable form class with a custom widget property:
import air
from air.forms import default_form_widget
class ContactForm(air.AirForm):
@property
def widget(self):
# ``self`` is the AirForm instance, the remaining args are the same as above
def wrapper(model, data=None, errors=None, includes=None):
base = default_form_widget(model, data, errors, includes)
return air.Raw(f"<div class='wrapper'>{base}</div>")
return wrapper
class ContactModel(air.AirModel):
name: str
email: str
message: str
# The model automatically picks up ContactForm.widget
html = ContactModel.to_form()
Example 3: Dynamic Widget Assignment
For runtime customization, assign a widget to an existing instance as shown in examples/src/forms__AirForm__widget.py:
import air
from air.forms import default_form_widget
def fancy_widget(model, data=None, errors=None, includes=None):
return air.Raw(f"<form class='fancy'>{default_form_widget(model, data, errors, includes)}</form>")
class SimpleModel(air.AirModel):
title: str
# Build the form first
form = SimpleModel.to_form()
# Attach a new widget
form.widget = fancy_widget
html = form()
Key Source Files and Implementation Details
When you create custom form widgets in Air, you will work primarily with these source files:
-
src/air/forms.py– Contains thedefault_form_widgetfunction (line 441) that generates standard HTML markup, and the property injection logic (lines 822–828) that binds widgets toAirForminstances. -
tests/test_forms.py– Provides unit tests demonstrating widget injection patterns, particularly lines 619–632 which validate thewidget=argument approach. -
examples/src/forms__AirForm__widget.py– Runnable example showing dynamic widget assignment to form instances. -
examples/src/models__AirModel__to_form.py– Example demonstrating theto_form()method with custom widget arguments.
Summary
To create custom form widgets in Air, remember these key points:
- A widget is any callable that accepts a Pydantic model class, optional data, errors, and field includes, then returns HTML or an
air.Rawobject. - You can inject custom widgets through three mechanisms: passing
widget=toModel.to_form(), overriding thewidgetproperty on anAirFormsubclass, or dynamically setting the attribute on an instance. - Always leverage
default_form_widgetfromsrc/air/forms.pyas your base renderer to maintain Air's built-in field generation and error handling. - Return
air.Rawwhen your widget constructs HTML manually to prevent the framework from escaping your markup.
Frequently Asked Questions
How do I prevent Air from escaping my custom HTML in a widget?
Return an air.Raw object instead of a plain string. When your widget function returns air.Raw(html_string), Air treats the content as safe HTML and does not apply additional escaping during the final rendering phase.
Can I use the same custom widget for multiple Pydantic models?
Yes. Because the widget callable receives the model class as its first argument, you can define the function once and reuse it across any Pydantic model. Pass it to different models via Model.to_form(widget=your_function) or assign it to a shared AirForm subclass used by those models.
What arguments does a custom widget function receive?
According to the implementation in src/air/forms.py, your widget must accept four parameters: model (the Pydantic model class), data (optional dictionary of current values), errors (optional validation errors), and includes (iterable of field names to render). When overriding the widget property on an AirForm subclass, the callable receives self as the first argument followed by these four parameters.
How do I modify only part of the form HTML without rewriting everything?
Call default_form_widget inside your custom widget to retrieve the base HTML string, then use standard Python string manipulation or templating to inject your modifications. For example, wrap the base HTML in a container div, add CSS classes to specific field labels, or insert JavaScript event handlers before returning the final markup as an air.Raw object. This approach preserves Air's automatic field generation while allowing targeted customization.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →