Skip to content

Usage

Defining a form

A form page is a FormPage, a pydantic.BaseModel subclass. Its fields become the inputs the frontend renders, using either plain Python/Pydantic types or the field types this library provides:

from pydantic_forms.core import FormPage
from pydantic_forms.types import strEnum


class Speed(strEnum):
    _1000 = "1000"
    _10000 = "10000"


class CreateServiceForm(FormPage):
    service_name: str
    service_speed: Speed

Form wizards

A form generator is a function that yields one FormPage subclass at a time. Each yield blocks until the corresponding page’s input has been validated; the validated data is sent back into the generator, and the return value is the combined result once there are no more pages:

from pydantic_forms.types import FormGenerator, State


def create_service_form(state: State) -> FormGenerator:
    user_input = yield CreateServiceForm

    class ConfirmForm(FormPage):
        service_name: str = user_input.service_name

    yield ConfirmForm

    return user_input.model_dump()

You don’t call this generator yourself but register it under a key for start_form to access it, as shown in the next section. Read How it works for details about the machinery.

Registering forms

register_form associates a generator with a key. start_form then resolves that key, seeds the initial state and iterates over the user inputs:

from pydantic_forms.core import register_form, start_form

register_form("create_service", create_service_form)

result = start_form(
    "create_service",
    user_inputs=[
        {"service_name": "svc-1", "service_speed": "1000"},
        {},  # Empty input suffices for the ConfirmForm
    ],
)

The result is whatever the generator returned, with every value already validated and coerced to its annotated type:

>>> result["service_name"]
'svc-1'
>>> result["service_speed"]
<Speed._1000: '1000'>

Omitting the second {} from the user input would produce a FormNotCompleteError.

Async

An async equivalent lives in pydantic_forms.core.asynchronous, with the same post_form, generate_form and start_form functions, for generators defined with async def and yield.

One difference matters: an async generator cannot return a value, so the final result is yielded instead of returned. Writing return user_input.model_dump() in an async def generator is a SyntaxError:

from pydantic_forms.types import FormGeneratorAsync


async def create_service_form(state: State) -> FormGeneratorAsync:
    user_input = yield CreateServiceForm
    yield user_input.model_dump()  # yield, not return

Note the return type: FormGeneratorAsync rather than FormGenerator, since the result is yielded rather than returned.

Register it exactly as above; the endpoint in the next section awaits start_form to drive it.

FastAPI integration

An example endpoint

An example of how you can hook up the form wizard in an API:

from typing import Any

from fastapi import APIRouter

from pydantic_forms.core.asynchronous import start_form

router = APIRouter()


@router.post("/{form_key}")
async def new_form(form_key: str, json_data: list[dict[str, Any]]) -> dict[str, Any]:
    return await start_form(form_key, user_inputs=json_data)

The frontend posts an empty list to get the first page, then re-posts the accumulated inputs after every step until the endpoint returns the validated state. Extra keyword arguments to start_form land in the generator’s initial state.

Error handling

form_error_handler turns FormNotCompleteError and FormValidationError into the JSON responses a frontend expects (see Errors):

from fastapi import FastAPI
from pydantic_forms.exceptions import FormException
from pydantic_forms.exception_handlers.fastapi import form_error_handler

app = FastAPI()
app.add_exception_handler(FormException, form_error_handler)
app.include_router(router)

An unknown form_key raises FormNotFoundError, which the handler reports as a 404.

Page metadata

Sometimes the frontend needs to know something about a page that its JSON schema cannot express. Setting meta__ on a form page attaches arbitrary JSON to the response that carries it:

from typing import ClassVar

from pydantic_forms.types import FormMeta


class ConfirmForm(FormPage):
    meta__: ClassVar[FormMeta] = {"hasNext": False}

    service_name: str

It comes back as .meta on the FormNotCompleteError, and as the meta key of the JSON response:

>>> from pydantic_forms.core import post_form
>>> from pydantic_forms.exceptions import FormNotCompleteError
>>> def confirm_only(state: State) -> FormGenerator:
...     yield ConfirmForm
...     return {}
...
>>> try:
...     post_form(confirm_only, state={}, user_inputs=[])
... except FormNotCompleteError as exc:
...     exc.meta
...
{'hasNext': False}

The value is entirely free-form: this library never looks inside it, it only passes it through, so whatever it holds is a contract between your own backend and frontend. FormMeta documents the well-known keys — hasNext marks whether another page follows, and customButtons relabels or restyles the previous/next buttons — but being total=False, it doesn’t stop a page from adding further keys of its own.

Custom buttons

To relabel or restyle the previous/next buttons of a page, add a field with a ButtonsConfig as its default. Each Button takes a text label and a color; set only the buttons and keys you want to change, and the frontend keeps its defaults for the rest. The config is sent as customButtons in the meta of the page, alongside the rest of its meta__, while the field itself stays out of the schema and the validated result:

from pydantic_forms.validators import Button, ButtonsConfig


class ConfirmButtonsForm(FormPage):
    meta__: ClassVar[FormMeta] = {"hasNext": False}

    custom_buttons: ButtonsConfig = ButtonsConfig(
        previous=Button(text="Back"),
        next=Button(text="Confirm", color="danger"),
    )
    confirm: bool
>>> def buttons_form(state: State) -> FormGenerator:
...     yield ConfirmButtonsForm
...     return {}
...
>>> try:
...     post_form(buttons_form, state={}, user_inputs=[])
... except FormNotCompleteError as exc:
...     exc.meta
...
{'hasNext': False, 'customButtons': {'previous': {'text': 'Back'}, 'next': {'text': 'Confirm', 'color': 'danger'}}}

Or set the same overrides as customButtons in meta__ directly:

class ConfirmMetaForm(FormPage):
    meta__: ClassVar[FormMeta] = {
        "hasNext": False,
        "customButtons": {
            "previous": {"text": "Back"},
            "next": {
                "text": "Confirm",
                "color": "danger",
            },
        },
    }

    confirm: bool
>>> def meta_form(state: State) -> FormGenerator:
...     yield ConfirmMetaForm
...     return {}
...
>>> try:
...     post_form(meta_form, state={}, user_inputs=[])
... except FormNotCompleteError as exc:
...     exc.meta["customButtons"]["next"]
...
{'text': 'Confirm', 'color': 'danger'}

Use one or the other, combining them will result in an error. The same goes for using ButtonsConfig multiple times.

meta__ is a ClassVar, which keeps it out of the generated schema and out of the validated result: it is metadata about the page, not a field on it.