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..017b758f395c 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 @@ -240,15 +252,14 @@ class TestException(Exception): def raising_function(): raise RuntimeError + def raising_function_VE(): + raise ValueError + def transformer(excp): if isinstance(excp, RuntimeError): 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) @@ -257,18 +268,21 @@ 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, None], - [cb_old, RuntimeError], - [cb_filt, TestException]])(func) + [[cb_old, RuntimeError], + [cb_filt, TestException], + [cb_filt_pass, ValueError]])(func) @raising_cb_reg -def test_callbackregistry_process_exception(cb, excp): - if excp is not None: - with pytest.raises(excp): - cb.process('foo') - else: +def test_callbackregistry_custom_exception_handler(monkeypatch, cb, excp): + monkeypatch.setattr( + cbook, "_get_running_interactive_framework", lambda: None) + with pytest.raises(excp): cb.process('foo')