Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit c756301

Browse files
committed
Fix support for Ctrl-C on the macosx backend.
Support and tests are largely copy-pasted from the qt implementation (qt_compat._maybe_allow_interrupt), the main difference being that what we need from QSocketNotifier, as well as the equivalent for QApplication.quit(), are reimplemented in ObjC. qt_compat._maybe_allow_interrupt is also slightly cleaned up by moving out the "do-nothing" case (`old_sigint_handler in (None, SIG_IGN, SIG_DFL)`) and dedenting the rest, instead of keeping track of whether signals were actually manipulated via a `skip` variable. Factoring out the common parts of _maybe_allow_interrupt and of the tests is left as a follow-up. (Test e.g. with `MPLBACKEND=macosx python -c "from pylab import *; plot(); show()"` followed by Ctrl-C.)
1 parent ffd3b12 commit c756301

File tree

6 files changed

+266
-69
lines changed

6 files changed

+266
-69
lines changed

lib/matplotlib/backends/backend_macosx.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import os
2+
import signal
3+
import socket
24

35
import matplotlib as mpl
46
from matplotlib import _api, cbook
@@ -164,7 +166,37 @@ def destroy(self):
164166

165167
@classmethod
166168
def start_main_loop(cls):
167-
_macosx.show()
169+
# Set up a SIGINT handler to allow terminating a plot via CTRL-C.
170+
# The logic is largely copied from qt_compat._maybe_allow_interrupt; see its
171+
# docstring for details. Parts are implemented by wake_on_fd_write in ObjC.
172+
173+
old_sigint_handler = signal.getsignal(signal.SIGINT)
174+
if old_sigint_handler in (None, signal.SIG_IGN, signal.SIG_DFL):
175+
_macosx.show()
176+
return
177+
178+
handler_args = None
179+
wsock, rsock = socket.socketpair()
180+
wsock.setblocking(False)
181+
rsock.setblocking(False)
182+
old_wakeup_fd = signal.set_wakeup_fd(wsock.fileno())
183+
_macosx.wake_on_fd_write(rsock.fileno())
184+
185+
def handle(*args):
186+
nonlocal handler_args
187+
handler_args = args
188+
_macosx.stop()
189+
190+
signal.signal(signal.SIGINT, handle)
191+
try:
192+
_macosx.show()
193+
finally:
194+
wsock.close()
195+
rsock.close()
196+
signal.set_wakeup_fd(old_wakeup_fd)
197+
signal.signal(signal.SIGINT, old_sigint_handler)
198+
if handler_args is not None:
199+
old_sigint_handler(*handler_args)
168200

169201
def show(self):
170202
if not self._shown:

lib/matplotlib/backends/qt_compat.py

Lines changed: 37 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -198,48 +198,46 @@ def _maybe_allow_interrupt(qapp):
198198
that a non-python handler was installed, i.e. in Julia, and not SIG_IGN
199199
which means we should ignore the interrupts.
200200
"""
201+
201202
old_sigint_handler = signal.getsignal(signal.SIGINT)
202-
handler_args = None
203-
skip = False
204203
if old_sigint_handler in (None, signal.SIG_IGN, signal.SIG_DFL):
205-
skip = True
206-
else:
207-
wsock, rsock = socket.socketpair()
208-
wsock.setblocking(False)
209-
old_wakeup_fd = signal.set_wakeup_fd(wsock.fileno())
210-
sn = QtCore.QSocketNotifier(
211-
rsock.fileno(), _enum('QtCore.QSocketNotifier.Type').Read
212-
)
204+
yield
205+
return
206+
207+
handler_args = None
208+
wsock, rsock = socket.socketpair()
209+
wsock.setblocking(False)
210+
rsock.setblocking(False)
211+
old_wakeup_fd = signal.set_wakeup_fd(wsock.fileno())
212+
sn = QtCore.QSocketNotifier(
213+
rsock.fileno(), _enum('QtCore.QSocketNotifier.Type').Read)
214+
215+
# We do not actually care about this value other than running some Python code to
216+
# ensure that the interpreter has a chance to handle the signal in Python land. We
217+
# also need to drain the socket because it will be written to as part of the wakeup!
218+
# There are some cases where this may fire too soon / more than once on Windows so
219+
# we should be forgiving about reading an empty socket.
220+
# Clear the socket to re-arm the notifier.
221+
@sn.activated.connect
222+
def _may_clear_sock(*args):
223+
try:
224+
rsock.recv(1)
225+
except BlockingIOError:
226+
pass
227+
228+
def handle(*args):
229+
nonlocal handler_args
230+
handler_args = args
231+
qapp.quit()
213232

214-
# We do not actually care about this value other than running some
215-
# Python code to ensure that the interpreter has a chance to handle the
216-
# signal in Python land. We also need to drain the socket because it
217-
# will be written to as part of the wakeup! There are some cases where
218-
# this may fire too soon / more than once on Windows so we should be
219-
# forgiving about reading an empty socket.
220-
rsock.setblocking(False)
221-
# Clear the socket to re-arm the notifier.
222-
@sn.activated.connect
223-
def _may_clear_sock(*args):
224-
try:
225-
rsock.recv(1)
226-
except BlockingIOError:
227-
pass
228-
229-
def handle(*args):
230-
nonlocal handler_args
231-
handler_args = args
232-
qapp.quit()
233-
234-
signal.signal(signal.SIGINT, handle)
233+
signal.signal(signal.SIGINT, handle)
235234
try:
236235
yield
237236
finally:
238-
if not skip:
239-
wsock.close()
240-
rsock.close()
241-
sn.setEnabled(False)
242-
signal.set_wakeup_fd(old_wakeup_fd)
243-
signal.signal(signal.SIGINT, old_sigint_handler)
244-
if handler_args is not None:
245-
old_sigint_handler(*handler_args)
237+
wsock.close()
238+
rsock.close()
239+
sn.setEnabled(False)
240+
signal.set_wakeup_fd(old_wakeup_fd)
241+
signal.signal(signal.SIGINT, old_sigint_handler)
242+
if handler_args is not None:
243+
old_sigint_handler(*handler_args)

lib/matplotlib/testing/__init__.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,33 @@ def _check_for_pgf(texsystem):
173173
return True
174174

175175

176+
class _WaitForStringPopen(subprocess.Popen):
177+
"""
178+
A Popen that passes flags that allow triggering KeyboardInterrupt.
179+
"""
180+
181+
def __init__(self, *args, **kwargs):
182+
if sys.platform == 'win32':
183+
kwargs['creationflags'] = subprocess.CREATE_NEW_CONSOLE
184+
super().__init__(
185+
*args, **kwargs,
186+
# Force Agg so that each test can switch to its desired Qt backend.
187+
env={**os.environ, "MPLBACKEND": "Agg", "SOURCE_DATE_EPOCH": "0"},
188+
stdout=subprocess.PIPE, universal_newlines=True)
189+
190+
def wait_for(self, terminator):
191+
"""Read until the terminator is reached."""
192+
buf = ''
193+
while True:
194+
c = self.stdout.read(1)
195+
if not c:
196+
raise RuntimeError(
197+
f'Subprocess died before emitting expected {terminator!r}')
198+
buf += c
199+
if buf.endswith(terminator):
200+
return
201+
202+
176203
def _has_tex_package(package):
177204
try:
178205
mpl.dviread.find_tex_file(f"{package}.sty")

lib/matplotlib/tests/test_backend_macosx.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
1+
import inspect
12
import os
3+
import signal
4+
import sys
25

36
import pytest
47

58
import matplotlib as mpl
69
import matplotlib.pyplot as plt
10+
from matplotlib.testing import _WaitForStringPopen
711
try:
812
from matplotlib.backends import _macosx
913
except ImportError:
1014
pytest.skip("These are mac only tests", allow_module_level=True)
1115

1216

17+
_test_timeout = 60 # A reasonably safe value for slower architectures.
18+
19+
1320
@pytest.mark.backend('macosx')
1421
def test_cached_renderer():
1522
# Make sure that figures have an associated renderer after
@@ -44,3 +51,112 @@ def new_choose_save_file(title, directory, filename):
4451
# Check the savefig.directory rcParam got updated because
4552
# we added a subdirectory "test"
4653
assert mpl.rcParams["savefig.directory"] == f"{tmp_path}/test"
54+
55+
56+
def _test_sigint_impl(backend, target_name, kwargs):
57+
import sys
58+
import matplotlib.pyplot as plt
59+
import os
60+
import threading
61+
62+
plt.switch_backend(backend)
63+
64+
def interrupter():
65+
if sys.platform == 'win32':
66+
import win32api
67+
win32api.GenerateConsoleCtrlEvent(0, 0)
68+
else:
69+
import signal
70+
os.kill(os.getpid(), signal.SIGINT)
71+
72+
target = getattr(plt, target_name)
73+
timer = threading.Timer(1, interrupter)
74+
fig = plt.figure()
75+
fig.canvas.mpl_connect(
76+
'draw_event',
77+
lambda *args: print('DRAW', flush=True)
78+
)
79+
fig.canvas.mpl_connect(
80+
'draw_event',
81+
lambda *args: timer.start()
82+
)
83+
try:
84+
target(**kwargs)
85+
except KeyboardInterrupt:
86+
print('SUCCESS', flush=True)
87+
88+
89+
@pytest.mark.backend('macosx', skip_on_importerror=True)
90+
@pytest.mark.parametrize("target, kwargs", [
91+
('show', {'block': True}),
92+
('pause', {'interval': 10})
93+
])
94+
def test_sigint(target, kwargs):
95+
backend = plt.get_backend()
96+
proc = _WaitForStringPopen(
97+
[sys.executable, "-c",
98+
inspect.getsource(_test_sigint_impl) +
99+
f"\n_test_sigint_impl({backend!r}, {target!r}, {kwargs!r})"])
100+
try:
101+
proc.wait_for('DRAW')
102+
stdout, _ = proc.communicate(timeout=_test_timeout)
103+
except Exception:
104+
proc.kill()
105+
stdout, _ = proc.communicate()
106+
raise
107+
print(stdout)
108+
assert 'SUCCESS' in stdout
109+
110+
111+
def _test_other_signal_before_sigint_impl(backend, target_name, kwargs):
112+
import signal
113+
import matplotlib.pyplot as plt
114+
plt.switch_backend(backend)
115+
116+
target = getattr(plt, target_name)
117+
118+
fig = plt.figure()
119+
fig.canvas.mpl_connect('draw_event',
120+
lambda *args: print('DRAW', flush=True))
121+
122+
timer = fig.canvas.new_timer(interval=1)
123+
timer.single_shot = True
124+
timer.add_callback(print, 'SIGUSR1', flush=True)
125+
126+
def custom_signal_handler(signum, frame):
127+
timer.start()
128+
signal.signal(signal.SIGUSR1, custom_signal_handler)
129+
130+
try:
131+
target(**kwargs)
132+
except KeyboardInterrupt:
133+
print('SUCCESS', flush=True)
134+
135+
136+
@pytest.mark.skipif(sys.platform == 'win32',
137+
reason='No other signal available to send on Windows')
138+
@pytest.mark.backend('macosx', skip_on_importerror=True)
139+
@pytest.mark.parametrize("target, kwargs", [
140+
('show', {'block': True}),
141+
('pause', {'interval': 10})
142+
])
143+
def test_other_signal_before_sigint(target, kwargs):
144+
backend = plt.get_backend()
145+
proc = _WaitForStringPopen(
146+
[sys.executable, "-c",
147+
inspect.getsource(_test_other_signal_before_sigint_impl) +
148+
"\n_test_other_signal_before_sigint_impl("
149+
f"{backend!r}, {target!r}, {kwargs!r})"])
150+
try:
151+
proc.wait_for('DRAW')
152+
os.kill(proc.pid, signal.SIGUSR1)
153+
proc.wait_for('SIGUSR1')
154+
os.kill(proc.pid, signal.SIGINT)
155+
stdout, _ = proc.communicate(timeout=_test_timeout)
156+
except Exception:
157+
proc.kill()
158+
stdout, _ = proc.communicate()
159+
raise
160+
print(stdout)
161+
assert 'SUCCESS' in stdout
162+
plt.figure()

lib/matplotlib/tests/test_backend_qt.py

Lines changed: 3 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from matplotlib import pyplot as plt
1616
from matplotlib._pylab_helpers import Gcf
1717
from matplotlib import _c_internal_utils
18+
from matplotlib.testing import _WaitForStringPopen
1819

1920

2021
try:
@@ -53,33 +54,6 @@ def test_fig_close():
5354
assert init_figs == Gcf.figs
5455

5556

56-
class WaitForStringPopen(subprocess.Popen):
57-
"""
58-
A Popen that passes flags that allow triggering KeyboardInterrupt.
59-
"""
60-
61-
def __init__(self, *args, **kwargs):
62-
if sys.platform == 'win32':
63-
kwargs['creationflags'] = subprocess.CREATE_NEW_CONSOLE
64-
super().__init__(
65-
*args, **kwargs,
66-
# Force Agg so that each test can switch to its desired Qt backend.
67-
env={**os.environ, "MPLBACKEND": "Agg", "SOURCE_DATE_EPOCH": "0"},
68-
stdout=subprocess.PIPE, universal_newlines=True)
69-
70-
def wait_for(self, terminator):
71-
"""Read until the terminator is reached."""
72-
buf = ''
73-
while True:
74-
c = self.stdout.read(1)
75-
if not c:
76-
raise RuntimeError(
77-
f'Subprocess died before emitting expected {terminator!r}')
78-
buf += c
79-
if buf.endswith(terminator):
80-
return
81-
82-
8357
def _test_sigint_impl(backend, target_name, kwargs):
8458
import sys
8559
import matplotlib.pyplot as plt
@@ -121,7 +95,7 @@ def interrupter():
12195
])
12296
def test_sigint(target, kwargs):
12397
backend = plt.get_backend()
124-
proc = WaitForStringPopen(
98+
proc = _WaitForStringPopen(
12599
[sys.executable, "-c",
126100
inspect.getsource(_test_sigint_impl) +
127101
f"\n_test_sigint_impl({backend!r}, {target!r}, {kwargs!r})"])
@@ -171,7 +145,7 @@ def custom_signal_handler(signum, frame):
171145
])
172146
def test_other_signal_before_sigint(target, kwargs):
173147
backend = plt.get_backend()
174-
proc = WaitForStringPopen(
148+
proc = _WaitForStringPopen(
175149
[sys.executable, "-c",
176150
inspect.getsource(_test_other_signal_before_sigint_impl) +
177151
"\n_test_other_signal_before_sigint_impl("

0 commit comments

Comments
 (0)