A 🤘 rockin' t-string HTML templating system for Python 3.14.
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!
You'll need Python 3.14, which was released in October 2025.
Then, just run:
pip install tdomIf you have Astral's uv you can easily try tdom in an isolated Python 3.14 environment:
uv run --with tdom --python 3.14 pythontdom 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.
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>"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, <script>alert('owned')</script>!</p>"The html() function provides a number of convenient ways to define HTML
attributes.
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 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 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 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.
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>'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>"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>"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><strong>This is safe HTML</strong></div>"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. You can also explicitly call
html() on nested templates if you prefer:
content = t"<h1>My Site</h1>"
page = html(t"<div>{content}</div>")
assert page == "<div><h1>My Site</h1></div>"The result is the same either way.
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>."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 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 iconAll 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 resultUnlike some template systems that provide implicit "context" objects for passing
data through component hierarchies, tdom embraces Python's explicit approach.
If you need to pass data to nested components, you have several Pythonic
options:
-
Pass data as explicit arguments: The most straightforward approach.
-
Use closures: Components are just functions, so they can 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-
Use module-level or global state: For truly application-wide configuration.
-
Use a dedicated context library: Libraries like
contextvarscan provide more sophisticated context management if needed.
This explicit approach makes it clear where data comes from and avoids the "magic" of implicit context passing.
The tdom package includes several utility functions for working with
interpolations:
format_interpolation(): This function handles the formatting of
interpolated values according to their format specifiers and conversions. It's
used internally by the html() function but can also be used independently:
from string.templatelib import Interpolation
from tdom.format import convert
# Test convert function
assert convert("hello", "s") == "hello"
assert convert("hello", "r") == "'hello'"
assert convert(42, None) == 42convert(): Applies conversion specifiers (!a, !r, !s) to values
before formatting, following the same semantics as f-strings.
These utilities follow the patterns established by PEP 750 for t-string processing, allowing you to build custom template processors if needed.
Contributions are welcome! Please feel free to submit issues or pull requests on GitHub.