Jeronimo
Jeronimo

Reputation: 2397

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

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!

Upvotes: 11

Views: 5089

Answers (3)

CrazyChucky
CrazyChucky

Reputation: 3518

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()

Upvotes: 5

Windel
Windel

Reputation: 759

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

Upvotes: 5

ekhumoro
ekhumoro

Reputation: 120738

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!'

Upvotes: 8

Related Questions