Plugin Hooks and Custom Composites¶
You can add your own inputs and your own composite controls (a whole table,
a repeating structure, a custom picker) without changing the library. Both
are registered at startup and then used through ui_element or a layout
field like any built-in one.
Custom inputs¶
Subclass an existing input and register it under a new ui_element name:
from typing import Any
from pydantic_schemaforms import Field, FormModel, TextInput, register_input_class, render_form_html
class ExtensionInput(TextInput):
"""A phone extension: up to six digits, with a numeric keypad on phones."""
ui_element = 'extension'
def render(self, **kwargs: Any) -> str:
kwargs.setdefault('inputmode', 'numeric')
kwargs.setdefault('pattern', r'\d{1,6}')
kwargs.setdefault('maxlength', 6)
return super().render(**kwargs)
register_input_class(ExtensionInput, aliases=('phone_extension',))
class DirectoryEntry(FormModel):
name: str = Field(title='Name')
extension: str = Field('', title='Extension', ui_element='extension')
form_html = render_form_html(DirectoryEntry, submit_url='/directory')
Subclassing a built-in input such as TextInput keeps everything it already
does: escaping every value, the label, help text, error message and the
accessibility wiring. Override render() to adjust the attributes, then hand
off to super().render().
register_input_class(cls, aliases=(...))registers one class, under itsui_elementand any aliases.register_inputs([...])registers several at once.reset_input_registry()removes the custom ones, for tests or hot reload.
Keep custom inputs to the accessibility baseline in Accessibility: inherit
render_with_label() (from FormInput) rather than writing your own, so the label,
help text and error get their ids and the control gets aria-invalid/aria-describedby;
make sure render() passes aria-* keyword arguments through to the control (the
validate_attributes() helper does this); and keep text at 4.5:1 contrast. A built-in
input also needs a widget-gallery entry, which puts it under the axe-core gate in
tests/playwright/test_example_pages.py.
Registering Custom Layout Renderers¶
LayoutEngine dispatches a field to a registered custom renderer through
two independent keyspaces, both backed by the same
LayoutEngine._custom_renderers registry and the same LayoutRenderer
callable signature — (field_name, field_schema, value, ui_info, context,
engine) -> str:
layout_handlerkeyspace — only reached when the field itself is declaredinput_type="layout", plus alayout_handler(orlayout_renderer) name injson_schema_extra/FormFieldkwargs. This is the mechanismDataTableLayoutuses (see below): the field's own value carries whatever the renderer needs.ui_elementkeyspace — reached directly by a field's ownui_element/input_typestring, whatever it is, viaLayoutEngine.get_renderer_for_element(ui_element). This is the mechanismmodel_listuses internally (FormField(..., input_type="model_list")) — from the field declaration's point of view nothing aboutmodel_listchanged, but internally it's dispatched through this same registry rather than a hardcoded check, so it's a second, real, load-bearing example of this mechanism working end-to-end (not just a name-collision risk to avoid).
Use register_layout_renderer(name, renderer) for either keyspace — which
one a given name lands in depends entirely on what triggers the lookup on
the field side (input_type="layout" + layout_handler=name, vs. a field
whose own input_type/ui_element literally equals name), not on
anything about the registration call itself.
from pydantic_schemaforms import Field, FormModel, LayoutEngine, SafeHTML, html, render_form_html
@LayoutEngine.layout_renderer('step_list', builtin=False)
def render_step_list(field_name, field_schema, value, ui_info, context, engine) -> str:
items = SafeHTML(''.join(html(t'<li>{step}</li>') for step in (value or [])))
return html(t'<ol class="step-list" data-field="{field_name}">{items}</ol>')
class OnboardingForm(FormModel):
steps: list[str] = Field(
['Account', 'Billing', 'Review'],
title='Steps',
input_type='layout',
layout_handler='step_list',
)
form_html = render_form_html(OnboardingForm, submit_url='/onboarding')
The renderer builds its HTML with html(t'...'), like everything else in the
library, so each step's text is escaped. The field declares
input_type="layout" and names the renderer with layout_handler; its value
(here the list of steps) is what the renderer receives.
- Names are arbitrary strings; collisions overwrite the previous handler.
- Call
LayoutEngine.reset_layout_renderers()in tests to clear non-builtin state (seebuiltin=Truebelow — a renderer registered that way survives this call). - Handlers receive the active
LayoutEngineinstance and the original renderer viaengine._rendererif you need to reuse field rendering helpers.
requires_multipart — declare a nested file input¶
If your renderer might emit its own <input type="file"> inside the
field's own output (as DataTableLayout does when csv_upload is on), pass
requires_multipart to register_layout_renderer/layout_renderer. A
nested file input like that isn't a top-level FormModel field, so the
form's own file-field scan — which only sees this field's layout element,
not what it renders — can never discover it on its own, and the enclosing
<form> would silently miss enctype="multipart/form-data", without which
a browser sends only the chosen file's name, never its contents:
def _needs_multipart(value: Any) -> bool:
# Called with this field's raw value (whatever your as_layout_value()-
# equivalent built) at render time; return whether *that specific
# value* needs multipart encoding.
return bool(value) and value.get('csv_upload_enabled', False)
@LayoutEngine.layout_renderer('steps', requires_multipart=_needs_multipart)
def render_steps(field_name, field_schema, value, ui_info, context, engine):
...
DataTableLayout wires this to its own csv_upload config flag — see
pydantic_schemaforms/rendering/datatable_renderer.py. If your composite
never renders a file input, omit the parameter (the default None means
"never needs multipart").
builtin=True and the @LayoutEngine.layout_renderer decorator¶
A renderer this library ships (like DataTableLayout's or model_list's)
only registers itself once per process, at import time. If you register
it the plain way and something later calls
LayoutEngine.reset_layout_renderers() — several test fixtures across this
project's own suite do, between tests — it's gone for the rest of the
process, since nothing re-imports the registering module afterward. Pass
builtin=True to register_layout_renderer to mark a renderer as
library-owned so resets leave it alone:
LayoutEngine.register_layout_renderer("datatable", render_datatable_layout_field, builtin=True)
Or use the decorator form, which defaults builtin=True (it's meant for
exactly this "register my own reusable composite once, at import time"
case):
@LayoutEngine.layout_renderer("datatable")
def render_datatable_layout_field(field_name, field_schema, value, ui_info, context, engine):
...
One-off, app-level, or test-only renderers should leave builtin at its
default (False) so reset_layout_renderers() still clears them between
tests, same as always.
Real, working composite examples¶
Three real composites in this library demonstrate both keyspaces end-to-end:
DataTableLayout(pydantic_schemaforms/datatable_layout.py+pydantic_schemaforms/rendering/datatable_renderer.py) — thelayout_handlerkeyspace. AFormModelsubclass whose own fields ARE the composite's structure (table columns), embedded asinput_type="layout", layout_handler="datatable". See Data Tables for the full pattern, includingas_layout_value()for building the field's value.model_list(pydantic_schemaforms/rendering/model_list_renderer.py) — theui_elementkeyspace.FormField(..., input_type="model_list")on alist[ItemModel]-annotated field, whereItemModelis any ordinary, unmodifiedFormModel— there's no wrapper class at all, since the item's shape already comes from its own type annotation.nested_model(pydantic_schemaforms/rendering/nested_model_renderer.py) — also theui_elementkeyspace, inferred from a field typed as anotherFormModel. It renders the sub-form's fields under dotted input names and is how complex forms are composed (see Nested Forms and Lists).
Every registered renderer is called with six positional arguments,
(field_name, field_schema, value, ui_info, context, engine). The field's
own error message is context.error, and its nested errors (same shape as
its value; see Nested Forms and Lists) are context.child_errors.
CompositeLayoutModel: a base for FormModel-shaped composites¶
If your composite is shaped like DataTableLayout — a FormModel subclass
whose own fields define the composite's structure, meant to be embedded as
a layout field and never rendered on its own — subclass
CompositeLayoutModel (a top-level export) instead of FormModel directly. It disables render_form() with a clear
NotImplementedError pointing callers at your own value-bundling
classmethod (the as_layout_value() convention DataTableLayout uses), so
nobody accidentally calls it as if it rendered one instance's inputs.
Not every composite needs this base — model_list doesn't, since its item
model is just an ordinary FormModel referenced via a list[ItemModel]
annotation, not a class of its own that needs render_form() disabled.
Reach for CompositeLayoutModel specifically when your composite's own
fields ARE its structure (table columns, wizard steps, and similar).
Packaging Tips¶
- For libraries: register your inputs/layouts in your package
__init__or an explicitsetup()function that users call during startup, and passbuiltin=True(or use the@LayoutEngine.layout_renderer(name)decorator) soreset_layout_renderers()calls elsewhere (e.g. in a consuming app's own test suite) don't silently remove your renderer. - For app code: register once at process start (e.g., FastAPI lifespan, Django AppConfig.ready). Avoid per-request registration. Leave
builtinat its default (False) so your own tests can still reset cleanly between runs. - Keep renderers pure and side-effect free; they should return HTML strings and not mutate shared state.