Hello World [Python]
Introduction
Python is the easiest way to use Azul. You can write idiomatic Python — plain classes,
plain str, plain method calls — and the binding (uses Rusts pyo3) takes care of the rest.
Installation
Azul is not on the public PyPI (the azul package on pypi.org is an unrelated
project - do not run a plain pip install azul). There are two ways to install:
the self-hosted pip index on azul.rs, or a manual download.
Self-hosted pip index
CI publishes abi3 wheels for Linux x86-64 (manylinux), macOS arm64 and
Windows x64 to a self-hosted PEP 503 index served from azul.rs. Pointing
--index-url at it makes pip (or uv, poetry, pdm) resolve azul from
azul.rs instead of pypi.org:
pip install azul --index-url https://azul.rs/ui
This is NOT the public PyPI - the index is generated by the same CI run that
builds the release binaries. It deliberately carries no source distribution:
the extension needs generated sources that only the repository's code
generator produces, so a pip source build cannot work, and an index that
offered one produced a long compiler failure where „no matching
distribution“ was the truthful answer. A platform without a wheel here (e.g.
Linux arm64) uses the manual download below.
Manual download
Download the prebuilt extension module for your platform from the GitHub release (the modules exceed the size limit of the azul.rs file host) and put it next to your script:
# macOS
curl -L -o azul.so https://github.com/fschutt/azul/releases/download/0.2.0/azul.so
# linux
curl -L -o azul.so https://github.com/fschutt/azul/releases/download/0.2.0/azul.cpython.so
# windows
curl.exe -L -O https://github.com/fschutt/azul/releases/download/0.2.0/azul.pyd
The module bundles the prebuilt native library, so there are no further system
dependencies to worry about. The abi3 wheel targets Python 3.10+ (pyo3 is
abi3-py310) - make sure you have a recent version.
Note
If there is no prebuilt module for your platform, see „Building the extension“ below for the manual route.
Simple „Counter“ Example
from azul import *
# Plain Python class - "single source of truth" for app state
class DataModel:
def __init__(self, counter):
self.counter = counter
# Layout callback: f(DataModel, LayoutCallbackInfo) -> Dom. Runs once on
# startup and again after every callback that returns Update.RefreshDom.
def layout(data, info):
# Rendered counter label: a text node wrapped in a styled div.
# .with_css(...) consumes self and returns a new Dom, so builder
# calls chain inline.
label = (Dom.create_div()
.with_child(Dom.create_span_with_text(str(data.counter)))
.with_css("font-size: 32px;"))
# Button widget with a click handler. Everything lives in the flat
# `azul` module; with_on_click(data, callback) registers the handler
# and .dom() turns the widget into a Dom node.
button = (Button.create("Increase counter")
.with_on_click(data, on_click)
.dom()
.with_css("flex-grow: 1;"))
# Final wrapup - Dom.create_body builds the root, then .with_child(...)
# appends children. Builder methods return a NEW Dom - keep chaining
# (or re-assign the result); they do not mutate in place.
return (Dom.create_body()
.with_child(label)
.with_child(button))
# Click callback: f(DataModel, CallbackInfo) -> Update. 'data' is the same
# Python instance you passed to App.create, it is mutated in place (thread
# safe). Update variants are plain class attributes - no parentheses:
# return Update.RefreshDom.
def on_click(data, info):
data.counter += 1
return Update.RefreshDom
# main function
if __name__ == "__main__":
# Initialize the data model (here we set counter=5 on startup)
model = DataModel(5)
# Configure the window. layout is the "/" default route; SPA-style
# routing is done later by swapping the layout callback.
window = WindowCreateOptions.create(layout)
# AppConfig discovers system-native styling, monitor layout, etc.
# App.run blocks until the last window closes.
app = App.create(model, AppConfig.create())
app.run(window)
Three things to notice.
- Pass plain Python objects. No upcast, no downcast, no reflection macro. The binding wraps your
DataModelinstance for you and hands the same instance back to your callbacks. The framework holds a strong reference until you drop theApp, so the GC will not eat it under your feet. - Strings are
str, styles are CSS strings. NoAzString, noString(...)wrapper, noAZ_CONST_STRmacro. Pass UTF-8 Python strings; the binding converts at the boundary. - Callbacks are regular functions with the signature
(data, info) -> Update(or-> Domfor layout). Noextern "C", no boxing, no decorators — justdef.
Things we did not use that you may want to explore next.
- The
infoargument — read-only access to the system font cache, image cache, GL context, current window size, routing, and localization dictionaries inlayout; lots of mutation helpers inon_click(DOM navigation, CSS overrides without rebuilding, computed-layout queries). WindowCreateOptions— the Python binding currently exposes onlyWindowCreateOptions.create(layout); setting the window title, size, decorations etc. from Python is not wrapped yet. The underlying options are covered in windowing.
Run it
python3 hello-world.py
You should see the window pictured on the hello-world landing page. Click the button: the counter increments, the layout callback re-runs, and the new value renders.
app.run(window)opened a native window and ranlayout()once with yourDataModelon startup.- The returned
Domwas styled, laid out, and rendered. - On click, the framework matched the button's event filter, called
on_click(data, info), observed theUpdate.RefreshDomreturn, and re-invokedlayout(). - The new
Domwas diffed against the previous one; only the changed text node was repainted.
Building the extension
Only needed if there is no prebuilt module for your platform, or if you want to track master. From a checkout:
# git clone https://github.com/fschutt/azul
# cd myfolder/azul
cargo build -p azul-dll --release \
--no-default-features --features python-extension
The resulting library is target/release/libazul.{so,dylib} (azul.dll on Windows). Python imports it as azul, so rename or symlink it:
# macOS
cp target/release/libazul.dylib target/release/azul.so
# Linux
cp target/release/libazul.so target/release/azul.so
# Windows
copy target\release\azul.dll target\release\azul.pyd
Then either run Python from the directory containing the file, or prepend that path to sys.path:
import sys, os
sys.path.insert(0,
os.path.join(os.path.dirname(__file__), 'target', 'release'))
import azul
Common errors
ModuleNotFoundError: No module named 'azul'— the downloadedazul.so/azul.pydis not in the directory you are running from (or onsys.path). Runpython3from the directory containing the file, or prepend that path tosys.path.- Blank window / dead button — a callback raised an exception. The binding prints the Python traceback to stderr and falls back to a default return value (an empty
Domforlayout,DoNothingfor event callbacks), so check the terminal you started the app from. - Counter does not advance — the click callback returned
Update.DoNothing, or it implicitly returnedNone(which the binding treats asDoNothing). Always end a mutating handler withreturn Update.RefreshDom. TypeError: layout() takes 0 positional arguments but 2 were given— your callback signature is wrong.layoutand click handlers must accept exactly(data, info).- Mutation isn't sticking — you mutated a copy of the model instead of the instance bound to the framework. The binding always passes the same instance back; check that you are not shadowing
datawith a freshDataModel(...)somewhere inside the callback.