Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to create PyQt Properties dynamically

I am currently looking into a way to create GUI desktop applications with Python and HTML/CSS/JS using PyQt5's QWebEngineView.

In my little demo application, I use a QWebChannel to publish a Python QObject to the JavaScript side, so that data can be shared and passed back and forth. Sharing and connecting slots and signals so far works fine.

I'm having difficulties though with the synchronisation of simple (property) values. From what I've read, the way to go is to implement a pyqtProperty in the shared QObject via decorated getter and setter functions, with an additional signal emitted in the setter, used to notify JavaScript when the value has changed. The code below shows that and so far this works fine:

import sys
from PyQt5.QtCore import QObject, pyqtSlot, pyqtProperty, pyqtSignal 
from PyQt5.QtWidgets import QApplication
from PyQt5.QtWebChannel import QWebChannel
from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEnginePage


class HelloWorldHtmlApp(QWebEngineView):
    html = '''
    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="utf-8"/>        
        <script src="qrc:///qtwebchannel/qwebchannel.js"></script>
        <script>
        var backend;
        new QWebChannel(qt.webChannelTransport, function (channel) {
            backend = channel.objects.backend;
        });
        </script>
    </head>
    <body> <h2>HTML loaded.</h2> </body>
    </html>
    '''

    def __init__(self):
        super().__init__()

        # setup a page with my html
        my_page = QWebEnginePage(self)
        my_page.setHtml(self.html)
        self.setPage(my_page)

        # setup channel
        self.channel = QWebChannel()
        self.backend = self.Backend(self)
        self.channel.registerObject('backend', self.backend)
        self.page().setWebChannel(self.channel)

    class Backend(QObject):
        """ Container for stuff visible to the JavaScript side. """
        foo_changed = pyqtSignal(str)

        def __init__(self, htmlapp):
            super().__init__()
            self.htmlapp = htmlapp
            self._foo = "Hello World"

        @pyqtSlot()
        def debug(self):
            self.foo = "I modified foo!"

        @pyqtProperty(str, notify=foo_changed)
        def foo(self):            
            return self._foo

        @foo.setter
        def foo(self, new_foo):            
            self._foo = new_foo
            self.foo_changed.emit(new_foo)


if __name__ == "__main__":
    app = QApplication.instance() or QApplication(sys.argv)
    view = HelloWorldHtmlApp()
    view.show()
    app.exec_()

Starting this with the Debugger connected, I can call the backend.debug() slot in the JavaScript console, which leads to the value of backend.foo being "I modified foo!" afterwards, which means the Python code succesfully changed the JavaScript variable.

This is kind of tedious though. For every value I'd want to share, I'd have to

  • create an internal variable (here self._foo)
  • create a getter function
  • create a setter function
  • create a signal in the QObject's body
  • emit this signal explicitly in the setter function

Is there any simpler way to achieve this? Ideally some sort of one-liner declaration? Maybe using a class or function to pack that all up? How would I have to bind this to the QObject later on? I'm thinking of something like

# in __init__
self.foo = SyncedProperty(str)

Is this possible? Thanks for your ideas!

like image 536
Jeronimo Avatar asked Jan 24 '18 14:01

Jeronimo


People also ask

Is PyQt better than PySide?

PyQt is significantly older than PySide and, partially due to that, has a larger community and is usually ahead when it comes to adopting new developments. It is mainly developed by Riverbank Computing Limited and distributed under GPL v3 and a commercial license.

Is PyQt an API?

PyQt5 provides an extension API that can be used by other modules. This has the advantage of sharing code and also enforcing consistent behaviour. Part of the API is accessable from Python and part from C++.

What is PyQt used for?

PyQt is widely used for creating large-scale GUI-based programs. It gives programmers the freedom to create GUIs of their choice while also providing a lot of good pre-built designs. PyQT gives you widgets to create complex GUIs.


3 Answers

One way to do this is by using a meta-class:

class Property(pyqtProperty):
    def __init__(self, value, name='', type_=None, notify=None):
        if type_ and notify:
            super().__init__(type_, self.getter, self.setter, notify=notify)
        self.value = value
        self.name = name

    def getter(self, inst=None):
        return self.value

    def setter(self, inst=None, value=None):
        self.value = value
        getattr(inst, '_%s_prop_signal_' % self.name).emit(value)

class PropertyMeta(type(QObject)):
    def __new__(mcs, name, bases, attrs):
        for key in list(attrs.keys()):
            attr = attrs[key]
            if not isinstance(attr, Property):
                continue
            value = attr.value
            notifier = pyqtSignal(type(value))
            attrs[key] = Property(
                value, key, type(value), notify=notifier)
            attrs['_%s_prop_signal_' % key] = notifier
        return super().__new__(mcs, name, bases, attrs)

class HelloWorldHtmlApp(QWebEngineView):
    ...
    class Backend(QObject, metaclass=PropertyMeta):
        foo = Property('Hello World')

        @pyqtSlot()
        def debug(self):
            self.foo = 'I modified foo!'
like image 126
ekhumoro Avatar answered Sep 28 '22 11:09

ekhumoro


Thanks for your metaclass idea, I modified it slightly to work properly with classes that contain more than one instance. The problem I faced was that the value was stored into the Property itself, not in the class instance attributes. Also I split up the Property class into two classes for clarity.


class PropertyMeta(type(QtCore.QObject)):
    def __new__(cls, name, bases, attrs):
        for key in list(attrs.keys()):
            attr = attrs[key]
            if not isinstance(attr, Property):
                continue
            initial_value = attr.initial_value
            type_ = type(initial_value)
            notifier = QtCore.pyqtSignal(type_)
            attrs[key] = PropertyImpl(
                initial_value, name=key, type_=type_, notify=notifier)
            attrs[signal_attribute_name(key)] = notifier
        return super().__new__(cls, name, bases, attrs)


class Property:
    """ Property definition.

    This property will be patched by the PropertyMeta metaclass into a PropertyImpl type.
    """
    def __init__(self, initial_value, name=''):
        self.initial_value = initial_value
        self.name = name


class PropertyImpl(QtCore.pyqtProperty):
    """ Actual property implementation using a signal to notify any change. """
    def __init__(self, initial_value, name='', type_=None, notify=None):
        super().__init__(type_, self.getter, self.setter, notify=notify)
        self.initial_value = initial_value
        self.name = name

    def getter(self, inst):
        return getattr(inst, value_attribute_name(self.name), self.initial_value)

    def setter(self, inst, value):
        setattr(inst, value_attribute_name(self.name), value)
        notifier_signal = getattr(inst, signal_attribute_name(self.name))
        notifier_signal.emit(value)

def signal_attribute_name(property_name):
    """ Return a magic key for the attribute storing the signal name. """
    return f'_{property_name}_prop_signal_'


def value_attribute_name(property_name):
    """ Return a magic key for the attribute storing the property value. """
    return f'_{property_name}_prop_value_'

Demo usage:


class Demo(QtCore.QObject, metaclass=PropertyMeta):
    my_prop = Property(3.14)

demo1 = Demo()
demo2 = Demo()
demo1.my_prop = 2.7

like image 33
Windel Avatar answered Sep 30 '22 11:09

Windel


Building on the excellent answers by ekhumoro and Windel (y'all are lifesavers), I've made a modified version that:

  • Is specified via type, with no initial value
  • Can correctly handle properties that are Python lists or dictionaries
    [Edit: now notifies when list/dict is modified in place, not just when it's reassigned]

Just as with Windel's version, to use it, simply specify the properties as class attributes, but with their types rather than values. (For a custom user-defined class that inherits from QObject, use QObject.) Values can be assigned in intializer methods, or wherever else you need.

from PyQt5.QtCore import QObject
# Or for PySide2:
# from PySide2.QtCore import QObject

from properties import PropertyMeta, Property

class Demo(QObject, metaclass=PropertyMeta):
    number = Property(float)
    things = Property(list)
    
    def __init__(self, parent=None):
        super().__init__(parent)
        self.number = 3.14

demo1 = Demo()
demo2 = Demo()
demo1.number = 2.7
demo1.things = ['spam', 'spam', 'baked beans', 'spam']

And here's the code. I've gone with Windel's structure to accommodate instances, simplified a couple of things that had remained as artefacts of ekhumoro's version, and added a new class to enable notification of in-place modifications.

# properties.py

from functools import wraps

from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal
# Or for PySide2:
# from PySide2.QtCore import QObject, Property as pyqtProperty, Signal as pyqtSignal

class PropertyMeta(type(QObject)):
    """Lets a class succinctly define Qt properties."""
    def __new__(cls, name, bases, attrs):
        for key in list(attrs.keys()):
            attr = attrs[key]
            if not isinstance(attr, Property):
                continue
            
            types = {list: 'QVariantList', dict: 'QVariantMap'}
            type_ = types.get(attr.type_, attr.type_)
            
            notifier = pyqtSignal(type_)
            attrs[f'_{key}_changed'] = notifier
            attrs[key] = PropertyImpl(type_=type_, name=key, notify=notifier)
        
        return super().__new__(cls, name, bases, attrs)


class Property:
    """Property definition.
    
    Instances of this class will be replaced with their full
    implementation by the PropertyMeta metaclass.
    """
    def __init__(self, type_):
        self.type_ = type_


class PropertyImpl(pyqtProperty):
    """Property implementation: gets, sets, and notifies of change."""
    def __init__(self, type_, name, notify):
        super().__init__(type_, self.getter, self.setter, notify=notify)
        self.name = name

    def getter(self, instance):
        return getattr(instance, f'_{self.name}')

    def setter(self, instance, value):
        signal = getattr(instance, f'_{self.name}_changed')
        
        if type(value) in {list, dict}:
            value = make_notified(value, signal)
        
        setattr(instance, f'_{self.name}', value)
        signal.emit(value)


class MakeNotified:
    """Adds notifying signals to lists and dictionaries.
    
    Creates the modified classes just once, on initialization.
    """
    change_methods = {
        list: ['__delitem__', '__iadd__', '__imul__', '__setitem__', 'append',
               'extend', 'insert', 'pop', 'remove', 'reverse', 'sort'],
        dict: ['__delitem__', '__ior__', '__setitem__', 'clear', 'pop',
               'popitem', 'setdefault', 'update']
    }
    
    def __init__(self):
        if not hasattr(dict, '__ior__'):
            # Dictionaries don't have | operator in Python < 3.9.
            self.change_methods[dict].remove('__ior__')
        self.notified_class = {type_: self.make_notified_class(type_)
                               for type_ in [list, dict]}
    
    def __call__(self, seq, signal):
        """Returns a notifying version of the supplied list or dict."""
        notified_class = self.notified_class[type(seq)]
        notified_seq = notified_class(seq)
        notified_seq.signal = signal
        return notified_seq
    
    @classmethod
    def make_notified_class(cls, parent):
        notified_class = type(f'notified_{parent.__name__}', (parent,), {})
        for method_name in cls.change_methods[parent]:
            original = getattr(notified_class, method_name)
            notified_method = cls.make_notified_method(original, parent)
            setattr(notified_class, method_name, notified_method)
        return notified_class
    
    @staticmethod
    def make_notified_method(method, parent):
        @wraps(method)
        def notified_method(self, *args, **kwargs):
            result = getattr(parent, method.__name__)(self, *args, **kwargs)
            self.signal.emit(self)
            return result
        return notified_method


make_notified = MakeNotified()
like image 27
CrazyChucky Avatar answered Sep 29 '22 11:09

CrazyChucky