Skip to main content

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>&lt;script&gt;alert('xss')&lt;/script&gt;</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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

htmpl-0.3.0.tar.gz (82.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

htmpl-0.3.0-py3-none-any.whl (26.5 kB view details)

Uploaded Python 3

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

Hashes for htmpl-0.3.0.tar.gz
Algorithm Hash digest
SHA256 5336b080f9201f050c72c5253a935055a8a3bd32444e272afd4b968bd507cc54
MD5 e86bcb004df3520e1b6ff4284e2ad1a3
BLAKE2b-256 a4c9948cbcb172546805fd88ea97c2312b9c5146576853449d1a0ab78220fe30

See more details on using hashes here.

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

Hashes for htmpl-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 67cecfb45fbb50737d2d7801565592743fb30d4d8676c2239f6d5bb27db698f5
MD5 bc29739b99097a4858dde6249e49198f
BLAKE2b-256 d105e0ec515341e7b94bdf06cde6bdc80e75c6cf88e73cdd23c42b0bebfc4a93

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page