From 0035e83a40ba77080f9ae8b829835b2a0c4834f6 Mon Sep 17 00:00:00 2001 From: Marijn van Vliet Date: Thu, 28 Mar 2024 17:48:25 +0200 Subject: [PATCH] Working on qt6 support. Needs further doc updates. --- .../pydevd/pydev_ipython/inputhook.py | 22 +- .../pydevd/pydev_ipython/inputhookqt6.py | 199 ++++++++++++++++++ .../pydevd/pydev_ipython/matplotlibtools.py | 1 + .../_vendored/pydevd/pydev_ipython/qt.py | 12 +- .../pydevd/pydev_ipython/qt_for_kernel.py | 17 +- .../pydevd/pydev_ipython/qt_loaders.py | 55 ++++- 6 files changed, 289 insertions(+), 17 deletions(-) create mode 100644 src/debugpy/_vendored/pydevd/pydev_ipython/inputhookqt6.py diff --git a/src/debugpy/_vendored/pydevd/pydev_ipython/inputhook.py b/src/debugpy/_vendored/pydevd/pydev_ipython/inputhook.py index fe088466f..b9e88fff3 100644 --- a/src/debugpy/_vendored/pydevd/pydev_ipython/inputhook.py +++ b/src/debugpy/_vendored/pydevd/pydev_ipython/inputhook.py @@ -26,6 +26,7 @@ GUI_QT = 'qt' GUI_QT4 = 'qt4' GUI_QT5 = 'qt5' +GUI_QT6 = 'qt6' GUI_GTK = 'gtk' GUI_TK = 'tk' GUI_OSX = 'osx' @@ -173,8 +174,10 @@ def disable_wx(self): self.clear_inputhook() def enable_qt(self, app=None): - from pydev_ipython.qt_for_kernel import QT_API, QT_API_PYQT5 - if QT_API == QT_API_PYQT5: + from pydev_ipython.qt_for_kernel import QT_API, QT_API_PYQT5, QT_API_PYQT6 + if QT_API == QT_API_PYQT6: + self.enable_qt6(app) + elif QT_API == QT_API_PYQT5: self.enable_qt5(app) else: self.enable_qt4(app) @@ -234,6 +237,21 @@ def disable_qt5(self): self._apps[GUI_QT5]._in_event_loop = False self.clear_inputhook() + def enable_qt6(self, app=None): + from pydev_ipython.inputhookqt6 import create_inputhook_qt6 + app, inputhook_qt6 = create_inputhook_qt6(self, app) + self.set_inputhook(inputhook_qt6) + + self._current_gui = GUI_QT6 + app._in_event_loop = True + self._apps[GUI_QT6] = app + return app + + def disable_qt6(self): + if GUI_QT6 in self._apps: + self._apps[GUI_QT6]._in_event_loop = False + self.clear_inputhook() + def enable_gtk(self, app=None): """Enable event loop integration with PyGTK. diff --git a/src/debugpy/_vendored/pydevd/pydev_ipython/inputhookqt6.py b/src/debugpy/_vendored/pydevd/pydev_ipython/inputhookqt6.py new file mode 100644 index 000000000..014642f15 --- /dev/null +++ b/src/debugpy/_vendored/pydevd/pydev_ipython/inputhookqt6.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- +""" +Qt6's inputhook support function + +Author: Christian Boos, Marijn van Vliet +""" + +#----------------------------------------------------------------------------- +# Copyright (C) 2011 The IPython Development Team +# +# Distributed under the terms of the BSD License. The full license is in +# the file COPYING, distributed as part of this software. +#----------------------------------------------------------------------------- + +#----------------------------------------------------------------------------- +# Imports +#----------------------------------------------------------------------------- + +import os +import signal + +import threading + +from pydev_ipython.qt_for_kernel import QtCore, QtGui +from pydev_ipython.inputhook import allow_CTRL_C, ignore_CTRL_C, stdin_ready + + +# To minimise future merging complexity, rather than edit the entire code base below +# we fake InteractiveShell here +class InteractiveShell: + _instance = None + + @classmethod + def instance(cls): + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def set_hook(self, *args, **kwargs): + # We don't consider the pre_prompt_hook because we don't have + # KeyboardInterrupts to consider since we are running under PyDev + pass + +#----------------------------------------------------------------------------- +# Module Globals +#----------------------------------------------------------------------------- + + +got_kbdint = False +sigint_timer = None + +#----------------------------------------------------------------------------- +# Code +#----------------------------------------------------------------------------- + + +def create_inputhook_qt6(mgr, app=None): + """Create an input hook for running the Qt6 application event loop. + + Parameters + ---------- + mgr : an InputHookManager + + app : Qt Application, optional. + Running application to use. If not given, we probe Qt for an + existing application object, and create a new one if none is found. + + Returns + ------- + A pair consisting of a Qt Application (either the one given or the + one found or created) and a inputhook. + + Notes + ----- + We use a custom input hook instead of PyQt6's default one, as it + interacts better with the readline packages (issue #481). + + The inputhook function works in tandem with a 'pre_prompt_hook' + which automatically restores the hook as an inputhook in case the + latter has been temporarily disabled after having intercepted a + KeyboardInterrupt. + """ + + if app is None: + app = QtCore.QCoreApplication.instance() + if app is None: + from PyQt6 import QtWidgets + app = QtWidgets.QApplication([" "]) + + # Re-use previously created inputhook if any + ip = InteractiveShell.instance() + if hasattr(ip, '_inputhook_qt6'): + return app, ip._inputhook_qt6 + + # Otherwise create the inputhook_qt6/preprompthook_qt6 pair of + # hooks (they both share the got_kbdint flag) + + def inputhook_qt6(): + """PyOS_InputHook python hook for Qt6. + + Process pending Qt events and if there's no pending keyboard + input, spend a short slice of time (50ms) running the Qt event + loop. + + As a Python ctypes callback can't raise an exception, we catch + the KeyboardInterrupt and temporarily deactivate the hook, + which will let a *second* CTRL+C be processed normally and go + back to a clean prompt line. + """ + try: + allow_CTRL_C() + app = QtCore.QCoreApplication.instance() + if not app: # shouldn't happen, but safer if it happens anyway... + return 0 + app.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents, 300) + if not stdin_ready(): + # Generally a program would run QCoreApplication::exec() + # from main() to enter and process the Qt event loop until + # quit() or exit() is called and the program terminates. + # + # For our input hook integration, we need to repeatedly + # enter and process the Qt event loop for only a short + # amount of time (say 50ms) to ensure that Python stays + # responsive to other user inputs. + # + # A naive approach would be to repeatedly call + # QCoreApplication::exec(), using a timer to quit after a + # short amount of time. Unfortunately, QCoreApplication + # emits an aboutToQuit signal before stopping, which has + # the undesirable effect of closing all modal windows. + # + # To work around this problem, we instead create a + # QEventLoop and call QEventLoop::exec(). Other than + # setting some state variables which do not seem to be + # used anywhere, the only thing QCoreApplication adds is + # the aboutToQuit signal which is precisely what we are + # trying to avoid. + timer = QtCore.QTimer() + event_loop = QtCore.QEventLoop() + timer.timeout.connect(event_loop.quit) + while not stdin_ready(): + timer.start(50) + event_loop.exec() + timer.stop() + except KeyboardInterrupt: + global got_kbdint, sigint_timer + + ignore_CTRL_C() + got_kbdint = True + mgr.clear_inputhook() + + # This generates a second SIGINT so the user doesn't have to + # press CTRL+C twice to get a clean prompt. + # + # Since we can't catch the resulting KeyboardInterrupt here + # (because this is a ctypes callback), we use a timer to + # generate the SIGINT after we leave this callback. + # + # Unfortunately this doesn't work on Windows (SIGINT kills + # Python and CTRL_C_EVENT doesn't work). + if(os.name == 'posix'): + pid = os.getpid() + if(not sigint_timer): + sigint_timer = threading.Timer(.01, os.kill, + args=[pid, signal.SIGINT]) + sigint_timer.start() + else: + print("\nKeyboardInterrupt - Ctrl-C again for new prompt") + + except: # NO exceptions are allowed to escape from a ctypes callback + ignore_CTRL_C() + from traceback import print_exc + print_exc() + print("Got exception from inputhook_qt6, unregistering.") + mgr.clear_inputhook() + finally: + allow_CTRL_C() + return 0 + + def preprompthook_qt6(ishell): + """'pre_prompt_hook' used to restore the Qt6 input hook + + (in case the latter was temporarily deactivated after a + CTRL+C) + """ + global got_kbdint, sigint_timer + + if(sigint_timer): + sigint_timer.cancel() + sigint_timer = None + + if got_kbdint: + mgr.set_inputhook(inputhook_qt6) + got_kbdint = False + + ip._inputhook_qt6 = inputhook_qt6 + ip.set_hook('pre_prompt_hook', preprompthook_qt6) + + return app, inputhook_qt6 diff --git a/src/debugpy/_vendored/pydevd/pydev_ipython/matplotlibtools.py b/src/debugpy/_vendored/pydevd/pydev_ipython/matplotlibtools.py index 71f026443..6c1fa2dce 100644 --- a/src/debugpy/_vendored/pydevd/pydev_ipython/matplotlibtools.py +++ b/src/debugpy/_vendored/pydevd/pydev_ipython/matplotlibtools.py @@ -8,6 +8,7 @@ 'qt': 'QtAgg', # Auto-choose qt4/5 'qt4': 'Qt4Agg', 'qt5': 'Qt5Agg', + 'qt6': 'Qt6Agg', 'osx': 'MacOSX'} # We also need a reverse backends2guis mapping that will properly choose which diff --git a/src/debugpy/_vendored/pydevd/pydev_ipython/qt.py b/src/debugpy/_vendored/pydevd/pydev_ipython/qt.py index 222c81b91..a45672ae8 100644 --- a/src/debugpy/_vendored/pydevd/pydev_ipython/qt.py +++ b/src/debugpy/_vendored/pydevd/pydev_ipython/qt.py @@ -8,15 +8,15 @@ import os -from pydev_ipython.qt_loaders import (load_qt, QT_API_PYSIDE, - QT_API_PYQT, QT_API_PYQT5) +from pydev_ipython.qt_loaders import (load_qt, QT_API_PYSIDE, QT_API_PYSIDE2, + QT_API_PYQT, QT_API_PYQT5, QT_API_PYQT6) QT_API = os.environ.get('QT_API', None) -if QT_API not in [QT_API_PYSIDE, QT_API_PYQT, QT_API_PYQT5, None]: - raise RuntimeError("Invalid Qt API %r, valid values are: %r, %r" % - (QT_API, QT_API_PYSIDE, QT_API_PYQT, QT_API_PYQT5)) +if QT_API not in [QT_API_PYSIDE, QT_API_PYSIDE2, QT_API_PYQT, QT_API_PYQT5, QT_API_PYQT6, None]: + raise RuntimeError("Invalid Qt API %r, valid values are: %r, %r, %r, %r, %r" % + (QT_API, QT_API_PYSIDE, QT_API_PYSIDE2, QT_API_PYQT, QT_API_PYQT5, QT_API_PYQT6)) if QT_API is None: - api_opts = [QT_API_PYSIDE, QT_API_PYQT, QT_API_PYQT5] + api_opts = [QT_API_PYSIDE, QT_API_PYSIDE2, QT_API_PYQT, QT_API_PYQT5, QT_API_PYQT6] else: api_opts = [QT_API] diff --git a/src/debugpy/_vendored/pydevd/pydev_ipython/qt_for_kernel.py b/src/debugpy/_vendored/pydevd/pydev_ipython/qt_for_kernel.py index 500d25a74..55d3d4848 100644 --- a/src/debugpy/_vendored/pydevd/pydev_ipython/qt_for_kernel.py +++ b/src/debugpy/_vendored/pydevd/pydev_ipython/qt_for_kernel.py @@ -37,7 +37,7 @@ from pydev_ipython.version import check_version from pydev_ipython.qt_loaders import (load_qt, QT_API_PYSIDE, QT_API_PYSIDE2, QT_API_PYQT, QT_API_PYQT_DEFAULT, - loaded_api, QT_API_PYQT5) + loaded_api, QT_API_PYQT5, QT_API_PYQT6) # Constraints placed on an imported matplotlib @@ -71,10 +71,21 @@ def matplotlib_options(mpl): raise ImportError("unhandled value for backend.qt5 from matplotlib: %r" % mpqt) + elif backend == 'Qt6Agg': + mpqt = mpl.rcParams.get('backend.qt6', None) + if mpqt is None: + return None + if mpqt.lower() == 'pyqt6': + return [QT_API_PYQT6] + raise ImportError("unhandled value for backend.qt6 from matplotlib: %r" % + mpqt) + # Fallback without checking backend (previous code) mpqt = mpl.rcParams.get('backend.qt4', None) if mpqt is None: mpqt = mpl.rcParams.get('backend.qt5', None) + if mpqt is None: + mpqt = mpl.rcParams.get('backend.qt6', None) if mpqt is None: return None @@ -84,6 +95,8 @@ def matplotlib_options(mpl): return [QT_API_PYQT_DEFAULT] elif mpqt.lower() == 'pyqt5': return [QT_API_PYQT5] + elif mpqt.lower() == 'pyqt6': + return [QT_API_PYQT6] raise ImportError("unhandled value for qt backend from matplotlib: %r" % mpqt) @@ -105,7 +118,7 @@ def get_options(): if os.environ.get('QT_API', None) is None: # no ETS variable. Ask mpl, then use either - return matplotlib_options(mpl) or [QT_API_PYQT_DEFAULT, QT_API_PYSIDE, QT_API_PYSIDE2, QT_API_PYQT5] + return matplotlib_options(mpl) or [QT_API_PYQT_DEFAULT, QT_API_PYSIDE, QT_API_PYSIDE2, QT_API_PYQT5, QT_API_PYQT6] # ETS variable present. Will fallback to external.qt return None diff --git a/src/debugpy/_vendored/pydevd/pydev_ipython/qt_loaders.py b/src/debugpy/_vendored/pydevd/pydev_ipython/qt_loaders.py index 0e30d4987..f2bff6dc8 100644 --- a/src/debugpy/_vendored/pydevd/pydev_ipython/qt_loaders.py +++ b/src/debugpy/_vendored/pydevd/pydev_ipython/qt_loaders.py @@ -19,7 +19,9 @@ QT_API_PYQT_DEFAULT = 'pyqtdefault' # don't set SIP explicitly QT_API_PYSIDE = 'pyside' QT_API_PYSIDE2 = 'pyside2' +QT_API_PYSIDE6 = 'pyside6' QT_API_PYQT5 = 'pyqt5' +QT_API_PYQT6 = 'pyqt6' class ImportDenier(object): @@ -58,9 +60,11 @@ def commit_api(api): if api == QT_API_PYSIDE: ID.forbid('PyQt4') ID.forbid('PyQt5') + ID.forbid('PyQt6') else: ID.forbid('PySide') ID.forbid('PySide2') + ID.forbid('PySide6') def loaded_api(): @@ -71,7 +75,7 @@ def loaded_api(): Returns ------- - None, 'pyside', 'pyside2', 'pyqt', or 'pyqtv1' + None, 'pyside', 'pyside2', 'pyside6', 'pyqt5', 'pyqt6', or 'pyqtv1' """ if 'PyQt4.QtCore' in sys.modules: if qtapi_version() == 2: @@ -82,13 +86,17 @@ def loaded_api(): return QT_API_PYSIDE elif 'PySide2.QtCore' in sys.modules: return QT_API_PYSIDE2 + elif 'PySide6.QtCore' in sys.modules: + return QT_API_PYSIDE6 elif 'PyQt5.QtCore' in sys.modules: return QT_API_PYQT5 + elif 'PyQt6.QtCore' in sys.modules: + return QT_API_PYQT6 return None def has_binding(api): - """Safely check for PyQt4 or PySide, without importing + """Safely check for PyQt or PySide, without importing submodules Parameters @@ -105,10 +113,12 @@ def has_binding(api): # check for complete presence before importing module_name = {QT_API_PYSIDE: 'PySide', QT_API_PYSIDE2: 'PySide2', + QT_API_PYSIDE6: 'PySide6', QT_API_PYQT: 'PyQt4', QT_API_PYQTv1: 'PyQt4', QT_API_PYQT_DEFAULT: 'PyQt4', QT_API_PYQT5: 'PyQt5', + QT_API_PYQT6: 'PyQt6', } module_name = module_name[api] @@ -154,7 +164,7 @@ def can_import(api): current = loaded_api() if api == QT_API_PYQT_DEFAULT: - return current in [QT_API_PYQT, QT_API_PYQTv1, QT_API_PYQT5, None] + return current in [QT_API_PYQT, QT_API_PYQTv1, QT_API_PYQT5, QT_API_PYQT6, None] else: return current in [api, None] @@ -211,6 +221,21 @@ def import_pyqt5(): return QtCore, QtGui, QtSvg, QT_API_PYQT5 +def import_pyqt6(): + """ + Import PyQt6 + + ImportErrors raised within this function are non-recoverable + """ + from PyQt6 import QtGui, QtCore, QtSvg + + # Alias PyQt-specific functions for PySide compatibility. + QtCore.Signal = QtCore.pyqtSignal + QtCore.Slot = QtCore.pyqtSlot + + return QtCore, QtGui, QtSvg, QT_API_PYQT6 + + def import_pyside(): """ Import PySide @@ -228,7 +253,17 @@ def import_pyside2(): ImportErrors raised within this function are non-recoverable """ from PySide2 import QtGui, QtCore, QtSvg # @UnresolvedImport - return QtCore, QtGui, QtSvg, QT_API_PYSIDE + return QtCore, QtGui, QtSvg, QT_API_PYSIDE2 + + +def import_pyside6(): + """ + Import PySide6 + + ImportErrors raised within this function are non-recoverable + """ + from PySide6 import QtGui, QtCore, QtSvg # @UnresolvedImport + return QtCore, QtGui, QtSvg, QT_API_PYSIDE6 def load_qt(api_options): @@ -259,19 +294,21 @@ def load_qt(api_options): """ loaders = {QT_API_PYSIDE: import_pyside, QT_API_PYSIDE2: import_pyside2, + QT_API_PYSIDE6: import_pyside6, QT_API_PYQT: import_pyqt4, QT_API_PYQTv1: partial(import_pyqt4, version=1), QT_API_PYQT_DEFAULT: partial(import_pyqt4, version=None), QT_API_PYQT5: import_pyqt5, + QT_API_PYQT6: import_pyqt6, } for api in api_options: if api not in loaders: raise RuntimeError( - "Invalid Qt API %r, valid values are: %r, %r, %r, %r, %r, %r" % - (api, QT_API_PYSIDE, QT_API_PYSIDE, QT_API_PYQT, - QT_API_PYQTv1, QT_API_PYQT_DEFAULT, QT_API_PYQT5)) + "Invalid Qt API %r, valid values are: %r, %r, %r, %r, %r, %r, %r" % + (api, QT_API_PYSIDE, QT_API_PYSIDE2, QT_API_PYQT, + QT_API_PYQTv1, QT_API_PYQT_DEFAULT, QT_API_PYQT5, QT_API_PYQT6)) if not can_import(api): continue @@ -290,12 +327,16 @@ def load_qt(api_options): Currently-imported Qt library: %r PyQt4 installed: %s PyQt5 installed: %s + PyQt6 installed: %s PySide >= 1.0.3 installed: %s PySide2 installed: %s + PySide6 installed: %s Tried to load: %r """ % (loaded_api(), has_binding(QT_API_PYQT), has_binding(QT_API_PYQT5), + has_binding(QT_API_PYQT6), has_binding(QT_API_PYSIDE), has_binding(QT_API_PYSIDE2), + has_binding(QT_API_PYSIDE6), api_options))