Plugin Hooks for Inputs and Layouts¶
This project now exposes lightweight extension points so third-party packages (or your own app code) can add new inputs or layout renderers without patching core modules.
Registering Custom Inputs¶
Use pydantic_schemaforms.inputs.registry.register_input_class to bind a BaseInput subclass to one or more ui_element aliases. The registry augments the built-in discovery of subclasses so you can register at import time or inside your framework startup.
from pydantic_schemaforms.inputs.base import BaseInput
from pydantic_schemaforms.inputs.registry import register_input_class
class ColorSwatchInput(BaseInput):
ui_element = "color_swatch"
# implement render_input / render_label, etc.
register_input_class(ColorSwatchInput)
- Aliases: pass
aliases=("color", "swatch")if you want multiple trigger names. - Bulk registration: use
register_inputs([Cls1, Cls2, ...]). - Reset (tests/hot reload):
reset_input_registry()clears custom entries and cache.
Once registered, any field with input_type="color_swatch" (or alias) will resolve to your component.
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.rendering.layout_engine import LayoutEngine
# signature: (field_name, field_schema, value, ui_info, context, engine) -> str
def render_steps(field_name, field_schema, value, ui_info, context, engine):
# value may be your own layout descriptor; use engine._renderer if needed
steps = value or []
items = "".join(f"<li>{step}</li>" for step in steps)
return f"<ol class='steps'>{items}</ol>"
LayoutEngine.register_layout_renderer("steps", render_steps)
Attach the handler in your form field (layout_handler keyspace):
class WizardForm(FormModel):
steps: list[str] = FormField(
["Account", "Billing", "Review"],
input_type="layout",
layout_handler="steps",
title="Wizard Steps",
)
- 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.
Two real, working composite examples¶
Earlier versions of this guide illustrated the mechanism with a WizardForm
example that was never actually shipped. Two real composites in this
library now 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". Seedocs/recipes.md's "CSV import to a table" recipe 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.
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
pydantic_schemaforms.composite_layout.CompositeLayoutModel 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.