diff --git a/IPython/core/pylabtools.py b/IPython/core/pylabtools.py index 1914deb..2c0d25b 100644 --- a/IPython/core/pylabtools.py +++ b/IPython/core/pylabtools.py @@ -19,6 +19,7 @@ backends = { "wx": "WXAgg", "qt4": "Qt4Agg", "qt5": "Qt5Agg", + "qt6": "QtAgg", "qt": "Qt5Agg", "osx": "MacOSX", "nbagg": "nbAgg", diff --git a/IPython/external/qt_for_kernel.py b/IPython/external/qt_for_kernel.py index 1a94e7e..d2e7bd9 100644 --- a/IPython/external/qt_for_kernel.py +++ b/IPython/external/qt_for_kernel.py @@ -32,15 +32,42 @@ import os import sys from IPython.utils.version import check_version -from IPython.external.qt_loaders import (load_qt, loaded_api, QT_API_PYSIDE, - QT_API_PYSIDE2, QT_API_PYQT, QT_API_PYQT5, - QT_API_PYQTv1, QT_API_PYQT_DEFAULT) +from IPython.external.qt_loaders import ( + load_qt, + loaded_api, + enum_factory, + # QT6 + QT_API_PYQT6, + QT_API_PYSIDE6, + # QT5 + QT_API_PYQT5, + QT_API_PYSIDE2, + # QT4 + QT_API_PYQTv1, + QT_API_PYQT, + QT_API_PYSIDE, + # default + QT_API_PYQT_DEFAULT, +) + +_qt_apis = ( + # QT6 + QT_API_PYQT6, + QT_API_PYSIDE6, + # QT5 + QT_API_PYQT5, + QT_API_PYSIDE2, + # QT4 + QT_API_PYQTv1, + QT_API_PYQT, + QT_API_PYSIDE, + # default + QT_API_PYQT_DEFAULT, +) -_qt_apis = (QT_API_PYSIDE, QT_API_PYSIDE2, QT_API_PYQT, QT_API_PYQT5, QT_API_PYQTv1, - QT_API_PYQT_DEFAULT) -#Constraints placed on an imported matplotlib def matplotlib_options(mpl): + """Constraints placed on an imported matplotlib.""" if mpl is None: return backend = mpl.rcParams.get('backend', None) @@ -66,9 +93,7 @@ def matplotlib_options(mpl): mpqt) def get_options(): - """Return a list of acceptable QT APIs, in decreasing order of - preference - """ + """Return a list of acceptable QT APIs, in decreasing order of preference.""" #already imported Qt somewhere. Use that loaded = loaded_api() if loaded is not None: @@ -83,13 +108,22 @@ def get_options(): qt_api = os.environ.get('QT_API', None) if qt_api is None: #no ETS variable. Ask mpl, then use default fallback path - return matplotlib_options(mpl) or [QT_API_PYQT_DEFAULT, QT_API_PYSIDE, - QT_API_PYQT5, QT_API_PYSIDE2] + return matplotlib_options(mpl) or [ + QT_API_PYQT_DEFAULT, + QT_API_PYQT6, + QT_API_PYSIDE6, + QT_API_PYQT5, + QT_API_PYSIDE2, + QT_API_PYQT, + QT_API_PYSIDE, + ] elif qt_api not in _qt_apis: raise RuntimeError("Invalid Qt API %r, valid values are: %r" % (qt_api, ', '.join(_qt_apis))) else: return [qt_api] + api_opts = get_options() QtCore, QtGui, QtSvg, QT_API = load_qt(api_opts) +enum_helper = enum_factory(QT_API, QtCore) diff --git a/IPython/external/qt_loaders.py b/IPython/external/qt_loaders.py index ca7483e..7980535 100644 --- a/IPython/external/qt_loaders.py +++ b/IPython/external/qt_loaders.py @@ -10,26 +10,41 @@ be accessed directly from the outside """ import sys import types -from functools import partial -from importlib import import_module +from functools import partial, lru_cache +import operator from IPython.utils.version import check_version -# Available APIs. -QT_API_PYQT = 'pyqt' # Force version 2 +# ### Available APIs. +# Qt6 +QT_API_PYQT6 = "pyqt6" +QT_API_PYSIDE6 = "pyside6" + +# Qt5 QT_API_PYQT5 = 'pyqt5' -QT_API_PYQTv1 = 'pyqtv1' # Force version 2 -QT_API_PYQT_DEFAULT = 'pyqtdefault' # use system default for version 1 vs. 2 -QT_API_PYSIDE = 'pyside' QT_API_PYSIDE2 = 'pyside2' -api_to_module = {QT_API_PYSIDE2: 'PySide2', - QT_API_PYSIDE: 'PySide', - QT_API_PYQT: 'PyQt4', - QT_API_PYQTv1: 'PyQt4', - QT_API_PYQT5: 'PyQt5', - QT_API_PYQT_DEFAULT: 'PyQt4', - } +# Qt4 +QT_API_PYQT = "pyqt" # Force version 2 +QT_API_PYQTv1 = "pyqtv1" # Force version 2 +QT_API_PYSIDE = "pyside" + +QT_API_PYQT_DEFAULT = "pyqtdefault" # use system default for version 1 vs. 2 + +api_to_module = { + # Qt6 + QT_API_PYQT6: "PyQt6", + QT_API_PYSIDE6: "PySide6", + # Qt5 + QT_API_PYQT5: "PyQt5", + QT_API_PYSIDE2: "PySide2", + # Qt4 + QT_API_PYSIDE: "PySide", + QT_API_PYQT: "PyQt4", + QT_API_PYQTv1: "PyQt4", + # default + QT_API_PYQT_DEFAULT: "PyQt6", +} class ImportDenier(object): @@ -56,6 +71,7 @@ class ImportDenier(object): already imported an Incompatible QT Binding: %s """ % (fullname, loaded_api())) + ID = ImportDenier() sys.meta_path.insert(0, ID) @@ -63,23 +79,11 @@ sys.meta_path.insert(0, ID) def commit_api(api): """Commit to a particular API, and trigger ImportErrors on subsequent dangerous imports""" + modules = set(api_to_module.values()) - if api == QT_API_PYSIDE2: - ID.forbid('PySide') - ID.forbid('PyQt4') - ID.forbid('PyQt5') - elif api == QT_API_PYSIDE: - ID.forbid('PySide2') - ID.forbid('PyQt4') - ID.forbid('PyQt5') - elif api == QT_API_PYQT5: - ID.forbid('PySide2') - ID.forbid('PySide') - ID.forbid('PyQt4') - else: # There are three other possibilities, all representing PyQt4 - ID.forbid('PyQt5') - ID.forbid('PySide2') - ID.forbid('PySide') + modules.remove(api_to_module[api]) + for mod in modules: + ID.forbid(mod) def loaded_api(): @@ -90,19 +94,24 @@ def loaded_api(): Returns ------- - None, 'pyside2', 'pyside', 'pyqt', 'pyqt5', or 'pyqtv1' + None, 'pyside6', 'pyqt6', 'pyside2', 'pyside', 'pyqt', 'pyqt5', 'pyqtv1' """ - if 'PyQt4.QtCore' in sys.modules: + if sys.modules.get("PyQt6.QtCore"): + return QT_API_PYQT6 + elif sys.modules.get("PySide6.QtCore"): + return QT_API_PYSIDE6 + elif sys.modules.get("PyQt5.QtCore"): + return QT_API_PYQT5 + elif sys.modules.get("PySide2.QtCore"): + return QT_API_PYSIDE2 + elif sys.modules.get("PyQt4.QtCore"): if qtapi_version() == 2: return QT_API_PYQT else: return QT_API_PYQTv1 - elif 'PySide.QtCore' in sys.modules: + elif sys.modules.get("PySide.QtCore"): return QT_API_PYSIDE - elif 'PySide2.QtCore' in sys.modules: - return QT_API_PYSIDE2 - elif 'PyQt5.QtCore' in sys.modules: - return QT_API_PYQT5 + return None @@ -122,7 +131,7 @@ def has_binding(api): from importlib.util import find_spec required = ['QtCore', 'QtGui', 'QtSvg'] - if api in (QT_API_PYQT5, QT_API_PYSIDE2): + if api in (QT_API_PYQT5, QT_API_PYSIDE2, QT_API_PYQT6, QT_API_PYSIDE6): # QT5 requires QtWidgets too required.append('QtWidgets') @@ -174,7 +183,7 @@ def can_import(api): current = loaded_api() if api == QT_API_PYQT_DEFAULT: - return current in [QT_API_PYQT, QT_API_PYQTv1, None] + return current in [QT_API_PYQT6, None] else: return current in [api, None] @@ -224,7 +233,7 @@ def import_pyqt5(): """ from PyQt5 import QtCore, QtSvg, QtWidgets, QtGui - + # Alias PyQt-specific functions for PySide compatibility. QtCore.Signal = QtCore.pyqtSignal QtCore.Slot = QtCore.pyqtSlot @@ -238,6 +247,28 @@ def import_pyqt5(): return QtCore, QtGuiCompat, QtSvg, api +def import_pyqt6(): + """ + Import PyQt6 + + ImportErrors rasied within this function are non-recoverable + """ + + from PyQt6 import QtCore, QtSvg, QtWidgets, QtGui + + # Alias PyQt-specific functions for PySide compatibility. + QtCore.Signal = QtCore.pyqtSignal + QtCore.Slot = QtCore.pyqtSlot + + # Join QtGui and QtWidgets for Qt4 compatibility. + QtGuiCompat = types.ModuleType("QtGuiCompat") + QtGuiCompat.__dict__.update(QtGui.__dict__) + QtGuiCompat.__dict__.update(QtWidgets.__dict__) + + api = QT_API_PYQT6 + return QtCore, QtGuiCompat, QtSvg, api + + def import_pyside(): """ Import PySide @@ -264,6 +295,23 @@ def import_pyside2(): return QtCore, QtGuiCompat, QtSvg, QT_API_PYSIDE2 +def import_pyside6(): + """ + Import PySide6 + + ImportErrors raised within this function are non-recoverable + """ + from PySide6 import QtGui, QtCore, QtSvg, QtWidgets, QtPrintSupport + + # Join QtGui and QtWidgets for Qt4 compatibility. + QtGuiCompat = types.ModuleType("QtGuiCompat") + QtGuiCompat.__dict__.update(QtGui.__dict__) + QtGuiCompat.__dict__.update(QtWidgets.__dict__) + QtGuiCompat.__dict__.update(QtPrintSupport.__dict__) + + return QtCore, QtGuiCompat, QtSvg, QT_API_PYSIDE6 + + def load_qt(api_options): """ Attempt to import Qt, given a preference list @@ -291,13 +339,19 @@ def load_qt(api_options): an incompatible library has already been installed) """ loaders = { - QT_API_PYSIDE2: import_pyside2, - QT_API_PYSIDE: import_pyside, - QT_API_PYQT: import_pyqt4, - QT_API_PYQT5: import_pyqt5, - QT_API_PYQTv1: partial(import_pyqt4, version=1), - QT_API_PYQT_DEFAULT: partial(import_pyqt4, version=None) - } + # Qt6 + QT_API_PYQT6: import_pyqt6, + QT_API_PYSIDE6: import_pyside6, + # Qt5 + QT_API_PYQT5: import_pyqt5, + QT_API_PYSIDE2: import_pyside2, + # Qt4 + QT_API_PYSIDE: import_pyside, + QT_API_PYQT: import_pyqt4, + QT_API_PYQTv1: partial(import_pyqt4, version=1), + # default + QT_API_PYQT_DEFAULT: import_pyqt6, + } for api in api_options: @@ -332,3 +386,16 @@ def load_qt(api_options): has_binding(QT_API_PYSIDE), has_binding(QT_API_PYSIDE2), api_options)) + + +def enum_factory(QT_API, QtCore): + """Construct an enum helper to account for PyQt5 <-> PyQt6 changes.""" + + @lru_cache(None) + def _enum(name): + # foo.bar.Enum.Entry (PyQt6) <=> foo.bar.Entry (non-PyQt6). + return operator.attrgetter( + name if QT_API == QT_API_PYQT6 else name.rpartition(".")[0] + )(sys.modules[QtCore.__package__]) + + return _enum diff --git a/IPython/terminal/pt_inputhooks/__init__.py b/IPython/terminal/pt_inputhooks/__init__.py index c7ba58d..8917baf 100644 --- a/IPython/terminal/pt_inputhooks/__init__.py +++ b/IPython/terminal/pt_inputhooks/__init__.py @@ -7,13 +7,19 @@ aliases = { } backends = [ - 'qt', 'qt4', 'qt5', - 'gtk', 'gtk2', 'gtk3', - 'tk', - 'wx', - 'pyglet', 'glut', - 'osx', - 'asyncio' + "qt", + "qt4", + "qt5", + "qt6", + "gtk", + "gtk2", + "gtk3", + "tk", + "wx", + "pyglet", + "glut", + "osx", + "asyncio", ] registered = {} @@ -22,6 +28,7 @@ def register(name, inputhook): """Register the function *inputhook* as an event loop integration.""" registered[name] = inputhook + class UnknownBackend(KeyError): def __init__(self, name): self.name = name @@ -31,6 +38,7 @@ class UnknownBackend(KeyError): "Supported event loops are: {}").format(self.name, ', '.join(backends + sorted(registered))) + def get_inputhook_name_and_func(gui): if gui in registered: return gui, registered[gui] @@ -42,9 +50,12 @@ def get_inputhook_name_and_func(gui): return get_inputhook_name_and_func(aliases[gui]) gui_mod = gui - if gui == 'qt5': - os.environ['QT_API'] = 'pyqt5' - gui_mod = 'qt' + if gui == "qt5": + os.environ["QT_API"] = "pyqt5" + gui_mod = "qt" + elif gui == "qt6": + os.environ["QT_API"] = "pyqt6" + gui_mod = "qt" mod = importlib.import_module('IPython.terminal.pt_inputhooks.'+gui_mod) return gui, mod.inputhook diff --git a/IPython/terminal/pt_inputhooks/qt.py b/IPython/terminal/pt_inputhooks/qt.py index b7683b8..bc34f70 100644 --- a/IPython/terminal/pt_inputhooks/qt.py +++ b/IPython/terminal/pt_inputhooks/qt.py @@ -1,6 +1,6 @@ import sys import os -from IPython.external.qt_for_kernel import QtCore, QtGui +from IPython.external.qt_for_kernel import QtCore, QtGui, enum_helper from IPython import get_ipython # If we create a QApplication, keep a reference to it so that it doesn't get @@ -9,6 +9,11 @@ _appref = None _already_warned = False +def _exec(obj): + # exec on PyQt6, exec_ elsewhere. + obj.exec() if hasattr(obj, "exec") else obj.exec_() + + def _reclaim_excepthook(): shell = get_ipython() if shell is not None: @@ -32,7 +37,16 @@ def inputhook(context): 'variable. Deactivate Qt5 code.' ) return - QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling) + try: + QtCore.QApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling) + except AttributeError: # Only for Qt>=5.6, <6. + pass + try: + QtCore.QApplication.setHighDpiScaleFactorRoundingPolicy( + QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough + ) + except AttributeError: # Only for Qt>=5.14. + pass _appref = app = QtGui.QApplication([" "]) # "reclaim" IPython sys.excepthook after event loop starts @@ -55,8 +69,9 @@ def inputhook(context): else: # On POSIX platforms, we can use a file descriptor to quit the event # loop when there is input ready to read. - notifier = QtCore.QSocketNotifier(context.fileno(), - QtCore.QSocketNotifier.Read) + notifier = QtCore.QSocketNotifier( + context.fileno(), enum_helper("QtCore.QSocketNotifier.Type").Read + ) try: # connect the callback we care about before we turn it on # lambda is necessary as PyQT inspect the function signature to know @@ -65,6 +80,6 @@ def inputhook(context): notifier.setEnabled(True) # only start the event loop we are not already flipped if not context.input_is_ready(): - event_loop.exec_() + _exec(event_loop) finally: notifier.setEnabled(False)