Nothing to do.
elif n == 1:
One thing to do.
else:
{n} things to do.
```
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
```pyweb
from pyweb import App, component
app = App()
@component
def Card(title, subtitle="", children=None):
` inside a `
`),
hydration stops and the page is rendered from scratch on the client, as
earlier versions always did. The browser console shows a warning that
names the first difference, and the page root gets
`data-pw-mode="rendered"` instead of `"hydrated"`.
---
# Python in the browser
Event handlers, markup expressions, computed values and helpers that
browser code calls are compiled from Python to JavaScript. PyWeb
compiles a **defined subset** of Python, with Python's semantics where
JavaScript differs, and reports anything outside it as a compile error
with the file and line.
The translation is checked continuously by a differential test that
evaluates well over a hundred expressions in CPython and in the
compiled JavaScript and requires identical results, including which
exception is raised.
## Supported
**Expressions:** literals (int, float, str, bool, None, list, tuple,
dict, set), f-strings (with format specs such as `{x:.2f}`, `{n:,}`,
`{v!r}`), arithmetic (`+ - * / // % **`, with Python's floor division
and modulo signs), comparisons including chained ones (`0 < x < 10`),
`==` on lists and dicts (structural), `in`/`not in`, `is None`,
`and`/`or`/`not` with Python truthiness (empty list, dict and string are
false), conditional expressions, indexing with negative indices,
slicing with steps, list/dict/set comprehensions and generator
expressions (multiple `for`/`if` clauses), lambdas, unpacking in
literals (`[*a, *b]`, `{**d}`), `await`.
**Statements:** assignment (including tuple unpacking and chained
assignment), augmented assignment, `if`/`elif`/`else`, `for` (with
tuple targets), `while`, `break`, `continue`, `return`, `pass`,
`del x[k]`, `try`/`except`/`finally` (matching on exception class
names, `except X as e`), `raise`, `assert`, nested `def`.
**Builtins:** `len`, `str`, `repr`, `int`, `float`, `bool`, `abs`,
`min`, `max` (with `key=`, `default=`), `sum`, `round` (banker's
rounding like Python), `range`, `sorted` (`key=`, `reverse=`),
`reversed`, `enumerate`, `zip`, `list`, `tuple`, `dict`, `set`, `any`,
`all`, `chr`, `ord`, `isinstance` (with builtin type names), `print`
(to the browser console).
**Methods:**
- `str`: `upper lower strip lstrip rstrip split splitlines join replace
startswith endswith find rfind index count title capitalize isdigit
isnumeric isalpha isalnum isspace isupper islower zfill ljust rjust
center format`
- `list`: `append extend insert pop remove index count clear sort
reverse copy`
- `dict`: `get keys values items pop update setdefault copy clear`
- `set`: `add discard remove clear copy`
**Errors:** out-of-range indexes raise `IndexError`, missing keys
`KeyError`, bad conversions `ValueError`, mixing `str + int`
`TypeError`, division by zero `ZeroDivisionError`, so the same
`try/except` you would write in Python works.
## Not supported (compile errors)
Classes, `with`, `import` inside functions, generators (`yield`),
`global` state outside the page, the walrus operator, `*args`/`**kwargs`
parameters, slice assignment, `for`/`else` and `while`/`else`, keyword
arguments to arbitrary JavaScript functions, integers larger than
2**53, and any module-level name that only exists on the server
(imports, database handles, classes). For those, call an `@server`
function.
## Known differences
- JavaScript has one number type: `2.0` prints as `2` and `1/3` uses
float arithmetic as in Python, but `int` and `float` are not
distinguished when printing whole numbers.
- `dict` values in the browser are plain JSON objects, so keys are
strings. Attribute access (`user.name`) and subscripting
(`user["name"]`) both work on them.
- `print` writes to the browser console.
- Strings compare by UTF-16 code units, which only differs from Python
for characters outside the Basic Multilingual Plane.
## Seeing the output
The compiled module for each page is readable JavaScript (minified only
by `pyweb build --production`). In `pyweb dev` open
`/static/.js` to see exactly what runs in the browser.
---
# npm packages
Browser code can use packages from npm: charts, maps, date pickers,
editors, confetti. You don't need Node.js. PyWeb downloads the package
from the npm registry, keeps only the files the browser loads, and
serves them from your app.
## Add a package
Run this in the folder that holds `app.pyweb`:
```text
pyweb add chart.js/auto
```
```text
@kurkle/color@0.3.4 1 file(s) (dependency)
chart.js@4.5.1 3 file(s)
pyweb.lock: 2 package(s); files in static/vendor/
```
`pyweb add` does four things:
1. It picks the newest version that matches the range you give
(`pyweb add chart.js@^4`, `pyweb add lit@3.2`), or the latest one.
2. It downloads the package and checks it against the registry's
sha512 checksum.
3. It follows the `import` statements from the package's browser entry
point and copies only the files they reach into
`static/vendor/@/`. Dependencies are installed the
same way.
4. It records versions, checksums and file lists in `pyweb.lock`.
Commit `pyweb.lock` and `static/vendor/`. Your app then builds and runs
without network access. Many packages keep their files in a `dist/`
folder, so if your `.gitignore` has a bare `dist/` line, change it to
`/dist/` (only the build output at the top) or the vendored files won't
be committed. Projects made with `pyweb new` already do this. Other commands:
```text
pyweb add reinstall exactly what pyweb.lock lists
pyweb add chart.js@^4 add, or change the requested range
pyweb remove chart.js remove a package and the files only it used
```
Locked versions are kept until you ask for a different range, so
running `pyweb add` again never upgrades a package by surprise.
## Use it in browser code
`npm(package, export)` binds a name to one of the package's exports at
the top of the file:
```pyweb
from pyweb import App, npm
Chart = npm("chart.js/auto") # the default export
confetti = npm("canvas-confetti")
dates = npm("date-fns", "*") # the whole module
app = App()
@app.page("/")
def Sales():
canvas = None
chart = None
total = 3
until = ""
def on_mount():
chart = Chart(canvas, {"type": "bar",
"data": {"labels": ["Mon", "Tue", "Wed"],
"datasets": [{"label": "Orders", "data": [1, 2, 3]}]}})
def add():
total += 1
chart.data.labels.append("Thu")
chart.data.datasets[0].data.append(total)
chart.update()
until = dates.format(dates.addDays(dates.startOfToday(), total), "EEE d MMM")
confetti(particleCount=80, spread=60)
Add a day
{total} orders, planned until {until}
```
The rules:
- **Calling a class constructs it.** `Chart(canvas, config)` becomes
`new Chart(canvas, config)` in JavaScript, so you write Python call
syntax for both functions and classes.
- **Keyword arguments become an options object.**
`confetti(particleCount=80, spread=60)` calls
`confetti({particleCount: 80, spread: 60})`, the convention most
JavaScript libraries use.
- **Objects are used as they are.** Attributes, methods and lists on a
package's objects are the real JavaScript ones. When browser code
changes one that a page variable holds (`chart.value = 5`), the page
re-renders anything that shows it.
- **npm values only exist in the browser.** Use them in event handlers,
`on_mount` and other browser functions. Using one directly in markup
is a compile error, because the server renders markup first and has
no copy of the package. Calling one from server code raises an error
that says so.
- **Pages load only what they use.** Each page imports just the
packages its own code calls, so adding a charting library doesn't
slow down pages without charts. For a large module such as
`date-fns`, a subpath (`npm("date-fns/format")`) loads only the one
function instead of the whole library.
If a package isn't installed, the compiler points at the `npm(...)`
line and tells you which `pyweb add` command to run.
## Elements: `ref=`
Many libraries need a DOM element to draw into. `ref={name}` sets a
page variable to the element once it exists, which is in time for
`on_mount`:
```pyweb
from pyweb import App
app = App()
@app.page("/")
def Focus():
box = None
def on_mount():
box.focus()
```
## Web components
Packages that define custom elements, such as Shoelace or Lit
components, work too. Import the module for its side effect and use the
tags in markup:
```pyweb
from pyweb import App, npm
shoelace = npm("@shoelace-style/shoelace/dist/components/button/button.js", "*")
app = App()
@app.page("/")
def Buttons():
clicks = 0
def on_mount():
print("loaded", shoelace)
def click():
clicks += 1
Clicked {clicks} times
```
A page imports a package only if its code uses the bound name, which is
why `on_mount` mentions `shoelace` here.
## Which packages work
Packages that ship ES modules for browsers work: most modern UI,
charting, date, maths, animation and editor libraries. `pyweb add`
explains the problem when one doesn't:
- **CommonJS only** (`module.exports`, `require()`): look for an ES
module build, often a package with `-es` or `esm` in its name
(`lodash-es` instead of `lodash`).
- **Node.js modules** (`fs`, `path`, `crypto`, ...): the package is
meant for servers. Do that work in an `@server` function in Python.
- **Version conflicts**: PyWeb installs one version of each package. If
two packages need incompatible versions of a dependency, it tells you
which ones.
Packages are served by your app, so the default Content Security
Policy (`script-src 'self'`) still applies. The import map that tells
the browser where each package lives is allowed by its hash.
## Editor support
`pyweb lsp` reads `pyweb.lock`. Hovering a name bound with `npm()`
shows the installed version and, when the package ships TypeScript
declarations, the signature (`class Chart(item: ChartItem, config: ...)`).
Inside `npm("` it completes installed packages, and after `name.` on a
whole-module binding it completes the module's exports.
`pyweb dts FILE.d.ts` turns TypeScript declarations into Python
dataclasses, for typing data that a package hands you.
## Building and deploying
`pyweb build` copies `pyweb.lock` and `static/vendor/` into `dist/`.
With `--production` your page code is minified and hashed as usual;
vendored files are served as they are, with long-lived caching because
their URLs include the version.
---
# Server functions and RPC
A module-level function decorated with `@server` runs only on the
server. Browser code calls it like any function; the compiler turns the
call into an awaited HTTP request and the enclosing handler into an
`async` function.
```pyweb
from pyweb import App, RPCError, server
app = App()
STOCK = {"apple": 3, "pear": 0}
@server
def buy(item: str, quantity: int = 1) -> dict:
if STOCK.get(item, 0) < quantity:
raise RPCError("conflict", f"Only {STOCK.get(item, 0)} {item}(s) left.")
STOCK[item] -= quantity
return {"item": item, "left": STOCK[item]}
@app.page("/")
def Shop():
message = ""
def purchase(item):
try:
result = buy(item, quantity=1)
message = f"Bought one {item}; {result['left']} left."
except RPCError as e:
message = str(e)
Buy an apple
Buy a pear
{message}
```
## Rules
- Parameters are matched by name; positional and keyword calls both
work. `*args`/`**kwargs` are not allowed on `@server` functions.
- Arguments are validated against annotations before your function runs:
`int`, `float` and `bool` are coerced (`"3"` → `3`); a failed coercion
or a missing required argument is a `validation_error`. Other
annotations are documentation.
- Return values are serialised to JSON (the same conversions as page
state: dataclasses, `datetime`, `Decimal`, `to_dict()` objects).
- `async def` server functions are supported.
- Server calls cannot appear directly in markup expressions (`{load()}`):
markup must be synchronous. Load data in a page variable or a handler.
## Errors
Raise `pyweb.RPCError(code, message)` for an error the browser should
handle. The code maps to an HTTP status and is available in the browser
as `e.code`; `str(e)` is the message.
| Code | Status | Use for |
|---|---|---|
| `validation_error` | 422 | bad input (also raised automatically) |
| `unauthenticated` | 401 | not signed in (`session.require()` raises it) |
| `forbidden` | 403 | signed in, not allowed |
| `not_found` | 404 | missing resource |
| `conflict` | 409 | state changed underneath the caller |
| `rate_limited` | 429 | too many calls |
| `timeout` | 504 | exceeded the server's RPC timeout |
| `internal` | 500 | any other exception (details are logged, not sent) |
`ValueError` and `TypeError` raised by your function are reported as
`validation_error` with their message. Any other exception is reported
as `internal` with a generic message; the traceback goes to the server
log with the request id.
In page functions, `RPCError("not_found")`, `("forbidden")` and
`("unauthenticated")` turn into 404, 403 and 401 responses.
## Request context
Inside server functions and page bodies:
```python
from pyweb import request, session, redirect, NotFound
request.path # "/products/3"
request.method # "GET" / "POST"
request.query # {"page": "2"}
request.headers # dict
request.cookies # dict
request.id # request id (also sent as X-Request-Id)
request.set_cookie("theme", "dark", max_age=86400)
session.user() # {"sub": user_id, ...} or None
session.login(user_id, roles=["admin"])
session.logout()
session.require("admin") # returns the session or raises RPCError
raise NotFound() # in a page: respond 404
redirect("/login") # return from a page: respond 303
```
## The wire protocol
```text
POST /__pyweb/rpc/
Content-Type: application/json
X-CSRF-Token: (when CSRF protection is configured)
{"args": {"item": "apple", "quantity": 1}}
200 {"result": ...}
4xx/5xx {"error": {"code": "conflict", "message": "...", "details": {}}}
```
A function that streams (below) answers `200` with
`Content-Type: application/x-ndjson`: one `{"chunk": value}` line per
`yield`, then `{"done": true}`, or `{"error": {...}}` if it raises part-way.
Every response carries `X-Request-Id` and a W3C `traceparent` header.
Because it is plain JSON over HTTP, server functions can also be called
from scripts, tests (`TestClient.rpc`) or other services.
## Streaming results
A server function that `yield`s sends each value to the browser as soon
as it is produced. Browser code reads it with `async for`:
```pyweb
import time
from pyweb import App, server
app = App()
@server
def progress(steps: int):
for i in range(steps):
time.sleep(0.5) # slow work: a model, a big query, a file conversion
yield {"done": i + 1, "of": steps}
@app.page("/")
def Job():
status = "idle"
job = None
async def start():
job = progress(10)
async for update in job:
status = f"{update['done']} of {update['of']}"
status = "finished"
def cancel():
if job:
job.cancel()
Start
Cancel
{status}
```
- Calling a streaming function returns a stream; nothing is sent until
`async for` reads it. Handlers that use `async for` are `async def`.
- `job.cancel()` stops it, and so does leaving the loop early with
`break`. Either way the request is aborted and the generator on the
server is closed: code after the current `yield` doesn't run, and
`finally:` blocks and `with` statements clean up, which closes an
upstream HTTP connection (an AI provider stops generating).
- If the function raises `RPCError` part-way, the `async for` raises it
after the values sent so far. Other exceptions arrive as
`RPCError("internal")` and are logged.
- `async def` generators work too. `session` and `request` work inside
the generator. The per-call `rpc_timeout` doesn't apply to streams.
- `TestClient.rpc(...)` returns the list of values a stream sent.
- Stop streams a page started when the user navigates away, in
`on_unmount`.
## Live updates
Server code can push messages to every browser showing a page, over
Server-Sent Events. Three functions from `pyweb` do it:
- `publish(name, data)` in server code sends `data` (anything
JSON-serialisable) to channel `name`.
- `channel(name)` in a page function returns a *feed*: a signed token
that lets that page listen to channel `name`. Only visitors who were
served the page get it, so the page decides who may listen.
- `subscribe(feed, handler)` in browser code (usually `on_mount`) calls
`handler(message)` for every message. The handler runs like an event
handler, so assigning page variables updates the page.
```pyweb
from pyweb import App, channel, publish, server, subscribe
app = App()
_scores = {"home": 0, "away": 0}
@server
def score(team: str) -> None:
_scores[team] += 1
publish("scores", _scores)
@app.page("/")
def Scoreboard():
scores = dict(_scores)
feed = channel("scores")
def changed(new_scores):
scores = new_scores
def on_mount():
subscribe(feed, changed)
Home {scores["home"]}
Away {scores["away"]}
Home scores
```
How it behaves:
- **Nothing is missed.** A feed remembers the channel's position when the
page rendered, so messages published before the browser connects are
still delivered, and a browser that reconnects resumes after the last
message it saw.
- **It falls back to polling** (`/__pyweb/poll`) if the stream can't be
opened, for example behind a proxy that refuses streaming responses.
- **Feeds expire** after 24 hours; reloading the page makes a new one.
- **One process or many.** Messages go through `pyweb.realtime`'s bus,
in memory by default. With several processes, share it through Redis
once at startup: `realtime.use_bus(RedisBus("redis://..."))`.
- **Servers.** `pyweb dev`, `pyweb serve` and the ASGI adapter all stream.
Each open page holds one connection; streams close after five minutes
and browsers reconnect on their own. Behind nginx, responses carry
`X-Accel-Buffering: no` so they aren't buffered.
## Limits and protection
`pyweb serve` and the ASGI adapter apply:
- a 1 MiB request body limit (`413`);
- a default rate limit of 120 RPC calls per minute per client;
- an optional per-call timeout (`rpc_timeout`, seconds);
- CSRF checks for authenticated calls when `PYWEB_CSRF_SECRET` is set;
- `POST` only for RPC endpoints (`405` otherwise).
---
# Pages, routing and assets
## Pages and routes
```pyweb
from pyweb import App, NotFound
app = App(title="Library", stylesheets=["/static/app.css"], lang="en")
BOOKS = {1: "Dune", 2: "Emma"}
@app.page("/")
def Home():
for book_id, title in BOOKS.items():
{title}
@app.page("/books/{book_id}", title="Book")
def Book(book_id: int):
if book_id not in BOOKS:
raise NotFound()
title = BOOKS[book_id]
{title}
Back
```
- `{name}` segments become parameters of the page function. Annotate
them as `int` or `float` to convert; a value that doesn't convert is a
404.
- Other parameters come from the query string; see below.
- `title=` on `@app.page` sets the ``; otherwise the app title is
used.
- Links between pages are ordinary `` links. In apps with
interactive pages they load without a full page reload, and shared
layouts stay in place; see [Layouts & navigation](19-layouts-navigation.md).
Each page ships only its own small module plus the shared, cached
runtime.
- Routes are matched in definition order; unmatched paths get a 404
page. `pyweb dev` shows Python tracebacks for errors in pages; `serve`
shows a generic 500 page with a request id and logs the traceback.
Both can be replaced with your own pages (below).
## Query parameters
Page parameters that aren't in the route are read from the query
string and converted to their annotated type:
```pyweb
from pyweb import App
app = App()
BOOKS = ["Dune", "Emma", "Ulysses"]
@app.page("/search")
def Search(q: str = "", page: int = 1, tags: list[str] = [], exact: bool = False):
found = [b for b in BOOKS if (b == q if exact else q.lower() in b.lower())]
Results for {q} (page {page})
for title in found:
{title}
```
`/search?q=em&page=2&tags=a&tags=b&exact=no` calls
`Search(q="em", page=2, tags=["a", "b"], exact=False)`.
- `int`, `float` and `bool` values are converted (`bool` accepts
`1/0`, `true/false`, `yes/no`, `on/off`). `list[...]` collects every
value of a repeated key.
- Parameters with a default are optional. A missing required one, or a
value that doesn't convert, answers **400** with a message saying
which. (Route segments that don't convert answer 404 instead.)
- `pyweb.request.query` still gives the raw values as a dict.
## Titles, descriptions and social cards
```pyweb
from pyweb import App, head
app = App(title="Library", base_url="https://books.example",
description="A small library", image="/static/cover.png")
BOOKS = {1: {"title": "Dune", "blurb": "Spice and sand."}}
@app.page("/about", title="About us", description="Who runs the library")
def About():
About
@app.page("/books/{book_id}")
def Book(book_id: int):
book = BOOKS[book_id]
head(title=book["title"] + " - Library", description=book["blurb"])
{book["title"]}
```
- `description=`, `image=` and `noindex=True` on `@app.page` add
` `, Open Graph and Twitter card tags, and a
`robots` tag. The app's `description` and `image` are the defaults.
- `head(title=..., description=..., image=..., canonical=..., noindex=...)`
sets them from page code, once the data is loaded. It runs on the
server; layouts can call it too, and the page's call wins.
- With `App(base_url=...)` every page gets ` ` and
`og:url` for its path (without the query string), and relative image
paths become absolute, which social sites need. `canonical=False` on a
page turns it off; `canonical="/other"` points elsewhere.
## Error pages
Write the 404 and 500 pages (or any status) as pages:
```pyweb
from pyweb import App
app = App()
@app.error(404)
def Missing(path):
Nothing at {path}
Home
@app.error(500)
def Broken(request_id):
Something went wrong
Quote this id if you contact us: {request_id}
```
- Error pages can take any of `path`, `status`, `message` and
`request_id`. They use root layouts (prefix `/`) like other pages.
- A 500 page also covers other 5xx errors. `pyweb dev` still shows the
traceback for 500s; `pyweb serve` shows your page.
- If an error page itself fails, the built-in page is used.
## Responses other than HTML
Return `redirect(url)` from a page to send `303 See Other`. Raise
`NotFound()` for 404. Bad query parameters answer 400. Raise `RPCError("forbidden")` or
`RPCError("unauthenticated")` for 403/401. Anything else that escapes a
page is a 500.
## Page-level Python
A page function body runs on the server for every request, top to
bottom, until the markup. You can use any Python there: queries,
`if` statements, loops, early returns. Values assigned that way are
computed on the server; see [State and reactivity](05-reactivity.md).
## Static files and stylesheets
Put files in a `static/` folder next to `app.pyweb`. They are served at
`/static/...` by `pyweb dev`, copied into `dist/static/` by
`pyweb build`, and served with long-lived caching by `pyweb serve`.
```text
myapp/
app.pyweb
static/
app.css
logo.svg
```
Add stylesheets for every page with `App(stylesheets=[...])`. Any CSS
approach works: hand-written CSS, a CSS framework's built file, or
Tailwind's CLI output written into `static/`.
## The HTML document
Every page response is:
```html
…title, stylesheets…
…server-rendered markup…
```
Pages with no interactivity have neither script tag. Layouts add their
own root element around the page's, with their own state script and
module.
---
# Layouts and navigation
Most sites have a header, navigation and footer around every page. A
layout writes that once. Links between pages then load without a full
page reload, and the layout stays on screen with its state.
## Layouts
```pyweb
from pyweb import App
app = App(title="Shop", stylesheets=["/static/app.css"])
@app.layout
def Shell(children):
menu_open = False
def toggle():
menu_open = not menu_open
{children}
@app.page("/")
def Home():
Welcome
@app.page("/products")
def Products():
Products
@app.page("/about", layout=None)
def About():
About (no layout)
```
- `{children}` marks where the page goes. It must appear exactly once.
- `@app.layout` wraps every page. `@app.layout("/admin")` wraps only
pages whose route starts with `/admin`. Layouts nest: a page under
`/admin` gets the `/` layout outside and the `/admin` one inside.
- `layout=None` on `@app.page` opts a page out; `layout="Admin"` picks
a layout by name (with the layouts above it).
- A layout is like a page: its body runs on the server for each
request (it can read `pyweb.request`, the session or the database),
and it can have state, handlers and components. Its state is its own;
pages can't read a layout's variables.
## Client-side navigation
When the current page has any interactivity, clicking a link to
another page of the same app:
1. fetches the next page's HTML (often already fetched: links are
prefetched when the pointer rests on them or they get focus);
2. keeps the layouts both pages share and replaces what's inside them;
3. updates the title, description and social tags and loads the new
page's module;
4. updates the address bar, so back, forward and reload work, and
restores the scroll position when you go back.
So the `menu_open` state above survives moving between pages, and only
the page's own HTML is transferred and rendered.
A layout is kept only while its code and its server-rendered output are
the same for both pages. A layout that shows something that changes
from page to page (such as `request.path`) is re-rendered on each
navigation, so it never shows stale data, but its browser state resets.
Mark the current link with `aria-current` instead (below).
A page's own state starts fresh each time you navigate to it, as it
would with a full load. If a page starts timers or other work, stop it
in `on_unmount`, which runs when the page is navigated away from:
```pyweb
from pyweb import App
app = App()
@app.page("/clock")
def Clock():
ticks = 0
timer = None
def on_mount():
timer = setInterval(lambda: tick(), 1000)
def tick():
ticks += 1
def on_unmount():
clearInterval(timer)
{ticks} seconds on this page
```
Subscriptions (`subscribe(...)`) and watches set up in `on_mount` stop
by themselves when the page is left.
The browser falls back to a normal page load whenever navigation can't
be done in place: links with `target`, `download`, `rel="external"` or
`data-pw-reload`; other sites; files under `/static/`; responses that
aren't PyWeb pages; and pages that need npm packages the current page
didn't load. Links to anchors on the same page scroll as usual.
To turn client-side navigation off for the whole app, use
`App(client_nav=False)`. To skip prefetching for one link, add
`data-pw-prefetch="false"`.
From browser code, `navigate("/path")` (from `pyweb.browser`) goes to
another page the same way, for example after a form is saved.
Navigation fires a `pyweb:navigate` event on `window` and announces the
new page title to screen readers.
## Marking the current link
Links to the current page get `aria-current="page"`, and links to a
section that contains it (`/products` while on `/products/7`) get
`aria-current="true"`. The server adds them, and the browser updates
them after each navigation. Style them with CSS:
```text
nav a[aria-current] { font-weight: 600; }
nav a[aria-current="page"] { text-decoration: underline; }
```
A link where you set `aria-current` yourself keeps your value.
---
# Live data
A page usually shows a snapshot of the database from when it rendered.
With `live()`, a page variable follows the database instead: when a
table it reads changes, every open page showing it gets the new rows. No
polling, channels or handlers to write.
```pyweb
from pyweb import App, live, server
from pyweb.db import connect
app = App(title="Orders")
db = connect("sqlite:///shop.db")
db.execute("create table if not exists orders (id integer primary key, item text, status text default 'new')")
@server
def place(item: str) -> None:
db.execute("insert into orders (item) values (?)", (item,))
@server
def ship(order_id: int) -> None:
db.execute("update orders set status = 'shipped' where id = ?", (order_id,))
@app.page("/")
def Orders():
orders = live(db, "select id, item, status from orders order by id desc limit 50")
waiting = live(db, "select count(*) as n from orders where status = 'new'")
item = ""
def order():
place(item)
item = ""
Orders ({waiting[0]["n"]} waiting)
for o in orders:
{o["item"]}: {o["status"]}
if o["status"] == "new":
Ship
```
Open the page in two windows: an order placed or shipped in one appears
in both at once.
## How it works
- **`live(db, sql, params)`** runs the query while the page renders, like
`db.execute(sql, params).dicts()`, so the first paint has the data and
needs no extra request. In the browser the variable is reactive state.
- **Writes announce themselves.** Every `INSERT`, `UPDATE`, `DELETE`,
`REPLACE` or `TRUNCATE` made through `pyweb.db` (and so through
`pyweb.models`) announces its table once it's committed. Inside
`with db.transaction():` the announcements wait for the commit, and a
rollback sends none.
- **Queries are shared.** Each distinct query (same database, SQL and
parameters) is re-run once per change, however many pages show it,
and only sends rows when they actually changed. A burst of writes is
coalesced into one re-run.
- **Pages follow a signed feed**, like `channel()`: only visitors who
were served the page can listen, and nothing written after the render
is missed.
The tables to watch are the ones after `FROM` and `JOIN`. Pass
`tables=["orders", "customers"]` when that isn't enough (views,
functions, subqueries).
## Writes PyWeb doesn't see
If another program, a cron job or a raw driver connection writes to the
database, tell the live queries:
```python
db.notify("orders") # after the other write has committed
```
## Several processes and servers
With more than one worker process or server, share the realtime bus
through Redis (the same setting live updates use):
```python
from pyweb.realtime import RedisBus, use_bus
use_bus(RedisBus("redis://localhost:6379/0"))
```
Then a write in any process reaches pages connected to any other. A
process that didn't render a page (the browser's connection landed on
another worker, or the worker restarted) takes over re-running its
query from the page's signed query description.
## Limits
- Live queries are for what a page shows: keep them small with `LIMIT`.
A live query that returns more than 10,000 rows is an error.
- Every change sends the query's full result. For large lists, page them
(`limit ? offset ?`) or show counts.
- Rows are sent to every page showing the query, so filter per user in
SQL (`where owner = ?`, with the id from `session.user()`), never in the
browser.
- A query nobody has rendered or watched for ten minutes is forgotten
until a page renders it again.
---
# Building AI apps
AI features need three things from a web framework: a server side that
can hold API keys and call a model, a way to show the answer while it's
still being generated, and safe rendering of what the model wrote.
PyWeb has all three, without a separate frontend.
## Start from the template
```text
pyweb new mychat --template ai-chat
cd mychat
pyweb dev app.pyweb
```
The app runs straight away with a built-in demo model. To use a real
one, set environment variables before starting it:
```text
ANTHROPIC_API_KEY=... Anthropic (AI_MODEL defaults to claude-sonnet-5-5)
OPENAI_API_KEY=... AI_MODEL=... OpenAI
OPENAI_BASE_URL=http://localhost:11434/v1 \
AI_MODEL=llama3 Ollama, vLLM, LM Studio or any OpenAI-compatible server
```
The template calls the provider's HTTP API with the standard library,
so there's nothing else to install. Swap in an official SDK if you
prefer: the server function only has to `yield` text.
## Streaming the answer
A `@server` function that `yield`s streams each value to the browser as
it's produced (see [Streaming results](06-server-functions.md)). This is
the core of the template, slightly shortened:
```pyweb
import os
from pyweb import App, Markdown, RPCError, server
app = App(title="AI chat")
def ask_model(messages):
"""Yield pieces of text from your model provider (see the template for real ones)."""
for word in ("Streaming ", "**works**."):
yield word
@server
def reply(messages: list):
if not messages or messages[-1].get("role") != "user":
raise RPCError("validation_error", "the last message must be from the user")
yield from ask_model(messages[-20:])
@app.page("/")
def Chat():
messages = []
draft = ""
busy = False
stream = None
async def send():
if not draft.strip() or busy:
return
messages.append({"role": "user", "content": draft})
messages.append({"role": "assistant", "content": ""})
draft = ""
busy = True
stream = reply(messages[:-1])
async for piece in stream:
messages[-1]["content"] += piece
busy = False
def stop():
if stream:
stream.cancel()
def on_unmount():
stop()
for m in messages:
```
- **Each piece is shown as it arrives.** `messages[-1]["content"] += piece`
changes page state, so the last bubble re-renders.
- **Stop really stops.** `stream.cancel()` aborts the request. On the
server the generator is closed, which closes the connection to the
model provider, so you don't pay for tokens nobody reads. Leaving the
page does the same through `on_unmount`.
- **Errors reach the page.** Raise `RPCError("unavailable", "...")` in
the generator (the template does this for provider errors) and the
`async for` raises it; catch it with `except RPCError as e`.
## Showing model output: ``
Models answer in Markdown. ` ` (from `pyweb`)
renders it on the server for the first page load and in the browser as
the text changes, using the same rules in both places:
- headings, emphasis, inline and fenced code, lists, quotes, tables,
links and images;
- an unclosed code fence runs to the end, so half-streamed code blocks
look right;
- **it is safe on untrusted text**: raw HTML is shown as text, and links
and images only accept `http(s)`, `mailto` and relative URLs, so a
prompt-injected reply can't run script or plant a `javascript:` link.
It renders into ``; add classes with `class=`.
## Keys, cost and abuse
- **Keys stay on the server.** Read them with `os.environ` inside server
functions. The compiler stops you from sending a variable named like a
secret (`api_key`, `token`, ...) to the browser.
- **Treat the conversation as user input.** The browser sends the whole
history with each call, so the server function should check roles,
trim the length (the template keeps the last 20 messages of up to
4,000 characters) and set the model's `max_tokens`.
- **Limit who can call it.** `pyweb serve` rate-limits RPC calls per
client (120 a minute by default). For a public app, also require a
login (`session.require()`) and keep per-user budgets in your
database.
## Other AI patterns
- **Long jobs with progress**: yield progress dicts from the server
function and show a bar.
- **Background work**: start it with `pyweb.jobs` and `publish()`
progress to the page over [live updates](06-server-functions.md).
- **npm packages for AI UIs** (syntax highlighting, charts of token
usage) work in browser code: see [npm packages](18-npm-packages.md).
---
# Data and databases
Database code runs on the server: at the top of page functions and in
`@server` functions. You can use any Python library (SQLAlchemy,
Django ORM, an HTTP client). PyWeb also ships a small, dependency-free
database layer.
## `pyweb.db`
```python
from pyweb.db import connect
db = connect("sqlite:///app.db") # relative path
# db = connect("sqlite:////var/data/app.db") # absolute path
# db = connect("postgresql://user:pw@host/db") # pip install "pyweb-stack[postgres]"
# db = connect("mysql://user:pw@host/db") # pip install "pyweb-stack[mysql]"
db.execute("insert into posts (title) values (?)", ("Hello",))
rows = db.execute("select id, title from posts where id > ?", (0,)).dicts()
first = db.execute("select count(*) from posts").fetchone()
```
- **Parameters only.** Values are always passed separately from SQL.
- **Portable placeholders.** Write `?`; it is rewritten to `%s` for
Postgres and MySQL drivers (and literal `%` is escaped). SQL that
already uses `%s` is passed through unchanged.
- **Pooling.** Connections open lazily, up to `pool_size` (default 5),
and are safe to share between request threads.
- **Results.** `.fetchall()`, `.fetchone()`, `.dicts()`, `.columns`,
`.rowcount`, `.lastrowid`.
### Transactions
```python
with db.transaction():
db.execute("update accounts set balance = balance - ? where id = ?", (10, 1))
db.execute("update accounts set balance = balance + ? where id = ?", (10, 2))
# committed here; any exception rolls both back
```
### Streaming large results
```python
for page in db.stream("select * from events where day = ?", ("2026-01-01",), chunksize=1000):
handle(page.dicts())
```
### Retries
`db.execute(sql, params, attempts=3)` retries transient failures
(deadlocks, serialization failures, dropped connections) and raises
`TransientDBError` if they persist.
## Live queries
`live(db, sql, params)` in a page keeps a page variable in step with the
database: pages showing it update when its tables are written. See
[Live data](21-live-data.md).
## Migrations
```bash
pyweb db new --name create_posts --migrations migrations # writes 001_create_posts.up.sql / .down.sql
pyweb db migrate --database "$DATABASE_URL" # applies pending, records them
pyweb db status --database "$DATABASE_URL"
pyweb db rollback --database "$DATABASE_URL" --steps 1 # runs .down.sql files
```
Applied versions are recorded in a `pyweb_migrations` table. Applying is
idempotent; a `.down.sql` without its `.up.sql` is rejected.
From Python: `pyweb.db.migrate.migrate(db_or_url, "migrations")`,
`status(...)`, `rollback(..., steps=1)`.
## Choosing SQLite, Postgres or MySQL
SQLite is ideal for single-server apps and tests. Use Postgres or MySQL
when several app servers share data. The drivers are tested against
real Postgres 16, MySQL 8.4 and SQLite in CI. DDL syntax differs
between engines (for example auto-increment keys), so keep schema in
migrations per engine if you need to support more than one.
## Query builder
`pyweb.db.Query` builds parameterised SQL with validated identifiers:
```python
from pyweb.db import Query
sql, params = Query("posts").select("id", "title").where(author="ada").order_by("-id").limit(10).build_select()
```
---
# Authentication
## Sessions
`pyweb.session` keeps the signed-in user in a signed, `HttpOnly`,
`SameSite=Lax` cookie (HMAC-SHA256). No server-side session store is
needed, so any server process can verify it.
```pyweb
from pyweb import App, RPCError, redirect, server, session
from pyweb.auth import hash_password, verify_password
app = App(title="Accounts")
USERS = {} # use a database table in a real app
@server
def register(email: str, password: str) -> bool:
if len(password) < 8:
raise RPCError("validation_error", "Use at least 8 characters.")
USERS[email] = {"hash": hash_password(password), "roles": ["member"]}
session.login(email, roles=["member"])
return True
@server
def login(email: str, password: str) -> bool:
user = USERS.get(email)
if user is None or not verify_password(password, user["hash"]):
return False
session.login(email, roles=user["roles"])
return True
@server
def admin_report() -> str:
session.require("admin") # 401 if signed out, 403 without the role
return "secret numbers"
@app.page("/account")
def Account():
user = session.user()
if not user:
return redirect("/login")
email = user["sub"]
Signed in as {email}
```
| Call | Effect |
|---|---|
| `session.login(user_id, **claims)` | Sets the cookie. Claims (e.g. `roles`, `name`) are stored in it, signed. |
| `session.user()` | The payload `{"sub": user_id, **claims}` or `None`. |
| `session.require(*roles)` | Returns the payload or raises `RPCError` (`unauthenticated` / `forbidden`). |
| `session.logout()` | Clears the cookie. |
Sessions last 7 days (`session.max_age`).
### Configuration
| Variable | Purpose |
|---|---|
| `PYWEB_AUTH_SECRET` | Signing key. **Required** when `PYWEB_ENV=production`; generate with `python -c "import secrets; print(secrets.token_hex(32))"`. Without it, development uses a key derived from the machine and project directory. |
| `PYWEB_COOKIE_SECURE=1` | Adds `Secure` to cookies. Set it whenever you serve over HTTPS. |
| `PYWEB_CSRF_SECRET` | Enables token-based CSRF checks for RPCs marked `@pyweb.decorators.auth_required`, in addition to the always-on checks (RPCs must be `application/json`, and a cross-origin `Origin` header is rejected). |
## Passwords
`pyweb.auth.hash_password(password)` uses PBKDF2-HMAC-SHA256 with a
random salt; `verify_password(password, stored)` compares in constant
time.
## One-time codes and magic links
```python
from pyweb import auth
code = auth.totp(secret_bytes) # RFC 6238, 6 digits, 30 s
ok = auth.verify_totp(secret_bytes, "123456") # ±1 step tolerance
token = auth.issue_magic_token(SECRET, "ada@example.com", ttl=900)
email = auth.verify_magic_token(SECRET, token) # None if expired/forged
```
## Passkeys (WebAuthn)
`pyweb.auth.verify_webauthn_assertion` verifies a login assertion for an
ES256 (P-256) credential:
```python
parsed = auth.verify_webauthn_assertion(
credential_public_key=stored_point, # 65-byte raw point; see cose_to_raw_point
auth_data=auth_data, client_data_json=client_data_json, signature=signature,
rp_id="example.com",
expected_challenge=challenge_you_issued, # bytes or base64url
expected_origin="https://example.com",
prior_sign_count=stored_count,
)
```
It checks the relying-party hash, user presence (and optionally
verification), the ceremony type, the challenge, the origin and the
signature counter, and accepts the DER signatures browsers send. With
`pip install "pyweb-stack[crypto]"` the signature is verified by the
`cryptography` library; otherwise by a pure-Python implementation.
Registration (attestation) parsing is not included; store the
credential's COSE key from your registration flow and convert it with
`cose_to_raw_point`.
## OAuth / OpenID Connect
`pyweb.auth.oidc_userinfo(endpoint, access_token)` fetches the user
profile once your OAuth flow has an access token. The redirect/callback
flow itself is left to your provider's SDK.
---
# Testing
Apps created with `pyweb new` include a `test_app.py` that checks the
home page renders; run `pytest` in the app folder and add tests next to it.
## `TestClient`: fast, in-process, no browser
`pyweb.testing.TestClient` loads your app exactly like the production
server and lets you request pages and call server functions. Cookies
persist between calls, so sessions work.
```python
from pyweb.testing import TestClient
from pyweb import RPCError
import pytest
def test_notes():
client = TestClient("app.pyweb")
assert client.get("/").status == 303 # redirects to /login
assert client.rpc("sign_in", name="ada") is True
page = client.get("/")
assert page.status == 200
assert "Notes for ada" in page.text
with pytest.raises(RPCError) as e:
client.rpc("add_note", body="")
assert e.value.code == "validation_error"
```
| API | |
|---|---|
| `TestClient(path)` / `TestClient(source="...")` | load an app file or source text |
| `.get(path)`, `.post(path, data)`, `.request(method, path, body, headers)` | return a response with `.status`, `.text`, `.json()`, `.header(name)` |
| `.rpc(name, **args)` | call a server function; returns the result or raises `RPCError` |
| `.cookies` | the cookie jar |
| `.app.module` | the executed app module, for direct access to its objects |
## Real browser tests
`pyweb.testing.serve(path)` runs the app on a free port. Combine it with
[Playwright](https://playwright.dev/python/) to test interactivity:
```python
from playwright.sync_api import expect, sync_playwright
from pyweb.testing import serve
def test_counter_in_a_browser():
with serve("app.pyweb") as url, sync_playwright() as p:
page = p.chromium.launch().new_page()
page.goto(url)
page.wait_for_selector("[data-pw-ready]") # set once the page is interactive
page.click("text=Count: 0")
expect(page.locator("button")).to_have_text("Count: 1")
```
`serve()` applies the production Content-Security-Policy, so your
tests also prove the app works under it.
## Compile-time checks
`pyweb check app.pyweb` compiles the app and runs the security checks;
it exits non-zero on errors, which makes it a useful first CI step.
`compile_source(text)` from `pyweb.compiler` gives you the compiled
pages (HTML, JS, signals, placement) if you want to assert on them.
---
# Deployment
## Build
```bash
pyweb build app.pyweb --out dist --production
```
`dist/` is self-contained:
```text
dist/
app.pyweb your app (server functions and page bodies run from it)
manifest.json routes, asset names, RPC table, byte sizes
Dockerfile a ready-to-use image definition
static/
runtime.
.js shared browser runtime (~14 KB gzip)
..js one module per interactive page
..js one module per interactive layout
markdown..js only when a page uses
vendor/ npm packages from pyweb.lock (see npm packages)
... your static/ folder
server/
.html static prerender (for hosting pages with no server data on a CDN)
```
Without `--production`, files keep readable names and are not minified.
## Serve
### Built-in server
```bash
PYWEB_ENV=production PYWEB_AUTH_SECRET=... pyweb serve dist --host 0.0.0.0 --port 8000
```
A threaded, dependency-free HTTP server. It serves hashed assets with
`Cache-Control: immutable`, answers `GET /healthz`, drains in-flight
requests on `SIGTERM`, and applies the security headers below. Put it
behind a reverse proxy (nginx, Caddy, a cloud load balancer) for TLS.
### ASGI (uvicorn, gunicorn, hypercorn)
```python
# asgi.py
from pyweb.asgi import create_app
app = create_app("dist") # or "app.pyweb" to compile at startup
```
```bash
pip install "pyweb-stack[asgi]"
uvicorn asgi:app --host 0.0.0.0 --port 8000 --workers 4
```
Page rendering and server functions run in a thread pool, so the event
loop is never blocked. Use this when you want a process manager,
HTTP/1.1 keep-alive tuning or HTTP/2 from your ASGI server.
### Docker
```bash
cd dist && docker build -t myapp . && docker run -p 8000:8000 -e PYWEB_AUTH_SECRET=... myapp
```
The generated `Dockerfile` installs the matching PyWeb version, installs
`requirements.txt` if you put one in `dist/`, and runs `pyweb serve`
with a health check. `pyweb deploy --target docker|compose|k8s` writes
deployment files for an app directory (Kubernetes manifests include
liveness and readiness probes on `/healthz`).
## Scaling
Servers keep no per-user state: sessions are signed cookies, pages
render per request, and RPC is stateless HTTP. Run as many processes or
containers as you need behind any load balancer; no sticky sessions are
required. Shared state belongs in your database (and Redis, if you use
`RedisBus`/`RedisQueue`/`RedisCache`).
## Environment variables
| Variable | Default | Purpose |
|---|---|---|
| `PYWEB_ENV` | `development` | `production` makes `PYWEB_AUTH_SECRET` mandatory for sessions |
| `PYWEB_AUTH_SECRET` | derived (dev only) | session signing key |
| `PYWEB_COOKIE_SECURE` | off | add `Secure` to cookies (set when behind HTTPS) |
| `PYWEB_CSRF_SECRET` | unset | token CSRF checks for `@auth_required` RPCs |
| `PYWEB_CSP` | see [Security](13-security.md) | override the Content-Security-Policy |
| `DATABASE_URL` | — | conventional; read it in your app and pass to `pyweb.db.connect` |
## Security headers
HTML responses carry a Content-Security-Policy that only allows scripts
from your own origin (the page-state JSON is data, not script), plus
`X-Content-Type-Options: nosniff`, `Referrer-Policy: same-origin` and
`X-Frame-Options: SAMEORIGIN`.
## Logs and request ids
`pyweb serve` writes JSON lines to stdout (`{"ts", "level", "msg",
"logger", ...}`). Every response has an `X-Request-Id`; errors in pages
and server functions are logged with it, and the 500 page shows it, so
a user report can be matched to the log line. Incoming W3C
`traceparent` headers are honoured and propagated.
---
# Security model
## What reaches the browser
Only three things are sent to a browser:
1. **HTML** rendered from your markup.
2. **The page module**: JavaScript compiled from event handlers, markup
expressions, computed values, browser-callable helpers and the
literal module constants they reference.
3. **The page state**: JSON containing the values of page variables
that browser code reads.
Everything else stays on the server: imports, database handles,
classes, `@server` function bodies, module objects, and page variables
that browser code doesn't read. Referencing a server-only name from
browser code is a compile error. `pyweb inspect` lists, per name,
whether it runs in the browser or on the server and why.
Treat everything in (1)–(3) as public. Authorization belongs in
`@server` functions and page bodies (`session.require(...)`), never in
browser code.
## Secrets
The compiler rejects, with file and line, a page variable whose name
looks like a credential (`secret`, `password`, `token`, `api_key`,
`private_key`, `credential`) if browser code or markup reads it and its
value is not an empty literal. Empty form fields (`password = ""` bound
to an input) are allowed because the value comes from the user.
`pyweb check` adds pattern-based checks over the source (environment
reads, credential-like literals) and exits non-zero on findings.
## Cross-site scripting
- Text and attribute values are escaped on the server and set as text
(never as HTML) in the browser.
- URLs in `href`, `src`, `action` and `formaction` that start with
`javascript:`, `vbscript:` or `data:text/html` are replaced by `#`.
- The default Content-Security-Policy only allows scripts from your
origin:
```text
default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self';
frame-ancestors 'self'; img-src 'self' data: https:;
style-src 'self' 'unsafe-inline' https:; font-src 'self' data: https:;
connect-src 'self'; worker-src 'self' blob:
```
`worker-src ... blob:` lets npm packages that start Web Workers from
generated code (canvas-confetti, PDF and map libraries) do so; only
scripts already allowed by `script-src` can create one. When a page
uses npm packages, the hash of its import map is added to
`script-src`. Override the policy with `PYWEB_CSP` if you load
scripts from a CDN.
## Cross-site request forgery
RPC endpoints only accept `POST` with a JSON content type (cross-site
form posts are rejected with `415`) and reject requests whose `Origin`
header names another site (`403 csrf_failed`). Session cookies are
`SameSite=Lax` and `HttpOnly`. Setting `PYWEB_CSRF_SECRET` adds a
token check for RPCs marked `@pyweb.decorators.auth_required`.
## SQL injection
`pyweb.db` only executes parameterised statements; `Query` validates
table and column identifiers.
## Other protections
- Request bodies are capped (1 MiB by default, `413` beyond).
- RPC calls are rate-limited (120/minute per client by default).
- Static file serving is confined to the static directory (path
traversal returns `403`).
- `pyweb.security.safe_next` / `is_safe_redirect` validate redirect
targets; `pyweb.uploads.validate_upload` checks size, declared type and
magic bytes, and generates safe storage names.
- Passwords use PBKDF2-HMAC-SHA256 with per-user salts; session cookies
are HMAC-SHA256 signed and compared in constant time.
- Live-update channels can only be read with a feed from `channel()`:
an HMAC-signed token that expires after 24 hours. A page that shows
private data should only create a feed after checking the visitor
(for example with `session.require()`), exactly as it would before
rendering the data itself.
## Reporting a vulnerability
See [SECURITY.md](https://github.com/MaanavKrishna/PyWeb/blob/main/SECURITY.md).
---
# Command line
```text
pyweb new NAME [--template T] create NAME/ with app.pyweb, test_app.py, AGENTS.md, CLAUDE.md, static/;
T = blank | counter (default) | todo | blog | auth | chat | ai-chat
pyweb dev FILE [--port 8000] [--host 127.0.0.1] [--no-reload]
development server: recompile on save, live reload,
in-browser error overlay with the failing line
pyweb check FILE compile + security checks; non-zero exit on problems
pyweb inspect FILE [--security] where every name runs and why; RPC table; findings
pyweb build FILE [--out dist] [--production] [--budget PATH=SIZE ...]
self-contained dist/; --production hashes and minifies;
budgets fail the build when a file exceeds SIZE (e.g. 20KB)
pyweb serve [DIR] [--host 0.0.0.0] [--port 8000]
production server for a built dist/
pyweb db migrate|status|rollback|new [--database URL] [--migrations DIR]
[--name NAME] [--steps N] [--to VERSION]
pyweb deploy [--target docker|compose|k8s] [--out deploy] [--port 8000]
write deployment files
pyweb add [PKG[@RANGE] ...] [--app DIR]
download npm packages for browser code into static/vendor/
and pin them in pyweb.lock; no arguments: reinstall from the lock
pyweb remove PKG ... [--app DIR] remove npm packages (see npm packages)
pyweb dts FILE.d.ts Python dataclasses from TypeScript declarations
pyweb mcp MCP server over stdio for AI assistants (see AI assistants & MCP)
pyweb lsp language server over stdio for editors (see below)
pyweb test [PATH] run pytest
pyweb fmt [PATH] / pyweb lint [PATH] run ruff format / ruff check (if installed)
pyweb --version
```
`python -m pyweb.cli ...` is equivalent to `pyweb ...`.
## Exit codes
`check` and `build` exit `1` on compile or security errors and `2` on
budget breaches, so they can gate CI.
## Editor support
`pyweb lsp` is a Language Server Protocol server for `.pyweb` files. It
reports compile errors and security warnings as you type (including
errors in imported `.pyweb` files), shows on hover whether a name runs
in the browser or on the server and why (and, for npm packages, the
installed version and TypeScript signature), completes components,
props, HTML tags and installed npm packages, jumps to definitions across files, and outlines pages,
components and server functions.
### VS Code
Install the **PyWeb** extension. It's in the VS Code Marketplace once
published; every [GitHub release](https://github.com/MaanavKrishna/PyWeb/releases)
also has a `.vsix` file (Extensions view → `...` → *Install from VSIX...*).
It adds highlighting and snippets, and starts `pyweb lsp` with the Python
interpreter selected in the Python extension. PyWeb 0.3 or later must be
installed in that environment; set `pyweb.server.command` to use a
different command.
### Other editors
Any LSP client works. Use `pyweb lsp` as the command and `.pyweb` as the
file type. Neovim (0.11+):
```lua
vim.filetype.add({ extension = { pyweb = "pyweb" } })
vim.lsp.config("pyweb", { cmd = { "pyweb", "lsp" }, filetypes = { "pyweb" }, root_markers = { "app.pyweb" } })
vim.lsp.enable("pyweb")
```
Helix (`languages.toml`):
```toml
[language-server.pyweb]
command = "pyweb"
args = ["lsp"]
[[language]]
name = "pyweb"
scope = "source.pyweb"
file-types = ["pyweb"]
language-servers = ["pyweb"]
grammar = "python"
```
---
# Server toolkit and API stability
Besides the compiler and server, PyWeb includes small, dependency-free
libraries for common server-side needs. Use them from page bodies and
`@server` functions.
## Caching
```python
from pyweb.cache import cache, get_cache
@cache(minutes=5, tags=["products"])
def product_list():
return expensive_query()
store = get_cache("memory") # or get_cache("redis", url="redis://...")
store.set("k", {"v": 1}, ttl=60, tags=["user:7"])
store.invalidate_tag("user:7")
```
## Background jobs
```python
from pyweb.jobs import task
@task(retries=2)
def send_report(email, _job=None): # accept _job to report progress
_job.set_progress(0.5)
...
job = send_report("ada@example.com") # returns immediately; runs in a worker thread
```
`pyweb.jobs.RedisQueue(url)` distributes jobs across processes: one
process submits, any process calling `queue.drain()` runs them, and
`queue.status(job_id)` reads the result from any process.
## Realtime fan-out
```python
from pyweb.realtime import RedisBus
bus = RedisBus("redis://localhost:6379/0")
bus.publish("room:python", {"author": "ada", "text": "hi"})
bus.since("room:python", last_id=0) # [(1, {...}), ...] shared across processes
```
`pyweb.publish(name, data)` publishes through this bus, and pages listen
with `channel()` and `subscribe()` (see
[Live updates](06-server-functions.md#live-updates)). To share one bus
between processes, call `realtime.use_bus(bus)` at startup.
## Uploads, forms, observability
- `pyweb.uploads.validate_upload(filename=, size=, content_type=,
allowed_types=, max_bytes=)` and `safe_filename`.
- `pyweb.forms.validate(model, data)` / `fields_for(model)`.
- `pyweb.observability.Logger`, `Tracer`, `Metrics`, `format_error`.
- `pyweb.security.escape`, `safe_join`, `safe_next`.
## Stability
PyWeb follows [semantic versioning](https://semver.org). It is in the
0.x series, so minor releases (0.2, 0.3, …) may still contain breaking
changes. For the APIs marked **stable** below, any breaking change is
listed in the changelog and, where possible, preceded by a deprecation
warning in an earlier release. They are the intended 1.0 API.
| Stable (intended 1.0 API) | |
|---|---|
| The `.pyweb` language | markup, expressions, attributes, events, `bind`, control flow, components, `on_mount`, state classification rules |
| `pyweb` | `App`, `server`, `component`, `request`, `session`, `redirect`, `NotFound`, `RPCError` |
| RPC wire protocol | URL, request/response JSON, error codes |
| `pyweb.testing` | `TestClient`, `serve` |
| `pyweb.asgi` | `create_app` |
| `pyweb.db`, `pyweb.db.migrate` | `connect`, `execute`, `transaction`, `stream`, `Query`, migrations |
| `pyweb.auth` | passwords, sessions, TOTP, magic links, WebAuthn assertion verification |
| `pyweb.cache`, `pyweb.jobs`, `pyweb.realtime` | as documented above |
| CLI | `new dev check inspect build serve db deploy` and their documented flags |
| `dist/` layout | `app.pyweb`, `manifest.json` keys documented in [Deployment](12-deployment.md) |
**Experimental** (importable, tested, but may change in any release):
`pyweb.models` (ORM-style models), `pyweb.livetable` (in-memory live tables; it was `pyweb.live` before 0.4),
`pyweb.sync` (offline sync), `pyweb.css` helpers, `pyweb.plugins`,
`pyweb.platform`, `pyweb.lsp` (its Python functions; the `pyweb lsp`
command itself is supported), `pyweb.dts` (TypeScript declarations to Python dataclasses;
it was `pyweb.npm` before 0.4), `pyweb.browser`
server-side stubs, the `pyweb.compiler` internals and the generated
JavaScript.
---
# Recipe: wallets and web3
PyWeb has nothing web3-specific built in, and doesn't need it: wallet
libraries are npm packages, and verifying a signature is a few lines of
Python. This recipe signs users in with their Ethereum wallet ("Sign-In
with Ethereum" style) using [ethers](https://docs.ethers.org) in the
browser and [eth-account](https://pypi.org/project/eth-account/) on the
server.
## Install
```text
pip install eth-account
pyweb add ethers
```
`pyweb add` vendors ethers and its dependencies (about 150 files) into
`static/vendor/`; pages that don't use it don't load it.
## The app
```pyweb
import secrets
import time
from eth_account import Account
from eth_account.messages import encode_defunct
from pyweb import App, RPCError, npm, server, session
ethers = npm("ethers", "*")
app = App(title="Sign in with a wallet")
NONCES = {} # nonce -> expiry; use your database or cache with several processes
@server
def challenge() -> str:
"""A one-time message for the wallet to sign."""
nonce = secrets.token_hex(16)
NONCES[nonce] = time.time() + 300
return f"Sign in to {app.title}\nNonce: {nonce}"
@server
def sign_in(message: str, signature: str) -> str:
nonce = message.rsplit("Nonce: ", 1)[-1]
if NONCES.pop(nonce, 0) < time.time():
raise RPCError("unauthenticated", "that sign-in request expired; try again")
address = Account.recover_message(encode_defunct(text=message), signature=signature)
session.login(address)
return address
@server
def sign_out() -> None:
session.logout()
@app.page("/")
def Home():
user = session.user()
address = user["sub"] if user else ""
error = ""
async def connect():
error = ""
if not window.ethereum:
error = "No wallet found: install one such as MetaMask or Rabby."
return
try:
provider = ethers.BrowserProvider(window.ethereum)
signer = await provider.getSigner()
message = challenge()
signature = await signer.signMessage(message)
address = sign_in(message, signature)
except RPCError as e:
error = str(e)
def leave():
sign_out()
address = ""
if address:
Signed in as {address}
Sign out
else:
Connect wallet
{error}
```
How it works:
1. **The server makes a one-time message** (`challenge`). The nonce
stops a signature from being reused, and it expires after five
minutes.
2. **The wallet signs it in the browser.** `ethers.BrowserProvider`
talks to whatever wallet the visitor has installed (it injects
`window.ethereum`); `signMessage` asks them to approve.
3. **The server recovers the address from the signature**
(`Account.recover_message`) and signs the visitor in with it as their
user id. The private key never leaves the wallet, and the browser can't
claim an address it can't sign for.
From there, `session.user()["sub"]` is the wallet address in any page or
server function.
## Reading the chain
For balances, contract calls and transactions, use ethers in browser
code, through the visitor's wallet:
```pyweb
from pyweb import App, npm
ethers = npm("ethers", "*")
app = App()
@app.page("/balance")
def Balance():
balance = ""
async def check():
provider = ethers.BrowserProvider(window.ethereum)
signer = await provider.getSigner()
wei = await provider.getBalance(await signer.getAddress())
balance = ethers.formatEther(wei) + " ETH"
Show my balance
{balance}
```
Or read the chain from the server with any Python library (for example
`web3.py` with an RPC provider URL in an environment variable) inside
`@server` functions, which keeps provider keys off the page.
## Notes
- Keep `NONCES` somewhere shared (your database, or `pyweb.cache` with
Redis) when you run several processes.
- Use the address as the user id, and store profile data in your own
tables keyed by it.
- The same pattern works for other chains with their own npm wallet
library and Python verification package.
---
# Limitations and roadmap
PyWeb is deliberately focused. These are the current limits, so you
can decide up front whether they matter for your app.
## Current limitations
- **Browser code is a Python subset.** See
[Python in the browser](07-browser-python.md) for exactly what
compiles. Anything else must live in an `@server` function.
- **npm packages must ship ES modules.** CommonJS-only packages and
packages built on Node.js modules can't run in the browser; see
[npm packages](18-npm-packages.md).
- **Dev reload is a full page reload.** State is not preserved across
edits.
## Released so far
- **0.4**: npm packages without Node.js, layouts and client-side
navigation, page head tags and `.pyweb` error pages, typed query
parameters, streaming server functions and `` for AI apps,
live queries, and the dashboard, site and AI chat examples.
- **0.3**: hydration, live updates over Server-Sent Events, multi-file
apps, the `pyweb lsp` language server and VS Code extension, the
browser playground, faster server rendering, and screenshot and test
tools for AI assistants.
- **0.2**: tools for building with AI assistants: the `pyweb mcp` server,
an AI guide, project templates with `AGENTS.md`/`CLAUDE.md`, and
`llms.txt`.
- **0.1**: the compiler, reactive runtime, server rendering, typed RPC,
sessions, database layer and CLI.
See the [changelog](https://github.com/MaanavKrishna/PyWeb/blob/main/CHANGELOG.md) for details.
## Roadmap
Planned, roughly in order. Nothing here is promised for a date.
1. **Partial live updates**: send only the rows that changed instead of
a live query's whole result.
2. **Form helpers**: validation shared between the server and the
browser.
Ideas, bug reports and pull requests are welcome; see
[CONTRIBUTING.md](https://github.com/MaanavKrishna/PyWeb/blob/main/CONTRIBUTING.md).