From f6c2fd57585804894f3f8d5e870ae2a9cf8d60a8 Mon Sep 17 00:00:00 2001 From: Halil Mehmet Date: Fri, 23 Jul 2021 00:57:19 +1000 Subject: [PATCH 1/7] Adding PyQt5Patcher from tk-krita --- python/tank/util/pyqt5_patcher.py | 235 ++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 python/tank/util/pyqt5_patcher.py diff --git a/python/tank/util/pyqt5_patcher.py b/python/tank/util/pyqt5_patcher.py new file mode 100644 index 000000000..296f0e648 --- /dev/null +++ b/python/tank/util/pyqt5_patcher.py @@ -0,0 +1,235 @@ +# Copyright (c) 2016 Shotgun Software Inc. +# +# CONFIDENTIAL AND PROPRIETARY +# +# This work is provided "AS IS" and subject to the Shotgun Pipeline Toolkit +# Source Code License included in this distribution package. See LICENSE. +# By accessing, using, copying or modifying this work you indicate your +# agreement to the Shotgun Pipeline Toolkit Source Code License. All rights +# not expressly granted therein are reserved by Shotgun Software Inc. + +from .pyside2_patcher import PySide2Patcher + + +class PyQt5Patcher(PySide2Patcher): + """ + Patches PyQt5 so it can be API compatible with PySide 1. + + Credit to Diego Garcia Huerta for the work done in tk-krita: + https://github.com/diegogarciahuerta/tk-krita/blob/80544f1b40702d58f0378936532d8e25f9981e65/engine.py + + .. code-block:: python + from PyQt5 import QtGui, QtCore, QtWidgets + import PyQt5 + PyQt5Patcher.patch(QtCore, QtGui, QtWidgets, PyQt5) + """ + + # Flag that will be set at the module level so that if an engine is reloaded + # the PySide 2 API won't be monkey patched twice. + + # Note: not sure where this is in use in SGTK, but wanted to make sure + # nothing breaks + _TOOLKIT_COMPATIBLE = "__toolkit_compatible" + + @classmethod + def patch(cls, QtCore, QtGui, QtWidgets, PyQt5): + """ + Patches QtCore, QtGui and QtWidgets + :param QtCore: The QtCore module. + :param QtGui: The QtGui module. + :param QtWidgets: The QtWidgets module. + :param PyQt5: The PyQt5 module. + """ + + # Add this version info otherwise it breaks since tk_core v0.19.9 + # PySide2Patcher is now checking the version of PySide2 in a way + # that PyQt5 does not like: __version_info__ is not defined in PyQt5 + version = list(map(int, QtCore.PYQT_VERSION_STR.split("."))) + PyQt5.__version_info__ = version + + QtCore, QtGui = PySide2Patcher.patch(QtCore, QtGui, QtWidgets, PyQt5) + + def SIGNAL(arg): + """ + This is a trick to fix the fact that old style signals are not + longer supported in pyQt5 + """ + return arg.replace("()", "") + + class QLabel(QtGui.QLabel): + """ + Unfortunately in some cases sgtk sets the pixmap as None to remove + the icon. This behaviour is not supported in PyQt5 and requires + an empty instance of QPixmap. + """ + + def setPixmap(self, pixmap): + if pixmap is None: + pixmap = QtGui.QPixmap() + return super(QLabel, self).setPixmap(pixmap) + + class QPixmap(QtGui.QPixmap): + """ + The following method is obsolete in PyQt5 so we have to provide + a backwards compatible solution. + https://doc.qt.io/qt-5/qpixmap-obsolete.html#grabWindow + """ + + def grabWindow(self, window, x=0, y=0, width=-1, height=-1): + screen = QtGui.QApplication.primaryScreen() + return screen.grabWindow(window, x=x, y=y, width=width, height=height) + + class QAction(QtGui.QAction): + """ + From the docs: + https://www.riverbankcomputing.com/static/Docs/PyQt5/incompatibilities.html#qt-signals-with-default-arguments + Explanation: + https://stackoverflow.com/questions/44371451/python-pyqt-qt-qmenu-qaction-syntax + A lot of cases in tk apps where QAction triggered signal is + connected with `triggered[()].connect` which in PyQt5 is a problem + because triggered is an overloaded signal with two signatures, + triggered = QtCore.pyqtSignal(bool) + triggered = QtCore.pyqtSignal() + If you wanted to use the second overload, you had to use the + `triggered[()]` approach to avoid the extra boolean attribute to + trip you in the callback function. + The issue is that in PyQt5.3+ this has changed and is no longer + allowed as only the first overloaded function is implemented and + always called with the extra boolean value. + To avoid this normally we would have to decorate our slots with the + decorator: + @QtCore.pyqtSlot + but changing the tk apps is out of the scope of this engine. + To fix this we implement a new signal and rewire the connections so + it is available once more for tk apps to be happy. + """ + + triggered_ = QtCore.pyqtSignal([bool], []) + + def __init__(self, *args, **kwargs): + super(QAction, self).__init__(*args, **kwargs) + super(QAction, self).triggered.connect(lambda checked: self.triggered_[()]) + super(QAction, self).triggered.connect(self.triggered_[bool]) + self.triggered = self.triggered_ + self.triggered.connect(self._onTriggered) + + def _onTriggered(self, checked=False): + self.triggered_[()].emit() + + class QAbstractButton(QtGui.QAbstractButton): + """ See QAction above for explanation """ + + clicked_ = QtCore.pyqtSignal([bool], []) + triggered_ = QtCore.pyqtSignal([bool], []) + + def __init__(self, *args, **kwargs): + super(QAbstractButton, self).__init__(*args, **kwargs) + super(QAbstractButton, self).clicked.connect(lambda checked: self.clicked_[()]) + super(QAbstractButton, self).clicked.connect(self.clicked_[bool]) + self.clicked = self.clicked_ + self.clicked.connect(self._onClicked) + + super(QAction, self).triggered.connect(lambda checked: self.triggered_[()]) + super(QAction, self).triggered.connect(self.triggered_[bool]) + self.triggered = self.triggered_ + self.triggered.connect(self._onTriggered) + + def _onClicked(self, checked=False): + self.clicked_[()].emit() + + class QObject(QtCore.QObject): + """ + QObject no longer has got the connect method in PyQt5 so we have to + reinvent it here... + https://doc.bccnsoft.com/docs/PyQt5/pyqt4_differences.html#old-style-signals-and-slots + """ + + def connect(sender, signal, method, connection_type=QtCore.Qt.AutoConnection): + if hasattr(sender, signal): + getattr(sender, signal).connect(method, connection_type) + + class QCheckBox(QtGui.QCheckBox): + """ + PyQt5 no longer allows anything but an QIcon as an argument. In some + cases sgtk is passing a pixmap, so we need to intercept the call to + convert the pixmap to an actual QIcon. + """ + + def setIcon(self, icon): + return super(QCheckBox, self).setIcon(QtGui.QIcon(icon)) + + class QTabWidget(QtGui.QTabWidget): + """ + For whatever reason pyQt5 is returning the name of the Tab + including the key accelerator, the & that indicates what key is + the shortcut. This is tripping dialog.py in tk-multi-loaders2 + """ + + def tabText(self, index): + return super(QTabWidget, self).tabText(index).replace("&", "") + + class QPyTextObject(QtCore.QObject, QtGui.QTextObjectInterface): + """ + PyQt4 implements the QPyTextObject as a workaround for the inability + to define a Python class that is sub-classed from more than one Qt + class. QPyTextObject is not implemented in PyQt5 + https://doc.bccnsoft.com/docs/PyQt5/pyqt4_differences.html#qpytextobject + """ + + pass + + class QStandardItem(QtGui.QStandardItem): + """ + PyQt5 no longer allows anything but an QIcon as an argument. In some + cases sgtk is passing a pixmap, so we need to intercept the call to + convert the pixmap to an actual QIcon. + """ + + def setIcon(self, icon): + icon = QtGui.QIcon(icon) + return super(QStandardItem, self).setIcon(icon) + + class QTreeWidgetItem(QtGui.QTreeWidgetItem): + """ + PyQt5 no longer allows anything but an QIcon as an argument. In some + cases sgtk is passing a pixmap, so we need to intercept the call to + convert the pixmap to an actual QIcon. + """ + + def setIcon(self, column, icon): + icon = QtGui.QIcon(icon) + return super(QTreeWidgetItem, self).setIcon(column, icon) + + class QTreeWidgetItemIterator(QtGui.QTreeWidgetItemIterator): + """ + This fixes the iteration over QTreeWidgetItems. It seems that it is + no longer iterable, so we create our own. + """ + + def __iter__(self): + value = self.value() + while value: + yield self + self += 1 + value = self.value() + + # hot patch the library to make it work with pyside code + QtCore.SIGNAL = SIGNAL + QtCore.Signal = QtCore.pyqtSignal + QtCore.Slot = QtCore.pyqtSlot + QtCore.Property = QtCore.pyqtProperty + QtCore.__version__ = QtCore.PYQT_VERSION_STR + + # widgets and class fixes + QtGui.QLabel = QLabel + QtGui.QPixmap = QPixmap + QtGui.QAction = QAction + QtCore.QObject = QObject + QtGui.QCheckBox = QCheckBox + QtGui.QTabWidget = QTabWidget + QtGui.QStandardItem = QStandardItem + QtGui.QPyTextObject = QPyTextObject + QtGui.QTreeWidgetItem = QTreeWidgetItem + QtGui.QTreeWidgetItemIterator = QTreeWidgetItemIterator + + return QtCore, QtGui From b38308cf4813ca2c4792a3d225cb27bcb1bef792 Mon Sep 17 00:00:00 2001 From: Halil Mehmet Date: Fri, 23 Jul 2021 01:03:29 +1000 Subject: [PATCH 2/7] Fix QObject.connect method --- python/tank/util/pyqt5_patcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/tank/util/pyqt5_patcher.py b/python/tank/util/pyqt5_patcher.py index 296f0e648..bdd0108d1 100644 --- a/python/tank/util/pyqt5_patcher.py +++ b/python/tank/util/pyqt5_patcher.py @@ -144,7 +144,7 @@ class QObject(QtCore.QObject): https://doc.bccnsoft.com/docs/PyQt5/pyqt4_differences.html#old-style-signals-and-slots """ - def connect(sender, signal, method, connection_type=QtCore.Qt.AutoConnection): + def connect(self, sender, signal, method, connection_type=QtCore.Qt.AutoConnection): if hasattr(sender, signal): getattr(sender, signal).connect(method, connection_type) From 78f61d6789745bc0d139d02445bbc2d708e8dcee Mon Sep 17 00:00:00 2001 From: Halil Mehmet Date: Fri, 23 Jul 2021 01:04:11 +1000 Subject: [PATCH 3/7] Add missing toTuple method to PyQt5 QColor class --- python/tank/util/pyqt5_patcher.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/python/tank/util/pyqt5_patcher.py b/python/tank/util/pyqt5_patcher.py index bdd0108d1..f31f8e406 100644 --- a/python/tank/util/pyqt5_patcher.py +++ b/python/tank/util/pyqt5_patcher.py @@ -213,6 +213,25 @@ def __iter__(self): self += 1 value = self.value() + class QColor(QtGui.QColor): + """ + Adds missing toTuple method to PyQt5 QColor class. + """ + def toTuple(self): + if self.spec() == QtGui.QColor.Rgb: + r, g, b, a = self.getRgb() + return (r, g, b, a) + elif self.spec() == QtGui.QColor.Hsv: + h, s, v, a = self.getHsv() + return (h, s, v, a) + elif self.spec() == QtGui.QColor.Cmyk: + c, m, y, k, a = self.getCmyk() + return (c, m, y, k, a) + elif self.spec() == QtGui.QColor.Hsl: + h, s, l, a = self.getHsl() + return (h, s, l, a) + return tuple() + # hot patch the library to make it work with pyside code QtCore.SIGNAL = SIGNAL QtCore.Signal = QtCore.pyqtSignal @@ -231,5 +250,6 @@ def __iter__(self): QtGui.QPyTextObject = QPyTextObject QtGui.QTreeWidgetItem = QTreeWidgetItem QtGui.QTreeWidgetItemIterator = QTreeWidgetItemIterator + QtGui.QColor = QColor return QtCore, QtGui From c1cec690edcf90708f03b9c6de3ae40ee9158fff Mon Sep 17 00:00:00 2001 From: Halil Mehmet Date: Fri, 23 Jul 2021 01:04:42 +1000 Subject: [PATCH 4/7] PyQt5 handling --- python/tank/util/qt_importer.py | 53 +++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/python/tank/util/qt_importer.py b/python/tank/util/qt_importer.py index 90785e0b6..6be95220c 100644 --- a/python/tank/util/qt_importer.py +++ b/python/tank/util/qt_importer.py @@ -277,9 +277,15 @@ def _import_pyside2_as_pyside(self): QtCore, QtGui = PySide2Patcher.patch(QtCore, QtGui, QtWidgets, PySide2) QtNetwork = self._import_module_by_name("PySide2", "QtNetwork") QtWebKit = self._import_module_by_name("PySide2.QtWebKitWidgets", "QtWebKit") - QtWebEngineWidgets = self._import_module_by_name( - "PySide2.QtWebEngineWidgets", "QtWebEngineWidgets" - ) + + # We have the potential for a deadlock in Maya 2018 on Windows if this + # is imported. We set the env var from the tk-maya engine when we + # detect that we are in this situation. + QtWebEngineWidgets = None + if "SHOTGUN_SKIP_QTWEBENGINEWIDGETS_IMPORT" not in os.environ: + QtWebEngineWidgets = self._import_module_by_name( + "PySide2", "QtWebEngineWidgets" + ) return ( "PySide2", @@ -332,6 +338,35 @@ def _import_pyqt4(self): self._to_version_tuple(QtCore.QT_VERSION_STR), ) + def _import_pyqt5(self): + """ + Imports PyQt5. + + :returns: The (binding name, binding version, modules) tuple. + """ + from PyQt5 import QtCore, QtGui, QtWidgets + import PyQt5 + from .pyqt5_patcher import PyQt5Patcher + + QtCore, QtGui = PyQt5Patcher.patch(QtCore, QtGui, QtWidgets, PyQt5) + QtNetwork = self._import_module_by_name("PyQt5", "QtNetwork") + QtWebEngineWidgets = self._import_module_by_name("PyQt5", "QtWebEngineWidgets") + + PyQt5.__version__ = QtCore.PYQT_VERSION_STR + + return ( + "PyQt5", + PyQt5.__version__, + PyQt5, + { + "QtCore": QtCore, + "QtGui": QtGui, + "QtNetwork": QtNetwork, + "QtWebEngineWidgets": QtWebEngineWidgets, + }, + self._to_version_tuple(QtCore.QT_VERSION_STR), + ) + def _to_version_tuple(self, version_str): """ Converts a version string with the dotted notation into a tuple @@ -349,6 +384,7 @@ def _import_modules(self, interface_version_requested): - PySide2 - PySide - PyQt4 + - PyQt5 :returns: The (binding name, binding version, modules) tuple or (None, None, None) if no binding is avaialble. @@ -373,8 +409,6 @@ def _import_modules(self, interface_version_requested): except ImportError: pass - # We do not test for PyQt5 since it is supported on Python 3 only at the moment. - # Now try PySide 1 if interface_version_requested == self.QT4: try: @@ -393,6 +427,15 @@ def _import_modules(self, interface_version_requested): except ImportError: pass + # Then finally try PyQt5 + if interface_version_requested == self.QT5: + try: + pyqt = self._import_pyqt5() + logger.debug("Imported PyQt5.") + return pyqt + except ImportError: + pass + logger.debug("No Qt matching that interface was found.") return (None, None, None, None, None) From 30a51bda03b0bb9f6f6c05e489d9563dd1b677a9 Mon Sep 17 00:00:00 2001 From: Halil Mehmet Date: Fri, 23 Jul 2021 01:05:00 +1000 Subject: [PATCH 5/7] Update docstring --- python/tank/platform/engine.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/python/tank/platform/engine.py b/python/tank/platform/engine.py index b7d9ad4cf..8ee52e52e 100644 --- a/python/tank/platform/engine.py +++ b/python/tank/platform/engine.py @@ -2161,8 +2161,6 @@ def __define_qt5_base(self): ``__version__``, which refer to the name of the binding and it's version, e.g. PySide2 and 2.0.1. - .. note:: PyQt5 not supported since it runs only on Python 3. - :returns: A dictionary with all the modules, __version__ and __name__. """ return QtImporter(interface_version_requested=QtImporter.QT5).base From 440e1752cc10c036a8b0c5df673d569625edba1f Mon Sep 17 00:00:00 2001 From: Halil Mehmet Date: Fri, 23 Jul 2021 01:20:01 +1000 Subject: [PATCH 6/7] Updated comments --- python/tank/platform/qt5/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/tank/platform/qt5/__init__.py b/python/tank/platform/qt5/__init__.py index 60423e935..428fe8419 100644 --- a/python/tank/platform/qt5/__init__.py +++ b/python/tank/platform/qt5/__init__.py @@ -9,4 +9,4 @@ # not expressly granted therein are reserved by Shotgun Software Inc. # This module will be populated during engine initialization with modules available for Qt 5 if -# PySide 2 is accessible. +# PySide 2 or PyQt5 is accessible. From 6d73e0255e2ebca6547898f5d2a446ed3a98e955 Mon Sep 17 00:00:00 2001 From: Halil Mehmet Date: Fri, 23 Jul 2021 01:24:06 +1000 Subject: [PATCH 7/7] Updated docstring --- python/tank/platform/engine.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/python/tank/platform/engine.py b/python/tank/platform/engine.py index 8ee52e52e..cc7cfe280 100644 --- a/python/tank/platform/engine.py +++ b/python/tank/platform/engine.py @@ -2156,9 +2156,10 @@ def _define_qt_base(self): def __define_qt5_base(self): """ - This will be called at initialization to discover every PySide 2 modules. It should provide - every Qt modules available as well as two extra attributes, ``__name__`` and - ``__version__``, which refer to the name of the binding and it's version, e.g. + This will be called at initialization to discover every PySide2 or PyQt5 + modules. It should provide every Qt module available as well as two extra + attributes, ``__name__`` and ``__version__``, which refer to the name of + the binding and it's version, e.g. PySide2 and 2.0.1. :returns: A dictionary with all the modules, __version__ and __name__.