Format: text/x-python
⬇ Download File (11.6 KB)
   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