##// 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 "wx": "WXAgg",
19 "wx": "WXAgg",
20 "qt4": "Qt4Agg",
20 "qt4": "Qt4Agg",
21 "qt5": "Qt5Agg",
21 "qt5": "Qt5Agg",
22 "qt6": "QtAgg",
22 "qt": "Qt5Agg",
23 "qt": "Qt5Agg",
23 "osx": "MacOSX",
24 "osx": "MacOSX",
24 "nbagg": "nbAgg",
25 "nbagg": "nbAgg",
@@ -32,15 +32,42 b' import os'
32 import sys
32 import sys
33
33
34 from IPython.utils.version import check_version
34 from IPython.utils.version import check_version
35 from IPython.external.qt_loaders import (load_qt, loaded_api, QT_API_PYSIDE,
35 from IPython.external.qt_loaders import (
36 QT_API_PYSIDE2, QT_API_PYQT, QT_API_PYQT5,
36 load_qt,
37 QT_API_PYQTv1, QT_API_PYQT_DEFAULT)
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 def matplotlib_options(mpl):
69 def matplotlib_options(mpl):
70 """Constraints placed on an imported matplotlib."""
44 if mpl is None:
71 if mpl is None:
45 return
72 return
46 backend = mpl.rcParams.get('backend', None)
73 backend = mpl.rcParams.get('backend', None)
@@ -66,9 +93,7 b' def matplotlib_options(mpl):'
66 mpqt)
93 mpqt)
67
94
68 def get_options():
95 def get_options():
69 """Return a list of acceptable QT APIs, in decreasing order of
96 """Return a list of acceptable QT APIs, in decreasing order of preference."""
70 preference
71 """
72 #already imported Qt somewhere. Use that
97 #already imported Qt somewhere. Use that
73 loaded = loaded_api()
98 loaded = loaded_api()
74 if loaded is not None:
99 if loaded is not None:
@@ -83,13 +108,22 b' def get_options():'
83 qt_api = os.environ.get('QT_API', None)
108 qt_api = os.environ.get('QT_API', None)
84 if qt_api is None:
109 if qt_api is None:
85 #no ETS variable. Ask mpl, then use default fallback path
110 #no ETS variable. Ask mpl, then use default fallback path
86 return matplotlib_options(mpl) or [QT_API_PYQT_DEFAULT, QT_API_PYSIDE,
111 return matplotlib_options(mpl) or [
87 QT_API_PYQT5, QT_API_PYSIDE2]
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 elif qt_api not in _qt_apis:
120 elif qt_api not in _qt_apis:
89 raise RuntimeError("Invalid Qt API %r, valid values are: %r" %
121 raise RuntimeError("Invalid Qt API %r, valid values are: %r" %
90 (qt_api, ', '.join(_qt_apis)))
122 (qt_api, ', '.join(_qt_apis)))
91 else:
123 else:
92 return [qt_api]
124 return [qt_api]
93
125
126
94 api_opts = get_options()
127 api_opts = get_options()
95 QtCore, QtGui, QtSvg, QT_API = load_qt(api_opts)
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 import sys
11 import sys
12 import types
12 import types
13 from functools import partial
13 from functools import partial, lru_cache
14 from importlib import import_module
14 import operator
15
15
16 from IPython.utils.version import check_version
16 from IPython.utils.version import check_version
17
17
18 # Available APIs.
18 # ### Available APIs.
19 QT_API_PYQT = 'pyqt' # Force version 2
19 # Qt6
20 QT_API_PYQT6 = "pyqt6"
21 QT_API_PYSIDE6 = "pyside6"
22
23 # Qt5
20 QT_API_PYQT5 = 'pyqt5'
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 QT_API_PYSIDE2 = 'pyside2'
25 QT_API_PYSIDE2 = 'pyside2'
25
26
26 api_to_module = {QT_API_PYSIDE2: 'PySide2',
27 # Qt4
27 QT_API_PYSIDE: 'PySide',
28 QT_API_PYQT = "pyqt" # Force version 2
28 QT_API_PYQT: 'PyQt4',
29 QT_API_PYQTv1 = "pyqtv1" # Force version 2
29 QT_API_PYQTv1: 'PyQt4',
30 QT_API_PYSIDE = "pyside"
30 QT_API_PYQT5: 'PyQt5',
31
31 QT_API_PYQT_DEFAULT: 'PyQt4',
32 QT_API_PYQT_DEFAULT = "pyqtdefault" # use system default for version 1 vs. 2
32 }
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 class ImportDenier(object):
50 class ImportDenier(object):
@@ -56,6 +71,7 b' class ImportDenier(object):'
56 already imported an Incompatible QT Binding: %s
71 already imported an Incompatible QT Binding: %s
57 """ % (fullname, loaded_api()))
72 """ % (fullname, loaded_api()))
58
73
74
59 ID = ImportDenier()
75 ID = ImportDenier()
60 sys.meta_path.insert(0, ID)
76 sys.meta_path.insert(0, ID)
61
77
@@ -63,23 +79,11 b' sys.meta_path.insert(0, ID)'
63 def commit_api(api):
79 def commit_api(api):
64 """Commit to a particular API, and trigger ImportErrors on subsequent
80 """Commit to a particular API, and trigger ImportErrors on subsequent
65 dangerous imports"""
81 dangerous imports"""
82 modules = set(api_to_module.values())
66
83
67 if api == QT_API_PYSIDE2:
84 modules.remove(api_to_module[api])
68 ID.forbid('PySide')
85 for mod in modules:
69 ID.forbid('PyQt4')
86 ID.forbid(mod)
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')
83
87
84
88
85 def loaded_api():
89 def loaded_api():
@@ -90,19 +94,24 b' def loaded_api():'
90
94
91 Returns
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 if qtapi_version() == 2:
108 if qtapi_version() == 2:
97 return QT_API_PYQT
109 return QT_API_PYQT
98 else:
110 else:
99 return QT_API_PYQTv1
111 return QT_API_PYQTv1
100 elif 'PySide.QtCore' in sys.modules:
112 elif sys.modules.get("PySide.QtCore"):
101 return QT_API_PYSIDE
113 return QT_API_PYSIDE
102 elif 'PySide2.QtCore' in sys.modules:
114
103 return QT_API_PYSIDE2
104 elif 'PyQt5.QtCore' in sys.modules:
105 return QT_API_PYQT5
106 return None
115 return None
107
116
108
117
@@ -122,7 +131,7 b' def has_binding(api):'
122 from importlib.util import find_spec
131 from importlib.util import find_spec
123
132
124 required = ['QtCore', 'QtGui', 'QtSvg']
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 # QT5 requires QtWidgets too
135 # QT5 requires QtWidgets too
127 required.append('QtWidgets')
136 required.append('QtWidgets')
128
137
@@ -174,7 +183,7 b' def can_import(api):'
174
183
175 current = loaded_api()
184 current = loaded_api()
176 if api == QT_API_PYQT_DEFAULT:
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 else:
187 else:
179 return current in [api, None]
188 return current in [api, None]
180
189
@@ -224,7 +233,7 b' def import_pyqt5():'
224 """
233 """
225
234
226 from PyQt5 import QtCore, QtSvg, QtWidgets, QtGui
235 from PyQt5 import QtCore, QtSvg, QtWidgets, QtGui
227
236
228 # Alias PyQt-specific functions for PySide compatibility.
237 # Alias PyQt-specific functions for PySide compatibility.
229 QtCore.Signal = QtCore.pyqtSignal
238 QtCore.Signal = QtCore.pyqtSignal
230 QtCore.Slot = QtCore.pyqtSlot
239 QtCore.Slot = QtCore.pyqtSlot
@@ -238,6 +247,28 b' def import_pyqt5():'
238 return QtCore, QtGuiCompat, QtSvg, api
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 def import_pyside():
272 def import_pyside():
242 """
273 """
243 Import PySide
274 Import PySide
@@ -264,6 +295,23 b' def import_pyside2():'
264 return QtCore, QtGuiCompat, QtSvg, QT_API_PYSIDE2
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 def load_qt(api_options):
315 def load_qt(api_options):
268 """
316 """
269 Attempt to import Qt, given a preference list
317 Attempt to import Qt, given a preference list
@@ -291,13 +339,19 b' def load_qt(api_options):'
291 an incompatible library has already been installed)
339 an incompatible library has already been installed)
292 """
340 """
293 loaders = {
341 loaders = {
294 QT_API_PYSIDE2: import_pyside2,
342 # Qt6
295 QT_API_PYSIDE: import_pyside,
343 QT_API_PYQT6: import_pyqt6,
296 QT_API_PYQT: import_pyqt4,
344 QT_API_PYSIDE6: import_pyside6,
297 QT_API_PYQT5: import_pyqt5,
345 # Qt5
298 QT_API_PYQTv1: partial(import_pyqt4, version=1),
346 QT_API_PYQT5: import_pyqt5,
299 QT_API_PYQT_DEFAULT: partial(import_pyqt4, version=None)
347 QT_API_PYSIDE2: import_pyside2,
300 }
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 for api in api_options:
356 for api in api_options:
303
357
@@ -332,3 +386,16 b' def load_qt(api_options):'
332 has_binding(QT_API_PYSIDE),
386 has_binding(QT_API_PYSIDE),
333 has_binding(QT_API_PYSIDE2),
387 has_binding(QT_API_PYSIDE2),
334 api_options))
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 backends = [
9 backends = [
10 'qt', 'qt4', 'qt5',
10 "qt",
11 'gtk', 'gtk2', 'gtk3',
11 "qt4",
12 'tk',
12 "qt5",
13 'wx',
13 "qt6",
14 'pyglet', 'glut',
14 "gtk",
15 'osx',
15 "gtk2",
16 'asyncio'
16 "gtk3",
17 "tk",
18 "wx",
19 "pyglet",
20 "glut",
21 "osx",
22 "asyncio",
17 ]
23 ]
18
24
19 registered = {}
25 registered = {}
@@ -22,6 +28,7 b' def register(name, inputhook):'
22 """Register the function *inputhook* as an event loop integration."""
28 """Register the function *inputhook* as an event loop integration."""
23 registered[name] = inputhook
29 registered[name] = inputhook
24
30
31
25 class UnknownBackend(KeyError):
32 class UnknownBackend(KeyError):
26 def __init__(self, name):
33 def __init__(self, name):
27 self.name = name
34 self.name = name
@@ -31,6 +38,7 b' class UnknownBackend(KeyError):'
31 "Supported event loops are: {}").format(self.name,
38 "Supported event loops are: {}").format(self.name,
32 ', '.join(backends + sorted(registered)))
39 ', '.join(backends + sorted(registered)))
33
40
41
34 def get_inputhook_name_and_func(gui):
42 def get_inputhook_name_and_func(gui):
35 if gui in registered:
43 if gui in registered:
36 return gui, registered[gui]
44 return gui, registered[gui]
@@ -42,9 +50,12 b' def get_inputhook_name_and_func(gui):'
42 return get_inputhook_name_and_func(aliases[gui])
50 return get_inputhook_name_and_func(aliases[gui])
43
51
44 gui_mod = gui
52 gui_mod = gui
45 if gui == 'qt5':
53 if gui == "qt5":
46 os.environ['QT_API'] = 'pyqt5'
54 os.environ["QT_API"] = "pyqt5"
47 gui_mod = 'qt'
55 gui_mod = "qt"
56 elif gui == "qt6":
57 os.environ["QT_API"] = "pyqt6"
58 gui_mod = "qt"
48
59
49 mod = importlib.import_module('IPython.terminal.pt_inputhooks.'+gui_mod)
60 mod = importlib.import_module('IPython.terminal.pt_inputhooks.'+gui_mod)
50 return gui, mod.inputhook
61 return gui, mod.inputhook
@@ -1,6 +1,6 b''
1 import sys
1 import sys
2 import os
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 from IPython import get_ipython
4 from IPython import get_ipython
5
5
6 # If we create a QApplication, keep a reference to it so that it doesn't get
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 _already_warned = False
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 def _reclaim_excepthook():
17 def _reclaim_excepthook():
13 shell = get_ipython()
18 shell = get_ipython()
14 if shell is not None:
19 if shell is not None:
@@ -32,7 +37,16 b' def inputhook(context):'
32 'variable. Deactivate Qt5 code.'
37 'variable. Deactivate Qt5 code.'
33 )
38 )
34 return
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 _appref = app = QtGui.QApplication([" "])
50 _appref = app = QtGui.QApplication([" "])
37
51
38 # "reclaim" IPython sys.excepthook after event loop starts
52 # "reclaim" IPython sys.excepthook after event loop starts
@@ -55,14 +69,15 b' def inputhook(context):'
55 else:
69 else:
56 # On POSIX platforms, we can use a file descriptor to quit the event
70 # On POSIX platforms, we can use a file descriptor to quit the event
57 # loop when there is input ready to read.
71 # loop when there is input ready to read.
58 notifier = QtCore.QSocketNotifier(context.fileno(),
72 notifier = QtCore.QSocketNotifier(
59 QtCore.QSocketNotifier.Read)
73 context.fileno(), enum_helper("QtCore.QSocketNotifier.Type").Read
74 )
60 try:
75 try:
61 # connect the callback we care about before we turn it on
76 # connect the callback we care about before we turn it on
62 notifier.activated.connect(lambda: event_loop.exit())
77 notifier.activated.connect(lambda: event_loop.exit())
63 notifier.setEnabled(True)
78 notifier.setEnabled(True)
64 # only start the event loop we are not already flipped
79 # only start the event loop we are not already flipped
65 if not context.input_is_ready():
80 if not context.input_is_ready():
66 event_loop.exec_()
81 _exec(event_loop)
67 finally:
82 finally:
68 notifier.setEnabled(False)
83 notifier.setEnabled(False)
General Comments 0
You need to be logged in to leave comments. Login now