How it works¶
The core function is walk_form, which validates each submitted form page and says where the wizard stands: the
next page, the result, or a rejected page. A program that reads a form uses it directly, together with page_schema.
Three wrappers build on it:
post_formturns those endings into a return value and two exceptions, for use in an API.start_formlooks a form up by its registered key and callspost_form; this is what an application’s endpoint calls.generate_formcallspost_formand returns the next page’s JSON schema rather than raising, orNoneonce the wizard is done.
walk_form¶
walk_form takes the generator function itself, an initial state, and one input dict per page, and returns where
the walk ended:
from pydantic_forms.core import Complete, FormPage, NextPage, Rejected, walk_form
from pydantic_forms.types import FormGenerator, State
class CreateServiceForm(FormPage):
service_name: str
def create_service_form(state: State) -> FormGenerator:
user_input = yield CreateServiceForm
return user_input.model_dump()
outcome = walk_form(create_service_form, state={}, user_inputs=[])
The three endings are NextPage, with the page model and its index, Complete, with the result, and Rejected,
with the page that did not validate, its index, and a FormValidationError carrying the translated Pydantic errors:
>>> outcome
NextPage(model=<class '...CreateServiceForm'>, index=0)
>>> walk_form(create_service_form, state={}, user_inputs=[{"service_name": "svc-1"}])
Complete(state={'service_name': 'svc-1'})
>>> rejected = walk_form(create_service_form, state={}, user_inputs=[{"service_name": 42}])
>>> rejected.index, rejected.error.errors[0]["msg"]
(0, 'Input should be a valid string')
This is the entry point for a program that reads a form, such as an agent filling it in: it wants the pending page as a model to inspect rather than as a schema to render, and a rejected page told apart from a failure.
The generator protocol¶
A form generator is an ordinary Python generator, and walk_form drives it like a coroutine:
- It calls the generator function with the initial state and sends
Noneto reach the firstyield. What comes back is aFormPagesubclass: the page to render. - It takes the first submitted input off the list and validates it against that page. A failure ends the walk in
Rejected. - The validated model is merged into the state and sent back into the generator, where it becomes the value of the
yieldexpression. The generator runs on until its nextyield, so it can branch on what the user just submitted. - Steps 2 and 3 repeat until either the inputs or the pages run out.
If the inputs run out first, the walk ends in NextPage with the page that is currently pending. If the pages run
out first, the generator’s return value is the result of the whole wizard, and the walk ends in Complete. If there
are still inputs left once the generator has finished, walk_form raises FormOverflowError: that is a caller’s
mistake, not an ending of the form.
post_form¶
post_form is a thin wrapper around walk_form for use in an API. It makes the same walk and turns the three endings
into a return value and two exceptions: Complete is returned as the result, Rejected raises its
FormValidationError, and NextPage converts the pending page to a JSON schema and raises it as
FormNotCompleteError.
from pydantic_forms.core import post_form
result = post_form(
create_service_form,
state={},
user_inputs=[{"service_name": "svc-1"}],
)
>>> result
{'service_name': 'svc-1'}
>>> post_form(create_service_form, state={}, user_inputs=[])
Traceback (most recent call last):
...
pydantic_forms.exceptions.FormNotCompleteError: {...}
That is the normal flow: a frontend keeps posting the inputs it has collected so far, and each response tells it
what to render next. FormOverflowError passes through unchanged.
A page for a program¶
A frontend renders the schema post_form raises. A program can read the same schema, and page_schema gives it the
page as one self-contained document:
from pydantic_forms.core import form_errors, page_schema
schema = page_schema(CreateServiceForm, inline_refs=True)
>>> schema["properties"]["service_name"]
{'title': 'Service Name', 'type': 'string'}
>>> schema["required"]
['service_name']
JSON Schema already says most of what a program needs: type, enum, format, default, required, and the
length and range keywords. Three things make it sufficient:
inline_refs=Truereplaces every$refwith the definition it points to, so a choice or a nested model is read in place. Only a model that contains itself keeps its reference, with its definition under$defs.- A field that is shown and never submitted carries the standard
readOnly: true: a label, a divider, a summary table, a read-only value. Submit nothing for it. - A
Choicelists its values inenumand, where they differ from what a person sees, the labels inoptions. Submit the value. AnAcceptfield listsACCEPTEDandINCOMPLETEfor the checkbox that renders it; onlyACCEPTEDpasses validation.
form_errors is the verdict on a rejected page as data: one entry per message, with its location on the page,
translated the way the frontend shows it:
>>> form_errors(rejected.error)
[FormFieldError(loc=['service_name'], msg='Input should be a valid string', type='string_type')]
The wrappers¶
start_form¶
start_form looks the generator up by key in the registry that register_form fills, seeds the initial state with
that key plus any extra keyword arguments it was given, and then calls post_form. An unknown key raises
FormNotFoundError. This is the one an application’s endpoint calls, so it is covered in
Registering forms.
generate_form¶
generate_form calls post_form and catches that exception, so it returns the next page’s JSON schema rather than
raising, and None once there is nothing left to render.
That makes it the one to reach for when the schema is data rather than the response itself: embedding the pending
form in a larger payload while serving a suspended process, or stepping through a wizard from a read-only endpoint
and using None to detect completion. A submit endpoint should use start_form instead and let the exception
handler turn FormNotCompleteError into its 510 response.
from pydantic_forms.core import generate_form
schema = generate_form(create_service_form, state={}, user_inputs=[])
>>> schema["required"]
['service_name']
>>> schema["properties"]
{'service_name': {'title': 'Service Name', 'type': 'string'}}
Once every page has been submitted there is nothing left to render, and the return value is None:
>>> generate_form(
... create_service_form, state={}, user_inputs=[{"service_name": "svc-1"}]
... ) is None
True
Async¶
The variants in pydantic_forms.core.asynchronous work the same way, interacting with the generator through asend
instead of send. Because an async generator cannot return a value the result is yielded instead, and the async
walk_form treats a yielded dict as the end of the wizard, ending in the same three outcomes; post_form wraps it
the same way. Also see Async.