From htpy¶
htpy assembles HTML in Python from the other direction than a parser: each element is an object
whose attributes come from a call (li(class_="item")) and whose children sit in a [...] subscript (ul[li("a"),
li("b")]), and stringifying the root renders the tree. Attributes can also be a leading CSS-selector string
(div(".card")) or a mapping, children accept strings, other elements, and iterables or generators, and it escapes
through markupsafe so a markupsafe.Markup value passes through unescaped. It is a typed alternative to a
template language, used to render server-side HTML in Django, Flask, Starlette, and FastAPI, and it can stream a page
out as chunks for async responses.
turbohtml covers the same ground with the terse turbohtml.build.E builder, but the value it hands back is a real
Element in a parsed tree rather than a render-only object, so the whole query, edit, and serialize
surface stays available on what you just built.
turbohtml vs htpy¶
Dimension |
turbohtml |
htpy |
|---|---|---|
Scope |
Full WHATWG parser, serializer, builder, selectors, sanitizer, converters |
HTML generation only, from Python |
Feature breadth |
|
Element objects, selector-string and conditional-class attributes, iterable children, markupsafe escaping,
|
Performance |
Builds and serializes in C |
Pure Python over markupsafe |
Typing |
Fully annotated, |
Fully annotated, |
Dependencies |
None (self-contained C extension) |
markupsafe |
Maintenance |
Active |
Active |
Feature overlap¶
The shared surface ports one-to-one:
div(class_="card")[h1("Title")]->E.div({"class": "card"}, E.h1("Title")); attributes are a leading mapping, children follow in the same call instead of a subscript.li(class_="item", data_i="1")["text"]->E.li({"class": "item", "data-i": "1"}, "text"); write the real attribute name as the dict key, with noclass_alias ordata_-to-data-rewrite to remember.A string child becomes escaped text in both: htpy escapes through markupsafe,
Ebuilds aTextnode.A non-identifier tag such as a custom element ->
E("my-card", ...), the call form.A class token list joins on a space:
div(class_="card lg")->E.div({"class": ["card", "lg"]}).
What turbohtml adds¶
The result is an ordinary
Element, soselect(),xpath(),find(),to_markdown(), and the full edit API are available. htpy’s element is a render-only object with no query surface.The markup serializes by exactly the WHATWG rules that parse it back, so a fragment round-trips; htpy renders its own string and cannot read it back into a tree.
Eassembles the fragment in turbohtml’s arena and serializes it in C, about two and a half times faster than htpy.A
Minifylayout (whitespace collapse, optional-tag omission, JS/CSS minify) on top of the compact andIndentlayouts; htpy renders compact only.Chunked streaming:
serialize_iter()yields the markup in boundedstrchunks, so a large page streams to an async response without ever building the whole string – the same shape as iterating an htpy element, and''.join(node.serialize_iter())equalsserialize().No third-party dependency: turbohtml is a self-contained C extension, where htpy pulls in markupsafe.
You can
turbohtml.parse()real markup and append anEfragment under it, mixing parsing and building in one tree.
What htpy has that turbohtml does not¶
Selector-string attributes:
div("#main.card.lg")sets id and classes from one CSS-selector string.Etakes an explicit mapping. Workaround: writeE.div({"id": "main", "class": ["card", "lg"]}).Conditional-class mapping:
div(class_={"active": is_active, "hidden": False})keeps only the true keys.Etakes a plain class value. Workaround: build the list yourself,E.div({"class": [name for name, on in flags if on]}).Iterable and generator children in the subscript:
ul[(li(x) for x in items)]consumes the generator, andNoneorFalsechildren are dropped.Etakes positional arguments. Workaround: splat and filter first,E.ul(*(E.li(x) for x in items if x is not None)).markupsafe pass-through: a
markupsafe.Markupchild is emitted unescaped without reparsing.Ealways escapes a string into aTextnode. Workaround:turbohtml.parse()the trusted markup and append the resulting nodes, since turbohtml is a real parser.fragment[...]groups several children with no wrapper element and renders them as adjacent siblings.E.<tag>always produces one rooted element. Workaround: serialize each child and concatenate, or parse the concatenation into a fragment.The
html2htpyCLI converts existing HTML into htpy builder source. turbohtml has no code generator. Workaround:turbohtml.parse()the HTML at runtime instead of generating builder code.
Performance¶
E is about two and a half times faster than htpy. The same <ul> of rows – a class, a data attribute, and a
text child apiece – built both ways:
build a list |
turbohtml |
|
|---|---|---|
100 rows |
117 µs |
378 µs (3.3x) |
1k rows |
1.41 ms |
3.72 ms (2.7x) |
10k rows |
14.8 ms |
37.2 ms (2.6x) |
The decisive difference is the result type: E hands back a real Element, not a string, so the
call that builds the markup also leaves a tree you can query, edit, and re-serialize().
How to migrate¶
Swap the per-tag imports for the single E builder:
# htpy
from htpy import div, h1, p
# turbohtml
from turbohtml.build import E
turbohtml |
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
htpy carries attributes in the call and children in a subscript; turbohtml passes both to one call:
from turbohtml.build import E
card = E.div({"class": "card"}, E.h1("Title"), E.p("body"))
print(card.serialize())
<div class="card"><h1>Title</h1><p>body</p></div>
E("tag", ...) is the call form for a tag that is not a Python identifier (a custom element, say), and a list-valued
attribute joins on a space so a class list reads naturally:
from turbohtml.build import E
print(E("my-card", {"class": ["card", "lg"]}, "hi").serialize())
<my-card class="card lg">hi</my-card>
Gotchas and pitfalls¶
Ebuilds a fragment, not a document: there is no implicit<html>/<head>/<body>wrapper and no doctype, where importing htpy’shtmlelement renders a leading<!doctype html>. Serialize the element you built, or append it under a parsed document when you need the full page shell.Attribute names are plain dict keys (
{"data-i": "1"}), not htpy’s keyword conventions (class_, the trailing underscore for reserved words, thedata_/aria_underscore-to-dash rewrite, or the".card"selector string).A leading mapping is always read as attributes; to start an element with literal text, pass the string first (
E.p("text", E.b("bold"))).Etakes children as positional arguments, so a generator must be splatted (E.ul(*(E.li(x) for x in items))); htpy consumes an iterable placed directly in the[...]subscript.htpy drops
NoneandFalsechildren;Eappends every argument, so filter conditionals out before the call.Eescapes strings into text nodes, soE.div("<b>")serializes as<b>. htpy emits amarkupsafe.Markupvalue unescaped; to inject real markup here,turbohtml.parse()it and append the nodes.The result is an ordinary
Element, so the whole edit and query surface (append,find,select,serialize,to_markdown) is available – the builder only saves the construction boilerplate.