Pydantic SchemaForms
| Description | Server-rendered form components and layout helpers for Pydantic models |
| Author(s) | Mike Ryan |
| Repository | https://github.com/devsetgo/pydantic-schemaforms |
| Copyright | Copyright © 2025 - 2026 Mike Ryan |
Table of Contents
Pydantic SchemaForms¶

Support Python Versions
SonarCloud:
Requires Python 3.14+ β this library uses Python 3.14 language features and will not import on older versions.
Overview¶
pydantic-schemaforms is a modern Python library that generates dynamic HTML forms from Pydantic 2.x+ models.
It is designed for server-rendered apps: you define a model (and optional UI hints) and get back ready-to-embed HTML with validation and framework styling.
Key Features:
- π Zero-Configuration Forms: Generate complete HTML forms directly from Pydantic models
- π¨ Multi-Framework Support: Bootstrap, Material Design, and plain HTML (framework="none")
- β
Built-in Validation: Client-side HTML5 + server-side Pydantic validation
- π§ JSON-Schema-form style UI hints: Uses a familiar ui_element, ui_autofocus, ui_options vocabulary
- π± Responsive & Accessible: Mobile-first design with full ARIA support
- π Framework Ready: First-class Flask and FastAPI helpers, plus plain HTML for other stacks
- π Dual-use form + JSON API: as_api_model() returns a clean Pydantic BaseModel β one model class drives both the HTML form and a typed JSON API, with UI metadata stripped from OpenAPI docs
Important:
submit_urlis required when rendering forms. The library does not choose a default submit target.
Documentation¶
- Docs site: GitHub Documentation Site
- Live Demo: Running Demo of Current Version
- Source: GitHub Source Code
Requirements¶
Python 3.14+ is required. The library will raise
RuntimeErroron import if your Python version is older.
- Python 3.14+ (hard requirement β no fallback or compatibility shim)
- Pydantic 2.13+ (included as a dependency)
Quick Start¶
Install¶
pip install pydantic-schemaforms
AI Assistant Bootstrap (for app repositories)¶
If you are using Copilot or Claude in your application project, you can pull packaged instructions directly from the installed library into your app repo's instruction file β one command, no manual copy-paste:
python -m pydantic_schemaforms.ai_instructions claude --write # writes ./CLAUDE.md
python -m pydantic_schemaforms.ai_instructions copilot --write # writes ./.github/copilot-instructions.md
python -m pydantic_schemaforms.ai_instructions generic > AI_INSTRUCTIONS.md
Or from Python:
from pydantic_schemaforms import get_app_instructions, suggested_instruction_filename
assistant = 'copilot' # or 'claude' / 'generic'
print(f'Suggested destination: {suggested_instruction_filename(assistant)}')
print(get_app_instructions(assistant))
This avoids having assistants reverse-engineer internals and keeps generated app code aligned with the supported FormModel + render_form_html flow.
FastAPI (async / ASGI)¶
This is the recommended βdrop-in HTMLβ pattern for FastAPI: define a FormModel and call render_form_html().
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from pydantic import ValidationError
from pydantic_schemaforms import render_form_html
from pydantic_schemaforms.schema_form import Field, FormModel
class MinimalLoginForm(FormModel):
username: str = Field(
title="Username",
ui_autofocus=True,
ui_placeholder="demo_user",
)
password: str = Field(
title="Password",
ui_element="password",
)
remember_me: bool = Field(
default=False,
title="Remember me",
ui_element="checkbox",
)
app = FastAPI()
@app.api_route("/login", methods=["GET", "POST"], response_class=HTMLResponse)
async def login(request: Request, style: str = "bootstrap"):
form_data = {}
errors = {}
if request.method == "POST":
submitted = dict(await request.form())
form_data = submitted
try:
MinimalLoginForm(**submitted)
except ValidationError as e:
errors = {err["loc"][0]: err["msg"] for err in e.errors() if err.get("loc")}
else:
# optional demo data
form_data = {"username": "demo_user", "remember_me": True}
form_html = render_form_html(
MinimalLoginForm,
framework=style,
form_data=form_data,
errors=errors,
submit_url="/login",
)
return f"""<!doctype html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\">
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
<title>Login</title>
<link href=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css\" rel=\"stylesheet\">
</head>
<body class=\"container my-5\">
<h1 class=\"mb-4\">Login</h1>
{form_html}
</body>
</html>"""
Run it:
pip install "pydantic-schemaforms[fastapi]" uvicorn
uvicorn main:app --reload
FastAPI: simple registration page¶
This mirrors the in-repo example apps: your host page loads Bootstrap, and render_form_html() returns form markup (plus any inline helper scripts), ready to embed.
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from pydantic import ValidationError
from pydantic_schemaforms import render_form_html
from pydantic_schemaforms.schema_form import FormModel, Field
class UserRegistrationForm(FormModel):
username: str = Field(title="Username", min_length=3)
email: str = Field(title="Email", ui_element="email")
password: str = Field(title="Password", ui_element="password", min_length=8)
app = FastAPI()
@app.api_route("/register", methods=["GET", "POST"], response_class=HTMLResponse)
async def register(request: Request):
form_data = {}
errors = {}
if request.method == "POST":
submitted = dict(await request.form())
form_data = submitted
try:
UserRegistrationForm(**submitted)
except ValidationError as e:
errors = {err["loc"][0]: err["msg"] for err in e.errors() if err.get("loc")}
form_html = render_form_html(
UserRegistrationForm,
framework="bootstrap",
form_data=form_data,
errors=errors,
submit_url="/register",
)
return f"""<!doctype html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\">
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
<title>Register</title>
<link href=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css\" rel=\"stylesheet\">
</head>
<body class=\"container my-5\">
<h1 class=\"mb-4\">Register</h1>
{form_html}
</body>
</html>"""
Flask (sync / WSGI)¶
In synchronous apps (Flask), the simplest pattern is the same: define a FormModel and call render_form_html().
from flask import Flask, request
from pydantic import ValidationError
from pydantic_schemaforms import render_form_html
from pydantic_schemaforms.schema_form import Field, FormModel
class MinimalLoginForm(FormModel):
username: str = Field(title="Username", ui_autofocus=True)
password: str = Field(title="Password", ui_element="password")
remember_me: bool = Field(default=False, title="Remember me", ui_element="checkbox")
app = Flask(__name__)
@app.route("/login", methods=["GET", "POST"])
def login():
form_data = {}
errors = {}
if request.method == "POST":
submitted = request.form.to_dict()
form_data = submitted
try:
MinimalLoginForm(**submitted)
except ValidationError as e:
errors = {err["loc"][0]: err["msg"] for err in e.errors() if err.get("loc")}
form_html = render_form_html(
MinimalLoginForm,
framework="bootstrap",
form_data=form_data,
errors=errors,
submit_url="/login",
)
return f"""<!doctype html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\">
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
<title>Login</title>
<link href=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css\" rel=\"stylesheet\">
</head>
<body class=\"container my-5\">
<h1 class=\"mb-4\">Login</h1>
{form_html}
</body>
</html>"""
Flask: simple registration page¶
from flask import Flask, request
from pydantic import ValidationError
from pydantic_schemaforms import render_form_html
from pydantic_schemaforms.schema_form import FormModel, Field
class UserRegistrationForm(FormModel):
username: str = Field(title="Username", min_length=3)
email: str = Field(title="Email", ui_element="email")
password: str = Field(title="Password", ui_element="password", min_length=8)
app = Flask(__name__)
@app.route("/register", methods=["GET", "POST"])
def register():
form_data = {}
errors = {}
if request.method == "POST":
submitted = request.form.to_dict()
form_data = submitted
try:
UserRegistrationForm(**submitted)
except ValidationError as e:
errors = {err["loc"][0]: err["msg"] for err in e.errors() if err.get("loc")}
form_html = render_form_html(
UserRegistrationForm,
framework="bootstrap",
form_data=form_data,
errors=errors,
submit_url="/register",
)
return f"""<!doctype html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\">
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
<title>Register</title>
<link href=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css\" rel=\"stylesheet\">
</head>
<body class=\"container my-5\">
<h1 class=\"mb-4\">Register</h1>
{form_html}
</body>
</html>"""
Template note:
- In Python f-string responses, use
{form_html}. - In Jinja templates, use
{{ form_html | safe }}.
UI vocabulary compatibility¶
The library supports a JSON-Schema-form style vocabulary (UI hints like input types and options), but you can also stay βpure Pydanticβ and let the defaults drive everything.
See the docs site for the current, supported UI hint patterns.
Framework Support¶
Bootstrap 5 (Recommended)¶
UserForm.render_form(framework="bootstrap", submit_url="/submit")
Note: Bootstrap markup/classes are always generated, but Bootstrap CSS/JS are only included if your host template provides them or you opt into self_contained=True / include_framework_assets=True.
Self-contained Bootstrap (no host template assets)¶
If you want a single HTML string that includes Bootstrap CSS/JS inline (no CDN, no global layout requirements), use the self_contained=True convenience flag:
from pydantic_schemaforms import render_form_html
form_html = render_form_html(
UserRegistrationForm,
framework=style,
form_data=form_data,
debug=debug,
self_contained=True,
submit_url="/register",
)
You can also call the FormModel convenience if you prefer:
form_html = UserRegistrationForm.render_form(
data=form_data,
framework=style,
debug=debug,
self_contained=True,
submit_url="/register",
)
Material Design¶
UserForm.render_form(framework="material", submit_url="/submit")
MaterialEmbeddedTheme
- Material icons integration
Plain HTML¶
UserForm.render_form(framework="none", submit_url="/submit")
?style=none (for example: /login?style=none&demo=true)
Renderer Architecture¶
- EnhancedFormRenderer is the canonical renderer. It walks the Pydantic
FormModel, feeds the sharedLayoutEngine, and delegates chrome/assets to aRendererTheme. - ModernFormRenderer now piggybacks on Enhanced by generating a throwaway
FormModelfrom legacyFormDefinition/FormFieldhelpers. It exists so existing builder/integration code keeps working while still benefiting from the shared pipeline. (The oldPy314Rendereralias has been removed; importModernFormRendererdirectly when you need the builder DSL.)
Because everything flows through Enhanced, fixes to layout, validation, or framework themes immediately apply to every renderer (Bootstrap, Material, embedded/self-contained, etc.). Choose the renderer based on the API surface you prefer (Pydantic models for FormModel or the builder DSL for ModernFormRenderer); the generated HTML is orchestrated by the same core engine either way.
Advanced Examples¶
File Upload Form¶
class FileUploadForm(FormModel):
title: str = Field(..., description="Upload title")
files: str = Field(
...,
description="Select files",
ui_element="file",
ui_options={"accept": ".pdf,.docx", "multiple": True}
)
description: str = Field(
...,
description="File description",
ui_element="textarea",
ui_options={"rows": 3}
)
Event Creation Form¶
class EventForm(FormModel):
event_name: str = Field(..., description="Event name", ui_autofocus=True)
event_datetime: str = Field(
...,
description="Event date and time",
ui_element="datetime-local"
)
max_attendees: int = Field(
...,
ge=1,
le=1000,
description="Maximum attendees",
ui_element="number"
)
is_public: bool = Field(
True,
description="Make event public",
ui_element="checkbox"
)
theme_color: str = Field(
"#3498db",
description="Event color",
ui_element="color"
)
Form Validation¶
from pydantic import ValidationError
@app.route("/submit", methods=["POST"])
def handle_submit():
try:
# Validate form data using your Pydantic model
user_data = UserForm(**request.form)
# Process valid data
return f"Welcome {user_data.username}!"
except ValidationError as e:
# Convert Pydantic errors to fieldβmessage mapping
errors = {err["loc"][0]: err["msg"] for err in e.errors() if err.get("loc")}
return f"Validation failed: {errors}", 400
Flask Integration¶
Complete Flask application example:
from flask import Flask, request, render_template_string
from pydantic import ValidationError
from pydantic_schemaforms.schema_form import FormModel, Field
app = Flask(__name__)
class UserRegistrationForm(FormModel):
username: str = Field(
...,
min_length=3,
max_length=20,
description="Choose a unique username",
ui_autofocus=True
)
email: str = Field(
...,
description="Your email address",
ui_element="email"
)
password: str = Field(
...,
min_length=8,
description="Choose a secure password",
ui_element="password"
)
age: int = Field(
...,
ge=13,
le=120,
description="Your age",
ui_element="number"
)
newsletter: bool = Field(
False,
description="Subscribe to our newsletter",
ui_element="checkbox"
)
@app.route("/", methods=["GET", "POST"])
def registration():
if request.method == "POST":
try:
# Validate form data
user = UserRegistrationForm(**request.form)
return f"Registration successful for {user.username}!"
except ValidationError as e:
# Convert Pydantic errors to fieldβmessage mapping expected by render_form
errors = {err["loc"][0]: err["msg"] for err in e.errors() if err.get("loc")}
# Re-render form with errors
form_html = UserRegistrationForm.render_form(
framework="bootstrap",
submit_url="/",
errors=errors
)
return render_template_string(BASE_TEMPLATE, form_html=form_html)
# Render empty form
form_html = UserRegistrationForm.render_form(framework="bootstrap", submit_url="/")
return render_template_string(BASE_TEMPLATE, form_html=form_html)
BASE_TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
<title>User Registration</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container my-5">
<h1>User Registration</h1>
{{ form_html | safe }}
</div>
</body>
</html>
"""
if __name__ == "__main__":
app.run(debug=True)
Render Timing¶
The library automatically measures form rendering time with multiple display options:
Display Timing Below Submit Button¶
Add a small timing display to your form:
html = render_form_html(MyForm, show_timing=True, submit_url="/submit")
This shows: Rendered in 0.0045s
Display in Debug Panel¶
Include comprehensive debugging information:
html = render_form_html(MyForm, debug=True, submit_url="/submit")
Shows timing in the debug panel header: Debug panel (development only) β 0.0045s render
Automatic INFO-Level Logging¶
Timing is always logged at INFO level:
import logging
logging.basicConfig(level=logging.INFO)
html = render_form_html(MyForm, submit_url="/submit")
# Logs: INFO pydantic_schemaforms: Form rendered in 0.0045s
Use Cases:
- Development: Use show_timing=True to see performance quickly
- Debugging: Use debug=True to see form structure and timing
- Production: Timing is logged automatically at INFO level for monitoring
See Render Timing Docs for complete details.
Application Logging¶
The library provides optional DEBUG-level logging that respects your application's logging configuration:
Automatic Timing Logs¶
Timing is always logged at INFO level (for production monitoring):
import logging
from pydantic_schemaforms import render_form_html
logging.basicConfig(level=logging.INFO)
html = render_form_html(MyForm, submit_url="/submit")
# Timing is logged automatically
Optional Debug Logs¶
Enable DEBUG logging to see detailed rendering steps:
import logging
from pydantic_schemaforms import render_form_html
# Option 1: Application-level DEBUG
logging.basicConfig(level=logging.DEBUG)
html = render_form_html(MyForm, submit_url="/submit")
# β
Timing + debug logs appear
# Option 2: Per-render control
html = render_form_html(MyForm, enable_logging=True, submit_url="/submit")
# β
Debug logs appear for this render only
Selective Logger Configuration¶
Enable library debugging without affecting your app's logging:
import logging
# Application at INFO level
logging.basicConfig(level=logging.INFO)
# Library DEBUG logs
library_logger = logging.getLogger('pydantic_schemaforms')
library_logger.setLevel(logging.DEBUG)
html = render_form_html(MyForm, submit_url="/submit")
# β
Library debug logs visible
# β
App remains at INFO level
Best Practice: Use Approach 1 (application-level configuration) in most cases. The library respects your app's logging setup.
See Application Logging Docs for complete details and integration examples.
Examples in This Repository¶
The main runnable demo in this repo is the FastAPI example:
- Run:
make ex-run - Visit: http://localhost:8000
- Self-contained demo: http://localhost:8000/self-contained
See examples/fastapi_example.py and examples/shared_models.py for the complete implementation.
For render timing and logging configuration patterns, see the
Render Timing and
Application Logging docs β the
standalone timing/logging demo scripts previously linked here have been removed from examples/.
Supported Input Types¶
Text Inputs:
- text (default), email, password, search
- tel, url
- textarea
Numeric Inputs:
- number, range
Date/Time Inputs:
- date, time, datetime-local
- week, month
Selection Inputs:
- checkbox, radio, select
Specialized Inputs:
- file, color, hidden
Input Options:
All HTML5 input attributes are supported through ui_options or Field parameters.
API Reference¶
FormModel¶
Extend your Pydantic models with FormModel to add form rendering capabilities:
from pydantic_schemaforms.schema_form import FormModel, Field
class MyForm(FormModel):
field_name: str = Field(..., ui_element="email")
# Render Bootstrap markup (expects host page to load Bootstrap)
html = MyForm.render_form(framework="bootstrap", submit_url="/submit")
# Render fully self-contained Bootstrap HTML (inlines vendored Bootstrap CSS/JS)
html = MyForm.render_form(framework="bootstrap", submit_url="/submit", self_contained=True)
as_api_model() β dual-use form + JSON API¶
FormModel is a plain Pydantic BaseModel, so it validates JSON directly. However, ui_* metadata stored in json_schema_extra clutters the FastAPI/OpenAPI docs when used as a body type. as_api_model() strips that metadata and returns a clean BaseModel that is safe to use as a FastAPI request/response model:
from pydantic_schemaforms import Field, FormModel
class ContactForm(FormModel):
name: str = Field(..., min_length=2, title="Full Name",
examples=["Alice Smith"],
ui_element="text", ui_placeholder="Your name")
email: str = Field(..., title="Email", examples=["alice@example.com"],
ui_element="email")
# Derive once at module level β cached per subclass
ContactSchema = ContactForm.as_api_model()
@app.post("/api/contact", response_model=ContactSchema)
async def api_contact(data: ContactSchema):
return data # Swagger shows clean schema; no ui_* keys
Field(title=...), Field(examples=[...]), descriptions, and all validation constraints (min_length, ge, pattern, β¦) are fully preserved in the API model. Only ui_* keys are removed.
Field Function¶
Enhanced Field function with UI element support:
Field(
default=..., # Pydantic default value
description="Label", # Field label
ui_element="email", # Input type
ui_autofocus=True, # Auto-focus field
ui_options={...}, # Additional options
# All standard Pydantic Field options...
)
Framework Options¶
"bootstrap"- Bootstrap 5 styling (recommended)"material"- Material Design 3 (self-contained, no external CSS framework)"none"- Plain HTML5 forms
Contributing¶
Contributions are welcome! Please check out the Contributing Guide for details.
Development Setup:
git clone https://github.com/devsetgo/pydantic-schemaforms.git
cd pydantic-schemaforms
pip install -e .
Run Tests:
python -m pytest tests/
Links¶
- Documentation: pydantic-schemaforms Docs
- Repository: GitHub
- PyPI: pydantic-schemaforms
- Issues: Bug Reports & Feature Requests
License¶
This project is licensed under the MIT License - see the LICENSE file for details: https://github.com/devsetgo/pydantic-schemaforms/blob/main/LICENSE
Quick Start¶
This page shows two common ways to integrate pydantic-schemaforms into an app:
- Model-first rendering (
FormModel+render_form_html()) - Builder + handlers (legacy):
- Build a
FormBuilder(often viacreate_form_from_model()) - Use exactly one handler per runtime:
- Sync:
handle_form() - Async:
handle_form_async()
- Sync:
- Build a
Option A: Model-first rendering (recommended)¶
from pydantic_schemaforms import Field, FormModel, render_form_html
class User(FormModel):
name: str = Field(...)
email: str = Field(..., ui_element="email")
html = render_form_html(User, submit_url="/user")
Async (FastAPI / ASGI)¶
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from pydantic_schemaforms import Field, FormModel, render_form_html_async
class User(FormModel):
name: str = Field(...)
email: str = Field(..., ui_element="email")
app = FastAPI()
@app.api_route("/user", methods=["GET", "POST"], response_class=HTMLResponse)
async def user_form(request: Request):
if request.method == "POST":
submitted = dict(await request.form())
result = User.validate(submitted, submit_url="/user")
if result.is_valid:
return f"<p>Hello {result.data['name']}!</p>"
form_html = await result.render_with_errors_async()
else:
form_html = await render_form_html_async(User, submit_url="/user")
return f"""
<!doctype html>
<html>
<body>
<h1>User</h1>
{form_html}
</body>
</html>
"""
User.validate() stores the submit URL so render_with_errors_async() needs no arguments.
Use render_with_errors_async() in async routes to avoid blocking the event loop.
If your host page already loads Bootstrap/Material, keep defaults. If you want a fully self-contained HTML chunk, pass self_contained=True. For Bootstrap forms this inlines Bootstrap CSS/JS and Bootstrap Icons (woff2 embedded) β no CDN required.
See: configuration.md and assets.md.
Template note:
- In Python f-string responses, embed
{form_html}directly. - In Jinja templates, render with
{{ form_html | safe }}.
CSRF setup¶
For browser forms with cookie/session auth, enable CSRF and verify token on submission.
Recommended rendering configuration:
from pydantic_schemaforms import CSRFMode
form_html = await render_form_html_async(
User,
form_data=form_data,
errors=errors,
submit_url="/user",
csrf_mode=CSRFMode.REQUIRED_PROVIDER,
csrf_token_provider=csrf_token,
csrf_field_name="csrf_token",
)
Notes:
csrf_modeaccepts either strings ("off","field-only","required-provider") orCSRFModeenum values.- Explicit
field-onlymode is debug-only and requiresdebug=True. - Legacy
include_csrf=Truestill works for backwards compatibility.
Then, in your POST handler, read and validate the submitted token before model validation.
See the full guide: csrf.md.
1) Build a form from a Pydantic model¶
from pydantic import BaseModel, EmailStr
from pydantic_schemaforms import create_form_from_model
class User(BaseModel):
name: str
email: EmailStr
builder = create_form_from_model(User, framework="bootstrap")
2) Async integration (FastAPI / ASGI)¶
from fastapi import FastAPI, Request
from pydantic_schemaforms import create_form_from_model, handle_form_async
app = FastAPI()
@app.api_route("/user", methods=["GET", "POST"])
async def user_form(request: Request):
builder = create_form_from_model(User, framework="bootstrap")
if request.method == "POST":
form = await request.form()
result = await handle_form_async(builder, submitted_data=dict(form))
if result.get("success"):
return {"ok": True, "data": result["data"]}
return result["form_html"]
result = await handle_form_async(builder)
return result["form_html"]
3) Sync integration (Flask / WSGI)¶
from flask import Flask, request
from pydantic_schemaforms import create_form_from_model, handle_form
app = Flask(__name__)
@app.route("/user", methods=["GET", "POST"])
def user_form():
builder = create_form_from_model(User, framework="bootstrap")
if request.method == "POST":
result = handle_form(builder, submitted_data=request.form.to_dict())
if result.get("success"):
return f"Saved: {result['data']}"
return result["form_html"]
return handle_form(builder)["form_html"]
Dual-use: form endpoint + JSON API¶
Because FormModel is a plain Pydantic BaseModel, you can use it for JSON
validation directly. Call as_api_model() to get a clean BaseModel with all
ui_* rendering metadata stripped β safe to use as a FastAPI typed body or
response_model without cluttering the OpenAPI docs.
ContactSchema = ContactForm.as_api_model()
@app.post("/api/contact", response_model=ContactSchema)
async def api_contact(data: ContactSchema):
return data # FastAPI validates JSON; Swagger shows a clean schema
Field(title=...), Field(description=...), Field(examples=[...]), and all
validation constraints survive the transform unchanged.
See the full pattern in tutorial_fastapi.md.
Notes¶
handle_form*()returns either{form_html}(initial render) or{success: bool, ...}(submission).- Asset delivery (
asset_mode) and full-page wrappers are documented in assets.md.
Tutorials
Tutorial: A Simple FastAPI Project¶
This tutorial walks through creating a small FastAPI app that renders a form from a Pydantic model using pydantic-schemaforms.
It uses the async-first render API so large forms wonβt block the event loop.
Prerequisites¶
- Python 3.14+
Note: model-first rendering¶
This tutorial uses the model-first API (recommended). You only need:
- Define a
FormModel - Render it (async) with
render_form_html_async()orFormModel.render_form_async() - If using Jinja templates, render
{{ form_html | safe }}
See configuration.md.
1) Create a project¶
mkdir schemaforms-fastapi-demo
cd schemaforms-fastapi-demo
python -m venv .venv
source .venv/bin/activate
2) Install dependencies¶
pip install "pydantic-schemaforms[fastapi]" uvicorn
3) Create main.py¶
Create a file named main.py:
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from pydantic_schemaforms import Field, FormModel, render_form_html_async
class User(FormModel):
name: str = Field(...)
email: str = Field(..., ui_element="email")
app = FastAPI(title="SchemaForms Demo")
@app.api_route("/user", methods=["GET", "POST"], response_class=HTMLResponse)
async def user_form(request: Request):
if request.method == "POST":
submitted = dict(await request.form())
result = User.validate(submitted, submit_url="/user")
if result.is_valid:
return f"<p>Hello {result.data['name']}!</p>"
form_html = await result.render_with_errors_async()
else:
form_html = await render_form_html_async(User, submit_url="/user")
return f"""
<html>
<body>
<h1>User</h1>
{form_html}
</body>
</html>
"""
User.validate() stores the submit URL and framework so render_with_errors_async()
needs no arguments on failure β the re-render just works.
4) Run the server¶
uvicorn main:app --reload
Open http://127.0.0.1:8000/user
Sync vs Async (whatβs the difference?)¶
render_form_html() (sync)¶
Use render_form_html() when your web framework is synchronous (WSGI) and you already have submitted data as a plain dict.
Typical environments:
- Flask / Django (classic request/response)
- CLI apps or scripts that validate a dict
Example (Flask):
from flask import Flask, request
from pydantic_schemaforms import Field, FormModel, render_form_html
class User(FormModel):
name: str = Field(...)
email: str = Field(..., ui_element="email")
app = Flask(__name__)
@app.route("/user", methods=["GET", "POST"])
def user_form():
if request.method == "POST":
result = User.validate(request.form.to_dict(), submit_url="/user")
if result.is_valid:
return f"Hello {result.data['name']}!"
return result.render_with_errors()
return render_form_html(User, submit_url="/user")
render_form_html_async() (async)¶
Use render_form_html_async() when you are in an async runtime (ASGI) and you are already await-ing things (like request.form() in FastAPI/Starlette).
Typical environments:
- FastAPI / Starlette
- Any async stack where you want to keep the request handler non-blocking
Important FastAPI note¶
FastAPIβs Request.form() is async, so the most natural implementation is an async def route and render_form_html_async().
If you already have a dict of submitted data (for example from a different parsing path), you can still call the sync renderer inside an async def route β but for large forms, the async renderer avoids blocking the event loop.
Dual-use: HTML form + JSON API from one model¶
FormModel is a plain Pydantic BaseModel subclass, so it can validate JSON
directly. The only friction is that Field(ui_element=..., ui_placeholder=...)
stores rendering metadata in the JSON schema, cluttering the OpenAPI docs when
the class is used as a FastAPI body type.
as_api_model() returns a new BaseModel with the same fields and validation
rules but no ui_* keys β exactly what a hand-written Pydantic model looks
like. Field(title=...), Field(description=...), Field(examples=[...]),
and all validation constraints (min_length, ge, pattern, β¦) are fully
preserved.
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from pydantic_schemaforms import Field, FormModel, parse_nested_form_data
app = FastAPI()
class ContactForm(FormModel):
name: str = Field(
...,
min_length=2,
title="Full Name",
examples=["Alice Smith"],
ui_element="text",
ui_placeholder="Enter your full name",
)
email: str = Field(
...,
title="Email Address",
examples=["alice@example.com"],
ui_element="email",
)
message: str = Field(
...,
min_length=10,
title="Message",
examples=["Hello, I would like to ask about..."],
ui_element="textarea",
)
# Derive the clean API model once at module level β safe to use as a
# FastAPI body type or response_model.
ContactSchema = ContactForm.as_api_model()
# ββ HTML form ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@app.get("/contact", response_class=HTMLResponse)
async def contact_get():
return await ContactForm.render_form_async(submit_url="/contact")
@app.post("/contact", response_class=HTMLResponse)
async def contact_post(request: Request):
data = parse_nested_form_data(await request.form())
result = ContactForm.validate(data, submit_url="/contact")
if result.is_valid:
return f"<p>Thank you, {result.data['name']}!</p>"
return HTMLResponse(await result.render_with_errors_async())
# ββ JSON API β OpenAPI shows clean schema with examples βββββββββββββββββββββ
@app.post("/api/contact", response_model=ContactSchema)
async def api_contact(data: ContactSchema):
# `data` is a fully validated ContactSchema instance.
# FastAPI's Swagger UI shows the request body and response schema without
# any ui_* keys, identical to a hand-written Pydantic model.
return data
What the OpenAPI schema looks like for the /api/contact body:
{
"properties": {
"name": { "minLength": 2, "title": "Full Name", "examples": ["Alice Smith"], "type": "string" },
"email": { "title": "Email Address", "examples": ["alice@..."], "type": "string" },
"message": { "minLength": 10,"title": "Message", "examples": ["Hello..."], "type": "string" }
},
"required": ["name", "email", "message"]
}
No ui_element, no ui_placeholder β just the information an API consumer
needs.
Next steps¶
- Learn about asset delivery (
asset_mode) in assets.md - See the broader integration pattern in quickstart.md
- Configure CSRF for browser + session workflows in csrf.md
Recipes¶
Copy-paste patterns for common form tasks. Each recipe is self-contained β combine them freely.
Minimal form¶
The smallest possible form: define a FormModel, render it, handle submission.
from pydantic_schemaforms import Field, FormModel, render_form_html
class LoginForm(FormModel):
username: str = Field(..., ui_element="text", ui_placeholder="Username")
password: str = Field(..., ui_element="password", ui_placeholder="Password")
html = render_form_html(LoginForm, submit_url="/login")
For async (FastAPI):
from pydantic_schemaforms import render_form_html_async
html = await render_form_html_async(LoginForm, submit_url="/login")
GET + POST route (FastAPI)¶
Full round-trip: render on GET, validate on POST, re-render with errors if invalid.
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from pydantic_schemaforms import Field, FormModel, render_form_html_async
class ContactForm(FormModel):
name: str = Field(..., min_length=2, title="Full Name", ui_element="text")
email: str = Field(..., title="Email", ui_element="email")
message: str = Field(..., min_length=10, title="Message", ui_element="textarea")
app = FastAPI()
@app.api_route("/contact", methods=["GET", "POST"], response_class=HTMLResponse)
async def contact(request: Request):
if request.method == "POST":
data = dict(await request.form())
result = ContactForm.validate(data, submit_url="/contact")
if result.is_valid:
# process result.data β¦
return "<p>Thanks!</p>"
form_html = await result.render_with_errors_async()
else:
form_html = await render_form_html_async(ContactForm, submit_url="/contact")
return f"<html><body>{form_html}</body></html>"
GET + POST route (Flask)¶
from flask import Flask, request
from pydantic_schemaforms import Field, FormModel, render_form_html
app = Flask(__name__)
class ContactForm(FormModel):
name: str = Field(..., min_length=2, title="Full Name", ui_element="text")
email: str = Field(..., title="Email", ui_element="email")
message: str = Field(..., min_length=10, title="Message", ui_element="textarea")
@app.route("/contact", methods=["GET", "POST"])
def contact():
if request.method == "POST":
result = ContactForm.validate(request.form.to_dict(), submit_url="/contact")
if result.is_valid:
return "Thanks!"
form_html = result.render_with_errors()
else:
form_html = render_form_html(ContactForm, submit_url="/contact")
return f"<html><body>{form_html}</body></html>"
Jinja2 template embedding¶
Render form HTML server-side, pass it to the template, render with | safe.
# route
form_html = await render_form_html_async(ContactForm, submit_url="/contact")
return templates.TemplateResponse("page.html", {"request": request, "form_html": form_html})
<!-- page.html -->
<div class="container">
{{ form_html | safe }}
</div>
Field types¶
from typing import Optional
from pydantic_schemaforms import Field, FormModel
class AllFieldsExample(FormModel):
# Text inputs
name: str = Field(..., ui_element="text", ui_placeholder="Full name")
email: str = Field(..., ui_element="email", ui_placeholder="you@example.com")
password: str = Field(..., ui_element="password", ui_placeholder="β’β’β’β’β’β’β’β’")
phone: Optional[str] = Field(None, ui_element="tel", ui_placeholder="+1 555 000 0000")
website: Optional[str] = Field(None, ui_element="url", ui_placeholder="https://")
query: Optional[str] = Field(None, ui_element="search", ui_placeholder="Searchβ¦")
# Multi-line
bio: Optional[str] = Field(None, ui_element="textarea", ui_placeholder="Tell us about yourself")
# Numeric
age: Optional[int] = Field(None, ui_element="number", ge=13, le=120)
satisfaction: Optional[int] = Field(None, ui_element="range", ge=1, le=10)
# Date / time
birth_date: Optional[str] = Field(None, ui_element="date")
appt_time: Optional[str] = Field(None, ui_element="time")
# Colour picker
theme_color: str = Field("#3498db", ui_element="color")
# Select (explicit options list)
plan: str = Field("free", ui_element="select",
json_schema_extra={"ui_options": {"choices": [
{"value": "free", "label": "Free"},
{"value": "pro", "label": "Pro"},
{"value": "team", "label": "Team"},
]}})
# Boolean
newsletter: bool = Field(False, ui_element="checkbox", title="Subscribe to newsletter")
agree: bool = Field(False, ui_element="checkbox", title="I accept the terms")
Optional fields¶
Use Optional[T] with a default of None. Pydantic won't require these; the form renders them without a * marker.
from typing import Optional
from pydantic_schemaforms import Field, FormModel
class ProfileForm(FormModel):
display_name: str = Field(..., title="Display Name", ui_element="text")
bio: Optional[str] = Field(None, title="Bio", ui_element="textarea")
website: Optional[str] = Field(None, title="Website", ui_element="url")
Select from a Python Enum¶
Enum members become select options automatically. The field value is the enum's .value.
from enum import Enum
from pydantic_schemaforms import Field, FormModel
class Role(str, Enum):
USER = "user"
ADMIN = "admin"
MOD = "moderator"
class InviteForm(FormModel):
email: str = Field(..., title="Email", ui_element="email")
role: Role = Field(Role.USER, title="Role", ui_element="select",
json_schema_extra={"ui_options": {"choices": [
{"value": r.value, "label": r.value.capitalize()} for r in Role
]}})
Cross-field validation (password confirmation)¶
Use @field_validator with info.data to read already-validated sibling fields.
from pydantic import field_validator
from pydantic_schemaforms import Field, FormModel
class RegistrationForm(FormModel):
username: str = Field(..., min_length=3, title="Username", ui_element="text")
password: str = Field(..., min_length=8, title="Password", ui_element="password")
confirm_password: str = Field(..., title="Confirm Password", ui_element="password")
@field_validator("confirm_password")
@classmethod
def passwords_match(cls, v, info):
if "password" in info.data and v != info.data["password"]:
raise ValueError("Passwords do not match")
return v
Field-level @field_validator¶
Strip and sanitise a field value, or apply custom business rules.
from pydantic import field_validator
from pydantic_schemaforms import Field, FormModel
class CommentForm(FormModel):
username: str = Field(..., title="Username", ui_element="text")
body: str = Field(..., title="Comment", ui_element="textarea", min_length=5)
@field_validator("username")
@classmethod
def clean_username(cls, v: str) -> str:
v = v.strip()
if not v.isalnum():
raise ValueError("Username may only contain letters and numbers")
return v
Bootstrap vs Material Design¶
Pass framework to change the CSS framework. The default is "bootstrap".
from pydantic_schemaforms import render_form_html_async
# Bootstrap (default)
html = await render_form_html_async(MyForm, submit_url="/submit")
# Material Design 3
html = await render_form_html_async(MyForm, submit_url="/submit", framework="material")
Self-contained form (no CDN)¶
Inline all assets so the HTML chunk has zero external dependencies. Useful for email templates, offline apps, or sandboxed iframes.
html = await render_form_html_async(
MyForm,
submit_url="/submit",
self_contained=True, # inlines Bootstrap CSS/JS + Bootstrap Icons woff2
)
Repeating sub-forms (model list)¶
Let users add/remove items dynamically with a model_list field.
from typing import List
from pydantic_schemaforms import Field, FormModel
from pydantic_schemaforms.form_field import FormField
class PhoneEntry(FormModel):
label: str = Field(..., title="Label", ui_element="text", ui_placeholder="Mobile / Home / Work")
number: str = Field(..., title="Number", ui_element="tel", ui_placeholder="+1 555 000 0000")
class PersonForm(FormModel):
name: str = Field(..., title="Full Name", ui_element="text")
phones: List[PhoneEntry] = FormField(
default_factory=list,
title="Phone Numbers",
input_type="model_list",
model_class=PhoneEntry,
add_button_label="Add phone",
min_length=0,
max_length=5,
)
Parse the submitted flat form data back into the nested structure:
from pydantic_schemaforms import parse_nested_form_data
raw = dict(await request.form())
nested = parse_nested_form_data(raw)
result = PersonForm.validate(nested, submit_url="/person")
Customizing the list with Field()¶
The item model is resolved automatically from the list[ItemModel] type
annotation β model_class is only needed with the FormField constructor
shown above. Using the recommended Field() constructor, the same list
can be customized with these ui_* keyword arguments:
class PersonForm(FormModel):
name: str = Field(..., title="Full Name", ui_element="text")
phones: List[PhoneEntry] = Field(
default_factory=list,
title="Phone Numbers",
ui_element="model_list",
ui_add_button_label="Add phone",
ui_item_title_template="{label}: {number}",
ui_collapsible_items=False, # render flat cards instead of collapsible ones
ui_items_expanded=False, # start collapsed (only relevant if collapsible)
min_length=0,
max_length=5,
)
ui_item_title_templateis formatted against the item's own field values (here{label}and{number}fromPhoneEntry), plus{index}(1-based).- Item-count bounds (
min_length/max_lengthabove) are the same Pydantic constraint used for string length β they are not a separateui_optionssetting. - Nested per-item validation errors (keyed like
phones[0].numberbyFormModel.validate()) render inline on the matching item automatically.
Tabbed layout¶
Group fields into tabs. Users navigate between tabs without a page reload.
from pydantic_schemaforms import Field, FormModel
from pydantic_schemaforms.form_layouts import TabbedLayout
class ProfileForm(FormModel):
# Tab 1
first_name: str = Field(..., title="First Name", ui_element="text")
last_name: str = Field(..., title="Last Name", ui_element="text")
# Tab 2
bio: str = Field("", title="Bio", ui_element="textarea")
website: str = Field("", title="Website", ui_element="url")
class FormConfig:
layout = TabbedLayout(
("Personal", ["first_name", "last_name"]),
("Profile", ["bio", "website"]),
)
Horizontal layout¶
Render label and input side-by-side (Bootstrap grid).
from pydantic_schemaforms.form_layouts import HorizontalLayout
class SettingsForm(FormModel):
display_name: str = Field(..., title="Display name", ui_element="text")
timezone: str = Field("UTC", title="Timezone", ui_element="text")
class FormConfig:
layout = HorizontalLayout(label_cols=3, input_cols=9)
CSRF protection¶
Generate a token on GET, verify it on POST with a constant-time compare.
import hmac
import secrets
from fastapi import Request
from starlette.middleware.sessions import SessionMiddleware
from pydantic_schemaforms import CSRFMode, render_form_html_async
app.add_middleware(SessionMiddleware, secret_key="change-me-in-production")
SESSION_KEY = "csrf_token"
@app.get("/form", response_class=HTMLResponse)
async def get_form(request: Request):
token = secrets.token_urlsafe(32)
request.session[SESSION_KEY] = token
html = await render_form_html_async(
MyForm,
submit_url="/form",
csrf_mode=CSRFMode.REQUIRED_PROVIDER,
csrf_token_provider=token,
)
return f"<html><body>{html}</body></html>"
@app.post("/form", response_class=HTMLResponse)
async def post_form(request: Request):
data = dict(await request.form())
submitted = data.get("csrf_token", "")
expected = request.session.get(SESSION_KEY, "")
if not hmac.compare_digest(submitted, expected):
raise HTTPException(status_code=403, detail="Invalid CSRF token")
result = MyForm.validate(data, submit_url="/form")
# β¦
Dual-use: HTML form + JSON API from one model¶
as_api_model() strips ui_* keys so OpenAPI docs look hand-written, while all validation constraints are preserved.
from pydantic_schemaforms import Field, FormModel
class ContactForm(FormModel):
name: str = Field(..., min_length=2, title="Name", ui_element="text")
email: str = Field(..., title="Email", ui_element="email")
message: str = Field(..., min_length=10, title="Message", ui_element="textarea")
# Derive once at module level β safe to use as response_model or body type.
ContactSchema = ContactForm.as_api_model()
# HTML route
@app.api_route("/contact", methods=["GET", "POST"], response_class=HTMLResponse)
async def contact_html(request: Request): ...
# JSON API route β same validation, clean OpenAPI schema
@app.post("/api/contact", response_model=ContactSchema)
async def contact_api(data: ContactSchema):
return data
Live HTMX validation¶
Validate individual fields on blur without a page reload. Requires HTMX loaded in the page.
Server setup:
from pydantic_schemaforms import (
EmailRule, FieldValidator, HTMXValidationConfig, LiveValidator, MinLengthRule,
)
from pydantic_schemaforms.live_validation import validation_response_headers
from fastapi.responses import HTMLResponse
# Build once at module level.
validator = LiveValidator(HTMXValidationConfig(validate_on_blur=True))
fv_name = FieldValidator("name")
fv_name.add_rule(MinLengthRule(2, message="At least 2 characters"))
validator.register_field_validator(fv_name)
fv_email = FieldValidator("email")
fv_email.add_rule(EmailRule())
validator.register_field_validator(fv_email)
VALIDATOR_SCRIPT = validator.render_htmx_script()
@app.post("/validate/{field_name}", response_class=HTMLResponse)
async def validate_field(field_name: str, request: Request):
raw = await request.form()
value = str(raw.get(field_name, ""))
result = validator.validate_field(field_name, value)
if result.is_valid:
feedback = '<span class="text-success">Looks good!</span>'
else:
errors = "; ".join(result.errors)
feedback = f'<span class="text-danger">{errors}</span>'
return HTMLResponse(feedback, headers=validation_response_headers(field_name, result.is_valid))
Template (Jinja2 / plain HTML):
<!-- Load HTMX -->
<script src="/vendor/htmx.min.js"></script>
<!-- Inline the LiveValidator JS (applies is-valid / is-invalid classes) -->
{{ validator_script | safe }}
<!-- Field with live validation -->
<input
type="text" name="name" id="name"
hx-post="/validate/name"
hx-trigger="blur"
hx-target="#name-feedback"
hx-swap="innerHTML"
/>
<div id="name-feedback"></div>
Pass validator_script=VALIDATOR_SCRIPT in the template context.
Multiple triggers (blur + debounced input)¶
Enable several triggers together; they are combined into one hx-trigger attribute.
validator = LiveValidator(HTMXValidationConfig(
validate_on_blur=True,
validate_on_input=True,
validate_on_change=False,
debounce_ms=400, # wait 400 ms after typing stops
))
This generates hx-trigger="blur, input delay:400ms" on each field.
Validate a full Pydantic model field-by-field¶
Use register_model_validator when your validation rules live inside the Pydantic model itself rather than explicit FieldValidator rules. Each field is validated in isolation (other required fields are not needed).
from pydantic import BaseModel
from pydantic_schemaforms import HTMXValidationConfig, LiveValidator
class OrderForm(BaseModel):
quantity: int
sku: str
validator = LiveValidator(HTMXValidationConfig(validate_on_blur=True))
validator.register_model_validator(OrderForm)
# Now /validate/quantity and /validate/sku both work via validator.validate_field(...)
Pre-populate form with existing data¶
Pass a form_data dict to render the form with values already filled in (useful for edit pages).
existing = {"name": "Alice", "email": "alice@example.com", "message": "Hello"}
html = await render_form_html_async(
ContactForm,
submit_url="/contact",
form_data=existing,
)
Re-render with validation errors¶
After a failed POST, re-render showing field-level error messages.
@app.post("/contact", response_class=HTMLResponse)
async def post_contact(request: Request):
data = dict(await request.form())
result = ContactForm.validate(data, submit_url="/contact")
if result.is_valid:
return "<p>Sent!</p>"
# result stores the submit URL; no need to pass it again.
form_html = await result.render_with_errors_async()
return f"<html><body>{form_html}</body></html>"
Reference
Configuration¶
This library is driven almost entirely by render-time options (which framework/theme to target, whether to inline assets, what layout to use) plus field-level UI metadata stored in your modelβs JSON Schema.
What render_form_html returns¶
render_form_html() returns an HTML fragment β a <form> element and its supporting scripts, not a complete HTML page. You are responsible for embedding it in your page template and for loading any required CSS framework.
# fragment β embed it inside your own <html>/<head>/<body>
form_html = render_form_html(MyForm, submit_url="/submit")
CSS loading options:
| How you load CSS | Render call |
|---|---|
| Your page already includes Bootstrap/Material | Default β no extra args |
| You want fully self-contained HTML (inlined CSS) | Pass self_contained=True |
You want the renderer to emit <link> tags |
Pass include_framework_assets=True |
If you embed form_html in a plain HTML response without loading Bootstrap/Material, the form will render without styling. Pass self_contained=True for offline/standalone use, or load Bootstrap/Material in your base template.
Rendering entry points¶
submit_url is required for render calls. This library does not default form submit targets.
You can render forms in a few different ways. Pick one that matches your project style:
1) Model-first (recommended)¶
Use FormModel + render_form_html().
from pydantic_schemaforms import Field, FormModel, render_form_html
class RegistrationForm(FormModel):
name: str = Field(..., ui_placeholder="Jane")
email: str = Field(..., ui_element="email")
form_html = render_form_html(
RegistrationForm,
submit_url="/register",
framework="bootstrap",
layout="vertical",
)
If you prefer a method on the model, use RegistrationForm.render_form(...).
2) Builder + handlers (legacy integration)¶
Docs and examples may still reference the builder pattern:
- Build with
create_form_from_model() - Validate + render with
handle_form()/handle_form_async()
This remains supported for backwards compatibility, but the underlying HTML rendering flows through the same enhanced renderer pipeline.
Framework and assets¶
There are two separate but related concepts:
- Framework selection:
framework="bootstrap" | "material" | "none" - Asset delivery: whether the form HTML includes the framework CSS/JS
include_framework_assets¶
False(default): the returned HTML assumes your page already loads Bootstrap/Material.True: the renderer emits framework CSS/JS tags.
asset_mode¶
Controls how the framework assets are provided when include_framework_assets=True:
"vendored": inline the vendored CSS/JS into the output (offline-friendly)."cdn": link to a CDN."none": emit no framework tags.
self_contained¶
For convenience, self_contained=True forces a fully-embedded result:
include_framework_assets=Trueasset_mode="vendored"
html = render_form_html(
RegistrationForm,
submit_url="/register",
self_contained=True,
)
See also: docs/assets.md
Layout selection¶
At the top level, pass layout= to the renderer:
"vertical"(default)"tabbed""side-by-side"
html = render_form_html(RegistrationForm, layout="tabbed", submit_url="/register")
For advanced composition (tabs/accordion/grid wrappers and schema-defined layout fields), see docs/layouts.md.
Field UI metadata¶
UI metadata is stored in json_schema_extra with keys like ui_element, ui_placeholder, etc. The library provides a convenience wrapper pydantic_schemaforms.Field() that populates these keys.
Common UI keys:
ui_element: widget type (see docs/inputs.md)ui_placeholderui_help_textui_options: widget-specific options (e.g. selection choices)ui_class,ui_styleui_disabled,ui_readonly,ui_hidden,ui_autofocusui_order: field ordering
Example:
class ProfileForm(FormModel):
bio: str = Field(
"",
title="Bio",
description="A short bio shown publicly",
ui_element="textarea",
ui_placeholder="Tell us about yourselfβ¦",
ui_options={"rows": 6},
ui_order=10,
)
Escaping and templates (|safe)¶
- If you return the HTML string directly from a framework response (e.g. FastAPI
HTMLResponse), no extra escaping happens. - If you embed the HTML into a Jinja template, you must mark it safe:
{{ form_html | safe }}
Otherwise Jinja will escape the markup and youβll see literal <div> tags in the browser.
Error rendering behavior¶
When you pass errors= to render_form_html() / render_form_html_async(), the renderer now includes a built-in top-level summary block inside form_html.
- Field paths are humanized for users (example:
pets[7].nameβPet #8 β Name). - The same behavior works for Bootstrap and Material output.
- No template-side error loop is required for standard usage.
This means most templates only need:
{{ form_html | safe }}
Layout support behavior¶
The enhanced renderer injects a small internal style block to keep nested/layout-heavy forms (layout, model_list, tabbed/side-by-side structures) width-safe across host templates.
- This reduces the need for route-specific template CSS hacks.
- If your app provides strict custom CSS, you can still override these classes in your host stylesheet.
Future functionality¶
Custom templates¶
A custom template API is planned that will let you replace individual form widgets and layout sections with your own markup, while retaining the library's structural XSS safety guarantees (Python 3.14 t-strings with the html() processor).
This is not yet available for external use. The internal implementation (FormStyleTemplates, TemplateString, FormStyle) is stable, but the public authoring API β how you write and register a custom template β has not been finalised.
Watch the release notes for when this is ready.
CSRF Protection¶
This guide explains how CSRF works with pydantic-schemaforms, when to use it, and how to configure it safely in production.
What CSRF Is¶
Cross-Site Request Forgery (CSRF) is an attack where a malicious site causes a user browser to submit a request to your app while the user is authenticated.
Typical risk pattern:
- Your app uses cookie-based auth/session.
- A browser automatically sends those cookies with cross-site requests.
- An attacker tricks a user into submitting a state-changing request (
POST,PUT,DELETE, etc.).
CSRF protection adds an unguessable token that must be present and valid for each state-changing form submission.
How pydantic-schemaforms Uses CSRF¶
pydantic-schemaforms renders HTML forms and can include a hidden CSRF field. Your application code is responsible for:
- Generating a token.
- Storing/verifying token state (for example, in server-side session storage).
- Rejecting invalid submissions.
The library intentionally does not own your trust boundary.
CSRF Modes¶
csrf_mode controls form rendering behavior:
off: do not render a CSRF field.field-only: render CSRF field without requiring a provider.required-provider: require a token provider and render token field.
You can pass either:
- the string value (for example
"required-provider"), or - the enum value (
CSRFMode.REQUIRED_PROVIDER).
Important production guidance:
- Prefer
required-providerfor real security. field-onlyis compatibility/migration-oriented and is intentionally restricted:- explicit
csrf_mode="field-only"is only allowed withdebug=True. - Backward compatibility remains for legacy
include_csrf=Truebehavior.
Parameters¶
Relevant render options:
csrf_mode: one ofoff,field-only,required-provider.csrf_token_provider: token string or callable returning a token string.csrf_field_name: hidden input name (defaultcsrf_token).
Example:
from pydantic_schemaforms import CSRFMode
form_html = await render_form_html_async(
LoginForm,
form_data=form_data,
errors=errors,
submit_url="/login",
csrf_mode=CSRFMode.REQUIRED_PROVIDER,
csrf_token_provider=csrf_token,
csrf_field_name="csrf_token",
)
FastAPI Setup (Recommended Pattern)¶
import hmac
import secrets
from fastapi import FastAPI, Request
from starlette.middleware.sessions import SessionMiddleware
app = FastAPI()
app.add_middleware(SessionMiddleware, secret_key="change-me")
CSRF_SESSION_KEY = "login_csrf_token"
def issue_csrf_token(request: Request) -> str:
token = secrets.token_urlsafe(32)
request.session[CSRF_SESSION_KEY] = token
return token
def verify_csrf_token(request: Request, submitted_token: str | None) -> bool:
expected = request.session.get(CSRF_SESSION_KEY)
if not expected or not submitted_token:
return False
return hmac.compare_digest(str(expected), str(submitted_token))
Flow:
- On
GET: issue token and render form withcsrf_mode="required-provider". - On
POST: extract submitted token, verify, reject with403if invalid. - On success: rotate/remove token.
Flask Setup (Equivalent Pattern)¶
Use Flask session storage and the same issue/verify steps:
- generate token on
GET. - render with
required-provider. - verify on
POSTbefore validation.
When CSRF Applies (And When It Usually Does Not)¶
CSRF usually applies:
- Browser-based forms.
- Cookie/session auth.
- State-changing endpoints.
CSRF often not applicable:
- Pure machine-to-machine APIs using bearer tokens in
Authorizationheader. - Non-browser clients that do not auto-send ambient cookies.
- Read-only endpoints (
GET) with no state changes.
Even when CSRF may be less relevant, keep standard API authz/authn controls in place.
Security Benefits¶
Using CSRF tokens helps:
- block cross-site forged submissions.
- ensure request intent from pages your app rendered.
- reduce account/action takeover risk in session-based browser workflows.
Logging Notes¶
By default, CSRF validation in example integrations surfaces user-facing errors but does not log token values.
Best practice:
- never log raw CSRF tokens.
- log only outcome/context (for example,
csrf_failed, route, request id, user id if available).
Production Checklist¶
- Use
csrf_mode="required-provider". - Verify token before processing form payload.
- Use constant-time comparison (
hmac.compare_digest). - Rotate tokens appropriately.
- Keep session secret strong and private.
- Use HTTPS in production.
- Avoid token leakage in logs.
Inputs (UI Elements)¶
This page documents the supported ui_element values and gives copy-paste examples. Each element includes:
- Required: minimum setup to render that element
- Optional: supported field metadata and ui_options values
Where you set these:
- Preferred:
pydantic_schemaforms.Field(..., ui_element="...") - Or directly via
json_schema_extra={"ui_element": "..."}
from pydantic_schemaforms import Field, FormModel
class Example(FormModel):
email: str = Field(..., ui_element="email")
Common Field Metadata (Optional)¶
These are available on any element type:
- ui_placeholder
- ui_help_text
- ui_disabled
- ui_readonly
- ui_hidden
- ui_autofocus
- ui_class
- ui_style
- ui_order
- ui_options (widget-specific attributes)
Pydantic Field Options Are Also Supported¶
UI metadata is additive. You can use standard Pydantic Field options together with ui_element.
Common Pydantic options you can combine with UI elements:
- title, description, examples
- default, default_factory
- alias
- min_length, max_length, pattern
- gt, ge, lt, le, multiple_of, strict
- validate_default, exclude, frozen
- any additional supported Field kwargs via passthrough
from pydantic_schemaforms import Field, FormModel
class PydanticAndUiExample(FormModel):
username: str = Field(
...,
title="Username",
description="Public handle shown on profile",
min_length=3,
max_length=32,
pattern=r"^[a-zA-Z0-9_]+$",
examples=["jane_doe"],
ui_element="text",
ui_placeholder="jane_doe",
ui_options={"autocomplete": "username"},
)
age: int = Field(
18,
ge=13,
le=120,
strict=True,
validate_default=True,
ui_element="number",
ui_options={"step": 1},
)
Copy-Paste Examples By ui_element¶
Use this import block for the examples below:
from pydantic_schemaforms import Field, FormModel
Supported ui_element values¶
These map to concrete input components in pydantic_schemaforms.inputs.*.
Text¶
text(default)passwordemailsearchtextareaurltel
text¶
Required:
- Field type + ui_element="text"
Optional:
- ui_placeholder
- ui_options: minlength, maxlength, pattern, autocomplete, inputmode
class TextExample(FormModel):
username: str = Field(
...,
ui_element="text",
ui_placeholder="jane_doe",
ui_options={"minlength": 3, "maxlength": 32, "autocomplete": "username"},
)
password¶
Required:
- Field type + ui_element="password"
Optional:
- ui_options: minlength, maxlength, autocomplete (defaults to new-password)
class PasswordExample(FormModel):
password: str = Field(
...,
ui_element="password",
ui_placeholder="Enter a strong password",
ui_options={"minlength": 12, "autocomplete": "new-password"},
)
email¶
Required:
- Field type + ui_element="email"
Optional:
- ui_placeholder
- ui_options: autocomplete, pattern, maxlength
class EmailExample(FormModel):
email: str = Field(
...,
ui_element="email",
ui_placeholder="name@company.com",
ui_options={"autocomplete": "email", "maxlength": 254},
)
search¶
Required:
- Field type + ui_element="search"
Optional:
- ui_placeholder
- ui_options: maxlength, autocomplete
class SearchExample(FormModel):
q: str = Field(
...,
ui_element="search",
ui_placeholder="Search products",
ui_options={"maxlength": 120, "autocomplete": "off"},
)
textarea¶
Required:
- Field type + ui_element="textarea"
Optional:
- ui_placeholder
- ui_options: rows, cols, wrap, resize, minlength, maxlength
class TextAreaExample(FormModel):
bio: str = Field(
...,
ui_element="textarea",
ui_placeholder="Tell us about yourself",
ui_options={"rows": 5, "cols": 60, "wrap": "soft", "resize": "vertical"},
)
url¶
Required:
- Field type + ui_element="url"
Optional:
- ui_placeholder
- ui_options: pattern, autocomplete
class UrlExample(FormModel):
website: str = Field(
...,
ui_element="url",
ui_placeholder="https://example.com",
ui_options={"pattern": r"https?://.+", "autocomplete": "url"},
)
tel¶
Required:
- Field type + ui_element="tel"
Optional:
- ui_placeholder
- ui_options: pattern, autocomplete, maxlength
class TelExample(FormModel):
phone: str = Field(
...,
ui_element="tel",
ui_placeholder="+1 555 123 4567",
ui_options={"autocomplete": "tel", "maxlength": 20},
)
Notes:
- Long string fields may auto-infer to textarea.
- password preserves the value if you supply one (use with care).
Numbers¶
numberrange
number¶
Required:
- Numeric field type + ui_element="number"
Optional:
- Pydantic constraints: ge, le, gt, lt, multiple_of
- ui_options: min, max, step, inputmode
class NumberExample(FormModel):
quantity: int = Field(
...,
ge=1,
le=100,
ui_element="number",
ui_options={"step": 1, "inputmode": "numeric"},
)
range¶
Required:
- Numeric field type + ui_element="range"
Optional:
- ui_options: min, max, step, value, show_value
class RangeExample(FormModel):
satisfaction: int = Field(
...,
ge=0,
le=10,
ui_element="range",
ui_options={"min": 0, "max": 10, "step": 1, "value": 7, "show_value": True},
)
Selection¶
selectmultiselectcheckboxradiotoggle(aliases:toggle_switch,checkbox_toggle)combobox
select¶
Required:
- ui_element="select"
- ui_options with choices/options list, or schema enum
Optional:
- ui_options: options, choices, size, class, style
class SelectExample(FormModel):
region: str = Field(
...,
ui_element="select",
ui_options={
"options": [
{"value": "na", "label": "North America"},
{"value": "eu", "label": "Europe"},
],
"size": 1,
},
)
multiselect¶
Required:
- ui_element="multiselect"
- ui_options with choices/options list, or schema enum
Optional:
- ui_options: options, choices, size
class MultiSelectExample(FormModel):
tags: list[str] = Field(
...,
ui_element="multiselect",
ui_options={
"options": [
{"value": "backend", "label": "Backend"},
{"value": "frontend", "label": "Frontend"},
{"value": "infra", "label": "Infra"},
],
"size": 4,
},
)
checkbox¶
Required:
- bool field type + ui_element="checkbox"
Optional:
- ui_options: value, checked, label
class CheckboxExample(FormModel):
terms_accepted: bool = Field(
...,
ui_element="checkbox",
ui_help_text="I agree to the terms and privacy policy",
ui_options={"value": "1", "checked": False},
)
radio¶
Required:
- ui_element="radio"
- ui_options with choices/options list, or schema enum
Optional:
- ui_options: options, choices, legend, class, style
class RadioExample(FormModel):
plan: str = Field(
...,
ui_element="radio",
ui_options={
"choices": [
{"value": "free", "label": "Free"},
{"value": "pro", "label": "Pro"},
],
"legend": "Subscription plan",
},
)
toggle (aliases: toggle_switch, checkbox_toggle)¶
Required:
- bool field type + ui_element="toggle"
Optional:
- ui_options: label, checked, class
class ToggleExample(FormModel):
marketing_opt_in: bool = Field(
...,
ui_element="toggle",
ui_options={"label": "Receive product updates", "checked": True},
)
combobox¶
Required:
- ui_element="combobox"
- ui_options with choices/options list, or schema enum
Optional:
- ui_placeholder
- ui_options: options, choices
class ComboBoxExample(FormModel):
city: str = Field(
...,
ui_element="combobox",
ui_placeholder="Select or type a city",
ui_options={
"options": [
{"value": "nyc", "label": "New York"},
{"value": "ldn", "label": "London"},
{"value": "tky", "label": "Tokyo"},
]
},
)
Selection note:
- Provide choices via ui_options={"options": [...]} or ui_options={"choices": [...]}.
- Or use JSON Schema enums (for example Literal[...] or Enum) and options are inferred.
Date/time¶
datetimedatetime(alias:datetime-local)monthweek
date¶
Required:
- date-like field + ui_element="date"
Optional:
- ui_options: min, max, value
from datetime import date
class DateExample(FormModel):
start_date: date = Field(
...,
ui_element="date",
ui_options={"min": date(2026, 1, 1), "max": date(2026, 12, 31)},
)
time¶
Required:
- time-like field + ui_element="time"
Optional:
- ui_options: min, max, step, value
from datetime import time
class TimeExample(FormModel):
start_time: time = Field(
...,
ui_element="time",
ui_options={"min": time(9, 0), "max": time(18, 0), "step": 900},
)
datetime (alias: datetime-local)¶
Required:
- datetime-like field + ui_element="datetime"
Optional:
- ui_options: min, max, value, auto_set_current, with_set_now_button
from datetime import datetime
class DatetimeExample(FormModel):
appointment_at: datetime = Field(
...,
ui_element="datetime",
ui_options={"auto_set_current": True, "with_set_now_button": True},
)
month¶
Required:
- date/datetime/string field + ui_element="month"
Optional:
- ui_options: min, max, value (YYYY-MM)
class MonthExample(FormModel):
billing_month: str = Field(
...,
ui_element="month",
ui_options={"min": "2026-01", "max": "2026-12", "value": "2026-04"},
)
week¶
Required:
- date/datetime/string field + ui_element="week"
Optional:
- ui_options: value (YYYY-W##)
class WeekExample(FormModel):
sprint_week: str = Field(
...,
ui_element="week",
ui_options={"value": "2026-W17"},
)
Specialized¶
filecolorhiddenssn(alias:social_security_number)phone(alias:phone_number)credit_card(aliases:card,cc_number)currency(alias:money)
file¶
Required:
- string field + ui_element="file"
Optional:
- ui_options: accept, multiple, capture, show_preview
class FileExample(FormModel):
attachments: str = Field(
...,
ui_element="file",
ui_options={
"accept": ".pdf,.docx,image/*",
"multiple": True,
"show_preview": True,
},
)
color¶
Required:
- string field + ui_element="color"
Optional:
- default value (for initial color)
- ui_options: show_value
class ColorExample(FormModel):
accent_color: str = Field(
"#0EA5E9",
ui_element="color",
ui_options={"show_value": True},
)
hidden¶
Required:
- field + ui_element="hidden"
Optional:
- default value
class HiddenExample(FormModel):
account_id: str = Field(
"acct_123",
ui_element="hidden",
)
ssn (alias: social_security_number)¶
Required:
- string field + ui_element="ssn"
Optional:
- ui_placeholder override
class SsnExample(FormModel):
ssn: str = Field(
...,
ui_element="ssn",
ui_placeholder="123-45-6789",
)
phone (alias: phone_number)¶
Required:
- string field + ui_element="phone"
Optional:
- ui_placeholder
- ui_options: country_code, autocomplete
class PhoneExample(FormModel):
contact_phone: str = Field(
...,
ui_element="phone",
ui_placeholder="555 123 4567",
ui_options={"country_code": "+1", "autocomplete": "tel"},
)
credit_card (aliases: card, cc_number)¶
Required:
- string field + ui_element="credit_card"
Optional:
- ui_placeholder override
class CreditCardExample(FormModel):
card_number: str = Field(
...,
ui_element="credit_card",
ui_placeholder="1234 5678 9012 3456",
)
currency (alias: money)¶
Required:
- string/decimal-like field + ui_element="currency"
Optional:
- ui_options: currency_symbol
class CurrencyExample(FormModel):
budget: str = Field(
...,
ui_element="currency",
ui_options={"currency_symbol": "$"},
)
These specialized elements are opt-in and will not override normal text fields.
Use them explicitly when you want built-in formatting/pattern behavior.
Complete Example (All Documented UI Elements)¶
Use this model if you want one copy-paste block that exercises every documented ui_element.
from datetime import date, datetime, time
from pydantic_schemaforms import Field, FormModel
class AllUiElementsExample(FormModel):
# Text
text_name: str = Field(
...,
title="Full name",
description="Customer display name",
min_length=2,
max_length=60,
examples=["Jane Doe"],
ui_element="text",
ui_placeholder="Jane Doe",
ui_options={"minlength": 2, "maxlength": 60},
)
text_password: str = Field(
...,
title="Password",
description="Must contain at least 12 characters",
min_length=12,
ui_element="password",
ui_placeholder="Choose a password",
ui_options={"minlength": 12, "autocomplete": "new-password"},
)
text_email: str = Field(
...,
title="Email",
description="Primary account email",
max_length=254,
examples=["jane@example.com"],
ui_element="email",
ui_placeholder="jane@example.com",
ui_options={"maxlength": 254, "autocomplete": "email"},
)
text_search: str = Field(
...,
ui_element="search",
ui_placeholder="Search products",
ui_options={"maxlength": 120},
)
text_area: str = Field(
...,
ui_element="textarea",
ui_placeholder="Write notes",
ui_options={"rows": 4, "cols": 60, "wrap": "soft", "resize": "vertical"},
)
text_url: str = Field(
...,
ui_element="url",
ui_placeholder="https://example.com",
ui_options={"pattern": r"https?://.+"},
)
text_tel: str = Field(
...,
ui_element="tel",
ui_placeholder="+1 555 123 4567",
ui_options={"autocomplete": "tel", "maxlength": 20},
)
# Numbers
number_quantity: int = Field(
...,
title="Quantity",
description="Order quantity",
ge=1,
le=100,
strict=True,
ui_element="number",
ui_options={"step": 1, "inputmode": "numeric"},
)
number_range: int = Field(
...,
title="Satisfaction",
description="Satisfaction score from 0 to 10",
ge=0,
le=10,
multiple_of=1,
ui_element="range",
ui_options={"min": 0, "max": 10, "step": 1, "value": 5, "show_value": True},
)
# Selection
select_region: str = Field(
...,
title="Region",
description="Primary operating region",
examples=["na"],
ui_element="select",
ui_options={
"options": [
{"value": "na", "label": "North America"},
{"value": "eu", "label": "Europe"},
]
},
)
select_tags: list[str] = Field(
default_factory=list,
title="Tags",
description="Select one or more team tags",
ui_element="multiselect",
ui_options={
"choices": [
{"value": "backend", "label": "Backend"},
{"value": "frontend", "label": "Frontend"},
{"value": "infra", "label": "Infra"},
],
"size": 4,
},
)
select_terms: bool = Field(
...,
title="Terms accepted",
description="Must be accepted before submit",
ui_element="checkbox",
ui_help_text="I agree to the terms",
ui_options={"value": "1", "checked": False},
)
select_plan: str = Field(
...,
title="Plan",
description="Choose a subscription plan",
ui_element="radio",
ui_options={
"choices": [
{"value": "free", "label": "Free"},
{"value": "pro", "label": "Pro"},
],
"legend": "Plan",
},
)
select_toggle: bool = Field(
...,
title="Enable alerts",
description="Turn notifications on or off",
ui_element="toggle",
ui_options={"label": "Enable alerts", "checked": True},
)
select_city: str = Field(
...,
title="City",
description="Choose from known cities or type your own",
ui_element="combobox",
ui_placeholder="Select or type a city",
ui_options={
"options": [
{"value": "nyc", "label": "New York"},
{"value": "ldn", "label": "London"},
{"value": "tky", "label": "Tokyo"},
]
},
)
# Date/time
dt_date: date = Field(
...,
title="Start date",
description="Project start date",
ui_element="date",
ui_options={"min": date(2026, 1, 1), "max": date(2026, 12, 31)},
)
dt_time: time = Field(
...,
title="Start time",
description="Daily start time",
ui_element="time",
ui_options={"min": time(9, 0), "max": time(18, 0), "step": 900},
)
dt_datetime: datetime = Field(
...,
title="Appointment",
description="Appointment date and time",
ui_element="datetime",
ui_options={"auto_set_current": True, "with_set_now_button": True},
)
dt_month: str = Field(
...,
title="Billing month",
description="Month in YYYY-MM format",
pattern=r"^\d{4}-\d{2}$",
ui_element="month",
ui_options={"min": "2026-01", "max": "2026-12", "value": "2026-04"},
)
dt_week: str = Field(
...,
title="Sprint week",
description="ISO week in YYYY-W## format",
pattern=r"^\d{4}-W\d{2}$",
ui_element="week",
ui_options={"value": "2026-W17"},
)
# Specialized
sp_file: str = Field(
...,
title="Attachments",
description="Upload one or more files",
ui_element="file",
ui_options={"accept": ".pdf,.docx,image/*", "multiple": True, "show_preview": True},
)
sp_color: str = Field(
"#0EA5E9",
title="Accent color",
description="Primary UI accent color",
pattern=r"^#[0-9A-Fa-f]{6}$",
ui_element="color",
ui_options={"show_value": True},
)
sp_hidden: str = Field(
"acct_123",
exclude=True,
ui_element="hidden",
)
sp_ssn: str = Field(
...,
title="Social security number",
description="US SSN format",
pattern=r"^\d{3}-\d{2}-\d{4}$",
ui_element="ssn",
ui_placeholder="123-45-6789",
)
sp_phone: str = Field(
...,
title="Phone",
description="Primary contact number",
ui_element="phone",
ui_placeholder="555 123 4567",
ui_options={"country_code": "+1", "autocomplete": "tel"},
)
sp_credit_card: str = Field(
...,
title="Card number",
description="Card number with spaces",
min_length=13,
max_length=19,
ui_element="credit_card",
ui_placeholder="1234 5678 9012 3456",
)
sp_currency: str = Field(
...,
title="Budget",
description="Amount in selected currency",
ui_element="currency",
ui_options={"currency_symbol": "$"},
)
Alias note:
- datetime-local is an alias for datetime
- toggle_switch and checkbox_toggle are aliases for toggle
- social_security_number is an alias for ssn
- phone_number is an alias for phone
- card and cc_number are aliases for credit_card
- money is an alias for currency
Pseudo elements¶
These are handled specially by the renderer (not standard inputs):
layout: layout-only schema fields (see docs/layouts.md)model_list: repeatable nested model items
Unknown elements¶
If you set ui_element to an unsupported value, the renderer falls back to a basic text input.
If you need a custom widget:
- Implement a
BaseInputsubclass - Register it at runtime via
pydantic_schemaforms.inputs.registry.register_input_class()
(Then you can use your custom ui_element key in schemas.)
Layouts¶
There are two layers of layout support:
1) Top-level layout modes (layout= when you render the form)
2) Composable layout primitives (horizontal/grid/tabs/etc) for advanced composition
1) Top-level layout modes¶
Pass layout= to render_form_html() / FormModel.render_form().
Supported values:
vertical(default)tabbed: groups fields into tabs automaticallyside-by-side: renders fields in two-column rows
from pydantic_schemaforms import render_form_html
html = render_form_html(MyFormModel, layout="side-by-side", submit_url="/submit")
2) Layout primitives (advanced)¶
The module pydantic_schemaforms.rendering.layout_engine contains reusable wrappers:
HorizontalLayoutVerticalLayoutGridLayoutResponsiveGridLayoutTabLayoutAccordionLayoutCardLayoutModalLayout
A convenience factory is provided:
LayoutComposer(aliases:Layout,LayoutFactory)
Example:
from pydantic_schemaforms.rendering.layout_engine import Layout
layout = Layout.create_grid(
"<div>Left</div>",
"<div>Right</div>",
columns="1fr 2fr",
gap="1rem",
)
html = layout.render(framework="bootstrap", renderer=my_renderer, data={}, errors={})
LayoutComposer (aliased as Layout) also exposes create_horizontal(), create_vertical(),
responsive_grid(), create_tabs(), and create_accordion().
Schema-defined layout fields¶
A schema field with ui_element="layout" is treated as a layout field.
At render time, the renderer will:
- Call a custom layout renderer if you configured one (
layout_handler/layout_renderer) - Or, if the field value is a
BaseLayoutinstance, call its.render()
This is intentionally an advanced feature (useful for complex nested forms and custom layout engines).
Registering custom layout renderers¶
You can register a named layout renderer:
from pydantic_schemaforms.rendering.layout_engine import LayoutEngine
def my_layout_renderer(field_name, field_schema, value, ui_info, context, engine):
return "<div>Custom layout output</div>"
LayoutEngine.register_layout_renderer("my_layout", my_layout_renderer)
Then set ui_options (or schema ui) to reference it:
layout_handler="my_layout"orlayout_renderer="my_layout"
Notes¶
tabbedgrouping is heuristic-based (field-name keywords). If you need deterministic tabbing, use explicit layout fields or custom renderers.- Layout fields can include nested form markup via
EnhancedFormRenderer.render_form_fields_only().
Assets & asset_mode¶
pydantic-schemaforms is offline-by-default: by default, rendered HTML ships all required JS/CSS from this library (vendored assets are embedded/packaged).
This page documents the standard knobs used across entry points to control asset injection.
Terminology¶
- Vendored assets: Third-party JS/CSS copied into this repo under
pydantic_schemaforms/assets/vendor/**. - Pinned: Versions are recorded in
pydantic_schemaforms/assets/vendor/vendor_manifest.jsonalong withsha256checksums and source URLs. asset_mode: How a renderer should include assets.
asset_mode values¶
Most APIs accept asset_mode with these values:
"vendored"(default)- No external network required.
-
Assets are inlined (e.g.,
<script>β¦</script>/<style>β¦</style>) from the packaged vendor files. -
"cdn"(explicit opt-in) - Emits
<script src="β¦">/<link href="β¦">tags pointing at a CDN. -
URLs are pinned to the versions in the vendored manifest.
-
"none" - Emits no assets.
- Useful when your host app provides its own asset pipeline.
Entry points¶
render_form_html() β the standard entry point¶
Import from the top-level package:
from pydantic_schemaforms import render_form_html
html = render_form_html(
MyForm,
submit_url="/submit",
framework="bootstrap",
asset_mode="vendored",
include_framework_assets=True, # inline Bootstrap CSS/JS for self-contained HTML
)
HTMX and IMask are not injected by render_form_html(). If you need HTMX/IMask injection, use the legacy render_form.py wrapper directly:
from pydantic_schemaforms.render_form import render_form_html as render_form_html_legacy
html = render_form_html_legacy(
MyForm,
submit_url="/submit",
include_htmx_script=True, # injects HTMX
include_imask=True, # injects IMask for masked inputs
)
Otherwise, add HTMX and IMask scripts to your host page manually.
If you already provide Bootstrap/Materialize in your host app, keep include_framework_assets=False.
Use self_contained=True as a shorthand for include_framework_assets=True, asset_mode=βvendoredβ β for Bootstrap this also embeds Bootstrap Icons as a data URI (truly zero external dependencies).
EnhancedFormRenderer β direct renderer instance¶
Use this when you want to render multiple forms with the same configuration, or when you need finer control over the rendering pipeline.
from pydantic_schemaforms.enhanced_renderer import EnhancedFormRenderer
renderer = EnhancedFormRenderer(
framework="bootstrap",
include_framework_assets=True,
asset_mode="vendored",
)
html = renderer.render_form_from_model(MyForm, submit_url="/submit")
Modern/builder path: FormBuilder + render_form_page()¶
File: pydantic_schemaforms/integration/builder.py
FormBuilder(..., include_framework_assets=..., asset_mode=...)controls how the builderβs form HTML is rendered.render_form_page(..., include_framework_assets=..., asset_mode=...)controls the full-page wrapperβs CSS/JS emission.
Example:
from pydantic_schemaforms.integration.builder import FormBuilder, render_form_page
builder = FormBuilder(
framework="bootstrap",
include_framework_assets=True,
asset_mode="vendored",
).text_input("ssn", "SSN")
page = render_form_page(
builder,
submit_url="/submit",
title="Signup",
include_framework_assets=True,
asset_mode="vendored",
)
Whatβs currently vendored¶
| Asset | Files |
|---|---|
| HTMX | htmx.min.js |
| IMask | imask.min.js |
| Bootstrap | bootstrap.min.css, bootstrap.bundle.min.js |
| Bootstrap Icons | bootstrap-icons.min.css, bootstrap-icons.woff2 |
| Materialize | materialize.min.css, materialize.min.js |
See pydantic_schemaforms/assets/vendor/vendor_manifest.json for exact versions, SHA256 checksums, and source URLs.
Bootstrap Icons¶
Bootstrap Icons are included as part of the Bootstrap themeβs asset delivery. The library
emits <i class="bi bi-*"> elements for icons (password-toggle, field icons, etc.), so the
icon font must be present for those elements to render correctly.
When include_framework_assets=True (or self_contained=True):
The CSS is inlined as a <style> block with the bootstrap-icons.woff2 font embedded as a
data:font/woff2;base64,β¦ URI. No network request is made β the icon font is fully
self-contained in the HTML.
When include_framework_assets=False (default):
Bootstrap Icons are not injected by the library. Your host page is responsible for providing them. Options:
- Use the libraryβs vendor endpoint (recommended for apps built on this library):
# In your FastAPI/Flask app
from pydantic_schemaforms.assets.runtime import bootstrap_icons_css_content
@app.get("/vendor/bootstrap-icons.css")
async def bootstrap_icons():
return Response(content=bootstrap_icons_css_content(), media_type="text/css")
Then in your base template:
<link rel="stylesheet" href="/vendor/bootstrap-icons.css" />
-
CDN (when
asset_mode="cdn"andinclude_framework_assets=True): the library emits a pinned<link>to jsDelivr. -
Your own asset pipeline: import
bootstrap-iconsfrom npm and bundle it yourself.
Runtime API¶
from pydantic_schemaforms.assets.runtime import (
bootstrap_icons_css_tag, # returns <style>β¦</style> / <link β¦/> / ""
bootstrap_icons_css_content, # returns raw CSS string with woff2 embedded
)
# Inline vendored CSS (default)
tag = bootstrap_icons_css_tag(asset_mode="vendored")
# Pinned CDN link
tag = bootstrap_icons_css_tag(asset_mode="cdn")
# Nothing (you manage the asset yourself)
tag = bootstrap_icons_css_tag(asset_mode="none")
# Raw CSS for serving via your own HTTP endpoint
css = bootstrap_icons_css_content()
Updating vendored assets¶
Vendored updates are scripted and checksum-verified.
- Verify vendored checksums:
-
make vendor-verify -
Update assets:
make vendor-update-htmx HTMX_VERSION=β¦make vendor-update-imask IMASK_VERSION=β¦(or omit to use npm latest)make vendor-update-bootstrap BOOTSTRAP_VERSION=β¦make vendor-update-bootstrap-icons BOOTSTRAP_ICONS_VERSION=β¦make vendor-update-materialize MATERIALIZE_VERSION=β¦
After updating, run make vendor-verify and the test suite.
Security note¶
asset_mode="cdn" is intentionally available, but it re-introduces an external dependency at runtime. For production systems with strict supply-chain or offline requirements, prefer asset_mode="vendored".
Render Timing¶
Use render timing options to inspect form performance during development.
Timing behavior¶
The renderer measures render duration internally and can expose it in two ways:
- Inline timing text in form output (
show_timing=True) - Debug panel timing metadata (
debug=True)
Inline timing (show_timing=True)¶
from pydantic_schemaforms import render_form_html
html = render_form_html(
MyForm,
submit_url="/submit",
show_timing=True,
)
This adds a small timing indicator in the rendered form markup.
Debug panel (debug=True)¶
html = render_form_html(
MyForm,
submit_url="/submit",
debug=True,
)
This appends a richer debug panel that includes timing plus schema/render metadata.
Combined usage¶
html = render_form_html(
MyForm,
submit_url="/submit",
show_timing=True,
debug=True,
)
Use this combination for local diagnosis of slow forms.
Async usage¶
The same options are available in async mode:
from pydantic_schemaforms import render_form_html_async
html = await render_form_html_async(
MyForm,
submit_url="/submit",
show_timing=True,
)
With logging¶
If you also want log output, enable it explicitly:
html = render_form_html(
MyForm,
submit_url="/submit",
show_timing=True,
enable_logging=True,
)
enable_logging=True controls debug log emission and is independent of timing display in HTML.
Tips¶
- Keep
show_timing=Falseanddebug=Falsefor production-facing pages. - Use representative data when benchmarking complex forms (nested models, model lists, layout-heavy schemas).
Logging¶
pydantic-schemaforms uses Python's standard logging module and keeps detailed renderer logs opt-in.
What is logged by default¶
Default behavior for rendering calls:
- No per-render debug/timing log lines are emitted.
- Form rendering still works normally with no logging setup.
This keeps production output quiet unless you opt in.
Enabling renderer logs¶
Use enable_logging=True on render calls:
from pydantic_schemaforms import render_form_html
html = render_form_html(
MyForm,
submit_url="/submit",
enable_logging=True,
)
When enabled, the renderer emits debug lines such as render duration and model name.
Logging level configuration¶
Renderer logs are emitted at DEBUG level. Configure logging accordingly:
import logging
logging.basicConfig(level=logging.DEBUG)
If your app is set to INFO/WARNING, DEBUG lines will remain hidden.
Per-request debugging pattern¶
For web apps, conditionally enable logs during troubleshooting:
from fastapi import FastAPI, Query
from fastapi.responses import HTMLResponse
from pydantic_schemaforms import render_form_html
app = FastAPI()
@app.get("/demo", response_class=HTMLResponse)
def demo(debug_logs: bool = Query(False)):
form_html = render_form_html(
MyForm,
submit_url="/demo",
enable_logging=debug_logs,
)
return f"<html><body>{form_html}</body></html>"
Related options¶
show_timing=True: displays render timing in the returned HTML.debug=True: appends the renderer debug panel to returned HTML.enable_logging=True: emits renderer debug log lines.
These options are independent and can be combined.
Security guidance¶
- Avoid logging raw secrets (CSRF tokens, passwords, session IDs).
- Prefer logging outcomes and context (
route,request_id,user_id) over sensitive payload values.
Unified Validation Engine Guide¶
Complete guide to validation in pydantic-schemaforms: server-side, real-time HTMX, and cross-field patterns.
Overview¶
The pydantic-schemaforms validation system is consolidated into a single, unified engine that works seamlessly across:
- Server-side validation via validate_form_data() and FormValidator
- Real-time HTMX validation via LiveValidator and field-level validators
- Cross-field validation via form-level rules
- Convenience validators for common patterns (email, password strength)
All validation rules live in pydantic_schemaforms/validation.py, re-exported from pydantic_schemaforms/live_validation.py for convenience, eliminating code duplication and ensuring consistency across all validation flows.
Core Concepts¶
ValidationResponse¶
The canonical response object for all validation operations (server-side or HTMX):
from pydantic_schemaforms import ValidationResponse
response = ValidationResponse(
field_name="email",
is_valid=True,
errors=[], # List of error messages
warnings=[], # List of warnings (non-blocking)
suggestions=["Example: user@example.com"], # Helpful hints
value="user@example.com", # The validated value
formatted_value="user@example.com" # Optionally formatted (e.g., lowercase)
)
# Serialize for HTMX responses
json_str = response.to_json()
dict_response = response.to_dict()
ValidationSchema & FieldValidator¶
Build reusable validation schemas from individual field validators:
from pydantic_schemaforms.validation import ValidationSchema, FieldValidator
# Create a schema with multiple fields
schema = ValidationSchema()
# Add field validators
email_validator = FieldValidator("email")
email_validator.add_rule(EmailRule())
schema.add_field(email_validator)
password_validator = FieldValidator("password")
password_validator.add_rule(
LengthRule(min=8, message="Minimum 8 characters required")
)
schema.add_field(password_validator)
# Build HTMX live validator from schema
live_validator = schema.build_live_validator()
FormValidator¶
Validate entire forms with both field-level and cross-field rules:
from pydantic_schemaforms.validation import FormValidator
form_validator = FormValidator()
# Add field validators
form_validator.field("age").add_rule(NumericRangeRule(min=0, max=150))
form_validator.field("email").add_rule(EmailRule())
# Add cross-field validation
def validate_age_and_consent(data):
age = data.get("age")
consent = data.get("parental_consent")
if age is not None and age < 18 and not consent:
return False, {
"parental_consent": ["Parental consent required for users under 18"]
}
return True, {}
form_validator.add_cross_field_rule(validate_age_and_consent)
# Validate form data
is_valid, errors = form_validator.validate({
"age": 16,
"email": "teen@example.com",
"parental_consent": False
})
Server-Side Validation¶
Using FormModel.validate()¶
The recommended way to validate submitted data is FormModel.validate(). Pass submit_url
(and optionally framework plus any other render kwargs) once; on failure, call
render_with_errors() with no arguments β the result re-renders itself:
from pydantic_schemaforms import FormModel, FormField
class RegistrationForm(FormModel):
username: str = FormField(
title="Username",
min_length=3,
max_length=20
)
email: str = FormField(
title="Email Address",
input_type="email"
)
password: str = FormField(
title="Password",
input_type="password",
min_length=8
)
# Validate β stores submit_url so render_with_errors() needs no args
result = RegistrationForm.validate(
{"username": "alice", "email": "alice@example.com", "password": "SecurePass123!"},
submit_url="/register",
)
if result.is_valid:
print(f"Valid! Data: {result.data}")
else:
print(f"Invalid! Errors: {result.errors}")
html = result.render_with_errors() # sync (Flask / scripts)
# html = await result.render_with_errors_async() # async (FastAPI)
result always exposes .is_valid, .data, and .errors regardless of the path.
Using validate_form_data() directly¶
validate_form_data() is the lower-level function used by FormModel.validate(). It is
still available for cases where you only need the validation result and do not intend to
re-render the form from the result object:
from pydantic_schemaforms import validate_form_data
result = validate_form_data(RegistrationForm, submitted_data)
if result.is_valid:
process(result.data)
Using FormValidator with Pydantic Models¶
For validation with additional custom rules:
from pydantic_schemaforms.validation import FormValidator
form_validator = FormValidator()
form_validator.field("username").add_rule(LengthRule(min=3, max=20))
form_validator.field("email").add_rule(EmailRule())
form_validator.field("password").add_rule(LengthRule(min=8))
# Validate and get results
is_valid, errors = form_validator.validate({
"username": "alice",
"email": "alice@example.com",
"password": "SecurePass123!"
})
# Also validate against Pydantic model
is_valid, errors = form_validator.validate_pydantic_model(
RegistrationForm,
request_data
)
Real-Time HTMX Validation¶
LiveValidator Setup¶
Use LiveValidator for server-side validation triggered via HTMX on blur/input/change events:
from pydantic_schemaforms import LiveValidator, HTMXValidationConfig, FieldValidator, EmailRule, MinLengthRule
# Configure HTMX behavior β any combination of triggers may be enabled.
# When multiple are True, the generated hx-trigger combines them:
# validate_on_blur=True + validate_on_change=True β hx-trigger="blur, change"
config = HTMXValidationConfig(
validate_on_blur=True, # Validate when field loses focus
validate_on_input=False, # Validate on every keystroke (with debounce)
validate_on_change=True, # Validate on value change (select, checkbox)
debounce_ms=300, # Debounce delay for validate_on_input
show_success_indicators=True, # Apply success_class on valid input
success_class="is-valid", # Bootstrap / custom CSS classes
error_class="is-invalid",
warning_class="has-warning",
loading_class="is-validating",
)
live_validator = LiveValidator(config)
# Register field validators
email_fv = FieldValidator("email")
email_fv.add_rule(EmailRule())
live_validator.register_field_validator(email_fv)
password_fv = FieldValidator("password")
password_fv.add_rule(MinLengthRule(8))
live_validator.register_field_validator(password_fv)
HTML Integration with HTMX¶
Use hx-swap="innerHTML" so HTMX replaces only the content of the feedback container,
keeping the element's id stable for future swaps:
<!-- Form field with HTMX validation -->
<input
type="email"
name="email"
id="email"
class="form-control"
placeholder="you@example.com"
hx-post="/validate/email"
hx-trigger="blur, change"
hx-target="#email-feedback"
hx-swap="innerHTML"
data-validate-endpoint="true"
/>
<!-- Validation feedback container -->
<div id="email-feedback"></div>
data-validate-endpoint="true"β the script emitted byrender_htmx_script()uses this attribute to attach loading-indicator and focus-clear behaviour automatically.
FastAPI Endpoint for HTMX Validation¶
A single parameterised endpoint handles all fields. validate_field() returns a
ValidationResponse; validation_response_headers() emits the HX-Trigger header that
the bundled JS uses to apply is-valid / is-invalid CSS classes to the input.
from fastapi import Request
from fastapi.responses import HTMLResponse
from pydantic_schemaforms import LiveValidator, HTMXValidationConfig, FieldValidator, EmailRule, MinLengthRule
from pydantic_schemaforms.live_validation import validation_response_headers
live_validator = LiveValidator(HTMXValidationConfig(validate_on_blur=True))
email_fv = FieldValidator("email")
email_fv.add_rule(EmailRule())
live_validator.register_field_validator(email_fv)
@app.post("/validate/{field_name}", response_class=HTMLResponse)
async def htmx_validate(field_name: str, request: Request):
data = await request.form()
value = str(data.get(field_name, ""))
result = live_validator.validate_field(field_name, value)
if result.is_valid:
feedback = '<span class="valid-feedback">β Looks good!</span>'
else:
feedback = f'<span class="invalid-feedback">{"; ".join(result.errors)}</span>'
headers = validation_response_headers(field_name, result.is_valid)
return HTMLResponse(feedback, headers=headers)
Include the init script in your template so the validationResult event updates CSS classes:
# Pass to template context
validator_script = live_validator.render_htmx_script()
<!-- In your base template, after HTMX is loaded -->
<script src="/vendor/htmx.min.js"></script>
{{ validator_script | safe }}
Building LiveValidator from ValidationSchema¶
from pydantic_schemaforms import FieldValidator, EmailRule
from pydantic_schemaforms.validation import ValidationSchema
schema = ValidationSchema()
email_fv = FieldValidator("email")
email_fv.add_rule(EmailRule())
schema.add_field(email_fv)
# Converts the schema to a ready-to-use LiveValidator
live_validator = schema.build_live_validator()
Validating Pydantic Model Fields¶
register_model_validator() registers a validator for every field in a Pydantic model.
Each field is validated in isolation using validate_assignment, so valid fields are never
marked invalid just because other required fields are absent:
from pydantic import BaseModel
from pydantic_schemaforms import LiveValidator
class ProfileModel(BaseModel):
name: str
age: int
live_validator = LiveValidator()
live_validator.register_model_validator(ProfileModel)
# Only the age field's own constraints are checked β name being absent is irrelevant
result = live_validator.validate_field("age", "not-a-number")
# result.is_valid == False, result.errors == ["Input should be a valid integer..."]
result = live_validator.validate_field("age", 25)
# result.is_valid == True
Cross-Field Validation¶
Form-Level Rules¶
Validate fields that depend on other fields:
from pydantic_schemaforms.validation import FormValidator
form_validator = FormValidator()
# Individual field rules
form_validator.field("age").add_rule(NumericRangeRule(min=0, max=150))
form_validator.field("parental_consent").add_rule(RequiredRule())
# Cross-field validation
def validate_minor_consent(data):
"""Minors must have parental consent."""
age = data.get("age")
consent = data.get("parental_consent")
if age is not None and age < 18 and not consent:
return False, {
"parental_consent": [
"Parental consent is required for users under 18 years old"
]
}
return True, {}
form_validator.add_cross_field_rule(validate_minor_consent)
# Validate returns both field and cross-field errors
is_valid, errors = form_validator.validate({
"age": 16,
"parental_consent": False
})
# errors = {"parental_consent": ["Parental consent is required..."]}
Conditional Field Validation¶
Validate a field only if another field has a certain value:
def validate_emergency_contact(data):
"""Emergency contact required if no direct phone provided."""
has_phone = bool(data.get("phone"))
has_emergency_contact = bool(data.get("emergency_contact"))
if not has_phone and not has_emergency_contact:
return False, {
"emergency_contact": [
"Either a phone number or emergency contact is required"
]
}
return True, {}
form_validator.add_cross_field_rule(validate_emergency_contact)
Password Matching Validation¶
def validate_passwords_match(data):
"""Ensure password and confirm_password match."""
password = data.get("password", "")
confirm = data.get("confirm_password", "")
if password and confirm and password != confirm:
return False, {
"confirm_password": ["Passwords do not match"]
}
return True, {}
form_validator.add_cross_field_rule(validate_passwords_match)
Convenience Validators¶
Email Validator¶
from pydantic_schemaforms.validation import create_email_validator
email_validator = create_email_validator()
response = email_validator("user@example.com")
# ValidationResponse(field_name="email", is_valid=True, ...)
response = email_validator("invalid-email")
# ValidationResponse(
# field_name="email",
# is_valid=False,
# errors=["Please enter a valid email address"],
# suggestions=["Example: user@example.com"],
# value="invalid-email"
# )
Password Strength Validator¶
from pydantic_schemaforms.validation import create_password_strength_validator
password_validator = create_password_strength_validator(min_length=8)
response = password_validator("WeakPass")
# ValidationResponse(
# field_name="password",
# is_valid=False,
# errors=["Password must be at least 8 characters long"],
# warnings=[
# "Password should contain at least one uppercase letter",
# "Password should contain at least one number"
# ],
# suggestions=[
# "Add an uppercase letter (A-Z)",
# "Add a number (0-9)"
# ],
# value="WeakPass"
# )
response = password_validator("SecurePass123!")
# ValidationResponse(field_name="password", is_valid=True, ...)
Common Validation Rules¶
Built-in Rules¶
The validation system includes pre-built rules for common patterns:
| Rule | Purpose | Example |
|---|---|---|
RequiredRule() |
Field must have a value | Required name field |
MinLengthRule(n) |
Minimum string length | Username β₯ 3 chars |
MaxLengthRule(n) |
Maximum string length | Username β€ 20 chars |
EmailRule() |
Valid email format | Email field |
PhoneRule() |
Valid phone number | Phone field |
NumericRangeRule(min, max) |
Numeric value range | Age 0β150 |
DateRangeRule(min_date, max_date) |
Date within range | Future date only |
RegexRule(pattern) |
Custom regex pattern | Custom format validation |
CustomRule(func) |
Custom validation function | Complex logic |
Example: Complete Field Validation¶
from pydantic_schemaforms import FieldValidator, EmailRule, MinLengthRule, MaxLengthRule
from pydantic_schemaforms.validation import RequiredRule, NumericRangeRule, FormValidator
# Email field validator
email_validator = FieldValidator("email")
email_validator.add_rule(RequiredRule("Email is required"))
email_validator.add_rule(EmailRule())
# Username field validator (fluent API: .min_length() / .max_length())
username_validator = FieldValidator("username")
username_validator.add_rule(RequiredRule("Username is required"))
username_validator.add_rule(MinLengthRule(3, message="Minimum 3 characters"))
username_validator.add_rule(MaxLengthRule(20, message="Maximum 20 characters"))
# Age field validator
age_validator = FieldValidator("age")
age_validator.add_rule(NumericRangeRule(min=13, max=150, message="Must be 13+"))
# Use in form validator
form_validator = FormValidator()
form_validator.field("email").add_rule(EmailRule())
form_validator.field("username").add_rule(MinLengthRule(3)).add_rule(MaxLengthRule(20))
form_validator.field("age").add_rule(NumericRangeRule(min=13, max=150))
Sync + HTMX Validation Flow¶
End-to-End Example¶
Here's a complete registration form with both server validation and real-time HTMX feedback:
1. Define Form Model¶
from pydantic_schemaforms import FormModel, FormField
class RegistrationForm(FormModel):
username: str = FormField(
title="Username",
input_type="text",
min_length=3,
max_length=20,
help_text="3β20 alphanumeric characters"
)
email: str = FormField(
title="Email Address",
input_type="email",
help_text="We'll send a confirmation link"
)
password: str = FormField(
title="Password",
input_type="password",
min_length=8,
help_text="Must be at least 8 characters"
)
confirm_password: str = FormField(
title="Confirm Password",
input_type="password",
help_text="Re-enter your password"
)
age: int = FormField(
title="Age",
input_type="number",
ge=13,
le=150,
help_text="Must be 13 or older"
)
2. Set Up Validation¶
from pydantic_schemaforms import FieldValidator, EmailRule, MinLengthRule, MaxLengthRule, LiveValidator
from pydantic_schemaforms.validation import FormValidator, NumericRangeRule
# Create form validator with all rules
form_validator = FormValidator()
# Field validators
form_validator.field("username").add_rule(MinLengthRule(3, message="3β20 characters")).add_rule(MaxLengthRule(20))
form_validator.field("email").add_rule(EmailRule())
form_validator.field("password").add_rule(MinLengthRule(8, message="Minimum 8 characters"))
form_validator.field("age").add_rule(NumericRangeRule(min=13, max=150, message="Must be 13+"))
# Cross-field rules
def validate_passwords_match(data):
if data.get("password") != data.get("confirm_password"):
return False, {"confirm_password": ["Passwords do not match"]}
return True, {}
form_validator.add_cross_field_rule(validate_passwords_match)
# Live validator for HTMX
live_validator = form_validator.build_live_validator()
3. FastAPI Endpoints¶
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse
from pydantic_schemaforms import render_form_html_async
app = FastAPI()
@app.get("/register", response_class=HTMLResponse)
async def show_registration():
return await render_form_html_async(
RegistrationForm, framework="bootstrap", submit_url="/register"
)
@app.post("/register", response_class=HTMLResponse)
async def handle_registration(request: Request):
form_data = await request.form()
result = RegistrationForm.validate(
dict(form_data), submit_url="/register", framework="bootstrap"
)
if result.is_valid:
return JSONResponse({
"success": True,
"message": "Registration successful!"
})
else:
return await result.render_with_errors_async()
# HTMX validation β one parameterised endpoint handles every field
@app.post("/validate/{field_name}", response_class=HTMLResponse)
async def htmx_validate(field_name: str, request: Request):
from pydantic_schemaforms.live_validation import validation_response_headers
data = await request.form()
value = str(data.get(field_name, ""))
result = live_validator.validate_field(field_name, value)
if result.is_valid:
feedback = '<span class="valid-feedback">β Looks good!</span>'
else:
feedback = f'<span class="invalid-feedback">{"; ".join(result.errors)}</span>'
headers = validation_response_headers(field_name, result.is_valid)
return HTMLResponse(feedback, headers=headers)
4. HTML Template¶
<form hx-post="/register" hx-target="#form-result">
<!-- Username field with HTMX validation -->
<div class="form-group">
<label for="username">Username</label>
<input
type="text"
id="username"
name="username"
class="form-control"
placeholder="3β20 characters"
hx-post="/validate/username"
hx-trigger="blur, change delay:300ms"
hx-target="#username-feedback"
hx-swap="innerHTML"
/>
<div id="username-feedback"></div>
</div>
<!-- Email field with HTMX validation -->
<div class="form-group">
<label for="email">Email Address</label>
<input
type="email"
id="email"
name="email"
class="form-control"
placeholder="you@example.com"
hx-post="/validate/email"
hx-trigger="blur, change delay:300ms"
hx-target="#email-feedback"
hx-swap="innerHTML"
/>
<div id="email-feedback"></div>
</div>
<!-- Other fields... -->
<button type="submit" class="btn btn-primary">Register</button>
<div id="form-result"></div>
</form>
Testing Your Validators¶
The test suite includes comprehensive coverage. Use these patterns in your tests:
import pytest
from pydantic_schemaforms.validation import (
FormValidator,
FieldValidator,
EmailRule,
ValidationResponse
)
def test_email_validation():
email_validator = FieldValidator("email")
email_validator.add_rule(EmailRule())
# Valid email
response = email_validator.validate("user@example.com")
assert response.is_valid
assert response.errors == []
# Invalid email
response = email_validator.validate("not-an-email")
assert not response.is_valid
assert len(response.errors) > 0
def test_cross_field_validation():
form_validator = FormValidator()
def validate_passwords(data):
if data.get("password") != data.get("confirm"):
return False, {"confirm": ["Passwords don't match"]}
return True, {}
form_validator.add_cross_field_rule(validate_passwords)
is_valid, errors = form_validator.validate({
"password": "secret",
"confirm": "different"
})
assert not is_valid
assert "confirm" in errors
Layout Demo & Tab Rendering Verification¶
The layout smoke tests in tests/test_layouts.py verify that initial tab content renders correctly for both Bootstrap and Material frameworks:
def test_layout_demo_bootstrap_initial_tab_renders():
"""Verify Bootstrap tabs show initial tab content."""
response = client.get("/layouts")
assert response.status_code == 200
assert "Tab 1 Content" in response.text
# Assert tab buttons exist
assert 'class="nav-link active"' in response.text
def test_layout_demo_material_initial_tab_renders():
"""Verify Material tabs show initial tab content."""
response = client.get("/layouts?style=material")
assert response.status_code == 200
# Assert initial content and Material tab classes
assert "Initial Tab Content" in response.text
assert 'data-toggle="tab"' in response.text
This coverage ensures that tab layouts work correctly across frameworks.
Pydantic v2 Deprecation Resolution¶
As of this release, all Pydantic v2 deprecation warnings have been resolved:
β
Resolved Deprecations:
- min_items/max_items β min_length/max_length in all FormField calls
- Extra kwargs on Field() β properly use json_schema_extra
- Starlette TemplateResponse signature updated to new parameter order
Result: Deprecation warnings reduced from 23 β 8 (removed 15 Pydantic deprecations). The remaining 8 warnings are intentional migration guides (form_layouts deprecation notice) and informational (JSON schema hints).
Run validation tests:
python -m pytest tests/test_validation.py -v
python -m pytest tests/test_layouts.py -v
Summary¶
The unified validation engine provides:
- Canonical ValidationResponse for all validation flows
- Single code path via
validation.pywith re-exports fromlive_validation.py - Flexible rule composition via
FieldValidatorandFormValidator - HTMX integration via
LiveValidatorwith configurable behavior - Cross-field validation for dependent fields and complex rules
- Convenience validators for common patterns (email, password strength)
- Full async support for FastAPI and async frameworks
- Pydantic v2 compatibility with zero deprecation warnings in critical paths
For questions or examples, see:
- tests/test_validation.py β All validation tests (144 tests)
- tests/test_layouts.py β Layout/tab rendering verification including async
- examples/fastapi_example.py β Real-world FastAPI integration
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 can now dispatch layout fields to custom renderers before falling back to built-in demos. Provide a callable and reference it from a field via layout_handler (or layout_renderer) in json_schema_extra / FormField kwargs.
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:
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 state. - Handlers receive the active
LayoutEngineinstance and the original renderer viaengine._rendererif you need to reuse field rendering helpers.
Packaging Tips¶
- For libraries: register your inputs/layouts in your package
__init__or an explicitsetup()function that users call during startup. - For app code: register once at process start (e.g., FastAPI lifespan, Django AppConfig.ready). Avoid per-request registration.
- Keep renderers pure and side-effect free; they should return HTML strings and not mutate shared state.
AI Assistant Instructions¶
pydantic-schemaforms ships packaged, verified integration instructions for AI coding
assistants (Claude, GitHub Copilot, and a generic profile), so an assistant working in an
app repo that depends on this library can produce correct, idiomatic integrations without
reverse-engineering package internals or guessing at deprecated patterns.
Two things make this different from a normal docs page copied into a prompt:
- It ships with the library. Every install of
pydantic-schemaformscarries the current instructions as packaged data β they can't drift out of sync the way a copy-pasted snippet in someone'sCLAUDE.mdcan. - It's discoverable without being asked. The top-level package docstring and
FormModel's class docstring both point an AI assistant at this feature, so it can find it on its own the first time it inspects the library β seepydantic_schemaforms/__init__.py.
What's covered¶
Each profile documents the same underlying integration contract:
- The
FormModel+Field()+render_form_html()pattern, and the lower-boilerplateFormModel.validate()/render_with_errors()alternative. - CSRF protection (
csrf_mode,csrf_token_provider, verification before validation). - Repeating sub-forms (
model_list): resolving the item model from alist[ItemModel]annotation, and theui_add_button_label/ui_item_title_template/ui_collapsible_items/ui_items_expandedcustomization knobs. - Dual-use JSON + HTML models via
as_api_model(). - The distinction between Field constraints (
min_length,max_length,pattern,ge/leβ enforced server-side and reflected in HTML automatically) andui_options(UI-only knobs with no constraint equivalent) β getting this wrong produces a form that looks validated but isn't. - Deterministic field-mapping rules (Python type β
ui_element) so repeated runs converge on the same output instead of drifting between requests.
HTMX live validation (LiveValidator) is intentionally not yet part of the packaged
instructions β see Validation Guide for that.
Python API¶
from pydantic_schemaforms import (
available_instruction_profiles,
get_app_instructions,
suggested_instruction_filename,
)
available_instruction_profiles()
# ('claude', 'copilot', 'generic')
suggested_instruction_filename('claude')
# 'CLAUDE.md'
suggested_instruction_filename('copilot')
# '.github/copilot-instructions.md'
suggested_instruction_filename('generic')
# 'AI_INSTRUCTIONS.md'
get_app_instructions('claude')
# full instructions text for the Claude profile
Aliases are accepted where they're unambiguous β get_app_instructions('github-copilot')
and get_app_instructions('anthropic-claude') both resolve to the same profile as
'copilot' / 'claude'.
An unsupported profile raises ValueError naming the supported profiles, rather than
silently falling back to one.
CLI¶
Bootstrap an app repo's instruction file in one command instead of writing a throwaway script:
python -m pydantic_schemaforms.ai_instructions claude --write
# writes ./CLAUDE.md
python -m pydantic_schemaforms.ai_instructions copilot --write
# writes ./.github/copilot-instructions.md (parent dirs created as needed)
python -m pydantic_schemaforms.ai_instructions generic > AI_INSTRUCTIONS.md
# or redirect stdout yourself
Full CLI usage:
usage: python -m pydantic_schemaforms.ai_instructions [-h] [--write]
[--output PATH]
[profile]
positional arguments:
profile Instruction profile (aliases accepted). One of: claude,
copilot, generic.
options:
-h, --help show this help message and exit
--write Write to the suggested destination file (e.g. CLAUDE.md)
instead of stdout.
--output PATH Write to an explicit path instead of stdout or the suggested
destination.
Try it in the example app¶
The FastAPI example app renders all three profiles as tabs at /ai-instructions (linked
from the "AI Assistant Instructions" card on the home page) β the same content
get_app_instructions() returns, rendered from the packaged markdown files.
Keeping this feature honest¶
The packaged instructions are cross-checked against the library's actual behavior, not
just written once and left to drift β every claim in them (which ui_* kwargs exist,
what model_list options do, how CSRF verification works) has been verified by running
the corresponding code, not just read from source. If you add a Field kwarg or change
model_list behavior, update pydantic_schemaforms/assets/ai/*.md in the same change.
Development
Testing & Contribution Workflow¶
This document describes the authoritative workflow for running tests, linting, and contributing to pydantic-schemaforms.
Single Entry Point: make tests¶
Use make tests (or make test) as the single, canonical command for running all quality checks before committing:
make tests
This runs:
1. Pre-commit hooks (Ruff linting + formatting, YAML/TOML checks, trailing whitespace)
2. Parser regression suite (tests/test_form_data_parsing.py)
3. Full pytest suite (985 tests covering validation, rendering, async, layouts, integration)
4. Coverage badge generation (summarizes test coverage)
What Changed¶
- Ruff is now enabled in
.pre-commit-config.yamland runs as part ofmake tests - Pre-commit hooks are mandatory before pytest runs; if linting fails, tests don't start
- No manual ruff invocation needed β it's automatic via pre-commit
Workflow¶
Before Committing¶
# Full quality check
make tests
# Or run individual steps if debugging:
make ruff # Lint + format only
make isort # Sort imports
make cleanup # Run all formatters (isort + ruff + autoflake)
What Happens in make tests¶
π Running pre-commit (ruff, formatting, yaml/toml checks)...
β
Pre-commit passed. Running form-data parser regression tests...
π§ͺ Regression passed. Running full pytest suite...
[985 tests run]
π Generating coverage and test badges...
β¨ Tests complete. Badges updated.
Test Organization¶
Tests are organized in tests/ by domain β one file per subject area:
| Test File | Purpose | ~Tests |
|---|---|---|
test_inputs.py |
All input component types (text, datetime, selection, specialized) | 148 |
test_validation.py |
Validation rules, engine, HTMX live validation | 144 |
test_integration.py |
FormBuilder, FastAPI integration, HTML markers | 107 |
test_assets.py |
Vendor assets, CDN/vendored modes, Bootstrap Icons | 109 |
test_rendering.py |
EnhancedFormRenderer, themes, templates, modern renderer | 99 |
test_form_layouts.py |
Form-model-backed layouts (Vertical, Horizontal, Tabbed, List) | 100 |
test_layouts.py |
Layout engine, TabLayout, AccordionLayout, async rendering | 94 |
test_field_renderer_coverage.py |
Field-level rendering logic | 43 |
test_schema_form.py + test_schema_form_coverage.py |
FormModel, Field, schema parsing | 67 |
test_live_validation_coverage.py |
HTMX live validation config | 34 |
test_form_data_parsing.py |
Nested form data parsing | 16 |
| Smaller focused files | CSRF, debug mode, model lists, plugin hooks, etc. | ~30 |
Linting Rules (Ruff)¶
Ruff checks for:
- Import ordering (from __future__ first, stdlib, third-party, local)
- Unused imports and variables
- Deprecated patterns (old Pydantic v1 syntax)
- Style (line length, unused code)
- Type hints (basic checks)
If Ruff finds issues, run:
make ruff # Auto-fix what it can
Then manually review any remaining issues and re-run make tests.
CI/CD Integration¶
- Local:
make testsvalidates code before pushing - GitHub Actions (
.github/workflows/testing.yml): Runs same checks on each PR - Pre-commit: Enabled for all developers via
.pre-commit-config.yaml
Dependencies¶
All test dependencies are in requirements.txt:
pip install -r requirements.txt
Key packages:
- pytest + pytest-asyncio + pytest-cov (testing)
- ruff (linting + formatting)
- isort (import sorting)
- genbadge (coverage reporting)
- pre-commit (hook management)
Troubleshooting¶
"pre-commit not found"¶
pip install pre-commit
# or
make install
"pytest not found"¶
pip install -r requirements.txt
Ruff keeps failing on the same file¶
Check what Ruff found:
make ruff
If it's an actual issue (not auto-fixable), edit the file manually and re-run.
Running only pytest (skip pre-commit)¶
pytest tests/
β οΈ Not recommended β linting failures will catch issues in CI anyway.
Running a specific test¶
# Run all tests for a domain
pytest tests/test_validation.py -v
pytest tests/test_rendering.py -v
# Run a specific test class or function
pytest tests/test_layouts.py::TestAsyncFormRendering::test_async_render_returns_same_html_as_sync -v
pytest tests/test_assets.py -k "bootstrap_icons" -v
Contributing¶
- Create a branch:
git checkout -b feature/my-feature - Make changes, add tests
- Run
make teststo validate - Commit and push
- Open a PR β CI will run the same checks
All PRs must pass: - β Ruff linting - β Import ordering (isort) - β pytest (all tests) - β Coverage thresholds
Contributing¶
Please feel free to contribute to this project. Adding common functions is the intent and if you have one to add or improve an existing it is greatly appreciated.
Ways to Contribute¶
- Add or improve a function
- Add or improve documentation
- Add or improve tests
- Report or fix a bug
Testing Workflow¶
- Run
make testsbefore opening or updating a pull request. tests/test_form_data_parsing.pyis a required regression suite and is run explicitly in localmake testsand CI workflows.- Keep this suite passing when changing nested form parsing or assignment behavior.
Changelog¶
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog.
Versions follow CalVer: YY.Quarter.Build β e.g. 26.2.1 = year 2026, quarter 2, build 1.
Beta releases append .beta (e.g. 26.2.1.beta); production releases have no suffix.
[26.2.3] β 2026-06-28¶
Fixed¶
-
LiveValidator.register_model_validatorpartial-validation bug β previously calledmodel_class.model_validate({field: value})with only one field's data; if the model had other required fields, Pydantic raisedValidationErrorfor them, causing all unrelated fields to produceis_valid=Falsewith no errors even when the value was valid. Now uses__pydantic_validator__.validate_assignment(model_construct(), field_name, value)to validate each field in isolation, independent of sibling required fields. -
LiveValidator.render_field_with_live_validationsingle-trigger limitation β theif/elif/elifchain forvalidate_on_blur/validate_on_input/validate_on_changesilently dropped every trigger after the first truthy one. Changed to independentifchecks so all enabled triggers combine into a singlehx-triggerattribute (e.g.hx-trigger="blur, change"when both areTrue).
Added¶
- Live-validation example β
GET /live-validationdemo route in the FastAPI example app shows a contact form with per-field HTMX validation on blur. IncludesPOST /validate/{field_name}HTMX endpoint usingvalidation_response_headers()andGET /vendor/htmx.min.jsto serve the vendored HTMX asset.
[26.2.2] β 2026-06-28¶
Added¶
FormModel.as_api_model()β new class method that returns a pure PydanticBaseModelwithui_*rendering metadata stripped fromjson_schema_extra. Allows the same model class to serve both as an HTML form and as a typed FastAPI body /response_modelwithoutui_element,ui_placeholder, etc. appearing in the OpenAPI/Swagger docs.Field(title=...),Field(examples=[...]), descriptions, and all validation constraints (min_length,ge,pattern, β¦) are fully preserved. Result is cached per subclass and safe to assign at module level (ContactSchema = ContactForm.as_api_model()).
Changed¶
- Reduced SonarCloud code smells: extracted duplicate magic strings to named constants,
prefixed unused parameters with
_, renamed methods that shadowed class attributes, and merged nestedifstatements into compound conditions across multiple modules.
[26.2.1] β 2026-05-17¶
First production release. All public Quick Start code paths verified working end-to-end.
Breaking Changes¶
submit_urlis now explicit across public form render APIs and no longer defaults silently. Calls that omitsubmit_urlraise a clearValueError, enforcing app-owned routing boundaries.
Added¶
- HTMX live validation β
LiveValidator,HTMXValidationConfig, andvalidation_response_headers()provide server-side field validation triggered by blur/input events via HTMX without page reloads. - Branch coverage β
branch = trueadded to coverage configuration for deeper test quality. - HTML structural validation β
tests/test_html_structural.pyvalidates tag balance across all frameworks and field types. - HTTP integration tests β FastAPI
TestClientand Flasktest_clientround-trip tests. - Property-based tests β Hypothesis suite covering arbitrary field names, labels, and frameworks.
- Performance benchmarks β
pytest-benchmarksuite for small, medium, and large form rendering. - Playwright browser tests β End-to-end HTMX validation tests against a live FastAPI server
(
tests/playwright/), running in CI on Chromium. - macOS in CI matrix β Tests now run on ubuntu-latest, windows-latest, and macos-latest.
py.typedmarker for PEP 561 compatibility.- Vendored assets (htmx, Bootstrap, Materialize, iMask) with sha256 manifest verification.
.gitattributesforcing LF line endings on vendored assets for cross-platform checksum stability.
Fixed¶
create_form_from_modelcrashed with any Pydantic model βAutoFormBuilder._build_from_modelused the wrong parameter name (default_valueinstead ofvalue) and compared against...instead ofPydanticUndefined; the sentinel leaked intojson_schema_extra, causingPydanticSerializationErroron every render call.- Windows path separator in test assertion caused CI failure on win32.
- Dependabot config used unsupported
interval: "cron"β silently ignored, causing weekly runs. Replaced withinterval: "monthly". - HTMX live validation JS used
JSON.parse(responseText)on HTML responses; replaced with the idiomaticHX-Trigger: validationResultheader pattern so CSS classes are applied correctly. except A, B:tuple syntax preserved via# fmt: skipto prevent ruff from removing parentheses that pyright requires.
Changed¶
- Layout rendering internals refactored for maintainability and reduced cognitive complexity.
- Reliability and maintainability improvements to satisfy SonarCloud quality gate.
- Improved nested/collapsible form behavior when multiple forms are rendered on a page.
- Pre-commit ruff version pinned to match
requirements.txtto eliminate local/CI formatting drift. - Pyright added to
make testsso type errors surface before commit.
Dependencies¶
fastapi[all]:0.121.2β0.128.4ruff:0.14.14β0.15.0tqdm:4.67.1β4.67.3pytest-html:4.1.1β4.2.0packaging:25.0β26.0black:25.12.0β26.1.0mkdocstrings[python]:1.0.0β1.0.1pymdown-extensions:10.20β10.20.1
Latest Changes¶
Production Releases¶
America 250 Release: Adding AI Help Instructions and Clean Up. (26.3.1)¶
Changes¶
Happy 250th Birthday USA!!!!
Features¶
- Enhancement: Adding AI Helper Documentation (#177) (@devsetgo)
- Fix: Live Validation (#167) (@devsetgo)
Bug Fixes¶
- Fix: Live Validation (#167) (@devsetgo)
Documentation¶
- Enhancement: Adding AI Helper Documentation (#177) (@devsetgo)
- Documentation Fix (#168) (@devsetgo)
- Fix: Live Validation (#167) (@devsetgo)
- Production Release - Final Edits (#166) (@devsetgo)
GitHub Actions¶
- Enhancement: Adding AI Helper Documentation (#177) (@devsetgo)
- Documentation Fix (#168) (@devsetgo)
Maintenance¶
- Enhancement: Adding AI Helper Documentation (#177) (@devsetgo)
- Fix: Live Validation (#167) (@devsetgo)
- Production Release - Final Edits (#166) (@devsetgo)
Contributors¶
@devsetgo
Published Date: 2026 July 03, 23:12
Live Validation Fix, Example, and Doc Updates (26.2.4)¶
Changes¶
Features¶
- Fix: Live Validation (#167) (@devsetgo)
Bug Fixes¶
- Fix: Live Validation (#167) (@devsetgo)
Documentation¶
- Fix: Live Validation (#167) (@devsetgo)
Maintenance¶
- Fix: Live Validation (#167) (@devsetgo)
Contributors¶
@devsetgo
Published Date: 2026 June 28, 14:32
CSRF Feature Release (26.2.1.beta)¶
Changes¶
- Documentation Updates (#116) (@devsetgo)
Features¶
- bump to v26.2.1.beta (#117) (@devsetgo)
- CSRF Feature (#114) (@devsetgo)
Maintenance¶
- feat(csrf): add CSRFMode enum with backward-compatible mode handling (#115) (@devsetgo)
- pip(deps): bump tox from 4.52.0 to 4.52.1 (#104) (@dependabot[bot])
- pip(deps): bump python-multipart from 0.0.24 to 0.0.26 (#106) (@dependabot[bot])
- github-actions(deps): bump actions/upload-pages-artifact from 4 to 5 (#107) (@dependabot[bot])
- pip(deps): bump mike from 2.1.4 to 2.2.0 (#108) (@dependabot[bot])
- pip(deps): bump fastapi from 0.134.0 to 0.135.3 (#97) (@dependabot[bot])
- pip(deps): bump pytest-cov from 7.0.0 to 7.1.0 (#98) (@dependabot[bot])
- pip(deps): bump pydantic-extra-types from 2.11.1 to 2.11.2 (#99) (@dependabot[bot])
- pip(deps): bump python-multipart from 0.0.22 to 0.0.24 (#100) (@dependabot[bot])
- pip(deps): bump pytest from 9.0.1 to 9.0.3 (#101) (@dependabot[bot])
- github-actions(deps): bump actions/github-script from 7 to 9 (#102) (@dependabot[bot])
- github-actions(deps): bump release-drafter/release-drafter from 6.2.0 to 7.2.0 (#103) (@dependabot[bot])
- dependency update and tests (#96) (@devsetgo)
- update of dependencies (#87) (@devsetgo)
- pip(deps): bump hatchling from 1.28.0 to 1.29.0 (#73) (@dependabot[bot])
- pip(deps): bump tox from 4.44.0 to 4.46.3 (#74) (@dependabot[bot])
- pip(deps): bump fastapi[all] from 0.128.4 to 0.134.0 (#75) (@dependabot[bot])
- pip(deps): bump mkdocs-material from 9.7.2 to 9.7.3 (#76) (@dependabot[bot])
- pip(deps): bump ruff from 0.15.1 to 0.15.4 (#77) (@dependabot[bot])
Contributors¶
@dependabot[bot], @devsetgo and dependabot[bot]
Published Date: 2026 April 26, 19:26
Removing External Validation Logic (26.1.8.beta)¶
Changes¶
Bug Fixes¶
- Removing validation logic outside of library. (#71) (@devsetgo)
Contributors¶
@devsetgo
Published Date: 2026 February 27, 21:32
Bug fix and cleanup. (26.1.7.beta)¶
Changes¶
- Move of examples (#69) (@devsetgo)
Bug Fixes¶
- fix(layouts): validate nested layout submissions and restore Preferen⦠(#70) (@devsetgo)
Contributors¶
@devsetgo
Published Date: 2026 February 22, 19:56
Many fixes, enhancements, and increasing code coverage (26.1.6.beta)¶
Changes¶
- bumping version to 26.1.6.beta (#68) (@devsetgo)
- feat(examples,docs): add plain HTML style support and document render⦠(#67) (@devsetgo)
- improving documents (#59) (@devsetgo)
- improving test coverage (#58) (@devsetgo)
- improving reliability rating (#57) (@devsetgo)
- working on sonarcloud issue (#41) (@devsetgo)
- fixing examples (#33) (@devsetgo)
Features¶
- working on example. (#65) (@devsetgo)
- Improvements - docs, examples, and bug fixes (#43) (@devsetgo)
- 34 add timing (#42) (@devsetgo)
- adding start line and end line for HTML sent to browser (#32) (@devsetgo)
Bug Fixes¶
- working on material design render issues (#66) (@devsetgo)
- working on example. (#65) (@devsetgo)
- Improvements - docs, examples, and bug fixes (#43) (@devsetgo)
Maintenance¶
- pip(deps): bump tox from 4.36.0 to 4.44.0 (#60) (@dependabot[bot])
- pip(deps): bump mkdocs-material from 9.7.1 to 9.7.2 (#61) (@dependabot[bot])
- pip(deps): bump isort from 7.0.0 to 8.0.0 (#62) (@dependabot[bot])
- pip(deps): bump flask from 3.1.2 to 3.1.3 (#63) (@dependabot[bot])
- pip(deps): bump autoflake from 2.3.1 to 2.3.3 (#64) (@dependabot[bot])
- pip(deps): bump pytest-html from 4.1.1 to 4.2.0 (#45) (@dependabot[bot])
- pip(deps): bump ruff from 0.14.14 to 0.15.0 (#44) (@dependabot[bot])
- pip(deps): bump tqdm from 4.67.1 to 4.67.3 (#47) (@dependabot[bot])
- pip(deps): bump fastapi[all] from 0.121.2 to 0.128.4 (#48) (@dependabot[bot])
- pip(deps): bump mkdocstrings[python] from 1.0.0 to 1.0.1 (#39) (@dependabot[bot])
- pip(deps): bump pymdown-extensions from 10.20 to 10.20.1 (#36) (@dependabot[bot])
- github-actions(deps): bump release-drafter/release-drafter from 6.1.0 to 6.2.0 (#35) (@dependabot[bot])
- pip(deps): bump black from 25.12.0 to 26.1.0 (#37) (@dependabot[bot])
- pip(deps): bump ruff from 0.14.11 to 0.14.14 (#38) (@dependabot[bot])
- pip(deps): bump packaging from 25.0 to 26.0 (#40) (@dependabot[bot])
Contributors¶
@dependabot[bot], @devsetgo and dependabot[bot]
Published Date: 2026 February 22, 16:35
Bug fixes and Improvements (26.1.2.beta)¶
Changes¶
- Publishing Improvements (#17) (@devsetgo)
- Doc (#16) (@devsetgo)
- GitHub Actions Improvements (#10) (@devsetgo)
- first release (#9) (@devsetgo)
- working on documentation (#5) (@devsetgo)
- working on coverage (#4) (@devsetgo)
Features¶
- Improving Workflow (#1) (@devsetgo)
Bug Fixes¶
- 18 bootstrap not included in self contained example (#24) (@devsetgo)
- Fix model-list delete for dynamically added items (#23) (@devsetgo)
- working on coverage issue (#3) (@devsetgo)
- working on publishing issue (#2) (@devsetgo)
Maintenance¶
- github-actions(deps): bump actions/upload-pages-artifact from 3 to 4 (#19) (@dependabot[bot])
- updating release drafter (#11) (@devsetgo)
- Pre-Release Checks (#6) (@devsetgo)
Contributors¶
@dependabot[bot], @devsetgo and dependabot[bot]
Published Date: 2026 January 09, 21:46
Pre-releases¶
Initial Beta Release (26.1.1.beta)¶
Changes¶
- GitHub Actions Improvements (#10) (@devsetgo)
- first release (#9) (@devsetgo)
- working on documentation (#5) (@devsetgo)
- working on coverage (#4) (@devsetgo)
Features¶
- Improving Workflow (#1) (@devsetgo)
Bug Fixes¶
- working on coverage issue (#3) (@devsetgo)
- working on publishing issue (#2) (@devsetgo)
Maintenance¶
- updating release drafter (#11) (@devsetgo)
- Pre-Release Checks (#6) (@devsetgo)
Contributors¶
@devsetgo
Published Date: 2026 January 02, 19:13
Codebase Review β pydantic-schemaforms¶
Date: 2025-12-21 (Updated)
Executive Summary¶
pydantic-schemaforms is in a solid place architecturally: there is now a single schemaβfieldβlayout rendering pipeline, theme/style are first-class and overrideable, and the test suite exercises both sync and async rendering across layout primitives. The remaining work is less about βmore featuresβ and more about locking the product to its original promise:
-
Python 3.14+ only: this library targets Python 3.14 and higher, and does not support earlier Python versions.
-
ship all required HTML/CSS/JS from the library (no external CDNs by default)
- keep UI + validation configuration expressed via Pydantic (schema is the source of truth)
- make sync + async usage boringly simple (one obvious way)
- add an optional debug rendering mode that helps adoption without changing normal UX
The renderer refactor eliminated shared mutable state and restored the enhanced/material renderers to a working baseline. Schema metadata is cached, field rendering is centralized, and model-list nesting now feeds explicit RenderContext objects. Django integration has been removed (Flask/FastAPI remain), and the JSON/OpenAPI generators now source constraints directly from Pydantic field metadata, unblocking the integration tests. Renderer themes now include a formal FrameworkTheme registry (Bootstrap/Material/plain) plus MaterialEmbeddedTheme, and both EnhancedFormRenderer and FieldRenderer source their form/input/button classes from the active theme before falling back to legacy framework config.
Latest (Dec 7, 2025): Theme-driven form chrome extraction is complete. The new FormStyle contract centralizes all framework-specific markup (model lists, tabs, accordions, submit buttons, layout sections, and field-level help/error blocks) in a registry-based system. FormStyleTemplates dataclass now holds 15 template slots (expanded from 13: added field_help and field_error for field-level chrome routing), registered per-framework (Bootstrap/Material/Plain/Default) with graceful fallbacks. RendererTheme and LayoutEngine now query FormStyle.templates at render time instead of inlining markup, enabling runtime overrides without renderer edits. Version-aware style descriptors are supported (e.g., get_form_style("bootstrap:5"), get_form_style("material:3")) with fallback to framework defaults. FastAPI example hardened with absolute paths (Path(__file__).resolve().parent) for templates/static, resolving path issues in tests and different working directories. Validation engine consolidated: ValidationResponse and convenience validators now live in validation.py, live_validation.py consumes/re-exports without duplication, and 10 new consolidation tests were added. Tabs regression fixed: Bootstrap panels now render initial content (show active), Material tabs use shared tab CSS/JS classes so tabs switch correctly, and a layout-demo smoke test asserts initial tab content renders for both frameworks. Pydantic v2 deprecations eliminated: All Pydantic Field() kwargs now properly use json_schema_extra instead of extra kwargs; min_items/max_items replaced with min_length/max_length in FormField calls; Starlette TemplateResponse signature updated to new parameter order (request first). Deprecation warnings suppressed: pytest filterwarnings config reduces test output from 19 warnings to 1 without losing developer guidance (form_layouts deprecation and JSON schema hints remain available with -W default). Validation documentation added: New comprehensive docs/validation_guide.md (787 lines) documents the unified validation engine with ValidationResponse/FieldValidator/FormValidator APIs, server-side and HTMX validation flows, cross-field validation patterns, and end-to-end FastAPI examples.
Latest (Dec 21, 2025): The core architecture is now strong enough to support the original product constraints (library supplies HTML/CSS/JS, configuration is expressed via Pydantic, sync+async are easy). The most important product-alignment work is now complete for assets and consistently enforced:
- Self-contained assets: Default rendering no longer emits external CDN URLs.
- HTMX is now vendored and inlined by default (offline-by-default).
- A CDN mode exists but is explicitly opt-in and pinned to the vendored manifest version.
- IMask is vendored and available when explicitly requested (e.g., SSN masking).
- Framework CSS/JS (Bootstrap + Materialize) are vendored and can be emitted inline in
asset_mode="vendored". - Consistent asset selection: A consistent
asset_modepattern is now threaded through the main entry points (enhanced renderer, modern renderer/builder, legacy wrappers) so βcdn vs vendoredβ behavior is deterministic. - Operational stability:
vendor_manifest.jsonchecksum verification is enforced by tests, and pre-commit is configured to avoid mutating vendored assets and generated test artifacts. - Debug rendering mode (COMPLETED): First-class debug panel now available via
debug=Trueflag. The panel exposes (1) rendered HTML/assets, (2) the Python form/model source, (3) validation rules/schema, and (4) live payload with real-time form data capture. Implementation uses JavaScript event listeners to update the live tab as users interact with the form, handles nested model-list fields correctly, and is fully self-contained (inline CSS/JS). FastAPI example updated with?debug=1support on all routes, and tests verify correct behavior.
Latest (Jul 4, 2026): model_list support was consolidated onto a single implementation and the parallel/unreachable code that consolidation exposed was removed outright β no deprecation shims, no "kept for compatibility" branches.
- Fixed a real rendering bug:
Field()kwargs likeui_placeholder,ui_autofocus,ui_help_text,ui_disabled,ui_readonly,ui_class,ui_style, andui_orderwere silently dropped for normally-declared fields. Root cause:rendering/schema_parser.py's UI-metadata extraction assumedjson_schema_extrakeys always arrived pre-stripped of theirui_prefix (true only for dynamically-registered fields viaregister_field()), so it never checked the flat, still-prefixed form thatmodel_json_schema()actually produces for ordinary annotated fields. Addedextract_ui_info()as the single normalizer, used everywherefield_schema.get('ui', {}) or field_schemaused to appear. - Consolidated
model_listrendering to one path:_render_model_list_field(inrendering/field_renderer.py) previously forked on whethermodel_classwas passed explicitly viaui_options(routing throughModelListRenderer.render_model_list()) vs. resolved automatically from alist[ItemModel]type annotation (routing through the schema-basedrender_model_list_from_schema()). The two paths had diverged: only the schema-based one supported per-item title templates, collapsible items, and nested per-item validation errors. Themodel_class-via-ui_optionsbranch was also confirmed unreachable through any public API βField()fails to serialize a raw class intojson_schema_extra, andregister_field()'s schema builder only forwardsui_-prefixed keys. The fork was removed; item models are now always resolved from the type annotation, and nested-error support was added to the surviving path so both former branches' capabilities are covered by one implementation. - Added the missing
Field()knobs:ui_add_button_label,ui_item_title_template,ui_collapsible_items,ui_items_expandedare now namedField()parameters (same pattern asui_help_text). The rendering logic for these already existed; they just weren't wired up as recognized kwargs, so they never reached the schema for ordinary fields. - Removed the now-dead code, not just the dead call site:
ModelListRenderer's rendering methods (render_model_list,_render_list,_render_bootstrap_list_item,_render_material_list_item,_render_item_body,_resolve_theme) had exactly one caller left β the branch removed above β so they were deleted rather than left as unreferenced "just in case" code; onlyget_model_list_javascript()(still used) remains inmodel_list.py. Tracing the same question one level further intoRendererTheme/FormStyleTemplatesfoundrender_model_list_item()(base,MaterialTheme,MaterialEmbeddedTheme) and themodel_list_itemtemplate slot (default/plain/material) had also lost their only caller in the same consolidation and were removed too, along with the now-inaccurate "Model list renderer mixes logic with theme markup" entry further down (marked superseded, not deleted, so the history stays legible). - Net effect: one
model_listimplementation instead of two, all four documented customization knobs actually work throughField(), and per-item validation errors render inline regardless of how the item model was declared. Files:pydantic_schemaforms/schema_form.py,pydantic_schemaforms/rendering/schema_parser.py,pydantic_schemaforms/rendering/field_renderer.py,pydantic_schemaforms/rendering/themes.py,pydantic_schemaforms/rendering/form_style.py,pydantic_schemaforms/model_list.py,tests/test_rendering.py,tests/test_model_list_integration.py,tests/test_input_rendering.py,tests/test_field_renderer_coverage.py,tests/test_template_coverage.py
Design Rules (NonβNegotiables)¶
These rules are intended to prevent βhelpfulβ drift away from the original concept.
- Python version policy
- The library supports Python 3.14 and higher only.
-
Avoid guidance that suggests compatibility with older Python versions.
-
Library ships the experience
- Default output must be fully functional offline: no third-party CDN assets (JS/CSS/fonts) unless explicitly opted in.
- Framework integrations may serve assets, but the assets must come from this package.
-
Pydantic is the single source of truth
- Validation constraints, required/optional, and shape come from Pydantic schema/Field metadata.
- UI configuration is expressed via Pydantic-friendly metadata (
json_schema_extra/ form field helpers) rather than ad-hoc runtime flags. - Avoid storing non-JSON-serializable objects in schema extras unless they are sanitized for schema generation.
-
One obvious way (sync + async)
- There should be exactly one recommended sync entry point and one async entry point.
- All other helpers should be thin compatibility wrappers and should not diverge in behavior.
-
Renderer outputs deterministic, self-contained HTML
- Rendering should not depend on global mutable state or ambient process configuration.
- Rendering should be deterministic for the same model + config.
-
Debug mode is optional and non-invasive
- Debug UI must be off by default.
- When enabled, it should wrap the existing form (collapsed panel) and never change validation/rendering semantics.
- Debug surfaces should be βread-only viewsβ of: rendered HTML/assets, model source (best effort), schema/validation rules, and live validation payloads.
-
Extensibility stays declarative
- Plugins register inputs/layout renderers via the official registries; no monkeypatching required.
- Extension points should compose with themes/styles, not bypass them.
Critical / High Priority Findings¶
-
External CDN assets violate the self-contained requirement (Addressed) Default output is now offline-by-default across the main entry points.
-
HTMX is vendored and inlined by default.
- An explicit
asset_mode="cdn"exists for users who want CDN delivery, but it is pinned to the vendored manifest version. - IMask is vendored and available for secure inputs (e.g., SSN masking). It is not injected unless explicitly requested.
- Framework CSS/JS (Bootstrap + Materialize) are vendored and can be emitted in
asset_mode="vendored"(inline) to keep βframework lookβ self-contained. - External CDN URLs still exist as an explicit opt-in (
asset_mode="cdn") and are pinned to the vendored manifest versions.
Files:
- pydantic_schemaforms/render_form.py (legacy wrapper)
- pydantic_schemaforms/assets/runtime.py (vendored HTMX + pinned CDN mode)
- pydantic_schemaforms/rendering/themes.py / pydantic_schemaforms/enhanced_renderer.py (asset-mode gating)
- pydantic_schemaforms/modern_renderer.py, pydantic_schemaforms/integration/builder.py (builder/modern entry points)
- pydantic_schemaforms/form_layouts.py (deprecated legacy layouts; now gated)
Vendored dependency policy (implemented for HTMX; extendable for others)
- Default is offline: the default renderer output must not reference external CDNs.
- Pinned + auditable: every vendored asset must be pinned to an explicit version and recorded with source_url + sha256 in a manifest.
- Licenses included: upstream license text (and any required notices) must be included in the repo alongside the vendored asset (or clearly referenced if inclusion is not permitted).
- Single update path: updates must happen via an explicit script + make target (no manual copy/paste), so diffs are reproducible.
- Opt-in CDN mode only: if a CDN mode exists, it must be explicitly selected (never default) and clearly documented as not-offline.
What βeasy to updateβ means (definition)
- One command updates the pinned version, downloads the asset(s), writes/updates the manifest checksums, and runs a verification check.
- A CI/test check fails if any default render output contains external asset URLs.
- The update process is deterministic and reviewable (diff shows only asset bytes + manifest/version bump).
- Formatting/lint tooling must not modify vendored bytes (otherwise checksum verification breaks). Pre-commit should exclude pydantic_schemaforms/assets/vendor/ from whitespace/EOF normalization hooks.
-
Multiple layout stacks compete for ownership (Resolved) Layout definitions now live exclusively in
layout_base.BaseLayout+rendering/layout_engine. The newLayoutComposerAPI exposes the canonical Horizontal/Vertical/Tabbed primitives, whilepydantic_schemaforms.layouts/pydantic_schemaforms.form_layoutsonly re-export the engine withDeprecationWarnings. Enhanced and Material renderers both call intoLayoutEngine, so markup is consistent across frameworks, and the tutorial documents LayoutComposer as the single supported API. Files:pydantic_schemaforms/rendering/layout_engine.py,pydantic_schemaforms/layouts.py,pydantic_schemaforms/form_layouts.py,pydantic_schemaforms/simple_material_renderer.py -
Renderer logic duplicated across frameworks (Resolved) All framework rendering β Bootstrap, Material, and plain β goes through
EnhancedFormRenderer. Material field rendering (_render_material_fieldand 13 helper methods) lives inenhanced_renderer.py;MaterialEmbeddedThemehandles CSS/JS chrome.SimpleMaterialRendereris now a 28-line backward-compatible alias (class SimpleMaterialRenderer(EnhancedFormRenderer)with__init__callingsuper().__init__(framework="material")). Therender_form_html()helper no longer has a hardcodedif framework == "material"branch β it instantiatesEnhancedFormRenderer(framework=framework)for every framework, soinclude_framework_assets,asset_mode, andself_containednow work correctly for Material too. Files:pydantic_schemaforms/enhanced_renderer.py,pydantic_schemaforms/rendering/themes.py,pydantic_schemaforms/simple_material_renderer.py -
Integration helpers mix unrelated responsibilities (Resolved) The
integration/frameworks/compatibility shim layer has been deleted. The sync/async helpers (handle_form,handle_form_async,handle_async_form,handle_sync_form,normalize_form_data,FormIntegration) are now imported directly from their canonical modules (adapters.py,async_support.py,sync.py) inintegration/__init__.py. The_LAZY_EXPORTS/__getattr__indirection is gone; all integration symbols are regular module-level imports. Files:pydantic_schemaforms/integration/__init__.py,pydantic_schemaforms/integration/adapters.py,pydantic_schemaforms/integration/sync.py,pydantic_schemaforms/integration/async_support.py
Medium Priority Refactors & Opportunities¶
-
Input component metadata duplicated (Resolved) Input classes now declare their
ui_element(plus optional aliases) and a lightweight registry walks the class hierarchy to expose a mapping.rendering/frameworks.pyimports that registry instead of maintaining its own list, so adding a new component only requires updating the input module where it already lives. Files:pydantic_schemaforms/inputs/base.py,pydantic_schemaforms/inputs/*,pydantic_schemaforms/inputs/registry.py,pydantic_schemaforms/rendering/frameworks.py -
Model list renderer mixes logic with theme markup (Superseded β see Jul 2026 entry)
ModelListRendererpreviously delegated both containers and per-item chrome throughRendererThemehooks (render_model_list_container()andrender_model_list_item(), with Material/embedded overrides). That per-item hook was never reached by any real caller once the schema-based renderer became the single path formodel_listfields, and has since been removed along with the rest ofModelListRenderer's rendering methods β see the consolidation entry below. Files:pydantic_schemaforms/rendering/field_renderer.py,pydantic_schemaforms/rendering/themes.py -
Template engine under-used (Resolved) The new
FormStylecontract (inrendering/form_style.py) extracts all framework-specific markup into aFormStyleTemplatesdataclass (current slots:form_wrapper,tab_layout,tab_button,tab_panel,accordion_layout,accordion_section,layout_section,layout_help,model_list_container,model_list_help,model_list_error,field_help,field_errorβ see the Jul 2026 entry below for the removal of themodel_list_itemslot). Framework-specific bundles (Bootstrap, Material, Plain, Default) are registered in a centralized registry viaregister_form_style()and queried at render time withget_form_style(framework, variant).RendererTheme.render_submit_button(),render_model_list_container()andLayoutEnginetab/accordion layouts all delegate toFormStyle.templateswith graceful fallback to defaults, eliminating inline markup strings and enabling runtime overrides. FastAPI example paths hardened to usePath(__file__).resolve().parentfor templates and static dirs, working correctly from any working directory. Files:pydantic_schemaforms/rendering/form_style.py,pydantic_schemaforms/rendering/themes.py,pydantic_schemaforms/rendering/layout_engine.py,examples/fastapi_example.py,tests/test_rendering.py,tests/test_integration.py -
Runtime field registration surfaced (New) Dynamically extending a
FormModelis now supported viaFormModel.register_field(), which wires the newFieldInfointo the schema cache and the validation stack by synthesizing a runtime subclass when necessary. Legacysetattr(MyForm, name, Field(...))still works for rendering, but the helper ensuresvalidate_form_data()and HTMX live validation enforce the same constraints without manual plumbing. Files:pydantic_schemaforms/schema_form.py,pydantic_schemaforms/validation.py,tests/test_integration.pyTODO: The temporaryDynamicFormRuntimecreated bypydantic.create_model()emits aUserWarningabout shadowing parent attributes. If this becomes noisy, add a localmodel_config = {"ignored_types": ...}or suppress the warning via the helper before rebuilding the runtime model. -
Validation rule duplication (Resolved) Validation is now canonical in
validation.py(rules,ValidationResponse, convenience validators).live_validation.pyconsumes/re-exports without duplicating code. Added consolidation coverage (10 tests) for schema β live validator flow, convenience validators, and serialization. Files:pydantic_schemaforms/validation.py,pydantic_schemaforms/live_validation.py,pydantic_schemaforms/__init__.py,tests/test_validation.py -
Input namespace still re-exports everything (Resolved) The root package now exposes inputs via module-level
__getattr__, delegating to a lazy-loading facade inpydantic_schemaforms.inputs. No wildcard imports remain, so importingpydantic_schemaformsdoes not instantiate every widget or template; consumers still getfrom pydantic_schemaforms import TextInputvia the cached attribute. Future work can build on the same facade to document a plugin hook for third-party inputs. Files:pydantic_schemaforms/__init__.py,pydantic_schemaforms/inputs/__init__.py -
Integration facade duplicated across namespaces (Resolved) The canonical sync/async helpers live only in
integration/adapters.py,integration/sync.py, andintegration/async_support.py. The intermediateintegration/frameworks/shim layer has been removed βintegration/__init__.pyimports from those canonical files directly. There is now exactly one code path for validation + rendering logic with no proxy indirection. Files:pydantic_schemaforms/integration/__init__.py,pydantic_schemaforms/integration/adapters.py,pydantic_schemaforms/integration/sync.py,pydantic_schemaforms/integration/async_support.py -
Public sync/async βone obvious wayβ (Resolved) Canonical entry points now exist and are exported from the root package:
- Sync:
handle_form() - Async:
handle_form_async()
Legacy helpers (handle_sync_form, handle_async_form, FormIntegration.*) remain as compatibility wrappers.
Files: pydantic_schemaforms/integration/adapters.py, pydantic_schemaforms/integration/__init__.py, pydantic_schemaforms/__init__.py, docs/quickstart.md, tests/test_integration.py
-
Theme/style contract centralized
RendererThemenow includes concreteFrameworkThemesubclasses for Bootstrap/Material/plain plusget_theme_for_framework, and both enhanced + field renderers request classes/assets from the active theme before falling back to legacy configs.FormStyleregistry now handles framework-level templates (including field-level chrome) and supports version-aware descriptors (e.g.,bootstrap:5,material:3) with fallbacks to base framework/default. Files:pydantic_schemaforms/enhanced_renderer.py,pydantic_schemaforms/rendering/themes.py,pydantic_schemaforms/rendering/field_renderer.py,pydantic_schemaforms/rendering/frameworks.py,pydantic_schemaforms/rendering/form_style.py -
Plugin hooks for inputs/layouts (NEW) Input components can now be registered via
register_input_class()andregister_inputs()ininputs/registry.pywith automatic cache invalidation. Layout renderers can be registered viaLayoutEngine.register_layout_renderer()and referenced from form fields usinglayout_handlermetadata. Both APIs support resettable state for testing. Docs pagedocs/plugin_hooks.mdexplains usage and packaging patterns. Files:pydantic_schemaforms/inputs/registry.py,pydantic_schemaforms/rendering/layout_engine.py,docs/plugin_hooks.md,tests/test_plugin_hooks.py
Testing & Tooling Gaps¶
- β
Renderer behavior E2E coverage (COMPLETED) β Tab/accordion DOM structure, aria attributes, display state; integration tests for
LayoutDemonstrationForm; async equivalence tests. All passing (now consolidated intests/test_layouts.py). - β
CI/docs alignment (COMPLETED) β Documented
make testsas single entry point in newdocs/testing_workflow.md(comprehensive guide with test organization, linting rules, CI/CD integration, troubleshooting). Ruff now enabled in.pre-commit-config.yamland enforced as part ofmake testsbefore pytest runs.
Recommended Next Steps¶
- β
Document unified validation engine (COMPLETED) β Created comprehensive
docs/validation_guide.md(787 lines) with: ValidationResponse,FieldValidator,FormValidator, andValidationSchemaAPI documentation- Server-side validation patterns with
validate_form_data()and custom rules - Real-time HTMX validation with
LiveValidatorandHTMXValidationConfig - Cross-field validation examples (age consent, password matching, conditional fields)
- Convenience validators (
create_email_validator(),create_password_strength_validator()) - Complete end-to-end sync + HTMX flow example with FastAPI endpoints and HTML templates
- Testing patterns and Pydantic v2 deprecation resolution notes
-
References to layout-demo smoke test coverage and tab rendering verification
-
β Suppress remaining expected deprecation warnings (COMPLETED) β Added
filterwarningsto[tool.pytest.ini_options]inpyproject.tomlto suppress intentional warnings:form_layoutsdeprecation notice (migration guidance), Pydantic JSON schema non-serializable defaults (informational), and Pydantic extra kwargs deprecation (handled in code). Result: test output reduced from 19 warnings to 1 in normal mode; warnings remain accessible viapytest -W default. -
β Field-level chrome routing (COMPLETED) β Extended
FormStyleto support field-level help/error templating: Addedfield_helpandfield_errortemplates toFormStyleTemplatesdataclass (15 slots total, up from 13), registered framework-specific versions for Bootstrap, Plain, and Material Design. Ready for field renderers to consume these templates; enables consistent field-level chrome across all frameworks without renderer edits. -
β Version-aware style variants (COMPLETED) β
FormStyledescriptors accept framework + variant (e.g.,"bootstrap:5","material:3") with graceful fallbacks to the framework base and default style. Aliases registered for Bootstrap 5 and Material 3 reuse existing templates; lookup stays backward compatible. -
β Extension hooks for inputs/layouts (COMPLETED) β Plugin registration API added:
register_input_class()/register_inputs()ininputs/registrywith cache clearing, andLayoutEngine.register_layout_renderer()with metadata-driven dispatch. Documented indocs/plugin_hooks.mdwith examples and best practices. -
β Automated E2E coverage for layouts/async (COMPLETED) β Comprehensive coverage of tab/accordion DOM structure, aria attributes, and display state;
LayoutDemonstrationFormintegration tests; async equivalence tests forrender_form_from_model_async(). All consolidated intotests/test_layouts.py. -
β CI/docs alignment (COMPLETED) β Documented
make testsas single entry point in newdocs/testing_workflow.md(comprehensive guide with test organization, linting rules, CI/CD integration, troubleshooting). Ruff now enabled in.pre-commit-config.yamland enforced as part ofmake testsbefore pytest runs. All 217+ tests passing with integrated linting. -
β Make asset delivery self-contained (Completed for HTMX + IMask + framework assets) β The default renderer output is offline-by-default.
- β HTMX is vendored and inlined by default; CDN mode is opt-in and pinned.
-
β IMask is vendored (for SSN and other secure input types) and can be included explicitly.
- β
Bootstrap + Materialize CSS/JS are vendored and can be emitted inline via
asset_mode="vendored"when framework assets are requested.
Update workflow (implemented) - Vendoring and verification scripts/targets exist, and tests enforce βno external URLs by defaultβ. -
docs/assets.mddocuments theasset_modecontract and the vendoring workflow. - β
Bootstrap + Materialize CSS/JS are vendored and can be emitted inline via
Tooling note (important)
- Pre-commit should exclude vendored assets and generated artifacts (coverage/test reports, debug HTML) from whitespace/EOF fixers to keep checksum verification and make tests stable.
- β Define one canonical sync + one canonical async entry point (COMPLETED) β Canonical entry points exist and are documented:
- Sync:
handle_form() - Async:
handle_form_async()
Both are exported from pydantic_schemaforms and covered by integration tests.
Files: pydantic_schemaforms/integration/adapters.py, pydantic_schemaforms/__init__.py, docs/quickstart.md, tests/test_integration.py
- β
Add a first-class debug rendering mode (COMPLETED) β Implemented a
debug=Trueoption that wraps the form in a collapsed debug panel with tabs: - Rendered output: raw HTML (including the CSS/JS assets block)
- Form/model source: Python source for the form class (best-effort via
inspect.getsource()) - Schema / validation: schema-derived constraints (field requirements, min/max, regex, etc.)
- Live payload: Real-time form data capture that updates as users type/interact with the form
Implementation details:
- Debug panel is off by default and non-invasive (collapsed <details> element)
- JavaScript event listeners capture input and change events to update live payload
- Handles nested form data (model lists with pets[0].name notation)
- Proper checkbox handling (true/false instead of "on" or missing)
- Tab UI consistent across frameworks using inline styles/scripts
- Tests verify debug panel presence when enabled and absence by default
- FastAPI example updated to expose ?debug=1 query parameter on all form routes
Files: pydantic_schemaforms/enhanced_renderer.py (debug panel builder), pydantic_schemaforms/render_form.py (debug flag forwarding), tests/test_debug_mode.py (2 tests), examples/fastapi_example.py (debug parameter on all routes)
## Codebase Layout (Package Map)
This section documents the git-tracked layout of the pydantic_schemaforms/ package.
Notes:
- Runtime-generated
__pycache__/folders and*.pycfiles are intentionally omitted here (they are not part of the source tree and should not be committed).
### pydantic_schemaforms/ (root package)
pydantic_schemaforms/__init__.pyβ Public package entry point (lazy exports) and top-level API surface.pydantic_schemaforms/enhanced_renderer.pyβ Enhanced renderer pipeline (schema β fields β layout) with sync/async HTML entry points.pydantic_schemaforms/form_field.pyβFormFieldabstraction and higher-level field helpers aligned with the design vision.pydantic_schemaforms/form_layouts.pyβ Legacy layout composition helpers (kept for compatibility; deprecated).pydantic_schemaforms/icon_mapping.pyβ Framework icon mapping helpers (bootstrap/material icon name/class resolution).pydantic_schemaforms/input_types.pyβ Input type constants, default mappings, and validation utilities for selecting HTML input types.pydantic_schemaforms/layout_base.pyβ Shared base layout primitives used by the layout engine and renderer(s).pydantic_schemaforms/layouts.pyβ Deprecated layout wrapper module (compatibility surface).pydantic_schemaforms/live_validation.pyβ HTMX-oriented βlive validationβ plumbing and configuration.pydantic_schemaforms/model_list.pyβ Rendering helpers for repeating/nested model lists.pydantic_schemaforms/modern_renderer.pyβ βModernβ renderer facade backed by the shared enhanced pipeline.pydantic_schemaforms/render_form.pyβ Backwards-compatible rendering wrapper(s) for legacy entry points.pydantic_schemaforms/schema_form.pyβ Pydantic-driven form model primitives (FormModel,Field, validator helpers, validation result types).pydantic_schemaforms/simple_material_renderer.pyβ Backward-compatible alias:SimpleMaterialRendereris a thin subclass ofEnhancedFormRenderer(framework="material"). All material rendering logic lives inenhanced_renderer.py.pydantic_schemaforms/templates.pyβ Python 3.14 template-string based templating helpers used throughout rendering.pydantic_schemaforms/validation.pyβ Canonical validation rules/engine and serializable validation responses.pydantic_schemaforms/vendor_assets.pyβ Vendoring/manifest helper utilities used to manage and verify shipped third-party assets.pydantic_schemaforms/version_check.pyβ Python version checks (enforces Python 3.14+ assumptions like template strings).
### pydantic_schemaforms/assets/ (packaged assets)
pydantic_schemaforms/assets/__init__.pyβ Asset package marker.pydantic_schemaforms/assets/runtime.pyβ Runtime helpers to load/inline assets and emit tags (vendored vs pinned CDN modes).
#### pydantic_schemaforms/assets/vendor/ (vendored thirdβparty assets)
pydantic_schemaforms/assets/vendor/README.mdβ Vendored asset policy and update workflow overview.pydantic_schemaforms/assets/vendor/vendor_manifest.jsonβ Pin list and SHA256 checksums for vendored assets (audit + verification).
##### pydantic_schemaforms/assets/vendor/bootstrap/
pydantic_schemaforms/assets/vendor/bootstrap/bootstrap.min.cssβ Vendored, minified Bootstrap CSS.pydantic_schemaforms/assets/vendor/bootstrap/bootstrap.bundle.min.jsβ Vendored, minified Bootstrap JS bundle.pydantic_schemaforms/assets/vendor/bootstrap/LICENSEβ Upstream Bootstrap license text.
##### pydantic_schemaforms/assets/vendor/htmx/
pydantic_schemaforms/assets/vendor/htmx/htmx.min.jsβ Vendored, minified HTMX library.pydantic_schemaforms/assets/vendor/htmx/LICENSEβ Upstream HTMX license text.
##### pydantic_schemaforms/assets/vendor/imask/
pydantic_schemaforms/assets/vendor/imask/imask.min.jsβ Vendored, minified IMask library (used for masked inputs).pydantic_schemaforms/assets/vendor/imask/LICENSEβ Upstream IMask license text.
##### pydantic_schemaforms/assets/vendor/materialize/
pydantic_schemaforms/assets/vendor/materialize/materialize.min.cssβ Vendored, minified Materialize CSS.pydantic_schemaforms/assets/vendor/materialize/materialize.min.jsβ Vendored, minified Materialize JS.pydantic_schemaforms/assets/vendor/materialize/LICENSEβ Upstream Materialize license text.
### pydantic_schemaforms/inputs/ (input components)
pydantic_schemaforms/inputs/__init__.pyβ Lazy-loading facade for input classes (keeps import cost low).pydantic_schemaforms/inputs/base.pyβ Base input types, rendering utilities, and shared label/help/error builders.pydantic_schemaforms/inputs/datetime_inputs.pyβ Date/time-related input components.pydantic_schemaforms/inputs/numeric_inputs.pyβ Numeric/slider/range-related input components.pydantic_schemaforms/inputs/registry.pyβ Runtime registry and discovery helpers for input components.pydantic_schemaforms/inputs/selection_inputs.pyβ Select/checkbox/radio/toggle-related input components.pydantic_schemaforms/inputs/specialized_inputs.pyβ Specialized inputs (file upload, color, hidden, csrf/honeypot, tags, etc.).pydantic_schemaforms/inputs/text_inputs.pyβ Text-ish inputs (text, password, email, URL, phone, credit card, etc.).
### pydantic_schemaforms/integration/ (framework/application integration)
pydantic_schemaforms/integration/__init__.pyβ Integration facade with lazy exports of framework glue.pydantic_schemaforms/integration/adapters.pyβ High-level sync/async integration entry points (handle_form,handle_form_async).pydantic_schemaforms/integration/async_support.pyβ Framework-agnostic async request/validation helpers.pydantic_schemaforms/integration/builder.pyβ Form builder utilities (prebuilt forms, page wrapper helpers, asset tag helpers).pydantic_schemaforms/integration/react.pyβ JSON-schema-form-oriented integration helpers.pydantic_schemaforms/integration/schema.pyβ JSON/OpenAPI schema generation utilities.pydantic_schemaforms/integration/sync.pyβ Framework-agnostic sync request/validation helpers.pydantic_schemaforms/integration/utils.pyβ Shared utilities for integrations (type mapping, framework selection, validation conversion).pydantic_schemaforms/integration/vue.pyβ Vue integration helpers.
### pydantic_schemaforms/rendering/ (shared rendering engine)
pydantic_schemaforms/rendering/__init__.pyβ Shared rendering module namespace.pydantic_schemaforms/rendering/context.pyβ Render context objects passed through renderers/layouts.pydantic_schemaforms/rendering/field_renderer.pyβ Field-level rendering logic used by multiple renderers.pydantic_schemaforms/rendering/form_style.pyβFormStylecontract/registry that centralizes framework templates.pydantic_schemaforms/rendering/frameworks.pyβ Framework configuration and input component mapping lookup.pydantic_schemaforms/rendering/layout_engine.pyβ Layout primitives and the engine that renders composed layouts.pydantic_schemaforms/rendering/schema_parser.pyβ Schema parsing/metadata extraction (pydantic model β render plan).pydantic_schemaforms/rendering/theme_assets.pyβ Default CSS/JS snippets for layout-oriented components.pydantic_schemaforms/rendering/themes.pyβ Theme strategies and framework themes (bootstrap/material/plain + embedded variants).
## Beta Release Readiness Assessment
### β Product Vision Alignment
All six Design Rules (Non-Negotiables) are now fully satisfied:
-
Library ships the experience β
- Default output is offline-by-default (vendored HTMX, IMask, Bootstrap, Materialize)
- CDN mode exists but is explicit opt-in and pinned to manifest versions
- All assets are checksummed and verified by tests
-
Pydantic is the single source of truth β
- Validation constraints, required/optional come from Pydantic schema/Field metadata
- UI configuration via
json_schema_extraand form field helpers - Schema generation sanitizes non-serializable objects
-
One obvious way (sync + async) β
- Canonical sync entry point:
handle_form() - Canonical async entry point:
handle_form_async() - Both exported from root package with integration test coverage
- Canonical sync entry point:
-
Renderer outputs deterministic, self-contained HTML β
- No global mutable state in renderer pipeline
- Deterministic output for same model + config
- Theme/style configuration is explicit
-
Debug mode is optional and non-invasive β
- Off by default (
debug=False) - Collapsed panel when enabled, never changes validation/rendering semantics
- Read-only views of: rendered HTML, model source, schema/validation, live payload
- Off by default (
-
Extensibility stays declarative β
- Plugin registration via official registries (
register_input_class,register_layout_renderer) - Extension points compose with themes/styles
- Documented in
docs/plugin_hooks.md
- Plugin registration via official registries (
### β Core Features Complete
Rendering Pipeline: - β Enhanced renderer with schema β fields β layout orchestration - β Theme-driven styling (Bootstrap, Material Design, Plain, Default) - β Field-level rendering with help/error chrome - β Layout engine (Vertical, Horizontal, Tabbed, Accordion) - β Model list support with add/remove functionality - β Async rendering equivalence to sync path
Validation: - β Server-side validation via Pydantic - β HTMX live validation support - β Custom field validators - β Cross-field validation patterns - β Comprehensive validation guide documentation
Integration: - β Flask integration helpers - β FastAPI integration helpers (async-first) - β Framework-agnostic sync/async adapters - β Working examples for both frameworks
Developer Experience: - β Debug mode with live payload inspection - β Comprehensive test suite (250+ tests passing) - β Documentation (quickstart, tutorial, validation guide, assets guide, plugin hooks, testing workflow) - β Ruff linting + pre-commit hooks - β CI/CD pipeline with coverage reporting
### β Quality Metrics
- Test Coverage: 250+ tests passing (see
htmlcov/for detailed report) - Linting: Ruff enabled in pre-commit, zero linting errors
- Python Version: 3.14+ only (clearly documented)
- Dependencies: Minimal (pydantic>=2.7, pydantic-extra-types[all]>=2.10.6)
- Optional Dependencies: FastAPI and Flask marked as optional
### β οΈ Known Limitations (Acceptable for Beta)
-
Dynamic field validation warning: Pydantic emits a
UserWarningwhen usingFormModel.register_field()due tocreate_model()behavior. This is cosmetic and doesn't affect functionality. Can be suppressed or improved in future releases. -
Material Design assets: Currently using Materialize CSS (older). Could be upgraded to Material Web Components or MUI in a future release, but current implementation is functional.
-
Documentation completeness: Core features are documented, but some advanced patterns (custom input components, complex layouts) could benefit from additional examples.
### π Release Checklist
- β All design rules satisfied
- β Core features complete and tested
- β Debug mode implemented
- β Examples working (Flask + FastAPI)
- β Documentation covers essential workflows
- β Test suite passing (250+ tests)
- β Linting clean
- β
Version marked as beta in
pyproject.toml(25.11.3.beta) - β README.md indicates beta status
- β³ CHANGELOG.md updates (should document all changes since last release)
- β³ Release notes prepared (features, breaking changes, migration guide)
### π― Recommendation
pydantic-schemaforms is ready for beta release with the following considerations:
- Update CHANGELOG.md to document all changes, new features, and breaking changes since the last release
- Prepare release notes highlighting:
- Debug mode as a major new feature
- Offline-by-default asset strategy
- Theme-driven rendering system
- Plugin extensibility
- Python 3.14+ requirement (breaking change if upgrading from older versions)
- Tag the release as
v25.11.3-beta(or use current version scheme) - Publish to PyPI with beta classifier
- Announce the beta in relevant communities (Reddit r/Python, Python Discord, etc.) and request feedback
The library has strong architectural foundations, clear design principles, comprehensive test coverage, and working examples. The beta period should focus on: - Gathering user feedback on API ergonomics - Identifying edge cases in real-world usage - Polishing documentation based on user questions - Building community examples/templates
Next Actions After Beta Release: - Monitor issue tracker for bug reports and feature requests - Gather feedback on debug mode usability - Consider Material Web Components migration for v2.0 - Expand documentation with more advanced patterns - Build gallery of community examples
About¶
pydantic-schemaforms is a Python library for generating server-rendered HTML forms from Pydantic models.
It focuses on:
- A simple model β schema β HTML pipeline
- First-class sync (WSGI) and async (ASGI) integration helpers
- Offline-by-default asset delivery (vendored/inlined assets), with explicit opt-in for CDNs
If youβre new, start here:
Project links:
- Docs: https://devsetgo.github.io/pydantic-schemaforms/
- GitHub: https://github.com/devsetgo/pydantic-schemaforms
- PyPI: https://pypi.org/project/pydantic-schemaforms/
About Me¶
I am a software engineering manager with an eclectic background in various industries (finance, manufacturing, and metrology). I am passionate about software development and love to learn new things.
Changelog¶
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog.
Versions follow CalVer: YY.Quarter.Build β e.g. 26.2.1 = year 2026, quarter 2, build 1.
Beta releases append .beta (e.g. 26.2.1.beta); production releases have no suffix.
[26.2.3] β 2026-06-28¶
Fixed¶
-
LiveValidator.register_model_validatorpartial-validation bug β previously calledmodel_class.model_validate({field: value})with only one field's data; if the model had other required fields, Pydantic raisedValidationErrorfor them, causing all unrelated fields to produceis_valid=Falsewith no errors even when the value was valid. Now uses__pydantic_validator__.validate_assignment(model_construct(), field_name, value)to validate each field in isolation, independent of sibling required fields. -
LiveValidator.render_field_with_live_validationsingle-trigger limitation β theif/elif/elifchain forvalidate_on_blur/validate_on_input/validate_on_changesilently dropped every trigger after the first truthy one. Changed to independentifchecks so all enabled triggers combine into a singlehx-triggerattribute (e.g.hx-trigger="blur, change"when both areTrue).
Added¶
- Live-validation example β
GET /live-validationdemo route in the FastAPI example app shows a contact form with per-field HTMX validation on blur. IncludesPOST /validate/{field_name}HTMX endpoint usingvalidation_response_headers()andGET /vendor/htmx.min.jsto serve the vendored HTMX asset.
[26.2.2] β 2026-06-28¶
Added¶
FormModel.as_api_model()β new class method that returns a pure PydanticBaseModelwithui_*rendering metadata stripped fromjson_schema_extra. Allows the same model class to serve both as an HTML form and as a typed FastAPI body /response_modelwithoutui_element,ui_placeholder, etc. appearing in the OpenAPI/Swagger docs.Field(title=...),Field(examples=[...]), descriptions, and all validation constraints (min_length,ge,pattern, β¦) are fully preserved. Result is cached per subclass and safe to assign at module level (ContactSchema = ContactForm.as_api_model()).
Changed¶
- Reduced SonarCloud code smells: extracted duplicate magic strings to named constants,
prefixed unused parameters with
_, renamed methods that shadowed class attributes, and merged nestedifstatements into compound conditions across multiple modules.
[26.2.1] β 2026-05-17¶
First production release. All public Quick Start code paths verified working end-to-end.
Breaking Changes¶
submit_urlis now explicit across public form render APIs and no longer defaults silently. Calls that omitsubmit_urlraise a clearValueError, enforcing app-owned routing boundaries.
Added¶
- HTMX live validation β
LiveValidator,HTMXValidationConfig, andvalidation_response_headers()provide server-side field validation triggered by blur/input events via HTMX without page reloads. - Branch coverage β
branch = trueadded to coverage configuration for deeper test quality. - HTML structural validation β
tests/test_html_structural.pyvalidates tag balance across all frameworks and field types. - HTTP integration tests β FastAPI
TestClientand Flasktest_clientround-trip tests. - Property-based tests β Hypothesis suite covering arbitrary field names, labels, and frameworks.
- Performance benchmarks β
pytest-benchmarksuite for small, medium, and large form rendering. - Playwright browser tests β End-to-end HTMX validation tests against a live FastAPI server
(
tests/playwright/), running in CI on Chromium. - macOS in CI matrix β Tests now run on ubuntu-latest, windows-latest, and macos-latest.
py.typedmarker for PEP 561 compatibility.- Vendored assets (htmx, Bootstrap, Materialize, iMask) with sha256 manifest verification.
.gitattributesforcing LF line endings on vendored assets for cross-platform checksum stability.
Fixed¶
create_form_from_modelcrashed with any Pydantic model βAutoFormBuilder._build_from_modelused the wrong parameter name (default_valueinstead ofvalue) and compared against...instead ofPydanticUndefined; the sentinel leaked intojson_schema_extra, causingPydanticSerializationErroron every render call.- Windows path separator in test assertion caused CI failure on win32.
- Dependabot config used unsupported
interval: "cron"β silently ignored, causing weekly runs. Replaced withinterval: "monthly". - HTMX live validation JS used
JSON.parse(responseText)on HTML responses; replaced with the idiomaticHX-Trigger: validationResultheader pattern so CSS classes are applied correctly. except A, B:tuple syntax preserved via# fmt: skipto prevent ruff from removing parentheses that pyright requires.
Changed¶
- Layout rendering internals refactored for maintainability and reduced cognitive complexity.
- Reliability and maintainability improvements to satisfy SonarCloud quality gate.
- Improved nested/collapsible form behavior when multiple forms are rendered on a page.
- Pre-commit ruff version pinned to match
requirements.txtto eliminate local/CI formatting drift. - Pyright added to
make testsso type errors surface before commit.
Dependencies¶
fastapi[all]:0.121.2β0.128.4ruff:0.14.14β0.15.0tqdm:4.67.1β4.67.3pytest-html:4.1.1β4.2.0packaging:25.0β26.0black:25.12.0β26.1.0mkdocstrings[python]:1.0.0β1.0.1pymdown-extensions:10.20β10.20.1
Latest Changes¶
Production Releases¶
America 250 Release: Adding AI Help Instructions and Clean Up. (26.3.1)¶
Changes¶
Happy 250th Birthday USA!!!!
Features¶
- Enhancement: Adding AI Helper Documentation (#177) (@devsetgo)
- Fix: Live Validation (#167) (@devsetgo)
Bug Fixes¶
- Fix: Live Validation (#167) (@devsetgo)
Documentation¶
- Enhancement: Adding AI Helper Documentation (#177) (@devsetgo)
- Documentation Fix (#168) (@devsetgo)
- Fix: Live Validation (#167) (@devsetgo)
- Production Release - Final Edits (#166) (@devsetgo)
GitHub Actions¶
- Enhancement: Adding AI Helper Documentation (#177) (@devsetgo)
- Documentation Fix (#168) (@devsetgo)
Maintenance¶
- Enhancement: Adding AI Helper Documentation (#177) (@devsetgo)
- Fix: Live Validation (#167) (@devsetgo)
- Production Release - Final Edits (#166) (@devsetgo)
Contributors¶
@devsetgo
Published Date: 2026 July 03, 23:12
Live Validation Fix, Example, and Doc Updates (26.2.4)¶
Changes¶
Features¶
- Fix: Live Validation (#167) (@devsetgo)
Bug Fixes¶
- Fix: Live Validation (#167) (@devsetgo)
Documentation¶
- Fix: Live Validation (#167) (@devsetgo)
Maintenance¶
- Fix: Live Validation (#167) (@devsetgo)
Contributors¶
@devsetgo
Published Date: 2026 June 28, 14:32
CSRF Feature Release (26.2.1.beta)¶
Changes¶
- Documentation Updates (#116) (@devsetgo)
Features¶
- bump to v26.2.1.beta (#117) (@devsetgo)
- CSRF Feature (#114) (@devsetgo)
Maintenance¶
- feat(csrf): add CSRFMode enum with backward-compatible mode handling (#115) (@devsetgo)
- pip(deps): bump tox from 4.52.0 to 4.52.1 (#104) (@dependabot[bot])
- pip(deps): bump python-multipart from 0.0.24 to 0.0.26 (#106) (@dependabot[bot])
- github-actions(deps): bump actions/upload-pages-artifact from 4 to 5 (#107) (@dependabot[bot])
- pip(deps): bump mike from 2.1.4 to 2.2.0 (#108) (@dependabot[bot])
- pip(deps): bump fastapi from 0.134.0 to 0.135.3 (#97) (@dependabot[bot])
- pip(deps): bump pytest-cov from 7.0.0 to 7.1.0 (#98) (@dependabot[bot])
- pip(deps): bump pydantic-extra-types from 2.11.1 to 2.11.2 (#99) (@dependabot[bot])
- pip(deps): bump python-multipart from 0.0.22 to 0.0.24 (#100) (@dependabot[bot])
- pip(deps): bump pytest from 9.0.1 to 9.0.3 (#101) (@dependabot[bot])
- github-actions(deps): bump actions/github-script from 7 to 9 (#102) (@dependabot[bot])
- github-actions(deps): bump release-drafter/release-drafter from 6.2.0 to 7.2.0 (#103) (@dependabot[bot])
- dependency update and tests (#96) (@devsetgo)
- update of dependencies (#87) (@devsetgo)
- pip(deps): bump hatchling from 1.28.0 to 1.29.0 (#73) (@dependabot[bot])
- pip(deps): bump tox from 4.44.0 to 4.46.3 (#74) (@dependabot[bot])
- pip(deps): bump fastapi[all] from 0.128.4 to 0.134.0 (#75) (@dependabot[bot])
- pip(deps): bump mkdocs-material from 9.7.2 to 9.7.3 (#76) (@dependabot[bot])
- pip(deps): bump ruff from 0.15.1 to 0.15.4 (#77) (@dependabot[bot])
Contributors¶
@dependabot[bot], @devsetgo and dependabot[bot]
Published Date: 2026 April 26, 19:26
Removing External Validation Logic (26.1.8.beta)¶
Changes¶
Bug Fixes¶
- Removing validation logic outside of library. (#71) (@devsetgo)
Contributors¶
@devsetgo
Published Date: 2026 February 27, 21:32
Bug fix and cleanup. (26.1.7.beta)¶
Changes¶
- Move of examples (#69) (@devsetgo)
Bug Fixes¶
- fix(layouts): validate nested layout submissions and restore Preferen⦠(#70) (@devsetgo)
Contributors¶
@devsetgo
Published Date: 2026 February 22, 19:56
Many fixes, enhancements, and increasing code coverage (26.1.6.beta)¶
Changes¶
- bumping version to 26.1.6.beta (#68) (@devsetgo)
- feat(examples,docs): add plain HTML style support and document render⦠(#67) (@devsetgo)
- improving documents (#59) (@devsetgo)
- improving test coverage (#58) (@devsetgo)
- improving reliability rating (#57) (@devsetgo)
- working on sonarcloud issue (#41) (@devsetgo)
- fixing examples (#33) (@devsetgo)
Features¶
- working on example. (#65) (@devsetgo)
- Improvements - docs, examples, and bug fixes (#43) (@devsetgo)
- 34 add timing (#42) (@devsetgo)
- adding start line and end line for HTML sent to browser (#32) (@devsetgo)
Bug Fixes¶
- working on material design render issues (#66) (@devsetgo)
- working on example. (#65) (@devsetgo)
- Improvements - docs, examples, and bug fixes (#43) (@devsetgo)
Maintenance¶
- pip(deps): bump tox from 4.36.0 to 4.44.0 (#60) (@dependabot[bot])
- pip(deps): bump mkdocs-material from 9.7.1 to 9.7.2 (#61) (@dependabot[bot])
- pip(deps): bump isort from 7.0.0 to 8.0.0 (#62) (@dependabot[bot])
- pip(deps): bump flask from 3.1.2 to 3.1.3 (#63) (@dependabot[bot])
- pip(deps): bump autoflake from 2.3.1 to 2.3.3 (#64) (@dependabot[bot])
- pip(deps): bump pytest-html from 4.1.1 to 4.2.0 (#45) (@dependabot[bot])
- pip(deps): bump ruff from 0.14.14 to 0.15.0 (#44) (@dependabot[bot])
- pip(deps): bump tqdm from 4.67.1 to 4.67.3 (#47) (@dependabot[bot])
- pip(deps): bump fastapi[all] from 0.121.2 to 0.128.4 (#48) (@dependabot[bot])
- pip(deps): bump mkdocstrings[python] from 1.0.0 to 1.0.1 (#39) (@dependabot[bot])
- pip(deps): bump pymdown-extensions from 10.20 to 10.20.1 (#36) (@dependabot[bot])
- github-actions(deps): bump release-drafter/release-drafter from 6.1.0 to 6.2.0 (#35) (@dependabot[bot])
- pip(deps): bump black from 25.12.0 to 26.1.0 (#37) (@dependabot[bot])
- pip(deps): bump ruff from 0.14.11 to 0.14.14 (#38) (@dependabot[bot])
- pip(deps): bump packaging from 25.0 to 26.0 (#40) (@dependabot[bot])
Contributors¶
@dependabot[bot], @devsetgo and dependabot[bot]
Published Date: 2026 February 22, 16:35
Bug fixes and Improvements (26.1.2.beta)¶
Changes¶
- Publishing Improvements (#17) (@devsetgo)
- Doc (#16) (@devsetgo)
- GitHub Actions Improvements (#10) (@devsetgo)
- first release (#9) (@devsetgo)
- working on documentation (#5) (@devsetgo)
- working on coverage (#4) (@devsetgo)
Features¶
- Improving Workflow (#1) (@devsetgo)
Bug Fixes¶
- 18 bootstrap not included in self contained example (#24) (@devsetgo)
- Fix model-list delete for dynamically added items (#23) (@devsetgo)
- working on coverage issue (#3) (@devsetgo)
- working on publishing issue (#2) (@devsetgo)
Maintenance¶
- github-actions(deps): bump actions/upload-pages-artifact from 3 to 4 (#19) (@dependabot[bot])
- updating release drafter (#11) (@devsetgo)
- Pre-Release Checks (#6) (@devsetgo)
Contributors¶
@dependabot[bot], @devsetgo and dependabot[bot]
Published Date: 2026 January 09, 21:46
Pre-releases¶
Initial Beta Release (26.1.1.beta)¶
Changes¶
- GitHub Actions Improvements (#10) (@devsetgo)
- first release (#9) (@devsetgo)
- working on documentation (#5) (@devsetgo)
- working on coverage (#4) (@devsetgo)
Features¶
- Improving Workflow (#1) (@devsetgo)
Bug Fixes¶
- working on coverage issue (#3) (@devsetgo)
- working on publishing issue (#2) (@devsetgo)
Maintenance¶
- updating release drafter (#11) (@devsetgo)
- Pre-Release Checks (#6) (@devsetgo)
Contributors¶
@devsetgo
Published Date: 2026 January 02, 19:13