duit

Duit (Data UI Toolkit)

Documentation Duit Test PyPI Github

Duit is a Python library that provides a set of tools for working with data in a structured and efficient way. The goal of duit is to make it easy for developers to create and manage data models and create simple user interfaces for data entry and display. The implementation is based on the ideas of cansik/bildspur-base and cansik/bildspur-ui.

gui-demo

Example UI rendered with NiceGUI Open3D, tkinter and wx (ltr).

Features

  • Data Modeling: duit provides a flexible data modeling framework to create structured data models and fields.

  • Annotations: Use annotations to attach metadata to data fields, making it easier to work with them.

  • Command-line Arguments: Easily parse and configure command-line arguments in your applications based on data fields.

  • Settings Serialization: Serialize and deserialize settings from data fields to and from json.

  • User Interface: Create simple user-interfaces for data fields.

Installation

By default, only the data modeling, annotation, arguments and settings modules are installed.

pip install duit

To support user interface creation for data fields, one of the following backends can be installed:

  • open3d - Cross platform UI framework with support for 3d visualisation.
  • wx - Wx based UI framework which is cross platfrom and very stable.
  • nicegui - Web based interface which looks really nice and is our favourite choice.
  • tkinter - More stable UI framework, currently not feature complete.

If you are already using open3d, this is the recommended choice as gui backend.

pip install "duit[open3d]"

To install tkinter use the following command:

pip install "duit[tk]"

To install wx use the following command:

pip install "duit[wx]"

To install nicegui use the following command:

pip install "duit[nicegui]"

To install duit with all backends call pip like this:

pip install "duit[all]"

Example

This is a very basic example on how to use duit. Read to documentation for a more information about the core concepts.

import argparse

from open3d.visualization import gui

from duit import ui
from Argument import Argument
from Arguments import DefaultArguments
from DataField import DataField
from Settings import DefaultSettings
from ContainerHelper import ContainerHelper
from Open3dPropertyPanel import Open3dPropertyPanel
from Open3dPropertyRegistry import init_open3d_registry


class Config:
    def __init__(self):
        container_helper = ContainerHelper(self)

        with container_helper.section("User"):
            self.name = DataField("Cat") | ui.Text("Name")
            self.age = DataField(21) | ui.Slider("Age", limit_min=18, limit_max=99)

        with container_helper.section("Application"):
            self.enabled = DataField(True) | ui.Boolean("Enabled") | Argument()


def main():
    # create initial config
    config = Config()

    # register a custom listener for the enabled flag
    config.enabled.on_changed += lambda e: print(f"Enabled: {e}")

    # add arguments and parse
    parser = argparse.ArgumentParser()
    args = DefaultArguments.add_and_configure(parser, config)

    # store current config
    DefaultSettings.save("config.json", config)

    # create open3d gui for to display config
    init_open3d_registry()

    app = gui.Application.instance
    app.initialize()

    window: gui.Window = gui.Application.instance.create_window("Demo Window", 400, 200)
    panel = Open3dPropertyPanel(window)
    window.add_child(panel)
    panel.data_context = config

    app.run()


if __name__ == "__main__":
    main()

Which results in the following GUI.

example-window

Development

To develop it is recommended to clone this repository and install the dependencies like this:

# in the duit directory
pip install -e ".[all]"

Generate Documentation

# create documentation into "./docs
python setup.py doc

# launch pdoc webserver
python setup.py doc --launch

About

MIT License - Copyright (c) 2025 Florian Bruggisser

Documentation

Almost every application has a configuration that contains various attributes defining how it operates. These attributes typically reside in a data model which represents the application's data. In Python, we can design a class with attributes to represent the data. duit provides valuable tools and methods to work with data models that facilitate recurring implementation. The documentation provides guidance on implementing common requirements such as serialization or observability.

This documentation gives an insight into the features of duit and explains the core concepts of the library.

Event

One of the central elements of duit is the Event class which implements the traditional observer pattern for generic values. It internally holds a list of event handlers and can be called upon to inform its listeners.

Here's a basic example of how to create an event, register a listener, and activate an event:

from Event import Event

# define event
on_new_age: Event[int] = Event()


# create an event handler
def on_birthday(value: int):
    print(f"You are now {value}")


# register handler on event - this can be repeated multiple times
on_new_age += on_birthday

# fire event multiple times
on_new_age(15)
on_new_age(16)
on_new_age(17)

Sometimes it can be beneficial to register a new handler and call it upon initialization. The Event class offers the invoke_latest() method to execute the most recently added event handler.

Additionally, it is possible to verify whether an event handler is already registered and remove it if necessary.

if on_birthday in on_new_age:
    on_new_age -= on_birthday

The choice to utilise the += and -= operators is based on their reliability and simplicity in the C# programming language. Additionally, traditional methods such as contains(), append(), and remove() have been implemented.

Register Method Decorator

Instead of adding the event handler method explicitly, it is also possible to use the register() method as a decorator for event handler methods.

from Event import Event
on_new_age: Event[int] = Event()

@on_new_age.register
def on_change(value: int):
    print(value)

Waiting for Events

In certain cases, it is useful to block execution until the next event occurs. The wait() method allows the program to pause and wait for an event to be fired, returning the value passed when the event was triggered.

# Wait for the next event (blocks until the event is fired)
result = on_new_age.wait()
print(f"The next age is {result}")

Optionally, a timeout can be specified in seconds. If no event is fired within the given time frame, wait() will return None:

# Wait for the next event with a timeout of 2 seconds
result = on_new_age.wait(timeout=2)
if result is None:
    print("No event occurred within the timeout")
else:
    print(f"The next age is {result}")

Streaming Events

For scenarios where continuous event listening is required, the stream() method provides an iterator that yields values as events are fired. This is particularly useful when handling streams of data.

# Stream events as they are fired
for age in on_new_age.stream():
    print(f"Streaming age: {age}")

Similar to wait(), the stream() method can accept a timeout parameter. If no event is fired within the timeout period, it will yield None.

# Stream events with a 1-second timeout
for age in on_new_age.stream(timeout=1):
    if age is None:
        print("Timeout reached, no event occurred within 1 second")
        break
    else:
        print(f"Streaming age: {age}")

Data Field

The DataField serves as a generic wrapper for data attributes, typically in the form of a state representation for an application.

This highlights the importance of notifying other application components when a state change occurs. The notification system is implemented internally using the Event class. This class permits the registration of data change listeners.

Value

Here is an example of how to use a DataField:

import numpy as np
from DataField import DataField

# creating example datafields
name = DataField("Test")
age = DataField(21)
data = DataField(np.zeros((5, 5)))

# display values
print(f"{name.value}: {age.value}")

# change value of the age
age.value += 1

The DataField can accept any data type, but requires initialization with a default value during setup. Data is stored in the value attribute and the internal system uses generic-type-hints to aid code-completion and static type-checking tools.

One benefit of encapsulating the data in an object is that the value can now be passed by reference.

def add_two(field: DataField):
    field.value += 2


age = DataField(21)
add_two(age)
print(age.value)  # outputs 23

Observable

The DataField class applies the observer pattern, allowing other system components to monitor value changes. The event triggered by a change in data is named on_changed(). It consistently updates the event handlers with the latest value.

# create event handler
def on_name_changed(new_name: str):
    print(f"New name: {new_name}")


# create datafield and register event handler
name = DataField("Test")
name.on_changed += on_name_changed

# change name
name.value = "Hello World"

If the value attribute is set with the exact same value (__eq__), the event will not trigger. However, it is still possible to manually trigger the event by calling the fire() or fire_latest() method. In some cases, it may be necessary to set the value without triggering an event. This can be achieved using the set_silent() method or by disabling the event invocation entirely by setting publish_enabled = false.

Data Binding

Another feature that the DataField allows is the ability to have data bindings between different attributes. For example, it is possible to update other data fields when the value of another data field is changed (one-way binding).

a = DataField("A")
b = DataField("B")
c = DataField("C")

# bind a to b / c
a.bind_to(b)
a.bind_to(c)

# update value in a
a.value = "X"

print(b.value)  # outputs X

# important: this does not update a or c because it's a one-way binding
b.value = "BB"

Bidirectional Binding

It is also possible to synchronize the value of two attributes by creating a two-way binding or bidirectional binding.

from DataField import DataField

a = DataField("A")
b = DataField("B")

a.bind_bidirectional(b)

a.value = "T"  # b gets updated to T
b.value = "X"  # a gets updated to X

Attribute Binding

Sometimes it can be helpful to bind directly to basic Python attributes of variables. The bind_to_attribute() method supports this behaviour.

# example user class containing basic python attributes
class User:
    def __init__(self):
        self.name = "Test"


# create objects
user = User()
name = DataField("A")

# bind datafield name to user.name
name.bind_to_attribute(user, "name")

Converter Method

It is also possible to provide a converter method to the binding. This method is called when the value has changed and before it is written to the base attribute.

def to_upper(name: str) -> str:
    return name.upper()


name.bind_to_attribute(user, "name", to_upper)

Named Reference

Since attributes cannot be passed by reference in Python, the attribute name must be passed to the method as a Python string. This can cause problems when using refactoring tools. To support refactoring and to reference the actual field instead of its name, duit provides a helper method create_name_reference() to look up the names of object attributes. It works by wrapping the actual object with a decorator class, which only returns the name of the called attribute instead of its value.

from DataField import DataField
from duit.utils.name_reference import create_name_reference

# every call to a user_ref attribute returns the attributes name instead of its value
user_ref = create_name_reference(user)
name.bind_to_attribute(user, user_ref.name)

Plugins

Sometimes it is necessary to modify the value as it is being written or read. To extend the functionality of a DataField and intercept at certain key points in the process, it is possible to write a DataFieldPlugin. A plugin is an abstract class that contains method stubs for handling the set, get and fire() value methods. By overriding the handlers, additional functionality can be added to a DataField. It is important to note that adding plugins to a DataField can lead to performance and logic problems and should only be done if the default API of a DataField is not sufficient.

For example, it may be necessary to constrain a numeric value between a min and a max number. To achieve this, it is possible to write the following plugin.

from typing import Union

from DataField import DataField
from DataFieldPlugin import DataFieldPlugin

Number = Union[int, float]


class RangePlugin(DataFieldPlugin[Number]):

    def __init__(self, min_value: Number, max_value: Number):
        super().__init__()
        self.min_value = min_value
        self.max_value = max_value

    def on_register(self, field: DataField[Number]):
        if not isinstance(field.value, Number.__args__):
            raise ValueError(f"Value of data-field {field} is not Number!")

    def on_set_value(self, field: DataField[Number], old_value: Number, new_value: Number) -> Number:
        return max(min(self.max_value, new_value), self.min_value)

The code snippet creates a RangePlugin that changes the inserted value to be between the specified min and max values. The plugin also checks on register if the DataField contains a Number type, otherwise it throws an exception. Please refer to the API documentation of the DataFieldPlugin for more handlers that can be overridden.

To register the plugin on an existing DataField, the register_plugin() method can be used.

range_plugin = RangePlugin(0, 1000)

field = DataField(500)
field.register_plugin(range_plugin)

To unregister a plugin, use the unregister_plugin() method.

# unregister a single plugin
field.unregister_plugin(range_plugin)

# remove all plugins
field.clear_plugins()

Plugin Order ⚠️

Each DataFieldPlugin contains a field order_index: int which specifies the order in which the plugin is applied. The order is set to 0 by default and is ascending (lowest first). The order is used to order the plugins when they are registered.

The order is defined as follows:

# on_get_value order
internal value -> plugin-1, plugin-2, plugin-3 -> return value

# on_set_value order is reversed
value.set -> plugin-3, plugin-2, plugin-1 -> internal value

# on_fire the order is set like in get order
on_fire -> plugin-1, plugin-2, plugin-3 -> fire value

This means that the lower the order_index of a plugin, the closer it is to the actual value stored in a DataField.

Data List

Since only the change of the whole value within a DataField is registered, changes of values within a value are not triggered. The following example illustrates this behaviour:

from DataField import DataField

numbers = DataField([1, 2, 3])

numbers.value.append(5)  # this does not trigger on_changed
numbers.value = [5, 6, 7]  # this triggers on_changed

Since lists are an essential part of data models, duit implements the DataList to support lists with the same behaviour strategy as DataField. It basically works like a normal Python list but implements the observable pattern.

from DataList import DataList

data = DataList([1, 2, 3])


def on_fire(value):
    print(f"list has changed: {value}")


data.on_changed += on_fire

data.append(5)
data.append(7)

for i in data:
    print(i)

It is also important to note that DataList inherits from DataField.

Annotation

This chapter explains the core concepts of annotations and how to create custom annotations. If you are interested in the annotations provided by duit, please go to that chapter:

In order to provide extended capabilities for DataField, duit introduces the concept of field annotations to python. In python there are only decorators to extend the functionality of an existing method or function. function. There is currently no concept for annotating a class attribute.

Custom Annotation

To create a custom annotation that can be applied to a DataField, a new class must be implemented that inherits from the abstract class Annotation. An annotation is applied to a data field by creating a private field attribute. This allows annotations to be applied to any Python object in the future. The attribute name must be provided as a static method. Here is an example annotation that provides a help text for a data field.

from Annotation import Annotation, M


class MyHelpAnnotation(Annotation):

    def __init__(self, help_text: str):
        self.help_text = help_text

    @staticmethod
    def _get_annotation_attribute_name() -> str:
        return "__my_help_annotation"

    def _apply_annotation(self, model: M) -> M:
        model.__setattr__(self._get_annotation_attribute_name(), self)
        return model

Usage

Currently, the concept of annotation can only be applied to existing data fields. Since the @ notation cannot be used due to Python syntax restrictions, the annotation must be applied using the right-or (__ror__) operator. This operator was chosen so as not to interfere with the existing type hint system, and to be able to easily stack multiple annotations to any object. Here is an example of applying the custom MyHelpAnnotation to an existing data field. Because the _apply_annotation method returns the same DataField type that was applied to the method, syntax completion in IDEs still works for the age attribute.

age = DataField(21) | MyHelpAnnotation(help_text="The age of the user.")

Multiple annotations can quickly lead to a long line length, which is usually limited in Python. To create multi-line annotation chains it is recommended to use the parenthesis syntax:

is_active = (DataField(False)
                  | FirstAnnotation()
                  | SecondAnnotation())

To find annotations inside objects, duit provides a helper class called AnnotationFinder. The class can find annotations of a certain type or subtype within objects, and also recursively within attributes of such an object. This allows for complex object structures, such as for example configurations. To find our custom annotation MyHelpAnnotation, it is possible to use the annotation finder as shown in the following example.

from AnnotationFinder import AnnotationFinder


# create user class and instantiate an example object
class User:
    age = DataField(21) | MyHelpAnnotation(help_text="The age of the user.")


user = User()

# create an annotation finder to find MyHelpAnnotations
finder = AnnotationFinder(MyHelpAnnotation)
annotations = finder.find(user)

# display the results
for field_name, (data_field, annotation) in annotations:
    print(f"Help text of attribute {field_name}: {annotation.help_text}")

Settings

The Settings class is an annotation based JSON serialiser and deserialiser for DataField. By default, every datafield already has this annotation on instantiation. Here an example on how to load and save objects into a file that contain data fields.

from Settings import DefaultSettings


# define and instantiate an example class User
class User:
    def __init__(self):
        self.name = DataField("Test")
        self.age = DataField(21)


user1 = User()

# save user
DefaultSettings.save("test.json", user1)

# load user
user2 = DefaultSettings.load("test.json", User)

Of course, there are also intermediate methods that simply serialise (to dict) or convert the object to a JSON string:

from Settings import DefaultSettings

# serialization
result_dict: Dict[str, Any] = DefaultSettings.serialize(user)
result_str = DefaultSettings.save_json(user)

# deserialization
obj_from_dict = DefaultSettings.deserialize(result_dict, User)
obj_from_json = DefaultSettings.load_json(result_str, User)

Setting Annotation

By default, each DataField contains a Setting annotation that defines the JSON attribute name and whether the datafield is exposed (default: True). To restrict the serialisation of a particular datafield, it is possible to override the default setting annotation.

from Setting import Setting


class CustomUser:
    def __init__(self):
        self.name = DataField("Test") | Setting(exposed=False)  # this datafield is not serialized
        self.age = DataField(21) | Setting(name="user-age")  # change the name of setting

The CustomUser would result in the following serialized JSON:

{
  "user-age": 21
}

Setting Order

To define the loading and saving order, it is possible to set the priority (ascending order - low to high) of each Setting. This can be useful if settings need to be loaded and saved in a particular order.

In the following example, the order of the fields to be loaded will be c - b - a. When saving, the fields are processed in the order b - a - c.

class Config:
    def __init__(self):
        self.a = DataField("A")
        self.b = DataField("B") | Setting(name="b-field", load_order=5, save_order=10)
        self.c = DataField("C") | Setting(load_order=1)

Orders are set to sys.maxsize by default.

Custom Settings

Instead of using the DefaultSettings instance, it is possible to create multiple custom Settings instances, that contain different type adapters or have different configuration parameters. For simplicity, it is recommended to just use the DefaultSettings class.

Type Adapter

A type adapter defines how a particular type is serialised and deserialised. A Settings class contains a list of type adapters that can be used to define the serialisation behaviour of complex data types. For an example of a custom type adapter, see PathSerializer.

To register a custom type, use the serializers list attribute of the Settings class.

DefaultSettings.serializers.append(YourCustomSerializer())

Arguments

Since data usually needs to be parameterised, duit provides tools and methods to expose datafields as program arguments via argparse. It is possible to use DefaultArguments to add the params to an argparse.ArgumentParser and also copy the argparse.NameSpace attributes back into datafields. To expose a datafield as argument, use the Argument annotation.

import argparse

from Argument import Argument
from Arguments import DefaultArguments


class Config:
    def __init__(self):
        self.device = DataField(0) | Argument(help="Device id.")
        self.write_output = DataField(False) | Argument(help="Write output to console.")
        self.debug_text = DataField("123") | Argument(dest="--dbg", help="Debug text.")


config = Config()

# create argument parser and automatically add and configure the config class
parser = argparse.ArgumentParser()
args = DefaultArguments.add_and_configure(parser, config)

Running the above script with the --help parameter will generate the following help text.

usage: ArgumentTest.py [-h] [--device DEVICE] [--write-output WRITE_OUTPUT]
                       [--dbg DBG]

optional arguments:
  -h, --help            show this help message and exit
  --device DEVICE       Device id.
  --write-output WRITE_OUTPUT
                        Write output to console.
  --dbg DBG             Debug text.

Custom Arguments

Instead of using the DefaultArguments instance, it is possible to create multiple custom Arguments instances that contain different type adapters or have different configuration parameters. For simplicity it is recommended to use only the DefaultArguments class.

Custom Type Adapters

To implement custom type adapters for the Arguments class, see the PathTypeAdapter example. Registering new type adapters can be done using the type_adapters attribute.

DefaultArguments.type_adapters.append(MyCustomTypeAdapter())

User-Interface

A strength of duit is that it automatically generates a property viewer for a class containing data fields. This helps to quickly change parameters in real time and observe the behaviour of a running application. To be future-proof, duit is able to implement different GUI backends and can be integrated into new and existing ones. This documentation focuses on the open3d implementation, but for example a tkinter and wx backend is also implemented.

Please note that additional dependencies need to be installed for the GUI backends. This can be done by adding the extra attribute to the install command:

# open3d
pip install "duit[open3d]"

# tkinter
pip install "duit[tk]"

# wx
pip install "duit[wx]"

Property Panel

The duit library implements a custom UI component called BasePropertyPanel, which is able to display all datafields of an object as UI properties. Each backend has its own implementation of the BasePropertyPanel, as well as the datafield type specific properties (e.g. Open3dPropertyPanel). It is possible to change the data_context of a BasePropertyPanel to display the properties of another object.

from open3d.visualization import gui

from Open3dPropertyPanel import Open3dPropertyPanel
from Open3dPropertyRegistry import init_open3d_registry

# first the property registry for the specific backend has to be initialized
# this step connects the ui-annotations with the actual property implementation
init_open3d_registry()

# create new 3d app
app = gui.Application.instance
app.initialize()

# create a new window
window: gui.Window = gui.Application.instance.create_window("Demo Window", 400, 200)

# create a new property panel for open3d and add it to the window
panel = Open3dPropertyPanel(window)
window.add_child(panel)

# set the data-context of the property panel to an existing object
panel.data_context = config

# run the application
app.run()

UI Annotations

To tell the BasePropertyPanel how to render a datafield value, predefined ui annotations can be used. They are all exposed in the duit.ui module. The special thing about ui annotations is that more than one ui annotation can be applied to a field.

from duit import ui


class Config:
    def __init__(self):
        self.device = DataField(0) | ui.Number("Device")
        self.write_output = DataField(False) | ui.Boolean("Write Output", readonly=True)
        self.debug_text = DataField("123") | ui.Text("Dbg", tooltip="The debug text.")
        self.threshold = DataField(127) | ui.Slider("Threshold", limit_min=0, limit_max=255)

UI Sections

To structure a configuration into different settings, it is possible to use section annotations. Due to the complexity, it is recommended to use a helper class to handle the definition of sections. Here is an example of how to define a subsection. Since the with scope is used, the programming interface for the Config class is not changed.

from ContainerHelper import ContainerHelper


class Config:
    def __init__(self):
        container_helper = ContainerHelper(self)

        with container_helper.section("User"):
            self.device = DataField(0) | ui.Number("Device")
            self.write_output = DataField(False) | ui.Boolean("Write Output", readonly=True)

        # create section for debug parameters
        with container_helper.section("Debug"):
            self.debug_text = DataField("123") | ui.Text("Dbg", tooltip="The debug text.")
            self.threshold = DataField(127) | ui.Slider("Threshold", limit_min=0, limit_max=255)

When rendered, the following GUI is created, as well as any necessary bindings between the UI widgets and the data fields.

doc-window

Experimental

Everything marked as experimental in this documentation has an exclamation mark emoji ⚠️behind its chapter title. That means that the functionality is very likely to change in the future and should be used with cautious.

1"""
2.. include:: ../README.md
3.. include:: ../DOCUMENTATION.md
4"""