Skip to content

Commit 7bae7dc

Browse files
authored
Merge pull request #20907 from QuLogic/qt-interrupt-popen
Move sigint tests into subprocesses
2 parents 36c0efa + 49e1cce commit 7bae7dc

File tree

3 files changed

+160
-63
lines changed

3 files changed

+160
-63
lines changed

.appveyor.yml

+2
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ install:
6060
# pull pywin32 from conda because on py38 there is something wrong with finding
6161
# the dlls when insalled from pip
6262
- conda install -c conda-forge pywin32
63+
# install pyqt from conda-forge
64+
- conda install -c conda-forge pyqt
6365
- echo %PYTHON_VERSION% %TARGET_ARCH%
6466
# Install dependencies from PyPI.
6567
- python -m pip install --upgrade -r requirements/testing/all.txt %EXTRAREQS% %PINNEDVERS%

lib/matplotlib/backends/qt_compat.py

+13-1
Original file line numberDiff line numberDiff line change
@@ -228,8 +228,20 @@ def _maybe_allow_interrupt(qapp):
228228
rsock.fileno(), _enum('QtCore.QSocketNotifier.Type').Read
229229
)
230230

231+
# We do not actually care about this value other than running some
232+
# Python code to ensure that the interpreter has a chance to handle the
233+
# signal in Python land. We also need to drain the socket because it
234+
# will be written to as part of the wakeup! There are some cases where
235+
# this may fire too soon / more than once on Windows so we should be
236+
# forgiving about reading an empty socket.
237+
rsock.setblocking(False)
231238
# Clear the socket to re-arm the notifier.
232-
sn.activated.connect(lambda *args: rsock.recv(1))
239+
@sn.activated.connect
240+
def _may_clear_sock(*args):
241+
try:
242+
rsock.recv(1)
243+
except BlockingIOError:
244+
pass
233245

234246
def handle(*args):
235247
nonlocal handler_args

lib/matplotlib/tests/test_backend_qt.py

+145-62
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
pytestmark = pytest.mark.skip('No usable Qt bindings')
2525

2626

27+
_test_timeout = 60 # A reasonably safe value for slower architectures.
28+
29+
2730
@pytest.fixture
2831
def qt_core(request):
2932
backend, = request.node.get_closest_marker('backend').args
@@ -33,19 +36,6 @@ def qt_core(request):
3336
return QtCore
3437

3538

36-
@pytest.fixture
37-
def platform_simulate_ctrl_c(request):
38-
import signal
39-
from functools import partial
40-
41-
if hasattr(signal, "CTRL_C_EVENT"):
42-
win32api = pytest.importorskip('win32api')
43-
return partial(win32api.GenerateConsoleCtrlEvent, 0, 0)
44-
else:
45-
# we're not on windows
46-
return partial(os.kill, os.getpid(), signal.SIGINT)
47-
48-
4939
@pytest.mark.backend('QtAgg', skip_on_importerror=True)
5040
def test_fig_close():
5141

@@ -64,50 +54,143 @@ def test_fig_close():
6454
assert init_figs == Gcf.figs
6555

6656

67-
@pytest.mark.backend('QtAgg', skip_on_importerror=True)
68-
@pytest.mark.parametrize("target, kwargs", [
69-
(plt.show, {"block": True}),
70-
(plt.pause, {"interval": 10})
71-
])
72-
def test_sigint(qt_core, platform_simulate_ctrl_c, target,
73-
kwargs):
74-
plt.figure()
75-
def fire_signal():
76-
platform_simulate_ctrl_c()
57+
class WaitForStringPopen(subprocess.Popen):
58+
"""
59+
A Popen that passes flags that allow triggering KeyboardInterrupt.
60+
"""
7761

78-
qt_core.QTimer.singleShot(100, fire_signal)
79-
with pytest.raises(KeyboardInterrupt):
62+
def __init__(self, *args, **kwargs):
63+
if sys.platform == 'win32':
64+
kwargs['creationflags'] = subprocess.CREATE_NEW_CONSOLE
65+
super().__init__(
66+
*args, **kwargs,
67+
# Force Agg so that each test can switch to its desired Qt backend.
68+
env={**os.environ, "MPLBACKEND": "Agg", "SOURCE_DATE_EPOCH": "0"},
69+
stdout=subprocess.PIPE, universal_newlines=True)
70+
71+
def wait_for(self, terminator):
72+
"""Read until the terminator is reached."""
73+
buf = ''
74+
while True:
75+
c = self.stdout.read(1)
76+
if not c:
77+
raise RuntimeError(
78+
f'Subprocess died before emitting expected {terminator!r}')
79+
buf += c
80+
if buf.endswith(terminator):
81+
return
82+
83+
84+
def _test_sigint_impl(backend, target_name, kwargs):
85+
import sys
86+
import matplotlib.pyplot as plt
87+
import os
88+
import threading
89+
90+
plt.switch_backend(backend)
91+
from matplotlib.backends.qt_compat import QtCore
92+
93+
def interupter():
94+
if sys.platform == 'win32':
95+
import win32api
96+
win32api.GenerateConsoleCtrlEvent(0, 0)
97+
else:
98+
import signal
99+
os.kill(os.getpid(), signal.SIGINT)
100+
101+
target = getattr(plt, target_name)
102+
timer = threading.Timer(1, interupter)
103+
fig = plt.figure()
104+
fig.canvas.mpl_connect(
105+
'draw_event',
106+
lambda *args: print('DRAW', flush=True)
107+
)
108+
fig.canvas.mpl_connect(
109+
'draw_event',
110+
lambda *args: timer.start()
111+
)
112+
try:
80113
target(**kwargs)
114+
except KeyboardInterrupt:
115+
print('SUCCESS', flush=True)
81116

82117

83118
@pytest.mark.backend('QtAgg', skip_on_importerror=True)
84119
@pytest.mark.parametrize("target, kwargs", [
85-
(plt.show, {"block": True}),
86-
(plt.pause, {"interval": 10})
120+
('show', {'block': True}),
121+
('pause', {'interval': 10})
87122
])
88-
def test_other_signal_before_sigint(qt_core, platform_simulate_ctrl_c,
89-
target, kwargs):
90-
plt.figure()
123+
def test_sigint(target, kwargs):
124+
backend = plt.get_backend()
125+
proc = WaitForStringPopen(
126+
[sys.executable, "-c",
127+
inspect.getsource(_test_sigint_impl) +
128+
f"\n_test_sigint_impl({backend!r}, {target!r}, {kwargs!r})"])
129+
try:
130+
proc.wait_for('DRAW')
131+
stdout, _ = proc.communicate(timeout=_test_timeout)
132+
except:
133+
proc.kill()
134+
stdout, _ = proc.communicate()
135+
raise
136+
print(stdout)
137+
assert 'SUCCESS' in stdout
138+
139+
140+
def _test_other_signal_before_sigint_impl(backend, target_name, kwargs):
141+
import signal
142+
import sys
143+
import matplotlib.pyplot as plt
144+
plt.switch_backend(backend)
145+
from matplotlib.backends.qt_compat import QtCore
91146

92-
sigcld_caught = False
93-
def custom_sigpipe_handler(signum, frame):
94-
nonlocal sigcld_caught
95-
sigcld_caught = True
96-
signal.signal(signal.SIGCHLD, custom_sigpipe_handler)
147+
target = getattr(plt, target_name)
97148

98-
def fire_other_signal():
99-
os.kill(os.getpid(), signal.SIGCHLD)
149+
fig = plt.figure()
150+
fig.canvas.mpl_connect('draw_event',
151+
lambda *args: print('DRAW', flush=True))
100152

101-
def fire_sigint():
102-
platform_simulate_ctrl_c()
153+
timer = fig.canvas.new_timer(interval=1)
154+
timer.single_shot = True
155+
timer.add_callback(print, 'SIGUSR1', flush=True)
103156

104-
qt_core.QTimer.singleShot(50, fire_other_signal)
105-
qt_core.QTimer.singleShot(100, fire_sigint)
157+
def custom_signal_handler(signum, frame):
158+
timer.start()
159+
signal.signal(signal.SIGUSR1, custom_signal_handler)
106160

107-
with pytest.raises(KeyboardInterrupt):
161+
try:
108162
target(**kwargs)
163+
except KeyboardInterrupt:
164+
print('SUCCESS', flush=True)
109165

110-
assert sigcld_caught
166+
167+
@pytest.mark.skipif(sys.platform == 'win32',
168+
reason='No other signal available to send on Windows')
169+
@pytest.mark.backend('QtAgg', skip_on_importerror=True)
170+
@pytest.mark.parametrize("target, kwargs", [
171+
('show', {'block': True}),
172+
('pause', {'interval': 10})
173+
])
174+
def test_other_signal_before_sigint(target, kwargs):
175+
backend = plt.get_backend()
176+
proc = WaitForStringPopen(
177+
[sys.executable, "-c",
178+
inspect.getsource(_test_other_signal_before_sigint_impl) +
179+
"\n_test_other_signal_before_sigint_impl("
180+
f"{backend!r}, {target!r}, {kwargs!r})"])
181+
try:
182+
proc.wait_for('DRAW')
183+
os.kill(proc.pid, signal.SIGUSR1)
184+
proc.wait_for('SIGUSR1')
185+
os.kill(proc.pid, signal.SIGINT)
186+
stdout, _ = proc.communicate(timeout=_test_timeout)
187+
except:
188+
proc.kill()
189+
stdout, _ = proc.communicate()
190+
raise
191+
print(stdout)
192+
assert 'SUCCESS' in stdout
193+
plt.figure()
111194

112195

113196
@pytest.mark.backend('Qt5Agg')
@@ -140,29 +223,31 @@ def custom_handler(signum, frame):
140223

141224
signal.signal(signal.SIGINT, custom_handler)
142225

143-
# mainloop() sets SIGINT, starts Qt event loop (which triggers timer and
144-
# exits) and then mainloop() resets SIGINT
145-
matplotlib.backends.backend_qt._BackendQT.mainloop()
226+
try:
227+
# mainloop() sets SIGINT, starts Qt event loop (which triggers timer
228+
# and exits) and then mainloop() resets SIGINT
229+
matplotlib.backends.backend_qt._BackendQT.mainloop()
146230

147-
# Assert: signal handler during loop execution is changed
148-
# (can't test equality with func)
149-
assert event_loop_handler != custom_handler
231+
# Assert: signal handler during loop execution is changed
232+
# (can't test equality with func)
233+
assert event_loop_handler != custom_handler
150234

151-
# Assert: current signal handler is the same as the one we set before
152-
assert signal.getsignal(signal.SIGINT) == custom_handler
235+
# Assert: current signal handler is the same as the one we set before
236+
assert signal.getsignal(signal.SIGINT) == custom_handler
153237

154-
# Repeat again to test that SIG_DFL and SIG_IGN will not be overridden
155-
for custom_handler in (signal.SIG_DFL, signal.SIG_IGN):
156-
qt_core.QTimer.singleShot(0, fire_signal_and_quit)
157-
signal.signal(signal.SIGINT, custom_handler)
238+
# Repeat again to test that SIG_DFL and SIG_IGN will not be overridden
239+
for custom_handler in (signal.SIG_DFL, signal.SIG_IGN):
240+
qt_core.QTimer.singleShot(0, fire_signal_and_quit)
241+
signal.signal(signal.SIGINT, custom_handler)
158242

159-
_BackendQT5.mainloop()
243+
_BackendQT5.mainloop()
160244

161-
assert event_loop_handler == custom_handler
162-
assert signal.getsignal(signal.SIGINT) == custom_handler
245+
assert event_loop_handler == custom_handler
246+
assert signal.getsignal(signal.SIGINT) == custom_handler
163247

164-
# Reset SIGINT handler to what it was before the test
165-
signal.signal(signal.SIGINT, original_handler)
248+
finally:
249+
# Reset SIGINT handler to what it was before the test
250+
signal.signal(signal.SIGINT, original_handler)
166251

167252

168253
@pytest.mark.parametrize(
@@ -548,8 +633,6 @@ def _get_testable_qt_backends():
548633
envs.append(pytest.param(env, marks=marks, id=str(env)))
549634
return envs
550635

551-
_test_timeout = 60 # A reasonably safe value for slower architectures.
552-
553636

554637
@pytest.mark.parametrize("env", _get_testable_qt_backends())
555638
def test_enums_available(env):

0 commit comments

Comments
 (0)