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
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
// 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);page Home route=/
browser notes reactive state: .append() in save(); literal initial value
browser draft reactive state: bound to an input (line 19); literal initial value
browser save event handler (compiled to JavaScript)<main>
<h1>Notes (1)</h1>
<form>
<input value="" placeholder="Write a note"><button disabled>Save</button>
</form><ul>
<li>Try PyWeb</li>
</ul></main>bind={draft}keeps the input and the variable in sync both ways.onsubmit={save}runssaveand 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.
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
// 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);page Home route=/
browser notes reactive state: item assigned in delete(); literal initial value
browser delete event handler (compiled to JavaScript)<ul>
<li><span>Try PyWeb</span><button aria-label="Delete">×</button>
</li>
<li><span>Write docs</span><button aria-label="Delete">×</button>
</li>
</ul>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.
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
// 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);page Home route=/
browser notes reactive state: assigned in save(); initial value computed on the server and sent
browser draft reactive state: bound to an input (line 46); literal initial value
browser error reactive state: assigned in save(); literal initial value
browser save event handler (compiled to JavaScript)
browser remove event handler (compiled to JavaScript)
rpc POST /__pyweb/rpc/add_note (body: str) -> list
rpc POST /__pyweb/rpc/delete_note (note_id: int) -> list<main>
<form>
<input value=""><button>Save</button>
</form><p class="error"></p>
<ul>
</ul></main>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.saveandremoveare compiled toasyncJavaScript functions that call/__pyweb/rpc/add_noteand/__pyweb/rpc/delete_note.dbandconnectnever 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 ecatches it there.
#4. Sign-in with sessions
pyweb.session stores a signed, HTTP-only cookie. Pages can redirect before rendering.
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
// 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);page Login route=/login
browser name reactive state: bound to an input (line 26); literal initial value
browser error reactive state: assigned in go(); literal initial value
browser go event handler (compiled to JavaScript)
page Home route=/
server user computed per request; never read by browser code, never sent
server who computed per request on the server; value sent because browser code reads it
rpc POST /__pyweb/rpc/sign_in (name: str) -> bool<form>
<input value="" placeholder="Your name"><button>Sign in</button>
<p></p>
</form>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
pyweb check app.pyweb
pyweb build app.pyweb --out dist --production
PYWEB_AUTH_SECRET=change-me pyweb serve distThe complete, tested versions of these apps live in examples/.