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.0.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.0-py3-none-any.whl (4.5 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: htmpl-0.1.0.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.0.tar.gz
Algorithm Hash digest
SHA256 3b3302b5bec089d2f757e3ffa91718478d5230b053be0176150550789d2ea7cf
MD5 80ae3082f7c3ca665a6557d8b6dfd02e
BLAKE2b-256 a76f76769d03b91af0f40acbdedfb1f3b692f2c4e29333d8630e3fe524de1fa5

See more details on using hashes here.

File details

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

File metadata

  • Download URL: htmpl-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 4.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.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 b07695716717e36c6584b8fe2f5d0b14bc5cd5bb29f2e30cd7af2234678ca43e
MD5 a8f71e4e7a599863d5d45663c7724705
BLAKE2b-256 23cc657b764518c4a526df3d660ba29d2bf6e09f5810a5369c021be9c75ba44c

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