From dc4bf6b30b1f59e0037eb3fd008186587d931995 Mon Sep 17 00:00:00 2001 From: JamesWrigley Date: Wed, 13 Apr 2022 16:35:39 +0200 Subject: [PATCH 1/4] Update qt_compat.py to the latest version from matplotlib --- matplotlib_backend_qtquick/qt_compat.py | 328 +++++++++++++++--------- 1 file changed, 206 insertions(+), 122 deletions(-) diff --git a/matplotlib_backend_qtquick/qt_compat.py b/matplotlib_backend_qtquick/qt_compat.py index 6dbe730..b4a1d01 100644 --- a/matplotlib_backend_qtquick/qt_compat.py +++ b/matplotlib_backend_qtquick/qt_compat.py @@ -2,76 +2,101 @@ Qt binding and backend selector. The selection logic is as follows: -- if any of PyQt5, PySide2, PyQt4 or PySide have already been imported - (checked in that order), use it; +- if any of PyQt6, PySide6, PyQt5, or PySide2 have already been + imported (checked in that order), use it; - otherwise, if the QT_API environment variable (used by Enthought) is set, use it to determine which binding to use (but do not change the backend based on it; i.e. if the Qt5Agg backend is requested but QT_API is set to "pyqt4", then actually use Qt5 with PyQt5 or PySide2 (whichever can be imported); - otherwise, use whatever the rcParams indicate. - -Support for PyQt4 is deprecated. - -(Copied and updated from matplotlib) """ -from distutils.version import LooseVersion +import functools +import operator import os +import platform import sys +import signal +import socket +import contextlib + +from packaging.version import parse as parse_version import matplotlib as mpl +from matplotlib import _api +QT_API_PYQT6 = "PyQt6" +QT_API_PYSIDE6 = "PySide6" QT_API_PYQT5 = "PyQt5" QT_API_PYSIDE2 = "PySide2" QT_API_PYQTv2 = "PyQt4v2" QT_API_PYSIDE = "PySide" -QT_API_PYQT = "PyQt4" # Use the old sip v1 API (Py3 defaults to v2). +QT_API_PYQT = "PyQt4" # Use the old sip v1 API (Py3 defaults to v2). QT_API_ENV = os.environ.get("QT_API") +if QT_API_ENV is not None: + QT_API_ENV = QT_API_ENV.lower() # Mapping of QT_API_ENV to requested binding. ETS does not support PyQt4v1. # (https://github.com/enthought/pyface/blob/master/pyface/qt/__init__.py) -_ETS = {"pyqt5": QT_API_PYQT5, "pyside2": QT_API_PYSIDE2, - "pyqt": QT_API_PYQTv2, "pyside": QT_API_PYSIDE, - None: None} +_ETS = { + "pyqt6": QT_API_PYQT6, "pyside6": QT_API_PYSIDE6, + "pyqt5": QT_API_PYQT5, "pyside2": QT_API_PYSIDE2, + None: None +} # First, check if anything is already imported. -if "PyQt5.QtCore" in sys.modules: +if sys.modules.get("PyQt6.QtCore"): + QT_API = QT_API_PYQT6 +elif sys.modules.get("PySide6.QtCore"): + QT_API = QT_API_PYSIDE6 +elif sys.modules.get("PyQt5.QtCore"): QT_API = QT_API_PYQT5 -elif "PySide2.QtCore" in sys.modules: +elif sys.modules.get("PySide2.QtCore"): QT_API = QT_API_PYSIDE2 -elif "PyQt4.QtCore" in sys.modules: - QT_API = QT_API_PYQTv2 -elif "PySide.QtCore" in sys.modules: - QT_API = QT_API_PYSIDE # Otherwise, check the QT_API environment variable (from Enthought). This can # only override the binding, not the backend (in other words, we check that the -# requested backend actually matches). -elif mpl.rcParams["backend"] in ["Qt5Agg", "Qt5Cairo", "QtQuickAgg"]: +# requested backend actually matches). Use dict.__getitem__ to avoid +# triggering backend resolution (which can result in a partially but +# incompletely imported backend_qt5). +elif ( + isinstance(dict.__getitem__(mpl.rcParams, "backend"), str) and + dict.__getitem__(mpl.rcParams, "backend").lower() in [ + "qt5agg", "qt5cairo" + ] +): if QT_API_ENV in ["pyqt5", "pyside2"]: QT_API = _ETS[QT_API_ENV] else: QT_API = None -elif mpl.rcParams["backend"] in ["Qt4Agg", "Qt4Cairo"]: - if QT_API_ENV in ["pyqt4", "pyside"]: - QT_API = _ETS[QT_API_ENV] - else: - QT_API = None # A non-Qt backend was selected but we still got there (possible, e.g., when # fully manually embedding Matplotlib in a Qt app without using pyplot). +elif QT_API_ENV is None: + QT_API = None else: try: QT_API = _ETS[QT_API_ENV] - except KeyError as err: + except KeyError: raise RuntimeError( - "The environment variable QT_API has the unrecognized value {!r};" - "valid values are 'pyqt5', 'pyside2', 'pyqt', and " - "'pyside'") from err + "The environment variable QT_API has the unrecognized value " + f"{QT_API_ENV!r}; " + f"valid values are {set(k for k in _ETS if k is not None)}" + ) from None -def _setup_pyqt5(): - global QtCore, QtGui, QtWidgets, QtQuick, QtQml, __version__, is_pyqt5, \ - _isdeleted, _devicePixelRatio, _setDevicePixelRatio, _getSaveFileName +def _setup_pyqt5plus(): + global QtCore, QtGui, QtWidgets, QtQuick, QtQml, __version__, _isdeleted, _getSaveFileName - if QT_API == QT_API_PYQT5: + if QT_API == QT_API_PYQT6: + from PyQt6 import QtCore, QtGui, QtWidgets, QtQuick, QtQml, sip + __version__ = QtCore.PYQT_VERSION_STR + QtCore.Signal = QtCore.pyqtSignal + QtCore.Slot = QtCore.pyqtSlot + QtCore.Property = QtCore.pyqtProperty + _isdeleted = sip.isdeleted + elif QT_API == QT_API_PYSIDE6: + from PySide6 import QtCore, QtGui, QtWidgets, QtQuick, QtQml, __version__ + import shiboken6 + def _isdeleted(obj): return not shiboken6.isValid(obj) + elif QT_API == QT_API_PYQT5: from PyQt5 import QtCore, QtGui, QtWidgets, QtQuick, QtQml import sip __version__ = QtCore.PYQT_VERSION_STR @@ -82,94 +107,23 @@ def _setup_pyqt5(): elif QT_API == QT_API_PYSIDE2: from PySide2 import QtCore, QtGui, QtWidgets, QtQuick, QtQml, __version__ import shiboken2 - def _isdeleted(obj): return not shiboken2.isValid(obj) + def _isdeleted(obj): + return not shiboken2.isValid(obj) else: - raise ValueError("Unexpected value for the 'backend.qt5' rcparam") + raise AssertionError(f"Unexpected QT_API: {QT_API}") _getSaveFileName = QtWidgets.QFileDialog.getSaveFileName - def is_pyqt5(): - return True - - # self.devicePixelRatio() returns 0 in rare cases - def _devicePixelRatio(obj): return obj.devicePixelRatio() or 1 - def _setDevicePixelRatio(obj, factor): obj.setDevicePixelRatio(factor) - -def _setup_pyqt4(): - global QtCore, QtGui, QtWidgets, __version__, is_pyqt5, \ - _isdeleted, _devicePixelRatio, _setDevicePixelRatio, _getSaveFileName +if QT_API in [QT_API_PYQT6, QT_API_PYQT5, QT_API_PYSIDE6, QT_API_PYSIDE2]: + _setup_pyqt5plus() +elif QT_API is None: # See above re: dict.__getitem__. + _candidates = [ + (_setup_pyqt5plus, QT_API_PYQT6), + (_setup_pyqt5plus, QT_API_PYSIDE6), + (_setup_pyqt5plus, QT_API_PYQT5), + (_setup_pyqt5plus, QT_API_PYSIDE2), + ] - def _setup_pyqt4_internal(api): - global QtCore, QtGui, QtWidgets, \ - __version__, is_pyqt5, _isdeleted, _getSaveFileName - # List of incompatible APIs: - # http://pyqt.sourceforge.net/Docs/PyQt4/incompatible_apis.html - _sip_apis = ["QDate", "QDateTime", "QString", "QTextStream", "QTime", - "QUrl", "QVariant"] - try: - import sip - except ImportError: - pass - else: - for _sip_api in _sip_apis: - try: - sip.setapi(_sip_api, api) - except ValueError: - pass - from PyQt4 import QtCore, QtGui - import sip # Always succeeds *after* importing PyQt4. - __version__ = QtCore.PYQT_VERSION_STR - # PyQt 4.6 introduced getSaveFileNameAndFilter: - # https://riverbankcomputing.com/news/pyqt-46 - if __version__ < LooseVersion("4.6"): - raise ImportError("PyQt<4.6 is not supported") - QtCore.Signal = QtCore.pyqtSignal - QtCore.Slot = QtCore.pyqtSlot - QtCore.Property = QtCore.pyqtProperty - _isdeleted = sip.isdeleted - _getSaveFileName = QtGui.QFileDialog.getSaveFileNameAndFilter - - if QT_API == QT_API_PYQTv2: - _setup_pyqt4_internal(api=2) - elif QT_API == QT_API_PYSIDE: - from PySide import QtCore, QtGui, __version__, __version_info__ - import shiboken - # PySide 1.0.3 fixed the following: - # https://srinikom.github.io/pyside-bz-archive/809.html - if __version_info__ < (1, 0, 3): - raise ImportError("PySide<1.0.3 is not supported") - def _isdeleted(obj): return not shiboken.isValid(obj) - _getSaveFileName = QtGui.QFileDialog.getSaveFileName - elif QT_API == QT_API_PYQT: - _setup_pyqt4_internal(api=1) - else: - raise ValueError("Unexpected value for the 'backend.qt4' rcparam") - QtWidgets = QtGui - - def is_pyqt5(): - return False - - def _devicePixelRatio(obj): return 1 - def _setDevicePixelRatio(obj, factor): pass - - -if QT_API in [QT_API_PYQT5, QT_API_PYSIDE2]: - _setup_pyqt5() -elif QT_API in [QT_API_PYQTv2, QT_API_PYSIDE, QT_API_PYQT]: - _setup_pyqt4() -elif QT_API is None: - if mpl.rcParams["backend"] == "Qt4Agg": - _candidates = [(_setup_pyqt4, QT_API_PYQTv2), - (_setup_pyqt4, QT_API_PYSIDE), - (_setup_pyqt4, QT_API_PYQT), - (_setup_pyqt5, QT_API_PYQT5), - (_setup_pyqt5, QT_API_PYSIDE2)] - else: - _candidates = [(_setup_pyqt5, QT_API_PYQT5), - (_setup_pyqt5, QT_API_PYSIDE2), - (_setup_pyqt4, QT_API_PYQTv2), - (_setup_pyqt4, QT_API_PYSIDE), - (_setup_pyqt4, QT_API_PYQT)] for _setup, QT_API in _candidates: try: _setup() @@ -179,13 +133,143 @@ def _setDevicePixelRatio(obj, factor): pass else: raise ImportError("Failed to import any qt binding") else: # We should not get there. - raise AssertionError("Unexpected QT_API: {}".format(QT_API)) + raise AssertionError(f"Unexpected QT_API: {QT_API}") + + +# Fixes issues with Big Sur +# https://bugreports.qt.io/browse/QTBUG-87014, fixed in qt 5.15.2 +if (sys.platform == 'darwin' and + parse_version(platform.mac_ver()[0]) >= parse_version("10.16") and + QtCore.QLibraryInfo.version().segments() <= [5, 15, 2]): + os.environ.setdefault("QT_MAC_WANTS_LAYER", "1") + +# PyQt6 enum compat helpers. + + +_to_int = operator.attrgetter("value") if QT_API == "PyQt6" else int + + +@functools.lru_cache(None) +def _enum(name): + # foo.bar.Enum.Entry (PyQt6) <=> foo.bar.Entry (non-PyQt6). + return operator.attrgetter( + name if QT_API == 'PyQt6' else name.rpartition(".")[0] + )(sys.modules[QtCore.__package__]) + + +# Backports. + + +def _exec(obj): + # exec on PyQt6, exec_ elsewhere. + obj.exec() if hasattr(obj, "exec") else obj.exec_() + + +def _devicePixelRatioF(obj): + """ + Return obj.devicePixelRatioF() with graceful fallback for older Qt. + + This can be replaced by the direct call when we require Qt>=5.6. + """ + try: + # Not available on Qt<5.6 + return obj.devicePixelRatioF() or 1 + except AttributeError: + pass + try: + # Not available on Qt4 or some older Qt5. + # self.devicePixelRatio() returns 0 in rare cases + return obj.devicePixelRatio() or 1 + except AttributeError: + return 1 + + +def _setDevicePixelRatio(obj, val): + """ + Call obj.setDevicePixelRatio(val) with graceful fallback for older Qt. + + This can be replaced by the direct call when we require Qt>=5.6. + """ + if hasattr(obj, 'setDevicePixelRatio'): + # Not available on Qt4 or some older Qt5. + obj.setDevicePixelRatio(val) + + +@contextlib.contextmanager +def _maybe_allow_interrupt(qapp): + """ + This manager allows to terminate a plot by sending a SIGINT. It is + necessary because the running Qt backend prevents Python interpreter to + run and process signals (i.e., to raise KeyboardInterrupt exception). To + solve this one needs to somehow wake up the interpreter and make it close + the plot window. We do this by using the signal.set_wakeup_fd() function + which organizes a write of the signal number into a socketpair connected + to the QSocketNotifier (since it is part of the Qt backend, it can react + to that write event). Afterwards, the Qt handler empties the socketpair + by a recv() command to re-arm it (we need this if a signal different from + SIGINT was caught by set_wakeup_fd() and we shall continue waiting). If + the SIGINT was caught indeed, after exiting the on_signal() function the + interpreter reacts to the SIGINT according to the handle() function which + had been set up by a signal.signal() call: it causes the qt_object to + exit by calling its quit() method. Finally, we call the old SIGINT + handler with the same arguments that were given to our custom handle() + handler. + + We do this only if the old handler for SIGINT was not None, which means + that a non-python handler was installed, i.e. in Julia, and not SIG_IGN + which means we should ignore the interrupts. + """ + old_sigint_handler = signal.getsignal(signal.SIGINT) + handler_args = None + skip = False + if old_sigint_handler in (None, signal.SIG_IGN, signal.SIG_DFL): + skip = True + else: + wsock, rsock = socket.socketpair() + wsock.setblocking(False) + old_wakeup_fd = signal.set_wakeup_fd(wsock.fileno()) + sn = QtCore.QSocketNotifier( + rsock.fileno(), _enum('QtCore.QSocketNotifier.Type').Read + ) + + # We do not actually care about this value other than running some + # Python code to ensure that the interpreter has a chance to handle the + # signal in Python land. We also need to drain the socket because it + # will be written to as part of the wakeup! There are some cases where + # this may fire too soon / more than once on Windows so we should be + # forgiving about reading an empty socket. + rsock.setblocking(False) + # Clear the socket to re-arm the notifier. + @sn.activated.connect + def _may_clear_sock(*args): + try: + rsock.recv(1) + except BlockingIOError: + pass + + def handle(*args): + nonlocal handler_args + handler_args = args + qapp.quit() + + signal.signal(signal.SIGINT, handle) + try: + yield + finally: + if not skip: + wsock.close() + rsock.close() + sn.setEnabled(False) + signal.set_wakeup_fd(old_wakeup_fd) + signal.signal(signal.SIGINT, old_sigint_handler) + if handler_args is not None: + old_sigint_handler(*handler_args) -# These globals are only defined for backcompatibility purposes. -ETS = dict(pyqt=(QT_API_PYQTv2, 4), pyside=(QT_API_PYSIDE, 4), - pyqt5=(QT_API_PYQT5, 5), pyside2=(QT_API_PYSIDE2, 5)) -QT_RC_MAJOR_VERSION = 5 if is_pyqt5() else 4 -if not is_pyqt5(): - mpl.cbook.warn_deprecated("3.3", name="support for Qt4") +@_api.caching_module_getattr +class __getattr__: + ETS = _api.deprecated("3.5")(property(lambda self: dict( + pyqt5=(QT_API_PYQT5, 5), pyside2=(QT_API_PYSIDE2, 5)))) + QT_RC_MAJOR_VERSION = _api.deprecated("3.5")(property( + lambda self: int(QtCore.qVersion().split(".")[0]))) From 620a231d0be312b7af4f23f38a187bded593eb89 Mon Sep 17 00:00:00 2001 From: JamesWrigley Date: Wed, 13 Apr 2022 16:37:22 +0200 Subject: [PATCH 2/4] Fix compatibility with latest matplotlib --- matplotlib_backend_qtquick/backend_qtquick.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/matplotlib_backend_qtquick/backend_qtquick.py b/matplotlib_backend_qtquick/backend_qtquick.py index 1164563..a097d96 100644 --- a/matplotlib_backend_qtquick/backend_qtquick.py +++ b/matplotlib_backend_qtquick/backend_qtquick.py @@ -8,8 +8,8 @@ FigureCanvasBase, NavigationToolbar2, MouseButton) from matplotlib.figure import Figure -from matplotlib.backends.backend_qt5 import ( - TimerQT, SPECIAL_KEYS, MODIFIER_KEYS, cursord) +from matplotlib.backends.backend_qt import ( + TimerQT, SPECIAL_KEYS, _MODIFIER_KEYS as MODIFIER_KEYS, cursord) from .qt_compat import ( QtCore, QtGui, QtQuick, QtWidgets, QT_API, QT_API_PYSIDE2) From 6ab8a98eb5fec98a15d5096ce01dda52d6deca5d Mon Sep 17 00:00:00 2001 From: JamesWrigley Date: Wed, 13 Apr 2022 16:41:06 +0200 Subject: [PATCH 3/4] Fix compatibility with Qt 6 - The Qt.MidButton alias was removed in Qt 6 - QQuickItem.geometryChanged() was renamed to QQuickItem.geometryChange() - The QWheelEvent.pos() alias was removed in Qt 6 --- matplotlib_backend_qtquick/backend_qtquick.py | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/matplotlib_backend_qtquick/backend_qtquick.py b/matplotlib_backend_qtquick/backend_qtquick.py index a097d96..c21bab8 100644 --- a/matplotlib_backend_qtquick/backend_qtquick.py +++ b/matplotlib_backend_qtquick/backend_qtquick.py @@ -12,7 +12,7 @@ TimerQT, SPECIAL_KEYS, _MODIFIER_KEYS as MODIFIER_KEYS, cursord) from .qt_compat import ( QtCore, QtGui, QtQuick, QtWidgets, - QT_API, QT_API_PYSIDE2) + QT_API, QT_API_PYSIDE2, QT_API_PYSIDE6) class FigureCanvasQtQuick(QtQuick.QQuickPaintedItem, FigureCanvasBase): @@ -25,7 +25,7 @@ class FigureCanvasQtQuick(QtQuick.QQuickPaintedItem, FigureCanvasBase): # map Qt button codes to MouseEvent's ones: buttond = {QtCore.Qt.LeftButton: MouseButton.LEFT, - QtCore.Qt.MidButton: MouseButton.MIDDLE, + QtCore.Qt.MiddleButton: MouseButton.MIDDLE, QtCore.Qt.RightButton: MouseButton.RIGHT, QtCore.Qt.XButton1: MouseButton.BACK, QtCore.Qt.XButton2: MouseButton.FORWARD, @@ -154,7 +154,7 @@ def _draw_idle(self): # Uncaught exceptions are fatal for PyQt5, so catch them. traceback.print_exc() - def geometryChanged(self, new_geometry, old_geometry): + def geometryChangeHelper(self, new_geometry, old_geometry): w = new_geometry.width() * self.dpi_ratio h = new_geometry.height() * self.dpi_ratio @@ -167,6 +167,17 @@ def geometryChanged(self, new_geometry, old_geometry): self.figure.set_size_inches(winch, hinch, forward=False) FigureCanvasBase.resize_event(self) self.draw_idle() + + # Overload for Qt 6 + def geometryChange(self, new_geometry, old_geometry): + self.geometryChangeHelper(new_geometry, old_geometry) + QtQuick.QQuickPaintedItem.geometryChange(self, + new_geometry, + old_geometry) + + # Overload for Qt 5 + def geometryChanged(self, new_geometry, old_geometry): + self.geometryChangeHelper(new_geometry, old_geometry) QtQuick.QQuickPaintedItem.geometryChanged(self, new_geometry, old_geometry) @@ -239,7 +250,7 @@ def mouseDoubleClickEvent(self, event): guiEvent=event) def wheelEvent(self, event): - x, y = self.mouseEventCoords(event.pos()) + x, y = self.mouseEventCoords(event.position()) # from QWheelEvent::delta doc if event.pixelDelta().x() == 0 and event.pixelDelta().y() == 0: steps = event.angleDelta().y() / 120 @@ -349,7 +360,7 @@ class NavigationToolbar2QtQuick(QtCore.QObject, NavigationToolbar2): def __init__(self, canvas, parent=None): # I think this is needed due to a bug in PySide2 - if QT_API == QT_API_PYSIDE2: + if QT_API in [QT_API_PYSIDE2, QT_API_PYSIDE6]: QtCore.QObject.__init__(self, parent) NavigationToolbar2.__init__(self, canvas) else: From 026bac0ec7ab08391829a1e15855dfcc59f0f9ef Mon Sep 17 00:00:00 2001 From: JamesWrigley Date: Wed, 13 Apr 2022 16:59:10 +0200 Subject: [PATCH 4/4] Fix compatibility with Python 3.10 Python 3.10 no longer automatically converts floats to ints. --- matplotlib_backend_qtquick/backend_qtquick.py | 2 +- matplotlib_backend_qtquick/backend_qtquickagg.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/matplotlib_backend_qtquick/backend_qtquick.py b/matplotlib_backend_qtquick/backend_qtquick.py index c21bab8..6b8ff94 100644 --- a/matplotlib_backend_qtquick/backend_qtquick.py +++ b/matplotlib_backend_qtquick/backend_qtquick.py @@ -109,7 +109,7 @@ def _draw_rect_callback(painter): pen = QtGui.QPen(QtCore.Qt.black, 1 / self.dpi_ratio, QtCore.Qt.DotLine) painter.setPen(pen) - painter.drawRect(*(pt / self.dpi_ratio for pt in rect)) + painter.drawRect(QtCore.QRectF(*(pt / self.dpi_ratio for pt in rect))) else: def _draw_rect_callback(painter): return diff --git a/matplotlib_backend_qtquick/backend_qtquickagg.py b/matplotlib_backend_qtquick/backend_qtquickagg.py index dc09a33..3d8d349 100644 --- a/matplotlib_backend_qtquick/backend_qtquickagg.py +++ b/matplotlib_backend_qtquick/backend_qtquickagg.py @@ -46,8 +46,8 @@ def paint(self, p): stringBuffer = self.renderer.tostring_argb() # convert the Agg rendered image -> qImage - qImage = QtGui.QImage(stringBuffer, self.renderer.width, - self.renderer.height, + qImage = QtGui.QImage(stringBuffer, int(self.renderer.width), + int(self.renderer.height), QtGui.QImage.Format_RGBA8888) if hasattr(qImage, 'setDevicePixelRatio'): # Not available on Qt4 or some older Qt5.