GitHub

The .pyweb language

A .pyweb file is a Python module in which page and component functions may contain markup statements. Everything else is ordinary Python and is parsed by CPython's own parser, so line numbers, scoping and error messages are exact.

#File structure

app.pyweb
import os                         # imports: server-side only

from pyweb import App, component, server

app = App(title="My app", stylesheets=["/static/app.css"], lang="en")

PAGE_SIZE = 20                    # literal module constants: usable everywhere


def slugify(text):                # plain helper: compiled to JS if browser code calls it
    return text.lower().replace(" ", "-")


@server                           # runs on the server, callable from the browser
def search(q: str) -> list:
    return []


@component                        # markup + props
def Badge(label, children=None):
    <span class="badge">{label}: {children}</span>


@app.page("/")                    # a page: route + markup
def Home():
    q = ""
    <h1>Hello</h1>
What this compiles to

No JavaScript: nothing on this page changes after it loads.

Module-level itemWhere it runs
import ... / from ... import ...Server only. Names imported this way are never available in browser code (except pyweb.browser, below).
NAME = <literal> (numbers, strings, lists, dicts, …)Both. Inlined into browser code when referenced.
NAME = <anything else> (connections, objects)Server only.
@server functionServer. Calls from browser code become RPC.
Undecorated functionServer; also compiled to JavaScript on demand when browser code calls it. Compile error if it can't run in a browser.
@component / capitalised function with markupRendered on the server and in the browser.
@app.page(route) functionRendered per request on the server, interactive in the browser.
classServer only.

#Markup statements

A line starting with <tag is markup. Markup can span several lines and nest by its own tags; indentation inside markup is cosmetic.

app.pyweb
from pyweb import App

app = App()


@app.page("/")
def Home():
    name = "Ada"
    items = ["a", "b"]
    show = True

    <main class="page">
        <h1>Hello, {name}!</h1>
        <img
            src="/static/logo.png"
            alt="Logo" />
        <p>{len(items)} items</p>
        <!-- comments are dropped -->
    </main>
What this compiles to

No JavaScript: nothing on this page changes after it loads.

#Text and expressions

  • Text inside a tag is HTML-escaped.
  • {expr} inserts the value of any Python expression; it is re-evaluated when the state it reads changes.
  • Values render like str(value), except None renders nothing.
  • Whitespace within a line is kept (collapsed to single spaces); line breaks between lines are not, as in JSX. Put text that needs a separating space on the same line.
  • To write a literal brace, use an expression: {"{"}.

#Attributes

FormMeaning
class="card"Literal string. No interpolation inside quotes.
class={expr}Python expression; updates reactively.
disabledBoolean attribute (present).
disabled={flag}True → present, False/None → absent.
class={{"done": t["done"], "row": True}}A dict: keys with truthy values become classes.
style={{"color": color, "font_size": "14px"}}A dict of CSS properties; snake_case/camelCase become kebab-case.
href={url}, src, actionURLs starting with javascript: are replaced by #.
onclick={handler}Event handler (see below). Any on<event> works: oninput, onchange, onsubmit, onkeydown, …
bind={name}Two-way binding to a page variable.

#Events

The value of an on* attribute can be:

  • a handler name: onclick={save}. The handler may take the DOM event as its single argument (def save(e):) or no arguments;
  • a lambda: onclick={lambda: remove(item)};
  • any other expression, evaluated when the event fires: onclick={remove(item)}.

onsubmit automatically calls preventDefault().

#Two-way binding

bind={name} works on <input>, <textarea> and <select>:

ElementBound propertyUpdates on
text-like <input>, <textarea>valueevery keystroke
<input type="number"> with a numeric initial valuevalue as a numbervalid numbers
<input type="checkbox">checked (bool)change
<input type="radio" value="x">variable == "x"change
<select>valuechange

The variable is updated before any oninput/onchange handler on the same element runs, so handlers see the new value.

#Control flow

for and if/elif/else lines whose bodies are markup are part of the markup:

app.pyweb
from pyweb import App

app = App()


@app.page("/")
def Home():
    todos = [{"title": "Write docs", "done": False}]
    n = len(todos)

    <ul>
        for i, todo in enumerate(todos):
            <li class={{"done": todo["done"]}}>{i + 1}. {todo["title"]}</li>
    </ul>
    if n == 0:
        <p>Nothing to do.</p>
    elif n == 1:
        <p>One thing to do.</p>
    else:
        <p>{n} things to do.</p>
What this compiles to

No JavaScript: nothing on this page changes after it loads.

Lists render with keyed updates: when the list changes, rows for items that are still present keep their DOM nodes; only added rows render and only removed rows are deleted.

#Components

app.pyweb
from pyweb import App, component

app = App()


@component
def Card(title, subtitle="", children=None):
    <section class="card">
        <h2>{title}</h2>
        if subtitle:
            <p class="muted">{subtitle}</p>
        {children}
    </section>


@app.page("/")
def Home():
    <Card title="Welcome" subtitle="Components take props">
        <p>Nested markup arrives as children.</p>
    </Card>
What this compiles to
JavaScript
// Home.js
function Card($p) {
  const title = $p["title"] || (() => null);
  const subtitle = $p["subtitle"] || (() => "");
  const children = $p["children"] || (() => null);
  return [$h("section", {"class": "card"}, () => [
      $h("h2", null, () => [$dyn(() => title())]),
      $when(() => $py.truth(subtitle()), () => [
        $h("p", {"class": "muted"}, () => [$dyn(() => subtitle())]),
        $dyn(() => children())
      ])
    ])];
}
function Home($s) {
  return [Card({"title": () => "Welcome", "subtitle": () => "Components take props", "children": () => [$h("p", null, () => [$t("Nested markup arrives as children.")])]})];
}
$mount("Home", Home);
  • Lowercase tags are HTML elements; capitalised tags are components defined in the same file or imported from another .pyweb file.
  • Parameters are props. Parameters with defaults are optional; missing required props and unknown props are compile errors.
  • A children parameter receives the nested markup.
  • Components may have their own state and handlers, created per instance. Their initial state must be computable in the browser (pass server data in as props).
  • Callback props are called like functions: on_delete={...} in the parent, onclick={on_delete} inside the component.

#Splitting an app into files

Components, @server functions and constants can live in other .pyweb files next to app.pyweb (or in subfolders) and be imported with from ... import:

Output
app.pyweb            pages
widgets.pyweb        components and the server functions they use
ui/icons.pyweb       imported as ui.icons
app.pyweb
# widgets.pyweb
from pyweb import server

COLOR = "red"


@server
def save(item: str) -> str:
    return "saved " + item


def Card(title, children):
    <section class={COLOR}>
        <h2>{title}</h2>
        {children}
    </section>
What this compiles to

No JavaScript: nothing on this page changes after it loads.

Python
# app.pyweb
from pyweb import App
from widgets import Card, save

app = App()
...
  • An imported component works exactly like a local one: props, children, its own state, and the components it uses or imports itself (so widgets.pyweb can do from ui.icons import Icon).
  • Each file keeps its own names. widgets.pyweb and app.pyweb can both define COLOR; each component sees its own file's value.
  • @server functions are imported by name (no as), because their name is their RPC endpoint; two server functions with the same name anywhere in the app are a compile error.
  • Pages belong in the app file. Other files hold components, server functions, helpers and constants. Circular imports are a compile error.
  • Plain .py modules still import as usual for server code.
  • pyweb build copies the imported files into dist/, and pyweb dev reloads when any of them changes.

#Pages

app.pyweb
from pyweb import App

app = App(title="Shop")


@app.page("/products/{product_id}", title="Product")
def Product(product_id: int):
    label = "Product #" + str(product_id)
    <h1>{label}</h1>
What this compiles to

No JavaScript: nothing on this page changes after it loads.

See Pages, routing and assets.

#Lifecycle hook

A handler named on_mount runs once in the browser after the page or component is in the document. Use it for timers, focus, or loading data after first paint:

app.pyweb
from pyweb import App, server

app = App()


@server
def server_time() -> str:
    import datetime
    return datetime.datetime.now().isoformat(timespec="seconds")


@app.page("/")
def Clock():
    now = ""

    def refresh():
        now = server_time()

    def on_mount():
        refresh()
        setInterval(refresh, 5000)

    <p>Server time: {now}</p>
What this compiles to
JavaScript
// Clock.js
function Clock($s) {
  const now = $signal("now" in $s ? $s["now"] : "");
  async function refresh() {
    now((await $rpc("server_time", {})));
  }
  async function on_mount() {
    (await refresh());
    setInterval(refresh, 5000);
  }
  $onMount(on_mount);
  return [$h("p", null, () => [
      $t("Server time: "),
      $dyn(() => now())
    ])];
}
$mount("Clock", Clock);

#Browser APIs

Browser code can use real JavaScript globals directly: window, document, console, localStorage, sessionStorage, navigator, location, history, setTimeout, setInterval, clearTimeout, clearInterval, fetch, alert, confirm, prompt, Math, JSON, Date, Intl, URL, URLSearchParams, FormData, crypto, performance, requestAnimationFrame and a few more. Calls on them are passed through unchanged (localStorage.setItem("k", v)).

Names in pyweb.browser (for example from pyweb.browser import storage) map to their JavaScript counterparts as well.

The supported Python subset for browser code is described in Python in the browser.

Edit this page on GitHub