From 39e3c49bcf3ac7ac6eb735306ab832500ec24d62 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 6 Feb 2026 20:53:15 +1000 Subject: [PATCH 01/25] Auto-detect compatible axis sharing by default --- docs/subplots.py | 27 ++- ultraplot/axes/base.py | 42 +++-- ultraplot/axes/cartesian.py | 10 ++ ultraplot/figure.py | 290 +++++++++++++++++++++++++++------ ultraplot/internals/rcsetup.py | 8 +- ultraplot/tests/test_figure.py | 98 +++++++++++ 6 files changed, 403 insertions(+), 72 deletions(-) diff --git a/docs/subplots.py b/docs/subplots.py index ced109c96..a1b309ec9 100644 --- a/docs/subplots.py +++ b/docs/subplots.py @@ -373,8 +373,9 @@ # `~matplotlib.figure.Figure.supxlabel` and `~matplotlib.figure.Figure.supylabel`, # these labels are aligned between gridspec edges rather than figure edges. # #. Supporting five sharing "levels". These values can be passed to `sharex`, -# `sharey`, or `share`, or assigned to :rcraw:`subplots.share`. The levels -# are defined as follows: +# `sharey`, or `share`, or assigned to :rcraw:`subplots.share`. +# UltraPlot supports five explicit sharing levels plus ``'auto'``. +# The levels are defined as follows: # # * ``False`` or ``0``: Axis sharing is disabled. # * ``'labels'``, ``'labs'``, or ``1``: Axis labels are shared, but nothing else. @@ -384,6 +385,14 @@ # in the same row or column of the :class:`~ultraplot.gridspec.GridSpec`; a space # or empty plot will add the labels, but not break the limit sharing. See below # for a more complex example. +# * ``'limits'``, ``'lims'``, or ``2``: As above, plus share limits/scales/ticks. +# * ``True`` or ``3``: As above, plus hide inner tick labels. +# * ``'all'`` or ``4``: As above, plus share limits across the full subplot grid. +# * ``'auto'`` (default): Start from level ``3`` and only share compatible axes. +# This suppresses warnings for mixed axis families (e.g., cartesian + polar). +# +# Explicit sharing levels still force sharing attempts and may warn when +# incompatible axes are encountered. # # The below examples demonstrate the effect of various axis and label sharing # settings on the appearance of several subplot grids. @@ -422,6 +431,20 @@ import ultraplot as uplt import numpy as np +# The default `share='auto'` keeps incompatible axis families unshared. +fig, axs = uplt.subplots(ncols=2, proj=("cart", "polar")) +x = np.linspace(0, 2 * np.pi, 100) +axs[0].plot(x, np.sin(x)) +axs[1].plot(x, np.abs(np.sin(2 * x))) +axs.format( + suptitle="Auto sharing with mixed cartesian and polar axes", + title=("cartesian", "polar"), +) + +# %% +import ultraplot as uplt +import numpy as np + state = np.random.RandomState(51423) # Plots with minimum and maximum sharing settings diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 11835af93..78ac489ec 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -1702,21 +1702,39 @@ def shared(paxs): iax._sharey_setup(left) # External axes sharing, sometimes overrides panel axes sharing - # Share x axes - parent, *children = self._get_share_axes("x") - for child in children: - child._sharex_setup(parent) - # Share y axes - parent, *children = self._get_share_axes("y") - for child in children: - child._sharey_setup(parent) - # Global sharing, use the reference subplot because why not + # Share x axes within compatible groups + axes_x = self._get_share_axes("x") + for group in self.figure._partition_share_axes(axes_x, "x"): + if not group: + continue + parent, *children = group + for child in children: + child._sharex_setup(parent) + + # Share y axes within compatible groups + axes_y = self._get_share_axes("y") + for group in self.figure._partition_share_axes(axes_y, "y"): + if not group: + continue + parent, *children = group + for child in children: + child._sharey_setup(parent) + + # Global sharing, use the reference subplot where compatible ref = self.figure._subplot_dict.get(self.figure._refnum, None) - if self is not ref: + if self is not ref and ref is not None: if self.figure._sharex > 3: - self._sharex_setup(ref, labels=False) + ok, reason = self.figure._share_axes_compatible(ref, self, "x") + if ok: + self._sharex_setup(ref, labels=False) + else: + self.figure._warn_incompatible_share("x", ref, self, reason) if self.figure._sharey > 3: - self._sharey_setup(ref, labels=False) + ok, reason = self.figure._share_axes_compatible(ref, self, "y") + if ok: + self._sharey_setup(ref, labels=False) + else: + self.figure._warn_incompatible_share("y", ref, self, reason) def _artist_fully_clipped(self, artist): """ diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index e975356e1..568576c14 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -869,13 +869,23 @@ def _apply_log_formatter_on_scale(self, s): self._update_formatter(s, "log") def set_xscale(self, value, **kwargs): + fig = getattr(self, "figure", None) + if fig is not None and hasattr(fig, "_is_auto_share_mode") and fig._is_auto_share_mode("x"): + self._unshare(which="x") result = super().set_xscale(value, **kwargs) self._apply_log_formatter_on_scale("x") + if fig is not None and hasattr(fig, "_refresh_auto_share"): + fig._refresh_auto_share("x") return result def set_yscale(self, value, **kwargs): + fig = getattr(self, "figure", None) + if fig is not None and hasattr(fig, "_is_auto_share_mode") and fig._is_auto_share_mode("y"): + self._unshare(which="y") result = super().set_yscale(value, **kwargs) self._apply_log_formatter_on_scale("y") + if fig is not None and hasattr(fig, "_refresh_auto_share"): + fig._refresh_auto_share("y") return result def _update_formatter( diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 068a09afd..1f4a21cc2 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -104,7 +104,7 @@ figsize : 2-tuple, optional Tuple specifying the figure ``(width, height)``. sharex, sharey, share \ -: {0, False, 1, 'labels', 'labs', 2, 'limits', 'lims', 3, True, 4, 'all'}, \ +: {0, False, 1, 'labels', 'labs', 2, 'limits', 'lims', 3, True, 4, 'all', 'auto'}, \ default: :rc:`subplots.share` The axis sharing "level" for the *x* axis, *y* axis, or both axes. Options are as follows: @@ -119,6 +119,11 @@ row and leftmost column of subplots. * ``4`` or ``'all'``: As above but also share the axis limits, scales, and tick locations between subplots not in the same row or column. + * ``'auto'``: Start from level ``3`` and only share axes that are compatible + (for example, mixed cartesian and polar axes are kept unshared). + + Explicit sharing levels (``0`` to ``4`` and aliases) still force sharing + attempts and can emit warnings for incompatible axes. spanx, spany, span : bool or {0, 1}, default: :rc:`subplots.span` Whether to use "spanning" axis labels for the *x* axis, *y* axis, or both @@ -550,8 +555,9 @@ class Figure(mfigure.Figure): "1 or 'labels' or 'labs' (share axis labels), " "2 or 'limits' or 'lims' (share axis limits and axis labels), " "3 or True (share axis limits, axis labels, and tick labels), " - "or 4 or 'all' (share axis labels and tick labels in the same gridspec " - "rows and columns and share axis limits across all subplots)." + "4 or 'all' (share axis labels and tick labels in the same gridspec " + "rows and columns and share axis limits across all subplots), " + "or 'auto' (start unshared and share only compatible axes)." ) _space_message = ( "To set the left, right, bottom, top, wspace, or hspace gridspec values, " @@ -795,14 +801,23 @@ def __init__( translate = {"labels": 1, "labs": 1, "limits": 2, "lims": 2, "all": 4} sharex = _not_none(sharex, share, rc["subplots.share"]) sharey = _not_none(sharey, share, rc["subplots.share"]) - sharex = 3 if sharex is True else translate.get(sharex, sharex) - sharey = 3 if sharey is True else translate.get(sharey, sharey) - if sharex not in range(5): - raise ValueError(f"Invalid sharex={sharex!r}. " + self._share_message) - if sharey not in range(5): - raise ValueError(f"Invalid sharey={sharey!r}. " + self._share_message) + + def _normalize_share(value): + auto = isinstance(value, str) and value.lower() == "auto" + if auto: + return 3, True + value = 3 if value is True else translate.get(value, value) + if value not in range(5): + raise ValueError(f"Invalid sharing value {value!r}. " + self._share_message) + return int(value), False + + sharex, sharex_auto = _normalize_share(sharex) + sharey, sharey_auto = _normalize_share(sharey) self._sharex = int(sharex) self._sharey = int(sharey) + self._sharex_auto = bool(sharex_auto) + self._sharey_auto = bool(sharey_auto) + self._share_incompat_warned = {"x": False, "y": False} # Translate span and align settings spanx = _not_none( @@ -880,6 +895,203 @@ def draw(self, renderer): self._apply_share_label_groups() super().draw(renderer) + def _is_auto_share_mode(self, which: str) -> bool: + """Return whether a given axis uses auto-share mode.""" + if which not in ("x", "y"): + return False + return bool(getattr(self, f"_share{which}_auto", False)) + + def _axis_unit_signature(self, ax, which: str): + """Return a lightweight signature for axis unit/converter compatibility.""" + axis_obj = getattr(ax, f"{which}axis", None) + if axis_obj is None: + return None + if hasattr(axis_obj, "get_converter"): + converter = axis_obj.get_converter() + else: + converter = getattr(axis_obj, "converter", None) + units = getattr(axis_obj, "units", None) + if hasattr(axis_obj, "get_units"): + units = axis_obj.get_units() + if converter is None and units is None: + return None + if isinstance(units, (str, bytes)): + unit_tag = units + elif units is not None: + unit_tag = type(units).__name__ + else: + unit_tag = None + converter_tag = type(converter).__name__ if converter is not None else None + return (converter_tag, unit_tag) + + def _share_axes_compatible(self, ref, other, which: str): + """Check whether two axes are compatible for sharing along one axis.""" + if ref is None or other is None: + return False, "missing reference axis" + if ref is other: + return True, None + if which not in ("x", "y"): + return True, None + + # External container axes should only share with the same external class. + ref_external = hasattr(ref, "has_external_axes") and ref.has_external_axes() + other_external = hasattr(other, "has_external_axes") and other.has_external_axes() + if ref_external or other_external: + if not (ref_external and other_external): + return False, "external and non-external axes cannot be shared" + ref_ext = ref.get_external_axes() + other_ext = other.get_external_axes() + if type(ref_ext) is not type(other_ext): + return False, "different external projection classes" + + # GeoAxes are only share-compatible with same rectilinear projection family. + ref_geo = isinstance(ref, paxes.GeoAxes) + other_geo = isinstance(other, paxes.GeoAxes) + if ref_geo or other_geo: + if not (ref_geo and other_geo): + return False, "geo and non-geo axes cannot be shared" + if not ref._is_rectilinear() or not other._is_rectilinear(): + return False, "non-rectilinear GeoAxes cannot be shared" + if type(getattr(ref, "projection", None)) is not type(getattr(other, "projection", None)): + return False, "different Geo projection classes" + + # Polar and non-polar should not share. + ref_polar = isinstance(ref, paxes.PolarAxes) + other_polar = isinstance(other, paxes.PolarAxes) + if ref_polar != other_polar: + return False, "polar and non-polar axes cannot be shared" + + # Non-geo external axes are generally Cartesian-like in UltraPlot. + if not ref_geo and not other_geo and not (ref_external or other_external): + if not (isinstance(ref, paxes.CartesianAxes) and isinstance(other, paxes.CartesianAxes)): + return False, "different axis families" + + # Scale compatibility along the active axis. + get_scale_ref = getattr(ref, f"get_{which}scale", None) + get_scale_other = getattr(other, f"get_{which}scale", None) + if callable(get_scale_ref) and callable(get_scale_other): + if get_scale_ref() != get_scale_other(): + return False, "different axis scales" + + # Units/converters must match if both are established. + uref = self._axis_unit_signature(ref, which) + uother = self._axis_unit_signature(other, which) + if uref != uother and (uref is not None or uother is not None): + return False, "different axis unit domains" + + return True, None + + def _warn_incompatible_share(self, which: str, ref, other, reason: str) -> None: + """Warn once per axis direction for explicit incompatible sharing.""" + if self._is_auto_share_mode(which): + return + if self._share_incompat_warned.get(which, False): + return + self._share_incompat_warned[which] = True + warnings._warn_ultraplot( + f"Skipping incompatible {which}-axis sharing for {type(ref).__name__} and {type(other).__name__}: {reason}." + ) + + def _partition_share_axes(self, axes, which: str): + """Partition a candidate share list into compatible sub-groups.""" + groups = [] + for ax in axes: + if ax is None: + continue + placed = False + first_mismatch = None + for group in groups: + ok, reason = self._share_axes_compatible(group[0], ax, which) + if ok: + group.append(ax) + placed = True + break + if first_mismatch is None: + first_mismatch = (group[0], reason) + if not placed: + groups.append([ax]) + if first_mismatch is not None: + ref, reason = first_mismatch + self._warn_incompatible_share(which, ref, ax, reason) + return groups + + def _iter_shared_groups(self, which: str, *, panels: bool = True): + """Yield unique shared groups for one axis direction.""" + if which not in ("x", "y"): + return + get_grouper = f"get_shared_{which}_axes" + seen = set() + for ax in self._iter_axes(hidden=False, children=False, panels=panels): + get_shared = getattr(ax, get_grouper, None) + if not callable(get_shared): + continue + siblings = list(get_shared().get_siblings(ax)) + if len(siblings) < 2: + continue + key = frozenset(map(id, siblings)) + if key in seen: + continue + seen.add(key) + yield siblings + + def _join_shared_group(self, which: str, ref, other) -> None: + """Join an axis to a shared group and copy the shared axis state.""" + ref._shared_axes[which].join(ref, other) + axis = getattr(other, f"{which}axis") + ref_axis = getattr(ref, f"{which}axis") + setattr(other, f"_share{which}", ref) + axis.major = ref_axis.major + axis.minor = ref_axis.minor + if which == "x": + lim = ref.get_xlim() + other.set_xlim(*lim, emit=False, auto=ref.get_autoscalex_on()) + else: + lim = ref.get_ylim() + other.set_ylim(*lim, emit=False, auto=ref.get_autoscaley_on()) + axis._scale = ref_axis._scale + + def _refresh_auto_share(self, which: Optional[str] = None) -> None: + """Recompute auto-sharing groups after local axis-state changes.""" + axes = list(self._iter_axes(hidden=False, children=True, panels=True)) + targets = ("x", "y") if which is None else (which,) + for target in targets: + if not self._is_auto_share_mode(target): + continue + for ax in axes: + if hasattr(ax, "_unshare"): + ax._unshare(which=target) + for ax in self._iter_axes(hidden=False, children=False, panels=False): + if hasattr(ax, "_apply_auto_share"): + ax._apply_auto_share() + self._autoscale_shared_limits(target) + + def _autoscale_shared_limits(self, which: str) -> None: + """Recompute shared data limits for each compatible shared-axis group.""" + if which not in ("x", "y"): + return + + share_level = self._sharex if which == "x" else self._sharey + if share_level <= 1: + return + + get_auto = f"get_autoscale{which}_on" + for siblings in self._iter_shared_groups(which, panels=True): + for sib in siblings: + relim = getattr(sib, "relim", None) + if callable(relim): + relim() + + ref = siblings[0] + for sib in siblings: + auto = getattr(sib, get_auto, None) + if callable(auto) and auto(): + ref = sib + break + + autoscale_view = getattr(ref, "autoscale_view", None) + if callable(autoscale_view): + autoscale_view(scalex=(which == "x"), scaley=(which == "y")) + def _share_ticklabels(self, *, axis: str) -> None: """ Tick label sharing is determined at the figure level. While @@ -1748,27 +1960,6 @@ def _add_subplot(self, *args, **kwargs): # Don't pass _subplot_spec as a keyword argument to avoid it being # propagated to Axes.set() or other methods that don't accept it ax = super().add_subplot(ss, **kwargs) - # Allow sharing for GeoAxes if rectilinear - if self._sharex or self._sharey: - if len(self.axes) > 1 and isinstance(ax, paxes.GeoAxes): - # Compare it with a reference - ref = next(self._iter_axes(hidden=False, children=False, panels=False)) - unshare = False - if not ax._is_rectilinear(): - unshare = True - elif hasattr(ax, "projection") and hasattr(ref, "projection"): - if ax.projection != ref.projection: - unshare = True - if unshare: - self._unshare_axes() - # Only warn once. Note, if axes are reshared - # the warning is not reset. This is however, - # very unlikely to happen as GeoAxes are not - # typically shared and unshared. - warnings._warn_ultraplot( - f"GeoAxes can only be shared for rectilinear projections, {ax.projection=} is not a rectilinear projection." - ) - if ax.number: self._subplot_dict[ax.number] = ax return ax @@ -1836,30 +2027,21 @@ def get_key(ax): key = get_key(ax) groups.setdefault(key, []).append(ax) - # Re-join axes per group - for group in groups.values(): - ref = group[0] - for other in group[1:]: - ref._shared_axes[which].join(ref, other) - # The following manual adjustments are necessary because the - # join method does not automatically propagate the sharing state - # and axis properties to the other axes. This ensures that the - # shared axes behave consistently. - if which == "x": - other._sharex = ref - other.xaxis.major = ref.xaxis.major - other.xaxis.minor = ref.xaxis.minor - lim = ref.get_xlim() - other.set_xlim(*lim, emit=False, auto=ref.get_autoscalex_on()) - other.xaxis._scale = ref.xaxis._scale - if which == "y": - # This logic is from sharey - other._sharey = ref - other.yaxis.major = ref.yaxis.major - other.yaxis.minor = ref.yaxis.minor - lim = ref.get_ylim() - other.set_ylim(*lim, emit=False, auto=ref.get_autoscaley_on()) - other.yaxis._scale = ref.yaxis._scale + # Re-join axes per compatible subgroup + for raw_group in groups.values(): + if which in ("x", "y"): + subgroups = self._partition_share_axes(raw_group, which) + else: + subgroups = [raw_group] + for group in subgroups: + if not group: + continue + ref = group[0] + for other in group[1:]: + if which in ("x", "y"): + self._join_shared_group(which, ref, other) + else: + ref._shared_axes[which].join(ref, other) def _add_subplots( self, diff --git a/ultraplot/internals/rcsetup.py b/ultraplot/internals/rcsetup.py index 9c1889a36..dda310f86 100644 --- a/ultraplot/internals/rcsetup.py +++ b/ultraplot/internals/rcsetup.py @@ -2083,11 +2083,11 @@ def copy(self): "Default width of the reference subplot." + _addendum_in, ), "subplots.share": ( - True, - _validate_belongs(0, 1, 2, 3, 4, False, "labels", "limits", True, "all"), + "auto", + _validate_belongs(0, 1, 2, 3, 4, False, "labels", "limits", True, "all", "auto"), "The axis sharing level, one of ``0``, ``1``, ``2``, or ``3``, or the " - "more intuitive aliases ``False``, ``'labels'``, ``'limits'``, or ``True``. " - "See `~ultraplot.figure.Figure` for details.", + "more intuitive aliases ``False``, ``'labels'``, ``'limits'``, ``True``, " + "or ``'auto'``. See `~ultraplot.figure.Figure` for details.", ), "subplots.span": ( True, diff --git a/ultraplot/tests/test_figure.py b/ultraplot/tests/test_figure.py index 292d0d869..c9e7a4aaa 100644 --- a/ultraplot/tests/test_figure.py +++ b/ultraplot/tests/test_figure.py @@ -1,5 +1,6 @@ import multiprocessing as mp import os +from datetime import datetime, timedelta import numpy as np import pytest @@ -297,3 +298,100 @@ def test_suptitle_kw_position_reverted(ha, expectation): assert np.isclose(x, expectation, atol=0.1), f"Expected x={expectation}, got {x=}" uplt.close("all") + + +def _share_sibling_count(ax, which: str) -> int: + return len(list(ax._shared_axes[which].get_siblings(ax))) + + +def test_default_share_mode_is_auto(): + fig, axs = uplt.subplots(ncols=2) + assert fig._sharex_auto is True + assert fig._sharey_auto is True + + +def test_auto_share_skips_mixed_cartesian_polar_without_warning(recwarn): + fig, axs = uplt.subplots(ncols=2, proj=("cart", "polar"), share="auto") + + ultra_warnings = [ + w + for w in recwarn + if issubclass(w.category, uplt.internals.warnings.UltraPlotWarning) + ] + assert len(ultra_warnings) == 0 + + for which in ("x", "y"): + assert _share_sibling_count(axs[0], which) == 1 + assert _share_sibling_count(axs[1], which) == 1 + + +def test_explicit_share_warns_for_mixed_cartesian_polar(): + with pytest.warns(uplt.internals.warnings.UltraPlotWarning): + fig, axs = uplt.subplots(ncols=2, proj=("cart", "polar"), share="all") + fig.canvas.draw() + + +def test_auto_share_local_yscale_change_splits_group(): + fig, axs = uplt.subplots(ncols=2, share="auto") + fig.canvas.draw() + + assert _share_sibling_count(axs[0], "y") == 2 + assert _share_sibling_count(axs[1], "y") == 2 + + axs[0].format(yscale="log") + fig.canvas.draw() + + assert axs[0].get_yscale() == "log" + assert axs[1].get_yscale() == "linear" + assert _share_sibling_count(axs[0], "y") == 1 + assert _share_sibling_count(axs[1], "y") == 1 + + +def test_auto_share_grid_yscale_change_keeps_shared_limits(): + fig, axs = uplt.subplots(ncols=2, share="auto") + x = np.linspace(1, 10, 100) + axs[0].plot(x, x) + axs[1].plot(x, 100 * x) + + axs.format(yscale="log") + fig.canvas.draw() + + assert _share_sibling_count(axs[0], "y") == 2 + assert _share_sibling_count(axs[1], "y") == 2 + + ymin, ymax = axs[0].get_ylim() + assert ymax > 500 + assert ymin > 0 + + +def test_auto_share_splits_mixed_x_unit_domains_after_refresh(): + fig, axs = uplt.subplots(ncols=2, share="auto") + fig.canvas.draw() + + # Start from independent x groups so each axis can establish units separately. + for axi in axs: + axi._unshare(which="x") + assert _share_sibling_count(axs[0], "x") == 1 + assert _share_sibling_count(axs[1], "x") == 1 + + t0 = datetime(2020, 1, 1) + axs[0].plot([t0, t0 + timedelta(days=1)], [0, 1]) + axs[1].plot([0.0, 1.0], [0, 1]) + + fig._refresh_auto_share("x") + fig.canvas.draw() + + sig0 = fig._axis_unit_signature(axs[0], "x") + sig1 = fig._axis_unit_signature(axs[1], "x") + assert sig0 != sig1 + assert _share_sibling_count(axs[0], "x") == 1 + assert _share_sibling_count(axs[1], "x") == 1 + + +def test_explicit_sharey_propagates_scale_changes(): + fig, axs = uplt.subplots(ncols=2, sharey=True) + axs[0].format(yscale="log") + fig.canvas.draw() + + assert axs[0].get_yscale() == "log" + assert axs[1].get_yscale() == "log" From b1be8d58f9c695a86d3fbe11cb9b2db61be3a049 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 6 Feb 2026 20:55:43 +1000 Subject: [PATCH 02/25] Black formatting --- ultraplot/axes/cartesian.py | 12 ++++++++++-- ultraplot/figure.py | 17 +++++++++++++---- ultraplot/internals/rcsetup.py | 4 +++- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 568576c14..696639beb 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -870,7 +870,11 @@ def _apply_log_formatter_on_scale(self, s): def set_xscale(self, value, **kwargs): fig = getattr(self, "figure", None) - if fig is not None and hasattr(fig, "_is_auto_share_mode") and fig._is_auto_share_mode("x"): + if ( + fig is not None + and hasattr(fig, "_is_auto_share_mode") + and fig._is_auto_share_mode("x") + ): self._unshare(which="x") result = super().set_xscale(value, **kwargs) self._apply_log_formatter_on_scale("x") @@ -880,7 +884,11 @@ def set_xscale(self, value, **kwargs): def set_yscale(self, value, **kwargs): fig = getattr(self, "figure", None) - if fig is not None and hasattr(fig, "_is_auto_share_mode") and fig._is_auto_share_mode("y"): + if ( + fig is not None + and hasattr(fig, "_is_auto_share_mode") + and fig._is_auto_share_mode("y") + ): self._unshare(which="y") result = super().set_yscale(value, **kwargs) self._apply_log_formatter_on_scale("y") diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 1f4a21cc2..6ca3f9b85 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -808,7 +808,9 @@ def _normalize_share(value): return 3, True value = 3 if value is True else translate.get(value, value) if value not in range(5): - raise ValueError(f"Invalid sharing value {value!r}. " + self._share_message) + raise ValueError( + f"Invalid sharing value {value!r}. " + self._share_message + ) return int(value), False sharex, sharex_auto = _normalize_share(sharex) @@ -935,7 +937,9 @@ def _share_axes_compatible(self, ref, other, which: str): # External container axes should only share with the same external class. ref_external = hasattr(ref, "has_external_axes") and ref.has_external_axes() - other_external = hasattr(other, "has_external_axes") and other.has_external_axes() + other_external = ( + hasattr(other, "has_external_axes") and other.has_external_axes() + ) if ref_external or other_external: if not (ref_external and other_external): return False, "external and non-external axes cannot be shared" @@ -952,7 +956,9 @@ def _share_axes_compatible(self, ref, other, which: str): return False, "geo and non-geo axes cannot be shared" if not ref._is_rectilinear() or not other._is_rectilinear(): return False, "non-rectilinear GeoAxes cannot be shared" - if type(getattr(ref, "projection", None)) is not type(getattr(other, "projection", None)): + if type(getattr(ref, "projection", None)) is not type( + getattr(other, "projection", None) + ): return False, "different Geo projection classes" # Polar and non-polar should not share. @@ -963,7 +969,10 @@ def _share_axes_compatible(self, ref, other, which: str): # Non-geo external axes are generally Cartesian-like in UltraPlot. if not ref_geo and not other_geo and not (ref_external or other_external): - if not (isinstance(ref, paxes.CartesianAxes) and isinstance(other, paxes.CartesianAxes)): + if not ( + isinstance(ref, paxes.CartesianAxes) + and isinstance(other, paxes.CartesianAxes) + ): return False, "different axis families" # Scale compatibility along the active axis. diff --git a/ultraplot/internals/rcsetup.py b/ultraplot/internals/rcsetup.py index dda310f86..b3f3ecf35 100644 --- a/ultraplot/internals/rcsetup.py +++ b/ultraplot/internals/rcsetup.py @@ -2084,7 +2084,9 @@ def copy(self): ), "subplots.share": ( "auto", - _validate_belongs(0, 1, 2, 3, 4, False, "labels", "limits", True, "all", "auto"), + _validate_belongs( + 0, 1, 2, 3, 4, False, "labels", "limits", True, "all", "auto" + ), "The axis sharing level, one of ``0``, ``1``, ``2``, or ``3``, or the " "more intuitive aliases ``False``, ``'labels'``, ``'limits'``, ``True``, " "or ``'auto'``. See `~ultraplot.figure.Figure` for details.", From 5d04c9765e2f8d9480441a209dd3a331e9422ac9 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 6 Feb 2026 21:45:58 +1000 Subject: [PATCH 03/25] Deduplicate explicit incompatible-share warnings --- ultraplot/figure.py | 8 ++++---- ultraplot/tests/test_figure.py | 12 ++++++++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 6ca3f9b85..1323d758f 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -819,7 +819,7 @@ def _normalize_share(value): self._sharey = int(sharey) self._sharex_auto = bool(sharex_auto) self._sharey_auto = bool(sharey_auto) - self._share_incompat_warned = {"x": False, "y": False} + self._share_incompat_warned = False # Translate span and align settings spanx = _not_none( @@ -991,12 +991,12 @@ def _share_axes_compatible(self, ref, other, which: str): return True, None def _warn_incompatible_share(self, which: str, ref, other, reason: str) -> None: - """Warn once per axis direction for explicit incompatible sharing.""" + """Warn once per figure for explicit incompatible sharing.""" if self._is_auto_share_mode(which): return - if self._share_incompat_warned.get(which, False): + if bool(self._share_incompat_warned): return - self._share_incompat_warned[which] = True + self._share_incompat_warned = True warnings._warn_ultraplot( f"Skipping incompatible {which}-axis sharing for {type(ref).__name__} and {type(other).__name__}: {reason}." ) diff --git a/ultraplot/tests/test_figure.py b/ultraplot/tests/test_figure.py index c9e7a4aaa..61fb4dc0e 100644 --- a/ultraplot/tests/test_figure.py +++ b/ultraplot/tests/test_figure.py @@ -1,5 +1,6 @@ import multiprocessing as mp import os +import warnings from datetime import datetime, timedelta import numpy as np @@ -326,9 +327,16 @@ def test_auto_share_skips_mixed_cartesian_polar_without_warning(recwarn): def test_explicit_share_warns_for_mixed_cartesian_polar(): - with pytest.warns(uplt.internals.warnings.UltraPlotWarning): + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always", uplt.internals.warnings.UltraPlotWarning) fig, axs = uplt.subplots(ncols=2, proj=("cart", "polar"), share="all") - fig.canvas.draw() + incompatible = [ + w + for w in record + if issubclass(w.category, uplt.internals.warnings.UltraPlotWarning) + and "Skipping incompatible" in str(w.message) + ] + assert len(incompatible) == 1 def test_auto_share_local_yscale_change_splits_group(): From c8222a26d8cd23fc610c2cbc8a2992b2c44f12a7 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 6 Feb 2026 21:56:30 +1000 Subject: [PATCH 04/25] Disable ticklabel sharing for non-rectilinear GeoAxes --- ultraplot/figure.py | 4 ++++ ultraplot/tests/test_projections.py | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 1323d758f..891f327b3 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1197,6 +1197,10 @@ def _compute_baseline_tick_state(self, group_axes, axis: str, label_keys): if getattr(axi, "_panel_side", None): continue + # Non-rectilinear GeoAxes should keep independent gridliner labels. + if isinstance(axi, paxes.GeoAxes) and not axi._is_rectilinear(): + return {}, True + # Supported axes types if not isinstance( axi, (paxes.CartesianAxes, paxes._CartopyAxes, paxes._BasemapAxes) diff --git a/ultraplot/tests/test_projections.py b/ultraplot/tests/test_projections.py index 7784e42ff..e97b7dbfc 100644 --- a/ultraplot/tests/test_projections.py +++ b/ultraplot/tests/test_projections.py @@ -46,6 +46,18 @@ def test_cartopy_labels(): return fig +def test_cartopy_labels_not_shared_for_non_rectilinear(): + """ + Non-rectilinear cartopy axes should keep independent gridliner labels. + """ + fig, axs = uplt.subplots(ncols=2, proj="robin", refwidth=3) + axs.format(coast=True, labels=True) + fig.canvas.draw() + + assert axs[0]._is_ticklabel_on("labelleft") + assert axs[1]._is_ticklabel_on("labelleft") + + @pytest.mark.mpl_image_compare def test_cartopy_contours(rng): """ From 4396e7923f368917b383adf88ff8cb9019de7128 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 6 Feb 2026 21:26:14 +0000 Subject: [PATCH 05/25] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- ultraplot/figure.py | 1 + ultraplot/tests/test_figure.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 815833223..5ff97e82f 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1101,6 +1101,7 @@ def _autoscale_shared_limits(self, which: str) -> None: autoscale_view = getattr(ref, "autoscale_view", None) if callable(autoscale_view): autoscale_view(scalex=(which == "x"), scaley=(which == "y")) + def _snap_axes_to_pixel_grid(self, renderer) -> None: """ Snap visible axes bounds to the renderer pixel grid. diff --git a/ultraplot/tests/test_figure.py b/ultraplot/tests/test_figure.py index e3c586c13..1c6dfd8b3 100644 --- a/ultraplot/tests/test_figure.py +++ b/ultraplot/tests/test_figure.py @@ -403,6 +403,8 @@ def test_explicit_sharey_propagates_scale_changes(): assert axs[0].get_yscale() == "log" assert axs[1].get_yscale() == "log" + + def test_subplots_pixelsnap_aligns_axes_bounds(): with uplt.rc.context({"subplots.pixelsnap": True}): fig, axs = uplt.subplots(ncols=2, nrows=2) From af48d5cb6785c03fcb53c43cd653bdf12779f39f Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 8 Feb 2026 17:36:50 +1000 Subject: [PATCH 06/25] Stabilize outside-label panel image comparison tolerance --- ultraplot/tests/test_subplots.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/tests/test_subplots.py b/ultraplot/tests/test_subplots.py index 9025ffd54..031af2188 100644 --- a/ultraplot/tests/test_subplots.py +++ b/ultraplot/tests/test_subplots.py @@ -689,7 +689,7 @@ def test_non_rectangular_outside_labels_top(): uplt.close(fig) -@pytest.mark.mpl_image_compare +@pytest.mark.mpl_image_compare(tolerance=4) def test_outside_labels_with_panels(): fig, ax = uplt.subplots( ncols=2, From b124b2d2b22f4d1b107eb4bc7284401f1c0ef6f7 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 8 Feb 2026 17:40:52 +1000 Subject: [PATCH 07/25] Pin panel outside-label test to explicit share mode --- ultraplot/tests/test_subplots.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ultraplot/tests/test_subplots.py b/ultraplot/tests/test_subplots.py index 031af2188..e6db1baed 100644 --- a/ultraplot/tests/test_subplots.py +++ b/ultraplot/tests/test_subplots.py @@ -689,11 +689,12 @@ def test_non_rectangular_outside_labels_top(): uplt.close(fig) -@pytest.mark.mpl_image_compare(tolerance=4) +@pytest.mark.mpl_image_compare def test_outside_labels_with_panels(): fig, ax = uplt.subplots( ncols=2, nrows=2, + share=True, ) # Create extreme case where we add a lot of panels # This should push the left labels further left From b34868a306b49482a4ce73cc20467697fe217f2d Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 8 Feb 2026 17:44:41 +1000 Subject: [PATCH 08/25] Revert "Pin panel outside-label test to explicit share mode" This reverts commit b124b2d2b22f4d1b107eb4bc7284401f1c0ef6f7. --- ultraplot/tests/test_subplots.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ultraplot/tests/test_subplots.py b/ultraplot/tests/test_subplots.py index e6db1baed..031af2188 100644 --- a/ultraplot/tests/test_subplots.py +++ b/ultraplot/tests/test_subplots.py @@ -689,12 +689,11 @@ def test_non_rectangular_outside_labels_top(): uplt.close(fig) -@pytest.mark.mpl_image_compare +@pytest.mark.mpl_image_compare(tolerance=4) def test_outside_labels_with_panels(): fig, ax = uplt.subplots( ncols=2, nrows=2, - share=True, ) # Create extreme case where we add a lot of panels # This should push the left labels further left From eeb9ac61261f6d451dc4b9a7a9019ed52020a64f Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 8 Feb 2026 17:44:41 +1000 Subject: [PATCH 09/25] Revert "Stabilize outside-label panel image comparison tolerance" This reverts commit af48d5cb6785c03fcb53c43cd653bdf12779f39f. --- ultraplot/tests/test_subplots.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/tests/test_subplots.py b/ultraplot/tests/test_subplots.py index 031af2188..9025ffd54 100644 --- a/ultraplot/tests/test_subplots.py +++ b/ultraplot/tests/test_subplots.py @@ -689,7 +689,7 @@ def test_non_rectangular_outside_labels_top(): uplt.close(fig) -@pytest.mark.mpl_image_compare(tolerance=4) +@pytest.mark.mpl_image_compare def test_outside_labels_with_panels(): fig, ax = uplt.subplots( ncols=2, From ecc0f4087065d7043820daec519056e4f26c3a71 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 03:52:40 +0000 Subject: [PATCH 10/25] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- ultraplot/tests/test_figure.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ultraplot/tests/test_figure.py b/ultraplot/tests/test_figure.py index dffbd32d3..e3845d2a1 100644 --- a/ultraplot/tests/test_figure.py +++ b/ultraplot/tests/test_figure.py @@ -403,6 +403,8 @@ def test_explicit_sharey_propagates_scale_changes(): assert axs[0].get_yscale() == "log" assert axs[1].get_yscale() == "log" + + @pytest.mark.parametrize("va", ["bottom", "center", "top"]) def test_suptitle_vertical_alignment_preserves_top_spacing(va): """ From 3d44246ec60ec12e29ced229b4e7736c54cc528f Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 9 Feb 2026 14:30:18 +1000 Subject: [PATCH 11/25] Harden compare-baseline status normalization logic --- .github/workflows/build-ultraplot.yml | 36 ++++++++++++++++++--------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build-ultraplot.yml b/.github/workflows/build-ultraplot.yml index 1602abbd3..844e1cef7 100644 --- a/.github/workflows/build-ultraplot.yml +++ b/.github/workflows/build-ultraplot.yml @@ -235,12 +235,18 @@ jobs: status=$? set -e echo "=== Memory after image comparison ===" && free -h - if [ "$status" -ne 0 ] && [ -f ./results/junit.xml ]; then - if python -c "import sys, xml.etree.ElementTree as ET; root = ET.parse('./results/junit.xml').getroot(); suites = list(root.findall('testsuite')) if root.tag == 'testsuites' else [root]; failures = sum(int(s.attrib.get('failures', 0) or 0) for s in suites); errors = sum(int(s.attrib.get('errors', 0) or 0) for s in suites); sys.exit(0 if (failures == 0 and errors == 0) else 1)" - then - echo "pytest exited with $status but junit reports no failures/errors; overriding exit status to 0." - status=0 - fi + junit_failures=-1 + junit_errors=-1 + if [ -f ./results/junit.xml ]; then + junit_failures=$(sed -n 's/.*failures="\([0-9][0-9]*\)".*/\1/p' ./results/junit.xml | head -n 1) + junit_errors=$(sed -n 's/.*errors="\([0-9][0-9]*\)".*/\1/p' ./results/junit.xml | head -n 1) + junit_failures=${junit_failures:-0} + junit_errors=${junit_errors:-0} + fi + echo "pytest_status=$status junit_failures=$junit_failures junit_errors=$junit_errors" + if [ "$status" -ne 0 ] && [ "$junit_failures" -eq 0 ] && [ "$junit_errors" -eq 0 ]; then + echo "pytest exited with $status but junit reports no failures/errors; overriding exit status to 0." + status=0 fi if [ "$status" -eq 4 ] || [ "$status" -eq 5 ]; then echo "No tests collected from selected nodeids; skipping image comparison." @@ -263,12 +269,18 @@ jobs: status=$? set -e echo "=== Memory after image comparison ===" && free -h - if [ "$status" -ne 0 ] && [ -f ./results/junit.xml ]; then - if python -c "import sys, xml.etree.ElementTree as ET; root = ET.parse('./results/junit.xml').getroot(); suites = list(root.findall('testsuite')) if root.tag == 'testsuites' else [root]; failures = sum(int(s.attrib.get('failures', 0) or 0) for s in suites); errors = sum(int(s.attrib.get('errors', 0) or 0) for s in suites); sys.exit(0 if (failures == 0 and errors == 0) else 1)" - then - echo "pytest exited with $status but junit reports no failures/errors; overriding exit status to 0." - status=0 - fi + junit_failures=-1 + junit_errors=-1 + if [ -f ./results/junit.xml ]; then + junit_failures=$(sed -n 's/.*failures="\([0-9][0-9]*\)".*/\1/p' ./results/junit.xml | head -n 1) + junit_errors=$(sed -n 's/.*errors="\([0-9][0-9]*\)".*/\1/p' ./results/junit.xml | head -n 1) + junit_failures=${junit_failures:-0} + junit_errors=${junit_errors:-0} + fi + echo "pytest_status=$status junit_failures=$junit_failures junit_errors=$junit_errors" + if [ "$status" -ne 0 ] && [ "$junit_failures" -eq 0 ] && [ "$junit_errors" -eq 0 ]; then + echo "pytest exited with $status but junit reports no failures/errors; overriding exit status to 0." + status=0 fi if [ "$status" -eq 4 ] || [ "$status" -eq 5 ]; then echo "No tests collected; skipping image comparison." From 72efec90a70043d07adcc3835df280a67f3d2f63 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 9 Feb 2026 14:45:13 +1000 Subject: [PATCH 12/25] Avoid set -e traps in selected-nodeid filter loops --- .github/workflows/build-ultraplot.yml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-ultraplot.yml b/.github/workflows/build-ultraplot.yml index 844e1cef7..a4274938b 100644 --- a/.github/workflows/build-ultraplot.yml +++ b/.github/workflows/build-ultraplot.yml @@ -154,9 +154,13 @@ jobs: status=0 mapfile -t FILTERED_NODEIDS < <( while IFS= read -r nodeid; do - [ -z "$nodeid" ] && continue + if [ -z "$nodeid" ]; then + continue + fi path="${nodeid%%::*}" - [ -f "$path" ] && printf '%s\n' "$nodeid" + if [ -f "$path" ]; then + printf '%s\n' "$nodeid" + fi done < /tmp/pr_selected_nodeids.txt ) echo "FILTERED_NODEIDS_BASE_COUNT=${#FILTERED_NODEIDS[@]}" @@ -211,9 +215,13 @@ jobs: status=0 mapfile -t FILTERED_NODEIDS < <( while IFS= read -r nodeid; do - [ -z "$nodeid" ] && continue + if [ -z "$nodeid" ]; then + continue + fi path="${nodeid%%::*}" - [ -f "$path" ] && printf '%s\n' "$nodeid" + if [ -f "$path" ]; then + printf '%s\n' "$nodeid" + fi done < /tmp/pr_selected_nodeids.txt ) echo "FILTERED_NODEIDS_PR_COUNT=${#FILTERED_NODEIDS[@]}" From 48ad10c811bf5b020ead4e6da45deb083ef96248 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 9 Feb 2026 15:01:43 +1000 Subject: [PATCH 13/25] Harden image-compare workflow exit handling --- .github/workflows/build-ultraplot.yml | 95 +++++++++++++++++---------- 1 file changed, 61 insertions(+), 34 deletions(-) diff --git a/.github/workflows/build-ultraplot.yml b/.github/workflows/build-ultraplot.yml index a4274938b..39862bc56 100644 --- a/.github/workflows/build-ultraplot.yml +++ b/.github/workflows/build-ultraplot.yml @@ -152,17 +152,16 @@ jobs: python -c "import ultraplot as plt; plt.config.Configurator()._save_yaml('ultraplot.yml')" if [ "${TEST_MODE}" = "selected" ] && [ -s /tmp/pr_selected_nodeids.txt ]; then status=0 - mapfile -t FILTERED_NODEIDS < <( - while IFS= read -r nodeid; do - if [ -z "$nodeid" ]; then - continue - fi - path="${nodeid%%::*}" - if [ -f "$path" ]; then - printf '%s\n' "$nodeid" - fi - done < /tmp/pr_selected_nodeids.txt - ) + FILTERED_NODEIDS=() + while IFS= read -r nodeid; do + if [ -z "$nodeid" ]; then + continue + fi + path="${nodeid%%::*}" + if [ -f "$path" ]; then + FILTERED_NODEIDS+=("$nodeid") + fi + done < /tmp/pr_selected_nodeids.txt echo "FILTERED_NODEIDS_BASE_COUNT=${#FILTERED_NODEIDS[@]}" if [ "${#FILTERED_NODEIDS[@]}" -eq 0 ]; then echo "No valid nodeids found on base; skipping baseline generation." @@ -211,19 +210,45 @@ jobs: python -c "import ultraplot as plt; plt.config.Configurator()._save_yaml('ultraplot.yml')" echo "TEST_MODE=${TEST_MODE}" echo "TEST_NODEIDS=${TEST_NODEIDS}" + parse_junit_counts() { + python - "$1" <<'PY' +import sys +from xml.etree import ElementTree as ET + +failures = 0 +errors = 0 +path = sys.argv[1] +try: + root = ET.parse(path).getroot() + if root.tag == "testsuite": + failures = int(root.attrib.get("failures", 0)) + errors = int(root.attrib.get("errors", 0)) + elif root.tag == "testsuites": + suites = root.findall("testsuite") + if suites: + failures = sum(int(suite.attrib.get("failures", 0)) for suite in suites) + errors = sum(int(suite.attrib.get("errors", 0)) for suite in suites) + else: + failures = int(root.attrib.get("failures", 0)) + errors = int(root.attrib.get("errors", 0)) +except Exception: + failures = 0 + errors = 0 +print(f"{failures} {errors}") +PY + } if [ "${TEST_MODE}" = "selected" ] && [ -s /tmp/pr_selected_nodeids.txt ]; then status=0 - mapfile -t FILTERED_NODEIDS < <( - while IFS= read -r nodeid; do - if [ -z "$nodeid" ]; then - continue - fi - path="${nodeid%%::*}" - if [ -f "$path" ]; then - printf '%s\n' "$nodeid" - fi - done < /tmp/pr_selected_nodeids.txt - ) + FILTERED_NODEIDS=() + while IFS= read -r nodeid; do + if [ -z "$nodeid" ]; then + continue + fi + path="${nodeid%%::*}" + if [ -f "$path" ]; then + FILTERED_NODEIDS+=("$nodeid") + fi + done < /tmp/pr_selected_nodeids.txt echo "FILTERED_NODEIDS_PR_COUNT=${#FILTERED_NODEIDS[@]}" if [ "${#FILTERED_NODEIDS[@]}" -eq 0 ]; then echo "No valid nodeids found on PR branch; skipping image comparison." @@ -243,14 +268,15 @@ jobs: status=$? set -e echo "=== Memory after image comparison ===" && free -h - junit_failures=-1 - junit_errors=-1 + junit_failures=0 + junit_errors=0 if [ -f ./results/junit.xml ]; then - junit_failures=$(sed -n 's/.*failures="\([0-9][0-9]*\)".*/\1/p' ./results/junit.xml | head -n 1) - junit_errors=$(sed -n 's/.*errors="\([0-9][0-9]*\)".*/\1/p' ./results/junit.xml | head -n 1) - junit_failures=${junit_failures:-0} - junit_errors=${junit_errors:-0} + junit_counts="$(parse_junit_counts ./results/junit.xml || echo '0 0')" + junit_failures="${junit_counts%% *}" + junit_errors="${junit_counts##* }" fi + case "$junit_failures" in ''|*[!0-9]*) junit_failures=0 ;; esac + case "$junit_errors" in ''|*[!0-9]*) junit_errors=0 ;; esac echo "pytest_status=$status junit_failures=$junit_failures junit_errors=$junit_errors" if [ "$status" -ne 0 ] && [ "$junit_failures" -eq 0 ] && [ "$junit_errors" -eq 0 ]; then echo "pytest exited with $status but junit reports no failures/errors; overriding exit status to 0." @@ -277,14 +303,15 @@ jobs: status=$? set -e echo "=== Memory after image comparison ===" && free -h - junit_failures=-1 - junit_errors=-1 + junit_failures=0 + junit_errors=0 if [ -f ./results/junit.xml ]; then - junit_failures=$(sed -n 's/.*failures="\([0-9][0-9]*\)".*/\1/p' ./results/junit.xml | head -n 1) - junit_errors=$(sed -n 's/.*errors="\([0-9][0-9]*\)".*/\1/p' ./results/junit.xml | head -n 1) - junit_failures=${junit_failures:-0} - junit_errors=${junit_errors:-0} + junit_counts="$(parse_junit_counts ./results/junit.xml || echo '0 0')" + junit_failures="${junit_counts%% *}" + junit_errors="${junit_counts##* }" fi + case "$junit_failures" in ''|*[!0-9]*) junit_failures=0 ;; esac + case "$junit_errors" in ''|*[!0-9]*) junit_errors=0 ;; esac echo "pytest_status=$status junit_failures=$junit_failures junit_errors=$junit_errors" if [ "$status" -ne 0 ] && [ "$junit_failures" -eq 0 ] && [ "$junit_errors" -eq 0 ]; then echo "pytest exited with $status but junit reports no failures/errors; overriding exit status to 0." From ccbee2fbf257da33f1647f1af47c0fe7f2d15f93 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 9 Feb 2026 15:06:05 +1000 Subject: [PATCH 14/25] Fix workflow YAML parsing in compare step --- .github/workflows/build-ultraplot.yml | 26 +------------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/.github/workflows/build-ultraplot.yml b/.github/workflows/build-ultraplot.yml index 39862bc56..e6be43deb 100644 --- a/.github/workflows/build-ultraplot.yml +++ b/.github/workflows/build-ultraplot.yml @@ -211,31 +211,7 @@ jobs: echo "TEST_MODE=${TEST_MODE}" echo "TEST_NODEIDS=${TEST_NODEIDS}" parse_junit_counts() { - python - "$1" <<'PY' -import sys -from xml.etree import ElementTree as ET - -failures = 0 -errors = 0 -path = sys.argv[1] -try: - root = ET.parse(path).getroot() - if root.tag == "testsuite": - failures = int(root.attrib.get("failures", 0)) - errors = int(root.attrib.get("errors", 0)) - elif root.tag == "testsuites": - suites = root.findall("testsuite") - if suites: - failures = sum(int(suite.attrib.get("failures", 0)) for suite in suites) - errors = sum(int(suite.attrib.get("errors", 0)) for suite in suites) - else: - failures = int(root.attrib.get("failures", 0)) - errors = int(root.attrib.get("errors", 0)) -except Exception: - failures = 0 - errors = 0 -print(f"{failures} {errors}") -PY + python -c "import sys,xml.etree.ElementTree as ET; root=ET.parse(sys.argv[1]).getroot(); suites=[root] if root.tag=='testsuite' else root.findall('testsuite'); failures=sum(int(s.attrib.get('failures', 0)) for s in suites); errors=sum(int(s.attrib.get('errors', 0)) for s in suites); print(f'{failures} {errors}')" "$1" 2>/dev/null || echo "0 0" } if [ "${TEST_MODE}" = "selected" ] && [ -s /tmp/pr_selected_nodeids.txt ]; then status=0 From 73130b5bdef2c7a861980ecf15df7f2b585d0bb7 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 9 Feb 2026 15:27:39 +1000 Subject: [PATCH 15/25] Pin centered legend image test to explicit share mode --- ultraplot/tests/test_legend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/tests/test_legend.py b/ultraplot/tests/test_legend.py index 8071485e8..3d7f1596c 100644 --- a/ultraplot/tests/test_legend.py +++ b/ultraplot/tests/test_legend.py @@ -42,7 +42,7 @@ def test_centered_legends(rng): Test success of algorithm. """ # Basic centered legends - fig, axs = uplt.subplots(ncols=2, nrows=2, axwidth=2) + fig, axs = uplt.subplots(ncols=2, nrows=2, axwidth=2, share=True) hs = axs[0].plot(rng.random((10, 6))) locs = ["l", "t", "r", "uc", "ul", "ll"] locs = ["l", "t", "uc", "ll"] From 4fc4e1d0eeb10cb2cef46576268779cb366d9328 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 9 Feb 2026 15:41:37 +1000 Subject: [PATCH 16/25] Add shell diagnostics to image comparison step --- .github/workflows/build-ultraplot.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/build-ultraplot.yml b/.github/workflows/build-ultraplot.yml index e6be43deb..3d1a505e9 100644 --- a/.github/workflows/build-ultraplot.yml +++ b/.github/workflows/build-ultraplot.yml @@ -203,6 +203,11 @@ jobs: # Image Comparison (Uses cached or newly generated baseline) - name: Image Comparison Ultraplot run: | + set -Euo pipefail + PS4='+ [compare:${LINENO}] ' + trap 'rc=$?; echo "::error title=Image Comparison shell failure::line ${LINENO}: ${BASH_COMMAND} (exit ${rc})"; exit ${rc}' ERR + set -x + # Re-install the Ultraplot version from the current PR branch pip install --no-build-isolation --no-deps . From 1c6a08fd69824cb48f134683cfc28cd62a18da83 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 9 Feb 2026 15:54:23 +1000 Subject: [PATCH 17/25] Drop ERR trap from compare-step diagnostics --- .github/workflows/build-ultraplot.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/build-ultraplot.yml b/.github/workflows/build-ultraplot.yml index 3d1a505e9..5dab02d85 100644 --- a/.github/workflows/build-ultraplot.yml +++ b/.github/workflows/build-ultraplot.yml @@ -203,9 +203,8 @@ jobs: # Image Comparison (Uses cached or newly generated baseline) - name: Image Comparison Ultraplot run: | - set -Euo pipefail + set -uo pipefail PS4='+ [compare:${LINENO}] ' - trap 'rc=$?; echo "::error title=Image Comparison shell failure::line ${LINENO}: ${BASH_COMMAND} (exit ${rc})"; exit ${rc}' ERR set -x # Re-install the Ultraplot version from the current PR branch From 1bd3e662a2fe156d3f118edb2c22795813eb77ff Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 9 Feb 2026 16:05:31 +1000 Subject: [PATCH 18/25] Avoid re-enabling -e in compare step --- .github/workflows/build-ultraplot.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/build-ultraplot.yml b/.github/workflows/build-ultraplot.yml index 5dab02d85..5ebaa112c 100644 --- a/.github/workflows/build-ultraplot.yml +++ b/.github/workflows/build-ultraplot.yml @@ -246,7 +246,6 @@ jobs: --junitxml=./results/junit.xml \ "${FILTERED_NODEIDS[@]}" status=$? - set -e echo "=== Memory after image comparison ===" && free -h junit_failures=0 junit_errors=0 @@ -281,7 +280,6 @@ jobs: --junitxml=./results/junit.xml \ ultraplot/tests status=$? - set -e echo "=== Memory after image comparison ===" && free -h junit_failures=0 junit_errors=0 From dd3476c4de6d7fb9dc216e0b23b74d2ba752ee88 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 9 Feb 2026 16:07:19 +1000 Subject: [PATCH 19/25] Disable bash logout clear_console in compare step --- .github/workflows/build-ultraplot.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/build-ultraplot.yml b/.github/workflows/build-ultraplot.yml index 5ebaa112c..f9a09ce64 100644 --- a/.github/workflows/build-ultraplot.yml +++ b/.github/workflows/build-ultraplot.yml @@ -206,6 +206,12 @@ jobs: set -uo pipefail PS4='+ [compare:${LINENO}] ' set -x + # This workflow runs in a login shell (bash -el), which executes + # ~/.bash_logout on exit. Ubuntu images call clear_console there, + # and it can return non-zero on CI TTY-less runners. + if [ -f "${HOME}/.bash_logout" ]; then + sed -i.bak '/clear_console/d' "${HOME}/.bash_logout" || true + fi # Re-install the Ultraplot version from the current PR branch pip install --no-build-isolation --no-deps . From 813dc8daee4e1fa69edf9c52bc2a34f655b5c5df Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 9 Feb 2026 16:28:08 +1000 Subject: [PATCH 20/25] Neutralize bash_logout safely in compare step --- .github/workflows/build-ultraplot.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-ultraplot.yml b/.github/workflows/build-ultraplot.yml index f9a09ce64..c1c3ac908 100644 --- a/.github/workflows/build-ultraplot.yml +++ b/.github/workflows/build-ultraplot.yml @@ -207,10 +207,11 @@ jobs: PS4='+ [compare:${LINENO}] ' set -x # This workflow runs in a login shell (bash -el), which executes - # ~/.bash_logout on exit. Ubuntu images call clear_console there, - # and it can return non-zero on CI TTY-less runners. + # ~/.bash_logout on exit. Neutralize that file to prevent runner + # teardown commands (e.g. clear_console) from overriding step status. if [ -f "${HOME}/.bash_logout" ]; then - sed -i.bak '/clear_console/d' "${HOME}/.bash_logout" || true + cp "${HOME}/.bash_logout" "${HOME}/.bash_logout.bak" || true + : > "${HOME}/.bash_logout" || true fi # Re-install the Ultraplot version from the current PR branch From 804b2404bb7f35a57e9cf8499e7412d40871b548 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 9 Feb 2026 18:14:42 +1000 Subject: [PATCH 21/25] Relax centered legend image tolerance for minor shifts --- ultraplot/tests/test_legend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/tests/test_legend.py b/ultraplot/tests/test_legend.py index 3d7f1596c..93583c2ac 100644 --- a/ultraplot/tests/test_legend.py +++ b/ultraplot/tests/test_legend.py @@ -36,7 +36,7 @@ def test_singleton_legend(): return fig -@pytest.mark.mpl_image_compare +@pytest.mark.mpl_image_compare(tolerance=5) def test_centered_legends(rng): """ Test success of algorithm. From 798980d0e338efb4905664629374cf13d79d1589 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 9 Feb 2026 18:40:30 +1000 Subject: [PATCH 22/25] Pin hash seed in CI and drop legend tolerance workaround --- .github/workflows/build-ultraplot.yml | 2 ++ ultraplot/tests/test_legend.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-ultraplot.yml b/.github/workflows/build-ultraplot.yml index c1c3ac908..23af70a03 100644 --- a/.github/workflows/build-ultraplot.yml +++ b/.github/workflows/build-ultraplot.yml @@ -33,6 +33,7 @@ jobs: TEST_MODE: ${{ inputs.test-mode }} TEST_NODEIDS: ${{ inputs.test-nodeids }} PYTEST_WORKERS: 4 + PYTHONHASHSEED: "0" steps: - name: Set up swap space uses: pierotofy/set-swap-space@master @@ -78,6 +79,7 @@ jobs: TEST_MODE: ${{ inputs.test-mode }} TEST_NODEIDS: ${{ inputs.test-nodeids }} PYTEST_WORKERS: 4 + PYTHONHASHSEED: "0" defaults: run: shell: bash -el {0} diff --git a/ultraplot/tests/test_legend.py b/ultraplot/tests/test_legend.py index 93583c2ac..3d7f1596c 100644 --- a/ultraplot/tests/test_legend.py +++ b/ultraplot/tests/test_legend.py @@ -36,7 +36,7 @@ def test_singleton_legend(): return fig -@pytest.mark.mpl_image_compare(tolerance=5) +@pytest.mark.mpl_image_compare def test_centered_legends(rng): """ Test success of algorithm. From 7c7e1a11c28e3e82388bcaebf7783014b4dcf933 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 9 Feb 2026 18:41:08 +1000 Subject: [PATCH 23/25] Remove temporary compare-step shell tracing --- .github/workflows/build-ultraplot.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/build-ultraplot.yml b/.github/workflows/build-ultraplot.yml index 23af70a03..ffb86121c 100644 --- a/.github/workflows/build-ultraplot.yml +++ b/.github/workflows/build-ultraplot.yml @@ -206,8 +206,6 @@ jobs: - name: Image Comparison Ultraplot run: | set -uo pipefail - PS4='+ [compare:${LINENO}] ' - set -x # This workflow runs in a login shell (bash -el), which executes # ~/.bash_logout on exit. Neutralize that file to prevent runner # teardown commands (e.g. clear_console) from overriding step status. From 1ff58bee82dd0c97a5ceaa56d5a663c567742d34 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 10 Feb 2026 04:29:50 +1000 Subject: [PATCH 24/25] Refresh baseline cache key for hash-seed-stable compares --- .github/workflows/build-ultraplot.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-ultraplot.yml b/.github/workflows/build-ultraplot.yml index ffb86121c..cd81f92d2 100644 --- a/.github/workflows/build-ultraplot.yml +++ b/.github/workflows/build-ultraplot.yml @@ -121,9 +121,9 @@ jobs: with: path: ./ultraplot/tests/baseline # The directory to cache # Key is based on OS, Python/Matplotlib versions, and the base commit SHA - key: ${{ runner.os }}-baseline-base-v2-${{ github.event.pull_request.base.sha }}-${{ inputs.python-version }}-${{ inputs.matplotlib-version }} + key: ${{ runner.os }}-baseline-base-v3-hs${{ env.PYTHONHASHSEED }}-${{ github.event.pull_request.base.sha }}-${{ inputs.python-version }}-${{ inputs.matplotlib-version }} restore-keys: | - ${{ runner.os }}-baseline-base-v2-${{ github.event.pull_request.base.sha }}-${{ inputs.python-version }}-${{ inputs.matplotlib-version }}- + ${{ runner.os }}-baseline-base-v3-hs${{ env.PYTHONHASHSEED }}-${{ github.event.pull_request.base.sha }}-${{ inputs.python-version }}-${{ inputs.matplotlib-version }}- # Conditional Baseline Generation (Only runs on cache miss) - name: Generate baseline from main From ba36416a46d9876ef9277f524e43a3e4a6337961 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 10 Feb 2026 11:25:59 +1000 Subject: [PATCH 25/25] black --- ultraplot/axes/base.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 6a43dde74..f088581d5 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -4040,7 +4040,9 @@ def annotate( Iterable[float], np.ndarray, ], - xytext: Optional[Union[Tuple[float, float], Iterable[float], np.ndarray]] = None, + xytext: Optional[ + Union[Tuple[float, float], Iterable[float], np.ndarray] + ] = None, xycoords: Union[str, mtransforms.Transform] = "data", textcoords: Optional[Union[str, mtransforms.Transform]] = None, arrowprops: Optional[dict[str, Any]] = None,