qt_compat.py 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. """
  2. Qt binding and backend selector.
  3. The selection logic is as follows:
  4. - if any of PyQt6, PySide6, PyQt5, or PySide2 have already been
  5. imported (checked in that order), use it;
  6. - otherwise, if the QT_API environment variable (used by Enthought) is set, use
  7. it to determine which binding to use;
  8. - otherwise, use whatever the rcParams indicate.
  9. """
  10. import operator
  11. import os
  12. import platform
  13. import sys
  14. from packaging.version import parse as parse_version
  15. import matplotlib as mpl
  16. from . import _QT_FORCE_QT5_BINDING
  17. QT_API_PYQT6 = "PyQt6"
  18. QT_API_PYSIDE6 = "PySide6"
  19. QT_API_PYQT5 = "PyQt5"
  20. QT_API_PYSIDE2 = "PySide2"
  21. QT_API_ENV = os.environ.get("QT_API")
  22. if QT_API_ENV is not None:
  23. QT_API_ENV = QT_API_ENV.lower()
  24. _ETS = { # Mapping of QT_API_ENV to requested binding.
  25. "pyqt6": QT_API_PYQT6, "pyside6": QT_API_PYSIDE6,
  26. "pyqt5": QT_API_PYQT5, "pyside2": QT_API_PYSIDE2,
  27. }
  28. # First, check if anything is already imported.
  29. if sys.modules.get("PyQt6.QtCore"):
  30. QT_API = QT_API_PYQT6
  31. elif sys.modules.get("PySide6.QtCore"):
  32. QT_API = QT_API_PYSIDE6
  33. elif sys.modules.get("PyQt5.QtCore"):
  34. QT_API = QT_API_PYQT5
  35. elif sys.modules.get("PySide2.QtCore"):
  36. QT_API = QT_API_PYSIDE2
  37. # Otherwise, check the QT_API environment variable (from Enthought). This can
  38. # only override the binding, not the backend (in other words, we check that the
  39. # requested backend actually matches). Use _get_backend_or_none to avoid
  40. # triggering backend resolution (which can result in a partially but
  41. # incompletely imported backend_qt5).
  42. elif (mpl.rcParams._get_backend_or_none() or "").lower().startswith("qt5"):
  43. if QT_API_ENV in ["pyqt5", "pyside2"]:
  44. QT_API = _ETS[QT_API_ENV]
  45. else:
  46. _QT_FORCE_QT5_BINDING = True # noqa: F811
  47. QT_API = None
  48. # A non-Qt backend was selected but we still got there (possible, e.g., when
  49. # fully manually embedding Matplotlib in a Qt app without using pyplot).
  50. elif QT_API_ENV is None:
  51. QT_API = None
  52. elif QT_API_ENV in _ETS:
  53. QT_API = _ETS[QT_API_ENV]
  54. else:
  55. raise RuntimeError(
  56. "The environment variable QT_API has the unrecognized value {!r}; "
  57. "valid values are {}".format(QT_API_ENV, ", ".join(_ETS)))
  58. def _setup_pyqt5plus():
  59. global QtCore, QtGui, QtWidgets, __version__
  60. global _isdeleted, _to_int
  61. if QT_API == QT_API_PYQT6:
  62. from PyQt6 import QtCore, QtGui, QtWidgets, sip
  63. __version__ = QtCore.PYQT_VERSION_STR
  64. QtCore.Signal = QtCore.pyqtSignal
  65. QtCore.Slot = QtCore.pyqtSlot
  66. QtCore.Property = QtCore.pyqtProperty
  67. _isdeleted = sip.isdeleted
  68. _to_int = operator.attrgetter('value')
  69. elif QT_API == QT_API_PYSIDE6:
  70. from PySide6 import QtCore, QtGui, QtWidgets, __version__
  71. import shiboken6
  72. def _isdeleted(obj): return not shiboken6.isValid(obj)
  73. if parse_version(__version__) >= parse_version('6.4'):
  74. _to_int = operator.attrgetter('value')
  75. else:
  76. _to_int = int
  77. elif QT_API == QT_API_PYQT5:
  78. from PyQt5 import QtCore, QtGui, QtWidgets
  79. import sip
  80. __version__ = QtCore.PYQT_VERSION_STR
  81. QtCore.Signal = QtCore.pyqtSignal
  82. QtCore.Slot = QtCore.pyqtSlot
  83. QtCore.Property = QtCore.pyqtProperty
  84. _isdeleted = sip.isdeleted
  85. _to_int = int
  86. elif QT_API == QT_API_PYSIDE2:
  87. from PySide2 import QtCore, QtGui, QtWidgets, __version__
  88. try:
  89. from PySide2 import shiboken2
  90. except ImportError:
  91. import shiboken2
  92. def _isdeleted(obj):
  93. return not shiboken2.isValid(obj)
  94. _to_int = int
  95. else:
  96. raise AssertionError(f"Unexpected QT_API: {QT_API}")
  97. if QT_API in [QT_API_PYQT6, QT_API_PYQT5, QT_API_PYSIDE6, QT_API_PYSIDE2]:
  98. _setup_pyqt5plus()
  99. elif QT_API is None: # See above re: dict.__getitem__.
  100. if _QT_FORCE_QT5_BINDING:
  101. _candidates = [
  102. (_setup_pyqt5plus, QT_API_PYQT5),
  103. (_setup_pyqt5plus, QT_API_PYSIDE2),
  104. ]
  105. else:
  106. _candidates = [
  107. (_setup_pyqt5plus, QT_API_PYQT6),
  108. (_setup_pyqt5plus, QT_API_PYSIDE6),
  109. (_setup_pyqt5plus, QT_API_PYQT5),
  110. (_setup_pyqt5plus, QT_API_PYSIDE2),
  111. ]
  112. for _setup, QT_API in _candidates:
  113. try:
  114. _setup()
  115. except ImportError:
  116. continue
  117. break
  118. else:
  119. raise ImportError(
  120. "Failed to import any of the following Qt binding modules: {}"
  121. .format(", ".join([QT_API for _, QT_API in _candidates]))
  122. )
  123. else: # We should not get there.
  124. raise AssertionError(f"Unexpected QT_API: {QT_API}")
  125. _version_info = tuple(QtCore.QLibraryInfo.version().segments())
  126. if _version_info < (5, 12):
  127. raise ImportError(
  128. f"The Qt version imported is "
  129. f"{QtCore.QLibraryInfo.version().toString()} but Matplotlib requires "
  130. f"Qt>=5.12")
  131. # Fixes issues with Big Sur
  132. # https://bugreports.qt.io/browse/QTBUG-87014, fixed in qt 5.15.2
  133. if (sys.platform == 'darwin' and
  134. parse_version(platform.mac_ver()[0]) >= parse_version("10.16") and
  135. _version_info < (5, 15, 2)):
  136. os.environ.setdefault("QT_MAC_WANTS_LAYER", "1")
  137. # Backports.
  138. def _exec(obj):
  139. # exec on PyQt6, exec_ elsewhere.
  140. obj.exec() if hasattr(obj, "exec") else obj.exec_()