From 459330fd4e01c185fd53f9189318d6d25933b51f 2021-08-06 22:45:43
From: Thomas A Caswell <tcaswell@gmail.com>
Date: 2021-08-06 22:45:43
Subject: [PATCH] ENH: add support for Qt6 input hooks

This is heavily cribbed from Matplotlib's Qt6 support.

---

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..8926cac 100644
--- a/IPython/external/qt_for_kernel.py
+++ b/IPython/external/qt_for_kernel.py
@@ -32,15 +32,32 @@ 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 +83,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 +98,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..14ce409 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
@@ -237,6 +246,27 @@ def import_pyqt5():
     api = QT_API_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():
     """
@@ -263,6 +293,22 @@ 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):
     """
@@ -291,13 +337,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 +384,15 @@ 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..095881c 100644
--- a/IPython/terminal/pt_inputhooks/__init__.py
+++ b/IPython/terminal/pt_inputhooks/__init__.py
@@ -7,7 +7,7 @@ aliases = {
 }
 
 backends = [
-    'qt', 'qt4', 'qt5',
+    'qt', 'qt4', 'qt5', 'qt6',
     'gtk', 'gtk2', 'gtk3',
     'tk',
     'wx',
@@ -22,6 +22,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 +32,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]
@@ -45,6 +47,9 @@ def get_inputhook_name_and_func(gui):
     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..6e9b120 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,10 @@ 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 +81,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)