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 JSON


class ConfirmForm(FormPage):
    meta__: ClassVar[JSON] = {"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. The hasNext key above is just one example: it is what orchestrator-core uses to mark the final page, so that its frontend can label and style the submit button accordingly.

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.