Attribute editor in PyQt

I’ve been working on a particle editor, though that isn’t entirely done yet, I did create something interesting in the process. An attribute editor for arbitrary python objects.

This is where I’m at right now, I hope to get to work on this more and share details about the particles themselves once it is more complete.

On the right you see an editor for the following object:

class ParticleSettings(OrderedClass):
    def __init__(self):
        super(ParticleSettings, self).__init__()
        # emitter
        self.emitterType = Enum(('Sphere', 'Cone', 'Box'), 0)
        self.emitterSettings = Vec3([0.5, 0.5, 0.5])
        self.emitterIsVolume = True
        self.randomDirection = False
        # not curve based
        self.startSize = RandomFloat()
        self.startSpeed = RandomFloat()
        self.startRotation = RandomFloat()
        self.lifeTime = RandomFloat()
        # curve based on particle alive time / life time
        self.sizeOverTime = RandomChannelFloat()
        self.angularVelocity = RandomChannelFloat()
        self.velocityOverTime = RandomChannelVec3()

I’ve added some data types so I can visualize them better, but the attribute editing framework I wrote works off the bat on python’s basic types.
I’d like to break down how I got here, as I wrote a heap of code which still needs a heap of refactoring for it to be presentable, I’ll demonstrate creating a more basic example, which should be more useful because it isn’t cluttered with my edge cases.

Preparing edit widgets

First, I made some widgets to edit some basic data types. Because we want to generate and connect our widgets to data, it’d be nice if they all have the same interface to keep the rest of our code abstract. I went for the following interface:

class AEComponent(QObject):
    # Attribute editor widget editing a single value type. Note that UI interactions from the user should emit valueChanged.
    valueChanged = pyqtSignal(object)

    def __init__(self):
        # The constructor may accept additional arguments, e.g. default value or enum options
        self._value = None

    def value(self):
        # Return the internal value
        return self._value

    def setValue(self, value):
        # Set value should programatically adjust the internal value, without emitting a signal; used in case multiple set values may trigger or when a parent widget is already going to send a change event.
        self.blockSignals(True)
        self._value = value
        self.blockSignals(False)

    def editValue(self, value):
        # Set the value and emit a change event
        self._value = value
        self.valueChanged.emit(value)

Note that this is an example of the interface, not a base class. I will not actually use the code above, I’ll just subclass Qt widgets and make them behave the same.

The core data types I want to support are:

int QSpinBox
float QDoubleSpinBox
bool checkable QPushButton
str QLineEdit
object recurse into its propreties
dict recurse into its items
list recurse into its items

Because tuples & sets are not mutable it’d be hard to construct a widget that sets the entire tuple at once.
I do not intend to adjust the composition of lists, dicts and objects – so no element insertion / removal.

Int & double
QSpinBox already has a value and setValue, but the setValue emits a signal. Instead I’m adding an editValue that forwards to the super setValue and make setValue block the signals. I’ve also made it so I can construct versions that only support e.g. ctypes.c_char by adding a number of bits parameter that is used to infer limits. It’d be trivial to extend this to unsigned and size-limited variants. The LineEditSelected used is listed below at the QLineEdit, it just simply selects all text at the focus event.

class SpinBox(QSpinBox):
    """
    QSpinBox with right limits & that follows the AEComponent interface.
    """
    def __init__(self, value=0, bits=32):
        super(SpinBox, self).__init__()
        self.setMinimum(-2 ** (bits - 1))
        self.setMaximum(2 ** (bits - 1) - 1)
        self.setValue(value)
        self.setLineEdit(LineEditSelected())

    def setValue(self, value):
        self.blockSignals(True)
        super(SpinBox, self).setValue(value)
        self.blockSignals(False)

    def editValue(self, value):
        super(SpinBox, self).setValue(value)

Doubles are almost identical.

class DoubleSpinBox(QDoubleSpinBox):
    """
    QDoubleSpinBox with right limits & that follows the AEComponent interface.
    """
    def __init__(self, value=0.0):
        super(DoubleSpinBox, self).__init__()
        self.setMinimum(-float('inf'))
        self.setMaximum(float('inf'))
        self.setValue(value)
        self.setSingleStep(0.01)  # Depending on use case this can be very coarse.
        self.setLineEdit(LineEditSelected())

    def setValue(self, value):
        self.blockSignals(True)
        super(DoubleSpinBox, self).setValue(value)
        self.blockSignals(False)

    def editValue(self, value):
        super(DoubleSpinBox, self).setValue(value)

Booleans with icons
A few more interesting things to make this work based on a checkable QPushButton.
Manual value changed signal handling & keeping track of the icon to use.

class IconBoolEdit(QPushButton):
    """
    QPushButton with icons to act as a boolean (not tri-state) toggle.
    """
    valueChanged = pyqtSignal(bool)

    def __init__(self, *args):
        super(IconBoolEdit, self).__init__(*args)
        self.__icons = icons.get('Unchecked'), icons.get('Checked')  # Implement your own way to get icons!
        self.setIcon(self.__icons[0])
        self.setCheckable(True)
        self.clicked.connect(self.__updateIcons)
        self.clicked.connect(self.__emitValueChanged)

    def setIcons(self, off, on):
        self.__icons = off, on
        self.__updateIcons(self.isChecked())

    def __updateIcons(self, state):
        self.setIcon(self.__icons[int(state)] or QIcon())

    def __emitValueChanged(self, state):
        self.valueChanged.emit(state)

    def value(self):
        return self.isChecked()

    def setValue(self, state):
        self.setChecked(state)
        self.__updateIcons(state)

    def editValue(self, state):
        self.setChecked(state)
        self.__updateIcons(state)
        self.__emitValueChanged(state)

Strings
This is very similar to the spinbox. One addition I added is to make sure clicking the line edit selects all constants so a user can start typing a new word immediately.

class LineEdit(QLineEdit):
    valueChanged = pyqtSignal(str)

    def __init__(self, *args):
        super(LineEdit, self).__init__(*args)
        self.textChanged.connect(self.valueChanged.emit)

    def value(self):
        return self.text()

    def setValue(self, text):
        self.blockSignals(True)
        self.setText(text)
        self.blockSignals(False)

    def editValue(self, text):
        self.setText(text)


class LineEditSelected(LineEdit):
    def __init__(self):
        super(LineEditSelected, self).__init__()
        self.__state = False

    def focusInEvent(self, event):
        super(LineEditSelected, self).focusInEvent(event)
        self.selectAll()
        self.__state = True

    def mousePressEvent(self, event):
        super(LineEditSelected, self).mousePressEvent(event)
        if self.__state:
            self.selectAll()
            self.__state = False

Reflecting python objects

Reflection in python is very easy, and our use case simple.
Every python object has a __dict__ attribute that contains all the current members of an object (but not methods).
In python we tend to denote protected (internal) data by prefixing variable names with an underscore.
So to find all attributes that we want to inspect we can simply do:

for name in instance.__dict__:
    if name[0] == '_':
        continue

Now to control such an attribute with a widget we need to construct the right widget and connect the change event to a setter.
In python we can use the functools module to bind the global getattr and setattr methods and get a way to connect a callback to a property assignment.

    value = getattr(instance, name)  # get the current value by name, like the dot operator but using a string to get to the right property
    cls = factory.findEditorForType(type(value))  # factory to get the right widget for our data type, more on this later
    widget = cls()  # construct the widget
    widget.setValue(getattr(instance, name))  # set the editor's initial value to match with our data
    widget.valueChanged.connect(functools.partial(setattr, instance, name))  # make the editor update our data

Widget factory

The last piece of the puzzle is a way to generate widgets based on data types. I wanted to keep this abstract, so I made a class out of it.
We can register data type & widget type relations and it understands to create a widget if we have one registered for a base class of the type we’re querying.

class AEFactory(object):
    def __init__(self):
        self.__typeWidgets = {}

    def registerType(self, dataType, widgetConstructor):
        self.__typeWidgets[dataType] = widgetConstructor

    @staticmethod
    def _allBaseTypes(cls):
        """
        Recurse all base classes and return a list of all bases with most close relatives first.
        https://stackoverflow.com/questions/1401661/list-all-base-classes-in-a-hierarchy-of-given-class
        """
        result = list(cls.__bases__)
        for base in result:
            result.extend(AEFactory._allBaseTypes(base))
        return result

    def _findEditorForType(self, dataType):
        if dataType in self.__typeWidgets:
            return self.__typeWidgets[dataType]

        for baseType in AEFactory._allBaseTypes(dataType):
            if dataType in self.__typeWidgets:
                return self.__typeWidgets[baseType]

Complex data

Now this will work fine for simple objects with simple data types. But the real fun begins when we have instances whose properties are lists of other instances.
Our findEditorForType will return None in this case and we get an error. Instead, we should split this up in several steps. First we determine the type of data we’re dealing with, to defer the widget creation to any type of recursive function until we reach simple data types for which we can generate widgets.

from collections import OrderedDict

class AEFactor(object):

... the above code still goes here ...

    def generate(self, data, parent=None, name=None):
        """
        This recursively generates widgets & returns an iterator of every resulting item.
        """
        if isinstance(data, (dict, OrderedDict)):
            generator = self._generateMap(data)
        elif hasattr(data, '__getitem__') and hasattr(data, '__setitem__'):
            generator = self._generateList(data)
        elif hasattr(data, '__dict__'):
            generator = self._generateInstance(data)
        else:
            generator = self._generateField(data, parent, name)

    def _generateField(self, data, parent, name):
        cls = self._findEditorForType(type(data))
        assert cls, 'Error: could not inspect object "%s" (parent: %s, name: %s). No wrapper registered or non-supported compound type.' % (data, parent, name)
        widget = cls()
        widget.setValue(data)
        widget.valueChanged.connect(functools.partial(setattr, parent, name))
        yield widget

    def _generateInstance(self, data):
        for name in data.__dict__:
            if name[0] == '_':
                continue
            yield QLabel(name)
            for widget in self.generate(getattr(data, name), data, name):
                yield widget

    def _generateList(self, data):
        for i in xrange(len(data)):
            yield QLabel(str(i))
            for widget in self.generate(data[i], data, str(i)):
                yield widget

    def _generateMap(self, data):
        for key in data:
            yield QLabel(str(key))
            for widget in self.generate(data[key], data, key):
                yield widget

Formatting

If we have a class that needs a special widget or layout, like my particle editor, we may wish to grab the widgets generated for that class and manipulate them.
One case I have is that I have a random channel, which has a minimum, maximum and isRandom flag. If isRandom is turned off then I just want to show the minimum field because the maximum is unused. In order to do this I extended the factory with the ability to inject functions that take groups of widgets for a certain data
type. See registerWrapper, findWrapperForType and the modifications at the end of generate.

class AEFactory(object):
    def __init__(self):
        self.__typeWidgets = {}
        self.__typeWrappers = {}

    def registerType(self, dataType, widgetConstructor):
        self.__typeWidgets[dataType] = widgetConstructor

    def registerWrapper(self, dataType, wrapperFunction):
        """
        The wrapperFunction must accept a generator of widgets & return a generator of widgets.
        """
        self.__typeWrappers[dataType] = wrapperFunction

    @staticmethod
    def _allBaseTypes(cls):
        """
        Recurse all base classes and return a list of all bases with most close relatives first.
        https://stackoverflow.com/questions/1401661/list-all-base-classes-in-a-hierarchy-of-given-class
        """
        result = list(cls.__bases__)
        for base in result:
            result.extend(AEFactory._allBaseTypes(base))
        return result

    def _findEditorForType(self, dataType):
        if dataType in self.__typeWidgets:
            return self.__typeWidgets[dataType]

        for baseType in AEFactory._allBaseTypes(dataType):
            if dataType in self.__typeWidgets:
                return self.__typeWidgets[baseType]

    def _findWrapperForType(self, dataType):
        if dataType in self.__typeWrappers:
            return self.__typeWrappers[dataType]

        for baseType in AEFactory._allBaseTypes(dataType):
            if dataType in self.__typeWrappers:
                return self.__typeWrappers[baseType]

    def generate(self, data, parent=None, name=None):
        """
        This recursively generates widgets & returns an iterator of every resulting item.
        """
        if isinstance(data, (dict, OrderedDict)):
            generator = self._generateMap(data)
        elif hasattr(data, '__getitem__') and hasattr(data, '__setitem__'):
            generator = self._generateList(data)
        elif hasattr(data, '__dict__'):
            generator = self._generateInstance(data)
        else:
            generator = self._generateField(data, parent, name)

        wrapper = self._findWrapperForType(type(data))
        if wrapper:
            generator = wrapper(generator)
        for widget in generator:
            yield widget

    def _generateField(self, data, parent, name):
        cls = self._findEditorForType(type(data))
        assert cls, 'Error: could not inspect object "%s" (parent: %s, name: %s). No wrapper registered or non-supported compound type.' % (data, parent, name)
        widget = cls()
        widget.setValue(data)
        widget.valueChanged.connect(functools.partial(setattr, parent, name))
        yield widget

    def _generateInstance(self, data):
        for name in data.__dict__:
            if name[0] == '_':
                continue
            yield QLabel(name)
            for widget in self.generate(getattr(data, name), data, name):
                yield widget

    def _generateList(self, data):
        for i in xrange(len(data)):
            yield QLabel(str(i))
            for widget in self.generate(data[i], data, str(i)):
                yield widget

    def _generateMap(self, data):
        for key in data:
            yield QLabel(str(key))
            for widget in self.generate(data[key], data, key):
                yield widget
Note: I currently allow it to work on sub classes, with the risk of that subclass having extra attributes – or a different attribute order – resulting in the widgets being jumbled & my function breaking the layout completely. I’m not sure yet how to validate that a sub class matches the base class’ member layout, so maybe I should just allow explicit overrides for a single type without inheritance support.

Constraining class member order

One thing that annoys me, and maybe you noticed already, is that python does not guarantee that dictionaries are ordered.
For this the collections.OrderedDict type exists, but when dealing with class members and the __dict__ attribute we have no control over this.

Now my solution to this is pretty shaky, and I’m definitely not proud of what I came up with, but let me share it anyway!
First I created a class that overrides __setattr__ to keep track of the order in which data is set.
Then I override __getattribute__ so that when the __dict__ attribute is requested we return a wrapper around it that behaves
like the real dict, but implements all iterators to use the ordered keys list instead.

class FakeOrderedDict(object):
    def __init__(self, realDict, order):
        self.realDict = realDict
        self.order = order

    def __getitem__(self, key):
        return self.realDict[key]

    def __setitem__(self, key, value):
        self.realDict[key] = value

    def __iter__(self):
        return iter(self.order)

    def iterkeys(self):
        return iter(self.order)

    def itervalues(self):
        for key in self.order:
            yield self.realDict[key]

    def iteritems(self):
        for key in self.order:
            yield key, self.realDict[key]


class OrderedClass(object):
    def __init__(self):
        self.__dict__['_OrderedClass__attrs'] = []

    def __getattribute__(self, key):
        result = super(OrderedClass, self).__getattribute__(key)
        if key == '__dict__':
            if '_OrderedClass__attrs' in result:
                return FakeOrderedDict(result, result['_OrderedClass__attrs'])
        return result

    def __setattr__(self, key, value):
        order = self.__dict__['_OrderedClass__attrs']
        if key not in order:
            order.append(key)
        return super(OrderedClass, self).__setattr__(key, value)

That’s all folks

Example usage:

# create test objects
class Vector(list):
    pass


class Compound(OrderedClass):  # inheriting from OrderedClass to ensure widget order
    def __init__(self):
        super(Compound, self).__init__()
        self.x = 2.0  # note how explicit floats are important now
        self.y = 5.0


class Data(OrderedClass):
    def __init__(self):
        super(Data, self).__init__()
        self.name = 'List test'
        self.value = Vector([1.0, 5, True])
        self.dict = {'A': Compound(), 'B': Compound()}


def groupHLayout(widgets):
    h = QHBoxLayout()
    m = QWidget()
    for w in widgets:
        h.addWidget(w)
    m.setLayout(h)
    yield m


# create test data
data = Data()

# create Qt application
app = QApplication([])
window = QWidget()
main = QVBoxLayout()
window.setLayout(main)

# initialize inspector
factory = AEFactory()
factory.registerType(bool, IconBoolEdit)
factory.registerType(int, SpinBox)
factory.registerType(float, DoubleSpinBox)
factory.registerType(str, LineEdit)
factory.registerWrapper(Vector, groupHLayout)

# inspect the data
for widget in factory.generate(data):
    main.addWidget(widget)

window.show()
app.exec_()

# print the data after closing the editor to show we indeed propagated the changes to the data as they happened
print data.name, data.value, data.dict['A'].x, data.dict['A'].y, data.dict, data.dict['B'].x, data.dict['B'].y

Last, have a full code dump!

from collections import OrderedDict
import functools
from PyQt4.QtCore import *
from PyQt4.QtGui import *


class SpinBox(QSpinBox):
    """
    QSpinBox with right limits & that follows the AEComponent interface.
    """

    def __init__(self, value=0, bits=32):
        super(SpinBox, self).__init__()
        self.setMinimum(-2 ** (bits - 1))
        self.setMaximum(2 ** (bits - 1) - 1)
        self.setValue(value)
        self.setLineEdit(LineEditSelected())

    def setValue(self, value):
        self.blockSignals(True)
        super(SpinBox, self).setValue(value)
        self.blockSignals(False)

    def editValue(self, value):
        super(SpinBox, self).setValue(value)


class DoubleSpinBox(QDoubleSpinBox):
    """
    QDoubleSpinBox with right limits & that follows the AEComponent interface.
    """

    def __init__(self, value=0.0):
        super(DoubleSpinBox, self).__init__()
        self.setMinimum(-float('inf'))
        self.setMaximum(float('inf'))
        self.setValue(value)
        self.setSingleStep(0.01)  # Depending on use case this can be very coarse.
        self.setLineEdit(LineEditSelected())

    def setValue(self, value):
        self.blockSignals(True)
        super(DoubleSpinBox, self).setValue(value)
        self.blockSignals(False)

    def editValue(self, value):
        super(DoubleSpinBox, self).setValue(value)


class IconBoolEdit(QPushButton):
    """
    QPushButton with icons to act as a boolean (not tri-state) toggle.
    """
    valueChanged = pyqtSignal(bool)

    def __init__(self, *args):
        super(IconBoolEdit, self).__init__(*args)
        self.__icons = None, None  # icons.get('Unchecked'), icons.get('Checked')  # Implement your own way to get icons!
        self.setIcon(self.__icons[0] or QIcon())
        self.setCheckable(True)
        self.clicked.connect(self.__updateIcons)
        self.clicked.connect(self.__emitValueChanged)

    def setIcons(self, off, on):
        self.__icons = off, on
        self.__updateIcons(self.isChecked())

    def __updateIcons(self, state):
        self.setIcon(self.__icons[int(state)] or QIcon())

    def __emitValueChanged(self, state):
        self.valueChanged.emit(state)

    def value(self):
        return self.isChecked()

    def setValue(self, state):
        self.setChecked(state)
        self.__updateIcons(state)

    def editValue(self, state):
        self.setChecked(state)
        self.__updateIcons(state)
        self.__emitValueChanged(state)


class LineEdit(QLineEdit):
    valueChanged = pyqtSignal(str)

    def __init__(self, *args):
        super(LineEdit, self).__init__(*args)
        self.textChanged.connect(self.valueChanged.emit)

    def value(self):
        return self.text()

    def setValue(self, text):
        self.blockSignals(True)
        self.setText(text)
        self.blockSignals(False)

    def editValue(self, text):
        self.setText(text)


class LineEditSelected(LineEdit):
    def __init__(self):
        super(LineEditSelected, self).__init__()
        self.__state = False

    def focusInEvent(self, event):
        super(LineEditSelected, self).focusInEvent(event)
        self.selectAll()
        self.__state = True

    def mousePressEvent(self, event):
        super(LineEditSelected, self).mousePressEvent(event)
        if self.__state:
            self.selectAll()
            self.__state = False


class AEFactory(object):
    def __init__(self):
        self.__typeWidgets = {}
        self.__typeWrappers = {}

    def registerType(self, dataType, widgetConstructor):
        self.__typeWidgets[dataType] = widgetConstructor

    def registerWrapper(self, dataType, wrapperFunction):
        """
        The wrapperFunction must accept a generator of widgets & return a generator of widgets.
        """
        self.__typeWrappers[dataType] = wrapperFunction

    @staticmethod
    def _allBaseTypes(cls):
        """
        Recurse all base classes and return a list of all bases with most close relatives first.
        https://stackoverflow.com/questions/1401661/list-all-base-classes-in-a-hierarchy-of-given-class
        """
        result = list(cls.__bases__)
        for base in result:
            result.extend(AEFactory._allBaseTypes(base))
        return result

    def _findEditorForType(self, dataType):
        if dataType in self.__typeWidgets:
            return self.__typeWidgets[dataType]

        for baseType in AEFactory._allBaseTypes(dataType):
            if dataType in self.__typeWidgets:
                return self.__typeWidgets[baseType]

    def _findWrapperForType(self, dataType):
        if dataType in self.__typeWrappers:
            return self.__typeWrappers[dataType]

        for baseType in AEFactory._allBaseTypes(dataType):
            if dataType in self.__typeWrappers:
                return self.__typeWrappers[baseType]

    def generate(self, data, parent=None, name=None):
        """
        This recursively generates widgets & returns an iterator of every resulting item.
        """
        if isinstance(data, (dict, OrderedDict)):
            generator = self._generateMap(data)
        elif hasattr(data, '__getitem__') and hasattr(data, '__setitem__'):
            generator = self._generateList(data)
        elif hasattr(data, '__dict__'):
            generator = self._generateInstance(data)
        else:
            generator = self._generateField(data, parent, name)

        wrapper = self._findWrapperForType(type(data))
        if wrapper:
            generator = wrapper(generator)
        for widget in generator:
            yield widget

    def _generateField(self, data, parent, name):
        cls = self._findEditorForType(type(data))
        assert cls, 'Error: could not inspect object "%s" (parent: %s, name: %s). No wrapper registered or non-supported compound type.' % (data, parent, name)
        widget = cls()
        widget.setValue(data)
        widget.valueChanged.connect(functools.partial(setattr, parent, name))
        yield widget

    def _generateInstance(self, data):
        for name in data.__dict__:
            if name[0] == '_':
                continue
            yield QLabel(name)
            for widget in self.generate(getattr(data, name), data, name):
                yield widget

    def _generateList(self, data):
        for i in xrange(len(data)):
            yield QLabel(str(i))
            for widget in self.generate(data[i], data, str(i)):
                yield widget

    def _generateMap(self, data):
        for key in data:
            yield QLabel(str(key))
            for widget in self.generate(data[key], data, key):
                yield widget


class FakeOrderedDict(object):
    def __init__(self, realDict, order):
        self.realDict = realDict
        self.order = order

    def __getitem__(self, key):
        return self.realDict[key]

    def __setitem__(self, key, value):
        self.realDict[key] = value

    def __iter__(self):
        return iter(self.order)

    def iterkeys(self):
        return iter(self.order)

    def itervalues(self):
        for key in self.order:
            yield self.realDict[key]

    def iteritems(self):
        for key in self.order:
            yield key, self.realDict[key]


class OrderedClass(object):
    def __init__(self):
        self.__dict__['_OrderedClass__attrs'] = []

    def __getattribute__(self, key):
        result = super(OrderedClass, self).__getattribute__(key)
        if key == '__dict__':
            if '_OrderedClass__attrs' in result:
                return FakeOrderedDict(result, result['_OrderedClass__attrs'])
        return result

    def __setattr__(self, key, value):
        order = self.__dict__['_OrderedClass__attrs']
        if key not in order:
            order.append(key)
        return super(OrderedClass, self).__setattr__(key, value)


# create test objects
class Vector(list):
    pass


class Compound(OrderedClass):  # inheriting from OrderedClass to ensure widget order
    def __init__(self):
        super(Compound, self).__init__()
        self.x = 2.0  # note how explicit floats are important now
        self.y = 5.0


class Data(OrderedClass):
    def __init__(self):
        super(Data, self).__init__()
        self.name = 'List test'
        self.value = Vector([1.0, 5, True])
        self.dict = {'A': Compound(), 'B': Compound()}


def groupHLayout(widgets):
    h = QHBoxLayout()
    m = QWidget()
    for w in widgets:
        h.addWidget(w)
    m.setLayout(h)
    yield m


# create test data
data = Data()

# create Qt application
app = QApplication([])
window = QWidget()
main = QVBoxLayout()
window.setLayout(main)

# initialize inspector
factory = AEFactory()
factory.registerType(bool, IconBoolEdit)
factory.registerType(int, SpinBox)
factory.registerType(float, DoubleSpinBox)
factory.registerType(str, LineEdit)
factory.registerWrapper(Vector, groupHLayout)

# inspect the data
for widget in factory.generate(data):
    main.addWidget(widget)

window.show()
app.exec_()

# print the data after closing the editor to show we indeed propagated the changes to the data as they happened
print data.name, data.value, data.dict['A'].x, data.dict['A'].y, data.dict, data.dict['B'].x, data.dict['B'].y

Leave a Reply

Your email address will not be published. Required fields are marked *