Skip to content

Commit b384c82

Browse files
vdrhtctacaswell
authored andcommitted
Using contextlib
1 parent ac27a17 commit b384c82

File tree

3 files changed

+150
-60
lines changed

3 files changed

+150
-60
lines changed

lib/matplotlib/backends/backend_qt.py

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
QtCore, QtGui, QtWidgets, __version__, QT_API,
1818
_enum, _to_int,
1919
_devicePixelRatioF, _isdeleted, _setDevicePixelRatio,
20-
_allow_interrupt
20+
_maybe_allow_interrupt
2121
)
2222

2323

@@ -402,11 +402,7 @@ def start_event_loop(self, timeout=0):
402402
timer = QtCore.QTimer.singleShot(int(timeout * 1000),
403403
event_loop.quit)
404404

405-
old_sigint_handler = signal.getsignal(signal.SIGINT)
406-
try:
407-
with _allow_interrupt(qApp, old_sigint_handler):
408-
qt_compat._exec(event_loop)
409-
except ValueError:
405+
with _maybe_allow_interrupt(qApp):
410406
qt_compat._exec(event_loop)
411407

412408
def stop_event_loop(self, event=None):
@@ -1009,11 +1005,5 @@ class _BackendQT(_Backend):
10091005

10101006
@staticmethod
10111007
def mainloop():
1012-
old_signal = signal.getsignal(signal.SIGINT)
1013-
# allow SIGINT exceptions to close the plot window.
1014-
is_python_signal_handler = old_signal is not None
1015-
if is_python_signal_handler:
1016-
with _allow_interrupt(qApp, old_signal):
1017-
qt_compat._exec(qApp)
1018-
else:
1008+
with _maybe_allow_interrupt(qApp):
10191009
qt_compat._exec(qApp)

lib/matplotlib/backends/qt_compat.py

Lines changed: 116 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
QT_API_PYSIDE2 = "PySide2"
3131
QT_API_PYQTv2 = "PyQt4v2"
3232
QT_API_PYSIDE = "PySide"
33-
QT_API_PYQT = "PyQt4" # Use the old sip v1 API (Py3 defaults to v2).
33+
QT_API_PYQT = "PyQt4" # Use the old sip v1 API (Py3 defaults to v2).
3434
QT_API_ENV = os.environ.get("QT_API")
3535
if QT_API_ENV is not None:
3636
QT_API_ENV = QT_API_ENV.lower()
@@ -101,7 +101,8 @@ def _isdeleted(obj): return not shiboken6.isValid(obj)
101101
elif QT_API == QT_API_PYSIDE2:
102102
from PySide2 import QtCore, QtGui, QtWidgets, QtNetwork, __version__
103103
import shiboken2
104-
def _isdeleted(obj): return not shiboken2.isValid(obj)
104+
def _isdeleted(obj):
105+
return not shiboken2.isValid(obj)
105106
else:
106107
raise AssertionError(f"Unexpected QT_API: {QT_API}")
107108
_getSaveFileName = QtWidgets.QFileDialog.getSaveFileName
@@ -136,7 +137,6 @@ def _isdeleted(obj): return not shiboken2.isValid(obj)
136137
"QT_MAC_WANTS_LAYER" not in os.environ):
137138
os.environ["QT_MAC_WANTS_LAYER"] = "1"
138139

139-
140140
# These globals are only defined for backcompatibility purposes.
141141
ETS = dict(pyqt5=(QT_API_PYQT5, 5), pyside2=(QT_API_PYSIDE2, 5))
142142

@@ -195,47 +195,116 @@ def _setDevicePixelRatio(obj, val):
195195
obj.setDevicePixelRatio(val)
196196

197197

198-
class _allow_interrupt:
199-
def __init__(self, qApp, old_sigint_handler):
200-
self.interrupted_qobject = qApp
201-
self.old_fd = None
202-
if old_sigint_handler in (None, signal.SIG_IGN, signal.SIG_DFL):
203-
raise ValueError(f"Old SIGINT handler {old_sigint_handler}"
204-
f" will not be overridden")
205-
self.old_sigint_handler = old_sigint_handler
206-
self.caught_args = None
207-
208-
QAS = QtNetwork.QAbstractSocket
209-
self.qt_socket = QAS(QAS.TcpSocket, qApp)
210-
# Create a socket pair
211-
self.wsock, self.rsock = socket.socketpair()
212-
# Let Qt listen on the one end
213-
self.qt_socket.setSocketDescriptor(self.rsock.fileno())
214-
self.wsock.setblocking(False)
215-
self.qt_socket.readyRead.connect(self._readSignal)
216-
217-
def __enter__(self):
218-
signal.signal(signal.SIGINT, self._handle)
219-
# And let Python write on the other end
220-
self.old_fd = signal.set_wakeup_fd(self.wsock.fileno())
221-
222-
def __exit__(self, type, val, traceback):
223-
signal.set_wakeup_fd(self.old_fd)
224-
signal.signal(signal.SIGINT, self.old_sigint_handler)
225-
226-
self.wsock.close()
227-
self.rsock.close()
228-
self.qt_socket.abort()
229-
if self.caught_args is not None:
230-
self.old_sigint_handler(*self.caught_args)
231-
232-
def _readSignal(self):
233-
# Read the written byte.
234-
# Note: readyRead is blocked from
235-
# occurring again until readData() was called, so call it,
236-
# even if you don't need the value.
237-
self.qt_socket.readData(1)
238-
239-
def _handle(self, *args):
240-
self.caught_args = args
241-
self.interrupted_qobject.quit()
198+
@contextlib.contextmanager
199+
def _maybe_allow_interrupt(qapp):
200+
'''
201+
This manager allows to terminate a plot by sending a SIGINT. It is
202+
necessary because the running Qt backend prevents Python interpreter to
203+
run and process signals (i.e., to raise KeyboardInterrupt exception). To
204+
solve this one needs to somehow wake up the interpreter and make it close
205+
the plot window. We do this by using the signal.set_wakeup_fd() function
206+
which organizes a write of the signal number into a socketpair connected
207+
to the QSocketNotifier (since it is part of the Qt backend, it can react
208+
to that write event). Afterwards, the Qt handler empties the socketpair
209+
by a recv() command to re-arm it (we need this if a signal different from
210+
SIGINT was caught by set_wakeup_fd() and we shall continue waiting). If
211+
the SIGINT was caught indeed, after exiting the on_signal() function the
212+
interpreter reacts to the SIGINT according to the handle() function which
213+
had been set up by a signal.signal() call: it causes the qt_object to
214+
exit by calling its quit() method. Finally, we call the old SIGINT
215+
handler with the same arguments that were given to our custom handle()
216+
handler.
217+
218+
We do this only if the old handler for SIGINT was not None, which means
219+
that a non-python handler was installed, i.e. in Julia, and not SIG_IGN
220+
which means we should ignore the interrupts.
221+
'''
222+
old_sigint_handler = signal.getsignal(signal.SIGINT)
223+
handler_args = None
224+
skip = False
225+
if old_sigint_handler in (None, signal.SIG_IGN, signal.SIG_DFL):
226+
skip = True
227+
else:
228+
wsock, rsock = socket.socketpair()
229+
wsock.setblocking(False)
230+
old_wakeup_fd = signal.set_wakeup_fd(wsock.fileno())
231+
sn = QtCore.QSocketNotifier(rsock.fileno(),
232+
QtCore.QSocketNotifier.Read)
233+
234+
@sn.activated.connect
235+
def on_signal(*args):
236+
rsock.recv(
237+
sys.getsizeof(int)) # clear the socket to re-arm the notifier
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)
256+
257+
# class _maybe_allow_interrupt:
258+
#
259+
# def __init__(self, qt_object):
260+
# self.interrupted_qobject = qt_object
261+
# self.old_fd = None
262+
# self.caught_args = None
263+
#
264+
# self.skip = False
265+
# self.old_sigint_handler = signal.getsignal(signal.SIGINT)
266+
# if self.old_sigint_handler in (None, signal.SIG_IGN, signal.SIG_DFL):
267+
# self.skip = True
268+
# return
269+
#
270+
# QAS = QtNetwork.QAbstractSocket
271+
# self.qt_socket = QAS(QAS.TcpSocket, qt_object)
272+
# # Create a socket pair
273+
# self.wsock, self.rsock = socket.socketpair()
274+
# # Let Qt listen on the one end
275+
# self.qt_socket.setSocketDescriptor(self.rsock.fileno())
276+
# self.wsock.setblocking(False)
277+
# self.qt_socket.readyRead.connect(self._readSignal)
278+
#
279+
# def __enter__(self):
280+
# if self.skip:
281+
# return
282+
#
283+
# signal.signal(signal.SIGINT, self._handle)
284+
# # And let Python write on the other end
285+
# self.old_fd = signal.set_wakeup_fd(self.wsock.fileno())
286+
#
287+
# def __exit__(self, type, val, traceback):
288+
# if self.skip:
289+
# return
290+
#
291+
# signal.set_wakeup_fd(self.old_fd)
292+
# signal.signal(signal.SIGINT, self.old_sigint_handler)
293+
#
294+
# self.wsock.close()
295+
# self.rsock.close()
296+
# self.qt_socket.abort()
297+
# if self.caught_args is not None:
298+
# self.old_sigint_handler(*self.caught_args)
299+
#
300+
# def _readSignal(self):
301+
# # Read the written byte to re-arm the socket if a signal different
302+
# # from SIGINT was caught.
303+
# # Since a custom handler was installed for SIGINT, KeyboardInterrupt
304+
# # is not raised here.
305+
# self.qt_socket.readData(1)
306+
#
307+
# def _handle(self, *args):
308+
# self.caught_args = args # save the caught args to call the old
309+
# # SIGINT handler afterwards
310+
# self.interrupted_qobject.quit()

lib/matplotlib/tests/test_backend_qt.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,37 @@ def fire_signal():
8585

8686

8787
@pytest.mark.backend('QtAgg', skip_on_importerror=True)
88+
@pytest.mark.parametrize("target, kwargs", [
89+
(plt.show, {"block": True}),
90+
(plt.pause, {"interval": 10})
91+
])
92+
def test_other_signal_before_sigint(qt_core, platform_simulate_ctrl_c,
93+
target, kwargs):
94+
plt.figure()
95+
96+
sigpipe_caught = False
97+
def custom_sigpipe_handler(signum, frame):
98+
nonlocal sigpipe_caught
99+
sigpipe_caught = True
100+
signal.signal(signal.SIGPIPE, custom_sigpipe_handler)
101+
102+
def fire_other_signal():
103+
os.kill(os.getpid(), signal.SIGPIPE)
104+
105+
def fire_sigint():
106+
platform_simulate_ctrl_c()
107+
108+
qt_core.QTimer.singleShot(50, fire_other_signal)
109+
qt_core.QTimer.singleShot(100, fire_sigint)
110+
try:
111+
target(**kwargs)
112+
except KeyboardInterrupt as e:
113+
assert sigpipe_caught
114+
else:
115+
assert False # KeyboardInterrupt must be raised
116+
117+
118+
@pytest.mark.backend('Qt5Agg')
88119
def test_fig_sigint_override(qt_core):
89120
from matplotlib.backends.backend_qt5 import _BackendQT5
90121
# Create a figure

0 commit comments

Comments
 (0)