Skip to content

Forms

Display and Validation of HTML forms. Powered by pydantic.

Pro-tip: Always validate incoming data.

AirForm

AirForm(initial_data=None)

A form handler that validates incoming form data against a Pydantic model. Can be used with awaited form data or with FastAPI's dependency injection system.

Example:

class FlightModel(BaseModel):
    flight_number: str
    destination: str

class FlightForm(air.AirForm):
    model = FlightModel

@app.post("/flight")
async def flight_form(request: air.Request):
    "Awaited form data"
    flight = await FlightForm.from_request(request)
    if flight.is_valid:
        return air.Html(air.H1(flight.data.flight_number))
    return air.Html(air.H1(air.Raw(str(len(flight.errors)))))

@app.post("/flight-depends")
async def flight_form_depends(flight: Annotated[FlightForm, Depends(FlightForm())]):
    "Dependency injection"
    if flight.is_valid:
        return air.Html(air.H1(flight.data.flight_number))
    return air.Html(air.H1(air.Raw(str(len(flight.errors)))))

NOTE: This is named AirForm to avoid collisions with tags.Form

Source code in src/air/forms.py
57
58
59
60
61
def __init__(self, initial_data: dict | None = None) -> None:
    if self.model is None:
        msg = "model"
        raise NotImplementedError(msg)
    self.initial_data = initial_data

widget property

widget

Widget for rendering of form in HTML

If you want a custom widget, replace with a function that accepts:

- model: BaseModel
- data: dict|None
- errors:dict|None=None

AirField

AirField(
    default=None,
    *,
    type=None,
    label=None,
    default_factory=None,
    alias=None,
    autofocus=False,
    title=None,
    description=None,
    gt=None,
    ge=None,
    lt=None,
    le=None,
    multiple_of=None,
    min_length=None,
    max_length=None,
    pattern=None,
    max_digits=None,
    decimal_places=None,
    examples=None,
    deprecated=None,
    exclude=False,
    discriminator=None,
    frozen=None,
    validate_default=None,
    repr=True,
    init_var=None,
    kw_only=None,
    json_schema_extra=None,
    **extra,
)

A wrapper around pydantic.Field to provide a cleaner interface for defining special input types and labels in air forms.

NOTE: This is named AirField to adhere to the same naming convention as AirForm.

Example:

class CheeseModel(BaseModel):
    name: str = air.AirField(label="Name", autofocus=True)
    age: int

class CheeseForm(air.AirForm):
    model = CheeseModel
Source code in src/air/forms.py
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
def AirField(
    default: Any = None,
    *,
    type: str | None = None,
    label: str | None = None,
    default_factory: Callable[[], Any] | None = None,
    alias: str | None = None,
    autofocus: bool = False,
    title: str | None = None,
    description: str | None = None,
    gt: float | None = None,
    ge: float | None = None,
    lt: float | None = None,
    le: float | None = None,
    multiple_of: float | None = None,
    min_length: int | None = None,
    max_length: int | None = None,
    pattern: str | None = None,
    max_digits: int | None = None,
    decimal_places: int | None = None,
    examples: list[Any] | None = None,
    deprecated: bool | str | None = None,
    exclude: bool = False,
    discriminator: str | None = None,
    frozen: bool | None = None,
    validate_default: bool | None = None,
    repr: bool = True,
    init_var: bool | None = None,
    kw_only: bool | None = None,
    json_schema_extra: dict | None = None,
    **extra: Any,
) -> Any:
    """A wrapper around pydantic.Field to provide a cleaner interface for defining
    special input types and labels in air forms.

    NOTE: This is named AirField to adhere to the same naming convention as AirForm.

    Example:

        class CheeseModel(BaseModel):
            name: str = air.AirField(label="Name", autofocus=True)
            age: int

        class CheeseForm(air.AirForm):
            model = CheeseModel
    """
    if json_schema_extra is None:
        json_schema_extra = {}
    if type:
        json_schema_extra[type] = True
    if label:
        json_schema_extra["label"] = label
    if autofocus:
        json_schema_extra["autofocus"] = True

    return Field(
        default,
        json_schema_extra=json_schema_extra,
        default_factory=default_factory,
        alias=alias,
        title=title,
        description=description,
        gt=gt,
        ge=ge,
        lt=lt,
        le=le,
        multiple_of=multiple_of,
        min_length=min_length,
        max_length=max_length,
        pattern=pattern,
        max_digits=max_digits,
        decimal_places=decimal_places,
        examples=examples,
        deprecated=deprecated,
        exclude=exclude,
        discriminator=discriminator,
        frozen=frozen,
        validate_default=validate_default,
        repr=repr,
        init_var=init_var,
        kw_only=kw_only,
        **extra,
    )

errors_to_dict

errors_to_dict(errors)

Converts a pydantic error list to a dictionary for easier reference.

Source code in src/air/forms.py
157
158
159
160
161
def errors_to_dict(errors: list[dict] | None) -> dict[str, dict]:
    """Converts a pydantic error list to a dictionary for easier reference."""
    if errors is None:
        return {}
    return {error["loc"][0]: error for error in errors}

get_user_error_message

get_user_error_message(error)

Convert technical pydantic error to user-friendly message.

Source code in src/air/forms.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
def get_user_error_message(error: dict) -> str:
    """Convert technical pydantic error to user-friendly message."""
    error_type = error.get("type", "")
    technical_msg = error.get("msg", "")

    # Map error types to user-friendly messages
    messages = {
        "missing": "This field is required.",
        "int_parsing": "Please enter a valid number.",
        "float_parsing": "Please enter a valid number.",
        "bool_parsing": "Please select a valid option.",
        "string_too_short": "This value is too short.",
        "string_too_long": "This value is too long.",
        "value_error": "This value is not valid.",
        "type_error": "Please enter the correct type of value.",
        "assertion_error": "This value doesn't meet the requirements.",
        "url_parsing": "Please enter a valid URL.",
        "email": "Please enter a valid email address.",
        "json_invalid": "Please enter valid JSON.",
        "enum": "Please select a valid option.",
        "greater_than": "This value must be greater than the minimum.",
        "greater_than_equal": "This value must be at least the minimum.",
        "less_than": "This value must be less than the maximum.",
        "less_than_equal": "This value must be at most the maximum.",
    }

    # Get user-friendly message or fallback to technical message
    return messages.get(error_type, technical_msg or "Please correct this error.")

pydantic_type_to_html_type

pydantic_type_to_html_type(field_info)

Return HTML type from pydantic type.

Default to 'text' for unknown types.

Source code in src/air/forms.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
def pydantic_type_to_html_type(field_info: Any) -> str:
    """Return HTML type from pydantic type.

    Default to 'text' for unknown types.
    """
    special_fields = [
        "hidden",
        "email",
        "password",
        "url",
        "datedatetime-local",
        "month",
        "time",
        "color",
        "file",
    ]
    for field in special_fields:
        if field_info.json_schema_extra and field_info.json_schema_extra.get(field, False):
            return field

    return {int: "number", float: "number", bool: "checkbox", str: "text"}.get(field_info.annotation, "text")