diff --git a/doc/api/next_api_changes/removals/21395-AL.rst b/doc/api/next_api_changes/removals/21395-AL.rst new file mode 100644 index 000000000000..8983b8b65819 --- /dev/null +++ b/doc/api/next_api_changes/removals/21395-AL.rst @@ -0,0 +1,3 @@ +Unknown keyword arguments to ``savefig`` and ``FigureCanvas.print_foo`` methods +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +... now raise a TypeError, instead of being ignored. diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index e3d1b3efbd83..a6f9ab283d16 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -34,10 +34,8 @@ import io import logging import os -import re import sys import time -import traceback from weakref import WeakKeyDictionary import numpy as np @@ -1534,14 +1532,14 @@ class Done(Exception): def _draw(renderer): raise Done(renderer) - with cbook._setattr_cm(figure, draw=_draw): + with cbook._setattr_cm(figure, draw=_draw), ExitStack() as stack: orig_canvas = figure.canvas if print_method is None: fmt = figure.canvas.get_default_filetype() # Even for a canvas' default output type, a canvas switch may be # needed, e.g. for FigureCanvasBase. - print_method = getattr( - figure.canvas._get_output_canvas(None, fmt), f"print_{fmt}") + print_method = stack.enter_context( + figure.canvas._switch_canvas_and_return_print_method(fmt)) try: print_method(io.BytesIO()) except Done as exc: @@ -1550,8 +1548,6 @@ def _draw(renderer): raise Done(renderer) else: raise RuntimeError(f"{print_method} did not call Figure.draw, so " f"no renderer is available") - finally: - figure.canvas = orig_canvas def _no_output_draw(figure): @@ -1574,84 +1570,6 @@ def _is_non_interactive_terminal_ipython(ip): and getattr(ip.parent, 'interact', None) is False) -def _check_savefig_extra_args(func=None, extra_kwargs=()): - """ - Decorator for the final print_* methods that accept keyword arguments. - - If any unused keyword arguments are left, this decorator will warn about - them, and as part of the warning, will attempt to specify the function that - the user actually called, instead of the backend-specific method. If unable - to determine which function the user called, it will specify `.savefig`. - - For compatibility across backends, this does not warn about keyword - arguments added by `FigureCanvasBase.print_figure` for use in a subset of - backends, because the user would not have added them directly. - """ - - if func is None: - return functools.partial(_check_savefig_extra_args, - extra_kwargs=extra_kwargs) - - old_sig = inspect.signature(func) - - @functools.wraps(func) - def wrapper(*args, **kwargs): - name = 'savefig' # Reasonable default guess. - public_api = re.compile( - r'^savefig|print_[A-Za-z0-9]+|_no_output_draw$' - ) - seen_print_figure = False - if sys.version_info < (3, 11): - current_frame = None - else: - import inspect - current_frame = inspect.currentframe() - for frame, line in traceback.walk_stack(current_frame): - if frame is None: - # when called in embedded context may hit frame is None. - break - # Work around sphinx-gallery not setting __name__. - frame_name = frame.f_globals.get('__name__', '') - if re.match(r'\A(matplotlib|mpl_toolkits)(\Z|\.(?!tests\.))', - frame_name): - name = frame.f_code.co_name - if public_api.match(name): - if name in ('print_figure', '_no_output_draw'): - seen_print_figure = True - - elif frame_name == '_functools': - # PyPy adds an extra frame without module prefix for this - # functools wrapper, which we ignore to assume we're still in - # Matplotlib code. - continue - else: - break - - accepted_kwargs = {*old_sig.parameters, *extra_kwargs} - if seen_print_figure: - for kw in ['dpi', 'facecolor', 'edgecolor', 'orientation', - 'bbox_inches_restore']: - # Ignore keyword arguments that are passed in by print_figure - # for the use of other renderers. - if kw not in accepted_kwargs: - kwargs.pop(kw, None) - - for arg in list(kwargs): - if arg in accepted_kwargs: - continue - _api.warn_deprecated( - '3.3', name=name, removal='3.6', - message='%(name)s() got unexpected keyword argument "' - + arg + '" which is no longer supported as of ' - '%(since)s and will become an error ' - '%(removal)s') - kwargs.pop(arg) - - return func(*args, **kwargs) - - return wrapper - - class FigureCanvasBase: """ The canvas the figure renders into. @@ -2155,21 +2073,30 @@ def get_supported_filetypes_grouped(cls): groupings[name].sort() return groupings - def _get_output_canvas(self, backend, fmt): + @contextmanager + def _switch_canvas_and_return_print_method(self, fmt, backend=None): """ - Set the canvas in preparation for saving the figure. + Context manager temporarily setting the canvas for saving the figure:: + + with canvas._switch_canvas_and_return_print_method(fmt, backend) \\ + as print_method: + # ``print_method`` is a suitable ``print_{fmt}`` method, and + # the figure's canvas is temporarily switched to the method's + # canvas within the with... block. ``print_method`` is also + # wrapped to suppress extra kwargs passed by ``print_figure``. Parameters ---------- - backend : str or None - If not None, switch the figure canvas to the ``FigureCanvas`` class - of the given backend. fmt : str If *backend* is None, then determine a suitable canvas class for saving to format *fmt* -- either the current canvas class, if it supports *fmt*, or whatever `get_registered_canvas_class` returns; switch the figure canvas to that canvas class. + backend : str or None, default: None + If not None, switch the figure canvas to the ``FigureCanvas`` class + of the given backend. """ + canvas = None if backend is not None: # Return a specific canvas class, if requested. canvas_class = ( @@ -2180,16 +2107,34 @@ def _get_output_canvas(self, backend, fmt): f"The {backend!r} backend does not support {fmt} output") elif hasattr(self, f"print_{fmt}"): # Return the current canvas if it supports the requested format. - return self + canvas = self + canvas_class = None # Skip call to switch_backends. else: # Return a default canvas for the requested format, if it exists. canvas_class = get_registered_canvas_class(fmt) if canvas_class: - return self.switch_backends(canvas_class) - # Else report error for unsupported format. - raise ValueError( - "Format {!r} is not supported (supported formats: {})" - .format(fmt, ", ".join(sorted(self.get_supported_filetypes())))) + canvas = self.switch_backends(canvas_class) + if canvas is None: + raise ValueError( + "Format {!r} is not supported (supported formats: {})".format( + fmt, ", ".join(sorted(self.get_supported_filetypes())))) + meth = getattr(canvas, f"print_{fmt}") + mod = (meth.func.__module__ + if hasattr(meth, "func") # partialmethod, e.g. backend_wx. + else meth.__module__) + if mod.startswith(("matplotlib.", "mpl_toolkits.")): + optional_kws = { # Passed by print_figure for other renderers. + "dpi", "facecolor", "edgecolor", "orientation", + "bbox_inches_restore"} + skip = optional_kws - {*inspect.signature(meth).parameters} + print_method = functools.wraps(meth)(lambda *args, **kwargs: meth( + *args, **{k: v for k, v in kwargs.items() if k not in skip})) + else: # Let third-parties do as they see fit. + print_method = meth + try: + yield print_method + finally: + self.figure.canvas = self def print_figure( self, filename, dpi=None, facecolor=None, edgecolor=None, @@ -2257,10 +2202,6 @@ def print_figure( filename = filename.rstrip('.') + '.' + format format = format.lower() - # get canvas object and print method for format - canvas = self._get_output_canvas(backend, format) - print_method = getattr(canvas, 'print_%s' % format) - if dpi is None: dpi = rcParams['savefig.dpi'] if dpi == 'figure': @@ -2268,9 +2209,11 @@ def print_figure( # Remove the figure manager, if any, to avoid resizing the GUI widget. with cbook._setattr_cm(self, manager=None), \ + self._switch_canvas_and_return_print_method(format, backend) \ + as print_method, \ cbook._setattr_cm(self.figure, dpi=dpi), \ - cbook._setattr_cm(canvas, _device_pixel_ratio=1), \ - cbook._setattr_cm(canvas, _is_saving=True), \ + cbook._setattr_cm(self.figure.canvas, _device_pixel_ratio=1), \ + cbook._setattr_cm(self.figure.canvas, _is_saving=True), \ ExitStack() as stack: for prop in ["facecolor", "edgecolor"]: @@ -2305,8 +2248,8 @@ def print_figure( bbox_inches = bbox_inches.padded(pad_inches) # call adjust_bbox to save only the given area - restore_bbox = tight_bbox.adjust_bbox(self.figure, bbox_inches, - canvas.fixed_dpi) + restore_bbox = tight_bbox.adjust_bbox( + self.figure, bbox_inches, self.figure.canvas.fixed_dpi) _bbox_inches_restore = (bbox_inches, restore_bbox) else: @@ -2329,7 +2272,6 @@ def print_figure( if bbox_inches and restore_bbox: restore_bbox() - self.figure.set_canvas(self) return result @classmethod diff --git a/lib/matplotlib/backends/backend_agg.py b/lib/matplotlib/backends/backend_agg.py index fc495333ddee..b462b47a429a 100644 --- a/lib/matplotlib/backends/backend_agg.py +++ b/lib/matplotlib/backends/backend_agg.py @@ -35,8 +35,7 @@ from matplotlib import _api, cbook from matplotlib import colors as mcolors from matplotlib.backend_bases import ( - _Backend, _check_savefig_extra_args, FigureCanvasBase, FigureManagerBase, - RendererBase) + _Backend, FigureCanvasBase, FigureManagerBase, RendererBase) from matplotlib.font_manager import findfont, get_font from matplotlib.ft2font import (LOAD_FORCE_AUTOHINT, LOAD_NO_HINTING, LOAD_DEFAULT, LOAD_NO_AUTOHINT) @@ -477,7 +476,6 @@ def buffer_rgba(self): """ return self.renderer.buffer_rgba() - @_check_savefig_extra_args @_api.delete_parameter("3.5", "args") def print_raw(self, filename_or_obj, *args): FigureCanvasAgg.draw(self) @@ -497,7 +495,6 @@ def _print_pil(self, filename_or_obj, fmt, pil_kwargs, metadata=None): filename_or_obj, self.buffer_rgba(), format=fmt, origin="upper", dpi=self.figure.dpi, metadata=metadata, pil_kwargs=pil_kwargs) - @_check_savefig_extra_args @_api.delete_parameter("3.5", "args") def print_png(self, filename_or_obj, *args, metadata=None, pil_kwargs=None): @@ -559,9 +556,8 @@ def print_to_buffer(self): # print_figure(), and the latter ensures that `self.figure.dpi` already # matches the dpi kwarg (if any). - @_check_savefig_extra_args() @_api.delete_parameter("3.5", "args") - def print_jpg(self, filename_or_obj, *args, pil_kwargs=None, **kwargs): + def print_jpg(self, filename_or_obj, *args, pil_kwargs=None): # Remove transparency by alpha-blending on an assumed white background. r, g, b, a = mcolors.to_rgba(self.figure.get_facecolor()) try: @@ -572,13 +568,11 @@ def print_jpg(self, filename_or_obj, *args, pil_kwargs=None, **kwargs): print_jpeg = print_jpg - @_check_savefig_extra_args def print_tif(self, filename_or_obj, *, pil_kwargs=None): self._print_pil(filename_or_obj, "tiff", pil_kwargs) print_tiff = print_tif - @_check_savefig_extra_args def print_webp(self, filename_or_obj, *, pil_kwargs=None): self._print_pil(filename_or_obj, "webp", pil_kwargs) diff --git a/lib/matplotlib/backends/backend_cairo.py b/lib/matplotlib/backends/backend_cairo.py index 05a760542f4f..1df50cdf69b3 100644 --- a/lib/matplotlib/backends/backend_cairo.py +++ b/lib/matplotlib/backends/backend_cairo.py @@ -28,8 +28,8 @@ import matplotlib as mpl from .. import _api, cbook, font_manager from matplotlib.backend_bases import ( - _Backend, _check_savefig_extra_args, FigureCanvasBase, FigureManagerBase, - GraphicsContextBase, RendererBase) + _Backend, FigureCanvasBase, FigureManagerBase, GraphicsContextBase, + RendererBase) from matplotlib.font_manager import ttfFontProperty from matplotlib.mathtext import MathTextParser from matplotlib.path import Path @@ -448,11 +448,9 @@ def restore_region(self, region): surface.mark_dirty_rectangle( slx.start, sly.start, slx.stop - slx.start, sly.stop - sly.start) - @_check_savefig_extra_args def print_png(self, fobj): self._get_printed_image_surface().write_to_png(fobj) - @_check_savefig_extra_args def print_rgba(self, fobj): width, height = self.get_width_height() buf = self._get_printed_image_surface().get_data() @@ -470,7 +468,6 @@ def _get_printed_image_surface(self): self.figure.draw(renderer) return surface - @_check_savefig_extra_args def _save(self, fmt, fobj, *, orientation='portrait'): # save PDF/PS/SVG diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index 011bb8042463..9eb0d76a1e52 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -29,8 +29,8 @@ from matplotlib import _api, _text_helpers, cbook from matplotlib._pylab_helpers import Gcf from matplotlib.backend_bases import ( - _Backend, _check_savefig_extra_args, FigureCanvasBase, FigureManagerBase, - GraphicsContextBase, RendererBase) + _Backend, FigureCanvasBase, FigureManagerBase, GraphicsContextBase, + RendererBase) from matplotlib.backends.backend_mixed import MixedModeRenderer from matplotlib.figure import Figure from matplotlib.font_manager import findfont, get_font @@ -2747,7 +2747,6 @@ class FigureCanvasPdf(FigureCanvasBase): def get_default_filetype(self): return 'pdf' - @_check_savefig_extra_args @_api.delete_parameter("3.4", "dpi") def print_pdf(self, filename, *, dpi=None, # dpi to use for images diff --git a/lib/matplotlib/backends/backend_pgf.py b/lib/matplotlib/backends/backend_pgf.py index b5eec48d922f..334cd6a05f16 100644 --- a/lib/matplotlib/backends/backend_pgf.py +++ b/lib/matplotlib/backends/backend_pgf.py @@ -18,8 +18,8 @@ import matplotlib as mpl from matplotlib import _api, cbook, font_manager as fm from matplotlib.backend_bases import ( - _Backend, _check_savefig_extra_args, FigureCanvasBase, FigureManagerBase, - GraphicsContextBase, RendererBase + _Backend, FigureCanvasBase, FigureManagerBase, GraphicsContextBase, + RendererBase ) from matplotlib.backends.backend_mixed import MixedModeRenderer from matplotlib.backends.backend_pdf import ( @@ -790,7 +790,6 @@ class FigureCanvasPgf(FigureCanvasBase): def get_default_filetype(self): return 'pdf' - @_check_savefig_extra_args def _print_pgf_to_fh(self, fh, *, bbox_inches_restore=None): header_text = """%% Creator: Matplotlib, PGF backend diff --git a/lib/matplotlib/backends/backend_ps.py b/lib/matplotlib/backends/backend_ps.py index e2ffb56f6643..3755fa334b9c 100644 --- a/lib/matplotlib/backends/backend_ps.py +++ b/lib/matplotlib/backends/backend_ps.py @@ -24,8 +24,8 @@ from matplotlib import _api, cbook, _path, _text_helpers from matplotlib.afm import AFM from matplotlib.backend_bases import ( - _Backend, _check_savefig_extra_args, FigureCanvasBase, FigureManagerBase, - GraphicsContextBase, RendererBase) + _Backend, FigureCanvasBase, FigureManagerBase, GraphicsContextBase, + RendererBase) from matplotlib.cbook import is_writable_file_like, file_requires_unicode from matplotlib.font_manager import get_font from matplotlib.ft2font import LOAD_NO_HINTING, LOAD_NO_SCALE, FT2Font @@ -888,7 +888,6 @@ def _print_ps( printer(outfile, format, dpi=dpi, dsc_comments=dsc_comments, orientation=orientation, papertype=papertype, **kwargs) - @_check_savefig_extra_args def _print_figure( self, outfile, format, *, dpi, dsc_comments, orientation, papertype, @@ -1026,7 +1025,6 @@ def print_figure_impl(fh): file = codecs.getwriter("latin-1")(file) print_figure_impl(file) - @_check_savefig_extra_args def _print_figure_tex( self, outfile, format, *, dpi, dsc_comments, orientation, papertype, diff --git a/lib/matplotlib/backends/backend_svg.py b/lib/matplotlib/backends/backend_svg.py index cd93a90861cb..57cfec238941 100644 --- a/lib/matplotlib/backends/backend_svg.py +++ b/lib/matplotlib/backends/backend_svg.py @@ -16,8 +16,7 @@ import matplotlib as mpl from matplotlib import _api, cbook, font_manager as fm from matplotlib.backend_bases import ( - _Backend, _check_savefig_extra_args, FigureCanvasBase, FigureManagerBase, - RendererBase) + _Backend, FigureCanvasBase, FigureManagerBase, RendererBase) from matplotlib.backends.backend_mixed import MixedModeRenderer from matplotlib.colors import rgb2hex from matplotlib.dates import UTC @@ -1288,7 +1287,6 @@ class FigureCanvasSVG(FigureCanvasBase): fixed_dpi = 72 - @_check_savefig_extra_args @_api.delete_parameter("3.4", "dpi") @_api.delete_parameter("3.5", "args") def print_svg(self, filename, *args, dpi=None, bbox_inches_restore=None, diff --git a/lib/matplotlib/backends/backend_wx.py b/lib/matplotlib/backends/backend_wx.py index 9f3ddd5c0a73..c7737f1d8b74 100644 --- a/lib/matplotlib/backends/backend_wx.py +++ b/lib/matplotlib/backends/backend_wx.py @@ -19,9 +19,9 @@ import matplotlib as mpl from matplotlib.backend_bases import ( - _Backend, _check_savefig_extra_args, FigureCanvasBase, FigureManagerBase, - GraphicsContextBase, MouseButton, NavigationToolbar2, RendererBase, - TimerBase, ToolContainerBase, cursors) + _Backend, FigureCanvasBase, FigureManagerBase, GraphicsContextBase, + MouseButton, NavigationToolbar2, RendererBase, TimerBase, + ToolContainerBase, cursors) from matplotlib import _api, cbook, backend_tools from matplotlib._pylab_helpers import Gcf @@ -841,7 +841,6 @@ def draw(self, drawDC=None): self._isDrawn = True self.gui_repaint(drawDC=drawDC) - @_check_savefig_extra_args def _print_image(self, filetype, filename): origBitmap = self.bitmap diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py index 5ab1f35a1a2c..249e8b4c1ae1 100644 --- a/lib/matplotlib/tests/test_figure.py +++ b/lib/matplotlib/tests/test_figure.py @@ -11,7 +11,7 @@ from PIL import Image import matplotlib as mpl -from matplotlib import cbook, rcParams +from matplotlib import rcParams from matplotlib._api.deprecation import MatplotlibDeprecationWarning from matplotlib.testing.decorators import image_comparison, check_figures_equal from matplotlib.axes import Axes @@ -517,9 +517,8 @@ def test_savefig(): def test_savefig_warns(): fig = plt.figure() - msg = r'savefig\(\) got unexpected keyword argument "non_existent_kwarg"' for format in ['png', 'pdf', 'svg', 'tif', 'jpg']: - with pytest.warns(cbook.MatplotlibDeprecationWarning, match=msg): + with pytest.raises(TypeError): fig.savefig(io.BytesIO(), format=format, non_existent_kwarg=True)