GitHub

Tutorial: a notes app

We'll build a small multi-user notes app in one file: a page with browser-side state, a reusable component, a SQLite database behind server functions, and sign-in with sessions. The finished app is about 90 lines.

#1. A page with state

app.pyweb
from pyweb import App

app = App(title="Notes")


@app.page("/")
def Home():
    notes = ["Try PyWeb"]
    draft = ""

    def save():
        if draft.strip():
            notes.append(draft.strip())
        draft = ""

    <main>
        <h1>Notes ({len(notes)})</h1>
        <form onsubmit={save}>
            <input bind={draft} placeholder="Write a note" />
            <button disabled={not draft.strip()}>Save</button>
        </form>
        <ul>
            for note in notes:
                <li>{note}</li>
        </ul>
    </main>
What this compiles to
JavaScript
// Home.js
function Home($s) {
  const notes = $signal("notes" in $s ? $s["notes"] : ["Try PyWeb"]);
  const draft = $signal("draft" in $s ? $s["draft"] : "");
  function save() {
    if ($py.truth($py.m(draft(), "strip"))) {
      $py.mut(notes, [], ($v) => $py.m($v, "append", $py.m(draft(), "strip")));
    }
    draft("");
  }
  return [$h("main", null, () => [
      $h("h1", null, () => [
        $t("Notes ("),
        $dyn(() => $py.len(notes())),
        $t(")")
      ]),
      $h("form", {"onsubmit": save}, () => [
        $h("input", {"$bind": draft, "placeholder": "Write a note"}),
        $h("button", {"disabled": () => !$py.truth($py.m(draft(), "strip"))}, () => [$t("Save")])
      ]),
      $h("ul", null, () => [$list(() => notes(), (note) => [$h("li", null, () => [$t($py.text(note))])])])
    ])];
}
$mount("Home", Home);
  • bind={draft} keeps the input and the variable in sync both ways.
  • onsubmit={save} runs save and prevents the browser's default form submission.
  • notes.append(...) mutates a list that markup reads. PyWeb makes the update copy-on-write, so the <ul> adds exactly one <li> and the heading's count updates.
  • disabled={not draft.strip()} re-evaluates as you type.

Inside handlers, assigning a page variable (draft = "") updates the page state. There is no nonlocal and no setState.

#2. A component

Any capitalised function containing markup is a component. Parameters are props; children receives nested markup.

app.pyweb
from pyweb import App, component

app = App(title="Notes")


@component
def Note(text, on_delete):
    <li>
        <span>{text}</span>
        <button onclick={on_delete} aria-label="Delete">×</button>
    </li>


@app.page("/")
def Home():
    notes = ["Try PyWeb", "Write docs"]

    def delete(i):
        del notes[i]

    <ul>
        for i, text in enumerate(notes):
            <Note text={text} on_delete={lambda: delete(i)} />
    </ul>
What this compiles to
JavaScript
// Home.js
function Note($p) {
  const text = $p["text"] || (() => null);
  const on_delete = $p["on_delete"] || (() => null);
  return [$h("li", null, () => [
      $h("span", null, () => [$dyn(() => text())]),
      $h("button", {"onclick": (e) => on_delete()(e), "aria-label": "Delete"}, () => [$t("\u00d7")])
    ])];
}
function Home($s) {
  const notes = $signal("notes" in $s ? $s["notes"] : ["Try PyWeb", "Write docs"]);
  function delete_(i) {
    $py.delp(notes, [i]);
  }
  return [$h("ul", null, () => [$list(() => $py.enumerate(notes()), ([i, text]) => [Note({"text": () => text, "on_delete": () => (() => (delete_(i)))})])])];
}
$mount("Home", Home);

onclick={delete(i)} would also work: an event attribute's expression runs when the event fires, not while rendering.

#3. Persist to a database with server functions

Functions marked @server run only on the server. Calling one from a handler sends a typed RPC request and waits for the result; the compiler generates both ends.

app.pyweb
from pyweb import App, RPCError, server
from pyweb.db import connect

app = App(title="Notes")
db = connect("sqlite:///notes.db")
db.execute("create table if not exists notes (id integer primary key, body text not null)")


def all_notes():
    return db.execute("select id, body from notes order by id").dicts()


@server
def add_note(body: str) -> list:
    if not body.strip():
        raise RPCError("validation_error", "A note can't be empty.")
    db.execute("insert into notes (body) values (?)", (body.strip(),))
    return all_notes()


@server
def delete_note(note_id: int) -> list:
    db.execute("delete from notes where id = ?", (note_id,))
    return all_notes()


@app.page("/")
def Home():
    notes = all_notes()
    draft = ""
    error = ""

    def save():
        try:
            notes = add_note(draft)
            draft = ""
            error = ""
        except RPCError as e:
            error = str(e)

    def remove(note):
        notes = delete_note(note["id"])

    <main>
        <form onsubmit={save}>
            <input bind={draft} />
            <button>Save</button>
        </form>
        <p class="error">{error}</p>
        <ul>
            for note in notes:
                <li>{note["body"]} <button onclick={remove(note)}>×</button></li>
        </ul>
    </main>
What this compiles to
JavaScript
// Home.js
function Home($s) {
  const notes = $signal("notes" in $s ? $s["notes"] : null);
  const draft = $signal("draft" in $s ? $s["draft"] : "");
  const error = $signal("error" in $s ? $s["error"] : "");
  async function save() {
    let e;
    try {
      notes((await $rpc("add_note", {"body": draft()})));
      draft("");
      error("");
    } catch ($err) {
      if ($py.exc($err, ["RPCError"])) {
        e = $err;
        error($py.str(e));
      } else { throw $err; }
    }
  }
  async function remove(note) {
    notes((await $rpc("delete_note", {"note_id": $py.at(note, "id")})));
  }
  return [$h("main", null, () => [
      $h("form", {"onsubmit": save}, () => [
        $h("input", {"$bind": draft}),
        $h("button", null, () => [$t("Save")])
      ]),
      $h("p", {"class": "error"}, () => [$dyn(() => error())]),
      $h("ul", null, () => [$list(() => notes(), (note) => [$h("li", null, () => [
            $t($py.text($py.at(note, "body"))),
            $t(" "),
            $h("button", {"onclick": (async () => (await remove(note)))}, () => [$t("\u00d7")])
          ])])])
    ])];
}
$mount("Home", Home);

What the compiler did (run pyweb inspect app.pyweb to see it):

  • notes = all_notes() calls a module function, so it runs on the server for each request; its value is rendered into the HTML and sent to the browser as initial state.
  • save and remove are compiled to async JavaScript functions that call /__pyweb/rpc/add_note and /__pyweb/rpc/delete_note.
  • db and connect never reach the browser. Referencing them from a handler is a compile error that points at the line.
  • RPCError("validation_error", ...) becomes a typed error in the browser; except RPCError as e catches it there.

#4. Sign-in with sessions

pyweb.session stores a signed, HTTP-only cookie. Pages can redirect before rendering.

app.pyweb
from pyweb import App, redirect, server, session

app = App(title="Notes")


@server
def sign_in(name: str) -> bool:
    if not name.strip():
        return False
    session.login(name.strip())
    return True


@app.page("/login")
def Login():
    name = ""
    error = ""

    def go():
        if sign_in(name):
            window.location.href = "/"
        else:
            error = "Enter a name"

    <form onsubmit={go}>
        <input bind={name} placeholder="Your name" />
        <button>Sign in</button>
        <p>{error}</p>
    </form>


@app.page("/")
def Home():
    user = session.user()
    if not user:
        return redirect("/login")
    who = user["sub"]

    <h1>Hello, {who}</h1>
What this compiles to
JavaScript
// Login.js
function Login($s) {
  const name = $signal("name" in $s ? $s["name"] : "");
  const error = $signal("error" in $s ? $s["error"] : "");
  async function go() {
    if ($py.truth((await $rpc("sign_in", {"name": name()})))) {
      window.location.href = "/";
    } else {
      error("Enter a name");
    }
  }
  return [$h("form", {"onsubmit": go}, () => [
      $h("input", {"$bind": name, "placeholder": "Your name"}),
      $h("button", null, () => [$t("Sign in")]),
      $h("p", null, () => [$dyn(() => error())])
    ])];
}
$mount("Login", Login);

Only who is sent to the browser. The full session record in user stays on the server because browser code never reads it.

In server functions, session.require() returns the session or raises an unauthenticated RPC error. Set PYWEB_AUTH_SECRET in production (see Authentication).

#5. Ship it

Terminal
pyweb check app.pyweb
pyweb build app.pyweb --out dist --production
PYWEB_AUTH_SECRET=change-me pyweb serve dist

The complete, tested versions of these apps live in examples/.

Edit this page on GitHub