Skip to content

Starting a new project

uv run pyweft new myapp
uv run pyweft run myapp

(Drop the uv run prefix if you installed with pip.) That's it — new scaffolds the project, run serves it. Open http://127.0.0.1:8642/, then edit a file and save: the open page updates in place, without losing what you'd clicked or typed (see "Hot reload" below). There is no build step; components are compiled in the browser.

A scaffolded app looks like this:

myapp/
  pyproject.toml    # name + dependencies — a completely ordinary pyproject
  app.weft          # root component (must be named app.weft)
  styles.css        # global CSS; component styles go in <style> blocks
  head.html         # extra <head> tags: CDN stylesheets, scripts, fonts
  loading.html      # what visitors see while Python starts (see below)
  AGENTS.md         # how to write this app, for AI coding assistants
  CLAUDE.md         # "@AGENTS.md": Claude Code reads AGENTS.md through it

AGENTS.md is for AI coding assistants (Claude Code, Codex, Cursor, Copilot and others read it): PyWeft is too new for them to know, so it spells out the file format, the directives, components, async, routing, pyweft-ui, and the mistakes generated code usually makes. It is written for the PyWeft version you have; after upgrading, refresh it with uv run pyweft agents myapp (which also adds the two files to an app made before they existed, and leaves an existing CLAUDE.md alone). The same text is on the website as agents.md, and all the documentation as one plain-text file for AI tools at llms-full.txt (indexed by llms.txt).

Add more components as sibling .weft files — <TodoItem> in a template finds todo_item.weft automatically — or group them in folders and use dotted tags such as <widgets.Row> (section 4). pyweft run and pyweft build pick up .weft and .css files in subfolders too. They skip folders a tag cannot name (.venv, anything starting with _), node_modules, assets/ (served as-is, see below), and build output such as dist/.

Plain Python modules work the same way. Put shared data or helper functions in a .py file next to app.weft (or in a package folder with an __init__.py) and import it from any component's <code> block:

myapp/
  app.weft
  orders_data.py    # ORDERS = [...], def total(order): ...
  pages/
    orders.weft     # <code> import orders_data </code>

The app folder is on the import path in the browser, and in the run_in_background worker too. .py files follow the same folder rules as components, so keep scripts that must not reach the browser (a deploy script with a token in it, say) in a _-prefixed folder or outside the app folder: everything else is shipped to visitors.

Hot reload

While pyweft run is serving, every save shows up in the open page within about half a second, and the app keeps its state: todos you added, text you'd typed, toggles you'd set. What happens depends on what you saved:

You save The page State
a .weft file: template, <code> or <style> recompiles it and patches only what changed kept
a global .css file swaps the stylesheet, in the same place kept
a .py module of your app reloads (Python keeps imported modules cached) reset
pyproject.toml, head.html or loading.html reloads (they only apply at startup) reset
anything in assets/ reloads (an image may be used anywhere) reset

How state carries over:

  • Values of existing fields are kept. Change count: int = 0 to count: int = 5 and a counter that is already at 3 stays at 3; the new default applies to fresh components and after a reload.
  • New fields start at their default, so you can add a field and use it in the template in the same save.
  • on_init does not run again for components that are already on the page. Effects in the file you edited do re-run: their cleanup first, then the new code.
  • A child component is identified by its position in the template, as on any re-render. Insert something before it and it starts fresh; give it a key to keep its state when it moves.

Mistakes don't break the page. Save a syntax error or unbalanced markup and an overlay at the bottom shows what's wrong and where, for example:

app.weft, line 38: cleared: int = = 0
SyntaxError: invalid syntax

The page underneath keeps running the last version that worked, so you can keep clicking around. Fix the file and save; the overlay goes away and the fix applies. An exception raised by an event handler, including an async one after its await, shows in the same overlay with the line in your .weft file, headed "unhandled error; the page is still running".

A few more details:

  • Saves made while the page is still starting (Python is loading) are not lost: the page catches up on them as soon as it is ready.
  • One server per port. If pyweft run says it cannot listen on the port, another pyweft run is still running somewhere. Stop it, or start this one with --port 8700.
  • After updating PyWeft itself, restart pyweft run. Your app's files reload on their own, but the server's own code only loads when it starts. If PyWeft changes while the server runs, open pages show an amber "restart pyweft run" banner at the top instead of reloading into a half-updated state, and the terminal says the same.

Hot reload is a development feature only: pyweft build output contains none of it.

Checking an app: pyweft check

uv run pyweft check myapp

check finds mistakes without running the app in a browser. It loads every component as pyweft run would, then reads each template against what its component defines, and prints one line per problem with the file and line:

app.weft:12: error: {{ }}: unknown name 'cout' (did you mean 'count'?) [unknown-name]
app.weft:20: warning: <ui.TextField>: 'lable' is not one of its props, so it goes to its root element as an attribute (did you mean 'label'?) [unknown-prop]
1 error, 1 warning in 4 component files.

Errors (the app would fail, or something would silently not work): template and Python syntax errors, a code block that fails as it runs, missing modules, components that can't be found, unknown names in expressions, handlers and @bind fields that don't exist, slot content for a slot the component doesn't have, modifiers on a component's events, a keyed <For> whose body has more than one element, and generator expressions or lambdas in a template that use the component's names (they can't see them).

Warnings (probably a mistake): an attribute on a component tag that isn't one of its props (it goes to the component's root element instead; class, id, style, title, aria-*, data-* and the like are expected there, so they don't count), an event the component never emits, a handler that sets state the component doesn't declare, :class next to class (it replaces it), a field named like a Python builtin that the same code block calls (max: int then max(a, b)), and list comprehensions in a template that fail on Python 3.10 and 3.11.

It also checks that the app's dependencies install in the browser (see Using PyPI packages): each one, and everything it needs, must be pure Python or built into Pyodide, in a version that fits. That part asks PyPI (answers are kept for a day); offline, it says it couldn't check rather than failing, and --no-deps skips it.

It exits with 1 when there are errors, so it can guard a commit or a CI job. --json prints the problems as JSON (file, line, severity, code, message) for editors and other tools. AI coding assistants are told to run it after every change (see AGENTS.md above); its "did you mean" suggestions are what lets them fix their own mistakes.

check runs your code blocks (and the modules they import) the way run does, but never your app's handlers, on_init or network calls. Browser-only modules (js, pyodide) are stood in for; a dependency that isn't installed where you run check is reported, so install the app's dependencies there first.

The loading screen

Starting Python in the browser takes a few seconds on the first visit (the runtime is downloaded, then cached), so the page shows a loading screen until your app has rendered, then fades it out. pyweft new gives you the default one as loading.html: a small piece of cloth being woven, two rows for each startup step, with a line of progress text underneath. It follows the system's light or dark colors (set --pyweft-loader-color on #pyweft-loading to tint it) and stays still for visitors who prefer reduced motion.

The file is plain HTML and CSS, inserted into the page before anything else loads, so change it however you like. Replace the animation, add your logo, set your brand colors:

<style>
  .splash { position: fixed; inset: 0; display: grid; place-content: center;
            background: #1e3a5f; color: #fff; font: 600 1.4rem system-ui; }
  .splash small { display: block; margin-top: .5rem; font-size: .9rem; }
</style>
<div class="splash">
  My Todo App
  <small data-pyweft-status></small>
</div>
  • Progress messages ("Loading Python…", "Installing humanize…") go into every element with a data-pyweft-status attribute. Leave it out if you don't want them.
  • Progress you can style: the wrapper #pyweft-loading gets data-pyweft-step="python", then "packages" (only when the app has dependencies), "app" and finally "ready" as it fades out, and the CSS variable --pyweft-progress goes from 0 to 1 with them. A progress bar needs no JavaScript:
.bar { height: 3px; background: #1e3a5f;
       width: calc(var(--pyweft-progress, 0) * 100%); transition: width .3s; }
#pyweft-loading[data-pyweft-step="packages"] .hint { display: block; }

Downloading Python is most of a first visit, so the bar moves by step, not by bytes. - If startup fails, the error appears in that same element, and the wrapper (#pyweft-loading) gets the class pyweft-failed, so you can style the failure state: .pyweft-failed .splash { background: #7a1f1f; }. The full Python traceback is in the browser console. - Cover the page. Use position: fixed; inset: 0 and a background, as above, because the app is rendered underneath while the screen is up. - Delete loading.html to go back to the built-in default; leave it empty for no loading screen at all. Projects created before this file existed get the default automatically.

With pyweft run, saving loading.html reloads the page so you see it again; pyweft build writes the screen into index.html, so it appears before any other file has downloaded.

The first screen, before Python starts

Most visitors never see the loading screen: pyweft run and pyweft build render your app's first screen on your machine and put it in the page. The real page appears at once, slightly dimmed and not yet clickable, with a thin progress bar along the top. When Python has started in the browser it takes over that same page, which brightens and starts responding. Nothing moves or blinks, because the page Python renders is the same one.

What the first screen shows is what your app renders before anything arrives: initial field values, synchronous on_init code, effects, and an async on_init up to its first await (a "Loading orders…" message, say). Your own .py modules and installed component libraries work as they do in the browser.

The loading screen is used instead when:

  • the app can't be rendered on your machine: code that only works in a browser (import js, pyodide), or a package that isn't installed locally. pyweft run and build print why, and the page still works;
  • the page is opened at another address than the one rendered (a link to #/orders/7 in an app with pages), so the wrong page never flashes up;
  • you turn it off with --no-prerender on run or build, for example to keep a custom splash screen.

Two things to know:

  • Your app's code runs on your machine as part of run and build, up to its first await, in a separate process. Keep code with side effects (sending analytics, say) behind an await or in a handler.
  • Clicks and typing before Python is ready don't count: the page is inert until then, which is why it is dimmed.

The bar's color is your system's highlight color; set --pyweft-bar-color on :root in your CSS to change it.

The app's title and icon

Name your app in its pyproject.toml (pyweft new starts you off):

[tool.pyweft]
title = "Acme Orders"
icon = "assets/icon.ico"
  • title is the browser tab's title, and the desktop window's. Without it, the app folder's name is used.
  • icon is the browser tab's icon (favicon) and the desktop window's. It must be a file in your app's assets/ folder: .ico (needed for a Windows window icon), .png or .svg.

A mistake, such as a misspelled setting or an icon that isn't there, stops pyweft run and build with a message naming it.

Changing the title while the app runs: call set_title() from a handler, an on_init or a task:

from pyweft import set_title

def on_init(self):
    set_title(f"Order #{self.id} - Acme")

It updates the browser tab, or the window in a desktop app. A title stays until the next set_title(), so with pages, give each page its own in its on_init.

The page head and the assets folder

head.html is added to the page's <head>, so anything that belongs there goes in it: stylesheets and scripts from a CDN, web fonts, a favicon, meta tags.

<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" />
<link rel="icon" href="assets/favicon.svg" />
<meta name="theme-color" content="#4f46e5" />

pyweft new creates one with every line commented out, as a menu of common uses. Saving it reloads the page.

Stylesheet order. The page loads head.html first, then your own .css files in alphabetical order, then the components' <style> blocks. When the order matters, for example your overrides must come after a framework, link your file in head.html at the point where it should load:

<link rel="stylesheet" href="https://cdn.example/framework.css" />
<link rel="stylesheet" href="app/overrides.css" />

A .css file linked this way is not added a second time, and hot reload still swaps it in place when you save it.

The assets/ folder holds files the page uses as-is: images, fonts, icons, a vendored stylesheet or script. They are served at assets/..., so myapp/assets/logo.svg is <img src="assets/logo.svg" /> in a template, url("assets/inter.woff2") in a global .css file, or <link rel="stylesheet" href="assets/icons.css" /> in head.html. Files in assets/ are never linked or loaded on their own, and pyweft build copies the folder unchanged.

The page starts unstyled. PyWeft styles only its own loading screen and error overlay; layout, fonts and colors are yours. New projects get a simple centered column in styles.css. A project created before this changed had that column built in, so to keep it, add this to its styles.css:

body { font-family: system-ui, sans-serif; max-width: 40rem;
       margin: 3rem auto; padding: 0 1rem; color: #222; }

Using CSS frameworks

Class names in a template are ordinary HTML classes, so any CSS framework works. Delete the body rule from styles.css first, so it doesn't fight the framework's layout.

Bootstrap. Link its stylesheet in head.html, then use its classes:

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" />
<button class="btn btn-primary" @click="save">Save</button>
<ul :class="'dropdown-menu show' if open else 'dropdown-menu'">...</ul>

Use Bootstrap's CSS and leave out its JavaScript bundle. Its dropdowns and modals open by changing classes on elements PyWeft owns, and the next update to that element undoes them. Keep "open" in your component's state instead, as above.

Tailwind, without a build step. Add Tailwind's browser script to head.html, optionally with your theme:

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<style type="text/tailwindcss">
  @theme { --color-brand: #4f46e5; }
</style>

Then use classes anywhere, with nothing else to run:

<button class="bg-brand text-white rounded-lg px-4 py-2" @click="save">Save</button>
<p :class="f'text-{level}-600'">{{ message }}</p>

The script watches the page and generates CSS for each class as it appears, so classes built in Python (the f-string above) work too. It's a large download and Tailwind doesn't recommend it for production, so switch to the CLI before you deploy.

Tailwind, with its CLI (for production). The CLI scans your .weft files and writes a stylesheet containing only the classes you use. Put its input file in a folder starting with _, which PyWeft ignores:

/* myapp/_tailwind/input.css */
@import "tailwindcss";
@source "../**/*.weft";
@theme { --color-brand: #4f46e5; }

Install the CLI in your project, not the app (pytailwindcss downloads Tailwind's standalone binary, so Node isn't needed; with Node, use npx @tailwindcss/cli instead):

uv add --dev pytailwindcss     # or: pip install pytailwindcss

Run it in watch mode next to pyweft run:

uv run tailwindcss -i myapp/_tailwind/input.css -o myapp/tailwind.css --watch

and link the output first in head.html, so your own CSS comes after it:

<link rel="stylesheet" href="app/tailwind.css" />

Each save regenerates tailwind.css and hot reload swaps it in. Before pyweft build, run the same command once without --watch (add --minify).

Two things the CLI can't do, because it reads your files instead of the page:

  • Built-up class names. f'text-{level}-600' produces nothing; write the classes out in full, for example :class="'text-red-600' if late else 'text-green-600'".
  • Variants containing &. Templates are XML, so [&>li]:py-1 must be written [&amp;>li]:py-1, which the CLI does not recognize. Use the browser script while you need these, or give the children their own classes.

@apply works in _tailwind/input.css, but not in a component's <style> block, which Tailwind never reads.

Classes that depend on state. A :class sets the whole attribute, so an element with both class and :class gets only the :class one. Put the fixed classes in the expression too:

<button @click="show = name"
        :class="'rounded-lg px-3 py-1.5 ' + ('bg-slate-900 text-white' if name == show else 'text-slate-500')">
  {{ name }}
</button>

The todo and shop examples (examples/todo, examples/shop) are styled with Tailwind's CLI exactly as above: see each one's _tailwind/input.css for the setup and the command that rebuilds its tailwind.css.

Ready-made controls: pyweft-ui

If you'd rather not write HTML and CSS at all, pyweft-ui gives you styled, accessible controls as components:

<ui.Theme mode="auto">
  <ui.AppShell title="Orders">
    <ui.Card title="New order">
      <ui.TextField label="Customer" @bind="customer" required="true" />
      <ui.Select label="Size" options="Small, Medium, Large" @bind="size" />
      <Fill name="footer">
        <ui.Button variant="primary" icon="send" @click="submit">Place order</ui.Button>
      </Fill>
    </ui.Card>
  </ui.AppShell>
</ui.Theme>

with from pyweft_ui import ui in the code block. It covers layout (AppShell, Row, Column, Grid, Card, Divider, Carousel, Expansion, Accordion), form fields (TextField, TextArea, NumberField, Select, Autocomplete, CascadeSelect, Chips, ChipInput, ButtonToggle, Slider, RadioGroup, Checkbox, Switch, DatePicker, DateRangePicker, Calendar), data and navigation (Table with sorting, Tree, Tabs, Stepper, Breadcrumbs, EventCalendar), charts (BarChart, LineChart, PieChart, PolarAreaChart, RadarChart, ScatterChart, Sparkline, Gauge, FunnelChart, WaterfallChart, Histogram, BoxPlot, Heatmap, CorrelationMatrix, CandlestickChart, Treemap, SankeyChart, CalendarHeatmap), actions and feedback (Button, Menu, ContextMenu, Dialog, BottomSheet, Popover, Tooltip, Alert, Toaster with toast(), Badge, Spinner, Progress, Icon) and a Theme with light, dark and system modes and your own colours.

It also checks forms. Name each field's rules, show each field's message, and check them all on submit:

from pyweft_ui import Form, rules

form = Form(
    email=[rules.required(), rules.email()],
    password=[rules.required(), rules.min_length(8)],
)

async def submit(self):
    if not self.form.check():   # shows every message, focuses the first field to fix
        return
<ui.TextField ref="email" label="Email" @bind="email"
              :error="form.error('email')" @blur="form.touch('email')" />

A message appears once the user has left the field or tried to submit, then follows what they type.

The pyweft-ui guide shows how to build pages, forms, tables and dialogs with them; packages/pyweft-ui/README.md is the reference; and the gallery shows every control live:

uv run pyweft run packages/pyweft-ui/gallery

Install it into the same environment as PyWeft:

uv add pyweft-ui

(with pip: pip install pyweft-ui), then add "pyweft-ui" to the dependencies in your app's own pyproject.toml. PyWeft ships it to the browser for you (see "Components from packages"). Inside a clone of this repository, uv sync already installs it.

Using PyPI packages

Declare dependencies the normal Python way, with uv or pip:

cd myapp
uv add humanize        # or: edit dependencies = [...] in pyproject.toml

pyweft run reads [project].dependencies from your pyproject.toml and installs those packages inside the browser (via Pyodide's micropip, from PyPI) before your components load. So this just works in a component:

import humanize

@property
def remaining_label(self):
    return humanize.apnumber(self.remaining)   # 3 -> "three"

Because the same pyproject.toml drives both, uv sync/pip install -e . gives you identical packages locally for tests and tooling.

One constraint, inherited from the browser: a package, and everything it depends on, must be pure Python (most are) or one of the packages Pyodide ships prebuilt (numpy, pandas, pydantic, scikit-learn, …), and the prebuilt ones come in fixed versions (pydantic is 2.7.0). A package with native code that Pyodide doesn't build can't be installed: today that includes LangChain and the official OpenAI and Anthropic SDKs. Packages needing raw sockets or subprocesses won't work in a browser regardless. The desktop app runs ordinary Python, so none of this applies there.

pyweft check tells you before you ship (see Checking an app): it follows each dependency's own requirements the way micropip would and names the one that won't install, and why:

pyproject.toml:8: error: in the browser: 'jiter' (needed by anthropic) has native code (wheels only for Windows, macOS and Linux), and Pyodide 0.26.4 doesn't build it, so it can't be installed in the browser (the desktop app isn't affected) [dependency-native]

Calling JavaScript

In the browser your component code runs inside Pyodide, which exposes the page's JavaScript globals as a Python module named js. Anything the page can do, a handler can do:

import js

def save(self):
    js.localStorage.setItem("draft", self.draft)

def restore(self):
    self.draft = js.localStorage.getItem("draft") or ""

def shout(self):
    js.alert(f"{self.count} clicks")

def retitle(self):
    js.document.title = f"{self.count} clicks"
  • Globals are attributes of js: js.window, js.document, js.console.log(...), js.navigator.clipboard.writeText(text).
  • Libraries you load with a <script> tag in head.html appear the same way, for example js.Chart.
  • Raw JavaScript runs with js.eval("...") when you need a one-off.
  • Async APIs such as fetch return promises; await them in an async handler (await js.fetch(url)), or use pyodide.http.pyfetch. See "Async handlers and background work" in section 2.
  • Callbacks. Passing a Python function to js.setTimeout(fn, ms) works for a one-shot call. For a listener that lives on, wrap it with pyodide.ffi.create_proxy(fn) and call .destroy() when you remove it, otherwise it leaks.

Two rules keep this safe:

  1. Never modify elements PyWeft rendered. The runtime owns everything under #app and patches it by position. Reading is fine; adding or removing nodes there from JavaScript puts the next patch on the wrong node. If you need a DOM library to draw somewhere, give it an element outside #app or a container your template renders and leaves empty.
  2. Keep import js inside handlers, not templates. The js module only exists in the browser. A component that imports it at the top of its code block fails in headless tests and on the desktop target, where Python runs natively; importing inside the method that needs it keeps the rest of the component portable.

State changed from a JavaScript callback happens outside a handler, so call self.state_has_changed() afterwards, exactly as with a timer.

A transport-independent way to call JavaScript, a call patch op that works identically in the browser, on desktop and in tests, is on the roadmap.

Files and dialogs

pyweft.dialogs opens and saves files and asks yes-or-no questions, the same way in the browser and in a desktop app. The calls are async, so use them in async handlers:

import csv, io
from pyweft import dialogs

async def import_orders(self):
    file = await dialogs.open_file(types=["csv"])
    if file:                                    # None when cancelled
        self.orders = list(csv.DictReader(io.StringIO(file.text)))

async def export(self):
    await dialogs.save_file(self.as_csv(), name="orders.csv")

async def clear(self):
    if await dialogs.confirm("Delete all orders?", title="Orders"):
        self.orders = []
Call Returns In the browser In a desktop app
open_file(types=["csv"]) a PickedFile, or None if cancelled the browser's file picker the system's Open dialog
open_files(types=[...]) a list of PickedFiles (empty if cancelled) the same, choosing several the same, choosing several
save_file(data, name="x.csv") True once saved, False if cancelled a download the system's Save As dialog
confirm(message, title="") True for OK the browser's confirm box a system dialog
  • A PickedFile has name, data (the bytes) and text (the bytes as UTF-8). In a desktop app it also has path; in the browser that is None, because a web page is never shown your file system.
  • types are extensions: "csv", ".csv" or "*.csv". save_file takes text (saved as UTF-8) or bytes, and in the browser always returns True, since a download can't be cancelled from the page.
  • In the browser, open a file from a click. Browsers only show a file picker in answer to the user, so call open_file in a click handler before any other await, not from a timer.
  • In a desktop app, your code can also use files directly, since it runs in ordinary Python: Path.home() / "Documents" / "orders.csv". Use absolute paths (a relative one starts from where you ran pyweft run), and keep files your app writes out of its own folder, where pyweft run would take them for edits.
  • In tests, dialogs.set_host() installs a stand-in that answers instead of the user; without one, the calls raise an error saying so.

Loading data: pyweft.http

pyweft.http calls web APIs and loads files, the same way in the browser and in a desktop app. Requests are async, so make them in async handlers or an async def on_init:

from pyweft import http

orders: list = []
error: str = ""

async def on_init(self):
    self.orders = await http.get_json("https://api.example.com/orders")

async def save(self, order):
    response = await http.post("https://api.example.com/orders", json=order)
    if not response.ok:
        self.error = f"Couldn't save the order ({response.status})"
Call Returns
get_json(url) the body parsed as JSON; http.HTTPError unless the status is 2xx
get(url), post(url), put(url), patch(url), delete(url) a Response, whatever its status
request(method, url) the same, for any method

Each takes params= (added to the URL's query), headers=, timeout= (seconds, 30 by default) and a body: json= (any JSON value) or data= (bytes, text, or a dict sent as a form).

  • A Response has status, ok (a 2xx status), headers (names in lower case), data (the bytes), text and json(). raise_for_status() raises http.HTTPError unless it's 2xx.
  • A relative URL is a file your app ships. Put data files in assets/, and await http.get_json("assets/prices.json") reads them in the browser and in a desktop window alike.
  • Failures: no answer at all (offline, a mistyped address) raises http.NetworkError; a slow server raises TimeoutError.
  • In the browser, the browser's rules apply. A request to another site only works if that server allows it (it must send CORS headers; public APIs usually do). There is no such rule in a desktop app.
  • Everyone can see the requests a web app makes, as with any web page, so an API key in your code isn't secret. Keep secrets on a server of your own and call that.
  • In tests, requests go out for real through the standard library. http.set_host() installs a stand-in instead: an object with async request(method, url, headers, body, timeout) returning (status, headers, data, url).

Saving data between visits: pyweft.storage

pyweft.storage keeps small amounts of data, such as settings, a draft or a list, until the next visit:

from pyweft import effect, storage

items: list = []

def on_init(self):
    self.items = storage.get("todos", [])   # [] the first time

@effect
def keep(self):
    storage.set("todos", self.items)        # runs again whenever items changes
Call Does
get(key, default=None) the stored value, or default
set(key, value) stores value, replacing what was there
delete(key), clear() removes one key, or everything this app stored
has(key), keys() whether a key has a value; every key, sorted
  • Values are anything JSON can hold: numbers, text, True/False/ None, and lists and dicts of those. Anything else raises TypeError; turn dates into text first, for example.
  • get returns a copy. Changing a list you got back doesn't change what's stored until you set it again, which is what the effect above does.
  • The calls aren't async, so they work anywhere: in on_init, an effect, a property, or a module of your app.
  • Where it lives: in the browser, the browser's local storage for your site (about 5 MB in all, cleared if the visitor clears site data), kept apart per app by the [project] name in its pyproject.toml. In a desktop app, a JSON file in the user's data folder: %APPDATA%\<name>\storage.json on Windows, ~/Library/Application Support/<name>/ on macOS, ~/.local/share/<name>/ on Linux.
  • Not in run_in_background: the worker can't reach the page's storage, so read what the function needs first and pass it in.
  • In tests it lives in memory. storage.set_host(storage.MemoryStorage()) starts a test empty; storage.FileStorage(path) keeps it in a file.

Building for deployment

uv run pyweft build myapp              # writes myapp/dist/
uv run pyweft build myapp --out site   # or pick the output folder

dist/ is a fully static site. There is no compile step: your .weft files ship verbatim and are parsed in the browser at load, and the dependencies from your pyproject.toml are written into index.html for micropip to install on first visit.

dist/
  index.html        shell: loads Pyodide, installs deps, boots app.weft
  runtime.js        applies DOM patches, sends events back to Python
  worker.js         the Web Worker for run_in_background (started on first use)
  pyweft-src/*.py   the framework, served to the browser
  app/              your .weft components, .py modules and .css files, as-is
  assets/           your assets/ folder, copied as-is

head.html and loading.html are written into index.html rather than copied.

Check it locally the way a host will serve it before deploying:

cd myapp/dist
python -m http.server 8000             # open http://localhost:8000/

Open it over http, not as a file:// URL — the shell fetches its own files and browsers block that from the local filesystem. The first visit downloads Pyodide (about 10 MB), so allow a few seconds before the status line reads "Running".

Deploy the folder. There is no server-side Python, so any static host works:

  • GitHub Pages — push dist to a gh-pages branch and enable Pages on it: git subtree push --prefix dist origin gh-pages. For deploy-on-push, add a GitHub Actions workflow that runs pyweft build and uploads dist with actions/deploy-pages.
  • Netlify / Vercel — drag dist onto the dashboard, or netlify deploy --dir dist --prod.
  • S3 and similar — aws s3 sync dist s3://my-bucket --delete with static website hosting enabled on the bucket.
  • Your own server — copy dist into the nginx or Apache document root.

Before going live:

  • The visitor's browser needs internet access: Pyodide comes from the jsdelivr CDN and dependencies from PyPI at runtime. Air-gapped hosting would need those vendored, which the CLI does not do yet.
  • .weft files are served as plain text, so your component source is visible in devtools — the same as any client-side framework.
  • dist/ is a snapshot. pyweft run re-reads files on refresh, but you must rebuild after every change you want deployed.
  • Subpath hosting works without configuration: every reference in index.html is relative, so the app runs at / or /myapp/ alike.
  • The same app can also be handed out as a desktop program, with pyweft build --desktop.

A desktop app: pyweft run --desktop

The same app runs in a native window, with Python running on your own machine instead of in a browser:

uv add "pyweft[desktop]"          # once: installs pywebview
uv run pyweft run myapp --desktop

With pip: pip install "pyweft[desktop]", then pyweft run myapp --desktop.

Nothing in the app changes. The window is a webview (WebView2 on Windows, WebKit on macOS, GTK or Qt on Linux) showing the same page; your components run in ordinary Python, and each event and update crosses between the two.

  • What you gain: Python starts at once, with no download; your code can use the machine (files, sockets, databases, any installed package, including ones with C extensions that can't run in a browser); and run_in_background runs functions on real threads.
  • System dialogs: pyweft.dialogs shows the system's own Open, Save As and confirmation dialogs here, and the browser's equivalents on the web, with the same code.
  • What works the same: templates, handlers, @bind, async handlers, effects, focus, pages and Back/Forward, stores, pyweft-ui, scoped styles, your .css files, head.html and assets/.
  • Hot reload: saving a .weft or .css file updates the open window and keeps its state, as in the browser. Editing a .py module or PyWeft itself needs a restart, since Python keeps imported modules loaded; the terminal says so.
  • Errors in a handler are shown in the window and printed in the terminal, and the app keeps running.
  • Browser-only code (import js, pyodide) doesn't exist on the desktop. Code that needs both should check, for example import sys; in_browser = sys.platform == "emscripten".
  • Your code runs one thing at a time, on a single thread, as in the browser. If you start threads of your own, hand results back with run_in_background or asyncio rather than changing component state from another thread.

The window takes its title and icon from [tool.pyweft], and the rest from [tool.pyweft.desktop] in your app's pyproject.toml:

[tool.pyweft.desktop]
width = 1200
height = 800
min_size = [800, 600]
maximized = false
Setting Meaning Default
width, height the window's size in pixels when it opens 1100, 800
min_size the smallest it can be resized to, [width, height] none
resizable whether the user can resize it true
maximized, fullscreen open maximized, or full screen false
on_top stay above other windows false
frameless no title bar or border (your page draws everything) false
background_color shown before the page paints, like "#0d1117" the system's
  • The icon shows on the window and in the taskbar on Windows (it must be an .ico; other formats keep the default icon, and the terminal says so) and on Linux. On macOS the Dock icon comes from a packaged app, so it waits for packaging.
  • set_title() renames the window while the app runs, and saving a new title in pyproject.toml renames the open window. The other window settings apply the next time it opens.

Close the window to stop.

Handing out a desktop app: pyweft build --desktop

To give your app to people who don't have Python, build it into one program they can run:

uv add --dev "pyweft[package]"    # once: installs PyInstaller, only where you build
uv run pyweft build myapp --desktop

With pip: pip install "pyweft[package]", then pyweft build myapp --desktop.

This writes myapp/dist-desktop/Acme Orders.exe on Windows, an Acme Orders.app on macOS, or a program on Linux, named after your title and carrying your icon. It contains Python, PyWeft, your app and everything it uses, so the people you give it to just run it: nothing to install.

  • Build on each system you ship for. PyInstaller builds for the system it runs on: build the .exe on Windows, the .app on a Mac.
  • What goes in: your .weft, .css and .py files, assets/, head.html and pyproject.toml (for the window settings); every module your code blocks and modules import, which PyWeft finds by reading them; and your component libraries, such as pyweft-ui, whole. Anything else in the app folder stays out.
  • The icon must be an .ico for Windows and an .icns for macOS; with another format, the program keeps the default icon and the build says so.
  • It runs like pyweft run --desktop, without hot reload or a terminal. If it can't start, it opens a window saying why.
  • Size and speed: a small app is around 15 MB, and the program unpacks itself each time it starts, so it opens in a second or two.
  • Imports PyWeft can't see, such as importlib.import_module(name) with a name worked out at run time, aren't bundled. Import such modules plainly somewhere in your code so they're found.
  • A building tool won't sign the program: Windows and macOS may warn the first time it runs, as they do for any unsigned download. Signing with a certificate, and installers (.msi, .dmg), are on the roadmap.