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.
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:
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.