##// END OF EJS Templates
ENH: add support for Qt6 input hooks...
Thomas A Caswell -
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,32 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, loaded_api, enum_factory,
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 59 def matplotlib_options(mpl):
60 """Constraints placed on an imported matplotlib."""
44 61 if mpl is None:
45 62 return
46 63 backend = mpl.rcParams.get('backend', None)
@@ -66,9 +83,7 b' def matplotlib_options(mpl):'
66 83 mpqt)
67 84
68 85 def get_options():
69 """Return a list of acceptable QT APIs, in decreasing order of
70 preference
71 """
86 """Return a list of acceptable QT APIs, in decreasing order of preference."""
72 87 #already imported Qt somewhere. Use that
73 88 loaded = loaded_api()
74 89 if loaded is not None:
@@ -83,13 +98,22 b' def get_options():'
83 98 qt_api = os.environ.get('QT_API', None)
84 99 if qt_api is None:
85 100 #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]
101 return matplotlib_options(mpl) or [
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 110 elif qt_api not in _qt_apis:
89 111 raise RuntimeError("Invalid Qt API %r, valid values are: %r" %
90 112 (qt_api, ', '.join(_qt_apis)))
91 113 else:
92 114 return [qt_api]
93 115
116
94 117 api_opts = get_options()
95 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 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
@@ -237,6 +246,27 b' def import_pyqt5():'
237 246 api = QT_API_PYQT5
238 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 271 def import_pyside():
242 272 """
@@ -263,6 +293,22 b' def import_pyside2():'
263 293
264 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 313 def load_qt(api_options):
268 314 """
@@ -291,13 +337,19 b' def load_qt(api_options):'
291 337 an incompatible library has already been installed)
292 338 """
293 339 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 }
340 # Qt6
341 QT_API_PYQT6: import_pyqt6,
342 QT_API_PYSIDE6: import_pyside6,
343 # Qt5
344 QT_API_PYQT5: import_pyqt5,
345 QT_API_PYSIDE2: import_pyside2,
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 354 for api in api_options:
303 355
@@ -332,3 +384,15 b' def load_qt(api_options):'
332 384 has_binding(QT_API_PYSIDE),
333 385 has_binding(QT_API_PYSIDE2),
334 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 9 backends = [
10 'qt', 'qt4', 'qt5',
10 'qt', 'qt4', 'qt5', 'qt6',
11 11 'gtk', 'gtk2', 'gtk3',
12 12 'tk',
13 13 'wx',
@@ -22,6 +22,7 b' def register(name, inputhook):'
22 22 """Register the function *inputhook* as an event loop integration."""
23 23 registered[name] = inputhook
24 24
25
25 26 class UnknownBackend(KeyError):
26 27 def __init__(self, name):
27 28 self.name = name
@@ -31,6 +32,7 b' class UnknownBackend(KeyError):'
31 32 "Supported event loops are: {}").format(self.name,
32 33 ', '.join(backends + sorted(registered)))
33 34
35
34 36 def get_inputhook_name_and_func(gui):
35 37 if gui in registered:
36 38 return gui, registered[gui]
@@ -45,6 +47,9 b' def get_inputhook_name_and_func(gui):'
45 47 if gui == 'qt5':
46 48 os.environ['QT_API'] = 'pyqt5'
47 49 gui_mod = 'qt'
50 elif gui == 'qt6':
51 os.environ['QT_API'] = 'pyqt6'
52 gui_mod = 'qt'
48 53
49 54 mod = importlib.import_module('IPython.terminal.pt_inputhooks.'+gui_mod)
50 55 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(
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 50 _appref = app = QtGui.QApplication([" "])
37 51
38 52 # "reclaim" IPython sys.excepthook after event loop starts
@@ -55,8 +69,10 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(),
74 enum_helper('QtCore.QSocketNotifier.Type').Read
75 )
60 76 try:
61 77 # connect the callback we care about before we turn it on
62 78 # lambda is necessary as PyQT inspect the function signature to know
@@ -65,6 +81,6 b' def inputhook(context):'
65 81 notifier.setEnabled(True)
66 82 # only start the event loop we are not already flipped
67 83 if not context.input_is_ready():
68 event_loop.exec_()
84 _exec(event_loop)
69 85 finally:
70 86 notifier.setEnabled(False)
General Comments 0
You need to be logged in to leave comments. Login now