From 89a5f9cba74a78cf229401ff172a75ce08324574 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Wed, 8 Apr 2020 20:11:35 +0200 Subject: [PATCH] Deprecate NavigationToolbar2._init_toolbar. As argued elsewhere, a customization point which requires third-party libraries to override a private method is awkward from the PoV of documentation and of required API stability. In fact _init_toolbar is not needed as a customization point; third-party libraries can simply override `__init__` and call `super().__init__` as appropriate. Moreover, *requiring* that `_init_toolbar` be overridden is actually overkill, e.g. for `test_backend_bases.py::test_interactive_zoom`: there, the base class NavigationToolbar2 is perfectly suitable -- see change there. In order to let third-parties write code that supports both pre- and post-deprecation versions of mpl, allow them to keep a fully empty `_init_toolbar` (an override is required by earlier versions of mpl) without triggering a deprecation warning. --- doc/api/api_changes_3.3/deprecations.rst | 10 +++ lib/matplotlib/backend_bases.py | 26 +++++-- lib/matplotlib/backends/_backend_tk.py | 68 ++++++------------ lib/matplotlib/backends/backend_gtk3.py | 72 +++++++++---------- lib/matplotlib/backends/backend_macosx.py | 5 +- lib/matplotlib/backends/backend_qt5.py | 53 +++++++------- .../backends/backend_webagg_core.py | 3 +- lib/matplotlib/backends/backend_wx.py | 28 ++++---- lib/matplotlib/cbook/deprecation.py | 16 ++++- lib/matplotlib/tests/test_backend_bases.py | 5 +- 10 files changed, 146 insertions(+), 140 deletions(-) diff --git a/doc/api/api_changes_3.3/deprecations.rst b/doc/api/api_changes_3.3/deprecations.rst index f686b701ee09..e3cbeb44f077 100644 --- a/doc/api/api_changes_3.3/deprecations.rst +++ b/doc/api/api_changes_3.3/deprecations.rst @@ -366,6 +366,16 @@ The ``Fil``, ``Fill``, ``Filll``, ``NegFil``, ``NegFill``, ``NegFilll``, and ``SsGlue`` classes in the :mod:`matplotlib.mathtext` module are deprecated. As an alternative, directly construct glue instances with ``Glue("fil")``, etc. +NavigationToolbar2._init_toolbar +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Overriding this method to initialize third-party toolbars is deprecated. +Instead, the toolbar should be initialized in the ``__init__`` method of the +subclass (which should call the base-class' ``__init__`` as appropriate). To +keep back-compatibility with earlier versions of Matplotlib (which *required* +``_init_toolbar`` to be overridden), a fully empty implementation (``def +_init_toolbar(self): pass``) may be kept and will not trigger the deprecation +warning. + NavigationToolbar2QT.parent and .basedir ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ These attributes are deprecated. In order to access the parent window, use diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 8f5c96c6f72e..ae53341095b5 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -2668,11 +2668,11 @@ def __str__(self): class NavigationToolbar2: """ - Base class for the navigation cursor, version 2 + Base class for the navigation cursor, version 2. - backends must implement a canvas that handles connections for + Backends must implement a canvas that handles connections for 'button_press_event' and 'button_release_event'. See - :meth:`FigureCanvasBase.mpl_connect` for more information + :meth:`FigureCanvasBase.mpl_connect` for more information. They must also define @@ -2682,9 +2682,6 @@ class NavigationToolbar2: :meth:`set_cursor` if you want the pointer icon to change - :meth:`_init_toolbar` - create your toolbar widget - :meth:`draw_rubberband` (optional) draw the zoom to rect "rubberband" rectangle @@ -2695,6 +2692,12 @@ class NavigationToolbar2: you can change the history back / forward buttons to indicate disabled / enabled state. + and override ``__init__`` to set up the toolbar -- without forgetting to + call the base-class init. Typically, ``__init__`` needs to set up toolbar + buttons connected to the `home`, `back`, `forward`, `pan`, `zoom`, and + `save_figure` methods and using standard icons in the "images" subdirectory + of the data path. + That's it, we'll do the rest! """ @@ -2724,7 +2727,15 @@ def __init__(self, canvas): self._xypress = None # location and axis info at the time of the press # This cursor will be set after the initial draw. self._lastCursor = cursors.POINTER - self._init_toolbar() + + init = cbook._deprecate_method_override( + __class__._init_toolbar, self, allow_empty=True, since="3.3", + addendum="Please fully initialize the toolbar in your subclass' " + "__init__; a fully empty _init_toolbar implementation may be kept " + "for compatibility with earlier versions of Matplotlib.") + if init: + init() + self._id_press = self.canvas.mpl_connect( 'button_press_event', self._zoom_pan_handler) self._id_release = self.canvas.mpl_connect( @@ -2787,6 +2798,7 @@ def home(self, *args): self.set_history_buttons() self._update_view() + @cbook.deprecated("3.3", alternative="__init__") def _init_toolbar(self): """ This is where you actually build the GUI widgets (called by diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index 1b613460a590..2170c93f77c3 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -491,6 +491,29 @@ def __init__(self, canvas, window, *, pack_toolbar=True): # Avoid using self.window (prefer self.canvas.get_tk_widget().master), # so that Tool implementations can reuse the methods. self.window = window + + tk.Frame.__init__(self, master=window, borderwidth=2, + width=int(canvas.figure.bbox.width), height=50) + + self._buttons = {} + for text, tooltip_text, image_file, callback in self.toolitems: + if text is None: + # Add a spacer; return value is unused. + self._Spacer() + else: + self._buttons[text] = button = self._Button( + text, + str(cbook._get_data_path(f"images/{image_file}.gif")), + toggle=callback in ["zoom", "pan"], + command=getattr(self, callback), + ) + if tooltip_text is not None: + ToolTip.createToolTip(button, tooltip_text) + + self.message = tk.StringVar(master=self) + self._message_label = tk.Label(master=self, textvariable=self.message) + self._message_label.pack(side=tk.RIGHT) + NavigationToolbar2.__init__(self, canvas) if pack_toolbar: self.pack(side=tk.BOTTOM, fill=tk.X) @@ -547,51 +570,6 @@ def _Spacer(self): s.pack(side=tk.LEFT, padx=5) return s - def _init_toolbar(self): - xmin, xmax = self.canvas.figure.bbox.intervalx - height, width = 50, xmax-xmin - tk.Frame.__init__(self, master=self.window, - width=int(width), height=int(height), - borderwidth=2) - - self.update() # Make axes menu - - self._buttons = {} - for text, tooltip_text, image_file, callback in self.toolitems: - if text is None: - # Add a spacer; return value is unused. - self._Spacer() - else: - self._buttons[text] = button = self._Button( - text, - str(cbook._get_data_path(f"images/{image_file}.gif")), - toggle=callback in ["zoom", "pan"], - command=getattr(self, callback), - ) - if tooltip_text is not None: - ToolTip.createToolTip(button, tooltip_text) - - self.message = tk.StringVar(master=self) - self._message_label = tk.Label(master=self, textvariable=self.message) - self._message_label.pack(side=tk.RIGHT) - - def _update_buttons_checked(self): - for name, mode in [("Pan", "PAN"), ("Zoom", "ZOOM")]: - button = self._buttons.get(name) - if button: - if self.mode.name == mode and not button.var.get(): - button.select() - elif self.mode.name != mode and button.var.get(): - button.deselect() - - def pan(self, *args): - super().pan(*args) - self._update_buttons_checked() - - def zoom(self, *args): - super().zoom(*args) - self._update_buttons_checked() - def configure_subplots(self): toolfig = Figure(figsize=(6, 3)) window = tk.Toplevel() diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index 52d7012b469c..d4812133d646 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -451,43 +451,7 @@ class NavigationToolbar2GTK3(NavigationToolbar2, Gtk.Toolbar): def __init__(self, canvas, window): self.win = window GObject.GObject.__init__(self) - NavigationToolbar2.__init__(self, canvas) - - @cbook.deprecated("3.3") - @property - def ctx(self): - return self.canvas.get_property("window").cairo_create() - - def set_message(self, s): - self.message.set_label(s) - - def set_cursor(self, cursor): - self.canvas.get_property("window").set_cursor(cursord[cursor]) - Gtk.main_iteration() - - def draw_rubberband(self, event, x0, y0, x1, y1): - # adapted from - # http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/189744 - ctx = self.canvas.get_property("window").cairo_create() - - # todo: instead of redrawing the entire figure, copy the part of - # the figure that was covered by the previous rubberband rectangle - self.canvas.draw() - height = self.canvas.figure.bbox.height - y1 = height - y1 - y0 = height - y0 - w = abs(x1 - x0) - h = abs(y1 - y0) - rect = [int(val) for val in (min(x0, x1), min(y0, y1), w, h)] - - ctx.new_path() - ctx.set_line_width(0.5) - ctx.rectangle(*rect) - ctx.set_source_rgb(0, 0, 0) - ctx.stroke() - - def _init_toolbar(self): self.set_style(Gtk.ToolbarStyle.ICONS) self._gtk_ids = {} @@ -521,6 +485,42 @@ def _init_toolbar(self): self.show_all() + NavigationToolbar2.__init__(self, canvas) + + @cbook.deprecated("3.3") + @property + def ctx(self): + return self.canvas.get_property("window").cairo_create() + + def set_message(self, s): + self.message.set_label(s) + + def set_cursor(self, cursor): + self.canvas.get_property("window").set_cursor(cursord[cursor]) + Gtk.main_iteration() + + def draw_rubberband(self, event, x0, y0, x1, y1): + # adapted from + # http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/189744 + self.ctx = self.canvas.get_property("window").cairo_create() + + # todo: instead of redrawing the entire figure, copy the part of + # the figure that was covered by the previous rubberband rectangle + self.canvas.draw() + + height = self.canvas.figure.bbox.height + y1 = height - y1 + y0 = height - y0 + w = abs(x1 - x0) + h = abs(y1 - y0) + rect = [int(val) for val in (min(x0, x1), min(y0, y1), w, h)] + + self.ctx.new_path() + self.ctx.set_line_width(0.5) + self.ctx.rectangle(rect[0], rect[1], rect[2], rect[3]) + self.ctx.set_source_rgb(0, 0, 0) + self.ctx.stroke() + def _update_buttons_checked(self): for name, active in [("Pan", "PAN"), ("Zoom", "ZOOM")]: button = self._gtk_ids.get(name) diff --git a/lib/matplotlib/backends/backend_macosx.py b/lib/matplotlib/backends/backend_macosx.py index bb75aeab7dc2..ad2f354db61a 100644 --- a/lib/matplotlib/backends/backend_macosx.py +++ b/lib/matplotlib/backends/backend_macosx.py @@ -110,11 +110,10 @@ def close(self): class NavigationToolbar2Mac(_macosx.NavigationToolbar2, NavigationToolbar2): def __init__(self, canvas): - NavigationToolbar2.__init__(self, canvas) - - def _init_toolbar(self): + self.canvas = canvas # Needed by the _macosx __init__. _macosx.NavigationToolbar2.__init__( self, str(cbook._get_data_path('images'))) + NavigationToolbar2.__init__(self, canvas) def draw_rubberband(self, event, x0, y0, x1, y1): self.canvas.set_rubberband(int(x0), int(y0), int(x1), int(y1)) diff --git a/lib/matplotlib/backends/backend_qt5.py b/lib/matplotlib/backends/backend_qt5.py index 897da61be67a..f0209cde2030 100644 --- a/lib/matplotlib/backends/backend_qt5.py +++ b/lib/matplotlib/backends/backend_qt5.py @@ -639,36 +639,12 @@ class NavigationToolbar2QT(NavigationToolbar2, QtWidgets.QToolBar): def __init__(self, canvas, parent, coordinates=True): """coordinates: should we show the coordinates on the right?""" + QtWidgets.QToolBar.__init__(self, parent) + self._parent = parent self.coordinates = coordinates self._actions = {} # mapping of toolitem method names to QActions. - QtWidgets.QToolBar.__init__(self, parent) - NavigationToolbar2.__init__(self, canvas) - - @cbook.deprecated("3.3", alternative="self.canvas.parent()") - @property - def parent(self): - return self._parent - - @cbook.deprecated( - "3.3", alternative="os.path.join(mpl.get_data_path(), 'images')") - @property - def basedir(self): - return str(cbook._get_data_path('images')) - - def _icon(self, name, color=None): - if is_pyqt5(): - name = name.replace('.png', '_large.png') - pm = QtGui.QPixmap(str(cbook._get_data_path('images', name))) - qt_compat._setDevicePixelRatio(pm, self.canvas._dpi_ratio) - if color is not None: - mask = pm.createMaskFromColor(QtGui.QColor('black'), - QtCore.Qt.MaskOutColor) - pm.fill(color) - pm.setMask(mask) - return QtGui.QIcon(pm) - def _init_toolbar(self): background_color = self.palette().color(self.backgroundRole()) foreground_color = self.palette().color(self.foregroundRole()) icon_color = (foreground_color @@ -699,6 +675,31 @@ def _init_toolbar(self): labelAction = self.addWidget(self.locLabel) labelAction.setVisible(True) + NavigationToolbar2.__init__(self, canvas) + + @cbook.deprecated("3.3", alternative="self.canvas.parent()") + @property + def parent(self): + return self._parent + + @cbook.deprecated( + "3.3", alternative="os.path.join(mpl.get_data_path(), 'images')") + @property + def basedir(self): + return str(cbook._get_data_path('images')) + + def _icon(self, name, color=None): + if is_pyqt5(): + name = name.replace('.png', '_large.png') + pm = QtGui.QPixmap(str(cbook._get_data_path('images', name))) + qt_compat._setDevicePixelRatio(pm, qt_compat._devicePixelRatio(self)) + if color is not None: + mask = pm.createMaskFromColor(QtGui.QColor('black'), + QtCore.Qt.MaskOutColor) + pm.fill(color) + pm.setMask(mask) + return QtGui.QIcon(pm) + def edit_parameters(self): axes = self.canvas.figure.get_axes() if not axes: diff --git a/lib/matplotlib/backends/backend_webagg_core.py b/lib/matplotlib/backends/backend_webagg_core.py index 6eceace69758..71f68ca52c6b 100644 --- a/lib/matplotlib/backends/backend_webagg_core.py +++ b/lib/matplotlib/backends/backend_webagg_core.py @@ -367,9 +367,10 @@ class NavigationToolbar2WebAgg(backend_bases.NavigationToolbar2): if name_of_method in _ALLOWED_TOOL_ITEMS ] - def _init_toolbar(self): + def __init__(self, canvas): self.message = '' self.cursor = 0 + super().__init__(canvas) def set_message(self, message): if message != self.message: diff --git a/lib/matplotlib/backends/backend_wx.py b/lib/matplotlib/backends/backend_wx.py index 1c9ca0be91a8..4e1513bdda16 100644 --- a/lib/matplotlib/backends/backend_wx.py +++ b/lib/matplotlib/backends/backend_wx.py @@ -1104,22 +1104,6 @@ def _set_frame_icon(frame): class NavigationToolbar2Wx(NavigationToolbar2, wx.ToolBar): def __init__(self, canvas): wx.ToolBar.__init__(self, canvas.GetParent(), -1) - NavigationToolbar2.__init__(self, canvas) - self._idle = True - self.prevZoomRect = None - # for now, use alternate zoom-rectangle drawing on all - # Macs. N.B. In future versions of wx it may be possible to - # detect Retina displays with window.GetContentScaleFactor() - # and/or dc.GetContentScaleFactor() - self.retinaFix = 'wxMac' in wx.PlatformInfo - - def get_canvas(self, frame, fig): - return type(self.canvas)(frame, -1, fig) - - def _init_toolbar(self): - _log.debug("%s - _init_toolbar", type(self)) - - self._parent = self.canvas.GetParent() self.wx_ids = {} for text, tooltip_text, image_file, callback in self.toolitems: @@ -1140,6 +1124,18 @@ def _init_toolbar(self): self.Realize() + NavigationToolbar2.__init__(self, canvas) + self._idle = True + self.prevZoomRect = None + # for now, use alternate zoom-rectangle drawing on all + # Macs. N.B. In future versions of wx it may be possible to + # detect Retina displays with window.GetContentScaleFactor() + # and/or dc.GetContentScaleFactor() + self.retinaFix = 'wxMac' in wx.PlatformInfo + + def get_canvas(self, frame, fig): + return type(self.canvas)(frame, -1, fig) + def zoom(self, *args): self.ToggleTool(self.wx_ids['Pan'], False) NavigationToolbar2.zoom(self, *args) diff --git a/lib/matplotlib/cbook/deprecation.py b/lib/matplotlib/cbook/deprecation.py index 235c8ee1ffe3..d62173cf2f77 100644 --- a/lib/matplotlib/cbook/deprecation.py +++ b/lib/matplotlib/cbook/deprecation.py @@ -429,7 +429,7 @@ def wrapper(*args, **kwargs): return wrapper -def _deprecate_method_override(method, obj, **kwargs): +def _deprecate_method_override(method, obj, *, allow_empty=False, **kwargs): """ Return ``obj.method`` with a deprecation if it was overridden, else None. @@ -442,13 +442,25 @@ def _deprecate_method_override(method, obj, **kwargs): being defined. obj An object of the class where *method* is defined. + allow_empty : bool, default: False + Whether to allow overrides by "empty" methods without emitting a + warning. **kwargs Additional parameters passed to `warn_deprecated` to generate the deprecation warning; must at least include the "since" key. """ + + def empty(): pass + def empty_with_docstring(): """doc""" + name = method.__name__ bound_method = getattr(obj, name) - if bound_method != method.__get__(obj): + if (bound_method != method.__get__(obj) + and (not allow_empty + or (getattr(getattr(bound_method, "__code__", None), + "co_code", None) + not in [empty.__code__.co_code, + empty_with_docstring.__code__.co_code]))): warn_deprecated(**{"name": name, "obj_type": "method", **kwargs}) return bound_method return None diff --git a/lib/matplotlib/tests/test_backend_bases.py b/lib/matplotlib/tests/test_backend_bases.py index 5ef21fe11277..7b6355407898 100644 --- a/lib/matplotlib/tests/test_backend_bases.py +++ b/lib/matplotlib/tests/test_backend_bases.py @@ -107,10 +107,7 @@ def test_interactive_zoom(): fig, ax = plt.subplots() ax.set(xscale="logit") - class NT2(NavigationToolbar2): - def _init_toolbar(self): pass - - tb = NT2(fig.canvas) + tb = NavigationToolbar2(fig.canvas) tb.zoom() xlim0 = ax.get_xlim()