From 9f6f71d79ad62d3b4ce7d6ed25c86992e54f253b Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Tue, 11 Aug 2020 20:36:33 -0400 Subject: [PATCH 1/4] Simplify some checks in widgets. --- lib/matplotlib/widgets.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 68bc543ba152..1ce7061dbed6 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -311,14 +311,13 @@ def __init__(self, ax, label, valmin, valmax, valinit=0.5, valfmt=None, super().__init__(ax) if slidermin is not None and not hasattr(slidermin, 'val'): - raise ValueError("Argument slidermin ({}) has no 'val'" - .format(type(slidermin))) + raise ValueError( + f"Argument slidermin ({type(slidermin)}) has no 'val'") if slidermax is not None and not hasattr(slidermax, 'val'): - raise ValueError("Argument slidermax ({}) has no 'val'" - .format(type(slidermax))) - if orientation not in ['horizontal', 'vertical']: - raise ValueError("Argument orientation ({}) must be either" - "'horizontal' or 'vertical'".format(orientation)) + raise ValueError( + f"Argument slidermax ({type(slidermax)}) has no 'val'") + cbook._check_in_list(['horizontal', 'vertical'], + orientation=orientation) self.orientation = orientation self.closedmin = closedmin @@ -629,8 +628,8 @@ def set_active(self, index): ValueError If *index* is invalid. """ - if not 0 <= index < len(self.labels): - raise ValueError("Invalid CheckButton index: %d" % index) + if index not in range(len(self.labels)): + raise ValueError(f'Invalid CheckButton index: {index}') l1, l2 = self.lines[index] l1.set_visible(not l1.get_visible()) @@ -1045,8 +1044,8 @@ def set_active(self, index): Callbacks will be triggered if :attr:`eventson` is True. """ - if 0 > index >= len(self.labels): - raise ValueError("Invalid RadioButton index: %d" % index) + if index not in range(len(self.labels)): + raise ValueError(f'Invalid RadioButton index: {index}') self.value_selected = self.labels[index].get_text() From da48bcd796ab8f94b0c84e2f5821303f6ee788e8 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Tue, 11 Aug 2020 20:37:39 -0400 Subject: [PATCH 2/4] Deprecated some internal Widget attributes. --- .../deprecations/18226-ES.rst | 12 ++ lib/matplotlib/widgets.py | 146 ++++++++++++------ 2 files changed, 115 insertions(+), 43 deletions(-) create mode 100644 doc/api/next_api_changes/deprecations/18226-ES.rst diff --git a/doc/api/next_api_changes/deprecations/18226-ES.rst b/doc/api/next_api_changes/deprecations/18226-ES.rst new file mode 100644 index 000000000000..ad85203d7876 --- /dev/null +++ b/doc/api/next_api_changes/deprecations/18226-ES.rst @@ -0,0 +1,12 @@ +Widget class internals +~~~~~~~~~~~~~~~~~~~~~~ + +Several `.widgets.Widget` class internals have been privatized and deprecated: + +* ``AxesWidget.cids`` +* ``Button.cnt`` and ``Button.observers`` +* ``CheckButtons.cnt`` and ``CheckButtons.observers`` +* ``RadioButtons.cnt`` and ``RadioButtons.observers`` +* ``Slider.cnt`` and ``Slider.observers`` +* ``TextBox.cnt``, ``TextBox.change_observers`` and + ``TextBox.submit_observers`` diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 1ce7061dbed6..fce2ae39ad0d 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -112,7 +112,12 @@ class AxesWidget(Widget): def __init__(self, ax): self.ax = ax self.canvas = ax.figure.canvas - self.cids = [] + self._cids = [] + + @cbook.deprecated("3.4") + @property + def cids(self): + return self._cids def connect_event(self, event, callback): """ @@ -122,11 +127,11 @@ def connect_event(self, event, callback): function stores callback ids for later clean up. """ cid = self.canvas.mpl_connect(event, callback) - self.cids.append(cid) + self._cids.append(cid) def disconnect_events(self): """Disconnect all events created by this widget.""" - for c in self.cids: + for c in self._cids: self.canvas.mpl_disconnect(c) @@ -175,8 +180,8 @@ def __init__(self, ax, label, image=None, horizontalalignment='center', transform=ax.transAxes) - self.cnt = 0 - self.observers = {} + self._cnt = 0 + self._observers = {} self.connect_event('button_press_event', self._click) self.connect_event('button_release_event', self._release) @@ -188,6 +193,16 @@ def __init__(self, ax, label, image=None, self.color = color self.hovercolor = hovercolor + @cbook.deprecated("3.4") + @property + def cnt(self): + return self._cnt + + @cbook.deprecated("3.4") + @property + def observers(self): + return self._observers + def _click(self, event): if (self.ignore(event) or event.inaxes != self.ax @@ -204,7 +219,7 @@ def _release(self, event): if (not self.eventson or event.inaxes != self.ax): return - for cid, func in self.observers.items(): + for cid, func in self._observers.items(): func(event) def _motion(self, event): @@ -222,15 +237,15 @@ def on_clicked(self, func): Returns a connection id, which can be used to disconnect the callback. """ - cid = self.cnt - self.observers[cid] = func - self.cnt += 1 + cid = self._cnt + self._observers[cid] = func + self._cnt += 1 return cid def disconnect(self, cid): """Remove the callback function with connection id *cid*.""" try: - del self.observers[cid] + del self._observers[cid] except KeyError: pass @@ -382,11 +397,21 @@ def __init__(self, ax, label, valmin, valmax, valinit=0.5, valfmt=None, verticalalignment='center', horizontalalignment='left') - self.cnt = 0 - self.observers = {} + self._cnt = 0 + self._observers = {} self.set_val(valinit) + @cbook.deprecated("3.4") + @property + def cnt(self): + return self._cnt + + @cbook.deprecated("3.4") + @property + def observers(self): + return self._observers + def _value_in_bounds(self, val): """Makes sure *val* is with given bounds.""" if self.valstep: @@ -469,7 +494,7 @@ def set_val(self, val): self.val = val if not self.eventson: return - for cid, func in self.observers.items(): + for cid, func in self._observers.items(): func(val) def on_changed(self, func): @@ -488,9 +513,9 @@ def on_changed(self, func): int Connection id (which can be used to disconnect *func*) """ - cid = self.cnt - self.observers[cid] = func - self.cnt += 1 + cid = self._cnt + self._observers[cid] = func + self._cnt += 1 return cid def disconnect(self, cid): @@ -503,7 +528,7 @@ def disconnect(self, cid): Connection id of the observer to be removed """ try: - del self.observers[cid] + del self._observers[cid] except KeyError: pass @@ -600,8 +625,18 @@ def __init__(self, ax, labels, actives=None): self.connect_event('button_press_event', self._clicked) - self.cnt = 0 - self.observers = {} + self._cnt = 0 + self._observers = {} + + @cbook.deprecated("3.4") + @property + def cnt(self): + return self._cnt + + @cbook.deprecated("3.4") + @property + def observers(self): + return self._observers def _clicked(self, event): if self.ignore(event) or event.button != 1 or event.inaxes != self.ax: @@ -640,7 +675,7 @@ def set_active(self, index): if not self.eventson: return - for cid, func in self.observers.items(): + for cid, func in self._observers.items(): func(self.labels[index].get_text()) def get_status(self): @@ -655,15 +690,15 @@ def on_clicked(self, func): Returns a connection id, which can be used to disconnect the callback. """ - cid = self.cnt - self.observers[cid] = func - self.cnt += 1 + cid = self._cnt + self._observers[cid] = func + self._cnt += 1 return cid def disconnect(self, cid): """Remove the observer with connection id *cid*.""" try: - del self.observers[cid] + del self._observers[cid] except KeyError: pass @@ -725,9 +760,9 @@ def __init__(self, ax, label, initial='', self.DIST_FROM_LEFT, 0.5, initial, transform=self.ax.transAxes, verticalalignment='center', horizontalalignment='left') - self.cnt = 0 - self.change_observers = {} - self.submit_observers = {} + self._cnt = 0 + self._change_observers = {} + self._submit_observers = {} ax.set( xlim=(0, 1), ylim=(0, 1), # s.t. cursor appears from first click. @@ -750,6 +785,21 @@ def __init__(self, ax, label, initial='', self.capturekeystrokes = False + @cbook.deprecated("3.4") + @property + def cnt(self): + return self._cnt + + @cbook.deprecated("3.4") + @property + def change_observers(self): + return self._change_observers + + @cbook.deprecated("3.4") + @property + def submit_observers(self): + return self._submit_observers + @property def text(self): return self.text_disp.get_text() @@ -780,7 +830,7 @@ def _rendercursor(self): def _notify_submit_observers(self): if self.eventson: - for cid, func in self.submit_observers.items(): + for cid, func in self._submit_observers.items(): func(self.text) def _release(self, event): @@ -836,7 +886,7 @@ def set_val(self, val): def _notify_change_observers(self): if self.eventson: - for cid, func in self.change_observers.items(): + for cid, func in self._change_observers.items(): func(self.text) def begin_typing(self, x): @@ -918,9 +968,9 @@ def on_text_change(self, func): A connection id is returned which can be used to disconnect. """ - cid = self.cnt - self.change_observers[cid] = func - self.cnt += 1 + cid = self._cnt + self._change_observers[cid] = func + self._cnt += 1 return cid def on_submit(self, func): @@ -930,14 +980,14 @@ def on_submit(self, func): A connection id is returned which can be used to disconnect. """ - cid = self.cnt - self.submit_observers[cid] = func - self.cnt += 1 + cid = self._cnt + self._submit_observers[cid] = func + self._cnt += 1 return cid def disconnect(self, cid): """Remove the observer with connection id *cid*.""" - for reg in [self.change_observers, self.submit_observers]: + for reg in [self._change_observers, self._submit_observers]: try: del reg[cid] except KeyError: @@ -1022,8 +1072,18 @@ def __init__(self, ax, labels, active=0, activecolor='blue'): self.connect_event('button_press_event', self._clicked) - self.cnt = 0 - self.observers = {} + self._cnt = 0 + self._observers = {} + + @cbook.deprecated("3.4") + @property + def cnt(self): + return self._cnt + + @cbook.deprecated("3.4") + @property + def observers(self): + return self._observers def _clicked(self, event): if self.ignore(event) or event.button != 1 or event.inaxes != self.ax: @@ -1061,7 +1121,7 @@ def set_active(self, index): if not self.eventson: return - for cid, func in self.observers.items(): + for cid, func in self._observers.items(): func(self.labels[index].get_text()) def on_clicked(self, func): @@ -1070,15 +1130,15 @@ def on_clicked(self, func): Returns a connection id, which can be used to disconnect the callback. """ - cid = self.cnt - self.observers[cid] = func - self.cnt += 1 + cid = self._cnt + self._observers[cid] = func + self._cnt += 1 return cid def disconnect(self, cid): """Remove the observer with connection id *cid*.""" try: - del self.observers[cid] + del self._observers[cid] except KeyError: pass From b9d9049ff8a55568b480e7b44abacc1cc7375602 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Tue, 11 Aug 2020 21:02:39 -0400 Subject: [PATCH 3/4] Use CallbackRegistry for all Widget event observers. This simplifies processing, fixes memory leaks with weak references, and fixes bugs like being unable to disconnect a callback in its own code. --- .../next_api_changes/behavior/18226-ES.rst | 10 ++ lib/matplotlib/widgets.py | 139 ++++++------------ 2 files changed, 56 insertions(+), 93 deletions(-) create mode 100644 doc/api/next_api_changes/behavior/18226-ES.rst diff --git a/doc/api/next_api_changes/behavior/18226-ES.rst b/doc/api/next_api_changes/behavior/18226-ES.rst new file mode 100644 index 000000000000..c6247737f662 --- /dev/null +++ b/doc/api/next_api_changes/behavior/18226-ES.rst @@ -0,0 +1,10 @@ +Widgets use ``CallbackRegistry`` to save callbacks +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +`.Widget`\s with event observers now use a `.CallbackRegistry` for storing +callbacks. This is consistent with canvas event callbacks, and fixes some bugs +in widget callback handling. + +Due to this change, callback methods are now stored as weak references, which +means you must keep a reference to the associated object. Otherwise it may be +garbage collected. diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index fce2ae39ad0d..95314722c649 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -180,8 +180,7 @@ def __init__(self, ax, label, image=None, horizontalalignment='center', transform=ax.transAxes) - self._cnt = 0 - self._observers = {} + self._observers = cbook.CallbackRegistry() self.connect_event('button_press_event', self._click) self.connect_event('button_release_event', self._release) @@ -196,12 +195,13 @@ def __init__(self, ax, label, image=None, @cbook.deprecated("3.4") @property def cnt(self): - return self._cnt + # Not real, but close enough. + return len(self._observers.callbacks['clicked']) @cbook.deprecated("3.4") @property def observers(self): - return self._observers + return self._observers.callbacks['clicked'] def _click(self, event): if (self.ignore(event) @@ -219,8 +219,7 @@ def _release(self, event): if (not self.eventson or event.inaxes != self.ax): return - for cid, func in self._observers.items(): - func(event) + self._observers.process('clicked', event) def _motion(self, event): if self.ignore(event): @@ -237,17 +236,11 @@ def on_clicked(self, func): Returns a connection id, which can be used to disconnect the callback. """ - cid = self._cnt - self._observers[cid] = func - self._cnt += 1 - return cid + return self._observers.connect('clicked', func) def disconnect(self, cid): """Remove the callback function with connection id *cid*.""" - try: - del self._observers[cid] - except KeyError: - pass + self._observers.disconnect(cid) class Slider(AxesWidget): @@ -397,20 +390,20 @@ def __init__(self, ax, label, valmin, valmax, valinit=0.5, valfmt=None, verticalalignment='center', horizontalalignment='left') - self._cnt = 0 - self._observers = {} + self._observers = cbook.CallbackRegistry() self.set_val(valinit) @cbook.deprecated("3.4") @property def cnt(self): - return self._cnt + # Not real, but close enough. + return len(self._observers.callbacks['changed']) @cbook.deprecated("3.4") @property def observers(self): - return self._observers + return self._observers.callbacks['changed'] def _value_in_bounds(self, val): """Makes sure *val* is with given bounds.""" @@ -494,8 +487,7 @@ def set_val(self, val): self.val = val if not self.eventson: return - for cid, func in self._observers.items(): - func(val) + self._observers.process('changed', val) def on_changed(self, func): """ @@ -513,10 +505,7 @@ def on_changed(self, func): int Connection id (which can be used to disconnect *func*) """ - cid = self._cnt - self._observers[cid] = func - self._cnt += 1 - return cid + return self._observers.connect('changed', func) def disconnect(self, cid): """ @@ -527,10 +516,7 @@ def disconnect(self, cid): cid : int Connection id of the observer to be removed """ - try: - del self._observers[cid] - except KeyError: - pass + self._observers.disconnect(cid) def reset(self): """Reset the slider to the initial value""" @@ -625,18 +611,18 @@ def __init__(self, ax, labels, actives=None): self.connect_event('button_press_event', self._clicked) - self._cnt = 0 - self._observers = {} + self._observers = cbook.CallbackRegistry() @cbook.deprecated("3.4") @property def cnt(self): - return self._cnt + # Not real, but close enough. + return len(self._observers.callbacks['clicked']) @cbook.deprecated("3.4") @property def observers(self): - return self._observers + return self._observers.callbacks['clicked'] def _clicked(self, event): if self.ignore(event) or event.button != 1 or event.inaxes != self.ax: @@ -675,8 +661,7 @@ def set_active(self, index): if not self.eventson: return - for cid, func in self._observers.items(): - func(self.labels[index].get_text()) + self._observers.process('clicked', self.labels[index].get_text()) def get_status(self): """ @@ -690,17 +675,11 @@ def on_clicked(self, func): Returns a connection id, which can be used to disconnect the callback. """ - cid = self._cnt - self._observers[cid] = func - self._cnt += 1 - return cid + return self._observers.connect('clicked', func) def disconnect(self, cid): """Remove the observer with connection id *cid*.""" - try: - del self._observers[cid] - except KeyError: - pass + self._observers.disconnect(cid) class TextBox(AxesWidget): @@ -760,9 +739,7 @@ def __init__(self, ax, label, initial='', self.DIST_FROM_LEFT, 0.5, initial, transform=self.ax.transAxes, verticalalignment='center', horizontalalignment='left') - self._cnt = 0 - self._change_observers = {} - self._submit_observers = {} + self._observers = cbook.CallbackRegistry() ax.set( xlim=(0, 1), ylim=(0, 1), # s.t. cursor appears from first click. @@ -788,17 +765,18 @@ def __init__(self, ax, label, initial='', @cbook.deprecated("3.4") @property def cnt(self): - return self._cnt + # Not real, but close enough. + return sum(len(d) for d in self._observers.callbacks.values()) @cbook.deprecated("3.4") @property def change_observers(self): - return self._change_observers + return self._observers.callbacks['change'] @cbook.deprecated("3.4") @property def submit_observers(self): - return self._submit_observers + return self._observers.callbacks['submit'] @property def text(self): @@ -828,11 +806,6 @@ def _rendercursor(self): self.ax.figure.canvas.draw() - def _notify_submit_observers(self): - if self.eventson: - for cid, func in self._submit_observers.items(): - func(self.text) - def _release(self, event): if self.ignore(event): return @@ -871,9 +844,10 @@ def _keypress(self, event): text[self.cursor_index + 1:]) self.text_disp.set_text(text) self._rendercursor() - self._notify_change_observers() - if key == "enter": - self._notify_submit_observers() + if self.eventson: + self._observers.process('change', self.text) + if key == "enter": + self._observers.process('submit', self.text) def set_val(self, val): newval = str(val) @@ -881,13 +855,9 @@ def set_val(self, val): return self.text_disp.set_text(newval) self._rendercursor() - self._notify_change_observers() - self._notify_submit_observers() - - def _notify_change_observers(self): if self.eventson: - for cid, func in self._change_observers.items(): - func(self.text) + self._observers.process('change', self.text) + self._observers.process('submit', self.text) def begin_typing(self, x): self.capturekeystrokes = True @@ -920,10 +890,10 @@ def stop_typing(self): self.capturekeystrokes = False self.cursor.set_visible(False) self.ax.figure.canvas.draw() - if notifysubmit: - # Because _notify_submit_observers might throw an error in the - # user's code, only call it once we've already done our cleanup. - self._notify_submit_observers() + if notifysubmit and self.eventson: + # Because process() might throw an error in the user's code, only + # call it once we've already done our cleanup. + self._observers.process('submit', self.text) def position_cursor(self, x): # now, we have to figure out where the cursor goes. @@ -968,10 +938,7 @@ def on_text_change(self, func): A connection id is returned which can be used to disconnect. """ - cid = self._cnt - self._change_observers[cid] = func - self._cnt += 1 - return cid + return self._observers.connect('change', func) def on_submit(self, func): """ @@ -980,18 +947,11 @@ def on_submit(self, func): A connection id is returned which can be used to disconnect. """ - cid = self._cnt - self._submit_observers[cid] = func - self._cnt += 1 - return cid + return self._observers.connect('submit', func) def disconnect(self, cid): """Remove the observer with connection id *cid*.""" - for reg in [self._change_observers, self._submit_observers]: - try: - del reg[cid] - except KeyError: - pass + self._observers.disconnect(cid) class RadioButtons(AxesWidget): @@ -1072,18 +1032,18 @@ def __init__(self, ax, labels, active=0, activecolor='blue'): self.connect_event('button_press_event', self._clicked) - self._cnt = 0 - self._observers = {} + self._observers = cbook.CallbackRegistry() @cbook.deprecated("3.4") @property def cnt(self): - return self._cnt + # Not real, but close enough. + return len(self._observers.callbacks['clicked']) @cbook.deprecated("3.4") @property def observers(self): - return self._observers + return self._observers.callbacks['clicked'] def _clicked(self, event): if self.ignore(event) or event.button != 1 or event.inaxes != self.ax: @@ -1121,8 +1081,7 @@ def set_active(self, index): if not self.eventson: return - for cid, func in self._observers.items(): - func(self.labels[index].get_text()) + self._observers.process('clicked', self.labels[index].get_text()) def on_clicked(self, func): """ @@ -1130,17 +1089,11 @@ def on_clicked(self, func): Returns a connection id, which can be used to disconnect the callback. """ - cid = self._cnt - self._observers[cid] = func - self._cnt += 1 - return cid + return self._observers.connect('clicked', func) def disconnect(self, cid): """Remove the observer with connection id *cid*.""" - try: - del self._observers[cid] - except KeyError: - pass + self._observers.disconnect(cid) class SubplotTool(Widget): From ae888ca7560079595a9936acfcf9b7d8ea49e561 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Tue, 11 Aug 2020 21:17:52 -0400 Subject: [PATCH 4/4] Shorten some conditions in widgets. --- lib/matplotlib/widgets.py | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 95314722c649..ee0711a8edd4 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -204,22 +204,17 @@ def observers(self): return self._observers.callbacks['clicked'] def _click(self, event): - if (self.ignore(event) - or event.inaxes != self.ax - or not self.eventson): + if self.ignore(event) or event.inaxes != self.ax or not self.eventson: return if event.canvas.mouse_grabber != self.ax: event.canvas.grab_mouse(self.ax) def _release(self, event): - if (self.ignore(event) - or event.canvas.mouse_grabber != self.ax): + if self.ignore(event) or event.canvas.mouse_grabber != self.ax: return event.canvas.release_mouse(self.ax) - if (not self.eventson - or event.inaxes != self.ax): - return - self._observers.process('clicked', event) + if self.eventson and event.inaxes == self.ax: + self._observers.process('clicked', event) def _motion(self, event): if self.ignore(event): @@ -485,9 +480,8 @@ def set_val(self, val): if self.drawon: self.ax.figure.canvas.draw_idle() self.val = val - if not self.eventson: - return - self._observers.process('changed', val) + if self.eventson: + self._observers.process('changed', val) def on_changed(self, func): """ @@ -659,9 +653,8 @@ def set_active(self, index): if self.drawon: self.ax.figure.canvas.draw() - if not self.eventson: - return - self._observers.process('clicked', self.labels[index].get_text()) + if self.eventson: + self._observers.process('clicked', self.labels[index].get_text()) def get_status(self): """ @@ -1079,9 +1072,8 @@ def set_active(self, index): if self.drawon: self.ax.figure.canvas.draw() - if not self.eventson: - return - self._observers.process('clicked', self.labels[index].get_text()) + if self.eventson: + self._observers.process('clicked', self.labels[index].get_text()) def on_clicked(self, func): """