ZePyGUI
Beautiful native desktop apps in pure Python. Zero dependencies.
ZePyGUI works like Tauri, but you write Python instead of Rust and JavaScript. Your app runs in a real native window using the operating system's web engine, comes with a polished design system, and updates itself when your data changes.
from zepygui import App, State, ui
app = App("Hello")
count = State(0)
@app.page("/")
def home():
return ui.column(
ui.h1(f"Clicked {count.value} times"),
ui.button("Click me", lambda: count.set(count.value + 1)),
)
app.run()
python hello.py
No pip install, no Node, no Rust toolchain. You only need Python 3.9+.
Why ZePyGUI
| Native window | On macOS, a real NSWindow + WKWebView (the same approach Tauri takes), driven via ctypes. Native menu bar, copy/paste, full screen, the title bar follows your theme, and the window remembers its size and position. |
| No dependencies | Only the standard library. Nothing to install, nothing to break. |
| Beautiful by default | 50+ components: buttons, inputs, tables, tabs, modals, toasts, charts, sidebar layouts. Dark and light themes, smooth animations. |
| Reactive | Change a State and every window that shows it re-renders automatically, even from background threads. |
| Simple | Your UI is just Python functions that return components. |
| Desktop APIs | Native open/save/folder dialogs, system notifications, open files/URLs. |
| Built for heavy work | Background tasks with progress and cancel (threads, processes or asyncio), streamed command output, a log console for 100,000+ lines, virtual tables for 100,000+ rows. |
| Validation | Rules, live field errors and validated submits, including errors sent back by your backend. |
| Dev friendly | app.run(reload=True) restarts the app on save; debug=True enables the web inspector. |
How it works
Your Python code ──► builds a tree of components ──► sent to the window over an in-process bridge
▲ │
└──────────── events (click, input, …) ◄────────────────┘
- macOS: native
NSWindow+WKWebView. No server and no network port: Python and the UI talk through a WebKit script-message bridge. - Windows / Linux: a frameless app window using the system's Edge/Chromium (Edge ships with Windows), talking to Python over a private, token-protected loopback channel. It's the same API, and native WebView2/WebKitGTK backends can be added without changing any app code.
Quick start
Install from PyPI:
python3 -m pip install zepygui
Or, from a clone of the repository, in editable mode:
git clone https://github.com/rzafiamy/zepygui.git
cd zepygui
python3 -m pip install -e .
Then create and run an app:
python3 -m zepygui new myapp # creates myapp/app.py (with a sidebar, pages, theme toggle)
python3 myapp/app.py
(Without installing, put your app next to the zepygui/ folder, or run the examples below, which find it on their own.)
Or run the examples:
python examples/hello.py # counter
python examples/todo.py # to-do list
python examples/dashboard.py # full app: sidebar, live charts, table, modal, settings, file dialogs
python examples/console.py # log console: 70,000 lines, filter, run commands, export
python examples/files.py # scan a folder in the background, 100k-row table, hash in a process
python examples/signup.py # form validation with live errors and server-side errors
The basics
Pages
@app.page("/", title="Home")
def home():
return ui.h1("Home")
@app.page("/user/{id:int}", title="Profile") # parameters: {name}, {name:int}, {name:float}, {name:path}
def profile(id):
return ui.h1(f"User #{id}")
Navigate with ui.link("Profile", to="/user/3"), ui.nav_item(...), or ui.navigate("/user/3").
State: the UI updates itself
count = State(0) # shared by all windows of the app
count.value # read (inside a page, this subscribes the window)
count.set(5) # write → UI re-renders
count.value += 1 # also works
count.update(lambda n: n * 2)
todos = State([])
todos.append("Write docs") # list helpers: append, remove, pop, insert, clear, extend
todos.value[0] = "Edited"
todos.notify() # after mutating in place yourself
Local state for one page or component uses ui.use_state:
@app.page("/search")
def search():
query = ui.use_state("")
return ui.input(query, placeholder="Search…") # a State as `value` means two-way binding
Two ways to write layouts
Nested calls:
ui.card(
ui.h3("Sign in"),
ui.input(email, label="Email"),
ui.button("Continue", login, full=True),
)
…or with blocks:
with ui.card():
ui.h3("Sign in")
ui.input(email, label="Email")
ui.button("Continue", login, full=True)
Reusable components
@ui.component
def Counter(label):
n = ui.use_state(0) # each Counter keeps its own count
return ui.button(f"{label}: {n.value}", lambda: n.set(n.value + 1))
ui.row(Counter("Apples"), Counter("Pears"))
In lists, give items a key= so their state follows them when the list is reordered.
Event handlers
Handlers can take zero or one argument; ZePyGUI passes the argument only if your function accepts it.
ui.button("Save", on_click=save) # save()
ui.input(on_change=lambda text: print(text)) # gets the text
ui.input(on_enter=lambda text: send(text))
ui.select(["S", "M", "L"], on_change=set_size) # gets the chosen option
ui.el("div", "Hover me", on_mouseenter=lambda e: ...) # any DOM event, gets an Event
async def load(): # async handlers work too
await asyncio.sleep(1)
data.set(await fetch())
A handler runs on its window's thread, so a slow one freezes that window (ZePyGUI prints a warning when a handler takes longer than 250 ms). Move slow work into a task.
App shell layout
@app.layout
def layout(content):
return ui.shell(
content,
sidebar=ui.sidebar(
ui.nav_item("Home", "/", icon="home"),
ui.nav_item("Inbox", "/inbox", icon="mail", badge=3),
ui.nav_section("Account"),
ui.nav_item("Settings", "/settings", icon="settings"),
title="Acme", logo="zap",
),
header=ui.header(ui.spacer(), ui.theme_toggle()),
)
Components
Layout: column, row, grid, container, card, spacer, divider, scroll_area,
shell, sidebar, nav_item, nav_section, header
Text: h1–h4, text, link, code, code_block, markdown, kbd
Inputs: button, icon_button, input, textarea, checkbox, switch, slider, select,
radio_group, segmented, form
Display: badge, avatar, icon, image, progress, spinner, alert, stat, table,
tabs, accordion, modal, tooltip, empty_state, theme_toggle
Charts: line_chart, bar_chart, donut, sparkline
Large data: virtual_list, log_view, and table(..., virtual=True)
Actions: toast, navigate, set_theme, toggle_theme, set_title, copy, run_js, background
Desktop: open_file, save_file, choose_folder, notify, open_url, open_path
A few examples:
ui.button("Delete", on_click=delete, variant="danger", icon="trash") # primary/secondary/outline/ghost/soft/danger/success
ui.input(email, label="Email", type="email", icon="mail", error="Invalid email" if bad else None)
ui.switch("Dark mode", dark)
ui.slider(volume, min=0, max=100, label="Volume", format=lambda v: f"{v}%")
ui.grid(ui.stat("Revenue", "$12k", delta="+8%", icon="dollar"), ..., cols=4)
ui.table(users, [
("name", "Name"),
{"key": "role", "label": "Role", "format": lambda v: ui.badge(v, "blue")},
{"key": "spent", "label": "Spent", "align": "right", "format": lambda v: f"${v:,}"},
], on_row_click=open_user) # sortable by default
ui.tabs({"Profile": profile_tab, "Billing": billing_tab}, variant="pills")
ui.modal(show_dialog, ui.text("Are you sure?"), title="Confirm",
footer=[ui.button("Cancel", close, variant="ghost"), ui.button("Yes", confirm)])
ui.line_chart({"Sales": [3, 5, 4, 8], "Costs": [2, 3, 3, 4]}, ["Q1", "Q2", "Q3", "Q4"])
ui.donut({"Free": 120, "Pro": 45, "Team": 12}, center_label="users")
Every component accepts cls=, style= and key=, and any other keyword becomes an HTML attribute.
Spacing (gap, padding) uses a 4px scale: gap=4 is 16px. Strings like "1.5rem" pass through.
Desktop features
path = ui.open_file("Choose an image", types=["png", "jpg"]) # None if cancelled
paths = ui.open_file(multiple=True)
target = ui.save_file("Export", "report.csv")
folder = ui.choose_folder()
ui.notify("Export finished", "report.csv was saved") # system notification
ui.open_path(target, reveal=True) # show in Finder/Explorer
Background updates
@app.timer(1.0)
def tick():
clock.set(time.strftime("%H:%M:%S")) # every open window updates
Heavy work
Tasks: keep the window responsive
ui.task runs a function in the background and returns a Task at once. Its status,
progress and message are States, so a page that reads them updates by itself.
def copy_folder(src, dst, task): # a parameter named `task` receives the Task
files = list(Path(src).rglob("*"))
for i, f in enumerate(files):
task.check() # raises Cancelled once task.cancel() is called
shutil.copy2(f, dst)
task.report((i + 1) / len(files), f.name)
return len(files)
job = ui.task(copy_folder, src, dst,
on_done=lambda n: ui.toast(f"{n} files copied", "success"), # on the window's thread
on_error=lambda e: ui.toast(str(e), "error"))
ui.progress(job.progress.value * 100 if job.progress.value is not None else 0, label=job.message.value)
ui.button("Cancel", job.cancel)
| Kind of work | How | Runs on |
|---|---|---|
| Blocking I/O: files, network, databases | ui.task(fn, ...) |
shared thread pool (min(32, cpu + 4) threads) |
async def code |
ui.task(coro_fn, ...), or an async handler |
one shared asyncio loop |
| CPU-heavy pure functions: hashing, parsing, images | ui.task(fn, ..., process=True) |
process pool, one process per core |
| External programs | ui.run_command(cmd, log=log, on_line=...) |
a reader thread per command |
process=True re-imports your script in each worker process: guard app.run() with
if __name__ == "__main__": (ZePyGUI also refuses to open a window from a worker).
ui.background(fn, *args) is the old spelling of ui.task(fn, *args).
Changing State from a task is safe and cheap: each window queues at most one redraw and
renders at most once per frame (60 per second), however fast the values change.
A log console for 100,000+ lines
A Log sends each window only the lines it has not seen yet. The window keeps the lines and
draws only the ones on screen, so appending stays fast however long the log gets.
log = Log(max_lines=200_000) # oldest lines are dropped beyond this
ui.log_view(log, height=480, filter=query, numbers=True) # follows the newest line, tints ERROR/WARN
ui.run_command(["pytest", "-x"], log=log) # stream a command's output into it
log.append("done") # from any thread
print("also works", file=log)
log.save("session.log"); log.clear()
Tables and lists with 100,000+ rows
ui.table(rows, columns, max_height=480) # above 1,000 rows it renders only the rows in view
ui.table(rows, columns, virtual=True, row_height=32)
ui.virtual_list(files, lambda f: ui.row(ui.icon("file"), ui.text(f["name"])), item_height=36, height=480)
Virtual rows have a fixed height and their cells don't wrap.
Performance numbers
python3 bench/bench.py measures the runtime headless (no window). Typical results on the development Mac with Python 3.9 (numbers vary by a few percent between runs):
| Scenario | Before | Now |
|---|---|---|
| Render 5,000 table rows | 3,640 ms | 277 ms |
| Render a 5,000-row keyed list | 7,107 ms | 511 ms, payload 5.5 → 4.2 MB |
| Render a 100,000-row table | (minutes) | 5 ms, 12 KB (virtual) |
| Scroll a 100,000-row table to new rows | — | ~30 ms per step |
50,000 State.set() from a thread until the window shows the last |
581 ms, 48 renders | 228 ms, 9 renders |
| Stream 70,000 log lines into a window | — | 250 ms, 31 messages |
| Click while slow work runs | 504 ms (blocked) | 22 ms (ui.task) |
| Memory kept after rendering 5,000 rows | 24 MB until the GC runs | 1.9 MB, no garbage cycles |
Validation
Give an input a Field instead of a State: it binds the value, shows the error and marks
the field required.
from zepygui import rules, ValidationError
@app.page("/signup")
def signup():
form = ui.use_form(
email=("", [rules.required(), rules.email()]),
password=("", [rules.required(), rules.min_length(8)]),
confirm=("", [rules.matches("password", "Passwords don't match")]),
age=(None, [rules.number(min=18, integer=True)]),
terms=(False, [rules.required("Accept the terms to continue")]),
)
return ui.form(
ui.input(form.email, label="Email"),
ui.input(form.password, type="password", label="Password"),
ui.input(form.confirm, type="password", label="Confirm"),
ui.input(form.age, type="number", label="Age"),
ui.checkbox("I accept the terms", form.terms),
ui.button("Create account", submit=True, loading=form.submitting.value),
on_submit=form.submit(create_account, background=True, reset=True),
)
def create_account(values): # called only when every field is valid
if db.exists(values["email"]):
raise ValidationError({"email": "Already registered"}) # shown under the field
db.insert(values)
- An error appears once the user leaves the field (or submits), then follows what they type.
- Rules:
required,min_length,max_length,pattern,email,url,number(min, max, integer),one_of,matches(field), andcheck(predicate, message)for your own. - A single field:
name = ui.use_field("", rules.required()), thenui.input(name). form.values(),form.errors(),form.valid,form.reset(),form.set_errors({...}).
Customizing
app = App(
"My App",
theme="auto", # "auto" (follows the OS), "dark" or "light"
accent="#10b981", # one color drives the whole palette
width=1200, height=800, min_size=(600, 400),
radius=12, # corner roundness
font="'Inter', sans-serif",
icon="icon.png", # dock icon (macOS)
)
app.add_css(""".hero { background: linear-gradient(135deg, var(--accent), #ec4899); }""")
app.static("/assets", "assets") # then ui.image("assets/logo.png")
ui.register_icon("diamond", "M12 2 22 12 12 22 2 12Z") # SVG paths on a 24x24 grid
Theme variables you can use in your CSS: --accent, --bg, --surface, --surface-2, --border,
--text, --text-2, --muted, --green, --red, --yellow, --blue, --radius, --shadow.
Escape hatches
ui.el("video", src="intro.mp4", controls=True, autoplay=True) # any HTML tag
ui.html("<marquee>raw HTML</marquee>") # raw markup (trusted content only)
ui.run_js("document.body.requestFullscreen()")
@app.expose # call Python from your own JavaScript:
def add(a, b): # const sum = await zepygui.call("add", 2, 3)
return a + b
Run options
app.run() # native window (macOS) / app window (Windows, Linux)
app.run(reload=True) # restart on file save, for development
app.run(debug=True) # right-click → Inspect Element
app.run(mode="window") # force the Edge/Chromium app window
app.run(mode="browser") # open in your default browser (handy for dev tools)
Project layout
zepygui/
app.py App, routing, per-window sessions, renderer
ui.py component library
state.py reactive State
tasks.py background work: thread pool, process pool, asyncio loop, commands
log.py streaming log buffer behind ui.log_view
forms.py validation: Field, Form, rules (also exported as zepygui.rules)
macos.py native NSWindow + WKWebView via ctypes
window.py Edge/Chromium app-window launcher (Windows/Linux)
server.py stdlib HTTP + WebSocket server (fallback transport only)
desktop.py file dialogs, notifications, open files/URLs
reloader.py restart on save
static/ client runtime (DOM patching) + design system CSS
examples/ hello.py, todo.py, dashboard.py, console.py, files.py, signup.py
bench/ python3 bench/bench.py (rendering, scheduling, memory, logs, tasks)
tests/ python3 -m unittest discover -s tests (headless)
ZEPYGUI_GUI_TESTS=1 python3 -m unittest discover -s tests (+ real native windows, macOS)
python3 tests/matrix.py (rebuild specs/matrix.md; fails on any uncovered requirement)
specs/ specification, traceability matrix, manual test suite
licences/ licence texts of third-party material (see CREDITS.md)
Status
- macOS: native backend, tested.
- Windows / Linux: use the Edge/Chromium app window. If no Chromium browser is installed, the app opens in the default browser. Native WebView2 (Windows) and WebKitGTK (Linux) backends are the next step.
- Packaging into a standalone
.app/.exeis not built in yet; tools such as PyInstaller can bundle a ZePyGUI app because it has no dependencies to collect.
Specifications
specs/specification.md: what ZePyGUI does, as atomic, testable requirementsspecs/matrix.md: traceability matrix, generated bypython3 tests/matrix.pyspecs/manual.md: manual test procedures for what automation cannot reach
License
MIT License, copyright (c) 2026 rzafiamy. See LICENSE.
Third-party material: CREDITS.md.
Metadata
Release files for zepygui 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| zepygui-0.1.0.tar.gz | 106.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| zepygui-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 193.2 kB
Release files / zepygui-0.1.0.tar.gz
| Download URL | zepygui-0.1.0.tar.gz |
|---|---|
| Size | 106.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d09d424d33105e49cf4ac7aaebf355e77cb497ac2598f8fdda49d92c31ddb3c3
|
|
BLAKE2b-256 checksum How to use checksums |
8a3e9d2391bd137b91c143c1ef554a32e83e5fcd5c4efdbb2353d713b2f13b6b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.9.6
|
Release files / zepygui-0.1.0-py3-none-any.whl
| Download URL | zepygui-0.1.0-py3-none-any.whl |
|---|---|
| Size | 86.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f04738a67937fb5dd0f612ff7e87c6f7b737726d4e03e3aa37f2cd48c862825e
|
|
BLAKE2b-256 checksum How to use checksums |
441695382a595057a0a0e427bf0ed9a924d5209211212418d98d91a94d477dea
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.9.6
|