Guide

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 Linux (manylinux x86_64) wheels to a self-hosted PEP 503 index served from azul.rs. Pointing --index-url at it makes pip 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. On platforms without a wheel on that index, use the manual download below.

Manual download

Download the prebuilt extension module for your platform from the release page and put it next to your script:

# macOS
curl -L -o azul.so https://azul.rs/ui/release/0.2.0/azul.so
# linux
curl -L -o azul.so https://azul.rs/ui/release/0.2.0/azul.cpython.so
# windows
curl.exe -L -O https://azul.rs/ui/release/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_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 DataModel instance for you and hands the same instance back to your callbacks. The framework holds a strong reference until you drop the App, so the GC will not eat it under your feet.
  • Strings are str, styles are CSS strings. No AzString, no String(...) wrapper, no AZ_CONST_STR macro. Pass UTF-8 Python strings; the binding converts at the boundary.
  • Callbacks are regular functions with the signature (data, info) -> Update (or -> Dom for layout). No extern "C", no boxing, no decorators — just def.

Things we did not use that you may want to explore next.

  • The info argument — read-only access to the system font cache, image cache, GL context, current window size, routing, and localization dictionaries in layout; lots of mutation helpers in on_click (DOM navigation, CSS overrides without rebuilding, computed-layout queries).
  • WindowCreateOptions — the Python binding currently exposes only WindowCreateOptions.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.

  1. app.run(window) opened a native window and ran layout() once with your DataModel on startup.
  2. The returned Dom was styled, laid out, and rendered.
  3. On click, the framework matched the button's event filter, called on_click(data, info), observed the Update.RefreshDom return, and re-invoked layout().
  4. The new Dom was 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 downloaded azul.so / azul.pyd is not in the directory you are running from (or on sys.path). Run python3 from the directory containing the file, or prepend that path to sys.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 Dom for layout, DoNothing for event callbacks), so check the terminal you started the app from.
  • Counter does not advance — the click callback returned Update.DoNothing, or it implicitly returned None (which the binding treats as DoNothing). Always end a mutating handler with return Update.RefreshDom.
  • TypeError: layout() takes 0 positional arguments but 2 were given — your callback signature is wrong. layout and 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 data with a fresh DataModel(...) somewhere inside the callback.

Coming Up Next