diff --git a/doc/api/api_changes/2017-08_TAC.rst b/doc/api/api_changes/2017-08_TAC.rst new file mode 100644 index 000000000000..31e55c977c84 --- /dev/null +++ b/doc/api/api_changes/2017-08_TAC.rst @@ -0,0 +1,30 @@ +:meth:`matpltolib.cbook.CallbackRegistry.process` suppresses exceptions by default +`````````````````````````````````````````````````````````````````````````````````` + +Matplotlib uses instances of :obj:`~matplotlib.cbook.CallbackRegistry` +as a bridge between user input event from the GUI and user callbacks. +Previously, any exceptions raised in a user call back would bubble out +of of the ``process`` method, which is typically in the GUI event +loop. Most GUI frameworks simple print the traceback to the screen +and continue as there is not always a clear method of getting the +exception back to the user. However PyQt5 now exits the process when +it receives and un-handled python exception in the event loop. Thus, +:meth:`~matplotlib.cbook.CallbackRegistry.process` now suppresses and +prints tracebacks to stderr by default. + +What :meth:`~matplotlib.cbook.CallbackRegistry.process` does with exceptions +is now user configurable via the ``exception_handler`` attribute and kwarg. To +restore the previous behavior pass ``None`` :: + + cb = CallbackRegistry(exception_handler=None) + + +A function which take and ``Exception`` as its only argument may also be passed :: + + def maybe_reraise(exc): + if isinstance(exc, RuntimeError): + pass + else: + raise exc + + cb = CallbackRegistry(exception_handler=maybe_reraise) diff --git a/lib/matplotlib/cbook/__init__.py b/lib/matplotlib/cbook/__init__.py index 423319df6d43..80dfc8551c01 100644 --- a/lib/matplotlib/cbook/__init__.py +++ b/lib/matplotlib/cbook/__init__.py @@ -249,9 +249,12 @@ def __hash__(self): return self._hash +def _exception_printer(exc): + traceback.print_exc() + + class CallbackRegistry(object): - """ - Handle registering and disconnecting for a set of signals and + """Handle registering and disconnecting for a set of signals and callbacks: >>> def oneat(x): @@ -286,8 +289,29 @@ class CallbackRegistry(object): functions). This technique was shared by Peter Parente on his `"Mindtrove" blog `_. + + + Parameters + ---------- + exception_handler : callable, optional + If provided must have signature :: + + def handler(exc: Exception) -> None: + + If not None this function will be called with any `Exception` + subclass raised by the callbacks in `CallbackRegistry.process`. + The handler may either consume the exception or re-raise. + + The callable must be pickle-able. + + The default handler is :: + + def h(exc): + traceback.print_exc() + """ - def __init__(self): + def __init__(self, exception_handler=_exception_printer): + self.exception_handler = exception_handler self.callbacks = dict() self._cid = 0 self._func_cid_map = {} @@ -301,10 +325,10 @@ def __init__(self): # http://bugs.python.org/issue12290). def __getstate__(self): - return True + return {'exception_handler': self.exception_handler} def __setstate__(self, state): - self.__init__() + self.__init__(**state) def connect(self, s, func): """ @@ -365,6 +389,13 @@ def process(self, s, *args, **kwargs): proxy(*args, **kwargs) except ReferenceError: self._remove_proxy(proxy) + # this does not capture KeyboardInterrupt, SystemExit, + # and GeneratorExit + except Exception as exc: + if self.exception_handler is not None: + self.exception_handler(exc) + else: + raise class silent_list(list): diff --git a/lib/matplotlib/tests/test_cbook.py b/lib/matplotlib/tests/test_cbook.py index 7e9f845e3548..f254b173c1ae 100644 --- a/lib/matplotlib/tests/test_cbook.py +++ b/lib/matplotlib/tests/test_cbook.py @@ -262,6 +262,45 @@ def test_pickling(self): "callbacks") +def raising_cb_reg(func): + class TestException(Exception): + pass + + def raising_function(): + raise RuntimeError + + 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) + + # filter + cb_filt = cbook.CallbackRegistry(exception_handler=transformer) + cb_filt.connect('foo', raising_function) + + return pytest.mark.parametrize('cb, excp', + [[cb, None], + [cb_old, RuntimeError], + [cb_filt, TestException]])(func) + + +@raising_cb_reg +def test_callbackregistry_process_exception(cb, excp): + if excp is not None: + with pytest.raises(excp): + cb.process('foo') + else: + cb.process('foo') + + def test_sanitize_sequence(): d = {'a': 1, 'b': 2, 'c': 3} k = ['a', 'b', 'c']