qt_compat.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  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. import signal
  15. import socket
  16. import contextlib
  17. from packaging.version import parse as parse_version
  18. import matplotlib as mpl
  19. from . import _QT_FORCE_QT5_BINDING
  20. QT_API_PYQT6 = "PyQt6"
  21. QT_API_PYSIDE6 = "PySide6"
  22. QT_API_PYQT5 = "PyQt5"
  23. QT_API_PYSIDE2 = "PySide2"
  24. QT_API_ENV = os.environ.get("QT_API")
  25. if QT_API_ENV is not None:
  26. QT_API_ENV = QT_API_ENV.lower()
  27. _ETS = { # Mapping of QT_API_ENV to requested binding.
  28. "pyqt6": QT_API_PYQT6, "pyside6": QT_API_PYSIDE6,
  29. "pyqt5": QT_API_PYQT5, "pyside2": QT_API_PYSIDE2,
  30. }
  31. # First, check if anything is already imported.
  32. if sys.modules.get("PyQt6.QtCore"):
  33. QT_API = QT_API_PYQT6
  34. elif sys.modules.get("PySide6.QtCore"):
  35. QT_API = QT_API_PYSIDE6
  36. elif sys.modules.get("PyQt5.QtCore"):
  37. QT_API = QT_API_PYQT5
  38. elif sys.modules.get("PySide2.QtCore"):
  39. QT_API = QT_API_PYSIDE2
  40. # Otherwise, check the QT_API environment variable (from Enthought). This can
  41. # only override the binding, not the backend (in other words, we check that the
  42. # requested backend actually matches). Use _get_backend_or_none to avoid
  43. # triggering backend resolution (which can result in a partially but
  44. # incompletely imported backend_qt5).
  45. elif (mpl.rcParams._get_backend_or_none() or "").lower().startswith("qt5"):
  46. if QT_API_ENV in ["pyqt5", "pyside2"]:
  47. QT_API = _ETS[QT_API_ENV]
  48. else:
  49. _QT_FORCE_QT5_BINDING = True # noqa
  50. QT_API = None
  51. # A non-Qt backend was selected but we still got there (possible, e.g., when
  52. # fully manually embedding Matplotlib in a Qt app without using pyplot).
  53. elif QT_API_ENV is None:
  54. QT_API = None
  55. elif QT_API_ENV in _ETS:
  56. QT_API = _ETS[QT_API_ENV]
  57. else:
  58. raise RuntimeError(
  59. "The environment variable QT_API has the unrecognized value {!r}; "
  60. "valid values are {}".format(QT_API_ENV, ", ".join(_ETS)))
  61. def _setup_pyqt5plus():
  62. global QtCore, QtGui, QtWidgets, __version__
  63. global _isdeleted, _to_int
  64. if QT_API == QT_API_PYQT6:
  65. from PyQt6 import QtCore, QtGui, QtWidgets, sip
  66. __version__ = QtCore.PYQT_VERSION_STR
  67. QtCore.Signal = QtCore.pyqtSignal
  68. QtCore.Slot = QtCore.pyqtSlot
  69. QtCore.Property = QtCore.pyqtProperty
  70. _isdeleted = sip.isdeleted
  71. _to_int = operator.attrgetter('value')
  72. elif QT_API == QT_API_PYSIDE6:
  73. from PySide6 import QtCore, QtGui, QtWidgets, __version__
  74. import shiboken6
  75. def _isdeleted(obj): return not shiboken6.isValid(obj)
  76. if parse_version(__version__) >= parse_version('6.4'):
  77. _to_int = operator.attrgetter('value')
  78. else:
  79. _to_int = int
  80. elif QT_API == QT_API_PYQT5:
  81. from PyQt5 import QtCore, QtGui, QtWidgets
  82. import sip
  83. __version__ = QtCore.PYQT_VERSION_STR
  84. QtCore.Signal = QtCore.pyqtSignal
  85. QtCore.Slot = QtCore.pyqtSlot
  86. QtCore.Property = QtCore.pyqtProperty
  87. _isdeleted = sip.isdeleted
  88. _to_int = int
  89. elif QT_API == QT_API_PYSIDE2:
  90. from PySide2 import QtCore, QtGui, QtWidgets, __version__
  91. try:
  92. from PySide2 import shiboken2
  93. except ImportError:
  94. import shiboken2
  95. def _isdeleted(obj):
  96. return not shiboken2.isValid(obj)
  97. _to_int = int
  98. else:
  99. raise AssertionError(f"Unexpected QT_API: {QT_API}")
  100. if QT_API in [QT_API_PYQT6, QT_API_PYQT5, QT_API_PYSIDE6, QT_API_PYSIDE2]:
  101. _setup_pyqt5plus()
  102. elif QT_API is None: # See above re: dict.__getitem__.
  103. if _QT_FORCE_QT5_BINDING:
  104. _candidates = [
  105. (_setup_pyqt5plus, QT_API_PYQT5),
  106. (_setup_pyqt5plus, QT_API_PYSIDE2),
  107. ]
  108. else:
  109. _candidates = [
  110. (_setup_pyqt5plus, QT_API_PYQT6),
  111. (_setup_pyqt5plus, QT_API_PYSIDE6),
  112. (_setup_pyqt5plus, QT_API_PYQT5),
  113. (_setup_pyqt5plus, QT_API_PYSIDE2),
  114. ]
  115. for _setup, QT_API in _candidates:
  116. try:
  117. _setup()
  118. except ImportError:
  119. continue
  120. break
  121. else:
  122. raise ImportError(
  123. "Failed to import any of the following Qt binding modules: {}"
  124. .format(", ".join([QT_API for _, QT_API in _candidates]))
  125. )
  126. else: # We should not get there.
  127. raise AssertionError(f"Unexpected QT_API: {QT_API}")
  128. _version_info = tuple(QtCore.QLibraryInfo.version().segments())
  129. if _version_info < (5, 12):
  130. raise ImportError(
  131. f"The Qt version imported is "
  132. f"{QtCore.QLibraryInfo.version().toString()} but Matplotlib requires "
  133. f"Qt>=5.12")
  134. # Fixes issues with Big Sur
  135. # https://bugreports.qt.io/browse/QTBUG-87014, fixed in qt 5.15.2
  136. if (sys.platform == 'darwin' and
  137. parse_version(platform.mac_ver()[0]) >= parse_version("10.16") and
  138. _version_info < (5, 15, 2)):
  139. os.environ.setdefault("QT_MAC_WANTS_LAYER", "1")
  140. # Backports.
  141. def _exec(obj):
  142. # exec on PyQt6, exec_ elsewhere.
  143. obj.exec() if hasattr(obj, "exec") else obj.exec_()
  144. @contextlib.contextmanager
  145. def _maybe_allow_interrupt(qapp):
  146. """
  147. This manager allows to terminate a plot by sending a SIGINT. It is
  148. necessary because the running Qt backend prevents Python interpreter to
  149. run and process signals (i.e., to raise KeyboardInterrupt exception). To
  150. solve this one needs to somehow wake up the interpreter and make it close
  151. the plot window. We do this by using the signal.set_wakeup_fd() function
  152. which organizes a write of the signal number into a socketpair connected
  153. to the QSocketNotifier (since it is part of the Qt backend, it can react
  154. to that write event). Afterwards, the Qt handler empties the socketpair
  155. by a recv() command to re-arm it (we need this if a signal different from
  156. SIGINT was caught by set_wakeup_fd() and we shall continue waiting). If
  157. the SIGINT was caught indeed, after exiting the on_signal() function the
  158. interpreter reacts to the SIGINT according to the handle() function which
  159. had been set up by a signal.signal() call: it causes the qt_object to
  160. exit by calling its quit() method. Finally, we call the old SIGINT
  161. handler with the same arguments that were given to our custom handle()
  162. handler.
  163. We do this only if the old handler for SIGINT was not None, which means
  164. that a non-python handler was installed, i.e. in Julia, and not SIG_IGN
  165. which means we should ignore the interrupts.
  166. """
  167. old_sigint_handler = signal.getsignal(signal.SIGINT)
  168. if old_sigint_handler in (None, signal.SIG_IGN, signal.SIG_DFL):
  169. yield
  170. return
  171. handler_args = None
  172. wsock, rsock = socket.socketpair()
  173. wsock.setblocking(False)
  174. rsock.setblocking(False)
  175. old_wakeup_fd = signal.set_wakeup_fd(wsock.fileno())
  176. sn = QtCore.QSocketNotifier(rsock.fileno(), QtCore.QSocketNotifier.Type.Read)
  177. # We do not actually care about this value other than running some Python code to
  178. # ensure that the interpreter has a chance to handle the signal in Python land. We
  179. # also need to drain the socket because it will be written to as part of the wakeup!
  180. # There are some cases where this may fire too soon / more than once on Windows so
  181. # we should be forgiving about reading an empty socket.
  182. # Clear the socket to re-arm the notifier.
  183. @sn.activated.connect
  184. def _may_clear_sock(*args):
  185. try:
  186. rsock.recv(1)
  187. except BlockingIOError:
  188. pass
  189. def handle(*args):
  190. nonlocal handler_args
  191. handler_args = args
  192. qapp.quit()
  193. signal.signal(signal.SIGINT, handle)
  194. try:
  195. yield
  196. finally:
  197. wsock.close()
  198. rsock.close()
  199. sn.setEnabled(False)
  200. signal.set_wakeup_fd(old_wakeup_fd)
  201. signal.signal(signal.SIGINT, old_sigint_handler)
  202. if handler_args is not None:
  203. old_sigint_handler(*handler_args)