From 3e7bedb6ca4b29771aeb1555e2eb999db75b33f6 Mon Sep 17 00:00:00 2001 From: Ian Hunt-Isaak Date: Fri, 12 Dec 2025 12:28:57 -0500 Subject: [PATCH 1/6] add callback disconnect by passing function --- lib/matplotlib/cbook.py | 12 ++++++ lib/matplotlib/cbook.pyi | 1 + lib/matplotlib/tests/test_cbook.py | 67 ++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+) diff --git a/lib/matplotlib/cbook.py b/lib/matplotlib/cbook.py index a2a9e54792d9..3a28f3baa4f3 100644 --- a/lib/matplotlib/cbook.py +++ b/lib/matplotlib/cbook.py @@ -229,6 +229,9 @@ class CallbackRegistry: >>> callbacks.process('drink', 123) drink 123 + >>> callbacks.disconnect_func('drink', ondrink) # disconnect by func + >>> callbacks.process('drink', 123) # nothing will be called + 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, @@ -349,6 +352,15 @@ def disconnect(self, cid): if len(self.callbacks[signal]) == 0: # Clean up empty dicts del self.callbacks[signal] + def disconnect_func(self, signal, func): + """ + Disconnect the callback for *func* registered to *signal*. + + No error is raised if such a callback does not exist. + """ + proxy = _weak_or_strong_ref(func, None) + self._remove_proxy(signal, proxy) + def process(self, s, *args, **kwargs): """ Process signal *s*. diff --git a/lib/matplotlib/cbook.pyi b/lib/matplotlib/cbook.pyi index f7959a6fd0bb..c6fa8ce19a23 100644 --- a/lib/matplotlib/cbook.pyi +++ b/lib/matplotlib/cbook.pyi @@ -34,6 +34,7 @@ class CallbackRegistry: ) -> None: ... def connect(self, signal: Any, func: Callable) -> int: ... def disconnect(self, cid: int) -> None: ... + def disconnect_func(self, signal: Any, func: Callable) -> None: ... def process(self, s: Any, *args, **kwargs) -> None: ... def blocked( self, *, signal: Any | None = ... diff --git a/lib/matplotlib/tests/test_cbook.py b/lib/matplotlib/tests/test_cbook.py index 9b97d8e7e231..10a88e7faa21 100644 --- a/lib/matplotlib/tests/test_cbook.py +++ b/lib/matplotlib/tests/test_cbook.py @@ -292,6 +292,73 @@ def test_callback_wrong_disconnect(self, pickle, cls): # check we still have callbacks registered self.is_not_empty() + @pytest.mark.parametrize('pickle', [True, False]) + @pytest.mark.parametrize('cls', [Hashable, Unhashable]) + def test_callback_disconnect_func(self, pickle, cls): + # ensure we start with an empty registry + self.is_empty() + + # create a class for testing + mini_me = cls() + + # test that we can add a callback + self.connect(self.signal, mini_me.dummy, pickle) + self.is_not_empty() + + # disconnect by function reference + self.callbacks.disconnect_func(self.signal, mini_me.dummy) + + # check we now have no callbacks registered + self.is_empty() + + @pytest.mark.parametrize('pickle', [True, False]) + @pytest.mark.parametrize('cls', [Hashable, Unhashable]) + def test_callback_disconnect_func_wrong(self, pickle, cls): + # ensure we start with an empty registry + self.is_empty() + + # create a class for testing + mini_me = cls() + + # test that we can add a callback + self.connect(self.signal, mini_me.dummy, pickle) + self.is_not_empty() + + # try to disconnect with wrong signal - should do nothing + self.callbacks.disconnect_func('wrong_signal', mini_me.dummy) + + # check we still have callbacks registered + self.is_not_empty() + + # try to disconnect with wrong function - should do nothing + mini_me2 = cls() + self.callbacks.disconnect_func(self.signal, mini_me2.dummy) + + # check we still have callbacks registered + self.is_not_empty() + + def test_callback_disconnect_func_redefined(self): + # Test that redefining a function name doesn't affect disconnect_func. + # When you redefine a function, it creates a new function object, + # so disconnect_func should not disconnect the original. + self.is_empty() + + def func(): + pass + + self.callbacks.connect(self.signal, func) + self.is_not_empty() + + # Redefine func - this creates a new function object + def func(): + pass + + # Try to disconnect with the redefined function + self.callbacks.disconnect_func(self.signal, func) + + # Original callback should still be registered + self.is_not_empty() + @pytest.mark.parametrize('pickle', [True, False]) @pytest.mark.parametrize('cls', [Hashable, Unhashable]) def test_registration_on_non_empty_registry(self, pickle, cls): From 895bd46d306847fcd367bf24ef37ece9c6b6244d Mon Sep 17 00:00:00 2001 From: Ian Hunt-Isaak Date: Fri, 12 Dec 2025 12:33:27 -0500 Subject: [PATCH 2/6] re-use remove_proxy in disconnect --- lib/matplotlib/cbook.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/matplotlib/cbook.py b/lib/matplotlib/cbook.py index 3a28f3baa4f3..233c6a6c402f 100644 --- a/lib/matplotlib/cbook.py +++ b/lib/matplotlib/cbook.py @@ -340,17 +340,12 @@ def disconnect(self, cid): No error is raised if such a callback does not exist. """ - self._pickled_cids.discard(cid) for signal, proxy in self._func_cid_map: if self._func_cid_map[signal, proxy] == cid: break else: # Not found return - assert self.callbacks[signal][cid] == proxy - del self.callbacks[signal][cid] - self._func_cid_map.pop((signal, proxy)) - if len(self.callbacks[signal]) == 0: # Clean up empty dicts - del self.callbacks[signal] + self._remove_proxy(signal, proxy) def disconnect_func(self, signal, func): """ From 469e094229ef6e08fbcb3ee5bf669de0e54a43f5 Mon Sep 17 00:00:00 2001 From: Ian Hunt-Isaak Date: Fri, 12 Dec 2025 12:44:27 -0500 Subject: [PATCH 3/6] whats new note --- .../callback_registry_disconnect_func.rst | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 doc/release/next_whats_new/callback_registry_disconnect_func.rst diff --git a/doc/release/next_whats_new/callback_registry_disconnect_func.rst b/doc/release/next_whats_new/callback_registry_disconnect_func.rst new file mode 100644 index 000000000000..debeabe6b0db --- /dev/null +++ b/doc/release/next_whats_new/callback_registry_disconnect_func.rst @@ -0,0 +1,20 @@ +``CallbackRegistry.disconnect_func`` to disconnect callbacks by function +------------------------------------------------------------------------- + +`.CallbackRegistry` now has a `~.CallbackRegistry.disconnect_func` method that +allows disconnecting a callback by passing the signal and function directly, +instead of needing to track the callback ID returned by +`~.CallbackRegistry.connect`. + +.. code-block:: python + + from matplotlib.cbook import CallbackRegistry + + def my_callback(event): + print(event) + + callbacks = CallbackRegistry() + callbacks.connect('my_signal', my_callback) + + # Disconnect by function reference instead of callback ID + callbacks.disconnect_func('my_signal', my_callback) From da7a5cc573af023cbe4380ef5d686a676f71cd47 Mon Sep 17 00:00:00 2001 From: Ian Hunt-Isaak Date: Sat, 13 Dec 2025 14:52:14 -0500 Subject: [PATCH 4/6] simplify into single function with overload and api rename --- .../deprecations/30844-IHI.rst | 9 +++ lib/matplotlib/cbook.py | 54 ++++++++++----- lib/matplotlib/cbook.pyi | 6 +- lib/matplotlib/tests/test_cbook.py | 66 +++++++++++++++++-- 4 files changed, 109 insertions(+), 26 deletions(-) create mode 100644 doc/api/next_api_changes/deprecations/30844-IHI.rst diff --git a/doc/api/next_api_changes/deprecations/30844-IHI.rst b/doc/api/next_api_changes/deprecations/30844-IHI.rst new file mode 100644 index 000000000000..03d065df119b --- /dev/null +++ b/doc/api/next_api_changes/deprecations/30844-IHI.rst @@ -0,0 +1,9 @@ +``CallbackRegistry.disconnect`` *cid* parameter renamed to *cid_or_func* +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The *cid* parameter of `.CallbackRegistry.disconnect` has been renamed to +*cid_or_func*. The method now also accepts a callable, which will disconnect +that callback from all signals or from a specific signal if the *signal* +keyword argument is provided. + +``CallbackRegistry.disconnect_func`` has been removed; use +``disconnect(func, signal=...)`` instead. diff --git a/lib/matplotlib/cbook.py b/lib/matplotlib/cbook.py index 233c6a6c402f..763389ac61e5 100644 --- a/lib/matplotlib/cbook.py +++ b/lib/matplotlib/cbook.py @@ -229,7 +229,7 @@ class CallbackRegistry: >>> callbacks.process('drink', 123) drink 123 - >>> callbacks.disconnect_func('drink', ondrink) # disconnect by func + >>> callbacks.disconnect(ondrink, signal='drink') # disconnect by func >>> callbacks.process('drink', 123) # nothing will be called In practice, one should always disconnect all callbacks when they are @@ -334,27 +334,45 @@ def _remove_proxy(self, signal, proxy, *, _is_finalizing=sys.is_finalizing): if len(self.callbacks[signal]) == 0: # Clean up empty dicts del self.callbacks[signal] - def disconnect(self, cid): + @_api.rename_parameter("3.11", "cid", "cid_or_func") + def disconnect(self, cid_or_func, *, signal=None): """ - Disconnect the callback registered with callback id *cid*. - - No error is raised if such a callback does not exist. - """ - for signal, proxy in self._func_cid_map: - if self._func_cid_map[signal, proxy] == cid: - break - else: # Not found - return - self._remove_proxy(signal, proxy) - - def disconnect_func(self, signal, func): - """ - Disconnect the callback for *func* registered to *signal*. + Disconnect a callback. + Parameters + ---------- + cid_or_func : int or callable + If an int, disconnect the callback with that connection id. + If a callable, disconnect that function from signals. + signal : optional + Only used when *cid_or_func* is a callable. If given, disconnect + the function only from that specific signal. If not given, + disconnect from all signals the function is connected to. + + Notes + ----- No error is raised if such a callback does not exist. """ - proxy = _weak_or_strong_ref(func, None) - self._remove_proxy(signal, proxy) + if isinstance(cid_or_func, int): + if signal is not None: + raise ValueError( + "signal cannot be specified when disconnecting by cid") + for sig, proxy in self._func_cid_map: + if self._func_cid_map[sig, proxy] == cid_or_func: + break + else: # Not found + return + self._remove_proxy(sig, proxy) + elif signal is not None: + # Disconnect from a specific signal + proxy = _weak_or_strong_ref(cid_or_func, None) + self._remove_proxy(signal, proxy) + else: + # Disconnect from all signals + proxy = _weak_or_strong_ref(cid_or_func, None) + for sig, prx in list(self._func_cid_map): + if prx == proxy: + self._remove_proxy(sig, proxy) def process(self, s, *args, **kwargs): """ diff --git a/lib/matplotlib/cbook.pyi b/lib/matplotlib/cbook.pyi index c6fa8ce19a23..abd5196454c6 100644 --- a/lib/matplotlib/cbook.pyi +++ b/lib/matplotlib/cbook.pyi @@ -33,8 +33,10 @@ class CallbackRegistry: signals: Iterable[Any] | None = ..., ) -> None: ... def connect(self, signal: Any, func: Callable) -> int: ... - def disconnect(self, cid: int) -> None: ... - def disconnect_func(self, signal: Any, func: Callable) -> None: ... + @overload + def disconnect(self, cid_or_func: int) -> None: ... + @overload + def disconnect(self, cid_or_func: Callable, *, signal: Any | None = ...) -> None: ... def process(self, s: Any, *args, **kwargs) -> None: ... def blocked( self, *, signal: Any | None = ... diff --git a/lib/matplotlib/tests/test_cbook.py b/lib/matplotlib/tests/test_cbook.py index 10a88e7faa21..bfcab398d34e 100644 --- a/lib/matplotlib/tests/test_cbook.py +++ b/lib/matplotlib/tests/test_cbook.py @@ -306,7 +306,7 @@ def test_callback_disconnect_func(self, pickle, cls): self.is_not_empty() # disconnect by function reference - self.callbacks.disconnect_func(self.signal, mini_me.dummy) + self.callbacks.disconnect(mini_me.dummy, signal=self.signal) # check we now have no callbacks registered self.is_empty() @@ -325,22 +325,22 @@ def test_callback_disconnect_func_wrong(self, pickle, cls): self.is_not_empty() # try to disconnect with wrong signal - should do nothing - self.callbacks.disconnect_func('wrong_signal', mini_me.dummy) + self.callbacks.disconnect(mini_me.dummy, signal='wrong_signal') # check we still have callbacks registered self.is_not_empty() # try to disconnect with wrong function - should do nothing mini_me2 = cls() - self.callbacks.disconnect_func(self.signal, mini_me2.dummy) + self.callbacks.disconnect(mini_me2.dummy, signal=self.signal) # check we still have callbacks registered self.is_not_empty() def test_callback_disconnect_func_redefined(self): - # Test that redefining a function name doesn't affect disconnect_func. + # Test that redefining a function name doesn't affect disconnect. # When you redefine a function, it creates a new function object, - # so disconnect_func should not disconnect the original. + # so disconnect should not disconnect the original. self.is_empty() def func(): @@ -354,11 +354,65 @@ def func(): pass # Try to disconnect with the redefined function - self.callbacks.disconnect_func(self.signal, func) + self.callbacks.disconnect(func, signal=self.signal) # Original callback should still be registered self.is_not_empty() + @pytest.mark.parametrize('pickle', [True, False]) + @pytest.mark.parametrize('cls', [Hashable, Unhashable]) + def test_callback_disconnect_func_all_signals(self, pickle, cls): + # Test disconnecting a callback from all signals at once + self.is_empty() + + mini_me = cls() + + # Connect to multiple signals + self.callbacks.connect('signal1', mini_me.dummy) + self.callbacks.connect('signal2', mini_me.dummy) + assert len(list(self.callbacks._func_cid_map)) == 2 + + # Disconnect from all signals at once (no signal specified) + self.callbacks.disconnect(mini_me.dummy) + + # All callbacks should be removed + self.is_empty() + + def test_disconnect_cid_with_signal_raises(self): + # Passing signal with a cid should raise an error + self.is_empty() + cid = self.callbacks.connect(self.signal, lambda: None) + with pytest.raises(ValueError, match="signal cannot be specified"): + self.callbacks.disconnect(cid, signal=self.signal) + + @pytest.mark.parametrize('pickle', [True, False]) + @pytest.mark.parametrize('cls', [Hashable, Unhashable]) + def test_callback_disconnect_func_selective(self, pickle, cls): + # Test selectively disconnecting a callback from one signal + # while keeping it connected to another + self.is_empty() + + mini_me = cls() + + # Connect same function to multiple signals + self.callbacks.connect('signal1', mini_me.dummy) + self.callbacks.connect('signal2', mini_me.dummy) + assert len(list(self.callbacks._func_cid_map)) == 2 + + # Disconnect from only signal1 + self.callbacks.disconnect(mini_me.dummy, signal='signal1') + + # Should still have one callback registered (on signal2) + assert len(list(self.callbacks._func_cid_map)) == 1 + assert 'signal2' in self.callbacks.callbacks + assert 'signal1' not in self.callbacks.callbacks + + # Disconnect from signal2 + self.callbacks.disconnect(mini_me.dummy, signal='signal2') + + # Now all should be removed + self.is_empty() + @pytest.mark.parametrize('pickle', [True, False]) @pytest.mark.parametrize('cls', [Hashable, Unhashable]) def test_registration_on_non_empty_registry(self, pickle, cls): From a019b4ff828feb46852c5e66ca272bda3d4fdf4b Mon Sep 17 00:00:00 2001 From: Ian Hunt-Isaak Date: Sun, 14 Dec 2025 00:52:12 -0500 Subject: [PATCH 5/6] update change log --- doc/api/next_api_changes/deprecations/30844-IHI.rst | 3 --- .../callback_registry_disconnect_func.rst | 11 +++++------ 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/doc/api/next_api_changes/deprecations/30844-IHI.rst b/doc/api/next_api_changes/deprecations/30844-IHI.rst index 03d065df119b..55ebe9af6d68 100644 --- a/doc/api/next_api_changes/deprecations/30844-IHI.rst +++ b/doc/api/next_api_changes/deprecations/30844-IHI.rst @@ -4,6 +4,3 @@ The *cid* parameter of `.CallbackRegistry.disconnect` has been renamed to *cid_or_func*. The method now also accepts a callable, which will disconnect that callback from all signals or from a specific signal if the *signal* keyword argument is provided. - -``CallbackRegistry.disconnect_func`` has been removed; use -``disconnect(func, signal=...)`` instead. diff --git a/doc/release/next_whats_new/callback_registry_disconnect_func.rst b/doc/release/next_whats_new/callback_registry_disconnect_func.rst index debeabe6b0db..f4688ba0af3f 100644 --- a/doc/release/next_whats_new/callback_registry_disconnect_func.rst +++ b/doc/release/next_whats_new/callback_registry_disconnect_func.rst @@ -1,10 +1,9 @@ -``CallbackRegistry.disconnect_func`` to disconnect callbacks by function +``CallbackRegistry.disconnect`` allows directly callbacks by function ------------------------------------------------------------------------- -`.CallbackRegistry` now has a `~.CallbackRegistry.disconnect_func` method that -allows disconnecting a callback by passing the signal and function directly, -instead of needing to track the callback ID returned by -`~.CallbackRegistry.connect`. +`.CallbackRegistry` now allows directly passing a function and optionally signal +`~.CallbackRegistry.disconnect` to disconnect instead of needing to track the callback +ID returned by `~.CallbackRegistry.connect`. .. code-block:: python @@ -17,4 +16,4 @@ instead of needing to track the callback ID returned by callbacks.connect('my_signal', my_callback) # Disconnect by function reference instead of callback ID - callbacks.disconnect_func('my_signal', my_callback) + callbacks.disconnect('my_signal', my_callback) From 8c88f8acf1e901a7dbd7c1b7121304a2cdab214e Mon Sep 17 00:00:00 2001 From: Ian Hunt-Isaak Date: Sun, 14 Dec 2025 00:52:54 -0500 Subject: [PATCH 6/6] grammar --- .../next_whats_new/callback_registry_disconnect_func.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/release/next_whats_new/callback_registry_disconnect_func.rst b/doc/release/next_whats_new/callback_registry_disconnect_func.rst index f4688ba0af3f..05825fe7e774 100644 --- a/doc/release/next_whats_new/callback_registry_disconnect_func.rst +++ b/doc/release/next_whats_new/callback_registry_disconnect_func.rst @@ -1,9 +1,9 @@ ``CallbackRegistry.disconnect`` allows directly callbacks by function ------------------------------------------------------------------------- -`.CallbackRegistry` now allows directly passing a function and optionally signal -`~.CallbackRegistry.disconnect` to disconnect instead of needing to track the callback -ID returned by `~.CallbackRegistry.connect`. +`.CallbackRegistry` now allows directly passing a function and optionally signal to +`~.CallbackRegistry.disconnect` instead of needing to track the callback ID +returned by `~.CallbackRegistry.connect`. .. code-block:: python