From d9fef331442f9eca1aaafb5d8c200c80ca29a1d5 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Mon, 9 Aug 2021 21:46:24 -0600 Subject: [PATCH] ENH: Add the ability to block callback signals CallbackRegistry objects now have a context manager .block() method that can be used to temporarily block callback signals from being processed. --- .../next_whats_new/callback_blocking.rst | 25 ++++++++++++++ lib/matplotlib/cbook/__init__.py | 34 ++++++++++++++++++- lib/matplotlib/tests/test_cbook.py | 33 ++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 doc/users/next_whats_new/callback_blocking.rst diff --git a/doc/users/next_whats_new/callback_blocking.rst b/doc/users/next_whats_new/callback_blocking.rst new file mode 100644 index 000000000000..090e06c61bbf --- /dev/null +++ b/doc/users/next_whats_new/callback_blocking.rst @@ -0,0 +1,25 @@ +``CallbackRegistry`` objects gain a method to temporarily block signals +----------------------------------------------------------------------- + +The context manager `~matplotlib.cbook.CallbackRegistry.blocked` can be used +to block callback signals from being processed by the ``CallbackRegistry``. +The optional keyword, *signal*, can be used to block a specific signal +from being processed and let all other signals pass. + +.. code-block:: + + import matplotlib.pyplot as plt + + fig, ax = plt.subplots() + ax.imshow([[0, 1], [2, 3]]) + + # Block all interactivity through the canvas callbacks + with fig.canvas.callbacks.blocked(): + plt.show() + + fig, ax = plt.subplots() + ax.imshow([[0, 1], [2, 3]]) + + # Only block key press events + with fig.canvas.callbacks.blocked(signal="key_press_event"): + plt.show() diff --git a/lib/matplotlib/cbook/__init__.py b/lib/matplotlib/cbook/__init__.py index ae7e5cd056e0..109b9ea69cc9 100644 --- a/lib/matplotlib/cbook/__init__.py +++ b/lib/matplotlib/cbook/__init__.py @@ -122,7 +122,8 @@ def _weak_or_strong_ref(func, callback): class CallbackRegistry: """ - Handle registering and disconnecting for a set of signals and callbacks: + Handle registering, processing, blocking, and disconnecting + for a set of signals and callbacks: >>> def oneat(x): ... print('eat', x) @@ -140,9 +141,15 @@ class CallbackRegistry: >>> callbacks.process('eat', 456) eat 456 >>> callbacks.process('be merry', 456) # nothing will be called + >>> callbacks.disconnect(id_eat) >>> callbacks.process('eat', 456) # nothing will be called + >>> with callbacks.blocked(signal='drink'): + ... callbacks.process('drink', 123) # nothing will be called + >>> callbacks.process('drink', 123) + drink 123 + In practice, one should always disconnect all callbacks when they are no longer needed to avoid dangling references (and thus memory leaks). However, real code in Matplotlib rarely does so, and due to its design, @@ -280,6 +287,31 @@ def process(self, s, *args, **kwargs): else: raise + @contextlib.contextmanager + def blocked(self, *, signal=None): + """ + Block callback signals from being processed. + + A context manager to temporarily block/disable callback signals + from being processed by the registered listeners. + + Parameters + ---------- + signal : str, optional + The callback signal to block. The default is to block all signals. + """ + orig = self.callbacks + try: + if signal is None: + # Empty out the callbacks + self.callbacks = {} + else: + # Only remove the specific signal + self.callbacks = {k: orig[k] for k in orig if k != signal} + yield + finally: + self.callbacks = orig + class silent_list(list): """ diff --git a/lib/matplotlib/tests/test_cbook.py b/lib/matplotlib/tests/test_cbook.py index 47287524afde..474ea1a41d93 100644 --- a/lib/matplotlib/tests/test_cbook.py +++ b/lib/matplotlib/tests/test_cbook.py @@ -361,6 +361,39 @@ def test_callbackregistry_custom_exception_handler(monkeypatch, cb, excp): cb.process('foo') +def test_callbackregistry_blocking(): + # Needs an exception handler for interactive testing environments + # that would only print this out instead of raising the exception + def raise_handler(excp): + raise excp + cb = cbook.CallbackRegistry(exception_handler=raise_handler) + def test_func1(): + raise ValueError("1 should be blocked") + def test_func2(): + raise ValueError("2 should be blocked") + cb.connect("test1", test_func1) + cb.connect("test2", test_func2) + + # block all of the callbacks to make sure they aren't processed + with cb.blocked(): + cb.process("test1") + cb.process("test2") + + # block individual callbacks to make sure the other is still processed + with cb.blocked(signal="test1"): + # Blocked + cb.process("test1") + # Should raise + with pytest.raises(ValueError, match="2 should be blocked"): + cb.process("test2") + + # Make sure the original callback functions are there after blocking + with pytest.raises(ValueError, match="1 should be blocked"): + cb.process("test1") + with pytest.raises(ValueError, match="2 should be blocked"): + cb.process("test2") + + def test_sanitize_sequence(): d = {'a': 1, 'b': 2, 'c': 3} k = ['a', 'b', 'c']