如何动态创建PyQt属性

10

我目前正在寻找一种使用Python和HTML/CSS/JS创建GUI桌面应用程序的方法,其中使用了PyQt5的QWebEngineView

在我的小演示应用程序中,我使用QWebChannel将Python QObject发布到JavaScript端,以便数据可以共享并来回传递。到目前为止,共享和连接插槽和信号都能正常工作。

然而,我遇到了简单(属性)值同步的困难。从我所读的内容来看,解决这个问题的方法是通过装饰的getter和setter函数在共享的QObject中实现pyqtProperty,同时在setter中发出一个额外的信号,用于在值更改时通知JavaScript。以下代码展示了这一点,到目前为止它也能正常工作:

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

在调试器连接的情况下,我可以在JavaScript控制台中调用backend.debug()槽,从而导致backend.foo的值在之后为"I modified foo!",这意味着Python代码成功地更改了JavaScript变量。

但是这有点繁琐。对于我想要共享的每个值,我都需要:

  • 创建一个内部变量(这里是self._foo
  • 创建一个getter函数
  • 创建一个setter函数
  • QObject的主体中创建一个信号
  • 在setter函数中显式地发射此信号

有没有更简单的方法来实现这一点?理想情况下,是否有某种一行声明的方式?也许使用类或函数将其打包起来?我该如何在稍后将其绑定到QObject上?我在考虑类似以下的东西:

# in __init__
self.foo = SyncedProperty(str)

这是否可能?感谢您的想法!

3个回答

8
通过使用元类,可以实现这一点:
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!'

我曾考虑使用元类,但这里有一些不同的陷阱我甚至没有想到。比如将type(QObject)用作元类的基础,或者在存在通知信号之前延迟pyqtProperty__init__,或者在getter和setter中访问inst(“第二个self”)以获取绑定信号的控制权。但你做到了这一切,而且它像魔法一样运行。非常感谢@ekhumoro! :) - Jeronimo
好的解决方案!谢谢! - user4157482

5

感谢您提出元类的想法,我稍微修改了它,使其能够适用于包含多个实例的类。我所面临的问题是,值被存储在Property本身中而不是类实例属性中。为了清晰起见,我将Property类拆分为两个类。


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

示例用法:


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

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


4

基于ekhumoro和Windel提供的精彩答案(你们真是救星),我制作了一个修改版本,它具有以下特点:

  • 通过类型进行指定,没有初始值
  • 可以正确处理Python列表或字典属性
    [编辑:现在不仅在重新分配时,而且在就地修改时通知列表/字典]

与Windel的版本一样,要使用它,只需将属性指定为类属性,但使用它们的类型而不是值。(对于继承自的自定义用户定义类,请使用。)可以在初始化方法或任何其他需要的地方指定值。

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

以下是代码。我采用Windel结构适应实例,简化了一些ekhumoro版本的残留物,并添加了一个新类以启用就地修改的通知。

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

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接