Skip to content

Commit 6cf8da4

Browse files
committed
Rework interactive backends tests.
- Move the long test-scripts to separate functions and extract their source using `inspect.getsource` to pass that to the subprocess; this avoids having really long source snippets in strings and lets us have e.g. syntax highlighting on them. - Use ThreadPoolExecutor in test_interactive_thread_safety / _test_thread_impl which provides a much simpler (basically automatic) API to rethrow exceptions from within the thread.
1 parent 1c20824 commit 6cf8da4

File tree

1 file changed

+124
-140
lines changed

1 file changed

+124
-140
lines changed

lib/matplotlib/tests/test_backends_interactive.py

+124-140
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import importlib
22
import importlib.util
3+
import inspect
34
import json
45
import os
56
import signal
@@ -61,92 +62,96 @@ def _get_testable_interactive_backends():
6162
return backends
6263

6364

65+
_test_timeout = 10 # Empirically, 1s is not enough on Travis.
66+
67+
68+
# The source of this function gets extracted and run in another process, so it
69+
# must be fully self-contained.
6470
# Using a timer not only allows testing of timers (on other backends), but is
6571
# also necessary on gtk3 and wx, where a direct call to key_press_event("q")
6672
# from draw_event causes breakage due to the canvas widget being deleted too
6773
# early. Also, gtk3 redefines key_press_event with a different signature, so
6874
# we directly invoke it from the superclass instead.
69-
_test_script = """\
70-
import importlib.util
71-
import io
72-
import json
73-
import sys
74-
from unittest import TestCase
75-
76-
import matplotlib as mpl
77-
from matplotlib import pyplot as plt, rcParams
78-
from matplotlib.backend_bases import FigureCanvasBase
79-
rcParams.update({
80-
"webagg.open_in_browser": False,
81-
"webagg.port_retries": 1,
82-
})
83-
if len(sys.argv) >= 2: # Second argument is json-encoded rcParams.
84-
rcParams.update(json.loads(sys.argv[1]))
85-
backend = plt.rcParams["backend"].lower()
86-
assert_equal = TestCase().assertEqual
87-
assert_raises = TestCase().assertRaises
88-
89-
if backend.endswith("agg") and not backend.startswith(("gtk3", "web")):
90-
# Force interactive framework setup.
91-
plt.figure()
92-
93-
# Check that we cannot switch to a backend using another interactive
94-
# framework, but can switch to a backend using cairo instead of agg, or a
95-
# non-interactive backend. In the first case, we use tkagg as the "other"
96-
# interactive backend as it is (essentially) guaranteed to be present.
97-
# Moreover, don't test switching away from gtk3 (as Gtk.main_level() is
98-
# not set up at this point yet) and webagg (which uses no interactive
99-
# framework).
100-
101-
if backend != "tkagg":
102-
with assert_raises(ImportError):
103-
mpl.use("tkagg", force=True)
104-
105-
def check_alt_backend(alt_backend):
106-
mpl.use(alt_backend, force=True)
107-
fig = plt.figure()
108-
assert_equal(
109-
type(fig.canvas).__module__,
110-
"matplotlib.backends.backend_{}".format(alt_backend))
111-
112-
if importlib.util.find_spec("cairocffi"):
113-
check_alt_backend(backend[:-3] + "cairo")
114-
check_alt_backend("svg")
115-
116-
mpl.use(backend, force=True)
117-
118-
fig, ax = plt.subplots()
119-
assert_equal(
120-
type(fig.canvas).__module__,
121-
"matplotlib.backends.backend_{}".format(backend))
122-
123-
ax.plot([0, 1], [2, 3])
124-
125-
timer = fig.canvas.new_timer(1.) # Test that floats are cast to int as needed.
126-
timer.add_callback(FigureCanvasBase.key_press_event, fig.canvas, "q")
127-
# Trigger quitting upon draw.
128-
fig.canvas.mpl_connect("draw_event", lambda event: timer.start())
129-
fig.canvas.mpl_connect("close_event", print)
130-
131-
result = io.BytesIO()
132-
fig.savefig(result, format='png')
133-
134-
plt.show()
135-
136-
# Ensure that the window is really closed.
137-
plt.pause(0.5)
138-
139-
# Test that saving works after interactive window is closed, but the figure is
140-
# not deleted.
141-
result_after = io.BytesIO()
142-
fig.savefig(result_after, format='png')
143-
144-
if not backend.startswith('qt5') and sys.platform == 'darwin':
145-
# FIXME: This should be enabled everywhere once Qt5 is fixed on macOS to
146-
# not resize incorrectly.
147-
assert_equal(result.getvalue(), result_after.getvalue())
148-
"""
149-
_test_timeout = 10 # Empirically, 1s is not enough on Travis.
75+
def _test_interactive_impl():
76+
import importlib.util
77+
import io
78+
import json
79+
import sys
80+
from unittest import TestCase
81+
82+
import matplotlib as mpl
83+
from matplotlib import pyplot as plt, rcParams
84+
from matplotlib.backend_bases import FigureCanvasBase
85+
86+
rcParams.update({
87+
"webagg.open_in_browser": False,
88+
"webagg.port_retries": 1,
89+
})
90+
if len(sys.argv) >= 2: # Second argument is json-encoded rcParams.
91+
rcParams.update(json.loads(sys.argv[1]))
92+
backend = plt.rcParams["backend"].lower()
93+
assert_equal = TestCase().assertEqual
94+
assert_raises = TestCase().assertRaises
95+
96+
if backend.endswith("agg") and not backend.startswith(("gtk3", "web")):
97+
# Force interactive framework setup.
98+
plt.figure()
99+
100+
# Check that we cannot switch to a backend using another interactive
101+
# framework, but can switch to a backend using cairo instead of agg,
102+
# or a non-interactive backend. In the first case, we use tkagg as
103+
# the "other" interactive backend as it is (essentially) guaranteed
104+
# to be present. Moreover, don't test switching away from gtk3 (as
105+
# Gtk.main_level() is not set up at this point yet) and webagg (which
106+
# uses no interactive framework).
107+
108+
if backend != "tkagg":
109+
with assert_raises(ImportError):
110+
mpl.use("tkagg", force=True)
111+
112+
def check_alt_backend(alt_backend):
113+
mpl.use(alt_backend, force=True)
114+
fig = plt.figure()
115+
assert_equal(
116+
type(fig.canvas).__module__,
117+
"matplotlib.backends.backend_{}".format(alt_backend))
118+
119+
if importlib.util.find_spec("cairocffi"):
120+
check_alt_backend(backend[:-3] + "cairo")
121+
check_alt_backend("svg")
122+
123+
mpl.use(backend, force=True)
124+
125+
fig, ax = plt.subplots()
126+
assert_equal(
127+
type(fig.canvas).__module__,
128+
"matplotlib.backends.backend_{}".format(backend))
129+
130+
ax.plot([0, 1], [2, 3])
131+
132+
timer = fig.canvas.new_timer(1.) # Test floats casting to int as needed.
133+
timer.add_callback(FigureCanvasBase.key_press_event, fig.canvas, "q")
134+
# Trigger quitting upon draw.
135+
fig.canvas.mpl_connect("draw_event", lambda event: timer.start())
136+
fig.canvas.mpl_connect("close_event", print)
137+
138+
result = io.BytesIO()
139+
fig.savefig(result, format='png')
140+
141+
plt.show()
142+
143+
# Ensure that the window is really closed.
144+
plt.pause(0.5)
145+
146+
# Test that saving works after interactive window is closed, but the figure
147+
# is not deleted.
148+
result_after = io.BytesIO()
149+
fig.savefig(result_after, format='png')
150+
151+
if not backend.startswith('qt5') and sys.platform == 'darwin':
152+
# FIXME: This should be enabled everywhere once Qt5 is fixed on macOS
153+
# to not resize incorrectly.
154+
assert_equal(result.getvalue(), result_after.getvalue())
150155

151156

152157
@pytest.mark.parametrize("backend", _get_testable_interactive_backends())
@@ -161,7 +166,9 @@ def test_interactive_backend(backend, toolbar):
161166
pytest.skip("toolbar2 for macosx is buggy on Travis.")
162167

163168
proc = subprocess.run(
164-
[sys.executable, "-c", _test_script,
169+
[sys.executable, "-c",
170+
inspect.getsource(_test_interactive_impl)
171+
+ "\n_test_interactive_impl()",
165172
json.dumps({"toolbar": toolbar})],
166173
env={**os.environ, "MPLBACKEND": backend, "SOURCE_DATE_EPOCH": "0"},
167174
timeout=_test_timeout,
@@ -172,64 +179,38 @@ def test_interactive_backend(backend, toolbar):
172179
assert proc.stdout.count("CloseEvent") == 1
173180

174181

175-
_thread_test_script = """\
176-
import json
177-
import sys
178-
import threading
182+
# The source of this function gets extracted and run in another process, so it
183+
# must be fully self-contained.
184+
def _test_thread_impl():
185+
from concurrent.futures import ThreadPoolExecutor
186+
import json
187+
import sys
179188

180-
from matplotlib import pyplot as plt, rcParams
181-
rcParams.update({
182-
"webagg.open_in_browser": False,
183-
"webagg.port_retries": 1,
184-
})
185-
if len(sys.argv) >= 2: # Second argument is json-encoded rcParams.
186-
rcParams.update(json.loads(sys.argv[1]))
189+
from matplotlib import pyplot as plt, rcParams
187190

188-
# Test artist creation and drawing does not crash from thread
189-
# No other guarantees!
190-
fig, ax = plt.subplots()
191-
# plt.pause needed vs plt.show(block=False) at least on toolbar2-tkagg
192-
plt.pause(0.5)
191+
rcParams.update({
192+
"webagg.open_in_browser": False,
193+
"webagg.port_retries": 1,
194+
})
195+
if len(sys.argv) >= 2: # Second argument is json-encoded rcParams.
196+
rcParams.update(json.loads(sys.argv[1]))
193197

194-
exc_info = None
198+
# Test artist creation and drawing does not crash from thread
199+
# No other guarantees!
200+
fig, ax = plt.subplots()
201+
# plt.pause needed vs plt.show(block=False) at least on toolbar2-tkagg
202+
plt.pause(0.5)
203+
204+
future = ThreadPoolExecutor().submit(ax.plot, [1, 3, 6])
205+
future.result() # Joins the thread; rethrows any exception.
206+
207+
fig.canvas.mpl_connect("close_event", print)
208+
future = ThreadPoolExecutor().submit(fig.canvas.draw)
209+
plt.pause(0.5) # flush_events fails here on at least Tkagg (bpo-41176)
210+
future.result() # Joins the thread; rethrows any exception.
211+
plt.close()
212+
fig.canvas.flush_events() # pause doesn't process events after close
195213

196-
def thread_artist_work():
197-
try:
198-
ax.plot([1,3,6])
199-
except:
200-
# Propagate error to main thread
201-
import sys
202-
global exc_info
203-
exc_info = sys.exc_info()
204-
205-
def thread_draw_work():
206-
try:
207-
fig.canvas.draw()
208-
except:
209-
# Propagate error to main thread
210-
import sys
211-
global exc_info
212-
exc_info = sys.exc_info()
213-
214-
t = threading.Thread(target=thread_artist_work)
215-
t.start()
216-
# artists never wait for the event loop to run, so just join
217-
t.join()
218-
219-
if exc_info: # Raise thread error
220-
raise exc_info[1].with_traceback(exc_info[2])
221-
222-
t = threading.Thread(target=thread_draw_work)
223-
fig.canvas.mpl_connect("close_event", print)
224-
t.start()
225-
plt.pause(0.5) # flush_events fails here on at least Tkagg (bpo-41176)
226-
t.join()
227-
plt.close()
228-
fig.canvas.flush_events() # pause doesn't process events after close
229-
230-
if exc_info: # Raise thread error
231-
raise exc_info[1].with_traceback(exc_info[2])
232-
"""
233214

234215
_thread_safe_backends = _get_testable_interactive_backends()
235216
# Known unsafe backends. Remove the xfails if they start to pass!
@@ -249,7 +230,8 @@ def thread_draw_work():
249230
@pytest.mark.flaky(reruns=3)
250231
def test_interactive_thread_safety(backend):
251232
proc = subprocess.run(
252-
[sys.executable, "-c", _thread_test_script],
233+
[sys.executable, "-c",
234+
inspect.getsource(_test_thread_impl) + "\n_test_thread_impl()"],
253235
env={**os.environ, "MPLBACKEND": backend, "SOURCE_DATE_EPOCH": "0"},
254236
timeout=_test_timeout, check=True,
255237
stdout=subprocess.PIPE, universal_newlines=True)
@@ -261,9 +243,11 @@ def test_interactive_thread_safety(backend):
261243
@pytest.mark.skipif(os.name == "nt", reason="Cannot send SIGINT on Windows.")
262244
def test_webagg():
263245
pytest.importorskip("tornado")
264-
proc = subprocess.Popen([sys.executable, "-c", _test_script],
265-
env={**os.environ, "MPLBACKEND": "webagg",
266-
"SOURCE_DATE_EPOCH": "0"})
246+
proc = subprocess.Popen(
247+
[sys.executable, "-c",
248+
inspect.getsource(_test_interactive_impl)
249+
+ "\n_test_interactive_impl()"],
250+
env={**os.environ, "MPLBACKEND": "webagg", "SOURCE_DATE_EPOCH": "0"})
267251
url = "http://{}:{}".format(
268252
mpl.rcParams["webagg.address"], mpl.rcParams["webagg.port"])
269253
timeout = time.perf_counter() + _test_timeout

0 commit comments

Comments
 (0)