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
self._foo
)QObject
's bodyIs 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!
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.
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++.
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.
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!'
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
Building on the excellent answers by ekhumoro and Windel (y'all are lifesavers), I've made a modified version that:
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()
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With