Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
7993257
animation: skip auto_layout on draw_idle
cvanelteren Jan 24, 2026
93f9672
layout: skip auto_layout unless layout is dirty
cvanelteren Jan 24, 2026
ecf6010
layout: avoid dirtying layout on backend size updates
cvanelteren Jan 24, 2026
eb70a98
ci: append coverage data with xdist
cvanelteren Jan 24, 2026
0d3bf5b
ci: force pytest-cov plugin with xdist
cvanelteren Jan 24, 2026
ab165b5
ci: fall back to full tests when selection is empty
cvanelteren Jan 24, 2026
008ddb1
ci: handle empty selected tests under bash -e
cvanelteren Jan 24, 2026
4d6c0a2
ci: fall back to full baseline generation on empty selection
cvanelteren Jan 25, 2026
b2ea1a3
ci: treat missing nodeids as empty selection
cvanelteren Jan 25, 2026
a959cd6
ci: run coverage without xdist to avoid worker gaps
cvanelteren Jan 25, 2026
c9a0f0f
ci: quiet pytest output
cvanelteren Jan 25, 2026
e3ba31a
ci: suppress pytest warnings output
cvanelteren Jan 25, 2026
9de5e83
ci: stabilize pytest exit handling
cvanelteren Jan 25, 2026
bcffb19
ci: retry pytest without xdist on nonzero exit
cvanelteren Jan 25, 2026
2591a82
ci: run main test step without xdist
cvanelteren Jan 25, 2026
f0b0622
ci: filter missing nodeids before pytest
cvanelteren Jan 25, 2026
9736984
ci: bump cache keys for test map and baselines
cvanelteren Jan 25, 2026
ed69c48
ci: rely on coverage step for test gating
cvanelteren Jan 25, 2026
e119fbb
ci: drop coverage step from build workflow
cvanelteren Jan 25, 2026
0d1564f
Merge branch 'main' into fix-idle-draw-animation
cvanelteren Jan 25, 2026
4942b5b
Remove workflow changes from branch
cvanelteren Jan 25, 2026
9e66b83
run test single thread
cvanelteren Jan 25, 2026
5f4e25e
Prevent None from interfering with tickers
cvanelteren Jan 25, 2026
1492c35
Remove git install
cvanelteren Jan 25, 2026
23f0466
Harden workflow
cvanelteren Jan 25, 2026
71e35b4
Merge branch 'main' into fix-idle-draw-animation
cvanelteren Jan 27, 2026
b6c0646
Merge branch 'main' into fix-idle-draw-animation
cvanelteren Jan 28, 2026
7259915
update workflow
cvanelteren Jan 25, 2026
0cab5f0
Restore workflow files
cvanelteren Jan 28, 2026
e0cb1d7
Merge branch 'main' into fix-idle-draw-animation
cvanelteren Jan 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ 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",
Expand Down
2 changes: 2 additions & 0 deletions ultraplot/axes/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions ultraplot/axes/cartesian.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion ultraplot/axes/plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
35 changes: 34 additions & 1 deletion ultraplot/figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,14 +476,28 @@ 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.
ctx1 = fig._context_adjusting(cache=cache)
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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
"""
Expand Down
60 changes: 60 additions & 0 deletions ultraplot/tests/test_animation.py
Original file line number Diff line number Diff line change
@@ -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()
3 changes: 2 additions & 1 deletion ultraplot/tests/test_geographic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion ultraplot/tests/test_subplots.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 32 additions & 14 deletions ultraplot/tests/test_tickers.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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):
Expand All @@ -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)
Expand Down Expand Up @@ -771,20 +787,22 @@ 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)
assert np.all(ticks <= 90)


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
Expand Down
Loading