Skip to main content

tdom

A 🤘 rockin' t-string HTML templating system for Python 3.14.

PyPI Tests Test Count Coverage Changelog License Docs

Upcoming (incomplete) documentation: https://t-strings.github.io/tdom/

NOTE WELL

This is pre-alpha software. It is still under heavy development and the API may change without warning. We would love community feedback to help shape the direction of this project!

Installation

You'll need Python 3.14, which was released in October 2025.

Then, just run:

pip install tdom

If you have Astral's uv you can easily try tdom in an isolated Python 3.14 environment:

uv run --with tdom --python 3.14 python

Usage

tdom leverages Python 3.14's new t-strings feature to provide a powerful HTML templating system that feels familiar if you've used JSX, Jinja2, or Django templates.

T-strings work just like f-strings but use a t prefix and create Template objects instead of strings.

Once you have a Template, you can call this package's html() function to render it to a string.

Getting Started

Import the html function and start creating templates:

from tdom import html

greeting = html(t"<h1>Hello, World!</h1>")
assert greeting == "<h1>Hello, World!</h1>"

Variable Interpolation

Just like f-strings, you can interpolate (substitute) variables directly into your templates:

name = "Alice"
age = 30
user_info = html(t"<p>Hello, {name}! You are {age} years old.</p>")
assert user_info == "<p>Hello, Alice! You are 30 years old.</p>"

The html() function ensures that interpolated values are automatically escaped to prevent XSS attacks:

user_name = "<script>alert('owned')</script>"
safe_output = html(t"<p>Hello, {user_name}!</p>")
assert (
    safe_output == "<p>Hello, &lt;script&gt;alert(&#39;owned&#39;)&lt;/script&gt;!</p>"
)

Attribute Substitution

The html() function provides a number of convenient ways to define HTML attributes.

Direct Attribute Values

You can place values directly in attribute positions:

url = "https://example.com"
link = html(t'<a href="{url}">Visit our site</a>')
assert link == '<a href="https://example.com">Visit our site</a>'

You don't have to wrap your attribute values in quotes:

element_id = "my-button"
button = html(t"<button id={element_id}>Click me</button>")
assert button == '<button id="my-button">Click me</button>'

Multiple substitutions in a single attribute are supported too:

first = "Alice"
last = "Smith"
button = html(t'<button data-name="{first} {last}">Click me</button>')
assert button == '<button data-name="Alice Smith">Click me</button>'

Boolean attributes are supported too. Just use a boolean value in the attribute position:

form_button = html(t"<button disabled={True} hidden={False}>Submit</button>")
assert form_button == "<button disabled>Submit</button>"

The class Attribute

The class attribute has special handling to make it easy to combine multiple classes from different sources. The simplest way is to provide a list of class names:

classes = ["btn", "btn-primary", "active"]
button = html(t'<button class="{classes}">Click me</button>')
assert button == '<button class="btn btn-primary active">Click me</button>'

The class attribute can also be a dictionary to toggle classes on or off:

classes = {"active": True, "btn": True}
button = html(t"<button class={classes}>Click me</button>")
assert button == '<button class="active btn">Click me</button>'

The class attribute can be specified more than once. The values are merged from left to right. A common use case would be to update and/or extend default classes:

classes = {"btn-primary": True, "btn-secondary": False}
button = html(t'<button class="btn btn-secondary" class={classes}>Click me</button>')
assert button == '<button class="btn btn-primary">Click me</button>'

The style Attribute

The style attribute has special handling to make it easy to combine multiple styles from different sources. The simplest way is to provide a dictionary of CSS properties and values for the style attribute:

# Style attributes from dictionaries
styles = {"color": "red", "font-weight": "bold", "margin": "10px"}
styled = html(t"<p style={styles}>Important text</p>")
assert (
    styled
    == '<p style="color: red; font-weight: bold; margin: 10px">Important text</p>'
)

Style attributes can also be merged to extend a base style:

add_styles = {"font-weight": "bold"}
para = html(t'<p style="color: red" style={add_styles}>Important text</p>')
assert para == '<p style="color: red; font-weight: bold">Important text</p>'

The data and aria Attributes

The data and aria attributes also have special handling to convert dictionary keys to the appropriate attribute names:

data_attrs = {"user-id": 123, "role": "admin"}
aria_attrs = {"label": "Close dialog", "hidden": True}
element = html(t"<div data={data_attrs} aria={aria_attrs}>Content</div>")
assert (
    element
    == '<div data-user-id="123" data-role="admin" aria-label="Close dialog" aria-hidden="true">Content</div>'
)

Note that boolean values in aria attributes are converted to "true" or "false" as per the ARIA specification.

Attribute Spreading

It's possible to specify multiple attributes at once by using a dictionary and spreading it into an element using curly braces:

attrs = {"href": "https://example.com", "target": "_blank"}
link = html(t"<a {attrs}>External link</a>")
assert link == '<a href="https://example.com" target="_blank">External link</a>'

You can also combine spreading with individual attributes:

base_attrs = {"id": "my-link"}
target = "_blank"
link = html(t'<a {base_attrs} target="{target}">Link</a>')
assert link == '<a id="my-link" target="_blank">Link</a>'

Special attributes likes class behave as expected when combined with spreading:

classes = {"btn": True, "active": True}
attrs = {"class": classes, "id": "act_now", "data": {"wow": "such-attr"}}
button = html(t"<button {attrs}>Click me</button>")
assert (
    button
    == '<button class="btn active" id="act_now" data-wow="such-attr">Click me</button>'
)

Conditional Rendering

You can use Python's conditional expressions for dynamic content:

is_logged_in = True
user_content = t"<span>Welcome back!</span>"
guest_content = t"<a href='/login'>Please log in</a>"
header = html(t"<div>{user_content if is_logged_in else guest_content}</div>")
assert header == "<div><span>Welcome back!</span></div>"

Short-circuit evaluation is also supported for conditionally including elements:

show_warning = False
warning = t'<div class="alert">Warning message</div>'
page = html(t"<main>{show_warning and warning}</main>")
assert page == "<main></main>"

Lists and Iteration

Generate repeated elements using list comprehensions:

fruits = ["Apple", "Banana", "Cherry"]
fruit_list = html(t"<ul>{[t'<li>{fruit}</li>' for fruit in fruits]}</ul>")
assert fruit_list == "<ul><li>Apple</li><li>Banana</li><li>Cherry</li></ul>"

Raw HTML Injection

The tdom package provides several ways to include trusted raw HTML content in your templates. This is useful when you have HTML content that you know is safe and do not wish to escape.

Under the hood, tdom builds on top of the familiar MarkupSafe library to handle trusted HTML content. If you've used Flask, Jinja2, or similar libraries, this will feel very familiar.

The Markup class from MarkupSafe is available for use:

from tdom import html, Markup

trusted_html = Markup("<strong>This is safe HTML</strong>")
content = html(t"<div>{trusted_html}</div>")
assert content == "<div><strong>This is safe HTML</strong></div>"

As a convenience, tdom also supports a :safe format specifier that marks a string as safe HTML:

trusted_html = "<em>Emphasized text</em>"
page = html(t"<p>Here is some {trusted_html:safe} content.</p>")
assert page == "<p>Here is some <em>Emphasized text</em> content.</p>"

For interoperability with other templating libraries, any object that implements a __html__ method will be treated as safe HTML. Many popular libraries (including MarkupSafe and Django) use this convention:

class SafeWidget:
    def __html__(self):
        return "<button>Custom Widget</button>"


page = html(t"<div>My widget: {SafeWidget()}</div>")
assert page == "<div>My widget: <button>Custom Widget</button></div>"

You can also explicitly mark a string as "unsafe" using the :unsafe format specifier. This forces the string to be escaped, even if it would normally be treated as safe:

from tdom import html, Markup

trusted_html = Markup("<strong>This is safe HTML</strong>")
page = html(t"<div>{trusted_html:unsafe}</div>")
assert page == "<div>&lt;strong&gt;This is safe HTML&lt;/strong&gt;</div>"

Template Composition

You can easily combine multiple templates and create reusable components.

Template nesting is straightforward:

content = t"<h1>My Site</h1>"
page = html(t"<div>{content}</div>")
assert page == "<div><h1>My Site</h1></div>"

In the example above, content is a Template object that gets correctly parsed and embedded within the outer template.

Nest the Template itself; don't render it first. html() returns a plain str, so interpolating an already-rendered result escapes it:

content = t"<h1>My Site</h1>"
page = html(t"<div>{html(content)}</div>")
assert page == "<div>&lt;h1&gt;My Site&lt;/h1&gt;</div>"

If you do need to embed HTML you have already rendered, mark it as safe:

from tdom import Markup

content = t"<h1>My Site</h1>"
page = html(t"<div>{Markup(html(content))}</div>")
assert page == "<div><h1>My Site</h1></div>"

Component Functions

You can create reusable component functions that generate templates with dynamic content and attributes. Use these like custom HTML elements in your templates.

The basic form of all component functions is:

from string.templatelib import Template

from typing import Any, Iterable
from tdom import html


def MyComponent(children: Template, **attrs: Any) -> Template:
    return t"<div {attrs}>Cool: {children}</div>"

To invoke your component within an HTML template, use the special <{ComponentName} ... /> syntax:

result = html(t"<{MyComponent} id='comp1'>Hello, Component!</{MyComponent}>")
assert result == '<div id="comp1">Cool: Hello, Component!</div>'

Because attributes are passed as keyword arguments, you can explicitly provide type hints for better editor support:

from string.templatelib import Template

from typing import Any
from tdom import html


def Link(
    *, href: str, text: str, data_value: int, children: Template = t"", **attrs: Any
) -> Template:
    # Children are ignored.
    return t'<a href="{href}" {attrs}>{text}: {data_value}</a>'


result = html(
    t'<{Link} href="https://example.com" text="Example" data-value={42} target="_blank" />'
)
assert result == '<a href="https://example.com" target="_blank">Example: 42</a>'

Note that attributes with hyphens (like data-value) are converted to underscores (data_value) in the function signature.

Component functions build and return a Template that will be processed as if it were interpolated exactly where the component was invoked.

def Greeting(name: str) -> Template:
    return t"<span>Hello, {name}!</span>"


result = html(t"Your greeting is <{Greeting} name='Alice' />.")
assert result == "Your greeting is <span>Hello, Alice!</span>."

Class-based components

Component functions are great for simple use cases, but for more complex components you may want to use a class-based approach. Remember that the component invocation syntax (<{ComponentName} ... />) works with any callable. That includes the __init__ method of a class. If a callable does not return a Template then it must return another callable that takes no arguments and returns a Template. That can be done by defining a __call__ method on the class.

One particularly useful pattern is to build class-based components with dataclasses:

from string.templatelib import Template
from dataclasses import dataclass, field
from typing import Any, Iterable
from tdom import html
from textwrap import dedent


@dataclass
class Card:
    children: Template
    title: str
    subtitle: str | None = None

    def __call__(self) -> Template:
        return t"""
            <div class='card'>
                <h2>{self.title}</h2>
                {self.subtitle and t"<h3>{self.subtitle}</h3>"}
                <div class="content">{self.children}</div>
            </div>
        """


result = html(
    t"<{Card} title='My Card' subtitle='A subtitle'><p>Card content</p></{Card}>"
)
assert (
    dedent(result)
    == """
<div class="card">
    <h2>My Card</h2>
    <h3>A subtitle</h3>
    <div class="content"><p>Card content</p></div>
</div>
"""
)

This approach allows you to encapsulate component logic and state within a class, making it easier to manage complex components.

As a note, children are optional in component signatures. If a component requests children, it will receive them if provided. If no children are provided, the value of children is an empty Template, ie. t"". If the component does not ask for children, but they are provided, then they are silently ignored.

SVG Support

SVG elements work seamlessly with tdom since they follow the same XML-like syntax as HTML. You can create inline SVG graphics by simply including SVG tags in your templates:

icon = html(t"""
    <svg width="24" height="24" viewBox="0 0 24 24" fill="none">
        <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
        <path d="M12 6v6l4 2" stroke="currentColor" stroke-width="2"/>
    </svg>
""")
assert '<svg width="24" height="24"' in icon
assert '<circle cx="12" cy="12" r="10"' in icon

All the same interpolation, attribute handling, and component features work with SVG elements:

def Icon(*, size: int = 24, color: str = "currentColor") -> Template:
    return t"""
        <svg width="{size}" height="{size}" viewBox="0 0 24 24" fill="none">
            <circle cx="12" cy="12" r="10" stroke="{color}" stroke-width="2"/>
        </svg>
    """


result = html(t'<{Icon} size={48} color="blue" />')
assert 'width="48"' in result
assert 'stroke="blue"' in result

Passing Data to Nested Components

tdom prefers Python's explicit approach: if a component needs a value, pass it as an argument. Most of the time that is the right answer, and it keeps the flow of data obvious.

Components are just functions, so they can also close over variables in their enclosing scope:

theme = {"primary": "blue", "spacing": "10px"}


def Button(text: str) -> Template:
    # Button has access to theme from enclosing scope
    return t'<button style="color: {theme["primary"]}; margin: {theme["spacing"]}">{text}</button>'


result = html(t'<{Button} text="Click me" />')
assert "color: blue" in result
assert "margin: 10px" in result
assert ">Click me</button>" in result

For values that need to reach deeply nested components without being threaded through every layer in between, tdom provides Scope and ScopedTemplate. These build on Python's contextvars, so they are async- and thread-safe.

A component returns a ScopedTemplate instead of a Template to bind a value for the duration of that subtree's render:

from contextvars import ContextVar
from tdom import Scope, ScopedTemplate

current_theme: ContextVar[str] = ContextVar("current_theme", default="light")


def ThemeProvider(children: Template, value: str) -> ScopedTemplate:
    return ScopedTemplate(Scope(current_theme, value), children)


def ThemedButton(text: str) -> Template:
    return t'<button class="btn-{current_theme.get()}">{text}</button>'


page = html(
    t'<{ThemeProvider} value="dark"><{ThemedButton} text="Save" /></{ThemeProvider}>'
)
assert page == '<button class="btn-dark">Save</button>'

Note that ThemedButton reads the value inside its own function body. This matters: t-string interpolations are evaluated when a template is built, not when it is rendered. An interpolation written directly between the provider's tags therefore runs before the scope is active:

page = html(t'<{ThemeProvider} value="dark">{current_theme.get()}</{ThemeProvider}>')
assert page == "light"

So consumers of a scoped value need to be components, not inline interpolations.

Scope and ScopedTemplate are deliberately low-level; a friendlier provider API may follow.

Lower-Level Utilities

The tdom.format submodule holds the helpers that html() uses to turn interpolations into values. They are not exported from the top-level tdom package, but you can import them directly if you are building your own t-string processor.

convert() applies a conversion specifier (!a, !r, !s) to a value, following the same semantics as f-strings:

from tdom.format import convert

assert convert("hello", "s") == "hello"
assert convert("hello", "r") == "'hello'"
assert convert(42, None) == 42

format_interpolation() takes a whole Interpolation and applies its conversion, then its format specifier:

from string.templatelib import Interpolation
from tdom.format import format_interpolation

# Interpolation(value, expression, conversion, format_spec)
assert format_interpolation(Interpolation(42, "x", None, "04d")) == "0042"
assert format_interpolation(Interpolation("hi", "x", "r", "")) == "'hi'"

These utilities follow the patterns established by PEP 750 for t-string processing, allowing you to build custom template processors if needed.

Contributing

Contributions are welcome! Please feel free to submit issues or pull requests on GitHub.

Metadata

Release files for tdom 0.1.19

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for tdom 0.1.19
File Size Uploaded
tdom-0.1.19.tar.gz 59.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for tdom 0.1.19
File Interpreter ABI Platform
tdom-0.1.19-py3-none-any.whl Python 3 none any Details

Total release size: 96.6 kB

Release files / tdom-0.1.19.tar.gz

Download URL tdom-0.1.19.tar.gz
Size 59.5 kB
Tags Source
SHA-256 checksum
How to use checksums
ac92a77545582aaaaacd8124c96e17a931e8070a4c805906fafcf4c1ae1caa00
BLAKE2b-256 checksum
How to use checksums
3b4b910d919cf22c28a5fe5d910eb9ffbbf045fac6cfd80958babf9a011885ad
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 22, 2026.

Transparency log

Release files / tdom-0.1.19-py3-none-any.whl

Download URL tdom-0.1.19-py3-none-any.whl
Size 37.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
188a905812218b0dcc321590eb4541f4dc6ac4ca27182f61bd98e2eb75ad6873
BLAKE2b-256 checksum
How to use checksums
49fa2137a83cef360c2875be8b533de5014f5984e07c82cc83c9920e3442ec5d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 22, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.19 This release

2 release files

0.1.18

2 release files

0.1.17

2 release files

0.1.16

2 release files

0.1.15

2 release files

0.1.13

2 release files

0.1.12

2 release files

0.1.11

2 release files

0.1.9

2 release files

0.1.8

2 release files

0.1.7

2 release files

0.1.6

2 release files

0.1.5

2 release files

0.0.7

2 release files

0.0.6

2 release files

0.0.5

2 release files

0.0.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page