Lumen Python SDK
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
Modelinstance per app is the intended shape (the field names are the binding names markup reads). state.signal("count")returns the underlyingSignalhandle - for.watch()or to pass intocomputed().
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
ctypesonly). - 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, andwatchfiring) 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:
LUMEN_LIBRARY_PATH- a direct path to the library file, or a directory containing it.LUMEN_LIB_DIR- the same overridelumenchonours, so one setting points both at the same runtime.target/{debug,release}relative to the current working directory.target/{debug,release}relative to the workspace root (found by walking up for the workspaceCargo.toml).- An installed toolchain: the directory holding the
lumenconPATH, then$LUMEN_PREFIX/bin, default~/.lumen/bin. - 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)
| File | Size | Uploaded | |
|---|---|---|---|
| lumenui-0.0.7.tar.gz | 42.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| lumenui-0.0.7-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 83.5 kB