diff --git a/pyproject.toml b/pyproject.toml index 6e20a36eb..e74f238f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,10 @@ ignore = ["I001", "I002", "I003", "I004"] [tool.basedpyright] exclude = ["**/*.ipynb"] +[tool.pytest.ini_options] +filterwarnings = [ + "ignore:'resetCache' deprecated - use 'reset_cache':DeprecationWarning:matplotlib._fontconfig_pattern", +] [project.optional-dependencies] docs = [ "jupyter", diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index ef9d59b43..b4462ed32 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -3371,6 +3371,8 @@ def format( ultraplot.gridspec.SubplotGrid.format ultraplot.config.Configurator.context """ + if self.figure is not None: + self.figure._layout_dirty = True skip_figure = kwargs.pop("skip_figure", False) # internal keyword arg params = _pop_params(kwargs, self.figure._format_signature) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index eb32d7db6..4eff7ae85 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -897,7 +897,7 @@ def _update_formatter( # Introduced in mpl 3.10 and deprecated in mpl 3.12 # Save the original if it exists converter = ( - axis.converter if hasattr(axis, "converter") else axis.get_converter() + axis.get_converter() if hasattr(axis, "get_converter") else axis.converter ) date = isinstance(converter, DATE_CONVERTERS) @@ -1038,7 +1038,7 @@ def _update_rotation(self, s, *, rotation=None): # Introduced in mpl 3.10 and deprecated in mpl 3.12 # Save the original if it exists converter = ( - axis.converter if hasattr(axis, "converter") else axis.get_converter() + axis.get_converter() if hasattr(axis, "get_converter") else axis.converter ) if rotation is not None: setattr(self, default, False) diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index 0ff325691..324e03d71 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -1826,7 +1826,7 @@ def curved_quiver( if cmap is None: cmap = constructor.Colormap(rc["image.cmap"]) else: - cmap = mcm.get_cmap(cmap) + cmap = mpl.colormaps.get_cmap(cmap) # Convert start_points from data to array coords # Shift the seed points from the bottom left of the data so that @@ -5387,6 +5387,9 @@ def _apply_boxplot( # Convert vert boolean to orientation string for newer versions orientation = "vertical" if vert else "horizontal" + if version.parse(str(_version_mpl)) >= version.parse("3.9.0"): + if "labels" in kw and "tick_labels" not in kw: + kw["tick_labels"] = kw.pop("labels") if version.parse(str(_version_mpl)) >= version.parse("3.10.0"): # For matplotlib 3.10+: # Use the orientation parameters diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 01d449d36..5536f22fc 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -476,6 +476,17 @@ def _canvas_preprocess(self, *args, **kwargs): else: return + skip_autolayout = getattr(fig, "_skip_autolayout", False) + layout_dirty = getattr(fig, "_layout_dirty", False) + if ( + skip_autolayout + and getattr(fig, "_layout_initialized", False) + and not layout_dirty + ): + fig._skip_autolayout = False + return func(self, *args, **kwargs) + fig._skip_autolayout = False + # Adjust layout # NOTE: The authorized_context is needed because some backends disable # constrained layout or tight layout before printing the figure. @@ -483,7 +494,10 @@ def _canvas_preprocess(self, *args, **kwargs): ctx2 = fig._context_authorized() # skip backend set_constrained_layout() ctx3 = rc.context(fig._render_context) # draw with figure-specific setting with ctx1, ctx2, ctx3: - fig.auto_layout() + if not fig._layout_initialized or layout_dirty: + fig.auto_layout() + fig._layout_initialized = True + fig._layout_dirty = False return func(self, *args, **kwargs) # Add preprocessor @@ -797,6 +811,9 @@ def __init__( self._subplot_counter = 0 # avoid add_subplot() returning an existing subplot self._is_adjusting = False self._is_authorized = False + self._layout_initialized = False + self._layout_dirty = True + self._skip_autolayout = False self._includepanels = None self._render_context = {} rc_kw, rc_mode = _pop_rc(kwargs) @@ -1546,6 +1563,7 @@ def _add_figure_panel( """ Add a figure panel. """ + self._layout_dirty = True # Interpret args and enforce sensible keyword args side = _translate_loc(side, "panel", default="right") if side in ("left", "right"): @@ -1579,6 +1597,7 @@ def _add_subplot(self, *args, **kwargs): """ The driver function for adding single subplots. """ + self._layout_dirty = True # Parse arguments kwargs = self._parse_proj(**kwargs) @@ -2549,6 +2568,7 @@ def format( ultraplot.gridspec.SubplotGrid.format ultraplot.config.Configurator.context """ + self._layout_dirty = True # Initiate context block axs = axs or self._subplot_dict.values() skip_axes = kwargs.pop("skip_axes", False) # internal keyword arg @@ -3134,6 +3154,17 @@ def set_canvas(self, canvas): # method = '_draw' if callable(getattr(canvas, '_draw', None)) else 'draw' _add_canvas_preprocessor(canvas, "print_figure", cache=False) # saves, inlines _add_canvas_preprocessor(canvas, method, cache=True) # renderer displays + + orig_draw_idle = getattr(type(canvas), "draw_idle", None) + if orig_draw_idle is not None: + + def _draw_idle(self, *args, **kwargs): + fig = self.figure + if fig is not None: + fig._skip_autolayout = True + return orig_draw_idle(self, *args, **kwargs) + + canvas.draw_idle = _draw_idle.__get__(canvas) super().set_canvas(canvas) def _is_same_size(self, figsize, eps=None): @@ -3200,6 +3231,8 @@ def set_size_inches(self, w, h=None, *, forward=True, internal=False, eps=None): super().set_size_inches(figsize, forward=forward) if not samesize: # gridspec positions will resolve differently self.gridspec.update() + if not backend and not internal: + self._layout_dirty = True def _iter_axes(self, hidden=False, children=False, panels=True): """ diff --git a/ultraplot/tests/test_animation.py b/ultraplot/tests/test_animation.py new file mode 100644 index 000000000..6e8ad2efc --- /dev/null +++ b/ultraplot/tests/test_animation.py @@ -0,0 +1,60 @@ +from unittest.mock import MagicMock + +import numpy as np +import pytest +from matplotlib.animation import FuncAnimation + +import ultraplot as uplt + + +def test_auto_layout_not_called_on_every_frame(): + """ + Test that auto_layout is not called on every frame of a FuncAnimation. + """ + fig, ax = uplt.subplots() + fig.auto_layout = MagicMock() + + x = np.linspace(0, 2 * np.pi, 100) + y = np.sin(x) + (line,) = ax.plot(x, y) + + def update(frame): + line.set_ydata(np.sin(x + frame / 10.0)) + return (line,) + + ani = FuncAnimation(fig, update, frames=10, blit=False) + # The animation is not actually run, but the initial draw will call auto_layout once + fig.canvas.draw() + + assert fig.auto_layout.call_count == 1 + + +def test_draw_idle_skips_auto_layout_after_first_draw(): + """ + draw_idle should not re-run auto_layout after the initial draw. + """ + fig, ax = uplt.subplots() + fig.auto_layout = MagicMock() + + fig.canvas.draw() + assert fig.auto_layout.call_count == 1 + + fig.canvas.draw_idle() + assert fig.auto_layout.call_count == 1 + + +def test_layout_array_no_crash(): + """ + Test that using layout_array with FuncAnimation does not crash. + """ + layout = [[1, 1], [2, 3]] + fig, axs = uplt.subplots(array=layout) + + def update(frame): + for ax in axs: + ax.clear() + ax.plot(np.sin(np.linspace(0, 2 * np.pi) + frame / 10.0)) + + ani = FuncAnimation(fig, update, frames=10) + # The test passes if no exception is raised + fig.canvas.draw() diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index 42499629e..4e096342c 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -967,7 +967,8 @@ def test_panels_geo(): for dir in dirs: not ax[0]._is_ticklabel_on(f"label{dir}") - return fig + fig.canvas.draw() + uplt.close(fig) @pytest.mark.mpl_image_compare diff --git a/ultraplot/tests/test_subplots.py b/ultraplot/tests/test_subplots.py index 39eb61c3e..cda3f74cd 100644 --- a/ultraplot/tests/test_subplots.py +++ b/ultraplot/tests/test_subplots.py @@ -684,7 +684,8 @@ def test_non_rectangular_outside_labels_top(): ax.format(bottomlabels=[4, 5]) ax.format(leftlabels=[1, 3, 4]) ax.format(toplabels=[1, 2]) - return fig + fig.canvas.draw() + uplt.close(fig) @pytest.mark.mpl_image_compare diff --git a/ultraplot/tests/test_tickers.py b/ultraplot/tests/test_tickers.py index bfb3d6e33..1bc40fdd0 100644 --- a/ultraplot/tests/test_tickers.py +++ b/ultraplot/tests/test_tickers.py @@ -1,8 +1,14 @@ -import pytest, numpy as np, xarray as xr, ultraplot as uplt, cftime -from ultraplot.ticker import AutoCFDatetimeLocator -from unittest.mock import patch import importlib +from unittest.mock import patch + import cartopy.crs as ccrs +import cftime +import numpy as np +import pytest +import xarray as xr + +import ultraplot as uplt +from ultraplot.ticker import AutoCFDatetimeLocator @pytest.mark.mpl_image_compare @@ -267,16 +273,20 @@ def test_missing_modules(module_name): assert cftime is None elif module_name == "ccrs": from ultraplot.ticker import ( - ccrs, LatitudeFormatter, LongitudeFormatter, _PlateCarreeFormatter, + ccrs, ) assert ccrs is None assert LatitudeFormatter is object assert LongitudeFormatter is object assert _PlateCarreeFormatter is object + # Restore module state for subsequent tests. + import ultraplot.ticker + + importlib.reload(ultraplot.ticker) def test_index_locator(): @@ -478,9 +488,10 @@ def test_auto_datetime_locator_tick_values( expected_exception, expected_resolution, ): - from ultraplot.ticker import AutoCFDatetimeLocator import cftime + from ultraplot.ticker import AutoCFDatetimeLocator + locator = AutoCFDatetimeLocator(calendar=calendar) resolution = expected_resolution if expected_exception == ValueError: @@ -659,10 +670,11 @@ def test_frac_formatter(formatter_args, value, expected): def test_frac_formatter_unicode_minus(): - from ultraplot.ticker import FracFormatter - from ultraplot.config import rc import numpy as np + from ultraplot.config import rc + from ultraplot.ticker import FracFormatter + formatter = FracFormatter(symbol=r"$\\pi$", number=np.pi) with rc.context({"axes.unicode_minus": True}): assert formatter(-np.pi / 2) == r"−$\\pi$/2" @@ -675,9 +687,10 @@ def test_frac_formatter_unicode_minus(): ], ) def test_cfdatetime_formatter_direct_call(fmt, calendar, dt_args, expected): - from ultraplot.ticker import CFDatetimeFormatter import cftime + from ultraplot.ticker import CFDatetimeFormatter + formatter = CFDatetimeFormatter(fmt, calendar=calendar) dt = cftime.datetime(*dt_args, calendar=calendar) assert formatter(dt) == expected @@ -694,9 +707,10 @@ def test_cfdatetime_formatter_direct_call(fmt, calendar, dt_args, expected): def test_autocftime_locator_subdaily( start_date_str, end_date_str, calendar, resolution ): - from ultraplot.ticker import AutoCFDatetimeLocator import cftime + from ultraplot.ticker import AutoCFDatetimeLocator + locator = AutoCFDatetimeLocator(calendar=calendar) units = locator.date_unit @@ -718,9 +732,10 @@ def test_autocftime_locator_subdaily( def test_autocftime_locator_safe_helpers(): - from ultraplot.ticker import AutoCFDatetimeLocator import cftime + from ultraplot.ticker import AutoCFDatetimeLocator + # Test _safe_num2date with invalid value locator_gregorian = AutoCFDatetimeLocator(calendar="gregorian") with pytest.raises(OverflowError): @@ -740,9 +755,10 @@ def test_autocftime_locator_safe_helpers(): ], ) def test_auto_formatter_options(formatter_args, values, expected, ylim): - from ultraplot.ticker import AutoFormatter import matplotlib.pyplot as plt + from ultraplot.ticker import AutoFormatter + fig, ax = plt.subplots() formatter = AutoFormatter(**formatter_args) ax.xaxis.set_major_formatter(formatter) @@ -771,9 +787,10 @@ def test_autocftime_locator_safe_daily_locator(): def test_latitude_locator(): - from ultraplot.ticker import LatitudeLocator import numpy as np + from ultraplot.ticker import LatitudeLocator + locator = LatitudeLocator() ticks = np.array(locator.tick_values(-100, 100)) assert np.all(ticks >= -90) @@ -781,10 +798,11 @@ def test_latitude_locator(): def test_cftime_converter(): - from ultraplot.ticker import CFTimeConverter, cftime - from ultraplot.config import rc import numpy as np + from ultraplot.config import rc + from ultraplot.ticker import CFTimeConverter, cftime + converter = CFTimeConverter() # test default_units