Skip to content

Components (Python)

A .py file under resources/views/ is a real, importable, type-checkable Python module that builds HTML by calling plain functions — no template language, no separate compiler, nothing to learn beyond Python itself. It's the third view style alongside plain Jinja2 (.html, see Views) and Blade (.blade.html, see Blade); all three are first-class and freely mixable in the same project.

zeython.components is entirely self-contained — no third-party HTML library, no new dependency. It's a small, from-scratch module built for this framework: elements are called like functions for attributes and indexed like a mapping for children.

A minimal view

# resources/views/posts/show.py
from zeython.components import Node, a, div, h1, p

def view(request, post) -> Node:
    return div(".post")[
        h1[post.title],
        p[post.body],
        a(href="/posts")["Back"],
    ]

render() from a controller looks exactly the same as it does for the other two styles:

from zeython.views import render

async def show(request):
    return render(request, "posts/show.py", {"post": post})

Zeython imports the file, calls view(request, post=post), renders the returned tree to a string, and wraps it in an HTMLResponse — the same contract a Jinja2 or Blade template fulfills, so a controller can switch view styles without touching anything else.

Attributes and children

Calling an element sets attributes; indexing it sets children. Both return a new element rather than mutating the original, so a shared base element is always safe to reuse:

from zeython.components import div, span

card = div(".card")
str(card(id="a")["A"])  # '<div class="card" id="a">A</div>'
str(card(id="b")["B"])  # '<div class="card" id="b">B</div>'
str(card["plain"])      # '<div class="card">plain</div>' -- unaffected

An optional leading string is CSS-selector-style shorthand for id/ class: #id once, .class any number of times.

div("#main.card.active")   # id="main" class="card active"

Every . in that leading string starts a new class, with no exception -- including a literal . inside a class name itself. That breaks Tailwind's fractional-spacing utilities (px-1.5, py-0.5, w-2.5, ...), which would otherwise split into two bogus classes (px-1 and 5). Use class_="px-1.5 py-0.5" instead of the shorthand for any class list that contains one of those:

div(class_="px-1.5 py-0.5")   # correct: class="px-1.5 py-0.5"
div(".px-1.5.py-0.5")         # wrong: class="px-1 5 py-0 5"

Keyword arguments become HTML attributes. A trailing underscore is stripped — for names that shadow a Python keyword or builtin (class_, for_, type_, del_, map_, object_); remaining underscores become hyphens, for data-*/aria-* attributes:

from zeython.components import div, label

label(for_="email")            # <label for="email">
div(data_controls="menu")      # <div data-controls="menu">
div(**{"_": "on click ..."})   # <div _="on click ...">  (_hyperscript, bare "_" kept as-is)

True renders a bare boolean attribute, False/None omits it entirely, and a dict value joins its truthy keys space-separated — handy for conditional classes:

from zeython.components import button

button(disabled=is_locked)                          # omitted when False/None
button(class_={"btn": True, "btn-primary": is_main}) # class="btn btn-primary"

Children

Index an element with a single child, a tuple of children, or any nested mix of lists/generators — they're flattened automatically. None and False are skipped (so condition and node reads naturally), and a zero-argument callable is called and its result rendered:

from zeython.components import div, li, ul

ul[(li[item] for item in items)]
div[
    header,
    is_admin and admin_banner,   # skipped entirely when False
    lambda: expensive_footer(),  # called lazily
]

Escaping

Every string/number child and every attribute value is escaped by default via html.escape. Wrap trusted markup in safe() to emit it unescaped — use this only for markup you trust (a constant, or output that's already been through render_to_string), never for unsanitized user input:

from zeython.components import div, safe

div[safe("<b>already-safe markup</b>")]

Any object exposing Jinja2/MarkupSafe's __html__() convention is treated the same way and passed through unescaped — this is what makes a .py component embeddable inside a {{ }} expression in a Jinja2 or Blade template, and a Jinja2 Markup value embeddable as a child here.

Reusable components are just functions

There's no separate "component" concept to learn — a function that returns a Node is a component:

# app/Components/card.py
from zeython.components import Node, div, h2

def card(title: str, body: Node) -> Node:
    return div(".card")[h2[title], body]
from app.Components.card import card
from zeython.components import Node, div

def view(request, post) -> Node:
    return div[
        card(post.title, post.body),
        card("Related", related_list),
    ]

Compose, extract, and test them exactly like any other Python function — str(card("T", "B")) in a unit test, no test client or template engine required.

Elements not built in

Every common HTML5 element is exported by name (div, p, a, ul, ...), including the HTML5 void elements (br, hr, img, input, meta, ... — these reject children, since a self-closing tag can't have any). For anything else — a web component, a less common tag — use tag():

from zeython.components import tag

my_widget = tag("my-widget")
my_widget(data_open=True)["content"]

self_closing = tag("x-icon", void=True)

Rendering without a single root element

str(element) renders one element. For a bare list of nodes — a partial with no single wrapping tag — call render_to_string() directly:

from zeython.components import li, render_to_string

render_to_string([li["a"], li["b"]])  # '<li>a</li><li>b</li>'

Design notes

.py views follow the same rule as Blade's @auth/@can: rendering itself is synchronous, so a view(request, **context) function does no async I/O — resolve anything from the database in the controller and pass the result in as context, the same as you already do for .html and .blade.html views.

Never build the name passed to render() from request input. A .py view is a real Python module -- render(request, name, ...) imports and executes whatever file name resolves to under resources/views/. For .html/.blade.html that's template disclosure at worst; for .py it's arbitrary code execution as your app if an attacker can influence which file gets loaded. render(request, f"posts/{request.path_params['view']}.py", {}) is exactly that mistake -- always pass a fixed, hardcoded string literal for name, the same way every example on this page does.