8000 Factor out common parts of qt and macos interrupt handling. · matplotlib/matplotlib@3bd9abe · GitHub
[go: up one dir, main page]

Skip to content

Commit 3bd9abe

Browse files
committed
Factor out common parts of qt and macos interrupt handling.
Note that we don't actually need to disable the QSocketNotifier at the end, just letting it go out of scope should be sufficient as its destructor also does that (see qsocketnotifier.cpp).
1 parent ea66786 commit 3bd9abe

File tree

4 files changed

+108
-129
lines changed

4 files changed

+108
-129
lines changed

lib/matplotlib/backend_bases.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
import itertools
3636
import logging
3737
import os
38+
import signal
39+
import socket
3840
import sys
3941
import time
4042
import weakref
@@ -1651,6 +1653,64 @@ def _is_non_interactive_terminal_ipython(ip):
16511653
and getattr(ip.parent, 'interact', None) is False)
16521654

16531655

1656+
@contextmanager
1657+
def _allow_interrupt(prepare_notifier, handle_sigint):
1658+
"""
1659+
A context manager that allows terminating a plot by sending a SIGINT. It
1660+
is necessary because the running backend prevents the Python interpreter
1661+
from running and processing signals (i.e., to raise a KeyboardInterrupt).
1662+
To solve this, one needs to somehow wake up the interpreter and make it
1663+
close the plot window. We do this by using the signal.set_wakeup_fd()
1664+
function which organizes a write of the signal number into a socketpair.
1665+
A backend-specific function, *prepare_notifier*, arranges to listen to
1666+
the pair's read socket while the event loop is running. (If it returns a
1667+
notifier object, that object is kept alive while the context manager runs.)
1668+
1669+
If SIGINT was indeed caught, after exiting the on_signal() function the
1670+
interpreter reacts to the signal according to the handler function which
1671+
had been set up by a signal.signal() call; here, we arrange to call the
1672+
backend-specific *handle_sigint* function. Finally, we call the old SIGINT
1673+
handler with the same arguments that were given to our custom handler.
1674+
1675+
We do this only if the old handler for SIGINT was not None, which means
1676+
that a non-python handler was installed, i.e. in Julia, and not SIG_IGN
1677+
which means we should ignore the interrupts.
1678+
1679+
Parameters
1680+
----------
1681+
prepare_notifier : Callable[[socket.socket], object]
1682+
handle_sigint : Callable[[], object]
1683+
"""
1684+
1685+
old_sigint_handler = signal.getsignal(signal.SIGINT)
1686+
if old_sigint_handler in (None, signal.SIG_IGN, signal.SIG_DFL):
1687+
yield
1688+
return
1689+
1690+
handler_args = None
1691+
wsock, rsock = socket.socketpair()
1692+
wsock.setblocking(False)
1693+
rsock.setblocking(False)
1694+
old_wakeup_fd = signal.set_wakeup_fd(wsock.fileno())
1695+
notifier = prepare_notifier(rsock)
1696+
1697+
def save_args_and_handle_sigint(*args):
1698+
nonlocal handler_args
1699+
handler_args = args
1700+
handle_sigint()
1701+
1702+
signal.signal(signal.SIGINT, save_args_and_handle_sigint)
1703+
try:
1704+
yield
1705+
finally:
1706+
wsock.close()
1707+
rsock.close()
1708+
signal.set_wakeup_fd(old_wakeup_fd)
1709+
signal.signal(signal.SIGINT, old_sigint_handler)
1710+
if handler_args is not None:
1711+
old_sigint_handler(*handler_args)
1712+
1713+
16541714
class FigureCanvasBase:
16551715
"""
16561716
The canvas the figure renders into.

lib/matplotlib/backends/backend_macosx.py

Lines changed: 11 additions & 50 deletions
Original file line numberDiff 9E88 line numberDiff line change
@@ -1,7 +1,4 @@
1-
import contextlib
21
import os
3-
import signal
4-
import socket
52

63
import matplotlib as mpl
74
from matplotlib import _api, cbook
@@ -10,14 +7,20 @@
107
from .backend_agg import FigureCanvasAgg
118
from matplotlib.backend_bases import (
129
_Backend, FigureCanvasBase, FigureManagerBase, NavigationToolbar2,
13-
ResizeEvent, TimerBase)
10+
ResizeEvent, TimerBase, _allow_interrupt)
1411

1512

1613
class TimerMac(_macosx.Timer, TimerBase):
1714
"""Subclass of `.TimerBase` using CFRunLoop timer events."""
1815
# completely implemented at the C-level (in _macosx.Timer)
1916

2017

18+
def _allow_interrupt_macos():
19+
"""A context manager that allows terminating a plot by sending a SIGINT."""
20+
return _allow_interrupt(
21+
lambda rsock: _macosx.wake_on_fd_write(rsock.fileno()), _macosx.stop)
22+
23+
2124
class FigureCanvasMac(FigureCanvasAgg, _macosx.FigureCanvas, FigureCanvasBase):
2225
# docstring inherited
2326

@@ -109,10 +112,9 @@ def resize(self, width, height):
109112

110113
def start_event_loop(self, timeout=0):
111114
# docstring inherited
112-
with _maybe_allow_interrupt():
113-
# Call the objc implementation of the event loop after
114-
# setting up the interrupt handling
115-
self._start_event_loop(timeout=timeout)
115+
# Set up a SIGINT handler to allow terminating a plot via CTRL-C.
116+
with _allow_interrupt_macos():
117+
self._start_event_loop(timeout=timeout) # Forward to ObjC implementation.
116118

117119

118120
class NavigationToolbar2Mac(_macosx.NavigationToolbar2, NavigationToolbar2):
@@ -177,9 +179,7 @@ def destroy(self):
177179
@classmethod
178180
def start_main_loop(cls):
179181
# Set up a SIGINT handler to allow terminating a plot via CTRL-C.
180-
# The logic is largely copied from qt_compat._maybe_allow_interrupt; see its
181-
# docstring for details. Parts are implemented by wake_on_fd_write in ObjC.
182-
with _maybe_allow_interrupt():
182+
with _allow_interrupt_macos():
183183
_macosx.show()
184184

185185
def show(self):
@@ -190,45 +190,6 @@ def show(self):
190190
self._raise()
191191

192192

193-
@contextlib.contextmanager
194-
def _maybe_allow_interrupt():
195-
"""
196-
This manager allows to terminate a plot by sending a SIGINT. It is
197-
necessary because the running backend prevents Python interpreter to
198-
run and process signals (i.e., to raise KeyboardInterrupt exception). To
199-
solve this one needs to somehow wake up the interpreter and make it close
200-
the plot window. The implementation is taken from qt_compat, see that
201-
docstring for a more detailed description.
202-
"""
203-
old_sigint_handler = signal.getsignal(signal.SIGINT)
204-
if old_sigint_handler in (None, signal.SIG_IGN, signal.SIG_DFL):
205-
yield
206-
return
207-
208-
handler_args = None
209-
wsock, rsock = socket.socketpair()
210-
wsock.setblocking(False)
211-
rsock.setblocking(False)
212-
old_wakeup_fd = signal.set_wakeup_fd(wsock.fileno())
213-
_macosx.wake_on_fd_write(rsock.fileno())
214-
215-
def handle(*args):
216-
nonlocal handler_args
217-
handler_args = args
218-
_macosx.stop()
219-
220-
signal.signal(signal.SIGINT, handle)
221-
try:
222-
yield
223-
finally:
224-
wsock.close()
225-
rsock.close()
226-
signal.set_wakeup_fd(old_wakeup_fd)
227-
signal.signal(signal.SIGINT, old_sigint_handler)
228-
if handler_args is not None:
229-
old_sigint_handler(*handler_args)
230-
231-
232193
@_Backend.export
233194
class _BackendMac(_Backend):
234195
FigureCanvas = FigureCanvasMac

lib/matplotlib/backends/backend_qt.py

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,12 @@
99
from matplotlib.backend_bases import (
1010
_Backend, FigureCanvasBase, FigureManagerBase, NavigationToolbar2,
1111
TimerBase, cursors, ToolContainerBase, MouseButton,
12-
CloseEvent, KeyEvent, LocationEvent, MouseEvent, ResizeEvent)
12+
CloseEvent, KeyEvent, LocationEvent, MouseEvent, ResizeEvent,
13+
_allow_interrupt)
1314
import matplotlib.backends.qt_editor.figureoptions as figureoptions
1415
from . import qt_compat
1516
from .qt_compat import (
16-
QtCore, QtGui, QtWidgets, __version__, QT_API,
17-
_to_int, _isdeleted, _maybe_allow_interrupt
18-
)
17+
QtCore, QtGui, QtWidgets, __version__, QT_API, _to_int, _isdeleted)
1918

2019

2120
# SPECIAL_KEYS are Qt::Key that do *not* return their Unicode name
@@ -148,6 +147,38 @@ def _create_qApp():
148147
return app
149148

150149

150+
def _allow_interrupt_qt(qapp_or_eventloop):
151+
"""A context manager that allows terminating a plot by sending a SIGINT."""
152+
153+
# Use QSocketNotifier to read the socketpair while the Qt event loop runs.
154+
155+
def prepare_notifier(rsock):
156+
sn = QtCore.QSocketNotifier(rsock.fileno(), QtCore.QSocketNotifier.Type.Read)
157+
158+
@sn.activated.connect
159+
def _may_clear_sock():
160+
# Running a Python function on socket activation gives the interpreter a
161+
# chance to handle the signal in Python land. We also need to drain the
162+
# socket with recv() to re-arm it, because it will be written to as part of
163+
# the wakeup. (We need this in case set_wakeup_fd catches a signal other
164+
# than SIGINT and we shall continue waiting.)
165+
try:
166+
rsock.recv(1)
167+
except BlockingIOError:
168+
# This may occasionally fire too soon or more than once on Windows, so
169+
# be forgiving about reading an empty socket.
170+
pass
171+
172+
return sn # Actually keep the notifier alive.
173+
174+
def handle_sigint():
175+
if hasattr(qapp_or_eventloop, 'closeAllWindows'):
176+
qapp_or_eventloop.closeAllWindows()
177+
qapp_or_eventloop.quit()
178+
179+
return _allow_interrupt(prepare_notifier, handle_sigint)
180+
181+
151182
class TimerQT(TimerBase):
152183
"""Subclass of `.TimerBase` using QTimer events."""
153184

@@ -417,7 +448,7 @@ def start_event_loop(self, timeout=0):
417448
if timeout > 0:
418449
_ = QtCore.QTimer.singleShot(int(timeout * 1000), event_loop.quit)
419450

420-
with _maybe_allow_interrupt(event_loop):
451+
with _allow_interrupt_qt(event_loop):
421452
qt_compat._exec(event_loop)
422453

423454
def stop_event_loop(self, event=None):
@@ -598,7 +629,7 @@ def resize(self, width, height):
598629
def start_main_loop(cls):
599630
qapp = QtWidgets.QApplication.instance()
600631
if qapp:
601-
with _maybe_allow_interrupt(qapp):
632+
with _allow_interrupt_qt(qapp):
602633
qt_compat._exec(qapp)
603634

604635
def show(self):

lib/matplotlib/backends/qt_compat.py

Lines changed: 0 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,6 @@
1313
import os
1414
import platform
1515
import sys
16-
import signal
17-
import socket
18-
import contextlib
1916

2017
from packaging.version import parse as parse_version
2118

@@ -160,73 +157,3 @@ def _isdeleted(obj):
160157
def _exec(obj):
161158
# exec on PyQt6, exec_ elsewhere.
162159
obj.exec() if hasattr(obj, "exec") else obj.exec_()
163-
164-
165-
@contextlib.contextmanager
166-
def _maybe_allow_interrupt(qapp_or_eventloop):
167-
"""
168-
This manager allows to terminate a plot by sending a SIGINT. It is
169-
necessary because the running Qt backend prevents Python interpreter to
170-
run and process signals (i.e., to raise KeyboardInterrupt exception). To
171-
solve this one needs to somehow wake up the interpreter and make it close
172-
the plot window. We do this by using the signal.set_wakeup_fd() function
173-
which organizes a write of the signal number into a socketpair connected
174-
to the QSocketNotifier (since it is part of the Qt backend, it can react
175-
to that write event). Afterwards, the Qt handler empties the socketpair
176-
by a recv() command to re-arm it (we need this if a signal different from
177-
SIGINT was caught by set_wakeup_fd() and we shall continue waiting). If
178-
the SIGINT was caught indeed, after exiting the on_signal() function the
179-
interpreter reacts to the SIGINT according to the handle() function which
180-
had been set up by a signal.signal() call: it causes the qt_object to
181-
exit by calling its quit() method. Finally, we call the old SIGINT
182-
handler with the same arguments that were given to our custom handle()
183-
handler.
184-
185-
We do this only if the old handler for SIGINT was not None, which means
186-
that a non-python handler was installed, i.e. in Julia, and not SIG_IGN
187-
which means we should ignore the interrupts.
188-
"""
189-
190-
old_sigint_handler = signal.getsignal(signal.SIGINT)
191-
if old_sigint_handler in (None, signal.SIG_IGN, signal.SIG_DFL):
192-
yield
193-
return
194-
195-
handler_args = None
196-
wsock, rsock = socket.socketpair()
197-
wsock.setblocking(False)
198-
rsock.setblocking(False)
199-
old_wakeup_fd = signal.set_wakeup_fd(wsock.fileno())
200-
sn = QtCore.QSocketNotifier(rsock.fileno(), QtCore.QSocketNotifier.Type.Read)
201-
202-
# We do not actually care about this value other than running some Python code to
203-
# ensure that the interpreter has a chance to handle the signal in Python land. We
204-
# also need to drain the socket because it will be written to as part of the wakeup!
205-
# There are some cases where this may fire too soon / more than once on Windows so
206-
# we should be forgiving about reading an empty socket.
207-
# Clear the socket to re-arm the notifier.
208-
@sn.activated.connect
209-
def _may_clear_sock(*args):
210-
try:
211-
rsock.recv(1)
212-
except BlockingIOError:
213-
pass
214-
215-
def handle(*args):
216-
nonlocal handler_args
217-
handler_args = args
218-
if hasattr(qapp_or_eventloop, 'closeAllWindows'):
219-
qapp_or_eventloop.closeAllWindows()
220-
qapp_or_eventloop.quit()
221-
222-
signal.signal(signal.SIGINT, handle)
223-
try:
224-
yield
225-
finally:
226-
wsock.close()
227-
rsock.close()
228-
sn.setEnabled(False)
229-
signal.set_wakeup_fd(old_wakeup_fd)
230-
signal.signal(signal.SIGINT, old_sigint_handler)
231-
if handler_args is not None:
232-
old_sigint_handler(*handler_args)

0 commit comments

Comments
 (0)
0