|
16 | 16 | import os
|
17 | 17 | import platform
|
18 | 18 | import sys
|
| 19 | +import signal |
| 20 | +import socket |
| 21 | +import contextlib |
19 | 22 |
|
20 | 23 | from packaging.version import parse as parse_version
|
21 | 24 |
|
|
28 | 31 | QT_API_PYSIDE2 = "PySide2"
|
29 | 32 | QT_API_PYQTv2 = "PyQt4v2"
|
30 | 33 | QT_API_PYSIDE = "PySide"
|
31 |
| -QT_API_PYQT = "PyQt4" # Use the old sip v1 API (Py3 defaults to v2). |
| 34 | +QT_API_PYQT = "PyQt4" # Use the old sip v1 API (Py3 defaults to v2). |
32 | 35 | QT_API_ENV = os.environ.get("QT_API")
|
33 | 36 | if QT_API_ENV is not None:
|
34 | 37 | QT_API_ENV = QT_API_ENV.lower()
|
|
74 | 77 |
|
75 | 78 |
|
76 | 79 | def _setup_pyqt5plus():
|
77 |
| - global QtCore, QtGui, QtWidgets, __version__, is_pyqt5, \ |
| 80 | + global QtCore, QtGui, QtWidgets, QtNetwork, __version__, is_pyqt5, \ |
78 | 81 | _isdeleted, _getSaveFileName
|
79 | 82 |
|
80 | 83 | if QT_API == QT_API_PYQT6:
|
81 |
| - from PyQt6 import QtCore, QtGui, QtWidgets, sip |
| 84 | + from PyQt6 import QtCore, QtGui, QtWidgets, QtNetwork, sip |
82 | 85 | __version__ = QtCore.PYQT_VERSION_STR
|
83 | 86 | QtCore.Signal = QtCore.pyqtSignal
|
84 | 87 | QtCore.Slot = QtCore.pyqtSlot
|
85 | 88 | QtCore.Property = QtCore.pyqtProperty
|
86 | 89 | _isdeleted = sip.isdeleted
|
87 | 90 | elif QT_API == QT_API_PYSIDE6:
|
88 |
| - from PySide6 import QtCore, QtGui, QtWidgets, __version__ |
| 91 | + from PySide6 import QtCore, QtGui, QtWidgets, QtNetwork, __version__ |
89 | 92 | import shiboken6
|
90 | 93 | def _isdeleted(obj): return not shiboken6.isValid(obj)
|
91 | 94 | elif QT_API == QT_API_PYQT5:
|
92 |
| - from PyQt5 import QtCore, QtGui, QtWidgets |
| 95 | + from PyQt5 import QtCore, QtGui, QtWidgets, QtNetwork |
93 | 96 | import sip
|
94 | 97 | __version__ = QtCore.PYQT_VERSION_STR
|
95 | 98 | QtCore.Signal = QtCore.pyqtSignal
|
96 | 99 | QtCore.Slot = QtCore.pyqtSlot
|
97 | 100 | QtCore.Property = QtCore.pyqtProperty
|
98 | 101 | _isdeleted = sip.isdeleted
|
99 | 102 | elif QT_API == QT_API_PYSIDE2:
|
100 |
| - from PySide2 import QtCore, QtGui, QtWidgets, __version__ |
| 103 | + from PySide2 import QtCore, QtGui, QtWidgets, QtNetwork, __version__ |
101 | 104 | import shiboken2
|
102 |
| - def _isdeleted(obj): return not shiboken2.isValid(obj) |
| 105 | + def _isdeleted(obj): |
| 106 | + return not shiboken2.isValid(obj) |
103 | 107 | else:
|
104 | 108 | raise AssertionError(f"Unexpected QT_API: {QT_API}")
|
105 | 109 | _getSaveFileName = QtWidgets.QFileDialog.getSaveFileName
|
@@ -134,7 +138,6 @@ def _isdeleted(obj): return not shiboken2.isValid(obj)
|
134 | 138 | "QT_MAC_WANTS_LAYER" not in os.environ):
|
135 | 139 | os.environ["QT_MAC_WANTS_LAYER"] = "1"
|
136 | 140 |
|
137 |
| - |
138 | 141 | # These globals are only defined for backcompatibility purposes.
|
139 | 142 | ETS = dict(pyqt5=(QT_API_PYQT5, 5), pyside2=(QT_API_PYSIDE2, 5))
|
140 | 143 |
|
@@ -191,3 +194,62 @@ def _setDevicePixelRatio(obj, val):
|
191 | 194 | if hasattr(obj, 'setDevicePixelRatio'):
|
192 | 195 | # Not available on Qt4 or some older Qt5.
|
193 | 196 | obj.setDevicePixelRatio(val)
|
| 197 | + |
| 198 | + |
| 199 | +@contextlib.contextmanager |
| 200 | +def _maybe_allow_interrupt(qapp): |
| 201 | + """ |
| 202 | + This manager allows to terminate a plot by sending a SIGINT. It is |
| 203 | + necessary because the running Qt backend prevents Python interpreter to |
| 204 | + run and process signals (i.e., to raise KeyboardInterrupt exception). To |
| 205 | + solve this one needs to somehow wake up the interpreter and make it close |
| 206 | + the plot window. We do this by using the signal.set_wakeup_fd() function |
| 207 | + which organizes a write of the signal number into a socketpair connected |
| 208 | + to the QSocketNotifier (since it is part of the Qt backend, it can react |
| 209 | + to that write event). Afterwards, the Qt handler empties the socketpair |
| 210 | + by a recv() command to re-arm it (we need this if a signal different from |
| 211 | + SIGINT was caught by set_wakeup_fd() and we shall continue waiting). If |
| 212 | + the SIGINT was caught indeed, after exiting the on_signal() function the |
| 213 | + interpreter reacts to the SIGINT according to the handle() function which |
| 214 | + had been set up by a signal.signal() call: it causes the qt_object to |
| 215 | + exit by calling its quit() method. Finally, we call the old SIGINT |
| 216 | + handler with the same arguments that were given to our custom handle() |
| 217 | + handler. |
| 218 | +
|
| 219 | + We do this only if the old handler for SIGINT was not None, which means |
| 220 | + that a non-python handler was installed, i.e. in Julia, and not SIG_IGN |
| 221 | + which means we should ignore the interrupts. |
| 222 | + """ |
| 223 | + old_sigint_handler = signal.getsignal(signal.SIGINT) |
| 224 | + handler_args = None |
| 225 | + skip = False |
| 226 | + if old_sigint_handler in (None, signal.SIG_IGN, signal.SIG_DFL): |
| 227 | + skip = True |
| 228 | + else: |
| 229 | + wsock, rsock = socket.socketpair() |
| 230 | + wsock.setblocking(False) |
| 231 | + old_wakeup_fd = signal.set_wakeup_fd(wsock.fileno()) |
| 232 | + sn = QtCore.QSocketNotifier( |
| 233 | + rsock.fileno(), _enum('QtCore.QSocketNotifier.Type').Read |
| 234 | + ) |
| 235 | + |
| 236 | + # Clear the socket to re-arm the notifier. |
| 237 | + sn.activated.connect(lambda *args: rsock.recv(1)) |
| 238 | + |
| 239 | + def handle(*args): |
| 240 | + nonlocal handler_args |
| 241 | + handler_args = args |
| 242 | + qapp.quit() |
| 243 | + |
| 244 | + signal.signal(signal.SIGINT, handle) |
| 245 | + try: |
| 246 | + yield |
| 247 | + finally: |
| 248 | + if not skip: |
| 249 | + wsock.close() |
| 250 | + rsock.close() |
| 251 | + sn.setEnabled(False) |
| 252 | + signal.set_wakeup_fd(old_wakeup_fd) |
| 253 | + signal.signal(signal.SIGINT, old_sigint_handler) |
| 254 | + if handler_args is not None: |
| 255 | + old_sigint_handler(*handler_args) |
0 commit comments