From 084d79b110af99859228ab864afe4453c2c051da Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Sun, 15 Sep 2019 15:58:15 +0200 Subject: [PATCH 1/3] When no gui event loop is running, propagate callback exceptions. CallbackRegistry currently defaults to suppressing exceptions that occur in callbacks because they cause PyQt5 to abort() the program with no chance of even catching the exception, but such a behavior is annoying when writing tests involving callbacks (e.g., mplcursors), because in that case we actually want to see whether an exception occurred, and typically fail the test in that case. Instead we can detect whether a GUI event loop is currently running and propagate the exception if none is running. Part of the patch is just moving `_get_running_interactive_framework` from `backends` to `cbook`, so that `cbook` keeps its property of being importable independent of the rest of mpl. --- doc/api/next_api_changes/behaviour.rst | 8 +++++ lib/matplotlib/backend_bases.py | 3 +- lib/matplotlib/backends/__init__.py | 47 -------------------------- lib/matplotlib/cbook/__init__.py | 45 +++++++++++++++++++++++- lib/matplotlib/figure.py | 2 +- lib/matplotlib/pyplot.py | 6 ++-- lib/matplotlib/tests/test_cbook.py | 23 +++++++++---- 7 files changed, 72 insertions(+), 62 deletions(-) diff --git a/doc/api/next_api_changes/behaviour.rst b/doc/api/next_api_changes/behaviour.rst index edeef2c38c9c..259cdf88f2b6 100644 --- a/doc/api/next_api_changes/behaviour.rst +++ b/doc/api/next_api_changes/behaviour.rst @@ -17,3 +17,11 @@ Previously, it did not necessarily have such an attribute. A check for ``hasattr(figure.canvas, "manager")`` should now be replaced by ``figure.canvas.manager is not None`` (or ``getattr(figure.canvas, "manager", None) is not None`` for back-compatibility). + +`.cbook.CallbackRegistry` now propagates exceptions when no GUI event loop is running +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +`.cbook.CallbackRegistry` now defaults to propagating exceptions thrown by +callbacks when no interactive GUI event loop is running. If a GUI event loop +*is* running, `.cbook.CallbackRegistry` still defaults to just printing a +traceback, as unhandled exceptions can make the program completely ``abort()`` +in that case. diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 47b10a327e86..414afd862f78 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -45,7 +45,6 @@ import numpy as np -import matplotlib as mpl from matplotlib import ( backend_tools as tools, cbook, colors, textpath, tight_bbox, transforms, widgets, get_backend, is_interactive, rcParams) @@ -2552,7 +2551,7 @@ def show(self): warning in `.Figure.show`. """ # This should be overridden in GUI backends. - if mpl.backends._get_running_interactive_framework() != "headless": + if cbook._get_running_interactive_framework() != "headless": raise NonGuiException( f"Matplotlib is currently using {get_backend()}, which is " f"a non-GUI backend, so cannot show the figure.") diff --git a/lib/matplotlib/backends/__init__.py b/lib/matplotlib/backends/__init__.py index 5efffd0ddce4..6f4015d6ea8e 100644 --- a/lib/matplotlib/backends/__init__.py +++ b/lib/matplotlib/backends/__init__.py @@ -1,49 +1,2 @@ -import logging -import os -import sys - -_log = logging.getLogger(__name__) - - # NOTE: plt.switch_backend() (called at import time) will add a "backend" # attribute here for backcompat. - - -def _get_running_interactive_framework(): - """ - Return the interactive framework whose event loop is currently running, if - any, or "headless" if no event loop can be started, or None. - - Returns - ------- - Optional[str] - One of the following values: "qt5", "qt4", "gtk3", "wx", "tk", - "macosx", "headless", ``None``. - """ - QtWidgets = (sys.modules.get("PyQt5.QtWidgets") - or sys.modules.get("PySide2.QtWidgets")) - if QtWidgets and QtWidgets.QApplication.instance(): - return "qt5" - QtGui = (sys.modules.get("PyQt4.QtGui") - or sys.modules.get("PySide.QtGui")) - if QtGui and QtGui.QApplication.instance(): - return "qt4" - Gtk = sys.modules.get("gi.repository.Gtk") - if Gtk and Gtk.main_level(): - return "gtk3" - wx = sys.modules.get("wx") - if wx and wx.GetApp(): - return "wx" - tkinter = sys.modules.get("tkinter") - if tkinter: - for frame in sys._current_frames().values(): - while frame: - if frame.f_code == tkinter.mainloop.__code__: - return "tk" - frame = frame.f_back - if 'matplotlib.backends._macosx' in sys.modules: - if sys.modules["matplotlib.backends._macosx"].event_loop_is_running(): - return "macosx" - if sys.platform.startswith("linux") and not os.environ.get("DISPLAY"): - return "headless" - return None diff --git a/lib/matplotlib/cbook/__init__.py b/lib/matplotlib/cbook/__init__.py index de2f47dfd1d9..0cef14c04b2f 100644 --- a/lib/matplotlib/cbook/__init__.py +++ b/lib/matplotlib/cbook/__init__.py @@ -36,8 +36,51 @@ MatplotlibDeprecationWarning, mplDeprecation) +def _get_running_interactive_framework(): + """ + Return the interactive framework whose event loop is currently running, if + any, or "headless" if no event loop can be started, or None. + + Returns + ------- + Optional[str] + One of the following values: "qt5", "qt4", "gtk3", "wx", "tk", + "macosx", "headless", ``None``. + """ + QtWidgets = (sys.modules.get("PyQt5.QtWidgets") + or sys.modules.get("PySide2.QtWidgets")) + if QtWidgets and QtWidgets.QApplication.instance(): + return "qt5" + QtGui = (sys.modules.get("PyQt4.QtGui") + or sys.modules.get("PySide.QtGui")) + if QtGui and QtGui.QApplication.instance(): + return "qt4" + Gtk = sys.modules.get("gi.repository.Gtk") + if Gtk and Gtk.main_level(): + return "gtk3" + wx = sys.modules.get("wx") + if wx and wx.GetApp(): + return "wx" + tkinter = sys.modules.get("tkinter") + if tkinter: + for frame in sys._current_frames().values(): + while frame: + if frame.f_code == tkinter.mainloop.__code__: + return "tk" + frame = frame.f_back + if 'matplotlib.backends._macosx' in sys.modules: + if sys.modules["matplotlib.backends._macosx"].event_loop_is_running(): + return "macosx" + if sys.platform.startswith("linux") and not os.environ.get("DISPLAY"): + return "headless" + return None + + def _exception_printer(exc): - traceback.print_exc() + if _get_running_interactive_framework() in ["headless", None]: + raise exc + else: + traceback.print_exc() class _StrongRef: diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index 52ff5584eeff..b2c80c0f11d3 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -17,7 +17,7 @@ import numpy as np from matplotlib import rcParams -from matplotlib import backends, docstring, projections +from matplotlib import docstring, projections from matplotlib import __version__ as _mpl_version from matplotlib import get_backend diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 48bd0ad13b0f..6da0ed1fab93 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -66,7 +66,6 @@ FormatStrFormatter, ScalarFormatter, LogFormatter, LogFormatterExponent, LogFormatterMathtext, Locator, IndexLocator, FixedLocator, NullLocator, LinearLocator, LogLocator, AutoLocator, MultipleLocator, MaxNLocator) -from matplotlib.backends import _get_running_interactive_framework _log = logging.getLogger(__name__) @@ -226,8 +225,7 @@ def switch_backend(newbackend): required_framework = getattr( Backend.FigureCanvas, "required_interactive_framework", None) if required_framework is not None: - current_framework = \ - matplotlib.backends._get_running_interactive_framework() + current_framework = cbook._get_running_interactive_framework() if (current_framework and required_framework and current_framework != required_framework): raise ImportError( @@ -2250,7 +2248,7 @@ def getname_val(identifier): # is compatible with the current running interactive framework. if (rcParams["backend_fallback"] and dict.__getitem__(rcParams, "backend") in _interactive_bk - and _get_running_interactive_framework()): + and cbook._get_running_interactive_framework()): dict.__setitem__(rcParams, "backend", rcsetup._auto_backend_sentinel) # Set up the backend. switch_backend(rcParams["backend"]) diff --git a/lib/matplotlib/tests/test_cbook.py b/lib/matplotlib/tests/test_cbook.py index 82aec8495845..487c4587835a 100644 --- a/lib/matplotlib/tests/test_cbook.py +++ b/lib/matplotlib/tests/test_cbook.py @@ -233,6 +233,18 @@ def test_pickling(self): "callbacks") +def test_callbackregistry_default_exception_handler(monkeypatch): + cb = cbook.CallbackRegistry() + cb.connect("foo", lambda: None) + monkeypatch.setattr( + cbook, "_get_running_interactive_framework", lambda: None) + with pytest.raises(TypeError): + cb.process("foo", "argument mismatch") + monkeypatch.setattr( + cbook, "_get_running_interactive_framework", lambda: "not-none") + cb.process("foo", "argument mismatch") # No error in that case. + + def raising_cb_reg(func): class TestException(Exception): pass @@ -245,10 +257,6 @@ def transformer(excp): raise TestException raise excp - # default behavior - cb = cbook.CallbackRegistry() - cb.connect('foo', raising_function) - # old default cb_old = cbook.CallbackRegistry(exception_handler=None) cb_old.connect('foo', raising_function) @@ -258,13 +266,14 @@ def transformer(excp): cb_filt.connect('foo', raising_function) return pytest.mark.parametrize('cb, excp', - [[cb, None], - [cb_old, RuntimeError], + [[cb_old, RuntimeError], [cb_filt, TestException]])(func) @raising_cb_reg -def test_callbackregistry_process_exception(cb, excp): +def test_callbackregistry_custom_exception_handler(monkeypatch, cb, excp): + monkeypatch.setattr( + cbook, "_get_running_interactive_framework", lambda: None) if excp is not None: with pytest.raises(excp): cb.process('foo') From 2617f66f4419086436078d6f7ec893ff1d75a140 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Sat, 19 Oct 2019 20:53:49 -0400 Subject: [PATCH 2/3] TST: remove dead code path in tests --- lib/matplotlib/tests/test_cbook.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/matplotlib/tests/test_cbook.py b/lib/matplotlib/tests/test_cbook.py index 487c4587835a..8cee45e3f390 100644 --- a/lib/matplotlib/tests/test_cbook.py +++ b/lib/matplotlib/tests/test_cbook.py @@ -274,10 +274,7 @@ def transformer(excp): def test_callbackregistry_custom_exception_handler(monkeypatch, cb, excp): monkeypatch.setattr( cbook, "_get_running_interactive_framework", lambda: None) - if excp is not None: - with pytest.raises(excp): - cb.process('foo') - else: + with pytest.raises(excp): cb.process('foo') From e9c75447d53cb9bb2af191f4e554589247dd97c2 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Sat, 19 Oct 2019 23:23:55 -0400 Subject: [PATCH 3/3] TST: extend test coverage --- lib/matplotlib/tests/test_cbook.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/tests/test_cbook.py b/lib/matplotlib/tests/test_cbook.py index 8cee45e3f390..017b758f395c 100644 --- a/lib/matplotlib/tests/test_cbook.py +++ b/lib/matplotlib/tests/test_cbook.py @@ -252,6 +252,9 @@ class TestException(Exception): def raising_function(): raise RuntimeError + def raising_function_VE(): + raise ValueError + def transformer(excp): if isinstance(excp, RuntimeError): raise TestException @@ -265,9 +268,14 @@ def transformer(excp): cb_filt = cbook.CallbackRegistry(exception_handler=transformer) cb_filt.connect('foo', raising_function) + # filter + cb_filt_pass = cbook.CallbackRegistry(exception_handler=transformer) + cb_filt_pass.connect('foo', raising_function_VE) + return pytest.mark.parametrize('cb, excp', [[cb_old, RuntimeError], - [cb_filt, TestException]])(func) + [cb_filt, TestException], + [cb_filt_pass, ValueError]])(func) @raising_cb_reg