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.1.1.tar.gz (77.1 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.1.1-py3-none-any.whl (21.1 kB view details)

Uploaded Python 3

File details

Details for the file htmpl-0.1.1.tar.gz.

File metadata

  • Download URL: htmpl-0.1.1.tar.gz
  • Upload date:
  • Size: 77.1 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.1.1.tar.gz
Algorithm Hash digest
SHA256 af567b6a1406213d570a1214ee2113c3419cd63b6ac94c5f26b12ee8ca6489bf
MD5 07fceba09a5958fb74e5bc92c4711c46
BLAKE2b-256 ab896839702bbbc79975460ab402b70fa0e048050f91024feca0f49aaff91316

See more details on using hashes here.

File details

Details for the file htmpl-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: htmpl-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 21.1 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.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 674a92217a346e01b1a1dfdc4fb859069cdebecb39fb8eacd14f2a4143acc0f1
MD5 623e3cf9af31a5c2069c9ad34bfae5bd
BLAKE2b-256 0a6bd963bdfc3d4b457c67e38b0e6d70a461f67c75585f4758597fdf2e34f423

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