Starting a new project¶
(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 = 0tocount: int = 5and 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_initdoes 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
keyto 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:
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 runsays it cannot listen on the port, anotherpyweft runis 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 "restartpyweft 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¶
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-statusattribute. Leave it out if you don't want them. - Progress you can style: the wrapper
#pyweft-loadinggetsdata-pyweft-step="python", then"packages"(only when the app has dependencies),"app"and finally"ready"as it fades out, and the CSS variable--pyweft-progressgoes 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 runandbuildprint why, and the page still works; - the page is opened at another address than the one rendered (a link
to
#/orders/7in an app with pages), so the wrong page never flashes up; - you turn it off with
--no-prerenderonrunorbuild, for example to keep a custom splash screen.
Two things to know:
- Your app's code runs on your machine as part of
runandbuild, up to its firstawait, in a separate process. Keep code with side effects (sending analytics, say) behind anawaitor 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):
titleis the browser tab's title, and the desktop window's. Without it, the app folder's name is used.iconis the browser tab's icon (favicon) and the desktop window's. It must be a file in your app'sassets/folder:.ico(needed for a Windows window icon),.pngor.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:
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):
Run it in watch mode next to pyweft run:
and link the output first in head.html, so your own CSS comes after it:
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-1must be written[&>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:
Install it into the same environment as PyWeft:
(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:
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 inhead.htmlappear the same way, for examplejs.Chart. - Raw JavaScript runs with
js.eval("...")when you need a one-off. - Async APIs such as
fetchreturn promises;awaitthem in anasynchandler (await js.fetch(url)), or usepyodide.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 withpyodide.ffi.create_proxy(fn)and call.destroy()when you remove it, otherwise it leaks.
Two rules keep this safe:
- Never modify elements PyWeft rendered. The runtime owns everything
under
#appand 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#appor a container your template renders and leaves empty. - Keep
import jsinside handlers, not templates. Thejsmodule 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
PickedFilehasname,data(the bytes) andtext(the bytes as UTF-8). In a desktop app it also haspath; in the browser that isNone, because a web page is never shown your file system. typesare extensions:"csv",".csv"or"*.csv".save_filetakes text (saved as UTF-8) or bytes, and in the browser always returnsTrue, 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_filein a click handler before any otherawait, 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 ranpyweft run), and keep files your app writes out of its own folder, wherepyweft runwould 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
Responsehasstatus,ok(a 2xx status),headers(names in lower case),data(the bytes),textandjson().raise_for_status()raiseshttp.HTTPErrorunless it's 2xx. - A relative URL is a file your app ships. Put data files in
assets/, andawait 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 raisesTimeoutError. - 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 withasync 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 raisesTypeError; turn dates into text first, for example. getreturns a copy. Changing a list you got back doesn't change what's stored until yousetit again, which is what the effect above does.- The calls aren't
async, so they work anywhere: inon_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 itspyproject.toml. In a desktop app, a JSON file in the user's data folder:%APPDATA%\<name>\storage.jsonon 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:
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
distto agh-pagesbranch and enable Pages on it:git subtree push --prefix dist origin gh-pages. For deploy-on-push, add a GitHub Actions workflow that runspyweft buildand uploadsdistwithactions/deploy-pages. - Netlify / Vercel — drag
distonto the dashboard, ornetlify deploy --dir dist --prod. - S3 and similar —
aws s3 sync dist s3://my-bucket --deletewith static website hosting enabled on the bucket. - Your own server — copy
distinto 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.
.weftfiles 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 runre-reads files on refresh, but you must rebuild after every change you want deployed.- Subpath hosting works without configuration: every reference in
index.htmlis 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:
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_backgroundruns functions on real threads. - System dialogs:
pyweft.dialogsshows 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.cssfiles,head.htmlandassets/. - Hot reload: saving a
.weftor.cssfile updates the open window and keeps its state, as in the browser. Editing a.pymodule 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 exampleimport 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_backgroundor 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:
| 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 newtitleinpyproject.tomlrenames 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
.exeon Windows, the.appon a Mac. - What goes in: your
.weft,.cssand.pyfiles,assets/,head.htmlandpyproject.toml(for the window settings); every module your code blocks and modules import, which PyWeft finds by reading them; and your component libraries, such aspyweft-ui, whole. Anything else in the app folder stays out. - The icon must be an
.icofor Windows and an.icnsfor 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.