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 © 2024 - 2025 Mike Ryan |
Table of Contents
Pydantic SchemaForms¶
Support Python Versions
SonarCloud:
Note: This project should be considered in beta as it is actively under development and may have breaking changes.
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, Tailwind CSS, and custom frameworks
- β
Built-in Validation: Client-side HTML5 + server-side Pydantic validation
- π§ React JSON Schema Forms Compatible: Uses familiar ui_element, ui_autofocus, ui_options syntax
- π± Responsive & Accessible: Mobile-first design with full ARIA support
- π Framework Ready: First-class Flask and FastAPI helpers, plus plain HTML for other stacks
Documentation¶
- Docs site: https://devsetgo.github.io/pydantic-schemaforms/
- Source: https://github.com/devsetgo/pydantic-schemaforms
Requirements¶
- Python 3.14+
- Pydantic 2.7+
Quick Start¶
Install¶
pip install pydantic-schemaforms
FastAPI (async / ASGI)¶
This is the recommended pattern for FastAPI: build a form once per request and use handle_form_async().
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, EmailStr
from pydantic_schemaforms import create_form_from_model, handle_form_async
class User(BaseModel):
name: str
email: EmailStr
app = FastAPI()
@app.api_route("/user", methods=["GET", "POST"], response_class=HTMLResponse)
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 f"Saved: {result['data']}"
return result["form_html"]
result = await handle_form_async(builder)
return result["form_html"]
Run it:
pip install "pydantic-schemaforms[fastapi]" uvicorn
uvicorn main:app --reload
Flask (sync / WSGI)¶
In synchronous apps (Flask), use handle_form().
from flask import Flask, request
from pydantic import BaseModel, EmailStr
from pydantic_schemaforms import create_form_from_model, handle_form
class User(BaseModel):
name: str
email: EmailStr
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"]
React JSON Schema Forms Compatibility¶
The library supports a React JSON Schema Forms-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")
Material Design¶
UserForm.render_form(framework="material", submit_url="/submit")
Plain HTML¶
UserForm.render_form(framework="none", submit_url="/submit")
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:
# Handle validation errors
errors = e.errors()
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:
errors = e.errors()
# 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)
Examples in This Repository¶
The repository includes several complete examples:
example_usage.py- React JSON Schema Forms compatible examplespydantic_example.py- Flask integration with multiple form typessimple_example.py- Basic usage without frameworksexample.py- Low-level UI components demonstration
Run any example:
python example_usage.py # http://localhost:5000
python pydantic_example.py # http://localhost:5001
python example.py # http://localhost:5002
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")
@classmethod
def render_form(cls, framework="bootstrap", submit_url="/submit", **kwargs):
"""Render complete HTML form"""
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 (Materialize CSS)"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 the one recommended way to integrate pydantic-schemaforms into an app:
- Build a
FormBuilder(often viacreate_form_from_model()) - Use exactly one handler per runtime:
- Sync:
handle_form() - Async:
handle_form_async()
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) 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"]
3) 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"]
Notes¶
handle_form*()returns either{form_html}(initial render) or{success: bool, ...}(submission).- Asset delivery (
asset_mode) and full-page wrappers are documented indocs/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 also explains when to use the sync handler (handle_form) vs the async handler (handle_form_async).
Prerequisites¶
- Python 3.14+
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 import BaseModel, EmailStr
from pydantic_schemaforms import create_form_from_model, handle_form_async
class User(BaseModel):
name: str
email: EmailStr
app = FastAPI(title="SchemaForms Demo")
@app.api_route("/user", methods=["GET", "POST"], response_class=HTMLResponse)
async def user_form(request: Request):
# Build a builder from your model (choose a framework theme).
builder = create_form_from_model(User, framework="bootstrap")
if request.method == "POST":
# FastAPI form parsing is async.
form = await request.form()
# Validate + render response.
result = await handle_form_async(builder, submitted_data=dict(form))
# On success, result contains parsed/validated data.
if result.get("success"):
return f"Saved: {result['data']}"
# On failure, you typically re-render the form (with errors).
return result["form_html"]
# Initial render.
result = await handle_form_async(builder)
return result["form_html"]
4) Run the server¶
uvicorn main:app --reload
Open http://127.0.0.1:8000/user
Sync vs Async (whatβs the difference?)¶
handle_form() (sync)¶
Use handle_form(builder, ...) 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 import BaseModel, EmailStr
from pydantic_schemaforms import create_form_from_model, handle_form
class User(BaseModel):
name: str
email: EmailStr
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"]
handle_form_async() (async)¶
Use handle_form_async(builder, ...) 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 handle_form_async().
If you already have a dict of submitted data (for example from a different parsing path), you can still call handle_form() inside an async def route β but the moment you need to await request parsing, youβll generally prefer the async handler for consistency.
Next steps¶
- Learn about asset delivery (
asset_mode) indocs/assets.md - See the broader integration pattern in
docs/quickstart.md
Pydantic SchemaForms Tutorial: Your First Dynamic Web Form¶
Welcome to Pydantic SchemaForms! This tutorial will guide you through creating your first dynamic web form from scratch. We'll explain every step in detail, so don't worry if you're new to web forms or Python web development.
What You'll Learn¶
By the end of this tutorial, you'll understand: - How to create a basic web form using Pydantic SchemaForms - What each line of code does and why it's important - How forms work in web applications - How to handle user input safely
What You Need Before Starting¶
Before we begin, make sure you have: - Python 3.14 or newer installed on your computer - Basic Python knowledge (variables, functions, imports) - A text editor (VS Code, PyCharm, or even Notepad++) - 5-10 minutes of your time
You don't need to know Flask or web development - we'll explain everything!
Step 1: Understanding Web Forms¶
Before we write code, let's understand what we're building. A web form is like a digital questionnaire that: 1. Shows input fields to users (text boxes, buttons, etc.) 2. Collects information when users type or click 3. Sends data to your Python program when submitted 4. Processes the data (save to database, send email, etc.)
Think of it like a restaurant order form - customers fill it out, and the kitchen receives the order details.
Step 2: Install Pydantic SchemaForms¶
First, we need to install the required packages. Open your terminal or command prompt and run:
pip install Flask pydantic-schemaforms
What this does: - Flask: A web framework that handles web requests and responses - pydantic-schemaforms: Our library that makes creating forms super easy
Step 3: Create Your First File¶
Create a new file called my_first_form.py and save it in a folder on your computer. This will contain all our code.
Step 4: Build Your First Form (Line by Line)¶
Copy this code into your my_first_form.py file. We'll explain every single line:
# Import the tools we need
from flask import Flask, render_template_string, request
from pydantic_schemaforms import FormBuilder
# Create a Flask web application
app = Flask(__name__)
# Define what happens when someone visits our website
@app.route("/", methods=["GET", "POST"])
def hello_form():
# Check if someone submitted the form
if request.method == "POST":
# Get the name they typed and display it
user_name = request.form['name']
return f"<h1>Hello {user_name}! Nice to meet you!</h1>"
# If they haven't submitted yet, show the form
# Build a simple form with one text input
form = FormBuilder().text_input("name", "What's your name?").render()
# Create a complete HTML page with our form
html_page = """
<!DOCTYPE html>
<html>
<head>
<title>My First Form</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-5">
<h1>Welcome to My First Form!</h1>
<p>Please tell us your name:</p>
{{ form | safe }}
</div>
</body>
</html>
"""
return render_template_string(html_page, form=form)
# Start the web server
if __name__ == "__main__":
app.run(debug=True)
Let's break down what each part does:
Line 1-2: Import Statements¶
from flask import Flask, render_template_string, request
from pydantic_schemaforms import FormBuilder
Flask: Creates our web application
- render_template_string: Converts our HTML template into a webpage
- request: Handles data coming from the form
- FormBuilder: Our magic tool for creating forms easily
Line 4-5: Create the Web App¶
app = Flask(__name__)
Line 7-8: Define the Route¶
@app.route("/", methods=["GET", "POST"])
def hello_form():
@app.route("/"): Says "when someone visits the main page of our website, run this function"
- methods=["GET", "POST"]: Allows both viewing the page (GET) and submitting forms (POST)
- def hello_form():: Creates a function that handles both showing and processing our form
Line 9-12: Handle Form Submission¶
if request.method == "POST":
user_name = request.form['name']
return f"<h1>Hello {user_name}! Nice to meet you!</h1>"
if request.method == "POST":: Checks if someone just submitted the form
- user_name = request.form['name']: Gets the text they typed in the "name" field
- return f"<h1>Hello {user_name}!...: Shows a personalized greeting with their name
Line 14-16: Create the Form¶
form = FormBuilder().text_input("name", "What's your name?").render()
FormBuilder(): Creates a new form builder (like getting a blank form template)
- .text_input("name", "What's your name?"): Adds a text input field
- "name": The internal name for this field (how we'll reference it later)
- "What's your name?": The label users will see
- .render(): Converts our form description into actual HTML code
Line 18-32: Create the Webpage¶
html_page = """
<!DOCTYPE html>
<html>
...
{{ form | safe }}
...
"""
{{ form | safe }}: Inserts our form into the page
- The Bootstrap CSS makes everything look modern and professional
Line 34: Return the Page¶
return render_template_string(html_page, form=form)
Line 36-38: Start the Server¶
if __name__ == "__main__":
app.run(debug=True)
debug=True: Shows helpful error messages if something goes wrong
Step 5: Run Your Form¶
- Save your
my_first_form.pyfile - Open terminal/command prompt in the same folder
- Run:
python my_first_form.py - You'll see output like:
Running on http://127.0.0.1:5000 - Open your web browser and go to:
http://127.0.0.1:5000
Congratulations! You just created your first web form! π
Step 6: Test Your Form¶
- View the form: You should see a text input asking for your name
- Type your name: Enter your name in the text field
- Submit: Click the submit button
- See the result: You should see a personalized greeting!
What Just Happened?¶
When you submitted the form:
1. Your browser sent your name to your Python program
2. Your program received it in the request.form['name'] variable
3. Your program created a new webpage with your name in it
4. Your browser displayed the greeting
This is the basic cycle of all web forms!
Step 7: Understanding the FormBuilder Magic¶
The real magic happens in this line:
form = FormBuilder().text_input("name", "What's your name?").render()
Behind the scenes, this creates HTML like:
<form method="POST">
<div class="mb-3">
<label for="name" class="form-label">What's your name?</label>
<input type="text" class="form-control" id="name" name="name">
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
But you don't have to write all that HTML yourself - Pydantic SchemaForms does it for you!
Step 8: Add More Fields (Optional Challenge)¶
Try modifying your form to ask for more information:
form = (FormBuilder()
.text_input("name", "What's your name?")
.text_input("city", "What city are you from?")
.number_input("age", "How old are you?")
.render())
Then update your greeting to use all the information:
if request.method == "POST":
name = request.form['name']
city = request.form['city']
age = request.form['age']
return f"<h1>Hello {name}!</h1><p>It's nice to meet someone from {city} who is {age} years old!</p>"
Bonus: Compose Layouts with LayoutComposer¶
Once you are comfortable rendering a single form, you can arrange multiple snippets with the LayoutComposer API. This is the single public entry point for layout primitives and it lives next to the renderer internals.
from pydantic_schemaforms import FormBuilder
from pydantic_schemaforms.rendering.layout_engine import LayoutComposer
contact_form = (FormBuilder()
.text_input("name", "What's your name?")
.email_input("email", "Where can we reach you?")
.render())
profile_card = LayoutComposer.card("Profile", contact_form)
settings_card = LayoutComposer.card("Settings", "<p>Coming soon...</p>")
two_column_layout = LayoutComposer.horizontal(
profile_card,
settings_card,
gap="2rem",
justify_content="space-between",
)
html = two_column_layout.render()
Every helper inside LayoutComposer returns a BaseLayout subclass, so you can freely nest them (e.g., a vertical stack of cards that contain grids). The legacy pydantic_schemaforms.layouts and pydantic_schemaforms.form_layouts modules now emit DeprecationWarnings and simply re-export this API for backward compatibility.
Theme Hooks for Tabs, Accordions, and Model Lists¶
The renderers no longer embed framework-specific HTML in random places. Instead, RendererTheme exposes hook methods so you can replace the shared assets in one spot:
tab_component_assets()andaccordion_component_assets()return the CSS/JS that power tab/accordion interactions. The default implementation ships with Bootstrap-flavored styling, whileMaterialEmbeddedThemeoverrides both to emit Material Design tokens.render_layout_section()controls how layout cards/tabs are wrapped, replacing the inlineCardLayoutmarkup when a theme wants its own chrome.render_model_list_container()owns the wrapper for schema-driven and class-basedModelListRendererinstances (labels, help/error text, add buttons, etc.). Bootstrap/Material both call through this hook now, so future frameworks only need to provide a themeβnot duplicate renderer code.render_model_list_item()owns the per-item chrome (card header, remove buttons, data attributes) for both schema-driven and imperative model lists. The renderer builds the inner field grid and hands the HTML off to this hook so your theme fully owns the markup users interact with.
Creating a custom theme is straightforward:
from pydantic_schemaforms.enhanced_renderer import EnhancedFormRenderer
from pydantic_schemaforms.rendering.themes import RendererTheme
class ShadcnTheme(RendererTheme):
name = "shadcn"
def tab_component_assets(self) -> str:
return """<script>/* shadcn tab switching */</script><style>.tab-button{font-family:var(--font-sans);}</style>"""
def render_model_list_container(self, **kwargs) -> str:
items_html = kwargs["items_html"] or ""
return f"""
<section class="shadcn-card">
<div class="shadcn-card__header">
<h3>{kwargs['label']}</h3>
</div>
<div class="shadcn-card__content">{items_html}</div>
<div class="shadcn-card__footer">
<button class="btn" data-target="{kwargs['field_name']}">
{kwargs['add_button_label']}
</button>
</div>
</section>
"""
def render_model_list_item(self, **kwargs) -> str:
body_html = kwargs["body_html"]
label = kwargs["model_label"]
index = kwargs["index"] + 1
return f"""
<article class="shadcn-model-item" data-index="{index}">
<header class="shadcn-model-item__header">
<h4>{label} #{index}</h4>
<button type="button" class="ghost-btn remove-item-btn" data-index="{kwargs['index']}">
Remove
</button>
</header>
<div class="shadcn-model-item__body">{body_html}</div>
</article>
"""
# Inject the theme while rendering
renderer = EnhancedFormRenderer(theme=ShadcnTheme())
html = renderer.render_form_from_model(MyForm)
Because both FieldRenderer and ModelListRenderer read from the active theme first, this one class controls the chrome for schema-derived fields, nested layouts, and repeatable models. Tests should assert for the presence of your wrapper classes (e.g., .shadcn-card) to verify the integration.
Runtime Fields and New UI Elements¶
Need to add fields after a form model is defined? Call FormModel.register_field() to describe the type and UI metadata at runtime. The helper keeps the renderer, the validation stack, and the live schema in sync:
from pydantic_schemaforms.schema_form import Field, FormModel
class ProfileForm(FormModel):
pass
ProfileForm.register_field(
"nickname",
annotation=str,
field=Field(..., ui_element="text", min_length=3),
)
ProfileForm.register_field(
"terms_accepted",
annotation=bool,
field=Field(False, ui_element="toggle"),
)
register_field stores the new FieldInfo, rebuilds the runtime validator, and clears the schema cache, so EnhancedFormRenderer, validate_form_data, and the HTMX live validator all see the same set of fields. If you prefer to continue using setattr(MyForm, name, Field(...)), the renderer still picks up the new entries, but validation will only engage when the helper is used.
Two new ui_element identifiers ship with this release:
"toggle"β renders theToggleSwitchwrapper and maps to a checkbox value server-side."combobox"β renders the enhanced combo-box (text input backed by a datalist) so users can search or pick from known options.
These map directly to the corresponding input components, so you can reference them in Field(..., ui_element="toggle") or inside ui_options blocks without writing custom renderer glue.
What You've Learned¶
π― You now know how to: - Create a web application with Flask - Build forms using Pydantic SchemaForms' FormBuilder - Handle form submissions in Python - Display dynamic content based on user input
π§ Key concepts you understand: - Form fields: Different types of inputs (text, number, etc.) - Form submission: How data travels from browser to Python - Request handling: How to process incoming form data - Template rendering: How to create dynamic HTML pages
What's Next?¶
Now that you understand the basics, you can: - Add validation to make sure users enter valid data - Use different input types like email, password, or dropdown menus - Style your forms with different CSS frameworks - Connect to databases to save form data permanently
Ready to dive deeper? Check out:
Troubleshooting¶
Form not showing? - Make sure you saved the file - Check that you're visiting the right URL (http://127.0.0.1:5000)
Errors when running? - Make sure you installed Flask and pydantic-schemaforms - Check that your Python indentation is correct
Form submits but no greeting?
- Make sure the field name in request.form['name'] matches the field name in your FormBuilder
Great job completing your first Pydantic SchemaForms tutorial!
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.
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¶
Legacy wrapper: render_form_html()¶
File: pydantic_schemaforms/render_form.py
asset_mode="vendored" | "cdn" | "none"- HTMX is included by default (vendored inline) because this wrapper historically assumed HTMX.
- IMask is available but not injected unless requested.
Example:
from pydantic_schemaforms.render_form import render_form_html
html = render_form_html(
MyForm,
framework="bootstrap",
asset_mode="vendored",
include_imask=True, # enable when you use masked inputs
)
Enhanced renderer: EnhancedFormRenderer¶
File: pydantic_schemaforms/enhanced_renderer.py
include_framework_assets: whether the renderer should include framework CSS/JS.asset_mode: controls whether those assets are vendored inline or pinned CDN URLs.
Example:
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)
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,
title="Signup",
include_framework_assets=True,
asset_mode="vendored",
)
Whatβs currently vendored¶
- HTMX
- IMask
- Bootstrap (CSS + bundle JS)
- Materialize (CSS + JS)
See pydantic_schemaforms/assets/vendor/vendor_manifest.json for exact versions and file paths.
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-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".
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 validate_form_data()¶
For simple synchronous validation against a Pydantic FormModel:
from pydantic_schemaforms import FormModel, FormField, validate_form_data
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 incoming form data
result = validate_form_data(RegistrationForm, {
"username": "alice",
"email": "alice@example.com",
"password": "SecurePass123!"
})
if result.is_valid:
print(f"Valid! Data: {result.data}")
else:
print(f"Invalid! Errors: {result.errors}")
# Result has: result.is_valid, result.data, result.errors
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/change events:
from pydantic_schemaforms.live_validation import LiveValidator, HTMXValidationConfig
from pydantic_schemaforms.validation import FieldValidator, EmailRule
# Configure HTMX behavior
config = HTMXValidationConfig(
validate_on_blur=True, # Validate when field loses focus
validate_on_input=False, # Don't validate on every keystroke
validate_on_change=True, # Validate when value changes
debounce_ms=300, # Wait 300ms before validation request
show_success_indicators=True, # Visual feedback on valid input
show_warnings=True, # Display warnings
show_suggestions=True, # Show helpful hints
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_validator = FieldValidator("email")
email_validator.add_rule(EmailRule())
live_validator.register_field_validator(email_validator)
password_validator = FieldValidator("password")
password_validator.add_rule(LengthRule(min=8))
live_validator.register_field_validator(password_validator)
HTML Integration with HTMX¶
In your template, set up HTMX triggers for real-time validation:
<!-- 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 delay:300ms"
hx-target="#email-feedback"
hx-swap="outerHTML"
/>
<!-- Validation feedback container -->
<div id="email-feedback"></div>
FastAPI Endpoint for HTMX Validation¶
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from pydantic_schemaforms.live_validation import LiveValidator
from pydantic_schemaforms.validation import FieldValidator, EmailRule
app = FastAPI()
live_validator = LiveValidator()
# Register validators
email_validator = FieldValidator("email")
email_validator.add_rule(EmailRule())
live_validator.register_field_validator(email_validator)
@app.post("/validate/email", response_class=HTMLResponse)
async def validate_email(request: Request):
data = await request.form()
value = data.get("email", "")
# Get validator for this field
validator = live_validator.get_field_validator("email")
response = validator.validate(value)
# Render feedback HTML
if response.is_valid:
return f"""
<div id="email-feedback" class="valid-feedback">
β Email looks good
</div>
"""
else:
errors_html = "".join([f"<li>{e}</li>" for e in response.errors])
return f"""
<div id="email-feedback" class="invalid-feedback">
<ul>{errors_html}</ul>
</div>
"""
Building LiveValidator from ValidationSchema¶
Automatically convert a schema to HTMX-ready validators:
from pydantic_schemaforms.validation import ValidationSchema, FieldValidator, EmailRule
schema = ValidationSchema()
email_validator = FieldValidator("email")
email_validator.add_rule(EmailRule())
schema.add_field(email_validator)
# Create HTMX live validator from schema
live_validator = schema.build_live_validator()
# Now use live_validator in HTMX endpoints
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 |
LengthRule(min, max) |
String length constraints | 3β20 char username |
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.validation import (
FieldValidator,
EmailRule,
LengthRule,
NumericRangeRule
)
# Email field validator
email_validator = FieldValidator("email")
email_validator.add_rule(RequiredRule("Email is required"))
email_validator.add_rule(EmailRule())
# Username field validator
username_validator = FieldValidator("username")
username_validator.add_rule(RequiredRule("Username is required"))
username_validator.add_rule(LengthRule(min=3, max=20, message="3β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(LengthRule(min=3, max=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.validation import (
FormValidator,
FieldValidator,
EmailRule,
LengthRule,
NumericRangeRule
)
# Create form validator with all rules
form_validator = FormValidator()
# Field validators
form_validator.field("username").add_rule(
LengthRule(min=3, max=20, message="3β20 characters")
)
form_validator.field("email").add_rule(EmailRule())
form_validator.field("password").add_rule(
LengthRule(min=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, Form
from fastapi.responses import HTMLResponse, JSONResponse
from pydantic_schemaforms import render_form, validate_form_data
app = FastAPI()
@app.get("/register")
def show_registration():
form = RegistrationForm()
return render_form(form, framework="bootstrap")
@app.post("/register")
async def handle_registration(request: Request):
# Get form data
form_data = await request.form()
# Server-side validation
result = validate_form_data(RegistrationForm, dict(form_data))
if result.is_valid:
# Process registration
return JSONResponse({
"success": True,
"message": "Registration successful!"
})
else:
# Return form with errors
form = RegistrationForm()
return render_form(
form,
framework="bootstrap",
errors=result.errors
)
# HTMX validation endpoints
@app.post("/validate/username")
async def validate_username(request: Request):
data = await request.form()
value = data.get("username", "")
validator = form_validator.field("username")
response = validator.validate(value)
if response.is_valid:
return HTMLResponse(
f'<div class="valid-feedback">β Available</div>'
)
else:
errors = "".join([f"<li>{e}</li>" for e in response.errors])
return HTMLResponse(
f'<div class="invalid-feedback"><ul>{errors}</ul></div>'
)
@app.post("/validate/email")
async def validate_email(request: Request):
data = await request.form()
value = data.get("email", "")
response = form_validator.field("email").validate(value)
if response.is_valid:
return HTMLResponse(
f'<div class="valid-feedback">β Valid email</div>'
)
else:
errors = "".join([f"<li>{e}</li>" for e in response.errors])
return HTMLResponse(
f'<div class="invalid-feedback"><ul>{errors}</ul></div>'
)
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="outerHTML"
/>
<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="outerHTML"
/>
<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 tests/test_layout_demo_smoke.py smoke test verifies 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_consolidation.py -v
python -m pytest tests/test_layout_demo_smoke.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_consolidation.py β Consolidated validation tests (10 tests)
- tests/test_layout_demo_smoke.py β Layout/tab rendering verification
- examples/fastapi_example.py β Real-world FastAPI integration
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. Pytest (217+ tests covering validation, rendering, async, layouts, integration) 3. Coverage badge generation (summarizes test coverage) 4. Ruff linting (import ordering, style, deprecated patterns) β now enforced via pre-commit
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 pytest...
[217 tests run]
π Generating coverage and test badges...
β¨ Tests complete. Badges updated.
Test Organization¶
Tests are organized in tests/ directory:
| Test File | Purpose | Count |
|---|---|---|
test_layout_demo_smoke.py |
Tab/accordion initial render | 3 |
test_e2e_layouts_async.py |
E2E: structure, integration, async | 14 |
test_plugin_hooks.py |
Input/layout registration | 2 |
test_model_list_integration.py |
Model list rendering & validation | 4 |
test_validation_consolidation.py |
Validation engine unification | 10 |
test_layouts.py |
Layout classes (tabs, accordions, cards) | 35+ |
| ... | Other units, integration, fixtures | 150+ |
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¶
pytest tests/test_validation_consolidation.py -v
pytest tests/test_e2e_layouts_async.py::TestAsyncFormRendering::test_async_render_returns_same_html_as_sync -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
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
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.
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
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.
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) Enhanced and Simple Material share the same orchestration pipeline via the new
RendererThemeabstraction andMaterialEmbeddedTheme, eliminating the duplicated CSS/JS scaffolding that previously lived insimple_material_renderer.py. The Modern renderer now builds a temporaryFormModeland hands off toEnhancedFormRenderer, and the redundantPy314Rendereralias has been removed entirely. Framework-specific assets live inRendererThemestrategies, so there is a single schema walk/layout path regardless of entry point. Files:pydantic_schemaforms/enhanced_renderer.py,pydantic_schemaforms/rendering/themes.py,pydantic_schemaforms/simple_material_renderer.py,pydantic_schemaforms/modern_renderer.py -
Integration helpers mix unrelated responsibilities (Addressed) The synchronous/async adapters now live in
pydantic_schemaforms/integration/frameworks/, leaving the rootintegrationpackage to expose only builder/schema utilities by default. The module uses lazy exports so simply importingpydantic_schemaforms.integrationno longer drags in optional framework glue unless those helpers are actually accessed. Files:pydantic_schemaforms/integration/__init__.py,pydantic_schemaforms/integration/frameworks/,pydantic_schemaforms/integration/builder.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 (Resolved)
ModelListRenderernow delegates both containers and per-item chrome throughRendererThemehooks:render_model_list_container()and the newrender_model_list_item()(with Material/embedded overrides) wrap the renderer-supplied field grid so frameworks own every byte of markup. Bootstrap/Material share the same plumbing, labels/help/errors/add buttons stay in the theme, and tests cover that custom themes can inject their own classes when rendering lists. Files:pydantic_schemaforms/model_list.py,pydantic_schemaforms/rendering/themes.py -
Template engine under-used (Resolved) The new
FormStylecontract (inrendering/form_style.py) extracts all framework-specific markup intoFormStyleTemplatesdataclass with 13 template slots:form_wrapper,tab_layout,tab_button,tab_panel,accordion_layout,accordion_section,layout_section,layout_help,model_list_container,model_list_item,model_list_help,model_list_error, andsubmit_button. 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_*()methods andLayoutEnginetab/accordion layouts all delegate toFormStyle.templateswith graceful fallback to defaults, eliminating inline markup strings and enabling runtime overrides. Tests intest_theme_hooks.py(7 tests) verify custom FormStyle templates drive rendering. 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_theme_hooks.py,tests/test_fastapi_example_smoke.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_workflow.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_consolidation.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 now live only in
integration/adapters.py,integration/sync.py, andintegration/async_support.py. Theintegration.frameworkspackage re-exports those implementations for legacy imports, andFormIntegration.async_integrationwas converted to a@staticmethodso the API is identical in both namespaces. Optional dependencies remain isolated via lazy imports, but there is now exactly one code path for validation + rendering logic. Files:pydantic_schemaforms/integration/__init__.py,pydantic_schemaforms/integration/adapters.py,pydantic_schemaforms/integration/frameworks/* -
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) β Added
tests/test_e2e_layouts_async.pywith 14 tests: unit tests for tab/accordion DOM structure, aria attributes, display state; integration tests forLayoutDemonstrationFormwith nested fields and model lists; async equivalence tests. All passing. - β
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) β Added comprehensive
tests/test_e2e_layouts_async.py(14 tests) covering: unit tests for tab/accordion DOM structure, aria attributes, and display state; integration tests forLayoutDemonstrationFormtab/layout field rendering with nested content and model lists; async tests verifyingrender_form_from_model_async()produces identical HTML to sync path, handles errors gracefully, and supports concurrent rendering. All tests passing. -
β 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β Minimal Material Design renderer implementation.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β React 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/integration/frameworks/ (compat + legacy namespace)
pydantic_schemaforms/integration/frameworks/__init__.pyβ Namespace package for framework adapters.pydantic_schemaforms/integration/frameworks/adapters.pyβ Compatibility shim re-exporting the canonical adapter API.pydantic_schemaforms/integration/frameworks/async_support.pyβ Compatibility shim re-exporting async helpers.pydantic_schemaforms/integration/frameworks/sync.pyβ Compatibility shim re-exporting sync 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