duit
Duit (Data UI Toolkit)
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.
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.
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.
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.