A ๐ฅ t-string (aka PEP 750) HTML templating system for upcoming Python 3.14 for both server-side rendering and frontend.
We don't yet have a package published on PyPI, so follow the instructions below. Once template strings are merged into
a Python 3.14 beta, we'll publish a package, and you can install from pip
, uv
, etc.
Note: These instructions point at an old version of template strings. We need some Pyodide work to start using the new API. So we'll use the old API for the CPython by cloning an old commit.
First, clone CPython main
then build it in some directory.
$ cd /tmp
$ git clone https://github.com/python/cpython.git
$ cd cpython
$ ./configure # Follow https://devguide.python.org
$ make
On macOS/Windows, this will produce a file python.exe
(on Linux, python
) which you will use as your Python executable.
Let's get tdom
setup for development. Clone this repo and make a virtual environment there, using the just-built
CPython:
$ git clone https://github.com/t-strings/tdom.git
$ cd tdom
$ /tmp/cpython/python.exe -m venv .venv # Use your path to Python build
$ .venv/bin/pip install --upgrade pip
Let's use uv
from now on. Install it
using one of the uv install method. Specifically,
we will use uv run pytest
, if you are using the command line as your test UI.
$ uv run pytest
And that's it!
If you are using an IDE with testing support (PyCharm, VS Code) and it doesn't have uv run
support, you'll need
another step. Whenever you change dependencies, run uv sync
, since the IDE is likely running pytest
directly.
The current SSR implementation offers 3 major features that can be split into these categories:
- attributes, meant as HTML/SVG nodes attributes
- content, meant as HTML/SVG elements or fragments possibilities
- components, meant as classes or functions that return some content after being instantiated/invoked
An element attribute is nothing more than a name/value pair definition, where the name
must be unique and it might have a special meaning, accordingly with the element where such attribute is defined.
<!-- HTML -->
<div class="class-attribute">
<!-- some content -->
</div>
<textarea placeholder="Your comment">
</textarea>
<!-- SVG -->
<rect width="200" height="100" rx="20" ry="20" fill="blue" />
Thanks to t
strings, attributes in here can be dynamic or even mixed, example:
<div class="{''.join(['special', 'container'])}">
<!-- some content -->
</div>
<textarea placeholder={placeholder}>
</textarea>
<rect width={width} height={height} rx="20" ry="20" fill='{color}' />
Note
- it doesn't matter if dynamic attributes have single or double quotes around, the logic is smart enough to understand and ultimately sanitize those quotes around, even if omitted
- it doesn't matter if the value is an integer, float, or something else, once stringified the output will use
str(value)
and it will safelyescape
those values automatically - attributes must be a single value, when dynamic, so that the following would break:
<!-- โ ๏ธ this is not possible -->
<div class="a {runtime} b"></div>
<!-- ๐ this works perfectly fine -->
<div class={f"a {runtime} b"}></div>
<div class={callback}></div>
<div class={some_class(runtime)}></div>
Some HTML attribute might not need a value to be significant and for these special cases a boolean hint would be enough to see it rendered or not. The hidden attribute is one of those special cases:
<div hidden={condition}>
<!-- some content -->
</div>
When that condition
is True
, <div hidden>
will be produced once the template will get stringified, while if False
it won't be part of the output at all, it's just <div>
.
The aria attribute is also special because it allows to automatically create all related attributes with ease, without needing to repeat aria-
prefix all over the place:
<div aria={{"role": "button", "describedby": uid}}>
<!-- some content -->
</div>
<!-- will result into -->
<div role="button" aria-describedby="unique-id">
<!-- some content -->
</div>
Similarly, the data attribute helps adding dataset attributes to any node, without needing to repeat the data-
prefix.
<div data={{"a": 1, "b": 2, "c": 3}}>
<!-- some content -->
</div>
<!-- will result into -->
<div data-a="1" data-b="2" data-c="3">
<!-- some content -->
</div>
Last, but not least, @events
are currently specially handled as well, such as @click
, @pointerover
and every other standard event will be translated into a specialized listener that will either work or silently do nothing unless instrumented/orchestrated explicitly.
<button @click={my_click_handler}>
click me
</button>
<!-- will result into -->
<button onclick="self.python_listeners?.[0](event)">
click me
</button>
Currently experimental, we've already managed to bring real Python listeners to the browser via dill
module and PyScript ability to bootstrap pyodide on the front end, but any custom logic able to map listeners to actual actions on the page could work similarly, if not better.
Runtime content can be placed almost anywhere and it could represent a string, number, node returned by html
or svg
utility or a callback that will be invoked to return any of these values or, ultimately, a list
or a tuple
that contains any previously mentioned value, or a component.
<div>
Some {'text'}.
Some {lambda_or_function}
<ul>
{[
html(t'<li>{'a'}</li>'),
html(t'<li>b</li>'),
html(t'<li>c {sep} d</li>'),
]}
</ul>
<{MyComponent} a='1' b={2} />
</div>
Differently from functions found as interpolation value within the content, a component is a function, or a class, that will be invoked, or instiated with 2 arguments, props
and children
, but it requires to be present right after an opening <
char, otherwise it won't receive any value:
def MyComponent(a:int, b:int, children:list):
# a == 1 and b == 2
# children == [<p />, <p />]
return html(t'<div data={props}>{children}</div>')
print(
str(
html(t'''
<{MyComponent} a="1" b={2}>
<p>first element {'child'}</p>
<p c={3}>second element child</p>
</>
''')
)
)
The output that will result is:
<div data-a="1" data-b="2">
<p>first element 'child'</p>
<p c="3">second element child</p>
</div>
where all children
will be passed along already resolved and all props
will contain every "attribute" defined at the component level: props is just a special dictionary that allows both props.x
and props['x']
ways to read its own values.
In these examples it is possible to note self-closing tags, such as <div />
or others, but also a special closing-tag such as </>
or <//>
(these are the same).
The @
attribute for events is also not standard, but it helps explicitly distinguish between what could be an actual JS content for a real onclick
, as opposite of being something "magic" that needs to be orchestrated @ the Python level.
$ uv run sphinx-build docs docs/_build
tdom
uses pytest
for tests. You can run the tests with uv run pytest
. The pytest configuration is in pyproject.toml
as well as the GitHub Actions workflows in .github/workflows
.
The tests are in two directories: examples
and tests
. The examples
directory has small snippets which serve three purposes: docs, testing, and standalone exploration.
The tests
directory has two kinds of tests: "native" and Playwright tests. The native tests execute in CPython. The Playwright tests load Pyodide and run in pytest-playwright under a fake
server. Any requests to http://localhost:8000/
are loaded from the filesystem: either under tests/pwright/stubs
or src/tdom
. (This is set in src/tdom/fixtures.py
)
Unfortunately, Playwright for Python depends on a package (greenlet) which doesn't compile yet for Python 3.14 on Ubuntu in
GitHub Actions (though it works locally under 3.14.) To solve this, the GHA workflow uses 3.13. This is the reason for the
extra pyproject-playwright.toml
file at the root: that file is copied to pyproject.toml
in the action.
However, this has some consequences in the code: Pyodide tests must be under tests/pwright
(to
get the correct pytest includes/excludes.)
You can manually run the Playwright tests locally with uv add --dev pytest-playwright
then uv run pytest tests/pwright
.
tdom
is an independent open source project, started by Andrea Giammarchi. His time, though, has generously been
supported by his work at Anaconda. Thank you Anaconda for your continued support of this
project.