🏷️ Tagflow
Tagflow is a Python library that generates HTML in a block-oriented way that
works with ordinary control flow. Instead of nested function calls, it uses
context managers and decorators to align HTML generation with Python's native
language features.
So you can write HTML using familiar Python constructs: loops for repeated
elements, conditionals for dynamic content, and try/except for error boundaries.
Create reusable components with decorators and organize code into reusable
components that yield to produce child content.
Most Python HTML generation libraries follow a pattern of nested call
expressions to mirror HTML's tree structure, using lists, list comprehensions,
and variable arguments to pass children. This forces us to abandon familiar
control flow patterns (if
, for
, continue
, try
) in favor of
functional-style composition, leading to convoluted code when dealing with
conditional rendering, loops, or error handling.
Tagflow takes a different approach by using context managers to track the
current position in the HTML tree, allowing developers to use ordinary Python
control flow while building HTML documents step by step in nested with
blocks.
We have not yet measured the performance of Tagflow, but it would be interesting
to see how it compares to other Python HTML generation libraries. It's not
optimized for speed but we haven't seen any major performance issues.
The current implementation uses Python's builtin ElementTree
for maintaining
the document tree and serializing it to HTML.
While streaming responses aren't implemented yet, Tagflow's design will support
generating and sending HTML incrementally without buffering entire pages in
memory.
Installation
Tagflow is available on PyPI with the name tagflow
.
uv add tagflow
Usage
from tagflow import tag, text, document, attr
def page(title: str):
with tag.html(lang="en"):
with tag.head():
with tag.title():
text(title)
with tag.script(src="https://cdn.tailwindcss.com"):
pass
with tag.body(classes="bg-gray-50 p-4"):
with tag.h1():
text("Welcome!")
with tag.p("serif", "mb-4"):
attr("contenteditable")
attr("spellcheck", False)
attr("class", ["text-lg text-gray-900", ["font-bold", "italic"]])
text("This is a paragraph.")
def index_html() -> str:
with document() as root:
page("Tagflow")
return root.to_html()
You can use decorators to specify the tag structure for a function. This is a
nice way to define reusable components.
from tagflow import tag, text, html
@html.main(lang="en")
@html.article("w-prose mx-auto", ["font-bold", "italic"], data_role="content")
def welcome(name: str):
with tag.h1():
text(f"Welcome, {name}!")
with tag.p():
text("This is a paragraph.")
To define a component that takes children, just define a context manager. Here
we also show some control flow examples.
from tagflow import tag, text, html
from dataclasses import dataclass
from contextlib import contextmanager
@contextmanager
def page(title: str):
with tag.html(lang="en"):
with tag.head():
with tag.title():
text(title)
with tag.script(src="https://cdn.tailwindcss.com"):
pass
with tag.body("bg-gray-50 p-4"):
yield
@contextmanager
def article():
with tag.article("w-prose mx-auto"):
yield
@dataclass
class Post:
id: str
title: str
content: list[str]
def index(posts: list[Post]):
with page("Posts"):
for post in posts:
if not post.content:
continue
try:
render_post(post)
except ValueError as e:
with tag.p("text-red-500"):
text("Error rendering post: ")
text(str(e))
@html.article()
def render_post(post: Post):
if not post.id:
raise ValueError("Post ID is required")
attr("id", post.id)
with tag.h1():
text(post.title)
for block in post.content:
with tag.p("mb-4"):
text(block)
FastAPI response class
Tagflow provides a custom FastAPI response class and middleware that make it
easy to integrate with FastAPI endpoints. The middleware automatically sets up a
fresh document context for each request, while the response class handles
rendering:
from fastapi import FastAPI
from tagflow import tag, text, DocumentMiddleware, TagResponse
app = FastAPI()
app.add_middleware(DocumentMiddleware)
@app.get("/", response_class=TagResponse)
def home():
with tag.html(lang="en"):
with tag.head():
with tag.title():
text("Home")
with tag.body():
with tag.h1():
text("Welcome!")
@app.get("/posts/{id}", response_class=TagResponse)
async def view_post(id: str):
post = await get_post(id)
with tag.html():
with tag.body():
render_post(post)
You can also set the FastAPI default response class to TagResponse
in your
FastAPI app.
from fastapi import FastAPI
from tagflow import TagResponse
app = FastAPI(default_response_class=TagResponse)
@app.get("/")
def home():
with tag.html():
with tag.body():
with tag.h1():
text("Welcome!")
Live Documents (early working prototype)
Tagflow also offers "live documents" that asynchronous server tasks can update
in real time after the initial page load. It works a bit like Phoenix LiveView:
the browser runs a script that exposes a DOM mutation capability via WebSocket,
letting the server send updates to elements in the document.
Let's look at a simple example of a live document that updates a counter.
from tagflow import tag, text, document, clear, spawn, transition
from tagflow import TagResponse, DocumentMiddleware, Live
from contextlib import asynccontextmanager
from fastapi import FastAPI
from trio import sleep
live = Live()
app = FastAPI(lifespan=live.run)
app.add_middleware(DocumentMiddleware)
@app.get("/counter", response_class=TagResponse)
async def counter():
session = await live.session()
with tag.html():
with tag.body():
live.script_tag()
session.client_tag()
with tag.h1():
async def loop():
i = 0
while True:
with transition():
clear()
text(str(i))
await sleep(1)
i += 1
spawn(loop)
This feature is currently based on Trio for structured concurrency with
nurseries. It works with the Hypercorn ASGI server and FastAPI. It might be nice
to change it into a plain ASGI middleware; maybe also supporting other async
systems via anyio
.
It remains to be seen whether the "session" concept makes sense, and how to
think about session lifecycles, reconnects, etc.
License
Tagflow is open source software released under the MIT license. See the
LICENSE file for more details.