kwebui
Build web UIs in pure Python — no HTML, CSS, or JavaScript required.
kwebui is a clean, plugin-oriented alternative to Streamlit: every widget is a
self-contained plugin, the core framework knows nothing about individual
widgets, and the whole thing is small enough to read in an afternoon (see
docs/architecture.md). A retained tree of
widgets lives server-side; mutating one pushes a single, minimal JSON patch
over a WebSocket to every connected browser — no full-page reruns, no
diffing a virtual DOM on every keystroke.
from kwebui import KApp
class Demo(KApp):
def build(self):
self.text("Hello World", size=28)
self.button("Click Me", on_click=lambda: print("Clicked"))
Demo(title="Demo").run()
Table of contents
- Why kwebui
- Installation
- Quickstart
- User guide
- Widgets
- Themes
- Packaging / installing elsewhere
- Project layout
- Session model — please read before production use
- Contributing
- License
Why kwebui
- Pure Python. No HTML/CSS/JS to write, no separate frontend build step,
no Node.js/npm involved anywhere —
pip install -e .is enough. - Plugin-oriented core. Every widget (
text,button,table,container, ...) is a self-contained plugin underkwebui/widgets/, auto-discovered at startup. Delete every file underwidgets/andKAppstill imports and runs — the core genuinely has no special knowledge of any individual widget type. - Retained tree, not rerun-the-script. Widgets are live Python objects
you mutate directly (
widget.set_text(...),widget.update(...)) — not something you recreate on every interaction. Each mutation ships exactly one minimal patch to connected browsers. - Small enough to actually read. The core (
app.py,widget.py,plugin.py,renderer.py,registry.py) is a few hundred lines total. Seedocs/architecture.md. - No CDN, no build tools. The frontend is Vue 3, vendored as a single
vue.global.jsfile and loaded directly — components are plain JS objects with template strings, not.vueSingle File Components.
Installation
Requires Python 3.12+.
python3.12 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e . # add [dev] for tests, [imagestream] for OpenCV demos, [file_uploader] for uploads
Or straight from GitHub, no local clone needed:
pip install "git+https://github.com/Kaptura-GmbH/kwebui.git"
See docs/user-guide.md
for every install path (editable, from git, or as a built wheel).
Quickstart
python examples/demo.py # the snippet above
python examples/showcase.py # every built-in widget on one page
python examples/workflow.py # topbar + workflow_tracker driving which page a container() shows
python examples/custom_theme.py # a theme CSS file shipped from your own project, not the kwebui package
python examples/mjpeg_demo.py # live MJPEG stream + snapshot capture (needs [imagestream])
python examples/daheng_demo.py # Daheng camera picker + live config (needs gxipy, see its docstring)
Open the printed URL (default http://127.0.0.1:8701, or the next free
port after it if 8701 is already taken) in a browser.
User guide
See docs/user-guide.md for the full walkthrough
with a real screenshot for every widget. The short version:
-
Subclass
KAppand overridebuild()to construct the page once by calling widget methods onself(self.text(...),self.button(...), ...) —build()runs automatically when you instantiate the class. Then callMyApp(title="...").run(). -
Every widget method returns a live widget object. Store it on
selfto change it later — from a callback, a background thread, or anywhere else — and the browser updates automatically, without a full page reload:class Demo(KApp): def build(self): self.greeting = self.text("Hello") self.button("Again", on_click=self.say_again) def say_again(self): self.greeting.set_text("Hello again") # pushes a minimal patch to every browser
-
Callbacks (
on_click,on_change, ...) are plain Python functions that run on the server, each on its own worker thread — the browser only ever sends events, so a blocking callback never freezes the UI for other connected browsers. -
Advanced: inside a callback,
self.sessiongives you the connection that triggered it (self.session.stateis a free scratchpad dict). Most apps never need this. -
self.on_shutdown(callback)registers cleanup (e.g. releasing a camera) that runs once on any graceful stop —Ctrl+C,SIGTERM, orself.exit(), which triggers that same stop from inside the app itself (a "Quit" button, a background thread, ...) — seedocs/user-guide.md.
Widgets
For the full, illustrated version of this table — a real screenshot for
every widget, plus the install-and-run walkthrough in more depth — see
docs/user-guide.md.
| Widget | Example (inside build(), called on self) |
|---|---|
| Text | self.text("Hi", size=24, color="red", bold=True, italic=False, align="left") |
| Button | self.button("Save", on_click=save, enabled=True, shortkey="ctrl+k", color="red", text_color="white") — shortkey binds a keyboard combo ("k", "shift+k", "ctrl+k", "shift+ctrl+k") that calls on_click, but only while the button is visible and enabled; color/text_color accept any CSS color and default to the theme's --sg-button-bg/--sg-button-text-color variables (an explicit value overrides just that button) |
| Checkbox | self.checkbox("Enable feature", checked=False, on_change=lambda v: ...) |
| TextEdit | self.textedit("Name", placeholder="Jane", multiline=False, password=False, on_change=lambda v: ..., on_enter=lambda v: ...) |
| Slider | self.slider("Volume", min_value=0, max_value=100, value=50, on_change=lambda v: ...) |
| ListBox | self.listbox(["A", "B", "C"], on_select=lambda item: ...) |
| ProgressBar | self.progressbar(50) / self.progressbar(0, indeterminate=True) |
| Spinner | with self.spinner("Working...", show_time=True): do_slow_thing() |
| Image | self.image("cat.jpg", width=150) / self.image("cat.jpg", stretch=True) — width (-1/0 = natural size, default) resizes the viewer; stretch=True fills the parent container's width instead. Local paths and URLs both work |
| ImageStream | self.imagestream(frame_provider=capture_jpeg, fps=15, max_send_fps=None) — live MJPEG feed (webcam/OpenCV); or call stream.push_frame(jpeg_bytes) yourself. Same width/stretch as Image. fps is the capture rate; max_send_fps separately caps what's sent to each viewer — useful when the provider is faster than a browser can paint, since feeding it more than that wastes bandwidth and can even leave the page's own JS starved of the main thread. stream.latest_frame() returns the most recent JPEG bytes (see examples/mjpeg_demo.py for a snapshot-capture button) |
| FileUploader | self.file_uploader("Upload a file", accept=".csv", on_upload=lambda filename, data: ...) — needs the [file_uploader] extra installed; without it, uploads return a clear error instead of failing |
| Html | self.html("<strong>Raw HTML</strong>") — trusts the string, don't pass unsanitized user input |
| Json | self.json({"status": "ok", "items": [1, 2, 3]}) — pretty-printed, read-only |
| Table | self.table([{"name": "Alice", "age": 30}], width=-1, stretch=False, border=True, hide_header=False) — read-only dataframe-style grid; also accepts a dict of columns, a list of lists + columns=..., or a real pandas.DataFrame (detected by shape, no pandas dependency added) |
| Empty | slot = self.empty(); slot.text("Loading..."); slot.progressbar(80) — a placeholder whose content can be swapped in place |
| Container | page = self.container(width=300, height=-1, stretch=False, border=True, border_roundness=True, caption="Settings", direction="vertical", wrap=False, horizontal_alignment=None, vertical_alignment=None, shortkey=None, on_keypress=None, vertical_padding=None, horizontal_padding=None); page.text("Hi"); page.button("Go"); ...; page.clear() — like Empty, but holds a whole group of widgets instead of one; .clear() wipes it before rebuilding with different content. direction="horizontal" lines children up side by side (each at its own natural size) instead of stacked — unlike columns(n)'s fixed-width slots — and wrap=True lets them flow onto further lines once a row no longer fits, e.g. a row of Badges. horizontal_alignment/vertical_alignment ("left"/"center"/"right" and "top"/"center"/"bottom") position children within a fixed width/height, e.g. a "panel" with a centered button — their meaning stays tied to the horizontal/vertical axis regardless of direction. shortkey works like Button's, but calls on_keypress (there's no on_click to reuse) and only fires while the container is visible. vertical_padding/horizontal_padding (px) default to the theme's --sg-container-padding-vertical/--sg-container-padding-horizontal variables (10px each); an explicit value (including 0) overrides just that container |
| Success / Info / Warning / Error | self.success("Saved!"), self.info(...), self.warning(...), self.error(...) — colored status banners |
| Badge | self.badge("New") / self.badge("Active", icon="✅", color="success") — a small pill-shaped status/tag label; color is success/info/warning/error (same colors as the banners above) or None for neutral |
| Toast | self.toast("Saved!", level="success") — a transient corner notification |
| Columns | left, right = self.columns(2) — side-by-side containers, each accepting the same widget calls as self; or narrow, wide = self.columns([0.3, 0.7]) for relative widths (weights must be positive and sum to 1) |
| Sidebar | nav = self.sidebar() — a persistent panel pinned to the left edge, collapsible by default |
| Popup | self.popup("Discard changes?", kind="yesno", on_return=lambda answer: ...) — a modal dialog; kind is ok, okcancel, yesno, or yesnocancel |
| WorkflowTracker | self.workflow_tracker(tasks, orientation="horizontal", on_select=lambda task_id: ...) — a step tracker; each task is {"id", "title", "status", "detail"} |
| Topbar | bar = self.topbar(); bar.workflow_tracker(tasks) — a full-width panel pinned to the top of the page, sticky by default |
Every widget returned by self.<widget>(...) has an .update(**props)
method plus typed setters (e.g. text.set_text(...), checkbox.set_checked(...)).
Every widget also has, regardless of type:
self.name.highlight() # outline in the theme's default color (red)
self.name.highlight("#16a34a") # or a specific color, for this widget only
self.name.unhighlight()
self.name.focus() # send keyboard focus to it
self.name.hide() # visually hide it (stays in the tree, keeps its state)
self.name.show() # reveal it again
self.name.remove() # drop it from the page and server memory for good
self.name.disable() # stays visible, but stops responding to clicks/typing/etc.
self.name.enable() # respond to interaction again
Themes
self.set_theme("dark") # or "light" (default)
Themes are plain CSS files. The bundled ones live in kwebui/themes/ —
add a third by dropping a themes/<name>.css file there defining the
same --sg-* custom properties as light.css/dark.css. --sg-highlight
is the default color .highlight() uses when called without an explicit
color.
set_theme() also accepts a path to a CSS file anywhere in your own
project, not bundled with kwebui at all — self.set_theme("assets/brand.css").
Its name becomes that file's own stem (brand.css → "brand"); pass
that name again later to switch back to it. See
docs/user-guide.md for the full
behavior (when it takes effect, live reload from disk, ...) and
examples/custom_theme.py for a runnable example.
Some --sg-* variables are structural defaults rather than
theme-specific colors — --sg-button-bg/--sg-button-text-color
(button's color/text_color defaults) and
--sg-container-padding-vertical/--sg-container-padding-horizontal
(container's padding defaults, 10px each) live once in base.css's
:root and apply across every theme. Override them in a custom
stylesheet to change the default everywhere at once, or pass the
matching widget parameter to override just one instance.
Packaging / installing elsewhere
This is a standard pyproject.toml package, so any of these work:
# Editable install for local development (what the steps above do)
pip install -e .
# Build a distributable wheel + sdist
pip install build
python -m build # writes dist/kwebui-<version>-py3-none-any.whl
pip install dist/kwebui-<version>-py3-none-any.whl # on any machine with that wheel
# Install straight from this repository (no PyPI needed)
pip install "git+https://github.com/Kaptura-GmbH/kwebui.git"
To embed kwebui in another app: pip install it into that app's own
venv the same way, then from kwebui import KApp as usual — there is
no separate "export" step, the package is the deliverable.
Project layout
See docs/architecture.md for the plugin system,
rendering pipeline, event flow, and session model, and
docs/class-diagram.md for a Mermaid class
diagram of the core framework and every built-in widget plugin.
The frontend (kwebui/frontend/static/) is Vue 3, loaded as a single
vue.global.js file (vendored under static/js/vendor/, no CDN) rather
than Single File Components — this keeps pip install -e . free of any
Node.js/npm involvement, at the cost of writing components as plain JS
objects with template strings instead of .vue files. Each widget type
is still its own file under frontend/static/js/widgets/, auto-discovered
and injected as a <script> tag the same way the Python side auto-discovers
plugins.
Session model
This is a deliberate simplification, not an oversight: true per-session
isolation would mean cloning the widget tree per connection and
re-binding every callback closure to the clone, which is a lot of
machinery for a library meant to be readable in an afternoon. Session
is still its own class internally, so per-session isolation could be
added later without an API break — see
docs/architecture.md for the
reasoning in full.
On top of the shared tree above, kwebui also defaults to keeping only
one live tab at a time (single_session=True, the default). Every
time a tab connects, the previously connected ones are disconnected and
show a "Disconnected — opened in another tab" badge; reloading such a
tab claims the app back. This is what you want for the common case of a
tool driving one piece of hardware or one workflow — no leftover tab
from an earlier run silently coming back to life as a second "live" view
when you restart the app. Pass KApp(single_session=False) to opt back
into the plain shared-broadcast model instead, for an app meant to be
open on a second monitor or another machine at the same time.
Contributing
Issues and pull requests are welcome. Before opening a PR:
- For anything touching rendering, layout, or an interactive round-trip (click → server → browser), verify it in a real browser — several real bugs in this codebase's history were invisible in a code read and obvious in a screenshot.
- Keep changes scoped: a bug fix doesn't need surrounding refactors, and
a new widget should follow the existing plugin pattern (see
docs/architecture.mdand any existingkwebui/widgets/*.pyfile as a template).
License
Apache License 2.0. See LICENSE.
Metadata
Release files for kwebui 0.12.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 | |
|---|---|---|---|
| kwebui-0.12.0.tar.gz | 684.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| kwebui-0.12.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 922.1 kB
Release files / kwebui-0.12.0.tar.gz
| Download URL | kwebui-0.12.0.tar.gz |
|---|---|
| Size | 684.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c8256ae17743474fbdee46bc95bb7ad33ebf53a2edba9118a2981f7a599b4b4b
|
|
BLAKE2b-256 checksum How to use checksums |
4eb6f29aaae1d4afa0dc587391e8907b189ec4abc0a089ed74bf7542e180a388
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.0 {"installer":{"name":"uv","version":"0.12.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / kwebui-0.12.0-py3-none-any.whl
| Download URL | kwebui-0.12.0-py3-none-any.whl |
|---|---|
| Size | 238.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
16c3d11d71b244e607b637ada9ab9796a0c9178ae9c05eaf8a14e2aca96d361c
|
|
BLAKE2b-256 checksum How to use checksums |
d69e6c6e4f29179804b1c9040d223774df459ca39800cc26172cdb4027408ea6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.0 {"installer":{"name":"uv","version":"0.12.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|