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:

from typing import Annotated

from fastapi import Depends
from pydantic import BaseModel

import air

app = air.Air()


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))
    errors = len(flight.errors or [])
    return air.Html(air.H1(air.Raw(str(errors))))


@app.post("/flight-depends")
async def flight_form_depends(flight: Annotated[FlightForm, Depends(FlightForm.from_request)]):
    "Dependency injection"
    if flight.is_valid:
        return air.Html(air.H1(flight.data.flight_number))
    errors = len(flight.errors or [])
    return air.Html(air.H1(air.Raw(str(errors))))
Source code in src/air/forms.py
71
72
73
74
75
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

Example:

import air
from air.forms import default_form_widget

app = air.Air()


class ContactModel(air.AirModel):
    # Note: This uses `str` for email. For stricter server-side validation,
    # you can use `EmailStr` from pydantic.
    name: str
    email: str
    message: str


def contact_widget(*, model, data, errors, includes):
    base_html = default_form_widget(
        model=model,
        data=data,
        errors=errors,
        includes=includes,
    )

    return air.Div(
        air.P("Custom widget wrapper"),
        air.Raw(base_html),
        class_="contact-form",
    )


def get_contact_form() -> air.AirForm:
    return ContactModel.to_form(widget=contact_widget)


@app.page
def contact(request: air.Request):

    form = get_contact_form()

    return air.layouts.mvpcss(
        air.H1("Contact Us"),
        air.P("This example uses a custom AirForm.widget to wrap the default form HTML."),
        air.Form(
            form.render(),
            air.Button("Send message", type="submit"),
            method="post",
            action="/contact",
        ),
    )


@app.post("/contact")
async def submit_contact(request: air.Request):
    form = get_contact_form()
    form_data = await request.form()

    if form.validate(form_data):
        return air.Html(
            air.H1("Thank you for your message!"),
            air.P("Your contact form was submitted successfully."),
        )

    error_count = len(form.errors or [])
    return air.Html(
        air.H1("Please fix the errors below."),
        air.P(f"Found {error_count} validation error(s)."),
        air.Form(
            form.render(),
            air.Button("Send message", type="submit"),
            method="post",
            action="/contact",
        ),
    )


if __name__ == "__main__":
    import uvicorn

    uvicorn.run(app, host="127.0.0.1", port=8000)

from_request async classmethod

from_request(request)

Create and validate an AirForm instance from a request.

Parameters:

Name Type Description Default
request Request

The incoming request containing form data

required

Returns:

Type Description
Self

An AirForm instance with validation results

Example:

import air

app = air.Air()


class FlightModel(air.AirModel):
    flight_number: str
    destination: str


FlightForm = FlightModel.to_form()


@app.post("/flight")
async def submit_flight(request: air.Request):
    flight = await FlightForm.from_request(request)

    if flight.is_valid:
        # Form is valid
        return air.Html(
            air.H1("Flight Submitted"),
            air.P(f"Flight: {flight.data.flight_number}"),
            air.P(f"Destination: {flight.data.destination}"),
        )

    # Form has errors
    return air.Html(
        air.H1("Validation Failed"),
        air.P(f"Errors: {len(flight.errors or [])}"),
    )
Source code in src/air/forms.py
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
@classmethod
async def from_request(cls, request: Request) -> Self:
    """Create and validate an AirForm instance from a request.

    Args:
        request: The incoming request containing form data

    Returns:
        An AirForm instance with validation results

    Example:

        import air

        app = air.Air()


        class FlightModel(air.AirModel):
            flight_number: str
            destination: str


        FlightForm = FlightModel.to_form()


        @app.post("/flight")
        async def submit_flight(request: air.Request):
            flight = await FlightForm.from_request(request)

            if flight.is_valid:
                # Form is valid
                return air.Html(
                    air.H1("Flight Submitted"),
                    air.P(f"Flight: {flight.data.flight_number}"),
                    air.P(f"Destination: {flight.data.destination}"),
                )

            # Form has errors
            return air.Html(
                air.H1("Validation Failed"),
                air.P(f"Errors: {len(flight.errors or [])}"),
            )
    """
    form_data = await request.form()
    self = cls()
    await self(form_data=form_data)
    return self

validate

validate(form_data)

Validate form data against the model.

Parameters:

Name Type Description Default
form_data dict[Any, Any] | FormData

Dictionary or FormData containing the form fields to validate

required

Returns:

Type Description
bool

True if validation succeeds, False otherwise

Example:

import air

app = air.Air()


class FlightModel(air.AirModel):
    flight_number: str
    destination: str


@app.post("/flight")
async def submit_flight(request: air.Request):
    form_data = await request.form()
    flight_form = FlightModel.to_form()

    if flight_form.validate(form_data):
        # Form is valid
        return air.Html(
            air.H1("Flight Submitted"),
            air.P(f"Flight: {flight_form.data.flight_number}"),
            air.P(f"Destination: {flight_form.data.destination}"),
        )

    # Form has errors
    return air.Html(
        air.H1("Validation Failed"),
        air.P(f"Errors: {len(flight_form.errors or [])}"),
    )
Source code in src/air/forms.py
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
def validate(self, form_data: dict[Any, Any] | FormData) -> bool:
    """Validate form data against the model.

    Args:
        form_data: Dictionary or FormData containing the form fields to validate

    Returns:
        True if validation succeeds, False otherwise

    Example:

        import air

        app = air.Air()


        class FlightModel(air.AirModel):
            flight_number: str
            destination: str


        @app.post("/flight")
        async def submit_flight(request: air.Request):
            form_data = await request.form()
            flight_form = FlightModel.to_form()

            if flight_form.validate(form_data):
                # Form is valid
                return air.Html(
                    air.H1("Flight Submitted"),
                    air.P(f"Flight: {flight_form.data.flight_number}"),
                    air.P(f"Destination: {flight_form.data.destination}"),
                )

            # Form has errors
            return air.Html(
                air.H1("Validation Failed"),
                air.P(f"Errors: {len(flight_form.errors or [])}"),
            )
    """
    # Store the submitted data to preserve values on error
    self.submitted_data = dict(form_data) if hasattr(form_data, "items") else form_data
    try:
        assert self.model is not None
        self.data = self.model(**form_data)
        self.is_valid = True
    except ValidationError as e:
        self.errors = e.errors()
    return self.is_valid

AirField

AirField(
    default=PydanticUndefined,
    *,
    default_factory=None,
    alias=None,
    alias_priority=None,
    validation_alias=None,
    serialization_alias=None,
    title=None,
    field_title_generator=None,
    description=None,
    examples=None,
    exclude=None,
    exclude_if=None,
    discriminator=None,
    deprecated=None,
    json_schema_extra=None,
    frozen=None,
    validate_default=None,
    repr=None,
    init=None,
    init_var=None,
    kw_only=None,
    pattern=None,
    strict=None,
    coerce_numbers_to_str=None,
    gt=None,
    ge=None,
    lt=None,
    le=None,
    multiple_of=None,
    allow_inf_nan=None,
    max_digits=None,
    decimal_places=None,
    min_length=None,
    max_length=None,
    union_mode=None,
    fail_fast=None,
    type=None,
    label=None,
    autofocus=False,
    **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:

# This example demonstrates:

# - How AirField customizes HTML input types (e.g. email, datetime-local)
# - How labels and autofocus attributes appear in rendered forms
# - How AirForm binds to a Pydantic model for validation
# - How the form behaves when submitted with valid and invalid data

# Run:

#     uv run examples/src/forms__AirField.py

# Then visit http://127.0.0.1:8000/ in your browser.

from pydantic import BaseModel

import air

app = air.Air()


class ContactModel(BaseModel):
    name: str = air.AirField(label="Full Name", min_length=2, max_length=50)
    # Note: This uses `str` for email. For stricter server-side validation,
    # you can use `EmailStr` from pydantic.
    email: str = air.AirField(type="email", label="Email Address")
    message: str = air.AirField(label="Message", min_length=10, max_length=500)
    preferred_datetime: str = air.AirField(
        type="datedatetime-local",
        label="Preferred Date & Time",
    )


class ContactForm(air.AirForm):
    model = ContactModel


@app.page
def index(request: air.Request):
    # Render a simple page containing the contact form.
    form = ContactForm()
    return air.layouts.mvpcss(
        air.H1("Contact Form Example Using AirField"),
        air.P("Submit the form below to see AirField + AirForm in action."),
        air.Form(
            form.render(),
            air.Button("Submit", type="submit"),
            method="post",
            action=submit.url(),  # ty: ignore[unresolved-attribute]
        ),
    )


@app.post("/submit")
async def submit(request: air.Request) -> air.Html:
    # Handle POSTed form data and re-render with errors if invalid.
    form = ContactForm()

    # Parse form data from the incoming request and validate
    form_data = await request.form()
    form.validate(form_data)

    if form.is_valid:
        return air.Html(
            air.layouts.mvpcss(
                air.H1("Thanks for your message!"),
                air.P("Here is what you sent:"),
                air.Ul(
                    air.Li(f"Name: {form.data.name}"),
                    air.Li(f"Email: {form.data.email}"),
                    air.Li(f"Message: {form.data.message}"),
                    air.Li(f"Preferred Date & Time: {form.data.preferred_datetime}"),
                ),
            )
        )

    # If invalid, re-render the form with errors and values preserved
    return air.Html(
        air.layouts.mvpcss(
            air.H1("Please fix the errors below."),
            air.Form(
                form.render(),
                air.Button("Submit", type="submit"),
                method="post",
                action=submit.url(),  # ty: ignore[unresolved-attribute]
            ),
        )
    )


if __name__ == "__main__":
    # Run this example with:
    #   uv run examples/src/forms__AirField.py
    import uvicorn

    uvicorn.run(app, host="127.0.0.1", port=8000)
Source code in src/air/forms.py
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
def AirField(
    default: Any = PydanticUndefined,
    *,
    default_factory: Callable[[], Any] | Callable[[dict[str, Any]], Any] | None = None,
    alias: str | None = None,
    alias_priority: int | None = None,
    validation_alias: str | AliasPath | AliasChoices | None = None,
    serialization_alias: str | None = None,
    title: str | None = None,
    field_title_generator: Callable[[str, FieldInfo], str] | None = None,
    description: str | None = None,
    examples: list[Any] | None = None,
    exclude: bool | None = None,
    exclude_if: Callable[[Any], bool] | None = None,
    discriminator: str | Discriminator | None = None,
    deprecated: Deprecated | str | bool | None = None,
    json_schema_extra: dict[str, JsonValue] | None = None,
    frozen: bool | None = None,
    validate_default: bool | None = None,
    repr: bool | None = None,
    init: bool | None = None,
    init_var: bool | None = None,
    kw_only: bool | None = None,
    pattern: str | re.Pattern[str] | None = None,
    strict: bool | None = None,
    coerce_numbers_to_str: bool | None = None,
    gt: annotated_types.SupportsGt | None = None,
    ge: annotated_types.SupportsGe | None = None,
    lt: annotated_types.SupportsLt | None = None,
    le: annotated_types.SupportsLe | None = None,
    multiple_of: float | None = None,
    allow_inf_nan: bool | None = None,
    max_digits: int | None = None,
    decimal_places: int | None = None,
    min_length: int | None = None,
    max_length: int | None = None,
    union_mode: Literal["smart", "left_to_right"] | None = None,
    fail_fast: bool | None = None,
    # Not in pydantic.Field:
    type: str | None = None,
    label: str | None = None,
    autofocus: bool = False,
    **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:

        # This example demonstrates:

        # - How AirField customizes HTML input types (e.g. email, datetime-local)
        # - How labels and autofocus attributes appear in rendered forms
        # - How AirForm binds to a Pydantic model for validation
        # - How the form behaves when submitted with valid and invalid data

        # Run:

        #     uv run examples/src/forms__AirField.py

        # Then visit http://127.0.0.1:8000/ in your browser.

        from pydantic import BaseModel

        import air

        app = air.Air()


        class ContactModel(BaseModel):
            name: str = air.AirField(label="Full Name", min_length=2, max_length=50)
            # Note: This uses `str` for email. For stricter server-side validation,
            # you can use `EmailStr` from pydantic.
            email: str = air.AirField(type="email", label="Email Address")
            message: str = air.AirField(label="Message", min_length=10, max_length=500)
            preferred_datetime: str = air.AirField(
                type="datedatetime-local",
                label="Preferred Date & Time",
            )


        class ContactForm(air.AirForm):
            model = ContactModel


        @app.page
        def index(request: air.Request):
            # Render a simple page containing the contact form.
            form = ContactForm()
            return air.layouts.mvpcss(
                air.H1("Contact Form Example Using AirField"),
                air.P("Submit the form below to see AirField + AirForm in action."),
                air.Form(
                    form.render(),
                    air.Button("Submit", type="submit"),
                    method="post",
                    action=submit.url(),  # ty: ignore[unresolved-attribute]
                ),
            )


        @app.post("/submit")
        async def submit(request: air.Request) -> air.Html:
            # Handle POSTed form data and re-render with errors if invalid.
            form = ContactForm()

            # Parse form data from the incoming request and validate
            form_data = await request.form()
            form.validate(form_data)

            if form.is_valid:
                return air.Html(
                    air.layouts.mvpcss(
                        air.H1("Thanks for your message!"),
                        air.P("Here is what you sent:"),
                        air.Ul(
                            air.Li(f"Name: {form.data.name}"),
                            air.Li(f"Email: {form.data.email}"),
                            air.Li(f"Message: {form.data.message}"),
                            air.Li(f"Preferred Date & Time: {form.data.preferred_datetime}"),
                        ),
                    )
                )

            # If invalid, re-render the form with errors and values preserved
            return air.Html(
                air.layouts.mvpcss(
                    air.H1("Please fix the errors below."),
                    air.Form(
                        form.render(),
                        air.Button("Submit", type="submit"),
                        method="post",
                        action=submit.url(),  # ty: ignore[unresolved-attribute]
                    ),
                )
            )


        if __name__ == "__main__":
            # Run this example with:
            #   uv run examples/src/forms__AirField.py
            import uvicorn

            uvicorn.run(app, host="127.0.0.1", port=8000)
    """
    schema_extra: dict[str, JsonValue] = json_schema_extra or {}
    if type:
        schema_extra[type] = True
    if label:
        schema_extra["label"] = label
    if autofocus:
        schema_extra["autofocus"] = True
    if extra:
        schema_extra.update(extra)

    # noinspection PyArgumentList
    return Field(
        default,
        default_factory=default_factory,
        alias=alias,
        alias_priority=alias_priority,
        validation_alias=validation_alias,
        serialization_alias=serialization_alias,
        title=title,
        field_title_generator=field_title_generator,
        description=description,
        examples=examples,
        exclude=exclude,
        exclude_if=exclude_if,
        discriminator=discriminator,
        deprecated=deprecated,
        json_schema_extra=schema_extra,
        frozen=frozen,
        validate_default=validate_default,
        repr=repr,
        init=init,
        init_var=init_var,
        kw_only=kw_only,
        pattern=pattern,
        strict=strict,
        coerce_numbers_to_str=coerce_numbers_to_str,
        gt=gt,
        ge=ge,
        lt=lt,
        le=le,
        multiple_of=multiple_of,
        allow_inf_nan=allow_inf_nan,
        max_digits=max_digits,
        decimal_places=decimal_places,
        min_length=min_length,
        max_length=max_length,
        union_mode=union_mode,
        fail_fast=fail_fast,
    )  # ty: ignore[no-matching-overload]

default_form_widget

default_form_widget(
    model, data=None, errors=None, includes=None
)

Render a form widget for a given Pydantic model.

Parameters:

Name Type Description Default
model type[BaseModel]

The Pydantic model class to render

required
data dict | None

Dictionary of data to pre-populate

None
errors list | None

List of Pydantic validation errors

None
includes Sequence[str] | None

Sequence of field names to include (None means all)

None

Returns:

Type Description
str

HTML string representing the form

Example:

import air
from air.forms import default_form_widget

app = air.Air()


class FlightModel(air.AirModel):
    flight_number: str
    destination: str
    passengers: int


@app.page
def index(request: air.Request):
    # Render different field groups separately using includes parameter
    basic_info = default_form_widget(
        model=FlightModel,
        data={"flight_number": "AA123"},  # Pre-populate flight_number
        includes=["flight_number", "destination"],
    )

    passenger_info = default_form_widget(
        model=FlightModel,
        includes=["passengers"],
    )

    return air.Html(
        air.H1("Flight Booking"),
        air.Form(
            air.Fieldset(
                air.Legend("Flight Information"),
                air.Raw(basic_info),
            ),
            air.Fieldset(
                air.Legend("Passenger Count"),
                air.Raw(passenger_info),
            ),
            air.Button("Submit", type="submit"),
            method="post",
            action="/submit",
        ),
    )


@app.post("/submit")
async def submit(request: air.Request):
    form_data = await request.form()
    flight_form = FlightModel.to_form()

    if flight_form.validate(form_data):
        return air.Html(
            air.H1("Flight Booked"),
            air.P(f"Flight: {flight_form.data.flight_number}"),
            air.P(f"Destination: {flight_form.data.destination}"),
            air.P(f"Passengers: {flight_form.data.passengers}"),
        )

    # Re-render with custom layout and errors
    basic_info = default_form_widget(
        model=FlightModel,
        data=dict(form_data),
        errors=flight_form.errors,
        includes=["flight_number", "destination"],
    )

    passenger_info = default_form_widget(
        model=FlightModel,
        data=dict(form_data),
        errors=flight_form.errors,
        includes=["passengers"],
    )

    return air.Html(
        air.H1("Please fix the errors"),
        air.Form(
            air.Fieldset(
                air.Legend("Flight Information"),
                air.Raw(basic_info),
            ),
            air.Fieldset(
                air.Legend("Passenger Count"),
                air.Raw(passenger_info),
            ),
            air.Button("Submit", type="submit"),
            method="post",
            action="/submit",
        ),
    )
Source code in src/air/forms.py
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
def default_form_widget(  # noqa: C901
    model: type[BaseModel],
    data: dict | None = None,
    errors: list | None = None,
    includes: Sequence[str] | None = None,
) -> str:
    """Render a form widget for a given Pydantic model.

    Args:
        model: The Pydantic model class to render
        data: Dictionary of data to pre-populate
        errors: List of Pydantic validation errors
        includes: Sequence of field names to include (None means all)

    Returns:
        HTML string representing the form

    Example:

        import air
        from air.forms import default_form_widget

        app = air.Air()


        class FlightModel(air.AirModel):
            flight_number: str
            destination: str
            passengers: int


        @app.page
        def index(request: air.Request):
            # Render different field groups separately using includes parameter
            basic_info = default_form_widget(
                model=FlightModel,
                data={"flight_number": "AA123"},  # Pre-populate flight_number
                includes=["flight_number", "destination"],
            )

            passenger_info = default_form_widget(
                model=FlightModel,
                includes=["passengers"],
            )

            return air.Html(
                air.H1("Flight Booking"),
                air.Form(
                    air.Fieldset(
                        air.Legend("Flight Information"),
                        air.Raw(basic_info),
                    ),
                    air.Fieldset(
                        air.Legend("Passenger Count"),
                        air.Raw(passenger_info),
                    ),
                    air.Button("Submit", type="submit"),
                    method="post",
                    action="/submit",
                ),
            )


        @app.post("/submit")
        async def submit(request: air.Request):
            form_data = await request.form()
            flight_form = FlightModel.to_form()

            if flight_form.validate(form_data):
                return air.Html(
                    air.H1("Flight Booked"),
                    air.P(f"Flight: {flight_form.data.flight_number}"),
                    air.P(f"Destination: {flight_form.data.destination}"),
                    air.P(f"Passengers: {flight_form.data.passengers}"),
                )

            # Re-render with custom layout and errors
            basic_info = default_form_widget(
                model=FlightModel,
                data=dict(form_data),
                errors=flight_form.errors,
                includes=["flight_number", "destination"],
            )

            passenger_info = default_form_widget(
                model=FlightModel,
                data=dict(form_data),
                errors=flight_form.errors,
                includes=["passengers"],
            )

            return air.Html(
                air.H1("Please fix the errors"),
                air.Form(
                    air.Fieldset(
                        air.Legend("Flight Information"),
                        air.Raw(basic_info),
                    ),
                    air.Fieldset(
                        air.Legend("Passenger Count"),
                        air.Raw(passenger_info),
                    ),
                    air.Button("Submit", type="submit"),
                    method="post",
                    action="/submit",
                ),
            )
    """
    error_dict = errors_to_dict(errors)
    fields = []
    for field_name, field_info in model.model_fields.items():
        if includes is not None and field_name not in includes:
            continue

        input_type = pydantic_type_to_html_type(field_info)
        kwargs = {}
        # Inject values
        if data is not None and field_name in data:
            kwargs["value"] = data[field_name]

        error = error_dict.get(field_name)
        if error:
            kwargs["aria-invalid"] = "true"
        json_schema_extra: dict = field_info.json_schema_extra or {}
        if json_schema_extra.get("autofocus"):
            kwargs["autofocus"] = True

        # Add HTML5 validation attributes from Pydantic constraints
        # Check if field is optional (Union with None)
        annotation = field_info.annotation
        origin = get_origin(annotation)
        is_optional = (origin is Union or origin is UnionType) and type(None) in get_args(annotation)

        # Add required attribute for non-optional required fields
        if field_info.is_required() and not is_optional:
            kwargs["required"] = True

        # Extract min_length and max_length from field metadata
        for meta in getattr(field_info, "metadata", []):
            if isinstance(meta, annotated_types.MinLen):
                kwargs["minlength"] = meta.min_length
            elif isinstance(meta, annotated_types.MaxLen):
                kwargs["maxlength"] = meta.max_length
            elif hasattr(annotated_types, "Len") and isinstance(meta, annotated_types.Len):
                if getattr(meta, "min_length", None) is not None:
                    kwargs["minlength"] = meta.min_length
                if getattr(meta, "max_length", None) is not None:
                    kwargs["maxlength"] = meta.max_length

        # Fallback to field_info attributes if present
        if hasattr(field_info, "min_length") and field_info.min_length is not None:
            kwargs.setdefault("minlength", field_info.min_length)
        if hasattr(field_info, "max_length") and field_info.max_length is not None:
            kwargs.setdefault("maxlength", field_info.max_length)

        fields.append(
            tags.Tags(
                tags.Label(
                    json_schema_extra.get("label") or field_name,
                    for_=field_name,
                ),
                tags.Input(name=field_name, type=input_type, id=field_name, **kwargs),
                (tags.Small(get_user_error_message(error), id=f"{field_name}-error") if error else ""),
            ),
        )

    return tags.Tags(*fields).render()

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
338
339
340
341
342
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
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
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
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
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")

to_form

to_form(model, *, name=None, includes=None, widget=None)

Generate an :class:AirForm instance for the given Pydantic model.

Parameters:

Name Type Description Default
model type[BaseModel]

The Pydantic model class the form should validate against.

required
name str | None

Optional explicit class name for the generated form.

None
includes Sequence[str] | None

Optional iterable of field names to render (defaults to all fields).

None
widget Callable | None

Optional callable to render the form. Falls back to :func:default_form_widget.

None

Returns:

Type Description
AirForm

A new :class:AirForm instance bound to model.

Source code in src/air/forms.py
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
def to_form(
    model: type[BaseModel],
    *,
    name: str | None = None,
    includes: Sequence[str] | None = None,
    widget: Callable | None = None,
) -> "AirForm":
    """Generate an :class:`AirForm` instance for the given Pydantic model.

    Args:
        model: The Pydantic model class the form should validate against.
        name: Optional explicit class name for the generated form.
        includes: Optional iterable of field names to render (defaults to all fields).
        widget: Optional callable to render the form. Falls back to :func:`default_form_widget`.

    Returns:
        A new :class:`AirForm` instance bound to ``model``.
    """

    attrs: dict[str, Any] = {"model": model, "__module__": model.__module__}

    if includes is not None:
        attrs["includes"] = tuple(includes)

    if widget is not None:

        def _widget(self: AirForm, _widget: Callable = widget) -> Callable:
            return _widget

        attrs["widget"] = property(_widget)

    form_name = name or f"{model.__name__}Form"
    generated_form = type(form_name, (AirForm,), attrs)
    generated_form.__doc__ = f"Auto-generated AirForm for {model.__module__}.{model.__name__}."
    return generated_form()