test_backend_qt.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. import copy
  2. import importlib
  3. import os
  4. import signal
  5. import sys
  6. from datetime import date, datetime
  7. from unittest import mock
  8. import pytest
  9. import matplotlib
  10. from matplotlib import pyplot as plt
  11. from matplotlib._pylab_helpers import Gcf
  12. from matplotlib import _c_internal_utils
  13. try:
  14. from matplotlib.backends.qt_compat import QtGui # type: ignore[attr-defined] # noqa: E501, F401
  15. from matplotlib.backends.qt_compat import QtWidgets # type: ignore[attr-defined]
  16. from matplotlib.backends.qt_editor import _formlayout
  17. except ImportError:
  18. pytestmark = pytest.mark.skip('No usable Qt bindings')
  19. _test_timeout = 60 # A reasonably safe value for slower architectures.
  20. @pytest.fixture
  21. def qt_core(request):
  22. from matplotlib.backends.qt_compat import QtCore
  23. return QtCore
  24. @pytest.mark.backend('QtAgg', skip_on_importerror=True)
  25. def test_fig_close():
  26. # save the state of Gcf.figs
  27. init_figs = copy.copy(Gcf.figs)
  28. # make a figure using pyplot interface
  29. fig = plt.figure()
  30. # simulate user clicking the close button by reaching in
  31. # and calling close on the underlying Qt object
  32. fig.canvas.manager.window.close()
  33. # assert that we have removed the reference to the FigureManager
  34. # that got added by plt.figure()
  35. assert init_figs == Gcf.figs
  36. @pytest.mark.parametrize(
  37. "qt_key, qt_mods, answer",
  38. [
  39. ("Key_A", ["ShiftModifier"], "A"),
  40. ("Key_A", [], "a"),
  41. ("Key_A", ["ControlModifier"], ("ctrl+a")),
  42. (
  43. "Key_Aacute",
  44. ["ShiftModifier"],
  45. "\N{LATIN CAPITAL LETTER A WITH ACUTE}",
  46. ),
  47. ("Key_Aacute", [], "\N{LATIN SMALL LETTER A WITH ACUTE}"),
  48. ("Key_Control", ["AltModifier"], ("alt+control")),
  49. ("Key_Alt", ["ControlModifier"], "ctrl+alt"),
  50. (
  51. "Key_Aacute",
  52. ["ControlModifier", "AltModifier", "MetaModifier"],
  53. ("ctrl+alt+meta+\N{LATIN SMALL LETTER A WITH ACUTE}"),
  54. ),
  55. # We do not currently map the media keys, this may change in the
  56. # future. This means the callback will never fire
  57. ("Key_Play", [], None),
  58. ("Key_Backspace", [], "backspace"),
  59. (
  60. "Key_Backspace",
  61. ["ControlModifier"],
  62. "ctrl+backspace",
  63. ),
  64. ],
  65. ids=[
  66. 'shift',
  67. 'lower',
  68. 'control',
  69. 'unicode_upper',
  70. 'unicode_lower',
  71. 'alt_control',
  72. 'control_alt',
  73. 'modifier_order',
  74. 'non_unicode_key',
  75. 'backspace',
  76. 'backspace_mod',
  77. ]
  78. )
  79. @pytest.mark.parametrize('backend', [
  80. # Note: the value is irrelevant; the important part is the marker.
  81. pytest.param(
  82. 'Qt5Agg',
  83. marks=pytest.mark.backend('Qt5Agg', skip_on_importerror=True)),
  84. pytest.param(
  85. 'QtAgg',
  86. marks=pytest.mark.backend('QtAgg', skip_on_importerror=True)),
  87. ])
  88. def test_correct_key(backend, qt_core, qt_key, qt_mods, answer, monkeypatch):
  89. """
  90. Make a figure.
  91. Send a key_press_event event (using non-public, qtX backend specific api).
  92. Catch the event.
  93. Assert sent and caught keys are the same.
  94. """
  95. from matplotlib.backends.qt_compat import _to_int, QtCore
  96. if sys.platform == "darwin" and answer is not None:
  97. answer = answer.replace("ctrl", "cmd")
  98. answer = answer.replace("control", "cmd")
  99. answer = answer.replace("meta", "ctrl")
  100. result = None
  101. qt_mod = QtCore.Qt.KeyboardModifier.NoModifier
  102. for mod in qt_mods:
  103. qt_mod |= getattr(QtCore.Qt.KeyboardModifier, mod)
  104. class _Event:
  105. def isAutoRepeat(self): return False
  106. def key(self): return _to_int(getattr(QtCore.Qt.Key, qt_key))
  107. monkeypatch.setattr(QtWidgets.QApplication, "keyboardModifiers",
  108. lambda self: qt_mod)
  109. def on_key_press(event):
  110. nonlocal result
  111. result = event.key
  112. qt_canvas = plt.figure().canvas
  113. qt_canvas.mpl_connect('key_press_event', on_key_press)
  114. qt_canvas.keyPressEvent(_Event())
  115. assert result == answer
  116. @pytest.mark.backend('QtAgg', skip_on_importerror=True)
  117. def test_device_pixel_ratio_change(qt_core):
  118. """
  119. Make sure that if the pixel ratio changes, the figure dpi changes but the
  120. widget remains the same logical size.
  121. """
  122. prop = 'matplotlib.backends.backend_qt.FigureCanvasQT.devicePixelRatioF'
  123. with mock.patch(prop) as p:
  124. p.return_value = 3
  125. fig = plt.figure(figsize=(5, 2), dpi=120)
  126. qt_canvas = fig.canvas
  127. qt_canvas.show()
  128. def set_device_pixel_ratio(ratio):
  129. p.return_value = ratio
  130. window = qt_canvas.window().windowHandle()
  131. current_version = tuple(
  132. int(x) for x in qt_core.qVersion().split('.', 2)[:2])
  133. if current_version >= (6, 6):
  134. qt_core.QCoreApplication.sendEvent(
  135. window,
  136. qt_core.QEvent(qt_core.QEvent.Type.DevicePixelRatioChange))
  137. else:
  138. # The value here doesn't matter, as we can't mock the C++ QScreen
  139. # object, but can override the functional wrapper around it.
  140. # Emitting this event is simply to trigger the DPI change handler
  141. # in Matplotlib in the same manner that it would occur normally.
  142. window.screen().logicalDotsPerInchChanged.emit(96)
  143. qt_canvas.draw()
  144. qt_canvas.flush_events()
  145. # Make sure the mocking worked
  146. assert qt_canvas.device_pixel_ratio == ratio
  147. qt_canvas.manager.show()
  148. qt_canvas.draw()
  149. qt_canvas.flush_events()
  150. size = qt_canvas.size()
  151. options = [
  152. (None, 360, 1800, 720), # Use ratio at startup time.
  153. (3, 360, 1800, 720), # Change to same ratio.
  154. (2, 240, 1200, 480), # Change to different ratio.
  155. (1.5, 180, 900, 360), # Fractional ratio.
  156. ]
  157. for ratio, dpi, width, height in options:
  158. if ratio is not None:
  159. set_device_pixel_ratio(ratio)
  160. # The DPI and the renderer width/height change
  161. assert fig.dpi == dpi
  162. assert qt_canvas.renderer.width == width
  163. assert qt_canvas.renderer.height == height
  164. # The actual widget size and figure logical size don't change.
  165. assert size.width() == 600
  166. assert size.height() == 240
  167. assert qt_canvas.get_width_height() == (600, 240)
  168. assert (fig.get_size_inches() == (5, 2)).all()
  169. @pytest.mark.backend('QtAgg', skip_on_importerror=True)
  170. def test_subplottool():
  171. fig, ax = plt.subplots()
  172. with mock.patch("matplotlib.backends.qt_compat._exec", lambda obj: None):
  173. fig.canvas.manager.toolbar.configure_subplots()
  174. @pytest.mark.backend('QtAgg', skip_on_importerror=True)
  175. def test_figureoptions():
  176. fig, ax = plt.subplots()
  177. ax.plot([1, 2])
  178. ax.imshow([[1]])
  179. ax.scatter(range(3), range(3), c=range(3))
  180. with mock.patch("matplotlib.backends.qt_compat._exec", lambda obj: None):
  181. fig.canvas.manager.toolbar.edit_parameters()
  182. @pytest.mark.backend('QtAgg', skip_on_importerror=True)
  183. def test_save_figure_return(tmp_path):
  184. fig, ax = plt.subplots()
  185. ax.imshow([[1]])
  186. expected = tmp_path / "foobar.png"
  187. prop = "matplotlib.backends.qt_compat.QtWidgets.QFileDialog.getSaveFileName"
  188. with mock.patch(prop, return_value=(str(expected), None)):
  189. fname = fig.canvas.manager.toolbar.save_figure()
  190. assert fname == str(expected)
  191. assert expected.exists()
  192. with mock.patch(prop, return_value=(None, None)):
  193. fname = fig.canvas.manager.toolbar.save_figure()
  194. assert fname is None
  195. @pytest.mark.backend('QtAgg', skip_on_importerror=True)
  196. def test_figureoptions_with_datetime_axes():
  197. fig, ax = plt.subplots()
  198. xydata = [
  199. datetime(year=2021, month=1, day=1),
  200. datetime(year=2021, month=2, day=1)
  201. ]
  202. ax.plot(xydata, xydata)
  203. with mock.patch("matplotlib.backends.qt_compat._exec", lambda obj: None):
  204. fig.canvas.manager.toolbar.edit_parameters()
  205. @pytest.mark.backend('QtAgg', skip_on_importerror=True)
  206. def test_double_resize():
  207. # Check that resizing a figure twice keeps the same window size
  208. fig, ax = plt.subplots()
  209. fig.canvas.draw()
  210. window = fig.canvas.manager.window
  211. w, h = 3, 2
  212. fig.set_size_inches(w, h)
  213. assert fig.canvas.width() == w * matplotlib.rcParams['figure.dpi']
  214. assert fig.canvas.height() == h * matplotlib.rcParams['figure.dpi']
  215. old_width = window.width()
  216. old_height = window.height()
  217. fig.set_size_inches(w, h)
  218. assert window.width() == old_width
  219. assert window.height() == old_height
  220. @pytest.mark.backend('QtAgg', skip_on_importerror=True)
  221. def test_canvas_reinit():
  222. from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
  223. called = False
  224. def crashing_callback(fig, stale):
  225. nonlocal called
  226. fig.canvas.draw_idle()
  227. called = True
  228. fig, ax = plt.subplots()
  229. fig.stale_callback = crashing_callback
  230. # this should not raise
  231. canvas = FigureCanvasQTAgg(fig)
  232. fig.stale = True
  233. assert called
  234. @pytest.mark.backend('Qt5Agg', skip_on_importerror=True)
  235. def test_form_widget_get_with_datetime_and_date_fields():
  236. from matplotlib.backends.backend_qt import _create_qApp
  237. _create_qApp()
  238. form = [
  239. ("Datetime field", datetime(year=2021, month=3, day=11)),
  240. ("Date field", date(year=2021, month=3, day=11))
  241. ]
  242. widget = _formlayout.FormWidget(form)
  243. widget.setup()
  244. values = widget.get()
  245. assert values == [
  246. datetime(year=2021, month=3, day=11),
  247. date(year=2021, month=3, day=11)
  248. ]
  249. def _get_testable_qt_backends():
  250. envs = []
  251. for deps, env in [
  252. ([qt_api], {"MPLBACKEND": "qtagg", "QT_API": qt_api})
  253. for qt_api in ["PyQt6", "PySide6", "PyQt5", "PySide2"]
  254. ]:
  255. reason = None
  256. missing = [dep for dep in deps if not importlib.util.find_spec(dep)]
  257. if (sys.platform == "linux" and
  258. not _c_internal_utils.display_is_valid()):
  259. reason = "$DISPLAY and $WAYLAND_DISPLAY are unset"
  260. elif missing:
  261. reason = "{} cannot be imported".format(", ".join(missing))
  262. elif env["MPLBACKEND"] == 'macosx' and os.environ.get('TF_BUILD'):
  263. reason = "macosx backend fails on Azure"
  264. marks = []
  265. if reason:
  266. marks.append(pytest.mark.skip(
  267. reason=f"Skipping {env} because {reason}"))
  268. envs.append(pytest.param(env, marks=marks, id=str(env)))
  269. return envs
  270. @pytest.mark.backend('QtAgg', skip_on_importerror=True)
  271. def test_fig_sigint_override(qt_core):
  272. from matplotlib.backends.backend_qt5 import _BackendQT5
  273. # Create a figure
  274. plt.figure()
  275. # Variable to access the handler from the inside of the event loop
  276. event_loop_handler = None
  277. # Callback to fire during event loop: save SIGINT handler, then exit
  278. def fire_signal_and_quit():
  279. # Save event loop signal
  280. nonlocal event_loop_handler
  281. event_loop_handler = signal.getsignal(signal.SIGINT)
  282. # Request event loop exit
  283. qt_core.QCoreApplication.exit()
  284. # Timer to exit event loop
  285. qt_core.QTimer.singleShot(0, fire_signal_and_quit)
  286. # Save original SIGINT handler
  287. original_handler = signal.getsignal(signal.SIGINT)
  288. # Use our own SIGINT handler to be 100% sure this is working
  289. def custom_handler(signum, frame):
  290. pass
  291. signal.signal(signal.SIGINT, custom_handler)
  292. try:
  293. # mainloop() sets SIGINT, starts Qt event loop (which triggers timer
  294. # and exits) and then mainloop() resets SIGINT
  295. matplotlib.backends.backend_qt._BackendQT.mainloop()
  296. # Assert: signal handler during loop execution is changed
  297. # (can't test equality with func)
  298. assert event_loop_handler != custom_handler
  299. # Assert: current signal handler is the same as the one we set before
  300. assert signal.getsignal(signal.SIGINT) == custom_handler
  301. # Repeat again to test that SIG_DFL and SIG_IGN will not be overridden
  302. for custom_handler in (signal.SIG_DFL, signal.SIG_IGN):
  303. qt_core.QTimer.singleShot(0, fire_signal_and_quit)
  304. signal.signal(signal.SIGINT, custom_handler)
  305. _BackendQT5.mainloop()
  306. assert event_loop_handler == custom_handler
  307. assert signal.getsignal(signal.SIGINT) == custom_handler
  308. finally:
  309. # Reset SIGINT handler to what it was before the test
  310. signal.signal(signal.SIGINT, original_handler)
  311. @pytest.mark.backend('QtAgg', skip_on_importerror=True)
  312. def test_ipython():
  313. from matplotlib.testing import ipython_in_subprocess
  314. ipython_in_subprocess("qt", {(8, 24): "qtagg", (8, 15): "QtAgg", (7, 0): "Qt5Agg"})