Type-safe HTML templating with Python 3.14 t-strings
Project description
htmpl
Type-safe HTML templating for Python 3.14+ using PEP 750 template strings.
from htmpl import html
from htmpl.elements import div, h1, p, ul, li
async def greeting(name: str, items: list[str]):
return div(
h1(f"Hello, {name}!"),
t"<p>You have <strong>{len(items)}</strong> items:</p>",
ul([li(item) for item in items]),
class_="card",
)
Why htmpl?
- Type-safe — Components are functions with typed parameters. Your IDE catches errors.
- No new syntax — It's just Python. No
{% %}or{{ }}to learn. - Async-native — Coroutines resolve automatically at render time. No await spam.
- Composable — Mix element factories and t-strings freely.
- XSS-safe — All interpolations escaped by default.
- Fast — Cache components with
@cached,@cached_lru, or@cached_ttl.
Installation
pip install htmpl # Core only
pip install htmpl[all] # Everything
Requires Python 3.14+.
Quick Start
Elements
Build HTML with function calls:
from htmpl.elements import div, h1, p, a, ul, li, button
# Simple element
div("Hello") # <div>Hello</div>
# With attributes
div("Hello", class_="greeting", id="main") # <div class="greeting" id="main">Hello</div>
# Nested
div(
h1("Title"),
p("Some text"),
a("Click me", href="/page"),
)
# Lists flatten automatically
ul([li(item) for item in ["one", "two", "three"]])
T-strings
Use Python 3.14 template strings for inline HTML:
from htmpl import html
name = "World"
await html(t"<h1>Hello, {name}!</h1>")
Values are escaped automatically:
user_input = "<script>alert('xss')</script>"
await html(t"<p>{user_input}</p>")
# <p><script>alert('xss')</script></p>
Mix Both
Elements and t-strings compose freely:
def UserCard(user: User):
return article(
t"<header>{user.name}</header>",
p(user.bio),
div(
[Badge(role) for role in user.roles],
class_="badges",
),
)
Async Components
Async functions just work—coroutines resolve at render time:
async def UserProfile(user_id: int):
user = await get_user(user_id) # DB call
posts = await get_posts(user_id)
return section(
h1(user.name),
PostList(posts), # Can be sync or async
)
# No awaits needed when composing
def Dashboard(user_id: int):
return div(
UserProfile(user_id), # Coroutine, resolved at render
Sidebar(),
)
Conditional Rendering
Return None to render nothing:
def AdminBadge(user: User):
if not user.is_admin:
return None
return span("Admin", class_="badge")
# Renders badge only for admins
div(
h1(user.name),
AdminBadge(user), # None disappears cleanly
)
Caching
from htmpl import cached, cached_lru, cached_ttl
@cached # Forever
async def Footer():
return footer(t"<p>© 2025</p>")
@cached_lru(maxsize=100) # LRU eviction
async def UserBadge(role: str):
return span(role, class_="badge")
@cached_ttl(seconds=60) # Expires after 60s
async def GlobalStats():
stats = await fetch_stats()
return div(t"<strong>{stats.users}</strong> users")
FastAPI Integration
from fastapi import FastAPI
from htmpl.fastapi import Router
from htmpl.elements import section, h1, p
app = FastAPI()
router = Router()
@router.get("/")
async def home():
return section(h1("Welcome"), p("Just return Elements."))
@router.get("/user/{name}")
async def user(name: str):
return section(h1(f"Hello, {name}!"))
app.include_router(router)
The Router automatically converts Element, Fragment, SafeHTML, and Template returns to HTML responses.
HTMX Integration
from htmpl.htmx import HX, SearchInput, LazyLoad
# HX attribute builder
hx = HX(post="/api/save", target="#result", swap="innerHTML")
button("Save", **{str(k): v for k, v in hx})
# Or use built-in patterns
SearchInput("q", src="/search", target="#results", debounce=300)
LazyLoad("/api/content", placeholder=div("Loading...", aria_busy="true"))
Forms
Render Pydantic models as forms with automatic HTML5 validation:
from pydantic import BaseModel, Field, EmailStr
from htmpl.fastapi import Router
from htmpl.elements import section, h1
router = Router()
class LoginSchema(BaseModel):
email: EmailStr
password: str = Field(min_length=8)
@router.form("/login", LoginSchema, submit_text="Sign In")
async def login(data: LoginSchema):
# Only called if validation passes
user = await authenticate(data.email, data.password)
return section(h1(f"Welcome, {user.name}!"))
The @router.form decorator:
- GET /login → Renders the form
- POST /login → Validates, re-renders with errors or calls your handler
Generates proper HTML5 validation attributes:
<input type="email" name="email" required />
<input type="password" name="password" required minlength="8" />
Custom Form Layouts
Use the template parameter for full control:
from htmpl.elements import article, h2, form, div, button
def login_template(renderer, values, errors):
return article(
h2("Sign In"),
form(
renderer.inline("email", "password", values=values, errors=errors),
button("Sign In", type="submit"),
action="/login",
),
)
@router.form("/login", LoginSchema, template=login_template)
async def login(data: LoginSchema):
user = await authenticate(data.email, data.password)
return section(h1(f"Welcome, {user.name}!"))
Or use FormRenderer directly for maximum flexibility:
from htmpl.forms import FormRenderer
renderer = FormRenderer(SignupSchema)
form(
renderer.inline("first_name", "last_name", values=values),
renderer.group("Contact", "email", "phone", values=values, errors=errors),
# Manual control
div(
renderer.label_for("password"),
renderer.input("password", class_="custom"),
renderer.error_for("password", errors),
),
button("Submit", type="submit"),
action="/submit",
)
Components Library
Built-in Pico CSS components:
from htmpl import Document, Page, Nav, Card, Form, Field, Button, Alert, Modal, Table, Grid
Page(
"My App",
nav=Nav("Brand", [("Home", "/"), ("About", "/about")]),
children=section(
Card(
p("Card content"),
title="Card Title",
),
),
)
API Reference
Core
| Function | Description |
|---|---|
html(template) |
Process a t-string into SafeHTML |
raw(str) |
Mark string as safe (no escaping) |
attr(name, value) |
Build a safe HTML attribute |
SafeHTML |
Wrapper for pre-escaped content |
Elements
All standard HTML elements as functions:
from htmpl.elements import (
# Layout
div, span, section, article, header, footer, nav, main, aside,
# Text
h1, h2, h3, h4, h5, h6, p, a, strong, em, code, pre,
# Lists
ul, ol, li,
# Tables
table, thead, tbody, tr, th, td,
# Forms
form, label, input_, button, select, option, textarea,
# Media
img, video, audio,
# Other
br, hr, fragment,
)
Attributes use _ suffix for Python keywords: class_, for_, type_.
Forms
| Method | Description |
|---|---|
render() |
Full form with all fields |
render_field(name) |
Single field with label |
input(name) |
Just the input element |
label_for(name) |
Just the label |
error_for(name, errors) |
Error message if present |
fields(*names) |
Multiple fields as list |
inline(*names) |
Fields in grid row |
group(title, *names) |
Fields in fieldset |
Comparison
| Feature | htmpl | Jinja | React |
|---|---|---|---|
| Type safety | ✅ | ❌ | ✅ (TSX) |
| Python-native | ✅ | ❌ | ❌ |
| Async support | ✅ | ❌ | ❌ |
| No build step | ✅ | ✅ | ❌ |
| IDE support | ✅ | Limited | ✅ |
| Learning curve | Low | Medium | High |
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file htmpl-0.3.0.tar.gz.
File metadata
- Download URL: htmpl-0.3.0.tar.gz
- Upload date:
- Size: 82.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.16 {"installer":{"name":"uv","version":"0.9.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5336b080f9201f050c72c5253a935055a8a3bd32444e272afd4b968bd507cc54
|
|
| MD5 |
e86bcb004df3520e1b6ff4284e2ad1a3
|
|
| BLAKE2b-256 |
a4c9948cbcb172546805fd88ea97c2312b9c5146576853449d1a0ab78220fe30
|
File details
Details for the file htmpl-0.3.0-py3-none-any.whl.
File metadata
- Download URL: htmpl-0.3.0-py3-none-any.whl
- Upload date:
- Size: 26.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.16 {"installer":{"name":"uv","version":"0.9.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
67cecfb45fbb50737d2d7801565592743fb30d4d8676c2239f6d5bb27db698f5
|
|
| MD5 |
bc29739b99097a4858dde6249e49198f
|
|
| BLAKE2b-256 |
d105e0ec515341e7b94bdf06cde6bdc80e75c6cf88e73cdd23c42b0bebfc4a93
|