##// END OF EJS Templates
Backport PR #13085: ENH: add support for Qt6 input hooks
Matthias Bussonnier -
Show More
@@ -19,6 +19,7 b' backends = {'
19 19 "wx": "WXAgg",
20 20 "qt4": "Qt4Agg",
21 21 "qt5": "Qt5Agg",
22 "qt6": "QtAgg",
22 23 "qt": "Qt5Agg",
23 24 "osx": "MacOSX",
24 25 "nbagg": "nbAgg",
@@ -32,15 +32,42 b' import os'
32 32 import sys
33 33
34 34 from IPython.utils.version import check_version
35 from IPython.external.qt_loaders import (load_qt, loaded_api, QT_API_PYSIDE,
36 QT_API_PYSIDE2, QT_API_PYQT, QT_API_PYQT5,
37 QT_API_PYQTv1, QT_API_PYQT_DEFAULT)
35 from IPython.external.qt_loaders import (
36 load_qt,
37 loaded_api,
38 enum_factory,
39 # QT6
40 QT_API_PYQT6,
41 QT_API_PYSIDE6,
42 # QT5
43 QT_API_PYQT5,
44 QT_API_PYSIDE2,
45 # QT4
46 QT_API_PYQTv1,
47 QT_API_PYQT,
48 QT_API_PYSIDE,
49 # default
50 QT_API_PYQT_DEFAULT,
51 )
52
53 _qt_apis = (
54 # QT6
55 QT_API_PYQT6,
56 QT_API_PYSIDE6,
57 # QT5
58 QT_API_PYQT5,
59 QT_API_PYSIDE2,
60 # QT4
61 QT_API_PYQTv1,
62 QT_API_PYQT,
63 QT_API_PYSIDE,
64 # default
65 QT_API_PYQT_DEFAULT,
66 )
38 67
39 _qt_apis = (QT_API_PYSIDE, QT_API_PYSIDE2, QT_API_PYQT, QT_API_PYQT5, QT_API_PYQTv1,
40 QT_API_PYQT_DEFAULT)
41 68
42 #Constraints placed on an imported matplotlib
43 69 def matplotlib_options(mpl):
70 """Constraints placed on an imported matplotlib."""
44 71 if mpl is None:
45 72 return
46 73 backend = mpl.rcParams.get('backend', None)
@@ -66,9 +93,7 b' def matplotlib_options(mpl):'
66 93 mpqt)
67 94
68 95 def get_options():
69 """Return a list of acceptable QT APIs, in decreasing order of
70 preference
71 """
96 """Return a list of acceptable QT APIs, in decreasing order of preference."""
72 97 #already imported Qt somewhere. Use that
73 98 loaded = loaded_api()
74 99 if loaded is not None:
@@ -83,13 +108,22 b' def get_options():'
83 108 qt_api = os.environ.get('QT_API', None)
84 109 if qt_api is None:
85 110 #no ETS variable. Ask mpl, then use default fallback path
86 return matplotlib_options(mpl) or [QT_API_PYQT_DEFAULT, QT_API_PYSIDE,
87 QT_API_PYQT5, QT_API_PYSIDE2]
111 return matplotlib_options(mpl) or [
112 QT_API_PYQT_DEFAULT,
113 QT_API_PYQT6,
114 QT_API_PYSIDE6,
115 QT_API_PYQT5,
116 QT_API_PYSIDE2,
117 QT_API_PYQT,
118 QT_API_PYSIDE,
119 ]
88 120 elif qt_api not in _qt_apis:
89 121 raise RuntimeError("Invalid Qt API %r, valid values are: %r" %
90 122 (qt_api, ', '.join(_qt_apis)))
91 123 else:
92 124 return [qt_api]
93 125
126
94 127 api_opts = get_options()
95 128 QtCore, QtGui, QtSvg, QT_API = load_qt(api_opts)
129 enum_helper = enum_factory(QT_API, QtCore)
@@ -10,26 +10,41 b' be accessed directly from the outside'
10 10 """
11 11 import sys
12 12 import types
13 from functools import partial
14 from importlib import import_module
13 from functools import partial, lru_cache
14 import operator
15 15
16 16 from IPython.utils.version import check_version
17 17
18 # Available APIs.
19 QT_API_PYQT = 'pyqt' # Force version 2
18 # ### Available APIs.
19 # Qt6
20 QT_API_PYQT6 = "pyqt6"
21 QT_API_PYSIDE6 = "pyside6"
22
23 # Qt5
20 24 QT_API_PYQT5 = 'pyqt5'
21 QT_API_PYQTv1 = 'pyqtv1' # Force version 2
22 QT_API_PYQT_DEFAULT = 'pyqtdefault' # use system default for version 1 vs. 2
23 QT_API_PYSIDE = 'pyside'
24 25 QT_API_PYSIDE2 = 'pyside2'
25 26
26 api_to_module = {QT_API_PYSIDE2: 'PySide2',
27 QT_API_PYSIDE: 'PySide',
28 QT_API_PYQT: 'PyQt4',
29 QT_API_PYQTv1: 'PyQt4',
30 QT_API_PYQT5: 'PyQt5',
31 QT_API_PYQT_DEFAULT: 'PyQt4',
32 }
27 # Qt4
28 QT_API_PYQT = "pyqt" # Force version 2
29 QT_API_PYQTv1 = "pyqtv1" # Force version 2
30 QT_API_PYSIDE = "pyside"
31
32 QT_API_PYQT_DEFAULT = "pyqtdefault" # use system default for version 1 vs. 2
33
34 api_to_module = {
35 # Qt6
36 QT_API_PYQT6: "PyQt6",
37 QT_API_PYSIDE6: "PySide6",
38 # Qt5
39 QT_API_PYQT5: "PyQt5",
40 QT_API_PYSIDE2: "PySide2",
41 # Qt4
42 QT_API_PYSIDE: "PySide",
43 QT_API_PYQT: "PyQt4",
44 QT_API_PYQTv1: "PyQt4",
45 # default
46 QT_API_PYQT_DEFAULT: "PyQt6",
47 }
33 48
34 49
35 50 class ImportDenier(object):
@@ -56,6 +71,7 b' class ImportDenier(object):'
56 71 already imported an Incompatible QT Binding: %s
57 72 """ % (fullname, loaded_api()))
58 73
74
59 75 ID = ImportDenier()
60 76 sys.meta_path.insert(0, ID)
61 77
@@ -63,23 +79,11 b' sys.meta_path.insert(0, ID)'
63 79 def commit_api(api):
64 80 """Commit to a particular API, and trigger ImportErrors on subsequent
65 81 dangerous imports"""
82 modules = set(api_to_module.values())
66 83
67 if api == QT_API_PYSIDE2:
68 ID.forbid('PySide')
69 ID.forbid('PyQt4')
70 ID.forbid('PyQt5')
71 elif api == QT_API_PYSIDE:
72 ID.forbid('PySide2')
73 ID.forbid('PyQt4')
74 ID.forbid('PyQt5')
75 elif api == QT_API_PYQT5:
76 ID.forbid('PySide2')
77 ID.forbid('PySide')
78 ID.forbid('PyQt4')
79 else: # There are three other possibilities, all representing PyQt4
80 ID.forbid('PyQt5')
81 ID.forbid('PySide2')
82 ID.forbid('PySide')
84 modules.remove(api_to_module[api])
85 for mod in modules:
86 ID.forbid(mod)
83 87
84 88
85 89 def loaded_api():
@@ -90,19 +94,24 b' def loaded_api():'
90 94
91 95 Returns
92 96 -------
93 None, 'pyside2', 'pyside', 'pyqt', 'pyqt5', or 'pyqtv1'
97 None, 'pyside6', 'pyqt6', 'pyside2', 'pyside', 'pyqt', 'pyqt5', 'pyqtv1'
94 98 """
95 if 'PyQt4.QtCore' in sys.modules:
99 if sys.modules.get("PyQt6.QtCore"):
100 return QT_API_PYQT6
101 elif sys.modules.get("PySide6.QtCore"):
102 return QT_API_PYSIDE6
103 elif sys.modules.get("PyQt5.QtCore"):
104 return QT_API_PYQT5
105 elif sys.modules.get("PySide2.QtCore"):
106 return QT_API_PYSIDE2
107 elif sys.modules.get("PyQt4.QtCore"):
96 108 if qtapi_version() == 2:
97 109 return QT_API_PYQT
98 110 else:
99 111 return QT_API_PYQTv1
100 elif 'PySide.QtCore' in sys.modules:
112 elif sys.modules.get("PySide.QtCore"):
101 113 return QT_API_PYSIDE
102 elif 'PySide2.QtCore' in sys.modules:
103 return QT_API_PYSIDE2
104 elif 'PyQt5.QtCore' in sys.modules:
105 return QT_API_PYQT5
114
106 115 return None
107 116
108 117
@@ -122,7 +131,7 b' def has_binding(api):'
122 131 from importlib.util import find_spec
123 132
124 133 required = ['QtCore', 'QtGui', 'QtSvg']
125 if api in (QT_API_PYQT5, QT_API_PYSIDE2):
134 if api in (QT_API_PYQT5, QT_API_PYSIDE2, QT_API_PYQT6, QT_API_PYSIDE6):
126 135 # QT5 requires QtWidgets too
127 136 required.append('QtWidgets')
128 137
@@ -174,7 +183,7 b' def can_import(api):'
174 183
175 184 current = loaded_api()
176 185 if api == QT_API_PYQT_DEFAULT:
177 return current in [QT_API_PYQT, QT_API_PYQTv1, None]
186 return current in [QT_API_PYQT6, None]
178 187 else:
179 188 return current in [api, None]
180 189
@@ -224,7 +233,7 b' def import_pyqt5():'
224 233 """
225 234
226 235 from PyQt5 import QtCore, QtSvg, QtWidgets, QtGui
227
236
228 237 # Alias PyQt-specific functions for PySide compatibility.
229 238 QtCore.Signal = QtCore.pyqtSignal
230 239 QtCore.Slot = QtCore.pyqtSlot
@@ -238,6 +247,28 b' def import_pyqt5():'
238 247 return QtCore, QtGuiCompat, QtSvg, api
239 248
240 249
250 def import_pyqt6():
251 """
252 Import PyQt6
253
254 ImportErrors rasied within this function are non-recoverable
255 """
256
257 from PyQt6 import QtCore, QtSvg, QtWidgets, QtGui
258
259 # Alias PyQt-specific functions for PySide compatibility.
260 QtCore.Signal = QtCore.pyqtSignal
261 QtCore.Slot = QtCore.pyqtSlot
262
263 # Join QtGui and QtWidgets for Qt4 compatibility.
264 QtGuiCompat = types.ModuleType("QtGuiCompat")
265 QtGuiCompat.__dict__.update(QtGui.__dict__)
266 QtGuiCompat.__dict__.update(QtWidgets.__dict__)
267
268 api = QT_API_PYQT6
269 return QtCore, QtGuiCompat, QtSvg, api
270
271
241 272 def import_pyside():
242 273 """
243 274 Import PySide
@@ -264,6 +295,23 b' def import_pyside2():'
264 295 return QtCore, QtGuiCompat, QtSvg, QT_API_PYSIDE2
265 296
266 297
298 def import_pyside6():
299 """
300 Import PySide6
301
302 ImportErrors raised within this function are non-recoverable
303 """
304 from PySide6 import QtGui, QtCore, QtSvg, QtWidgets, QtPrintSupport
305
306 # Join QtGui and QtWidgets for Qt4 compatibility.
307 QtGuiCompat = types.ModuleType("QtGuiCompat")
308 QtGuiCompat.__dict__.update(QtGui.__dict__)
309 QtGuiCompat.__dict__.update(QtWidgets.__dict__)
310 QtGuiCompat.__dict__.update(QtPrintSupport.__dict__)
311
312 return QtCore, QtGuiCompat, QtSvg, QT_API_PYSIDE6
313
314
267 315 def load_qt(api_options):
268 316 """
269 317 Attempt to import Qt, given a preference list
@@ -291,13 +339,19 b' def load_qt(api_options):'
291 339 an incompatible library has already been installed)
292 340 """
293 341 loaders = {
294 QT_API_PYSIDE2: import_pyside2,
295 QT_API_PYSIDE: import_pyside,
296 QT_API_PYQT: import_pyqt4,
297 QT_API_PYQT5: import_pyqt5,
298 QT_API_PYQTv1: partial(import_pyqt4, version=1),
299 QT_API_PYQT_DEFAULT: partial(import_pyqt4, version=None)
300 }
342 # Qt6
343 QT_API_PYQT6: import_pyqt6,
344 QT_API_PYSIDE6: import_pyside6,
345 # Qt5
346 QT_API_PYQT5: import_pyqt5,
347 QT_API_PYSIDE2: import_pyside2,
348 # Qt4
349 QT_API_PYSIDE: import_pyside,
350 QT_API_PYQT: import_pyqt4,
351 QT_API_PYQTv1: partial(import_pyqt4, version=1),
352 # default
353 QT_API_PYQT_DEFAULT: import_pyqt6,
354 }
301 355
302 356 for api in api_options:
303 357
@@ -332,3 +386,16 b' def load_qt(api_options):'
332 386 has_binding(QT_API_PYSIDE),
333 387 has_binding(QT_API_PYSIDE2),
334 388 api_options))
389
390
391 def enum_factory(QT_API, QtCore):
392 """Construct an enum helper to account for PyQt5 <-> PyQt6 changes."""
393
394 @lru_cache(None)
395 def _enum(name):
396 # foo.bar.Enum.Entry (PyQt6) <=> foo.bar.Entry (non-PyQt6).
397 return operator.attrgetter(
398 name if QT_API == QT_API_PYQT6 else name.rpartition(".")[0]
399 )(sys.modules[QtCore.__package__])
400
401 return _enum
@@ -7,13 +7,19 b' aliases = {'
7 7 }
8 8
9 9 backends = [
10 'qt', 'qt4', 'qt5',
11 'gtk', 'gtk2', 'gtk3',
12 'tk',
13 'wx',
14 'pyglet', 'glut',
15 'osx',
16 'asyncio'
10 "qt",
11 "qt4",
12 "qt5",
13 "qt6",
14 "gtk",
15 "gtk2",
16 "gtk3",
17 "tk",
18 "wx",
19 "pyglet",
20 "glut",
21 "osx",
22 "asyncio",
17 23 ]
18 24
19 25 registered = {}
@@ -22,6 +28,7 b' def register(name, inputhook):'
22 28 """Register the function *inputhook* as an event loop integration."""
23 29 registered[name] = inputhook
24 30
31
25 32 class UnknownBackend(KeyError):
26 33 def __init__(self, name):
27 34 self.name = name
@@ -31,6 +38,7 b' class UnknownBackend(KeyError):'
31 38 "Supported event loops are: {}").format(self.name,
32 39 ', '.join(backends + sorted(registered)))
33 40
41
34 42 def get_inputhook_name_and_func(gui):
35 43 if gui in registered:
36 44 return gui, registered[gui]
@@ -42,9 +50,12 b' def get_inputhook_name_and_func(gui):'
42 50 return get_inputhook_name_and_func(aliases[gui])
43 51
44 52 gui_mod = gui
45 if gui == 'qt5':
46 os.environ['QT_API'] = 'pyqt5'
47 gui_mod = 'qt'
53 if gui == "qt5":
54 os.environ["QT_API"] = "pyqt5"
55 gui_mod = "qt"
56 elif gui == "qt6":
57 os.environ["QT_API"] = "pyqt6"
58 gui_mod = "qt"
48 59
49 60 mod = importlib.import_module('IPython.terminal.pt_inputhooks.'+gui_mod)
50 61 return gui, mod.inputhook
@@ -1,6 +1,6 b''
1 1 import sys
2 2 import os
3 from IPython.external.qt_for_kernel import QtCore, QtGui
3 from IPython.external.qt_for_kernel import QtCore, QtGui, enum_helper
4 4 from IPython import get_ipython
5 5
6 6 # If we create a QApplication, keep a reference to it so that it doesn't get
@@ -9,6 +9,11 b' _appref = None'
9 9 _already_warned = False
10 10
11 11
12 def _exec(obj):
13 # exec on PyQt6, exec_ elsewhere.
14 obj.exec() if hasattr(obj, "exec") else obj.exec_()
15
16
12 17 def _reclaim_excepthook():
13 18 shell = get_ipython()
14 19 if shell is not None:
@@ -32,7 +37,16 b' def inputhook(context):'
32 37 'variable. Deactivate Qt5 code.'
33 38 )
34 39 return
35 QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling)
40 try:
41 QtCore.QApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling)
42 except AttributeError: # Only for Qt>=5.6, <6.
43 pass
44 try:
45 QtCore.QApplication.setHighDpiScaleFactorRoundingPolicy(
46 QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
47 )
48 except AttributeError: # Only for Qt>=5.14.
49 pass
36 50 _appref = app = QtGui.QApplication([" "])
37 51
38 52 # "reclaim" IPython sys.excepthook after event loop starts
@@ -55,14 +69,15 b' def inputhook(context):'
55 69 else:
56 70 # On POSIX platforms, we can use a file descriptor to quit the event
57 71 # loop when there is input ready to read.
58 notifier = QtCore.QSocketNotifier(context.fileno(),
59 QtCore.QSocketNotifier.Read)
72 notifier = QtCore.QSocketNotifier(
73 context.fileno(), enum_helper("QtCore.QSocketNotifier.Type").Read
74 )
60 75 try:
61 76 # connect the callback we care about before we turn it on
62 77 notifier.activated.connect(lambda: event_loop.exit())
63 78 notifier.setEnabled(True)
64 79 # only start the event loop we are not already flipped
65 80 if not context.input_is_ready():
66 event_loop.exec_()
81 _exec(event_loop)
67 82 finally:
68 83 notifier.setEnabled(False)
General Comments 0
You need to be logged in to leave comments. Login now