##// END OF EJS Templates
ENH: add support for Qt6 input hooks...
Thomas A Caswell -
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,32 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, loaded_api, enum_factory,
37 QT_API_PYQTv1, QT_API_PYQT_DEFAULT)
37 # QT6
38 QT_API_PYQT6, QT_API_PYSIDE6,
39 # QT5
40 QT_API_PYQT5, QT_API_PYSIDE2,
41 # QT4
42 QT_API_PYQTv1, QT_API_PYQT, QT_API_PYSIDE,
43 # default
44 QT_API_PYQT_DEFAULT
45 )
46
47 _qt_apis = (
48 # QT6
49 QT_API_PYQT6, QT_API_PYSIDE6,
50 # QT5
51 QT_API_PYQT5, QT_API_PYSIDE2,
52 # QT4
53 QT_API_PYQTv1, QT_API_PYQT, QT_API_PYSIDE,
54 # default
55 QT_API_PYQT_DEFAULT
56 )
38
57
39 _qt_apis = (QT_API_PYSIDE, QT_API_PYSIDE2, QT_API_PYQT, QT_API_PYQT5, QT_API_PYQTv1,
40 QT_API_PYQT_DEFAULT)
41
58
42 #Constraints placed on an imported matplotlib
43 def matplotlib_options(mpl):
59 def matplotlib_options(mpl):
60 """Constraints placed on an imported matplotlib."""
44 if mpl is None:
61 if mpl is None:
45 return
62 return
46 backend = mpl.rcParams.get('backend', None)
63 backend = mpl.rcParams.get('backend', None)
@@ -66,9 +83,7 b' def matplotlib_options(mpl):'
66 mpqt)
83 mpqt)
67
84
68 def get_options():
85 def get_options():
69 """Return a list of acceptable QT APIs, in decreasing order of
86 """Return a list of acceptable QT APIs, in decreasing order of preference."""
70 preference
71 """
72 #already imported Qt somewhere. Use that
87 #already imported Qt somewhere. Use that
73 loaded = loaded_api()
88 loaded = loaded_api()
74 if loaded is not None:
89 if loaded is not None:
@@ -83,13 +98,22 b' def get_options():'
83 qt_api = os.environ.get('QT_API', None)
98 qt_api = os.environ.get('QT_API', None)
84 if qt_api is None:
99 if qt_api is None:
85 #no ETS variable. Ask mpl, then use default fallback path
100 #no ETS variable. Ask mpl, then use default fallback path
86 return matplotlib_options(mpl) or [QT_API_PYQT_DEFAULT, QT_API_PYSIDE,
101 return matplotlib_options(mpl) or [
87 QT_API_PYQT5, QT_API_PYSIDE2]
102 QT_API_PYQT_DEFAULT,
103 QT_API_PYQT6,
104 QT_API_PYSIDE6,
105 QT_API_PYQT5,
106 QT_API_PYSIDE2,
107 QT_API_PYQT,
108 QT_API_PYSIDE
109 ]
88 elif qt_api not in _qt_apis:
110 elif qt_api not in _qt_apis:
89 raise RuntimeError("Invalid Qt API %r, valid values are: %r" %
111 raise RuntimeError("Invalid Qt API %r, valid values are: %r" %
90 (qt_api, ', '.join(_qt_apis)))
112 (qt_api, ', '.join(_qt_apis)))
91 else:
113 else:
92 return [qt_api]
114 return [qt_api]
93
115
116
94 api_opts = get_options()
117 api_opts = get_options()
95 QtCore, QtGui, QtSvg, QT_API = load_qt(api_opts)
118 QtCore, QtGui, QtSvg, QT_API = load_qt(api_opts)
119 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
@@ -237,6 +246,27 b' def import_pyqt5():'
237 api = QT_API_PYQT5
246 api = QT_API_PYQT5
238 return QtCore, QtGuiCompat, QtSvg, api
247 return QtCore, QtGuiCompat, QtSvg, api
239
248
249 def import_pyqt6():
250 """
251 Import PyQt6
252
253 ImportErrors rasied within this function are non-recoverable
254 """
255
256 from PyQt6 import QtCore, QtSvg, QtWidgets, QtGui
257
258 # Alias PyQt-specific functions for PySide compatibility.
259 QtCore.Signal = QtCore.pyqtSignal
260 QtCore.Slot = QtCore.pyqtSlot
261
262 # Join QtGui and QtWidgets for Qt4 compatibility.
263 QtGuiCompat = types.ModuleType('QtGuiCompat')
264 QtGuiCompat.__dict__.update(QtGui.__dict__)
265 QtGuiCompat.__dict__.update(QtWidgets.__dict__)
266
267 api = QT_API_PYQT6
268 return QtCore, QtGuiCompat, QtSvg, api
269
240
270
241 def import_pyside():
271 def import_pyside():
242 """
272 """
@@ -263,6 +293,22 b' def import_pyside2():'
263
293
264 return QtCore, QtGuiCompat, QtSvg, QT_API_PYSIDE2
294 return QtCore, QtGuiCompat, QtSvg, QT_API_PYSIDE2
265
295
296 def import_pyside6():
297 """
298 Import PySide6
299
300 ImportErrors raised within this function are non-recoverable
301 """
302 from PySide6 import QtGui, QtCore, QtSvg, QtWidgets, QtPrintSupport
303
304 # Join QtGui and QtWidgets for Qt4 compatibility.
305 QtGuiCompat = types.ModuleType('QtGuiCompat')
306 QtGuiCompat.__dict__.update(QtGui.__dict__)
307 QtGuiCompat.__dict__.update(QtWidgets.__dict__)
308 QtGuiCompat.__dict__.update(QtPrintSupport.__dict__)
309
310 return QtCore, QtGuiCompat, QtSvg, QT_API_PYSIDE6
311
266
312
267 def load_qt(api_options):
313 def load_qt(api_options):
268 """
314 """
@@ -291,13 +337,19 b' def load_qt(api_options):'
291 an incompatible library has already been installed)
337 an incompatible library has already been installed)
292 """
338 """
293 loaders = {
339 loaders = {
294 QT_API_PYSIDE2: import_pyside2,
340 # Qt6
295 QT_API_PYSIDE: import_pyside,
341 QT_API_PYQT6: import_pyqt6,
296 QT_API_PYQT: import_pyqt4,
342 QT_API_PYSIDE6: import_pyside6,
297 QT_API_PYQT5: import_pyqt5,
343 # Qt5
298 QT_API_PYQTv1: partial(import_pyqt4, version=1),
344 QT_API_PYQT5: import_pyqt5,
299 QT_API_PYQT_DEFAULT: partial(import_pyqt4, version=None)
345 QT_API_PYSIDE2: import_pyside2,
300 }
346 # Qt4
347 QT_API_PYSIDE: import_pyside,
348 QT_API_PYQT: import_pyqt4,
349 QT_API_PYQTv1: partial(import_pyqt4, version=1),
350 # default
351 QT_API_PYQT_DEFAULT: import_pyqt6,
352 }
301
353
302 for api in api_options:
354 for api in api_options:
303
355
@@ -332,3 +384,15 b' def load_qt(api_options):'
332 has_binding(QT_API_PYSIDE),
384 has_binding(QT_API_PYSIDE),
333 has_binding(QT_API_PYSIDE2),
385 has_binding(QT_API_PYSIDE2),
334 api_options))
386 api_options))
387
388
389 def enum_factory(QT_API, QtCore):
390 """Construct an enum helper to account for PyQt5 <-> PyQt6 changes."""
391 @lru_cache(None)
392 def _enum(name):
393 # foo.bar.Enum.Entry (PyQt6) <=> foo.bar.Entry (non-PyQt6).
394 return operator.attrgetter(
395 name if QT_API == QT_API_PYQT6 else name.rpartition(".")[0]
396 )(sys.modules[QtCore.__package__])
397
398 return _enum
@@ -7,7 +7,7 b' aliases = {'
7 }
7 }
8
8
9 backends = [
9 backends = [
10 'qt', 'qt4', 'qt5',
10 'qt', 'qt4', 'qt5', 'qt6',
11 'gtk', 'gtk2', 'gtk3',
11 'gtk', 'gtk2', 'gtk3',
12 'tk',
12 'tk',
13 'wx',
13 'wx',
@@ -22,6 +22,7 b' def register(name, inputhook):'
22 """Register the function *inputhook* as an event loop integration."""
22 """Register the function *inputhook* as an event loop integration."""
23 registered[name] = inputhook
23 registered[name] = inputhook
24
24
25
25 class UnknownBackend(KeyError):
26 class UnknownBackend(KeyError):
26 def __init__(self, name):
27 def __init__(self, name):
27 self.name = name
28 self.name = name
@@ -31,6 +32,7 b' class UnknownBackend(KeyError):'
31 "Supported event loops are: {}").format(self.name,
32 "Supported event loops are: {}").format(self.name,
32 ', '.join(backends + sorted(registered)))
33 ', '.join(backends + sorted(registered)))
33
34
35
34 def get_inputhook_name_and_func(gui):
36 def get_inputhook_name_and_func(gui):
35 if gui in registered:
37 if gui in registered:
36 return gui, registered[gui]
38 return gui, registered[gui]
@@ -45,6 +47,9 b' def get_inputhook_name_and_func(gui):'
45 if gui == 'qt5':
47 if gui == 'qt5':
46 os.environ['QT_API'] = 'pyqt5'
48 os.environ['QT_API'] = 'pyqt5'
47 gui_mod = 'qt'
49 gui_mod = 'qt'
50 elif gui == 'qt6':
51 os.environ['QT_API'] = 'pyqt6'
52 gui_mod = 'qt'
48
53
49 mod = importlib.import_module('IPython.terminal.pt_inputhooks.'+gui_mod)
54 mod = importlib.import_module('IPython.terminal.pt_inputhooks.'+gui_mod)
50 return gui, mod.inputhook
55 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(
42 QtCore.Qt.AA_EnableHighDpiScaling)
43 except AttributeError: # Only for Qt>=5.6, <6.
44 pass
45 try:
46 QtCore.QApplication.setHighDpiScaleFactorRoundingPolicy(
47 QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough)
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,8 +69,10 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(),
74 enum_helper('QtCore.QSocketNotifier.Type').Read
75 )
60 try:
76 try:
61 # connect the callback we care about before we turn it on
77 # connect the callback we care about before we turn it on
62 # lambda is necessary as PyQT inspect the function signature to know
78 # lambda is necessary as PyQT inspect the function signature to know
@@ -65,6 +81,6 b' def inputhook(context):'
65 notifier.setEnabled(True)
81 notifier.setEnabled(True)
66 # only start the event loop we are not already flipped
82 # only start the event loop we are not already flipped
67 if not context.input_is_ready():
83 if not context.input_is_ready():
68 event_loop.exec_()
84 _exec(event_loop)
69 finally:
85 finally:
70 notifier.setEnabled(False)
86 notifier.setEnabled(False)
General Comments 0
You need to be logged in to leave comments. Login now