Dynamic creation of QObject with Signals and QProperties
-
Hi everyone,
I use Qt with Qt Quick to create my personal applications and utilities.
I really like the MVVM pattern, UI bindings and speed of development using WPF with C#, which I use at work.
As such I always create a "ViewModel" QObject subclass, which I then expose to the main QML context, which contains Q_PROPERTY-ies and Q_INVOKABLE-s to use from QML. This works quite well.
I have been using a bunch of macros to make creating and using Qt Properties faster, when a custom getter/setter is not required, for example. They create the trivial getter, setter, notify Signal, and the Q_PROPERTY call.I have been looking into Qt for Python to add UI to some existing Python utils.
All works well, except I cannot find a way to remove the boilerplate of creating all the default getters and setters by hand at class creation.
I tried using a metaclass, but realised that it wouldn't work because QObject also needs to use it.
So I made a class factory function, which creates a class using the type() factory. I use closures to make the functions remember the name of the property.# create attributes dict with all Qt Properties .... for each property if initial_val is not None: attributes["_" + name] = initial_val else: attributes["_" + name] = _type() attributes["_" + name + "_fget"] = make_getter(name) attributes["_" + name + "_fset"] = make_setter(name) attributes[name + "_changed"] = Signal(None, name=name + "_changed") attributes[name] = Property(_type, fget=attributes["_" + name + "_fget"], fset=attributes["_" + name + "_fset"], notify=attributes[name + "_changed"]) .... vm = type(vm_name, (QObject, ), attributes) return vm
It seems to work: I can create a ViewModel class super quickly with the required Qt Properties. The properties are accessible from QML as normal. Here is an example of the API.
# property type, property name, property initial value ViewModel = create_viewmodel("ViewModel", [(str, "testStr", "testStr"), (int, "testInt", 555)]) vm = ViewModel()
However, I am having an issue with Qt Signals. It seems like the Signal objects do not have an "emit()" method. As such any property binding with QML does not work if the property is updated from QML.
Can somebody explain why Signal object is lacking emit(), and how to get them working correctly?
I can see that there is some sort of "magic" going on when one declares a signal, like maybe the Shiboken subclass hook? I don't know much about how this works, or if it would be possible to trigger it manually.Thanks for the help in advance. If I can get this working, I think Qt + Qt Quick would be my new go-to tool for rapid development.
Full code for my example:
import os from PySide2.QtCore import * from PySide2.QtQml import QQmlApplicationEngine from PySide2.QtWidgets import QApplication def create_viewmodel(vm_name, props): attributes = {} for _type, name, initial_val in props: if initial_val is not None: attributes["_" + name] = initial_val else: attributes["_" + name] = _type() def make_getter(name): def getter(self): return attributes["_" + name] return getter def make_setter(name): def setter(self, val): attributes["_" + name] = val # Fails here: does not have emit() method attributes["_property_changed"].emit(name) attributes[name + "_changed"].emit() return setter attributes["_" + name + "_fget"] = make_getter(name) attributes["_" + name + "_fset"] = make_setter(name) attributes[name + "_changed"] = Signal(None, name=name + "_changed") attributes[name] = Property(_type, fget=attributes["_" + name + "_fget"], fset=attributes["_" + name + "_fset"], notify=attributes[name + "_changed"]) def init(self): print("init!") QObject.__init__(self) attributes["__init__"] = init attributes["_property_changed"] = Signal(str, name="_property_changed", arguments=str) print(attributes) vm = type(vm_name, (QObject, ), attributes) return vm # Conventional QObject subclass does work as expected. But so verbose. class VM2(QObject): test_changed = Signal() property_changed = Signal(str) def get_test(self): return self._test def set_test(self, val): self._test = val self.test_changed.emit() self.property_changed.emit("test") test = Property(str, get_test, set_test, notify=test_changed) def __init__(self): QObject.__init__(self) self._test = "testVM2" def main(): app = QApplication([]) engine = QQmlApplicationEngine() qml_file_path = os.path.join(os.path.dirname(__file__), 'main.qml') qml_url = QUrl.fromLocalFile(os.path.abspath(qml_file_path)) ViewModel = create_viewmodel("ViewModel", [(str, "testStr", "testStr"), (int, "testInt", 555)]) vm = ViewModel() vm2 = VM2() engine.rootContext().setContextProperty("viewModel", vm) engine.rootContext().setContextProperty("viewModel2", vm2) engine.load(qml_url) app.exec_() if __name__ == '__main__': main()
import QtQuick 2.0 import QtQuick.Window 2.0 import QtQuick.Controls 2.11 import QtQuick.Layouts 1.11 Window { width: 1024 height: 768 visible: true ColumnLayout { Text { text: "%1 %2 %3".arg(viewModel.testStr).arg(viewModel.testInt).arg(viewModel2.test) } Button { text: "Change property" onClicked: viewModel.testStr = "CHANGED" } } }
-
Hey Denni O,
Thanks for your reply.
Basically I think I have the concept of the signals and slots down, I have been using them for a while in C++ and also with PySide2.
My post was basically to ask, why do Signals not work when created dynamically in a class factory?
It all boils down to this issue: it seems to be that creating the signal this way works, as in, it creates a usable Signal class with "emit()" methods and more:test_changed = Signal() property_changed = Signal(str)
But not this way:
attributes[name + "_changed"] = Signal(None, name=name + "_changed")
In this case, the Signal class is created but is "empty", does not have any of the Signal usual methods such as emit().
As such I was thinking, maybe anybody knowns if Shiboken object base class is doing something extra in the first case: if so, can I replicate this to make my dynamic class work?
Thanks in advance.
-
Hurrah! I have solved the issue.
This is related to people trying to use the signal from the class and not on the instance (through "self"), which is a common pitfall with PySide2, seems like. Now I also fall to it....
I suppose the attributes dict is the "class variable" and not the "instance variable" and as such does not have the methods, I guess they are generated on instantiation.This is beautiful! Fully dynamic class creation. Can't wait to make something with this.
I only had to change this part of the create_viewmodel function:def make_getter(name): def getter(self): return getattr(self, "_" + name) return getter def make_setter(name): def setter(self, val): setattr(self, "_" + name, val) # Works!! getattr(self, "_property_changed").emit(name) getattr(self, name + "_changed").emit() return setter