From 9b9dacfa687e02377bdcb012f23729710b1e938d Mon Sep 17 00:00:00 2001 From: daniel Date: Sun, 13 Sep 2026 00:26:38 -0400 Subject: [PATCH] Fix selectors when the onselect callback raises `_SelectorWidget.release` cleared `_eventpress` and `_eventrelease` only after `self._release(event)` returned, but `_release` is what calls the user's `onselect` callback. If that callback raised, the cleanup was skipped, and the stale `_eventpress` made `ignore` stop rejecting events outside the selector's Axes. The failed selector then kept reacting to later interactions, including events meant for another selector. Move the cleanup into a `finally` block so the exception still propagates while the selector stays usable afterwards. This covers all `_SelectorWidget` subclasses, which all call `onselect` from `_release`. Fixes #32327. --- lib/matplotlib/tests/test_widgets.py | 104 +++++++++++++++++++++++++++ lib/matplotlib/widgets.py | 11 +-- 2 files changed, 111 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/tests/test_widgets.py b/lib/matplotlib/tests/test_widgets.py index 1aa6b70e7790..c14de4bfb503 100644 --- a/lib/matplotlib/tests/test_widgets.py +++ b/lib/matplotlib/tests/test_widgets.py @@ -681,6 +681,110 @@ def test_span_selector_onselect(ax, interactive): onselect.assert_called_once() +@pytest.mark.parametrize('selector_factory', [ + lambda ax, onselect: widgets.SpanSelector(ax, onselect, 'horizontal'), + lambda ax, onselect: widgets.SpanSelector(ax, onselect, 'horizontal', + interactive=True), + lambda ax, onselect: widgets.RectangleSelector(ax, onselect), + lambda ax, onselect: widgets.RectangleSelector(ax, onselect, + interactive=True), +], ids=['SpanSelector', 'SpanSelector-interactive', + 'RectangleSelector', 'RectangleSelector-interactive']) +def test_selector_callback_exception_resets_state(ax, selector_factory): + # An exception from onselect must not leave the selector in a dragging + # state. + calls = [] + + def onselect(*args): + calls.append(args) + if len(calls) == 1: + raise RuntimeError("callback failure") + + tool = selector_factory(ax, onselect) + # The default handler prints callback exceptions instead of raising them + # when a GUI framework is running; force propagation for this test. + ax.figure.canvas.callbacks.exception_handler = None + + # Drag with the 'move' modifier held so that the 'move' state really is + # set when the callback raises. + with pytest.raises(RuntimeError, match="callback failure"): + MouseEvent._from_ax_coords( + "button_press_event", ax, (100, 100), 1, key=' ')._process() + MouseEvent._from_ax_coords( + "motion_notify_event", ax, (150, 150), 1, key=' ')._process() + MouseEvent._from_ax_coords( + "button_release_event", ax, (150, 150), 1, key=' ')._process() + + assert tool._eventpress is None + assert tool._eventrelease is None + assert 'move' not in tool._state + + # The selector can still be used after the callback raised. Start away + # from the previous shape so an interactive selector creates a new one + # rather than moving the existing one. + click_and_drag(tool, start=(20, 20), end=(70, 70)) + assert len(calls) == 2 + assert tool._selection_completed + + +def test_lasso_selector_callback_exception_resets_state(ax): + # LassoSelector uses the shared release handler but has a different + # callback signature. + calls = [] + + def onselect(verts): + calls.append(verts) + if len(calls) == 1: + raise RuntimeError("callback failure") + + tool = widgets.LassoSelector(ax, onselect) + ax.figure.canvas.callbacks.exception_handler = None + + with pytest.raises(RuntimeError, match="callback failure"): + click_and_drag(tool, start=(100, 100), end=(150, 150)) + + assert tool._eventpress is None + assert tool._eventrelease is None + + click_and_drag(tool, start=(100, 100), end=(150, 150)) + assert len(calls) == 2 + + +def test_span_selector_callback_exception_does_not_leak_to_other_selector(): + # A failed selector must not consume events that are meant for a selector + # on another Axes. + fig, (ax1, ax2) = plt.subplots(2, 1) + fig.canvas.draw() + # The default handler prints callback exceptions instead of raising them + # when a GUI framework is running; force propagation for this test. + fig.canvas.callbacks.exception_handler = None + + top_calls = [] + + def failing_onselect(vmin, vmax): + top_calls.append((vmin, vmax)) + raise RuntimeError("callback failure") + + selections = [] + top = widgets.SpanSelector(ax1, failing_onselect, 'horizontal') + bottom = widgets.SpanSelector( + ax2, lambda vmin, vmax: selections.append((vmin, vmax)), + 'horizontal') + + with pytest.raises(RuntimeError, match="callback failure"): + click_and_drag(top, start=(0.2, 0.2), end=(0.5, 0.2)) + + top_extents = top.extents + + click_and_drag(bottom, start=(0.2, 0.2), end=(0.6, 0.2)) + + # The failed selector must not react to the events on the other Axes. + assert len(top_calls) == 1 + assert top.extents == top_extents + assert selections == [(0.2, 0.6)] + assert bottom._selection_completed + + @pytest.mark.parametrize('ignore_event_outside', [True, False]) def test_span_selector_ignore_outside(ax, ignore_event_outside): onselect = mock.Mock(spec=noop, return_value=None) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index daec97f42ce6..7299883b22b8 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -2502,10 +2502,13 @@ def release(self, event): if not self.ignore(event) and self._eventpress: event = self._clean_event(event) self._eventrelease = event - self._release(event) - self._eventpress = None - self._eventrelease = None - self._state.discard('move') + try: + self._release(event) + finally: + # The callback may raise; reset the state regardless. + self._eventpress = None + self._eventrelease = None + self._state.discard('move') return True return False