Skip to content

Commit bc7f723

Browse files
Fix idle draw animation (#504)
* animation: skip auto_layout on draw_idle * layout: skip auto_layout unless layout is dirty * layout: avoid dirtying layout on backend size updates * ci: append coverage data with xdist * ci: force pytest-cov plugin with xdist * ci: fall back to full tests when selection is empty * ci: handle empty selected tests under bash -e * ci: fall back to full baseline generation on empty selection * ci: treat missing nodeids as empty selection * ci: run coverage without xdist to avoid worker gaps * ci: quiet pytest output * ci: suppress pytest warnings output * ci: stabilize pytest exit handling * ci: retry pytest without xdist on nonzero exit * ci: run main test step without xdist * ci: filter missing nodeids before pytest * ci: bump cache keys for test map and baselines * ci: rely on coverage step for test gating * ci: drop coverage step from build workflow * Remove workflow changes from branch * run test single thread * Prevent None from interfering with tickers * Remove git install * Harden workflow * update workflow * Restore workflow files * Apply suggestion from @beckermr --------- Co-authored-by: Matthew R. Becker <beckermr@users.noreply.github.com>
1 parent 8b36bf3 commit bc7f723

9 files changed

Lines changed: 142 additions & 20 deletions

File tree

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ ignore = ["I001", "I002", "I003", "I004"]
5959
[tool.basedpyright]
6060
exclude = ["**/*.ipynb"]
6161

62+
[tool.pytest.ini_options]
63+
filterwarnings = [
64+
"ignore:'resetCache' deprecated - use 'reset_cache':DeprecationWarning:matplotlib._fontconfig_pattern",
65+
]
6266
[project.optional-dependencies]
6367
docs = [
6468
"jupyter",

ultraplot/axes/base.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3371,6 +3371,8 @@ def format(
33713371
ultraplot.gridspec.SubplotGrid.format
33723372
ultraplot.config.Configurator.context
33733373
"""
3374+
if self.figure is not None:
3375+
self.figure._layout_dirty = True
33743376
skip_figure = kwargs.pop("skip_figure", False) # internal keyword arg
33753377
params = _pop_params(kwargs, self.figure._format_signature)
33763378

ultraplot/axes/cartesian.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -897,7 +897,7 @@ def _update_formatter(
897897
# Introduced in mpl 3.10 and deprecated in mpl 3.12
898898
# Save the original if it exists
899899
converter = (
900-
axis.converter if hasattr(axis, "converter") else axis.get_converter()
900+
axis.get_converter() if hasattr(axis, "get_converter") else axis.converter
901901
)
902902
date = isinstance(converter, DATE_CONVERTERS)
903903

@@ -1038,7 +1038,7 @@ def _update_rotation(self, s, *, rotation=None):
10381038
# Introduced in mpl 3.10 and deprecated in mpl 3.12
10391039
# Save the original if it exists
10401040
converter = (
1041-
axis.converter if hasattr(axis, "converter") else axis.get_converter()
1041+
axis.get_converter() if hasattr(axis, "get_converter") else axis.converter
10421042
)
10431043
if rotation is not None:
10441044
setattr(self, default, False)

ultraplot/axes/plot.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2005,7 +2005,7 @@ def curved_quiver(
20052005
if cmap is None:
20062006
cmap = constructor.Colormap(rc["image.cmap"])
20072007
else:
2008-
cmap = mcm.get_cmap(cmap)
2008+
cmap = mpl.colormaps.get_cmap(cmap)
20092009

20102010
# Convert start_points from data to array coords
20112011
# Shift the seed points from the bottom left of the data so that
@@ -6076,6 +6076,9 @@ def _apply_boxplot(
60766076
# Convert vert boolean to orientation string for newer versions
60776077
orientation = "vertical" if vert else "horizontal"
60786078

6079+
if version.parse(str(_version_mpl)) >= version.parse("3.9.0"):
6080+
if "labels" in kw and "tick_labels" not in kw:
6081+
kw["tick_labels"] = kw.pop("labels")
60796082
if version.parse(str(_version_mpl)) >= version.parse("3.10.0"):
60806083
# For matplotlib 3.10+:
60816084
# Use the orientation parameters

ultraplot/figure.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -476,14 +476,28 @@ def _canvas_preprocess(self, *args, **kwargs):
476476
else:
477477
return
478478

479+
skip_autolayout = getattr(fig, "_skip_autolayout", False)
480+
layout_dirty = getattr(fig, "_layout_dirty", False)
481+
if (
482+
skip_autolayout
483+
and getattr(fig, "_layout_initialized", False)
484+
and not layout_dirty
485+
):
486+
fig._skip_autolayout = False
487+
return func(self, *args, **kwargs)
488+
fig._skip_autolayout = False
489+
479490
# Adjust layout
480491
# NOTE: The authorized_context is needed because some backends disable
481492
# constrained layout or tight layout before printing the figure.
482493
ctx1 = fig._context_adjusting(cache=cache)
483494
ctx2 = fig._context_authorized() # skip backend set_constrained_layout()
484495
ctx3 = rc.context(fig._render_context) # draw with figure-specific setting
485496
with ctx1, ctx2, ctx3:
486-
fig.auto_layout()
497+
if not fig._layout_initialized or layout_dirty:
498+
fig.auto_layout()
499+
fig._layout_initialized = True
500+
fig._layout_dirty = False
487501
return func(self, *args, **kwargs)
488502

489503
# Add preprocessor
@@ -797,6 +811,9 @@ def __init__(
797811
self._subplot_counter = 0 # avoid add_subplot() returning an existing subplot
798812
self._is_adjusting = False
799813
self._is_authorized = False
814+
self._layout_initialized = False
815+
self._layout_dirty = True
816+
self._skip_autolayout = False
800817
self._includepanels = None
801818
self._render_context = {}
802819
rc_kw, rc_mode = _pop_rc(kwargs)
@@ -1548,6 +1565,7 @@ def _add_figure_panel(
15481565
"""
15491566
Add a figure panel.
15501567
"""
1568+
self._layout_dirty = True
15511569
# Interpret args and enforce sensible keyword args
15521570
side = _translate_loc(side, "panel", default="right")
15531571
if side in ("left", "right"):
@@ -1581,6 +1599,7 @@ def _add_subplot(self, *args, **kwargs):
15811599
"""
15821600
The driver function for adding single subplots.
15831601
"""
1602+
self._layout_dirty = True
15841603
# Parse arguments
15851604
kwargs = self._parse_proj(**kwargs)
15861605

@@ -2551,6 +2570,7 @@ def format(
25512570
ultraplot.gridspec.SubplotGrid.format
25522571
ultraplot.config.Configurator.context
25532572
"""
2573+
self._layout_dirty = True
25542574
# Initiate context block
25552575
axs = axs or self._subplot_dict.values()
25562576
skip_axes = kwargs.pop("skip_axes", False) # internal keyword arg
@@ -3136,6 +3156,17 @@ def set_canvas(self, canvas):
31363156
# method = '_draw' if callable(getattr(canvas, '_draw', None)) else 'draw'
31373157
_add_canvas_preprocessor(canvas, "print_figure", cache=False) # saves, inlines
31383158
_add_canvas_preprocessor(canvas, method, cache=True) # renderer displays
3159+
3160+
orig_draw_idle = getattr(type(canvas), "draw_idle", None)
3161+
if orig_draw_idle is not None:
3162+
3163+
def _draw_idle(self, *args, **kwargs):
3164+
fig = self.figure
3165+
if fig is not None:
3166+
fig._skip_autolayout = True
3167+
return orig_draw_idle(self, *args, **kwargs)
3168+
3169+
canvas.draw_idle = _draw_idle.__get__(canvas)
31393170
super().set_canvas(canvas)
31403171

31413172
def _is_same_size(self, figsize, eps=None):
@@ -3202,6 +3233,8 @@ def set_size_inches(self, w, h=None, *, forward=True, internal=False, eps=None):
32023233
super().set_size_inches(figsize, forward=forward)
32033234
if not samesize: # gridspec positions will resolve differently
32043235
self.gridspec.update()
3236+
if not backend and not internal:
3237+
self._layout_dirty = True
32053238

32063239
def _iter_axes(self, hidden=False, children=False, panels=True):
32073240
"""

ultraplot/tests/test_animation.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from unittest.mock import MagicMock
2+
3+
import numpy as np
4+
import pytest
5+
from matplotlib.animation import FuncAnimation
6+
7+
import ultraplot as uplt
8+
9+
10+
def test_auto_layout_not_called_on_every_frame():
11+
"""
12+
Test that auto_layout is not called on every frame of a FuncAnimation.
13+
"""
14+
fig, ax = uplt.subplots()
15+
fig.auto_layout = MagicMock()
16+
17+
x = np.linspace(0, 2 * np.pi, 100)
18+
y = np.sin(x)
19+
(line,) = ax.plot(x, y)
20+
21+
def update(frame):
22+
line.set_ydata(np.sin(x + frame / 10.0))
23+
return (line,)
24+
25+
ani = FuncAnimation(fig, update, frames=10, blit=False)
26+
# The animation is not actually run, but the initial draw will call auto_layout once
27+
fig.canvas.draw()
28+
29+
assert fig.auto_layout.call_count == 1
30+
31+
32+
def test_draw_idle_skips_auto_layout_after_first_draw():
33+
"""
34+
draw_idle should not re-run auto_layout after the initial draw.
35+
"""
36+
fig, ax = uplt.subplots()
37+
fig.auto_layout = MagicMock()
38+
39+
fig.canvas.draw()
40+
assert fig.auto_layout.call_count == 1
41+
42+
fig.canvas.draw_idle()
43+
assert fig.auto_layout.call_count == 1
44+
45+
46+
def test_layout_array_no_crash():
47+
"""
48+
Test that using layout_array with FuncAnimation does not crash.
49+
"""
50+
layout = [[1, 1], [2, 3]]
51+
fig, axs = uplt.subplots(array=layout)
52+
53+
def update(frame):
54+
for ax in axs:
55+
ax.clear()
56+
ax.plot(np.sin(np.linspace(0, 2 * np.pi) + frame / 10.0))
57+
58+
ani = FuncAnimation(fig, update, frames=10)
59+
# The test passes if no exception is raised
60+
fig.canvas.draw()

ultraplot/tests/test_geographic.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -965,7 +965,8 @@ def test_panels_geo():
965965
for dir in dirs:
966966
not ax[0]._is_ticklabel_on(f"label{dir}")
967967

968-
return fig
968+
fig.canvas.draw()
969+
uplt.close(fig)
969970

970971

971972
@pytest.mark.mpl_image_compare

ultraplot/tests/test_subplots.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -684,7 +684,8 @@ def test_non_rectangular_outside_labels_top():
684684
ax.format(bottomlabels=[4, 5])
685685
ax.format(leftlabels=[1, 3, 4])
686686
ax.format(toplabels=[1, 2])
687-
return fig
687+
fig.canvas.draw()
688+
uplt.close(fig)
688689

689690

690691
@pytest.mark.mpl_image_compare

ultraplot/tests/test_tickers.py

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1-
import pytest, numpy as np, xarray as xr, ultraplot as uplt, cftime
2-
from ultraplot.ticker import AutoCFDatetimeLocator
3-
from unittest.mock import patch
41
import importlib
2+
from unittest.mock import patch
3+
54
import cartopy.crs as ccrs
5+
import cftime
6+
import numpy as np
7+
import pytest
8+
import xarray as xr
9+
10+
import ultraplot as uplt
11+
from ultraplot.ticker import AutoCFDatetimeLocator
612

713

814
@pytest.mark.mpl_image_compare
@@ -267,16 +273,20 @@ def test_missing_modules(module_name):
267273
assert cftime is None
268274
elif module_name == "ccrs":
269275
from ultraplot.ticker import (
270-
ccrs,
271276
LatitudeFormatter,
272277
LongitudeFormatter,
273278
_PlateCarreeFormatter,
279+
ccrs,
274280
)
275281

276282
assert ccrs is None
277283
assert LatitudeFormatter is object
278284
assert LongitudeFormatter is object
279285
assert _PlateCarreeFormatter is object
286+
# Restore module state for subsequent tests.
287+
import ultraplot.ticker
288+
289+
importlib.reload(ultraplot.ticker)
280290

281291

282292
def test_index_locator():
@@ -478,9 +488,10 @@ def test_auto_datetime_locator_tick_values(
478488
expected_exception,
479489
expected_resolution,
480490
):
481-
from ultraplot.ticker import AutoCFDatetimeLocator
482491
import cftime
483492

493+
from ultraplot.ticker import AutoCFDatetimeLocator
494+
484495
locator = AutoCFDatetimeLocator(calendar=calendar)
485496
resolution = expected_resolution
486497
if expected_exception == ValueError:
@@ -659,10 +670,11 @@ def test_frac_formatter(formatter_args, value, expected):
659670

660671

661672
def test_frac_formatter_unicode_minus():
662-
from ultraplot.ticker import FracFormatter
663-
from ultraplot.config import rc
664673
import numpy as np
665674

675+
from ultraplot.config import rc
676+
from ultraplot.ticker import FracFormatter
677+
666678
formatter = FracFormatter(symbol=r"$\\pi$", number=np.pi)
667679
with rc.context({"axes.unicode_minus": True}):
668680
assert formatter(-np.pi / 2) == r"−$\\pi$/2"
@@ -675,9 +687,10 @@ def test_frac_formatter_unicode_minus():
675687
],
676688
)
677689
def test_cfdatetime_formatter_direct_call(fmt, calendar, dt_args, expected):
678-
from ultraplot.ticker import CFDatetimeFormatter
679690
import cftime
680691

692+
from ultraplot.ticker import CFDatetimeFormatter
693+
681694
formatter = CFDatetimeFormatter(fmt, calendar=calendar)
682695
dt = cftime.datetime(*dt_args, calendar=calendar)
683696
assert formatter(dt) == expected
@@ -694,9 +707,10 @@ def test_cfdatetime_formatter_direct_call(fmt, calendar, dt_args, expected):
694707
def test_autocftime_locator_subdaily(
695708
start_date_str, end_date_str, calendar, resolution
696709
):
697-
from ultraplot.ticker import AutoCFDatetimeLocator
698710
import cftime
699711

712+
from ultraplot.ticker import AutoCFDatetimeLocator
713+
700714
locator = AutoCFDatetimeLocator(calendar=calendar)
701715
units = locator.date_unit
702716

@@ -718,9 +732,10 @@ def test_autocftime_locator_subdaily(
718732

719733

720734
def test_autocftime_locator_safe_helpers():
721-
from ultraplot.ticker import AutoCFDatetimeLocator
722735
import cftime
723736

737+
from ultraplot.ticker import AutoCFDatetimeLocator
738+
724739
# Test _safe_num2date with invalid value
725740
locator_gregorian = AutoCFDatetimeLocator(calendar="gregorian")
726741
with pytest.raises(OverflowError):
@@ -740,9 +755,10 @@ def test_autocftime_locator_safe_helpers():
740755
],
741756
)
742757
def test_auto_formatter_options(formatter_args, values, expected, ylim):
743-
from ultraplot.ticker import AutoFormatter
744758
import matplotlib.pyplot as plt
745759

760+
from ultraplot.ticker import AutoFormatter
761+
746762
fig, ax = plt.subplots()
747763
formatter = AutoFormatter(**formatter_args)
748764
ax.xaxis.set_major_formatter(formatter)
@@ -771,20 +787,22 @@ def test_autocftime_locator_safe_daily_locator():
771787

772788

773789
def test_latitude_locator():
774-
from ultraplot.ticker import LatitudeLocator
775790
import numpy as np
776791

792+
from ultraplot.ticker import LatitudeLocator
793+
777794
locator = LatitudeLocator()
778795
ticks = np.array(locator.tick_values(-100, 100))
779796
assert np.all(ticks >= -90)
780797
assert np.all(ticks <= 90)
781798

782799

783800
def test_cftime_converter():
784-
from ultraplot.ticker import CFTimeConverter, cftime
785-
from ultraplot.config import rc
786801
import numpy as np
787802

803+
from ultraplot.config import rc
804+
from ultraplot.ticker import CFTimeConverter, cftime
805+
788806
converter = CFTimeConverter()
789807

790808
# test default_units

0 commit comments

Comments
 (0)