From 5324adaec6a7fd3d78dea7b28451d5f6e95392a6 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Wed, 3 Jun 2020 16:28:21 -0400 Subject: [PATCH 1/2] FIX: do not let no-op monkey patches to renderer leak out closes #17542 --- lib/matplotlib/backend_bases.py | 31 ++++++++++++++----------- lib/matplotlib/figure.py | 16 ++++++++++--- lib/matplotlib/tests/test_bbox_tight.py | 23 ++++++++++++++++++ lib/matplotlib/tight_layout.py | 2 +- 4 files changed, 54 insertions(+), 18 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 71eb153f24ab..8009207dd2a9 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -46,6 +46,7 @@ from matplotlib.backend_managers import ToolManager from matplotlib.transforms import Affine2D from matplotlib.path import Path +from matplotlib.cbook import _setattr_cm _log = logging.getLogger(__name__) @@ -1502,15 +1503,14 @@ def __init__(self, name, canvas, key, x=0, y=0, guiEvent=None): self.key = key -def _get_renderer(figure, print_method=None, *, draw_disabled=False): +def _get_renderer(figure, print_method=None): """ Get the renderer that would be used to save a `~.Figure`, and cache it on the figure. - If *draw_disabled* is True, additionally replace drawing methods on - *renderer* by no-ops. This is used by the tight-bbox-saving renderer, - which needs to walk through the artist tree to compute the tight-bbox, but - for which the output file may be closed early. + If you need a renderer without any active draw methods use + cbook._setattr_cm to temporary patch them out at your call site. + """ # This is implemented by triggering a draw, then immediately jumping out of # Figure.draw() by raising an exception. @@ -1529,12 +1529,6 @@ def _draw(renderer): raise Done(renderer) except Done as exc: renderer, = figure._cachedRenderer, = exc.args - if draw_disabled: - for meth_name in dir(RendererBase): - if (meth_name.startswith("draw_") - or meth_name in ["open_group", "close_group"]): - setattr(renderer, meth_name, lambda *args, **kwargs: None) - return renderer @@ -2093,9 +2087,18 @@ def print_figure( renderer = _get_renderer( self.figure, functools.partial( - print_method, orientation=orientation), - draw_disabled=True) - self.figure.draw(renderer) + print_method, orientation=orientation) + ) + no_ops = { + meth_name: lambda *args, **kwargs: None + for meth_name in dir(RendererBase) + if (meth_name.startswith("draw_") + or meth_name in ["open_group", "close_group"]) + } + + with _setattr_cm(renderer, **no_ops): + self.figure.draw(renderer) + bbox_inches = self.figure.get_tightbbox( renderer, bbox_extra_artists=bbox_extra_artists) if pad_inches is None: diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index 9e7fc4c2d16e..83577ac92b73 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -2392,6 +2392,8 @@ def tight_layout(self, renderer=None, pad=1.08, h_pad=None, w_pad=None, from .tight_layout import ( get_renderer, get_subplotspec_list, get_tight_layout_figure) + from .cbook import _setattr_cm + from .backend_bases import RendererBase subplotspec_list = get_subplotspec_list(self.axes) if None in subplotspec_list: @@ -2402,9 +2404,17 @@ def tight_layout(self, renderer=None, pad=1.08, h_pad=None, w_pad=None, if renderer is None: renderer = get_renderer(self) - kwargs = get_tight_layout_figure( - self, self.axes, subplotspec_list, renderer, - pad=pad, h_pad=h_pad, w_pad=w_pad, rect=rect) + no_ops = { + meth_name: lambda *args, **kwargs: None + for meth_name in dir(RendererBase) + if (meth_name.startswith("draw_") + or meth_name in ["open_group", "close_group"]) + } + + with _setattr_cm(renderer, **no_ops): + kwargs = get_tight_layout_figure( + self, self.axes, subplotspec_list, renderer, + pad=pad, h_pad=h_pad, w_pad=w_pad, rect=rect) if kwargs: self.subplots_adjust(**kwargs) diff --git a/lib/matplotlib/tests/test_bbox_tight.py b/lib/matplotlib/tests/test_bbox_tight.py index 4d52580e8b5d..235e02461f37 100644 --- a/lib/matplotlib/tests/test_bbox_tight.py +++ b/lib/matplotlib/tests/test_bbox_tight.py @@ -110,3 +110,26 @@ def test_tight_pcolorfast(): # Previously, the bbox would include the area of the image clipped out by # the axes, resulting in a very tall image given the y limits of (0, 0.1). assert width > height + + +def test_noop_tight_bbox(): + from PIL import Image + x_size, y_size = (10, 7) + dpi = 100 + # make the figure just the right size up front + fig = plt.figure(frameon=False, dpi=dpi, figsize=(x_size/dpi, y_size/dpi)) + ax = plt.Axes(fig, [0., 0., 1., 1.]) + fig.add_axes(ax) + ax.set_axis_off() + ax.get_xaxis().set_visible(False) + ax.get_yaxis().set_visible(False) + + data = np.arange(x_size * y_size).reshape(y_size, x_size) + ax.imshow(data) + out = BytesIO() + fig.savefig(out, bbox_inches='tight', pad_inches=0) + out.seek(0) + im = np.asarray(Image.open(out)) + assert (im[:, :, 3] == 255).all() + assert not (im[:, :, :3] == 255).all() + assert im.shape == (7, 10, 4) diff --git a/lib/matplotlib/tight_layout.py b/lib/matplotlib/tight_layout.py index 43b578fef625..df55005047f9 100644 --- a/lib/matplotlib/tight_layout.py +++ b/lib/matplotlib/tight_layout.py @@ -173,7 +173,7 @@ def get_renderer(fig): return canvas.get_renderer() else: from . import backend_bases - return backend_bases._get_renderer(fig, draw_disabled=True) + return backend_bases._get_renderer(fig) def get_subplotspec_list(axes_list, grid_spec=None): From f777177971f12c79d46e85819caad5539c5c221b Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Mon, 8 Jun 2020 18:58:42 -0400 Subject: [PATCH 2/2] MNT: consolidate the no-op logic into a RenderBase method Be forgiving about renderer instances that do not inherit from RendereBase. --- lib/matplotlib/backend_bases.py | 33 +++++++++++++++++++++++---------- lib/matplotlib/figure.py | 17 +++++------------ 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 8009207dd2a9..447fa0aa0e31 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -25,7 +25,7 @@ The base class for the Toolbar class of each interactive backend. """ -from contextlib import contextmanager +from contextlib import contextmanager, suppress from enum import Enum, IntEnum import functools import importlib @@ -709,6 +709,23 @@ def stop_filter(self, filter_func): Currently only supported by the agg renderer. """ + def _draw_disabled(self): + """ + Context manager to temporary disable drawing. + + This is used for getting the drawn size of Artists. This lets us + run the draw process to update any Python state but does not pay the + cost of the draw_XYZ calls on the canvas. + """ + no_ops = { + meth_name: lambda *args, **kwargs: None + for meth_name in dir(RendererBase) + if (meth_name.startswith("draw_") + or meth_name in ["open_group", "close_group"]) + } + + return _setattr_cm(self, **no_ops) + class GraphicsContextBase: """An abstract base class that provides color, line styles, etc.""" @@ -1509,7 +1526,7 @@ def _get_renderer(figure, print_method=None): the figure. If you need a renderer without any active draw methods use - cbook._setattr_cm to temporary patch them out at your call site. + renderer._draw_disabled to temporary patch them out at your call site. """ # This is implemented by triggering a draw, then immediately jumping out of @@ -2089,14 +2106,10 @@ def print_figure( functools.partial( print_method, orientation=orientation) ) - no_ops = { - meth_name: lambda *args, **kwargs: None - for meth_name in dir(RendererBase) - if (meth_name.startswith("draw_") - or meth_name in ["open_group", "close_group"]) - } - - with _setattr_cm(renderer, **no_ops): + ctx = (renderer._draw_disabled() + if hasattr(renderer, '_draw_disabled') + else suppress()) + with ctx: self.figure.draw(renderer) bbox_inches = self.figure.get_tightbbox( diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index 83577ac92b73..6af6870832af 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -2392,9 +2392,7 @@ def tight_layout(self, renderer=None, pad=1.08, h_pad=None, w_pad=None, from .tight_layout import ( get_renderer, get_subplotspec_list, get_tight_layout_figure) - from .cbook import _setattr_cm - from .backend_bases import RendererBase - + from contextlib import suppress subplotspec_list = get_subplotspec_list(self.axes) if None in subplotspec_list: cbook._warn_external("This figure includes Axes that are not " @@ -2403,15 +2401,10 @@ def tight_layout(self, renderer=None, pad=1.08, h_pad=None, w_pad=None, if renderer is None: renderer = get_renderer(self) - - no_ops = { - meth_name: lambda *args, **kwargs: None - for meth_name in dir(RendererBase) - if (meth_name.startswith("draw_") - or meth_name in ["open_group", "close_group"]) - } - - with _setattr_cm(renderer, **no_ops): + ctx = (renderer._draw_disabled() + if hasattr(renderer, '_draw_disabled') + else suppress()) + with ctx: kwargs = get_tight_layout_figure( self, self.axes, subplotspec_list, renderer, pad=pad, h_pad=h_pad, w_pad=w_pad, rect=rect)