Skip to main content

Lumen Python SDK

ci python docs license

Typed Python bindings for the Lumen UI framework, over the C ABI (include/lumen.h). State is declarative and reactive: you write a Model whose fields are typed signals, mutate them with plain Python, and the runtime keeps the UI in sync.

Stdlib only: ctypes, no cffi, no compiled extension, no build step of its own. The only thing to build is the Rust side, once.

Quickstart

import lumen

class Counter(lumen.Model):        # dataclass-style: each field is a signal
    count: int = 0
    label: str = "0 clicks"

app = lumen.App("counter_app", title="Counter")
state = Counter(app)

@app.on_click("bump")              # decorator; handler takes no args
def bump():
    state.count += 1               # typed, autocompleted, syncs to the runtime
    state.label = f"{state.count} clicks"

app.run()                          # blocks until the window closes

Handlers may take the clicked element_id string or no argument at all; def bump(): and def bump(element_id): both work, mirroring the C++ SDK's void() and void(std::string) overloads. You can also register without the decorator: app.on_click("bump", bump).

state.count and state.label are signals: reading a field reads the signal, writing it pushes the new value to the runtime, and markup bound with bind-text="label" reflects it next tick. Field names are the binding names; override with Field(name="other").

The surface, most to least abstract

You want... Reach for
Declarative reactive state lumen.Model + lumen.Field
A single typed signal lumen.Signal[T]
A derived value lumen.computed(name, fn, *deps)
To react to a change signal.watch(fn)
To query / mutate the live element tree lumen.dom
A raw lumen_signal_* C call lumen.raw (appendix)

Dynamic DOM (lumen.dom)

lumen.dom reaches the live element tree the way a browser script does: query and traverse nodes, read and write attributes / classes / text / inline style, build and rearrange subtrees, read post-layout geometry, and bind event handlers. A dom.Node is a cheap handle; reads return None / empty for a stale handle and mutations chain.

from lumen import dom

row = dom.spawn("row").add_class("item").set_text("hello")
list_node = dom.get_by_id("list")
if list_node:
    list_node.append(row)

row.on("click", lambda e: print("clicked", e.target().handle, e.type()))

Model / Field

class TodoList(lumen.Model):
    items: list = lumen.Field(default_factory=list)   # array signal (<for>)
    summary: str = "0 items"

state = TodoList(app)
state.items = state.items + [{"id": "t1", "text": "Buy milk"}]  # re-renders <for>
state.summary = f"{len(state.items)} items"
  • Annotated fields become typed signals (int->int64, float->float64, bool, str, lumen.Color, list->array).
  • Defaults are applied on construction; use Field(default_factory=...) for mutable defaults (lists).
  • Signals are global by name, so one Model instance per app is the intended shape (the field names are the binding names markup reads).
  • state.signal("count") returns the underlying Signal handle - for .watch() or to pass into computed().

Signal[T] - standalone typed handles

count = lumen.Signal("count", 0)          # kind inferred from the initial value
count += 1                                 # typed operators where they make sense
count.value = 10                           # .value / .get() / .set()
tint = lumen.Signal("tint", lumen.Color(255, 128, 0))

@count.watch                               # decorator or count.watch(fn)
def _(new_value):
    print("count is now", new_value)

Signal(name, initial=None, *, type=...) - pass type= when there is no initial value, or to widen an int literal to float.

watch and computed

signal.watch(fn) is an ABI subscription (lumen_signal_watch), not a polling loop: fn(new_value) fires on the Lumen tick thread each time the value commits. A freshly-registered watch also fires once with the current value on the first tick it is observed. Watches only fire while an app is running (run or run_headless), because that is when ticks happen.

computed(name, fn, *deps) derives a signal from fn(), recomputing whenever any dependency commits. Direct or decorator form:

count = lumen.Signal("count", 0)

# direct
lumen.computed("label", lambda: f"{count.value} clicks", count)

# decorator (deps first, then decorate the recompute fn)
@lumen.computed("label", count)
def label():
    return f"{count.value} clicks"

Colors

lumen.Color is an RGBA tuple with named channels and CSS-style hex:

lumen.Color(255, 128, 0)            # r, g, b (, a=255)
lumen.Color("#ff8000")             # hex string (also #f80 / #ff8000ff)
lumen.Color.from_hex("#ff8000ff")  # explicit
c = lumen.Color("#ff8000"); c.r, c.g, c.b, c.a
c.to_hex()                          # "#ff8000ff"

Typing

The package ships a PEP 561 py.typed marker, so once installed the inline annotations are visible to mypy / Pyright and drive IDE completion for Model fields, Signal[T], and the App methods.

Requirements

  • Python 3.9+ (stdlib ctypes only).
  • The Lumen C library, liblumen, built as a shared library.
  • App.run() opens an OS window and a GPU surface. For CI or a no-display environment, App.run_headless(ticks) drives the full app (scripts, bindings, <for> and <if> reconciliation, and watch firing) for a fixed number of ticks with no window.

Build the C library

From the Lumen workspace root:

cargo build -p lumen            # -> target/debug/liblumen.{so,dylib,dll}
cargo build -p lumen --release  # -> target/release/...

Run the examples

LUMEN_LIBRARY_PATH=target/debug python sdk/python/examples/counter.py
LUMEN_LIBRARY_PATH=target/debug python sdk/python/examples/todo.py

Both scripts add their parent directory to sys.path, so they run straight from a checkout with no pip install. counter.py is the quickstart above; todo.py exercises a Model with a list field driving a <for> block (array signals).

Installing the package

pip install -e sdk/python

Registers the lumen package; the distribution is named lumenui, which is also the name it carries on PyPI. The distribution is pure Python: it holds no compiled extension and bundles no liblumen, so one install works on every platform and the runtime it binds is whichever one the machine already has. That means something has to provide liblumen: either the Lumen toolchain install (curl -fsSL https://lumenfx.dev/install.sh | sh, which puts the library in ~/.lumen/bin next to lumenc) or a checkout you built with cargo build -p lumen.

Locating the library

load_library() (called on first use) searches, in order:

  1. LUMEN_LIBRARY_PATH - a direct path to the library file, or a directory containing it.
  2. LUMEN_LIB_DIR - the same override lumenc honours, so one setting points both at the same runtime.
  3. target/{debug,release} relative to the current working directory.
  4. target/{debug,release} relative to the workspace root (found by walking up for the workspace Cargo.toml).
  5. An installed toolchain: the directory holding the lumenc on PATH, then $LUMEN_PREFIX/bin, default ~/.lumen/bin.
  6. The system loader's own search paths (LD_LIBRARY_PATH, /usr/lib, ...).

A checkout you are working in therefore wins over an installed toolchain, and either one is found without setting anything.

The loaded library's lumen_abi_version() is checked against the version this SDK targets, ABI 0.14. A major mismatch or an older minor raises LumenAbiVersionError.

Threading

@app.on_click handlers and watch callbacks fire on Lumen's tick thread, not necessarily the thread that called run. ctypes acquires the GIL for you, so touching Python state is safe; a slow or blocking handler stalls the whole event loop (rendering, input, timers). Keep handlers fast; hand real work to a Python thread you spawn yourself.

State you mutate from your own threads is still your responsibility to synchronise. Signal set/get themselves are thread-safe (each maps to one thread-safe C call).

Errors

Every lumen_* call maps a non-OK LumenStatus onto a dedicated exception under lumen.LumenError (LumenBadPathError, LumenCssError, LumenScriptError, ...), so you can except LumenCssError instead of string-matching. LumenLibraryNotFoundError and LumenAbiVersionError cover the two pre-call failures.

Appendix - the raw layer (lumen.raw)

lumen.raw.Signal is the thin surface every abstraction above is built on: each method is exactly one lumen_signal_* C call. Reach for it only when you need the raw ABI, for example the array setter.

from lumen import raw

raw.Signal.set_int64("count", 5)          # typed scalar setters/getters
raw.Signal.get_int64("count")             # -> 5
raw.Signal.set_str("label", "hi")         # string scalar
raw.Signal.set_array("rows", [{"id": "a", "name": "x"}])   # <for> array
raw.Signal.get_str("label")               # read back what the FFI last pushed

Read-back caveat: get_str, array_len, and array_field return what the embedder last pushed through the FFI, not live in-app state. A write from the app's own script, or a two-way input binding, is not visible through them. The number, bool, and color getters do see in-app writes. The typed layer above hides all of this.

Metadata

Release files for lumenui 0.0.7

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for lumenui 0.0.7
File Size Uploaded
lumenui-0.0.7.tar.gz 42.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for lumenui 0.0.7
File Interpreter ABI Platform
lumenui-0.0.7-py3-none-any.whl Python 3 none any Details

Total release size: 83.5 kB

Release files / lumenui-0.0.7.tar.gz

Download URL lumenui-0.0.7.tar.gz
Size 42.0 kB
Tags Source
SHA-256 checksum
How to use checksums
7f499b46a17bbbdac84e5c18d2405fd87be68d394c64bb3dedf6f0e3ea0aedcf
BLAKE2b-256 checksum
How to use checksums
a3fa3e52072ae303614d36399edbf6faf0132354f328c7b56fd33ba2b9befed2
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 21, 2026.

Transparency log

Release files / lumenui-0.0.7-py3-none-any.whl

Download URL lumenui-0.0.7-py3-none-any.whl
Size 41.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
cd583d2fc10b9f48c4d253df0c902efd0cbca87994f813def31a1d06298841c1
BLAKE2b-256 checksum
How to use checksums
825155c026ce9911b76b9477709103948e971c2855b208fd534c2bafb8bc111c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 21, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.0.7 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page