Skip to content

Examples

Field types

Every field type is exported from pydantic_forms.validators and described in full under Field types in the reference. They fall into a few groups.

Display-only. Rendered but collect no input, and all are frozen: Label, Divider, Hidden, callout(), markdown(), migration_summary(), DisplaySubscription.

Text, numbers and dates. Plain str, int, float and bool need no special type and render as text, number and checkbox inputs. Beyond those: LongText, timestamp() / Timestamp, Accept, OrganisationId.

Choices. Choice for one option, choice_list() for several.

Lists. ListOfOne, ListOfTwo, unique_conlist().

Read-only. read_only_field() for a scalar, read_only_list() for a list.

Contact persons. ContactPerson and contact_person_list().

A form using them

Field types are ordinary annotations, so a page mixes them freely with plain Python types:

from pydantic_forms.core import FormPage
from pydantic_forms.validators import (
    Choice,
    Divider,
    Label,
    LongText,
    Timestamp,
    choice_list,
)


class Speed(Choice):
    _1000 = ("1000", "1 Gbit/s")
    _10000 = ("10000", "10 Gbit/s")


class Tag(Choice):
    core = ("core", "Core")
    edge = ("edge", "Edge")


class CreatePortForm(FormPage):
    intro: Label = "Configure the new port"
    separator: Divider = None
    description: LongText
    speed: Speed
    tags: choice_list(Tag, min_items=1)
    starts_at: Timestamp

Submitting it gives back the validated model, with each value coerced to its annotated type:

>>> port = CreatePortForm(
...     description="uplink to core",
...     speed="1000",
...     tags=["core"],
...     starts_at=1735689600,
... )
>>> port.speed
<Speed._1000: '1000'>
>>> port.tags
[<Tag.core: 'core'>]

A Choice member is a (value, label) pair, and the generated schema carries the mapping, so the frontend can show something friendlier than the stored value:

>>> CreatePortForm.model_json_schema()["$defs"]["Speed"]["options"]
{'1000': '1 Gbit/s', '10000': '10 Gbit/s'}

The display-only and text types mostly show up as a format that the frontend switches on, rather than as extra validation:

>>> CreatePortForm.model_json_schema()["properties"]["description"]["format"]
'long'
>>> CreatePortForm.model_json_schema()["properties"]["intro"]["format"]
'label'

A modify form

A modify form usually lets the user change part of what already exists while showing the rest as context. read_only_field fixes a value and marks it disabled, so the frontend displays it but does not offer it for editing:

from pydantic_forms.validators import read_only_field


class ModifyPortForm(FormPage):
    port_name: read_only_field("uplink-1")
    speed: read_only_field("1000")
    description: LongText
>>> ModifyPortForm.model_json_schema()["properties"]["port_name"]["extraProperties"]
{'disabled': True, 'value': 'uplink-1'}

The fixed values are normally read from whatever is being modified rather than written as literals, so a form like this is defined inside the generator, where that data is in hand.

Read-only fields may be omitted from the submitted input, and still come back in the result:

>>> ModifyPortForm(description="uplink to core, upgraded").model_dump()
{'port_name': 'uplink-1', 'speed': '1000', 'description': 'uplink to core, upgraded'}

And because each value is fixed, submitting a different one is a validation error rather than a silent overwrite:

>>> ModifyPortForm(port_name="renamed", description="uplink to core")
Traceback (most recent call last):
  ...
pydantic_core._pydantic_core.ValidationError: ...

More examples

For complete form generators in the context of real workflows, see the example-orchestrator repository. Its workflows/ directory contains multi-page wizards that branch on earlier input, such as workflows/l2vpn/create_l2vpn.py.