1 """ 2 This module contains factory functions that attempt 3 to return Qt submodules from the various python Qt bindings. 4 5 It also protects against double-importing Qt with different 6 bindings, which is unstable and likely to crash 7 8 This is used primarily by qt and qt_for_kernel, and shouldn't 9 be accessed directly from the outside 10 """ 11 12 import importlib.abc 13 import sys 14 import os 15 import types 16 from functools import partial, lru_cache 17 import operator 18 19 # ### Available APIs. 20 # Qt6 21 QT_API_PYQT6 = "pyqt6" 22 QT_API_PYSIDE6 = "pyside6" 23 24 # Qt5 25 QT_API_PYQT5 = 'pyqt5' 26 QT_API_PYSIDE2 = 'pyside2' 27 28 # Qt4 29 # NOTE: Here for legacy matplotlib compatibility, but not really supported on the IPython side. 30 QT_API_PYQT = "pyqt" # Force version 2 31 QT_API_PYQTv1 = "pyqtv1" # Force version 2 32 QT_API_PYSIDE = "pyside" 33 34 QT_API_PYQT_DEFAULT = "pyqtdefault" # use system default for version 1 vs. 2 35 36 api_to_module = { 37 # Qt6 38 QT_API_PYQT6: "PyQt6", 39 QT_API_PYSIDE6: "PySide6", 40 # Qt5 41 QT_API_PYQT5: "PyQt5", 42 QT_API_PYSIDE2: "PySide2", 43 # Qt4 44 QT_API_PYSIDE: "PySide", 45 QT_API_PYQT: "PyQt4", 46 QT_API_PYQTv1: "PyQt4", 47 # default 48 QT_API_PYQT_DEFAULT: "PyQt6", 49 } 50 51 52 class ImportDenier(importlib.abc.MetaPathFinder): 53 """Import Hook that will guard against bad Qt imports 54 once IPython commits to a specific binding 55 """ 56 57 def __init__(self): 58 self.__forbidden = set() 59 60 def forbid(self, module_name): 61 sys.modules.pop(module_name, None) 62 self.__forbidden.add(module_name) 63 64 def find_spec(self, fullname, path, target=None): 65 if path: 66 return 67 if fullname in self.__forbidden: 68 raise ImportError( 69 """ 70 Importing %s disabled by IPython, which has 71 already imported an Incompatible QT Binding: %s 72 """ 73 % (fullname, loaded_api()) 74 ) 75 76 77 ID = ImportDenier() 78 sys.meta_path.insert(0, ID) 79 80 81 def commit_api(api): 82 """Commit to a particular API, and trigger ImportErrors on subsequent 83 dangerous imports""" 84 modules = set(api_to_module.values()) 85 86 modules.remove(api_to_module[api]) 87 for mod in modules: 88 ID.forbid(mod) 89 90 91 def loaded_api(): 92 """Return which API is loaded, if any 93 94 If this returns anything besides None, 95 importing any other Qt binding is unsafe. 96 97 Returns 98 ------- 99 None, 'pyside6', 'pyqt6', 'pyside2', 'pyside', 'pyqt', 'pyqt5', 'pyqtv1' 100 """ 101 if sys.modules.get("PyQt6.QtCore"): 102 return QT_API_PYQT6 103 elif sys.modules.get("PySide6.QtCore"): 104 return QT_API_PYSIDE6 105 elif sys.modules.get("PyQt5.QtCore"): 106 return QT_API_PYQT5 107 elif sys.modules.get("PySide2.QtCore"): 108 return QT_API_PYSIDE2 109 elif sys.modules.get("PyQt4.QtCore"): 110 if qtapi_version() == 2: 111 return QT_API_PYQT 112 else: 113 return QT_API_PYQTv1 114 elif sys.modules.get("PySide.QtCore"): 115 return QT_API_PYSIDE 116 117 return None 118 119 120 def has_binding(api): 121 """Safely check for PyQt4/5, PySide or PySide2, without importing submodules 122 123 Parameters 124 ---------- 125 api : str [ 'pyqtv1' | 'pyqt' | 'pyqt5' | 'pyside' | 'pyside2' | 'pyqtdefault'] 126 Which module to check for 127 128 Returns 129 ------- 130 True if the relevant module appears to be importable 131 """ 132 module_name = api_to_module[api] 133 from importlib.util import find_spec 134 135 required = ['QtCore', 'QtGui', 'QtSvg'] 136 if api in (QT_API_PYQT5, QT_API_PYSIDE2, QT_API_PYQT6, QT_API_PYSIDE6): 137 # QT5 requires QtWidgets too 138 required.append('QtWidgets') 139 140 for submod in required: 141 try: 142 spec = find_spec('%s.%s' % (module_name, submod)) 143 except ImportError: 144 # Package (e.g. PyQt5) not found 145 return False 146 else: 147 if spec is None: 148 # Submodule (e.g. PyQt5.QtCore) not found 149 return False 150 151 if api == QT_API_PYSIDE: 152 # We can also safely check PySide version 153 import PySide 154 155 return PySide.__version_info__ >= (1, 0, 3) 156 157 return True 158 159 160 def qtapi_version(): 161 """Return which QString API has been set, if any 162 163 Returns 164 ------- 165 The QString API version (1 or 2), or None if not set 166 """ 167 try: 168 import sip 169 except ImportError: 170 # as of PyQt5 5.11, sip is no longer available as a top-level 171 # module and needs to be imported from the PyQt5 namespace 172 try: 173 from PyQt5 import sip 174 except ImportError: 175 return 176 try: 177 return sip.getapi('QString') 178 except ValueError: 179 return 180 181 182 def can_import(api): 183 """Safely query whether an API is importable, without importing it""" 184 if not has_binding(api): 185 return False 186 187 current = loaded_api() 188 if api == QT_API_PYQT_DEFAULT: 189 return current in [QT_API_PYQT6, None] 190 else: 191 return current in [api, None] 192 193 194 def import_pyqt4(version=2): 195 """ 196 Import PyQt4 197 198 Parameters 199 ---------- 200 version : 1, 2, or None 201 Which QString/QVariant API to use. Set to None to use the system 202 default 203 ImportErrors raised within this function are non-recoverable 204 """ 205 # The new-style string API (version=2) automatically 206 # converts QStrings to Unicode Python strings. Also, automatically unpacks 207 # QVariants to their underlying objects. 208 import sip 209 210 if version is not None: 211 sip.setapi('QString', version) 212 sip.setapi('QVariant', version) 213 214 from PyQt4 import QtGui, QtCore, QtSvg 215 216 if QtCore.PYQT_VERSION < 0x040700: 217 raise ImportError("IPython requires PyQt4 >= 4.7, found %s" % 218 QtCore.PYQT_VERSION_STR) 219 220 # Alias PyQt-specific functions for PySide compatibility. 221 QtCore.Signal = QtCore.pyqtSignal 222 QtCore.Slot = QtCore.pyqtSlot 223 224 # query for the API version (in case version == None) 225 version = sip.getapi('QString') 226 api = QT_API_PYQTv1 if version == 1 else QT_API_PYQT 227 return QtCore, QtGui, QtSvg, api 228 229 230 def import_pyqt5(): 231 """ 232 Import PyQt5 233 234 ImportErrors raised within this function are non-recoverable 235 """ 236 237 from PyQt5 import QtCore, QtSvg, QtWidgets, QtGui 238 239 # Alias PyQt-specific functions for PySide compatibility. 240 QtCore.Signal = QtCore.pyqtSignal 241 QtCore.Slot = QtCore.pyqtSlot 242 243 # Join QtGui and QtWidgets for Qt4 compatibility. 244 QtGuiCompat = types.ModuleType('QtGuiCompat') 245 QtGuiCompat.__dict__.update(QtGui.__dict__) 246 QtGuiCompat.__dict__.update(QtWidgets.__dict__) 247 248 api = QT_API_PYQT5 249 return QtCore, QtGuiCompat, QtSvg, api 250 251 252 def import_pyqt6(): 253 """ 254 Import PyQt6 255 256 ImportErrors raised within this function are non-recoverable 257 """ 258 259 from PyQt6 import QtCore, QtSvg, QtWidgets, QtGui 260 261 # Alias PyQt-specific functions for PySide compatibility. 262 QtCore.Signal = QtCore.pyqtSignal 263 QtCore.Slot = QtCore.pyqtSlot 264 265 # Join QtGui and QtWidgets for Qt4 compatibility. 266 QtGuiCompat = types.ModuleType("QtGuiCompat") 267 QtGuiCompat.__dict__.update(QtGui.__dict__) 268 QtGuiCompat.__dict__.update(QtWidgets.__dict__) 269 270 api = QT_API_PYQT6 271 return QtCore, QtGuiCompat, QtSvg, api 272 273 274 def import_pyside(): 275 """ 276 Import PySide 277 278 ImportErrors raised within this function are non-recoverable 279 """ 280 from PySide import QtGui, QtCore, QtSvg 281 return QtCore, QtGui, QtSvg, QT_API_PYSIDE 282 283 def import_pyside2(): 284 """ 285 Import PySide2 286 287 ImportErrors raised within this function are non-recoverable 288 """ 289 from PySide2 import QtGui, QtCore, QtSvg, QtWidgets, QtPrintSupport 290 291 # Join QtGui and QtWidgets for Qt4 compatibility. 292 QtGuiCompat = types.ModuleType('QtGuiCompat') 293 QtGuiCompat.__dict__.update(QtGui.__dict__) 294 QtGuiCompat.__dict__.update(QtWidgets.__dict__) 295 QtGuiCompat.__dict__.update(QtPrintSupport.__dict__) 296 297 return QtCore, QtGuiCompat, QtSvg, QT_API_PYSIDE2 298 299 300 def import_pyside6(): 301 """ 302 Import PySide6 303 304 ImportErrors raised within this function are non-recoverable 305 """ 306 307 def get_attrs(module): 308 return { 309 name: getattr(module, name) 310 for name in dir(module) 311 if not name.startswith("_") 312 } 313 314 from PySide6 import QtGui, QtCore, QtSvg, QtWidgets, QtPrintSupport 315 316 # Join QtGui and QtWidgets for Qt4 compatibility. 317 QtGuiCompat = types.ModuleType("QtGuiCompat") 318 QtGuiCompat.__dict__.update(QtGui.__dict__) 319 if QtCore.__version_info__ < (6, 7): 320 QtGuiCompat.__dict__.update(QtWidgets.__dict__) 321 QtGuiCompat.__dict__.update(QtPrintSupport.__dict__) 322 else: 323 QtGuiCompat.__dict__.update(get_attrs(QtWidgets)) 324 QtGuiCompat.__dict__.update(get_attrs(QtPrintSupport)) 325 326 return QtCore, QtGuiCompat, QtSvg, QT_API_PYSIDE6 327 328 329 def load_qt(api_options): 330 """ 331 Attempt to import Qt, given a preference list 332 of permissible bindings 333 334 It is safe to call this function multiple times. 335 336 Parameters 337 ---------- 338 api_options : List of strings 339 The order of APIs to try. Valid items are 'pyside', 'pyside2', 340 'pyqt', 'pyqt5', 'pyqtv1' and 'pyqtdefault' 341 342 Returns 343 ------- 344 A tuple of QtCore, QtGui, QtSvg, QT_API 345 The first three are the Qt modules. The last is the 346 string indicating which module was loaded. 347 348 Raises 349 ------ 350 ImportError, if it isn't possible to import any requested 351 bindings (either because they aren't installed, or because 352 an incompatible library has already been installed) 353 """ 354 loaders = { 355 # Qt6 356 QT_API_PYQT6: import_pyqt6, 357 QT_API_PYSIDE6: import_pyside6, 358 # Qt5 359 QT_API_PYQT5: import_pyqt5, 360 QT_API_PYSIDE2: import_pyside2, 361 # Qt4 362 QT_API_PYSIDE: import_pyside, 363 QT_API_PYQT: import_pyqt4, 364 QT_API_PYQTv1: partial(import_pyqt4, version=1), 365 # default 366 QT_API_PYQT_DEFAULT: import_pyqt6, 367 } 368 369 for api in api_options: 370 371 if api not in loaders: 372 raise RuntimeError( 373 "Invalid Qt API %r, valid values are: %s" % 374 (api, ", ".join(["%r" % k for k in loaders.keys()]))) 375 376 if not can_import(api): 377 continue 378 379 #cannot safely recover from an ImportError during this 380 result = loaders[api]() 381 api = result[-1] # changed if api = QT_API_PYQT_DEFAULT 382 commit_api(api) 383 return result 384 else: 385 # Clear the environment variable since it doesn't work. 386 if "QT_API" in os.environ: 387 del os.environ["QT_API"] 388 389 raise ImportError( 390 """ 391 Could not load requested Qt binding. Please ensure that 392 PyQt4 >= 4.7, PyQt5, PyQt6, PySide >= 1.0.3, PySide2, or 393 PySide6 is available, and only one is imported per session. 394 395 Currently-imported Qt library: %r 396 PyQt5 available (requires QtCore, QtGui, QtSvg, QtWidgets): %s 397 PyQt6 available (requires QtCore, QtGui, QtSvg, QtWidgets): %s 398 PySide2 installed: %s 399 PySide6 installed: %s 400 Tried to load: %r 401 """ 402 % ( 403 loaded_api(), 404 has_binding(QT_API_PYQT5), 405 has_binding(QT_API_PYQT6), 406 has_binding(QT_API_PYSIDE2), 407 has_binding(QT_API_PYSIDE6), 408 api_options, 409 ) 410 ) 411 412 413 def enum_factory(QT_API, QtCore): 414 """Construct an enum helper to account for PyQt5 <-> PyQt6 changes.""" 415 416 @lru_cache(None) 417 def _enum(name): 418 # foo.bar.Enum.Entry (PyQt6) <=> foo.bar.Entry (non-PyQt6). 419 return operator.attrgetter( 420 name if QT_API == QT_API_PYQT6 else name.rpartition(".")[0] 421 )(sys.modules[QtCore.__package__]) 422 423 return _enum 424