From yattag¶
yattag assembles HTML in Python from the other direction than a parser. You unpack doc,
tag, text = Doc().tagtext(), open each element as a with tag(...) block, call text(...) for escaped content
and line(...) for an open-text-close shorthand, then read the string back with doc.getvalue(). Attributes are
keyword arguments (klass for class) or ("data-i", "1") tuples for names that are not Python identifiers.
Beyond plain generation it scaffolds forms: Doc(defaults=..., errors=...) with
doc.input/doc.textarea/doc.select fills field values from a defaults dict and wraps invalid fields with
error text, which makes it a common pick for server-rendered pages and form redisplay in Flask and similar stacks
without a template language.
turbohtml covers the same generation ground with the terse turbohtml.build.E builder, but the value it hands
back is a real Element in a parsed tree rather than an accumulated string, so the whole query, edit,
and serialize surface stays available on what you just built and the markup serializes by exactly the WHATWG rules that
parse it back.
turbohtml vs yattag¶
Dimension |
turbohtml |
yattag |
|---|---|---|
Scope |
Full WHATWG parser, serializer, builder, selectors, sanitizer, converters |
HTML/XML generation only, from Python |
Feature breadth |
|
Context-manager nesting, |
Performance |
Builds and serializes in C |
Pure Python |
Typing |
Fully annotated, |
Untyped |
Dependencies |
None (self-contained C extension) |
None (pure Python) |
Maintenance |
Active |
Stable, infrequent releases |
Feature overlap¶
The shared surface ports one-to-one:
with tag("div", klass="card"): ...->E.div({"class": "card"}, ...); the attribute mapping leads, the children follow in the same call instead of a context-manager block.line("h1", "Title", klass="t")->E.h1({"class": "t"}, "Title"); the open-text-close shorthand is the default builder call.text("body")-> a plain string child, whichEescapes into aTextnode just astextescapes into the buffer.doc.stag("img", src="a.png")->E.img({"src": "a.png"}); the serializer omits the close tag for a void element by the WHATWG rules, so no explicit self-closing call is needed.A non-identifier attribute name such as
("data-i", "1")-> the plain dict key{"data-i": "1"}, with noklassalias or tuple form to remember.A non-identifier tag name ->
E("my-card", ...), the call form.A class token list joins on a space:
klass="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 on what you built. yattag hands back only a string fromdoc.getvalue().The markup serializes by exactly the WHATWG rules that parse it back, so a fragment round-trips through
turbohtml.parse()unchanged; yattag hand-assembles its own string.Eassembles the fragment in turbohtml’s arena and serializes it in C, where yattag stays in Python.Layout control beyond yattag’s
indent: a compact default, anIndentpretty-print, and aMinifylayout (whitespace collapse, optional-tag omission, JS/CSS minify), all through oneserialize()call.Full type annotations with a
py.typedmarker.You can
turbohtml.parse()real markup and append anEfragment under it, mixing parsing and building in one tree.
What yattag has that turbohtml does not¶
Context-manager nesting:
with tag("div"): text("hi")collects everything written inside the block.Ehas no context-manager form. Workaround: pass children as positional arguments, or build bottom-up andappend().Form fill and validation:
Doc(defaults=..., errors=...)withdoc.input/doc.textarea/doc.selectfills a field’s value from the defaults dict and wraps an invalid field with its error text.Ehas no form-aware builder. Workaround: set thevalue/checkedattributes yourself from the dict, and build the error markup as ordinary elements.doc.asis("<b>x</b>")injects unescaped HTML into the buffer.Ealways escapes a string into aTextnode. Workaround:turbohtml.parse()the raw markup and append the resulting nodes, since turbohtml is a real parser.doc.attr(...)adds attributes to the currently open tag from inside its block.Esets every attribute in the leading mapping at construction, so there is no open-tag-to-mutate. Workaround: assemble the full attribute dict before theEcall, or set attributes on the returnedElementafterward.Docalso builds XML with arbitrary tags and no void-element or HTML-serialization rules.Ebuilds an HTML tree and serializes by WHATWG rules. Workaround: for XML output, build the tree and serialize it as HTML, accepting the HTML void-element and escaping conventions.
Performance¶
E runs on par with yattag. 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 |
143 µs (1.3x) |
1k rows |
1.41 ms |
1.35 ms (1.0x) |
10k rows |
14.8 ms |
13.7 ms (1.0x) |
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 Doc().tagtext() unpacking for the single E builder:
# yattag
from yattag import Doc
doc, tag, text = Doc().tagtext()
# turbohtml
from turbohtml.build import E
turbohtml |
|
|---|---|
|
|
|
|
|
|
|
the plain dict key |
|
|
|
|
A with-block tree becomes one nested call, attributes leading and children following:
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. Serialize the element you built, or append it under a parsed document when you need the full page shell.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"))).Eescapes strings into text nodes, soE.div("<b>")serializes as<b>. yattag’sasis()did no escaping; to inject real markup,turbohtml.parse()it and append the nodes.turbohtml serializes compact by default, where yattag’s
indent()pretty-prints. PassHtml(layout=Indent())toserialize()for indented output.Write attribute names as plain dict keys (
{"class": "card", "data-i": "1"}), not yattag’sklasskeyword or("name", "value")tuple conventions.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.