From d7186349d3a19e19f485b25fee645f70d62af2a6 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 4 Sep 2026 15:39:36 +1000 Subject: [PATCH 01/11] make format behavior more sane --- ultraplot/axes/cartesian.py | 2 +- ultraplot/axes/geo.py | 12 ++++++++-- ultraplot/figure.py | 43 +++++++++++++++++++++++++++++++++- ultraplot/tests/test_format.py | 20 ++++++++++++++++ 4 files changed, 73 insertions(+), 4 deletions(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 064c1eed8..282885c7a 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -546,7 +546,7 @@ def _apply_axis_sharing_for_axis( level = 3 if panel_group else sharing_level # Handle axis label sharing (level > 0) - if level > 0: + if level > 0 and getattr(self.figure, f"_share{axis_name}_labels", True): if self.figure._is_share_label_group_member(self, axis_name): pass elif self.figure._is_share_label_group_member(shared_axis, axis_name): diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index c6bd8eb5c..57773cf61 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -2078,7 +2078,11 @@ def _apply_axis_sharing(self) -> None: """ # Share axis labels - if self._sharex and self.figure._sharex >= 1: + if ( + self._sharex + and self.figure._sharex >= 1 + and getattr(self.figure, "_sharex_labels", True) + ): if self.figure._is_share_label_group_member(self, "x"): pass elif self.figure._is_share_label_group_member(self._sharex, "x"): @@ -2086,7 +2090,11 @@ def _apply_axis_sharing(self) -> None: else: labels._transfer_label(self.xaxis.label, self._sharex.xaxis.label) self.xaxis.label.set_visible(False) - if self._sharey and self.figure._sharey >= 1: + if ( + self._sharey + and self.figure._sharey >= 1 + and getattr(self.figure, "_sharey_labels", True) + ): if self.figure._is_share_label_group_member(self, "y"): pass elif self.figure._is_share_label_group_member(self._sharey, "y"): diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 89c830bc1..f201edd3b 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -3558,7 +3558,37 @@ def format( pending_layout = kwargs.pop("_pending_layout", None) if pending_layout is None: pending_layout = bool(self.stale) - axs = axs or self._iter_subplots() + axs = list(axs or self._iter_subplots()) + # Unlike titles, axis labels are normally forwarded verbatim to every + # axes. Accept a sequence of strings here as a request for one label per + # formatted axes. Distinct labels are incompatible with label sharing, so + # disable sharing in that direction and trust the explicit request. + label_sequences = {} + for key, axis in (("xlabel", "x"), ("ylabel", "y")): + value = kwargs.get(key) + if isinstance(value, str) or not np.iterable(value): + continue + value = tuple(value) + if not all(isinstance(item, str) for item in value): + continue + if len(value) != len(axs): + raise ValueError( + f"Invalid {key} list length {len(value)} " + f"for {len(axs)} formatted axes." + ) + label_sequences[key] = value + kwargs[key] = value + if len(value) > 1: + if getattr(self, f"_share{axis}", 0): + # Rebuild at level 2: preserve the shared limits, scales, + # and tickers but remove label sharing (level 1). + self._toggle_axis_sharing(which=axis, share=0) + self._toggle_axis_sharing(which=axis, share=2) + setattr(self, f"_share{axis}_labels", False) + # Spanning labels are enabled by default with shared axes, but + # would replace the per-axes labels with a single figure artist. + setattr(self, f"_span{axis}", False) + self._clear_share_label_groups(target=axis) skip_axes = kwargs.pop("skip_axes", False) # internal keyword arg explicit_format_keys = set(kwargs) signature_axis_kwargs, generic_axis_kwargs = pop_axis_format_kwargs( @@ -3682,6 +3712,17 @@ def _axis_has_label_text(ax, axis): for key, value in kw.items() if isinstance(ax, cls) and not classes.add(cls) } + # Titles already support this convention in Axes._update_title(). + # Labels need dispatch here because Matplotlib otherwise treats a + # sequence as one label object and converts it to its repr. + for key, values in label_sequences.items(): + supports_label = any( + key in cls_kw + for cls, cls_kw in kws.items() + if isinstance(ax, cls) + ) + if supports_label: + kw[key] = values[number - 1] if kw.get("xlabel") is not None and self._has_share_label_groups("x"): if _axis_has_share_label_text(ax, "x") or _axis_has_label_text(ax, "x"): kw.pop("xlabel", None) diff --git a/ultraplot/tests/test_format.py b/ultraplot/tests/test_format.py index 72eb83d1a..8fde1ec6c 100644 --- a/ultraplot/tests/test_format.py +++ b/ultraplot/tests/test_format.py @@ -366,6 +366,26 @@ def test_label_settings(): return fig +def test_format_distributes_axis_label_sequences_and_reduces_sharing(): + """Per-axes labels retain shared limits but override label sharing.""" + fig, axs = uplt.subplots(ncols=2, share=True) + axs.format( + title=["First", "Second"], + xlabel=["First x", "Second x"], + ylabel=["First y", "Second y"], + ) + fig.canvas.draw() + + assert [ax.get_title() for ax in axs] == ["First", "Second"] + assert [ax.get_xlabel() for ax in axs] == ["First x", "Second x"] + assert [ax.get_ylabel() for ax in axs] == ["First y", "Second y"] + assert fig._sharex == fig._sharey == 2 + assert all(ax.xaxis.label.get_visible() for ax in axs) + assert all(ax.yaxis.label.get_visible() for ax in axs) + axs[0].set_xlim(1, 2) + assert axs[1].get_xlim() == (1, 2) + + def test_colormap_parsing(): """Test colormaps merging""" reds = uplt.colormaps.get_cmap("reds") From ceba128adf282b969dabf6220b1489a1dded14d7 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 4 Sep 2026 16:16:49 +1000 Subject: [PATCH 02/11] add new keywords + docs --- ultraplot/axes/base.py | 13 ++-- ultraplot/axes/cartesian.py | 45 ++++++----- ultraplot/axes/geo.py | 39 ++++------ ultraplot/figure.py | 133 +++++++++++++++++++++++++++++---- ultraplot/gridspec.py | 14 ++-- ultraplot/tests/test_format.py | 105 ++++++++++++++++++++++++-- 6 files changed, 265 insertions(+), 84 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index c08c93d41..a0a4a8262 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -1485,7 +1485,7 @@ def shared(paxs): iax._panel_sharex_group = True iax._sharex_setup(bottom) # parent is bottom-most paxs = shared(self._panel_dict["top"]) - if paxs and self.figure._sharex > 0: + if paxs and self.figure._axis_sharing_enabled("x"): self._panel_sharex_group = True for iax in paxs: iax._panel_sharex_group = True @@ -1501,7 +1501,7 @@ def shared(paxs): iax._panel_sharey_group = True iax._sharey_setup(left) # parent is left-most paxs = shared(self._panel_dict["right"]) - if paxs and self.figure._sharey > 0: + if paxs and self.figure._axis_sharing_enabled("y"): self._panel_sharey_group = True for iax in paxs: iax._panel_sharey_group = True @@ -1509,7 +1509,7 @@ def shared(paxs): # External axes sharing, sometimes overrides panel axes sharing # Share x axes within compatible groups - if self.figure._sharex > 0: + if self.figure._axis_sharing_enabled("x"): axes_x = self._get_share_axes("x") for group in self.figure._partition_share_axes(axes_x, "x"): if not group: @@ -1519,7 +1519,7 @@ def shared(paxs): child._sharex_setup(parent) # Share y axes within compatible groups - if self.figure._sharey > 0: + if self.figure._axis_sharing_enabled("y"): axes_y = self._get_share_axes("y") for group in self.figure._partition_share_axes(axes_y, "y"): if not group: @@ -2966,10 +2966,7 @@ def _update_title(self, loc, title=None, **kwargs): if self.number is None: pass elif self.number > len(title): - raise ValueError( - f"Invalid title list length {len(title)} " - f"for axes with number {self.number}." - ) + pass else: kw["text"] = title[self.number - 1] else: diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 282885c7a..094a299b9 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -545,8 +545,11 @@ def _apply_axis_sharing_for_axis( level = 3 if panel_group else sharing_level - # Handle axis label sharing (level > 0) - if level > 0 and getattr(self.figure, f"_share{axis_name}_labels", True): + # Handle axis-title sharing independently from numeric sharing. + share_labels = panel_group or getattr( + self.figure, f"_share{axis_name}_labels", level > 0 + ) + if share_labels: if self.figure._is_share_label_group_member(self, axis_name): pass elif self.figure._is_share_label_group_member(shared_axis, axis_name): @@ -556,8 +559,11 @@ def _apply_axis_sharing_for_axis( labels._transfer_label(axis.label, shared_axis_obj.label) axis.label.set_visible(False) - # Handle tick label sharing (level > 2) - if level > 2: + # Handle tick-label suppression independently from numeric sharing. + share_ticklabels = panel_group or getattr( + self.figure, f"_share{axis_name}_ticklabels", level > 2 + ) + if share_ticklabels: label_visibility = self._determine_tick_label_visibility( axis, shared_axis, @@ -874,25 +880,21 @@ def _sharex_setup(self, sharex, *, labels=True, limits=True): """ # Share panels across *different* subplots super()._sharex_setup(sharex) - # Get the axis sharing level - level = ( - 3 - if self._panel_sharex_group and self._is_panel_group_member(sharex) - else self.figure._sharex - ) - if level not in range(5): # must be internal error - raise ValueError(f"Invalid sharing level sharex={level!r}.") + panel_group = self._panel_sharex_group and self._is_panel_group_member(sharex) if sharex in (None, self) or not isinstance(sharex, CartesianAxes): return # Share future axis label changes. Implemented in _apply_axis_sharing(). # Matplotlib only uses these attributes in __init__() and cla() to share # tickers -- all other builtin sharing features derives from shared x axes - if level > 0 and labels: + share_labels = panel_group or self.figure._sharex_labels + share_limits = panel_group or self.figure._sharex_limits + share_ticklabels = panel_group or self.figure._sharex_ticklabels + if (share_labels and labels) or (share_limits and limits) or share_ticklabels: self._sharex = sharex # Share future axis tickers, limits, and scales # NOTE: Only difference between levels 2 and 3 is level 3 hides tick # labels. But this is done after the fact -- tickers are still shared. - if level > 1 and limits: + if share_limits and limits: self._sharex_limits(sharex) def _sharey_setup(self, sharey, *, labels=True, limits=True): @@ -902,18 +904,15 @@ def _sharey_setup(self, sharey, *, labels=True, limits=True): """ # NOTE: See _sharex_setup for notes super()._sharey_setup(sharey) - level = ( - 3 - if self._panel_sharey_group and self._is_panel_group_member(sharey) - else self.figure._sharey - ) - if level not in range(5): # must be internal error - raise ValueError(f"Invalid sharing level sharey={level!r}.") + panel_group = self._panel_sharey_group and self._is_panel_group_member(sharey) if sharey in (None, self) or not isinstance(sharey, CartesianAxes): return - if level > 0 and labels: + share_labels = panel_group or self.figure._sharey_labels + share_limits = panel_group or self.figure._sharey_limits + share_ticklabels = panel_group or self.figure._sharey_ticklabels + if (share_labels and labels) or (share_limits and limits) or share_ticklabels: self._sharey = sharey - if level > 1 and limits: + if share_limits and limits: self._sharey_limits(sharey) def _apply_log_formatter_on_scale(self, s): diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index 57773cf61..401d9209f 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -1613,12 +1613,12 @@ def _sync_shared_tick_state( if not any((copy_major_locator, copy_minor_locator, copy_major_formatter)): return if which == "x": - if self.figure._sharex < 2: + if not self.figure._sharex_limits: return this_axis = self._lonaxis siblings = list(self._shared_axes["x"].get_siblings(self)) else: - if self.figure._sharey < 2: + if not self.figure._sharey_limits: return this_axis = self._lataxis siblings = list(self._shared_axes["y"].get_siblings(self)) @@ -1880,24 +1880,25 @@ def __share_axis_setup( labels: bool, limits: bool, ) -> None: - level = getattr(self.figure, f"_share{which}") - if getattr(self, f"_panel_share{which}_group") and self._is_panel_group_member( - other - ): - level = 3 - if level not in range(5): # must be internal error - raise ValueError(f"Invalid sharing level sharex={level!r}.") + panel_group = getattr( + self, f"_panel_share{which}_group" + ) and self._is_panel_group_member(other) if other in (None, self) or not isinstance(other, GeoAxes): return # Share future axis label changes. Implemented in _apply_axis_sharing(). # Matplotlib only uses these attributes in __init__() and cla() to share # tickers -- all other builtin sharing features derives from shared x axes - if level > 0 and labels: + share_labels = panel_group or getattr(self.figure, f"_share{which}_labels") + share_limits = panel_group or getattr(self.figure, f"_share{which}_limits") + share_ticklabels = panel_group or getattr( + self.figure, f"_share{which}_ticklabels" + ) + if (share_labels and labels) or (share_limits and limits) or share_ticklabels: setattr(self, f"_share{which}", other) # Share future axis tickers, limits, and scales # NOTE: Only difference between levels 2 and 3 is level 3 hides ticklabels # labels. But this is done after the fact -- tickers are still shared. - if level > 1 and limits: + if share_limits and limits: self._share_limits_with(other, which=which) @override @@ -2078,11 +2079,7 @@ def _apply_axis_sharing(self) -> None: """ # Share axis labels - if ( - self._sharex - and self.figure._sharex >= 1 - and getattr(self.figure, "_sharex_labels", True) - ): + if self._sharex and self.figure._sharex_labels: if self.figure._is_share_label_group_member(self, "x"): pass elif self.figure._is_share_label_group_member(self._sharex, "x"): @@ -2090,11 +2087,7 @@ def _apply_axis_sharing(self) -> None: else: labels._transfer_label(self.xaxis.label, self._sharex.xaxis.label) self.xaxis.label.set_visible(False) - if ( - self._sharey - and self.figure._sharey >= 1 - and getattr(self.figure, "_sharey_labels", True) - ): + if self._sharey and self.figure._sharey_labels: if self.figure._is_share_label_group_member(self, "y"): pass elif self.figure._is_share_label_group_member(self._sharey, "y"): @@ -2104,12 +2097,12 @@ def _apply_axis_sharing(self) -> None: self.yaxis.label.set_visible(False) # Share interval x - if self._sharex and self.figure._sharex >= 2: + if self._sharex and self.figure._sharex_limits: self._lonaxis.set_view_interval(*self._sharex._lonaxis.get_view_interval()) self._lonaxis.set_minor_locator(self._sharex._lonaxis.get_minor_locator()) # Share interval y - if self._sharey and self.figure._sharey >= 2: + if self._sharey and self.figure._sharey_limits: self._lataxis.set_view_interval(*self._sharey._lataxis.get_view_interval()) self._lataxis.set_minor_locator(self._sharey._lataxis.get_minor_locator()) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index f201edd3b..e68e776af 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -130,6 +130,15 @@ def _any_not_none(*values): Explicit sharing levels (``0`` to ``4`` and aliases) still force sharing attempts and can emit warnings for incompatible axes. +sharexlabels, shareylabels : bool, optional + Override whether the x or y axis-title text (``xlabel`` or ``ylabel``) is + shared. By default this is enabled by sharing levels 1 and above. +sharexlimits, shareylimits : bool, optional + Override whether limits, scales, tick locations, and formatters are shared. + By default this is enabled by sharing levels 2 and above. +sharexticklabels, shareyticklabels : bool, optional + Override whether tick labels are suppressed on interior axes. By default + this is enabled by sharing levels 3 and above. 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 @@ -774,6 +783,12 @@ def __init__( sharex=None, sharey=None, share=None, # used for default spaces + sharexlabels=None, + shareylabels=None, + sharexlimits=None, + shareylimits=None, + sharexticklabels=None, + shareyticklabels=None, spanx=None, spany=None, span=None, @@ -855,6 +870,12 @@ def __init__( sharex=sharex, sharey=sharey, share=share, + sharexlabels=sharexlabels, + shareylabels=shareylabels, + sharexlimits=sharexlimits, + shareylimits=shareylimits, + sharexticklabels=sharexticklabels, + shareyticklabels=shareyticklabels, spanx=spanx, spany=spany, span=span, @@ -1003,7 +1024,23 @@ def _normalize_share(value): return int(value), False def _init_sharing( - self, *, sharex, sharey, share, spanx, spany, span, alignx, aligny, align + self, + *, + sharex, + sharey, + share, + sharexlabels, + shareylabels, + sharexlimits, + shareylimits, + sharexticklabels, + shareyticklabels, + spanx, + spany, + span, + alignx, + aligny, + align, ): """ Resolve share, span, and align settings. @@ -1014,16 +1051,32 @@ def _init_sharing( sharey, sharey_auto = self._normalize_share(sharey) self._sharex = int(sharex) self._sharey = int(sharey) + self._sharex_labels = bool(sharex > 0 if sharexlabels is None else sharexlabels) + self._sharey_labels = bool(sharey > 0 if shareylabels is None else shareylabels) + self._sharex_limits = bool(sharex > 1 if sharexlimits is None else sharexlimits) + self._sharey_limits = bool(sharey > 1 if shareylimits is None else shareylimits) + self._sharex_ticklabels = bool( + sharex > 2 if sharexticklabels is None else sharexticklabels + ) + self._sharey_ticklabels = bool( + sharey > 2 if shareyticklabels is None else shareyticklabels + ) self._sharex_auto = bool(sharex_auto) self._sharey_auto = bool(sharey_auto) self._share_incompat_warned = False # Span and align settings spanx = _not_none( - spanx, span, False if not sharex else None, rc["subplots.span"] + spanx, + span, + False if not self._sharex_labels else None, + rc["subplots.span"], ) spany = _not_none( - spany, span, False if not sharey else None, rc["subplots.span"] + spany, + span, + False if not self._sharey_labels else None, + rc["subplots.span"], ) if spanx and (alignx or align): warnings._warn_ultraplot('"alignx" has no effect when spanx=True.') @@ -1354,8 +1407,7 @@ def _autoscale_shared_limits(self, which: str) -> None: if which not in ("x", "y"): return - share_level = self._sharex if which == "x" else self._sharey - if share_level <= 1: + if not getattr(self, f"_share{which}_limits"): return get_auto = f"get_autoscale{which}_on" @@ -1729,9 +1781,10 @@ def _effective_share_level(self, axi, axis: str, sides: tuple[str, str]) -> int: adjacent panels. Fixes the original variable leak by checking any relevant side. """ level = getattr(self, f"_share{axis}") - # If figure-level sharing is disabled (0/False), don't promote due to panels - if not level or (isinstance(level, (int, float)) and level < 1): - return level + ticklabels = getattr(self, f"_share{axis}_ticklabels") + # If tick-label suppression is disabled, don't promote due to panels. + if not ticklabels: + return min(level, 2) # Panel group-level sharing if getattr(axi, f"_panel_share{axis}_group", None): @@ -2323,6 +2376,23 @@ def _unshare_axes(self): if isinstance(ax, paxes.GeoAxes) and hasattr(ax, "set_global"): ax.set_global() + def _axis_sharing_enabled(self, which): + """Return whether any sharing component is enabled for an axis.""" + return any( + getattr(self, f"_share{which}_{component}") + for component in ("labels", "limits", "ticklabels") + ) + + def _rebuild_axis_sharing(self, which): + """Rebuild axis relationships from the orthogonal sharing flags.""" + axes = list(self._iter_axes(hidden=False, children=False, panels=False)) + for ax in axes: + if hasattr(ax, "_unshare"): + ax._unshare(which=which) + for ax in axes: + if hasattr(ax, "_apply_auto_share"): + ax._apply_auto_share() + def _toggle_axis_sharing( self, *, @@ -2349,10 +2419,16 @@ def _toggle_axis_sharing( return axes = list(self._iter_axes(hidden=hidden, children=children, panels=panels)) + if which in ("x", "y"): + share, _ = self._normalize_share(share) if which == "x": self._sharex = share elif which == "y": self._sharey = share + if which in ("x", "y"): + setattr(self, f"_share{which}_labels", bool(share > 0)) + setattr(self, f"_share{which}_limits", bool(share > 1)) + setattr(self, f"_share{which}_ticklabels", bool(share > 2)) # Unshare first if needed if share == 0: @@ -3579,16 +3655,36 @@ def format( label_sequences[key] = value kwargs[key] = value if len(value) > 1: - if getattr(self, f"_share{axis}", 0): - # Rebuild at level 2: preserve the shared limits, scales, - # and tickers but remove label sharing (level 1). - self._toggle_axis_sharing(which=axis, share=0) - self._toggle_axis_sharing(which=axis, share=2) setattr(self, f"_share{axis}_labels", False) # Spanning labels are enabled by default with shared axes, but # would replace the per-axes labels with a single figure artist. setattr(self, f"_span{axis}", False) self._clear_share_label_groups(target=axis) + + # A sequence of limit pairs requests independent numeric axes in that + # direction. Preserve axis-title sharing, but detach limits/tickers and + # stop suppressing interior tick labels. + limit_sequences = {} + for key, axis in (("xlim", "x"), ("ylim", "y")): + value = kwargs.get(key) + if value is None or isinstance(value, str) or not np.iterable(value): + continue + value = tuple(value) + if not all( + np.iterable(item) and not isinstance(item, str) for item in value + ): + continue + if len(value) != len(axs): + raise ValueError( + f"Invalid {key} list length {len(value)} " + f"for {len(axs)} formatted axes." + ) + limit_sequences[key] = value + kwargs[key] = value + if len(value) > 1: + setattr(self, f"_share{axis}_limits", False) + setattr(self, f"_share{axis}_ticklabels", False) + self._rebuild_axis_sharing(axis) skip_axes = kwargs.pop("skip_axes", False) # internal keyword arg explicit_format_keys = set(kwargs) signature_axis_kwargs, generic_axis_kwargs = pop_axis_format_kwargs( @@ -3717,12 +3813,17 @@ def _axis_has_label_text(ax, axis): # sequence as one label object and converts it to its repr. for key, values in label_sequences.items(): supports_label = any( - key in cls_kw - for cls, cls_kw in kws.items() - if isinstance(ax, cls) + key in cls_kw for cls, cls_kw in kws.items() if isinstance(ax, cls) ) if supports_label: kw[key] = values[number - 1] + getattr(ax, f"{key[0]}axis").label.set_visible(True) + for key, values in limit_sequences.items(): + supports_limit = any( + key in cls_kw for cls, cls_kw in kws.items() if isinstance(ax, cls) + ) + if supports_limit: + kw[key] = values[number - 1] if kw.get("xlabel") is not None and self._has_share_label_groups("x"): if _axis_has_share_label_text(ax, "x") or _axis_has_label_text(ax, "x"): kw.pop("xlabel", None) diff --git a/ultraplot/gridspec.py b/ultraplot/gridspec.py index 89915645e..2656e1401 100644 --- a/ultraplot/gridspec.py +++ b/ultraplot/gridspec.py @@ -1073,21 +1073,23 @@ def _get_default_space(self, key, pad=None, share=None, title=True): space = self._labelspace + self._xticklabelspace + self._xtickspace elif key == "wspace_total": pad = _not_none(pad, self._innerpad) - share = _not_none(share, fig._sharey, 0) + share_labels = fig._sharey_labels if share is None else share >= 1 + share_ticklabels = fig._sharey_ticklabels if share is None else share >= 3 space = self._ytickspace - if share < 3: + if not share_ticklabels: space += self._yticklabelspace - if share < 1: + if not share_labels: space += self._labelspace elif key == "hspace_total": pad = _not_none(pad, self._innerpad) - share = _not_none(share, fig._sharex, 0) + share_labels = fig._sharex_labels if share is None else share >= 1 + share_ticklabels = fig._sharex_ticklabels if share is None else share >= 3 space = self._xtickspace if title: space += self._titlespace - if share < 3: + if not share_ticklabels: space += self._xticklabelspace - if share < 1: + if not share_labels: space += self._labelspace else: raise ValueError(f"Invalid space key {key!r}.") diff --git a/ultraplot/tests/test_format.py b/ultraplot/tests/test_format.py index 8fde1ec6c..64a85727e 100644 --- a/ultraplot/tests/test_format.py +++ b/ultraplot/tests/test_format.py @@ -368,24 +368,113 @@ def test_label_settings(): def test_format_distributes_axis_label_sequences_and_reduces_sharing(): """Per-axes labels retain shared limits but override label sharing.""" - fig, axs = uplt.subplots(ncols=2, share=True) + fig, axs = uplt.subplots(nrows=2, ncols=2, share=True) axs.format( - title=["First", "Second"], - xlabel=["First x", "Second x"], - ylabel=["First y", "Second y"], + title=["First", "Second", "Third", "Fourth"], + xlabel=["First x", "Second x", "Third x", "Fourth x"], + ylabel=["First y", "Second y", "Third y", "Fourth y"], ) fig.canvas.draw() - assert [ax.get_title() for ax in axs] == ["First", "Second"] - assert [ax.get_xlabel() for ax in axs] == ["First x", "Second x"] - assert [ax.get_ylabel() for ax in axs] == ["First y", "Second y"] - assert fig._sharex == fig._sharey == 2 + assert [ax.get_title() for ax in axs] == ["First", "Second", "Third", "Fourth"] + assert [ax.get_xlabel() for ax in axs] == [ + "First x", + "Second x", + "Third x", + "Fourth x", + ] + assert [ax.get_ylabel() for ax in axs] == [ + "First y", + "Second y", + "Third y", + "Fourth y", + ] + assert fig._sharex_limits and fig._sharey_limits + assert not fig._sharex_labels and not fig._sharey_labels assert all(ax.xaxis.label.get_visible() for ax in axs) assert all(ax.yaxis.label.get_visible() for ax in axs) axs[0].set_xlim(1, 2) + axs[0].set_ylim(3, 4) + assert axs[2].get_xlim() == (1, 2) + assert axs[1].get_ylim() == (3, 4) + + +def test_format_short_title_sequence_formats_prefix(): + """Short title sequences format the first axes and leave the rest alone.""" + fig, axs = uplt.subplots(ncols=4) + axs[2].format(title="Existing") + axs[3].format(title="Existing") + axs.format(title=["First", "Second"]) + + assert [ax.get_title() for ax in axs] == [ + "First", + "Second", + "Existing", + "Existing", + ] + + +def test_orthogonal_axis_sharing_controls(): + """Limits can be shared while axis-title and tick-label sharing stay off.""" + fig, axs = uplt.subplots( + nrows=2, + share=0, + sharexlimits=True, + sharexlabels=False, + sharexticklabels=False, + ) + axs.format(xlabel=["Upper x", "Lower x"]) + fig.canvas.draw() + + assert fig._sharex == 0 + assert fig._sharex_limits + assert not fig._sharex_labels + assert not fig._sharex_ticklabels + assert [ax.get_xlabel() for ax in axs] == ["Upper x", "Lower x"] + axs[0].set_xlim(1, 2) assert axs[1].get_xlim() == (1, 2) +@pytest.mark.parametrize( + ("key", "limits", "shared_attr", "unaffected_attr", "getter", "sibling"), + ( + ( + "xlim", + [(0, 1), (0, 2), (0, 3), (0, 4)], + "_sharex", + "_sharey", + "get_xlim", + 2, + ), + ( + "ylim", + [(0, 5), (0, 6), (0, 7), (0, 8)], + "_sharey", + "_sharex", + "get_ylim", + 1, + ), + ), +) +def test_format_distributes_limit_sequences_and_unshares( + key, limits, shared_attr, unaffected_attr, getter, sibling +): + """Per-axes limits disable sharing only in the corresponding direction.""" + fig, axs = uplt.subplots(nrows=2, ncols=2, share=True) + axs.format(**{key: limits}) + fig.canvas.draw() + + assert getattr(fig, shared_attr) == 3 + assert getattr(fig, unaffected_attr) == 3 + assert not getattr(fig, f"{shared_attr}_limits") + assert not getattr(fig, f"{shared_attr}_ticklabels") + assert getattr(fig, f"{shared_attr}_labels") + assert getattr(fig, f"{unaffected_attr}_limits") + assert [getattr(ax, getter)() for ax in axs] == limits + shared = getattr(axs[0], f"get_shared_{key[0]}_axes")() + assert not shared.joined(axs[0], axs[sibling]) + + def test_colormap_parsing(): """Test colormaps merging""" reds = uplt.colormaps.get_cmap("reds") From c128cd186ace7738e53297fb7d14a655b587ff24 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 4 Sep 2026 16:46:28 +1000 Subject: [PATCH 03/11] Support sparse axes formatting and local unsharing --- ultraplot/axes/_formatting.py | 53 ++++++++++ ultraplot/axes/cartesian.py | 26 ++++- ultraplot/figure.py | 169 +++++++++++++++++++++++++++++-- ultraplot/tests/test_figure.py | 6 +- ultraplot/tests/test_format.py | 80 +++++++++++++++ ultraplot/tests/test_subplots.py | 12 ++- 6 files changed, 329 insertions(+), 17 deletions(-) diff --git a/ultraplot/axes/_formatting.py b/ultraplot/axes/_formatting.py index f0489c1da..3187fce46 100644 --- a/ultraplot/axes/_formatting.py +++ b/ultraplot/axes/_formatting.py @@ -5,6 +5,59 @@ import inspect +_GENERIC_AXIS_LABEL_FORMAT_KEYS = { + "labelpad", + "labelcolor", + "labelsize", + "labelweight", +} + +AXIS_LABEL_FORMAT_KEYS = { + axis: { + f"{axis}label", + f"{axis}labelloc", + f"{axis}labelpad", + f"{axis}labelcolor", + f"{axis}labelsize", + f"{axis}labelweight", + f"{axis}label_kw", + } + | _GENERIC_AXIS_LABEL_FORMAT_KEYS + for axis in "xy" +} +AXIS_SHARED_STATE_FORMAT_KEYS = { + axis: { + f"{axis}lim", + f"{axis}min", + f"{axis}max", + f"{axis}scale", + f"{axis}reverse", + f"{axis}margin", + f"{axis}formatter", + f"{axis}ticklabels", + f"{axis}ticks", + f"{axis}locator", + f"{axis}minorticks", + f"{axis}minorlocator", + f"{axis}tickrange", + f"{axis}wraprange", + f"{axis}scale_kw", + f"{axis}locator_kw", + f"{axis}formatter_kw", + f"{axis}minorlocator_kw", + } + for axis in "xy" +} +AXIS_TICKLABEL_SHARING_FORMAT_KEYS = { + axis: { + f"{axis}loc", + f"{axis}spineloc", + f"{axis}tickloc", + f"{axis}ticklabelloc", + } + for axis in "xy" +} + _AXIS_STYLE_FIELD_TEMPLATES = { "color": ( "{axis}color", diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 094a299b9..392ed7b91 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -31,6 +31,9 @@ ) from ..utils import units from ._formatting import ( + AXIS_LABEL_FORMAT_KEYS, + AXIS_SHARED_STATE_FORMAT_KEYS, + AXIS_TICKLABEL_SHARING_FORMAT_KEYS, CARTESIAN_PARENT_FILTER_KEYS, axis_format_requires_layout, get_axis_style_fields, @@ -1221,7 +1224,6 @@ def _validate_loc(loc, opts, descrip): labelloc = _validate_loc(labelloc, label_opts, "axis label") axis.set_label_position(labelloc) if offsetloc is not None: - offsetloc = _not_none(offsetloc, options[0]) if hasattr(axis, "set_offset_position"): # y axis (and future x axis?) axis.set_offset_position(offsetloc) elif s == "x" and _version_mpl >= "3.3": # ugly x axis kludge @@ -1718,6 +1720,28 @@ def format( # such as a set_ylabel() call since the last draw. format() has always # flushed those into the layout, so keep doing that. pending_layout = bool(self.stale) + if self.figure is not None and not kwargs.get("skip_figure", False): + format_values = locals().copy() + format_values.update(kwargs) + format_keys = { + key + for key, value in format_values.items() + if value is not None + and key + in ( + AXIS_LABEL_FORMAT_KEYS["x"] + | AXIS_LABEL_FORMAT_KEYS["y"] + | AXIS_SHARED_STATE_FORMAT_KEYS["x"] + | AXIS_SHARED_STATE_FORMAT_KEYS["y"] + | AXIS_TICKLABEL_SHARING_FORMAT_KEYS["x"] + | AXIS_TICKLABEL_SHARING_FORMAT_KEYS["y"] + ) + } + self.figure._update_sharing_for_format_keys(format_keys) + if format_keys & AXIS_LABEL_FORMAT_KEYS["x"]: + self.xaxis.label.set_visible(True) + if format_keys & AXIS_LABEL_FORMAT_KEYS["y"]: + self.yaxis.label.set_visible(True) explicit_format_keys = set(kwargs.pop("_explicit_format_keys", ())) signature_axis_kwargs, generic_axis_kwargs = pop_axis_format_kwargs( kwargs, self._format_signatures[CartesianAxes] diff --git a/ultraplot/figure.py b/ultraplot/figure.py index e68e776af..7784d7358 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -7,6 +7,7 @@ import inspect import os from contextlib import ExitStack +from numbers import Integral try: from typing import Any, Iterable, List, Optional, Tuple, Union @@ -25,7 +26,14 @@ from typing_extensions import override from . import axes as paxes -from .axes._formatting import axis_format_requires_layout, pop_axis_format_kwargs +from .axes._formatting import ( + AXIS_LABEL_FORMAT_KEYS, + AXIS_SHARED_STATE_FORMAT_KEYS, + AXIS_TICKLABEL_SHARING_FORMAT_KEYS, + GENERIC_AXIS_FORMAT_KEYS, + axis_format_requires_layout, + pop_axis_format_kwargs, +) from . import constructor from . import gridspec as pgridspec from . import legend as plegend @@ -2383,6 +2391,68 @@ def _axis_sharing_enabled(self, which): for component in ("labels", "limits", "ticklabels") ) + def _update_axis_sharing_for_format( + self, which, *, labels=False, limits=False, ticklabels=False + ): + """Disable sharing components contradicted by local format values.""" + if labels: + setattr(self, f"_share{which}_labels", False) + setattr(self, f"_span{which}", False) + self._clear_share_label_groups(target=which) + if limits or ticklabels: + restore_ticklabels = getattr(self, f"_share{which}_ticklabels") + setattr(self, f"_share{which}_ticklabels", False) + if limits: + setattr(self, f"_share{which}_limits", False) + self._rebuild_axis_sharing(which) + if (limits or ticklabels) and restore_ticklabels: + self._restore_axis_ticklabels(which) + + def _update_sharing_for_format_keys(self, keys): + """Update sharing for axes-specific format keyword names.""" + keys = set(keys) + for which in "xy": + self._update_axis_sharing_for_format( + which, + labels=bool(keys & AXIS_LABEL_FORMAT_KEYS[which]), + limits=bool(keys & AXIS_SHARED_STATE_FORMAT_KEYS[which]), + ticklabels=bool(keys & AXIS_TICKLABEL_SHARING_FORMAT_KEYS[which]), + ) + + def _restore_axis_ticklabels(self, which): + """Restore labels hidden only by subplot tick-label sharing.""" + sides = ("bottom", "top") if which == "x" else ("left", "right") + labels = tuple(f"label{side}" for side in sides) + for ax in self._iter_axes(hidden=False, children=False, panels=False): + axis = getattr(ax, f"{which}axis", None) + if axis is None or not hasattr(axis, "get_tick_params"): + continue + params = axis.get_tick_params() + state_getter = getattr(ax, "_get_axis_style_state", None) + state = state_getter(which) if state_getter is not None else {} + loc = state.get("ticklabelloc") + if loc is None: + visibility = { + label: bool(params.get(label, False) or params.get(side, False)) + for side, label in zip(sides, labels) + } + else: + aliases = {side[0]: side for side in sides} + if isinstance(loc, str): + loc = loc.lower() + if loc in ("none", "neither"): + active = () + elif loc == "both": + active = sides + else: + active = (aliases.get(loc, loc),) + else: + active = tuple(loc) + visibility = { + label: side in active for side, label in zip(sides, labels) + } + axis.set_tick_params(which="both", **visibility) + def _rebuild_axis_sharing(self, which): """Rebuild axis relationships from the orthogonal sharing flags.""" axes = list(self._iter_axes(hidden=False, children=False, panels=False)) @@ -3596,6 +3666,10 @@ def format( ---------- axs : sequence of `~ultraplot.axes.Axes`, optional The axes to format. Default is the numbered subplots. + Axes-format arguments may be dictionaries mapping one-based positions + in this sequence to values, for example ``xlabel={1: 'First', + (2, 3): 'Others'}``. Dictionaries with string keys remain ordinary + style dictionaries. %(figure.format)s Important @@ -3635,6 +3709,75 @@ def format( if pending_layout is None: pending_layout = bool(self.stale) axs = list(axs or self._iter_subplots()) + + # Parse per-axes dictionaries using the same one-based selector syntax as + # subplot projection dictionaries: {1: value, (2, 3): value}. Dictionaries + # with ordinary string keys remain native format/style dictionaries. + axis_format_keys = { + key + for signature in paxes.Axes._format_signatures.values() + for key in signature.parameters + } + axis_format_keys.update(GENERIC_AXIS_FORMAT_KEYS) + + def _selector_numbers(selector): + if isinstance(selector, Integral) and not isinstance(selector, bool): + return (int(selector),) + if isinstance(selector, (tuple, list, range)) and all( + isinstance(item, Integral) and not isinstance(item, bool) + for item in selector + ): + return tuple(int(item) for item in selector) + return None + + axis_mappings = {} + for key, value in tuple(kwargs.items()): + if key not in axis_format_keys or not isinstance(value, dict) or not value: + continue + parsed = [ + (_selector_numbers(selector), item) for selector, item in value.items() + ] + if not any(numbers is not None for numbers, _ in parsed): + continue + if any(numbers is None for numbers, _ in parsed): + raise ValueError(f"Invalid mixed axes mapping for {key!r}: {value!r}.") + mapping = {} + for numbers, item in parsed: + for number in numbers: + if number not in range(1, len(axs) + 1): + raise ValueError( + f"Invalid axes number {number} for {key!r}; " + f"expected 1 through {len(axs)}." + ) + mapping[number] = item + axis_mappings[key] = mapping + kwargs[key] = next( + (item for item in mapping.values() if item is not None), None + ) + if axis_mappings: + self._update_sharing_for_format_keys(axis_mappings) + all_axes = set(self._iter_subplots()) + is_subset = bool(axs) and all_axes and set(axs) != all_axes + if is_subset: + local_keys = { + key + for keys in ( + *AXIS_SHARED_STATE_FORMAT_KEYS.values(), + *AXIS_TICKLABEL_SHARING_FORMAT_KEYS.values(), + ) + for key in keys + } + if len(axs) == 1: + local_keys.update( + key for keys in AXIS_LABEL_FORMAT_KEYS.values() for key in keys + ) + local_state_keys = { + key + for key, value in kwargs.items() + if value is not None and key in local_keys + } + self._update_sharing_for_format_keys(local_state_keys) + # Unlike titles, axis labels are normally forwarded verbatim to every # axes. Accept a sequence of strings here as a request for one label per # formatted axes. Distinct labels are incompatible with label sharing, so @@ -3655,11 +3798,7 @@ def format( label_sequences[key] = value kwargs[key] = value if len(value) > 1: - setattr(self, f"_share{axis}_labels", False) - # Spanning labels are enabled by default with shared axes, but - # would replace the per-axes labels with a single figure artist. - setattr(self, f"_span{axis}", False) - self._clear_share_label_groups(target=axis) + self._update_axis_sharing_for_format(axis, labels=True) # A sequence of limit pairs requests independent numeric axes in that # direction. Preserve axis-title sharing, but detach limits/tickers and @@ -3682,9 +3821,7 @@ def format( limit_sequences[key] = value kwargs[key] = value if len(value) > 1: - setattr(self, f"_share{axis}_limits", False) - setattr(self, f"_share{axis}_ticklabels", False) - self._rebuild_axis_sharing(axis) + self._update_axis_sharing_for_format(axis, limits=True) skip_axes = kwargs.pop("skip_axes", False) # internal keyword arg explicit_format_keys = set(kwargs) signature_axis_kwargs, generic_axis_kwargs = pop_axis_format_kwargs( @@ -3808,6 +3945,18 @@ def _axis_has_label_text(ax, axis): for key, value in kw.items() if isinstance(ax, cls) and not classes.add(cls) } + generic_kw = generic_axis_kwargs.copy() + for key, mapping in axis_mappings.items(): + is_generic = key in generic_axis_kwargs + supports_value = is_generic or any( + key in cls_kw for cls, cls_kw in kws.items() if isinstance(ax, cls) + ) + if number in mapping and supports_value: + (generic_kw if is_generic else kw)[key] = mapping[number] + if key in ("xlabel", "ylabel"): + getattr(ax, f"{key[0]}axis").label.set_visible(True) + else: + (generic_kw if is_generic else kw).pop(key, None) # Titles already support this convention in Axes._update_title(). # Labels need dispatch here because Matplotlib otherwise treats a # sequence as one label object and converts it to its repr. @@ -3840,7 +3989,7 @@ def _axis_has_label_text(ax, axis): **explicit_kw, **kw, **kwargs, - **generic_axis_kwargs, + **generic_kw, ) ax.number = store_old_number # Warn unused keyword argument(s). Shared params (those in multiple diff --git a/ultraplot/tests/test_figure.py b/ultraplot/tests/test_figure.py index 08257045d..ed92027be 100644 --- a/ultraplot/tests/test_figure.py +++ b/ultraplot/tests/test_figure.py @@ -461,13 +461,15 @@ def test_auto_share_splits_mixed_x_unit_domains_after_refresh(): assert _share_sibling_count(axs[1], "x") == 1 -def test_explicit_sharey_propagates_scale_changes(): +def test_explicit_sharey_local_scale_change_unshares(): 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" + assert axs[1].get_yscale() == "linear" + assert not fig._sharey_limits + assert not fig._sharey_ticklabels @pytest.mark.parametrize("va", ["bottom", "center", "top"]) diff --git a/ultraplot/tests/test_format.py b/ultraplot/tests/test_format.py index 64a85727e..d126a781d 100644 --- a/ultraplot/tests/test_format.py +++ b/ultraplot/tests/test_format.py @@ -435,6 +435,86 @@ def test_orthogonal_axis_sharing_controls(): assert axs[1].get_xlim() == (1, 2) +def test_format_axes_mapping_uses_one_based_selectors(): + """Axes mappings format selected axes and preserve native style dictionaries.""" + fig, axs = uplt.subplots(nrows=2, ncols=2, share=True) + axs.format( + title={1: "First", (3, 4): "Last"}, + xlabel={2: "Second x"}, + ylim={1: (0, 2), 4: (0, 4)}, + title_kw={"color": "red"}, + labelcolor={1: "blue"}, + ) + + assert [ax.get_title() for ax in axs] == ["First", "", "Last", "Last"] + assert [ax.get_xlabel() for ax in axs] == ["", "Second x", "", ""] + assert axs[0].get_ylim() == (0, 2) + assert axs[3].get_ylim() == (0, 4) + assert axs[0]._title_dict[axs[0]._title_loc].get_color() == "red" + assert axs[0].xaxis.label.get_color() == "blue" + assert axs[0].yaxis.label.get_color() == "blue" + assert axs[1].xaxis.label.get_color() != "blue" + assert not fig._sharex_labels + assert not fig._sharey_labels + assert not fig._sharey_limits + + +def test_indexed_format_updates_axis_sharing(): + """Formatting one axes updates sharing like a sparse figure-level mapping.""" + fig, axs = uplt.subplots(nrows=2, ncols=2, share=True) + axs[0].format(ylabel="Local y") + + assert not fig._sharey_labels + assert fig._sharey_limits + axs[0].set_ylim(1, 2) + assert axs[1].get_ylim() == (1, 2) + axs[0].format(ylim=(3, 4)) + assert not fig._sharey_limits + assert not fig._sharey_ticklabels + assert axs[0].get_ylim() == (3, 4) + assert axs[1].get_ylim() == (1, 2) + + +def test_indexed_limit_format_restores_interior_ticklabels(): + """Local limits restore tick labels previously hidden by global sharing.""" + fig, axs = uplt.subplots(nrows=2, ncols=2, share=True) + fig.canvas.draw() + assert not axs[0].xaxis.get_tick_params()["labelbottom"] + + axs[2].format(xlim=(-1, 0)) + fig.canvas.draw() + + assert axs[0].xaxis.get_tick_params()["labelbottom"] + assert axs[0].get_xlim() == (0, 1) + assert axs[2].get_xlim() == (-1, 0) + + +def test_indexed_tick_location_only_disables_ticklabel_sharing(): + """Local tick-label placement retains shared numeric limits.""" + fig, axs = uplt.subplots(nrows=2, ncols=2, share=True) + fig.canvas.draw() + + axs[0].format(xticklabelloc="bottom") + fig.canvas.draw() + + assert fig._sharex_limits + assert not fig._sharex_ticklabels + assert axs[0].xaxis.get_tick_params()["labelbottom"] + axs[0].set_xlim(2, 3) + assert axs[2].get_xlim() == (2, 3) + + +def test_singleton_grid_format_updates_axis_sharing(): + """A one-item grid slice has the same sharing semantics as direct indexing.""" + fig, axs = uplt.subplots(nrows=2, share=True) + axs[:1].format(xlim=(2, 3), ylabel="Local y") + + assert not fig._sharex_limits + assert not fig._sharey_labels + assert axs[0].get_xlim() == (2, 3) + assert axs[1].get_xlim() != (2, 3) + + @pytest.mark.parametrize( ("key", "limits", "shared_attr", "unaffected_attr", "getter", "sibling"), ( diff --git a/ultraplot/tests/test_subplots.py b/ultraplot/tests/test_subplots.py index 458e9b902..7e610e6e5 100644 --- a/ultraplot/tests/test_subplots.py +++ b/ultraplot/tests/test_subplots.py @@ -275,8 +275,10 @@ def test_subset_share_xlabels_override(): fig.canvas.draw() - assert not ax[0, 0].xaxis.get_label().get_visible() - assert not ax[0, 1].xaxis.get_label().get_visible() + assert ax[0, 0].xaxis.get_label().get_visible() + assert ax[0, 0].get_xlabel() == "Top-left X" + assert ax[0, 1].xaxis.get_label().get_visible() + assert ax[0, 1].get_xlabel() == "Top-right X" assert bottom[0].get_xlabel().strip() == "" assert bottom[1].get_xlabel().strip() == "" assert any(lab.get_text() == "Bottom-row X" for lab in fig._supxlabel_dict.values()) @@ -340,8 +342,10 @@ def test_subset_share_xlabels_implicit(): fig.canvas.draw() - assert not ax[0, 0].xaxis.get_label().get_visible() - assert not ax[0, 1].xaxis.get_label().get_visible() + assert ax[0, 0].xaxis.get_label().get_visible() + assert ax[0, 0].get_xlabel() == "Top-left X" + assert ax[0, 1].xaxis.get_label().get_visible() + assert ax[0, 1].get_xlabel() == "Top-right X" assert bottom[0].get_xlabel().strip() == "" assert bottom[1].get_xlabel().strip() == "" assert any(lab.get_text() == "Bottom-row X" for lab in fig._supxlabel_dict.values()) From 172d0fbe5f0e5012e0610eecf18739e00648943d Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 4 Sep 2026 16:54:50 +1000 Subject: [PATCH 04/11] Detach shared tickers when formatting local limits --- ultraplot/axes/base.py | 31 +++++----- ultraplot/figure.py | 16 +++++ ultraplot/tests/test_format.py | 110 ++++++++++++++++++++++++++++++++- 3 files changed, 140 insertions(+), 17 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index a0a4a8262..b3a14c539 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -2565,21 +2565,22 @@ def _unshare(self, *, which: str): self._shared_axes[which].remove(sibling) if which in "xy": setattr(sibling, f"_share{which}", None) - this_ax = getattr(self, f"{which}axis") - sib_ax = getattr(sibling, f"{which}axis") - # Reset formatters by creating new Ticker objects. - # A deepcopy can trigger redraws. - new_major = maxis.Ticker() - if this_ax.major: - new_major.locator = copy.copy(this_ax.major.locator) - new_major.formatter = copy.copy(this_ax.major.formatter) - this_ax.major = new_major - - new_minor = maxis.Ticker() - if this_ax.minor: - new_minor.locator = copy.copy(this_ax.minor.locator) - new_minor.formatter = copy.copy(this_ax.minor.formatter) - this_ax.minor = new_minor + if which in "xy" and len(siblings) > 1: + # Matplotlib shares the actual Ticker objects, not just their state. + # Give every detached axes independent copies so that subsequent + # limits select tick locations from that axes' own view interval. + # A deepcopy can trigger redraws, so shallow-copy each component. + for sibling in siblings: + axis = getattr(sibling, f"{which}axis") + for name in ("major", "minor"): + ticker = getattr(axis, name) + new_ticker = maxis.Ticker() + if ticker: + new_ticker.locator = copy.copy(ticker.locator) + new_ticker.formatter = copy.copy(ticker.formatter) + new_ticker.locator.set_axis(axis) + new_ticker.formatter.set_axis(axis) + setattr(axis, name, new_ticker) def _sharex_setup(self, sharex, **kwargs): """ diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 7784d7358..0c63a66e8 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1069,6 +1069,8 @@ def _init_sharing( self._sharey_ticklabels = bool( sharey > 2 if shareyticklabels is None else shareyticklabels ) + self._sync_axis_sharing_level("x") + self._sync_axis_sharing_level("y") self._sharex_auto = bool(sharex_auto) self._sharey_auto = bool(sharey_auto) self._share_incompat_warned = False @@ -2391,6 +2393,17 @@ def _axis_sharing_enabled(self, which): for component in ("labels", "limits", "ticklabels") ) + def _sync_axis_sharing_level(self, which): + """Synchronize the legacy level with the active sharing components.""" + previous = getattr(self, f"_share{which}") + labels = getattr(self, f"_share{which}_labels") + limits = getattr(self, f"_share{which}_limits") + ticklabels = getattr(self, f"_share{which}_ticklabels") + level = max(1 if labels else 0, 2 if limits else 0, 3 if ticklabels else 0) + if previous == 4 and limits: + level = 4 + setattr(self, f"_share{which}", level) + def _update_axis_sharing_for_format( self, which, *, labels=False, limits=False, ticklabels=False ): @@ -2404,6 +2417,9 @@ def _update_axis_sharing_for_format( setattr(self, f"_share{which}_ticklabels", False) if limits: setattr(self, f"_share{which}_limits", False) + if labels or limits or ticklabels: + self._sync_axis_sharing_level(which) + if limits: self._rebuild_axis_sharing(which) if (limits or ticklabels) and restore_ticklabels: self._restore_axis_ticklabels(which) diff --git a/ultraplot/tests/test_format.py b/ultraplot/tests/test_format.py index d126a781d..03cc0d378 100644 --- a/ultraplot/tests/test_format.py +++ b/ultraplot/tests/test_format.py @@ -426,7 +426,7 @@ def test_orthogonal_axis_sharing_controls(): axs.format(xlabel=["Upper x", "Lower x"]) fig.canvas.draw() - assert fig._sharex == 0 + assert fig._sharex == 2 assert fig._sharex_limits assert not fig._sharex_labels assert not fig._sharex_ticklabels @@ -457,6 +457,7 @@ def test_format_axes_mapping_uses_one_based_selectors(): assert not fig._sharex_labels assert not fig._sharey_labels assert not fig._sharey_limits + assert fig._sharey == 0 def test_indexed_format_updates_axis_sharing(): @@ -471,6 +472,7 @@ def test_indexed_format_updates_axis_sharing(): axs[0].format(ylim=(3, 4)) assert not fig._sharey_limits assert not fig._sharey_ticklabels + assert fig._sharey == 0 assert axs[0].get_ylim() == (3, 4) assert axs[1].get_ylim() == (1, 2) @@ -485,8 +487,111 @@ def test_indexed_limit_format_restores_interior_ticklabels(): fig.canvas.draw() assert axs[0].xaxis.get_tick_params()["labelbottom"] + assert fig._sharex == 1 assert axs[0].get_xlim() == (0, 1) assert axs[2].get_xlim() == (-1, 0) + assert 1 in axs[0].get_xticks() + assert "1" in [label.get_text() for label in axs[0].get_xticklabels()] + + +def test_indexed_ylim_restores_interior_ticklabels(): + """The ticker-detachment behavior is symmetric for y axes.""" + fig, axs = uplt.subplots(nrows=2, ncols=2, share=True) + fig.canvas.draw() + assert not axs[1].yaxis.get_tick_params()["labelleft"] + + axs[1].format(ylim=(-1, 0)) + fig.canvas.draw() + + assert fig._sharey == 1 + assert axs[1].yaxis.get_tick_params()["labelleft"] + assert axs[0].get_ylim() == (0, 1) + assert axs[1].get_ylim() == (-1, 0) + assert 1 in axs[0].get_yticks() + assert "1" in [label.get_text() for label in axs[0].get_yticklabels()] + + +@pytest.mark.parametrize("which", ("x", "y")) +def test_unshared_tickers_are_independent_and_bound_to_owner(which): + """Detached tickers must not retain another axes as their data source.""" + fig, axs = uplt.subplots(nrows=2, ncols=2, share=True) + axs[2 if which == "x" else 1].format(**{f"{which}lim": (-1, 0)}) + + tickers = [getattr(ax, f"{which}axis").major for ax in axs] + assert len({id(ticker) for ticker in tickers}) == len(axs) + for ax, ticker in zip(axs, tickers): + axis = getattr(ax, f"{which}axis") + assert ticker.locator.axis is axis + assert ticker.formatter.axis is axis + + +def test_local_limits_remain_independent_after_repeated_updates(): + """Later limit updates cannot leak after an indexed format call detaches axes.""" + fig, axs = uplt.subplots(nrows=2, share=True) + axs[1].format(xlim=(-1, 0)) + axs[0].set_xlim(2, 3) + assert axs[0].get_xlim() == (2, 3) + assert axs[1].get_xlim() == (-1, 0) + + axs[1].set_xlim(-3, -2) + assert axs[0].get_xlim() == (2, 3) + assert axs[1].get_xlim() == (-3, -2) + + +def test_sparse_limit_mapping_detaches_tickers_for_every_axes(): + """Sparse dict formatting gives selected and unselected axes valid locators.""" + fig, axs = uplt.subplots(nrows=2, ncols=2, share=True) + axs.format(xlim={3: (-1, 0)}) + fig.canvas.draw() + + assert fig._sharex == 1 + assert [ax.get_xlim() for ax in axs] == [(0, 1), (0, 1), (-1, 0), (0, 1)] + assert 1 in axs[0].get_xticks() + assert -1 in axs[2].get_xticks() + assert all(ax.xaxis.major.locator.axis is ax.xaxis for ax in axs) + + +def test_limit_sequence_gives_every_axes_an_independent_ticker(): + """Each item in a limit sequence gets an independently evaluated ticker.""" + limits = [(0, 1), (1, 2), (2, 3), (3, 4)] + fig, axs = uplt.subplots(nrows=2, ncols=2, share=True) + axs.format(xlim=limits) + fig.canvas.draw() + + for ax, lim in zip(axs, limits): + assert ax.get_xlim() == lim + assert np.isclose(ax.get_xticks(), lim[0]).any() + assert np.isclose(ax.get_xticks(), lim[1]).any() + assert ax.xaxis.major.locator.axis is ax.xaxis + + +def test_sharing_level_tracks_each_indexed_component_transition(): + """The numeric level follows the highest active orthogonal component.""" + fig, axs = uplt.subplots(nrows=2, share=True) + axs[0].format(xlabel="Local x") + assert fig._sharex == 3 + assert not fig._sharex_labels + + axs[0].format(xticklabelloc="bottom") + assert fig._sharex == 2 + assert not fig._sharex_ticklabels + + axs[0].format(xlim=(-1, 0)) + assert fig._sharex == 0 + assert not fig._sharex_limits + + +def test_explicit_ticklabel_location_survives_later_limit_detach(): + """Restoring shared tick labels must preserve an explicit local opt-out.""" + fig, axs = uplt.subplots(nrows=2, ncols=2, share=True) + fig.canvas.draw() + axs[1].format(xticklabelloc="neither") + axs[2].format(xlim=(-1, 0)) + fig.canvas.draw() + + assert axs[0].xaxis.get_tick_params()["labelbottom"] + assert not axs[1].xaxis.get_tick_params()["labelbottom"] + assert not axs[1].xaxis.get_tick_params()["labeltop"] def test_indexed_tick_location_only_disables_ticklabel_sharing(): @@ -499,6 +604,7 @@ def test_indexed_tick_location_only_disables_ticklabel_sharing(): assert fig._sharex_limits assert not fig._sharex_ticklabels + assert fig._sharex == 2 assert axs[0].xaxis.get_tick_params()["labelbottom"] axs[0].set_xlim(2, 3) assert axs[2].get_xlim() == (2, 3) @@ -544,7 +650,7 @@ def test_format_distributes_limit_sequences_and_unshares( axs.format(**{key: limits}) fig.canvas.draw() - assert getattr(fig, shared_attr) == 3 + assert getattr(fig, shared_attr) == 1 assert getattr(fig, unaffected_attr) == 3 assert not getattr(fig, f"{shared_attr}_limits") assert not getattr(fig, f"{shared_attr}_ticklabels") From 36f5f021ff46bf21acf2bffc8db098c2ab0e743b Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 4 Sep 2026 17:31:34 +1000 Subject: [PATCH 05/11] Constrain local unsharing and expose sharing controls --- ultraplot/axes/_formatting.py | 47 ++++++++++ ultraplot/axes/base.py | 8 ++ ultraplot/axes/cartesian.py | 19 +++- ultraplot/axes/geo.py | 44 +++++++++ ultraplot/axes/plot.py | 9 +- ultraplot/axes/plot_types/ribbon.py | 7 +- ultraplot/figure.py | 134 +++++++++++++++++++++++++++- ultraplot/gridspec.py | 14 +++ ultraplot/tests/test_axes.py | 6 +- ultraplot/tests/test_figure.py | 107 ++++++++++++++++++++++ ultraplot/tests/test_format.py | 16 ++-- ultraplot/tests/test_geographic.py | 13 +-- 12 files changed, 399 insertions(+), 25 deletions(-) diff --git a/ultraplot/axes/_formatting.py b/ultraplot/axes/_formatting.py index 3187fce46..46bba510e 100644 --- a/ultraplot/axes/_formatting.py +++ b/ultraplot/axes/_formatting.py @@ -58,6 +58,53 @@ for axis in "xy" } +# Geographic axes use longitude as their x-like coordinate and latitude as +# their y-like coordinate. Keep these aliases in the shared classifier so +# sparse Figure.format() calls and direct GeoAxes.format() calls make the same +# sharing decision as their Cartesian counterparts. +AXIS_SHARED_STATE_FORMAT_KEYS["x"].update( + { + "extent", + "lonlim", + "lonlocator", + "lonlines", + "lonminorlocator", + "lonminorlines", + "lonformatter", + "lonlocator_kw", + "lonlines_kw", + "lonminorlocator_kw", + "lonminorlines_kw", + "lonformatter_kw", + "dms", + } +) +AXIS_SHARED_STATE_FORMAT_KEYS["y"].update( + { + "extent", + "latlim", + "boundinglat", + "latmax", + "latlocator", + "latlines", + "latminorlocator", + "latminorlines", + "latformatter", + "latlocator_kw", + "latlines_kw", + "latminorlocator_kw", + "latminorlines_kw", + "latformatter_kw", + "dms", + } +) +AXIS_TICKLABEL_SHARING_FORMAT_KEYS["x"].update( + {"labels", "lonlabels", "loninline", "inlinelabels"} +) +AXIS_TICKLABEL_SHARING_FORMAT_KEYS["y"].update( + {"labels", "latlabels", "latinline", "inlinelabels"} +) + _AXIS_STYLE_FIELD_TEMPLATES = { "color": ( "{axis}color", diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index b3a14c539..db1635a6f 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -3333,6 +3333,14 @@ def format( change them for specific axes. But many :ref:`other configuration settings ` can be passed to ``format`` too. + Notes + ----- + Formatting an indexed axes with parameters that require independent axis + labels, limits, scales, locators, formatters, or tick locations may reduce + the corresponding sharing component for the entire figure. Inspect or + restore sharing with `~ultraplot.figure.Figure.get_axis_sharing` and + `~ultraplot.figure.Figure.set_axis_sharing`. + Other parameters ---------------- %(figure.format)s diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 392ed7b91..455de0ae7 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -721,7 +721,10 @@ def _add_alt(self, sx, **kwargs): self._twinned_axes.join(self, ax) # Format parent and child axes - self.format(**{f"{sx}loc": OPPOSITE_SIDE.get(kwargs[f"{sx}loc"], None)}) + self.format( + _skip_share_update=True, + **{f"{sx}loc": OPPOSITE_SIDE.get(kwargs[f"{sx}loc"], None)}, + ) setattr(ax, f"_alt{sx}_parent", self) getattr(ax, f"{sy}axis").set_visible(False) getattr(ax, "patch").set_visible(False) @@ -1720,7 +1723,18 @@ def format( # such as a set_ylabel() call since the last draw. format() has always # flushed those into the layout, so keep doing that. pending_layout = bool(self.stale) - if self.figure is not None and not kwargs.get("skip_figure", False): + skip_share_update = kwargs.pop("_skip_share_update", False) + main_subplots = ( + tuple(self.figure._iter_subplots()) if self.figure is not None else () + ) + can_reduce_sharing = len(main_subplots) > 1 and any( + self is ax for ax in main_subplots + ) + if ( + can_reduce_sharing + and not skip_share_update + and not kwargs.get("skip_figure", False) + ): format_values = locals().copy() format_values.update(kwargs) format_keys = { @@ -1743,6 +1757,7 @@ def format( if format_keys & AXIS_LABEL_FORMAT_KEYS["y"]: self.yaxis.label.set_visible(True) explicit_format_keys = set(kwargs.pop("_explicit_format_keys", ())) + explicit_format_keys.discard("_skip_share_update") signature_axis_kwargs, generic_axis_kwargs = pop_axis_format_kwargs( kwargs, self._format_signatures[CartesianAxes] ) diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index 401d9209f..41530eafd 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -48,6 +48,11 @@ ) from ..utils import units from . import plot, shared +from ._formatting import ( + AXIS_LABEL_FORMAT_KEYS, + AXIS_SHARED_STATE_FORMAT_KEYS, + AXIS_TICKLABEL_SHARING_FORMAT_KEYS, +) try: import cartopy.crs as ccrs @@ -3027,6 +3032,45 @@ def format( ultraplot.axes.Axes.format ultraplot.config.Configurator.context """ + skip_share_update = kwargs.pop("_skip_share_update", False) + main_subplots = ( + tuple(self.figure._iter_subplots()) if self.figure is not None else () + ) + can_reduce_sharing = len(main_subplots) > 1 and any( + self is ax for ax in main_subplots + ) + if ( + can_reduce_sharing + and not skip_share_update + and not kwargs.get("skip_figure", False) + ): + format_values = locals().copy() + format_values.update(kwargs) + format_keys = { + key + for key, value in format_values.items() + if value is not None + and key + in ( + AXIS_LABEL_FORMAT_KEYS["x"] + | AXIS_LABEL_FORMAT_KEYS["y"] + | AXIS_SHARED_STATE_FORMAT_KEYS["x"] + | AXIS_SHARED_STATE_FORMAT_KEYS["y"] + | AXIS_TICKLABEL_SHARING_FORMAT_KEYS["x"] + | AXIS_TICKLABEL_SHARING_FORMAT_KEYS["y"] + ) + } + # These generic names style geographic gridline labels rather than + # Cartesian x/y axis-title text, so they do not contradict shared + # xlabel or ylabel state. + format_keys.difference_update( + {"labelpad", "labelcolor", "labelsize", "labelweight"} + ) + self.figure._update_sharing_for_format_keys(format_keys) + if format_keys & AXIS_LABEL_FORMAT_KEYS["x"]: + self.xaxis.label.set_visible(True) + if format_keys & AXIS_LABEL_FORMAT_KEYS["y"]: + self.yaxis.label.set_visible(True) self._format_init_basemap_boundary() lonlabels, latlabels = self._format_normalize_label_inputs( labels=labels, diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index acb4a7a63..399398b9b 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -3994,7 +3994,7 @@ def _parse_1d_format( # pandas DataFrame specifically is passed to hist, boxplot, or violinplot, rows # of data assumed! Converting to ndarray necessary. if kw_format: - self.format(**kw_format) + self.format(_skip_share_update=True, **kw_format) ys = tuple(map(inputs._to_numpy_array, ys)) if x is not None: # pie() and hist() x = inputs._to_numpy_array(x) @@ -4102,7 +4102,7 @@ def _parse_2d_format( # Apply formatting if kw_format: - self.format(**kw_format) + self.format(_skip_share_update=True, **kw_format) # Apply title for legend or colorbar if autoguide and autoformat: @@ -5347,6 +5347,7 @@ def loglog(self, *args, **kwargs): objs = self._call_native("loglog", *args, **kwargs) if rc["formatter.log"]: self.format( + _skip_share_update=True, xformatter="log", yformatter="log", ) @@ -5361,6 +5362,7 @@ def semilogy(self, *args, **kwargs): objs = self._call_native("semilogy", *args, **kwargs) if rc["formatter.log"]: self.format( + _skip_share_update=True, yformatter="log", ) return objs @@ -5373,6 +5375,7 @@ def semilogx(self, *args, **kwargs): objs = self._call_native("semilogx", *args, **kwargs) if rc["formatter.log"]: self.format( + _skip_share_update=True, xformatter="log", ) return objs @@ -7445,7 +7448,7 @@ def heatmap(self, *args, aspect=None, **kwargs): kw["xtickminor"] = False if self.yaxis.isDefault_minloc: kw["ytickminor"] = False - self.format(**kw) + self.format(_skip_share_update=True, **kw) return obj @inputs._preprocess_or_redirect("x", "y", "u", "v", ("c", "color", "colors")) diff --git a/ultraplot/axes/plot_types/ribbon.py b/ultraplot/axes/plot_types/ribbon.py index 90713806f..dd5135499 100644 --- a/ultraplot/axes/plot_types/ribbon.py +++ b/ultraplot/axes/plot_types/ribbon.py @@ -329,7 +329,12 @@ def ribbon_diagram( ) period_text.append(text) - ax.format(xlim=(0, 1), ylim=(0, 1), grid=False) + ax.format( + xlim=(0, 1), + ylim=(0, 1), + grid=False, + _skip_share_update=True, + ) ax.axis("off") return { "node_patches": node_patches, diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 0c63a66e8..1266eb9ed 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -2393,6 +2393,128 @@ def _axis_sharing_enabled(self, which): for component in ("labels", "limits", "ticklabels") ) + @staticmethod + def _normalize_axis_directions(axis): + """Normalize a public x/y axis selector.""" + if axis in (None, "both", "xy", "yx"): + return ("x", "y") + if axis in ("x", "y"): + return (axis,) + raise ValueError( + f"Invalid axis {axis!r}. Expected 'x', 'y', 'both', 'xy', or 'yx'." + ) + + def get_axis_sharing(self, axis=None): + """ + Return the active axis-sharing state. + + Parameters + ---------- + axis : {'x', 'y', 'both', 'xy', 'yx'}, optional + Axis direction. Passing ``None`` or ``'both'`` returns states for + both directions. + + Returns + ------- + dict + For one direction, a dictionary containing ``level``, ``labels``, + ``limits``, ``ticklabels``, and ``auto``. For both directions, a + dictionary mapping ``'x'`` and ``'y'`` to those state dictionaries. + + Notes + ----- + ``labels`` refers to axis-title text (``xlabel`` and ``ylabel``), while + ``ticklabels`` controls suppression of tick labels on interior axes. + The component booleans are authoritative. ``level`` is the highest active + legacy sharing level and may not fully describe non-cumulative overrides. + + See also + -------- + Figure.set_axis_sharing + """ + + def _get(which): + return { + "level": getattr(self, f"_share{which}"), + "labels": getattr(self, f"_share{which}_labels"), + "limits": getattr(self, f"_share{which}_limits"), + "ticklabels": getattr(self, f"_share{which}_ticklabels"), + "auto": getattr(self, f"_share{which}_auto"), + } + + directions = self._normalize_axis_directions(axis) + states = {which: _get(which) for which in directions} + return states[directions[0]] if len(directions) == 1 else states + + def set_axis_sharing( + self, + axis="both", + *, + level=None, + labels=None, + limits=None, + ticklabels=None, + ): + """ + Set or restore axis-sharing components after figure creation. + + Parameters + ---------- + axis : {'x', 'y', 'both', 'xy', 'yx'}, default: 'both' + Axis direction to update. + level : {0, False, 1, 'labels', 'labs', 2, 'limits', 'lims', 3, True, 4, 'all', 'auto'}, optional + Apply a standard sharing preset. Individual component arguments + override the corresponding part of the preset. + labels : bool, optional + Share axis-title text (``xlabel`` or ``ylabel``). + limits : bool, optional + Share limits, scales, tick locations, and formatters. + ticklabels : bool, optional + Suppress tick labels on interior axes. + + Notes + ----- + Local or sparse ``format`` calls can reduce sharing in the affected + direction. Use this method to deliberately restore it, for example + ``fig.set_axis_sharing('x', level=3)``. + + See also + -------- + Figure.get_axis_sharing + Figure.format + """ + if all(value is None for value in (level, labels, limits, ticklabels)): + return + directions = self._normalize_axis_directions(axis) + normalized = auto = None + if level is not None: + normalized, auto = self._normalize_share(level) + for which in directions: + old_ticklabels = getattr(self, f"_share{which}_ticklabels") + if normalized is not None: + setattr(self, f"_share{which}", normalized) + setattr(self, f"_share{which}_labels", normalized > 0) + setattr(self, f"_share{which}_limits", normalized > 1) + setattr(self, f"_share{which}_ticklabels", normalized > 2) + setattr(self, f"_share{which}_auto", auto) + else: + setattr(self, f"_share{which}_auto", False) + for component, value in ( + ("labels", labels), + ("limits", limits), + ("ticklabels", ticklabels), + ): + if value is not None: + setattr(self, f"_share{which}_{component}", bool(value)) + if not getattr(self, f"_share{which}_labels"): + setattr(self, f"_span{which}", False) + self._clear_share_label_groups(target=which) + self._sync_axis_sharing_level(which) + self._rebuild_axis_sharing(which) + if old_ticklabels and not getattr(self, f"_share{which}_ticklabels"): + self._restore_axis_ticklabels(which) + self._invalidate_layout() + def _sync_axis_sharing_level(self, which): """Synchronize the legacy level with the active sharing components.""" previous = getattr(self, f"_share{which}") @@ -3698,6 +3820,14 @@ def format( change them for specific figures. But many :ref:`other configuration settings ` can be passed to ``format`` too. + Notes + ----- + When formatting fewer than all axes, parameters that require independent + axis labels, limits, scales, locators, formatters, or tick locations may + reduce the corresponding sharing component for the entire figure. This + treats the local values as intentional. Inspect or restore sharing with + `Figure.get_axis_sharing` and `Figure.set_axis_sharing`. + Other parameters ---------------- %(axes.format)s @@ -3770,9 +3900,9 @@ def _selector_numbers(selector): kwargs[key] = next( (item for item in mapping.values() if item is not None), None ) - if axis_mappings: - self._update_sharing_for_format_keys(axis_mappings) all_axes = set(self._iter_subplots()) + if axis_mappings and len(all_axes) > 1: + self._update_sharing_for_format_keys(axis_mappings) is_subset = bool(axs) and all_axes and set(axs) != all_axes if is_subset: local_keys = { diff --git a/ultraplot/gridspec.py b/ultraplot/gridspec.py index 2656e1401..4cb214004 100644 --- a/ultraplot/gridspec.py +++ b/ultraplot/gridspec.py @@ -2064,6 +2064,20 @@ def format(self, **kwargs): **kwargs Passed to the projection-specific ``format`` command for each axes. Valid only if every axes in the grid belongs to the same class. + Axes-format arguments may be dictionaries mapping one-based positions + in this grid to values, for example ``title={1: 'First', (2, 3): + 'Others'}``. Dictionaries with string keys remain ordinary style + dictionaries. + + Notes + ----- + Formatting a subset with parameters that require independent axis labels, + limits, scales, locators, formatters, or tick locations may reduce the + corresponding sharing component for the entire figure. A scalar label on a + multi-axes subset can instead form a shared label group for that subset. + Inspect or restore sharing with + `~ultraplot.figure.Figure.get_axis_sharing` and + `~ultraplot.figure.Figure.set_axis_sharing`. Other parameters ---------------- diff --git a/ultraplot/tests/test_axes.py b/ultraplot/tests/test_axes.py index 590ae9386..83027c727 100644 --- a/ultraplot/tests/test_axes.py +++ b/ultraplot/tests/test_axes.py @@ -658,9 +658,9 @@ def test_subset_format(): axs[1:].format(title=["c", "d", "e"]) # allowed but does not use e assert axs[-1].get_title() == "d" assert axs[0].get_title() == "" - # Shorter than number of axs - with pytest.raises(ValueError): - axs.format(title=["a"]) + # Short title sequences update the first axes and preserve the remainder. + axs.format(title=["a"]) + assert [ax.get_title() for ax in axs] == ["a", "c", "d"] def test_unsharing(): diff --git a/ultraplot/tests/test_figure.py b/ultraplot/tests/test_figure.py index ed92027be..f56b65c4c 100644 --- a/ultraplot/tests/test_figure.py +++ b/ultraplot/tests/test_figure.py @@ -59,6 +59,113 @@ def test_unsharing_on_creation(): assert axi in siblings +@pytest.mark.parametrize("kind", ("inset", "panel", "alternate")) +def test_auxiliary_axes_format_does_not_change_figure_sharing(kind): + """Only numbered main subplots may reduce figure-wide sharing.""" + fig, axs = uplt.subplots(nrows=2, share=True) + ax = axs[0] + if kind == "inset": + other = ax.inset_axes((0.2, 0.2, 0.4, 0.4)) + elif kind == "panel": + other = ax.panel_axes("right") + else: + other = ax.altx() + before = fig.get_axis_sharing() + + other.format(xlim=(-1, 0), ylim=(-2, 0), xlabel="local", ylabel="local") + + assert fig.get_axis_sharing() == before + + +def test_single_subplot_format_does_not_change_nominal_sharing(): + """There is no sharing contradiction when a figure has only one subplot.""" + fig, axs = uplt.subplots(share=True) + before = fig.get_axis_sharing() + + axs[0].format(xlim=(-1, 0), ylim=(-2, 0), xlabel="x", ylabel="y") + + assert fig.get_axis_sharing() == before + + +def test_public_axis_sharing_state_and_restore(): + """Sharing reduced by local formatting can be inspected and restored.""" + fig, axs = uplt.subplots(nrows=2, share=True) + assert fig.get_axis_sharing("x") == { + "level": 3, + "labels": True, + "limits": True, + "ticklabels": True, + "auto": False, + } + assert set(fig.get_axis_sharing()) == {"x", "y"} + + axs[1].format(xlim=(-1, 0)) + assert fig.get_axis_sharing("x") == { + "level": 1, + "labels": True, + "limits": False, + "ticklabels": False, + "auto": False, + } + + fig.set_axis_sharing("x", level=3) + assert fig.get_axis_sharing("x") == { + "level": 3, + "labels": True, + "limits": True, + "ticklabels": True, + "auto": False, + } + assert axs[0].get_shared_x_axes().joined(axs[0], axs[1]) + assert axs[0].get_xlim() == axs[1].get_xlim() == (-1, 0) + fig.canvas.draw() + assert not axs[0]._is_ticklabel_on("labelbottom") + + +def test_public_axis_sharing_component_overrides(): + """Component setters support non-cumulative sharing combinations.""" + fig, axs = uplt.subplots(nrows=2, share=0) + fig.set_axis_sharing("x", limits=True) + assert fig.get_axis_sharing("x") == { + "level": 2, + "labels": False, + "limits": True, + "ticklabels": False, + "auto": False, + } + axs[0].set_xlim(2, 3) + assert axs[1].get_xlim() == (2, 3) + + fig.set_axis_sharing("both", level="labels") + for state in fig.get_axis_sharing().values(): + assert state == { + "level": 1, + "labels": True, + "limits": False, + "ticklabels": False, + "auto": False, + } + + +def test_public_axis_sharing_rejects_invalid_axis(): + """Public sharing methods reject ambiguous direction selectors.""" + fig, _ = uplt.subplots() + with pytest.raises(ValueError, match="Invalid axis"): + fig.get_axis_sharing("z") + with pytest.raises(ValueError, match="Invalid axis"): + fig.set_axis_sharing("z", level=1) + + +def test_internal_plot_formatting_does_not_reduce_sharing(): + """Automatic locator and label formatting from plot commands stays shared.""" + fig, axs = uplt.subplots(nrows=2, share=True) + before = fig.get_axis_sharing() + + axs[0].heatmap(np.arange(4).reshape(2, 2)) + + assert fig.get_axis_sharing() == before + + def test_unsharing_different_rectilinear(): """ Even if the projections are rectilinear, the coordinates systems may be different, as such we only allow sharing for the same kind of projections. diff --git a/ultraplot/tests/test_format.py b/ultraplot/tests/test_format.py index 03cc0d378..57589c26a 100644 --- a/ultraplot/tests/test_format.py +++ b/ultraplot/tests/test_format.py @@ -481,12 +481,12 @@ def test_indexed_limit_format_restores_interior_ticklabels(): """Local limits restore tick labels previously hidden by global sharing.""" fig, axs = uplt.subplots(nrows=2, ncols=2, share=True) fig.canvas.draw() - assert not axs[0].xaxis.get_tick_params()["labelbottom"] + assert not axs[0]._is_ticklabel_on("labelbottom") axs[2].format(xlim=(-1, 0)) fig.canvas.draw() - assert axs[0].xaxis.get_tick_params()["labelbottom"] + assert axs[0]._is_ticklabel_on("labelbottom") assert fig._sharex == 1 assert axs[0].get_xlim() == (0, 1) assert axs[2].get_xlim() == (-1, 0) @@ -498,13 +498,13 @@ def test_indexed_ylim_restores_interior_ticklabels(): """The ticker-detachment behavior is symmetric for y axes.""" fig, axs = uplt.subplots(nrows=2, ncols=2, share=True) fig.canvas.draw() - assert not axs[1].yaxis.get_tick_params()["labelleft"] + assert not axs[1]._is_ticklabel_on("labelleft") axs[1].format(ylim=(-1, 0)) fig.canvas.draw() assert fig._sharey == 1 - assert axs[1].yaxis.get_tick_params()["labelleft"] + assert axs[1]._is_ticklabel_on("labelleft") assert axs[0].get_ylim() == (0, 1) assert axs[1].get_ylim() == (-1, 0) assert 1 in axs[0].get_yticks() @@ -589,9 +589,9 @@ def test_explicit_ticklabel_location_survives_later_limit_detach(): axs[2].format(xlim=(-1, 0)) fig.canvas.draw() - assert axs[0].xaxis.get_tick_params()["labelbottom"] - assert not axs[1].xaxis.get_tick_params()["labelbottom"] - assert not axs[1].xaxis.get_tick_params()["labeltop"] + assert axs[0]._is_ticklabel_on("labelbottom") + assert not axs[1]._is_ticklabel_on("labelbottom") + assert not axs[1]._is_ticklabel_on("labeltop") def test_indexed_tick_location_only_disables_ticklabel_sharing(): @@ -605,7 +605,7 @@ def test_indexed_tick_location_only_disables_ticklabel_sharing(): assert fig._sharex_limits assert not fig._sharex_ticklabels assert fig._sharex == 2 - assert axs[0].xaxis.get_tick_params()["labelbottom"] + assert axs[0]._is_ticklabel_on("labelbottom") axs[0].set_xlim(2, 3) assert axs[2].get_xlim() == (2, 3) diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index 699800566..5eb2dfb0b 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -1135,6 +1135,9 @@ def assert_views_are_sharing(ax): lonlim=lonlim * axi.number, latlim=latlim * axi.number, ) + assert fig._sharex == fig._sharey == min(level, 1) + assert not fig._sharex_limits and not fig._sharey_limits + assert not fig._sharex_ticklabels and not fig._sharey_ticklabels fig.canvas.draw() for idx, axi in enumerate(ax): @@ -1151,12 +1154,10 @@ def assert_views_are_sharing(ax): ) assert_views_are_sharing(axi) - # When we share the labels but not the limits, - # we expect all ticks to be on - if level > 2: - assert s == 2 - else: - assert s == 4 + # The explicit per-axes limits above override both limit sharing and + # the associated interior tick-label suppression, regardless of the + # sharing level requested when the figure was created. + assert s == 4 uplt.close(fig) From 258961e85dbf2ef9042b06e4ec57001f27bb2971 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 4 Sep 2026 17:54:11 +1000 Subject: [PATCH 06/11] Preserve sharing for auxiliary grids --- ultraplot/figure.py | 6 ++++-- ultraplot/tests/test_figure.py | 5 +++-- ultraplot/tests/test_statistical_plotting.py | 13 ++++++------- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 1266eb9ed..89c922641 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -3901,10 +3901,12 @@ def _selector_numbers(selector): (item for item in mapping.values() if item is not None), None ) all_axes = set(self._iter_subplots()) - if axis_mappings and len(all_axes) > 1: + formatted_main_axes = set(axs) & all_axes + can_reduce_sharing = len(all_axes) > 1 and bool(formatted_main_axes) + if axis_mappings and can_reduce_sharing: self._update_sharing_for_format_keys(axis_mappings) is_subset = bool(axs) and all_axes and set(axs) != all_axes - if is_subset: + if is_subset and can_reduce_sharing: local_keys = { key for keys in ( diff --git a/ultraplot/tests/test_figure.py b/ultraplot/tests/test_figure.py index f56b65c4c..cd0820b4d 100644 --- a/ultraplot/tests/test_figure.py +++ b/ultraplot/tests/test_figure.py @@ -60,10 +60,11 @@ def test_unsharing_on_creation(): @pytest.mark.parametrize("kind", ("inset", "panel", "alternate")) -def test_auxiliary_axes_format_does_not_change_figure_sharing(kind): +@pytest.mark.parametrize("wrapped", (False, True)) +def test_auxiliary_axes_format_does_not_change_figure_sharing(kind, wrapped): """Only numbered main subplots may reduce figure-wide sharing.""" fig, axs = uplt.subplots(nrows=2, share=True) - ax = axs[0] + ax = axs[:1] if wrapped else axs[0] if kind == "inset": other = ax.inset_axes((0.2, 0.2, 0.4, 0.4)) elif kind == "panel": diff --git a/ultraplot/tests/test_statistical_plotting.py b/ultraplot/tests/test_statistical_plotting.py index c82401d72..b6f1bdb7f 100644 --- a/ultraplot/tests/test_statistical_plotting.py +++ b/ultraplot/tests/test_statistical_plotting.py @@ -310,7 +310,7 @@ def test_ridgeline_comparison_kde_vs_hist(rng): cmap="viridis", alpha=0.7, ) - axs[0].format(title="KDE Ridgeline", xlabel="Value", grid=False) + axs[0].format(title="KDE Ridgeline", grid=False) # Histogram version axs[1].ridgeline( @@ -322,9 +322,9 @@ def test_ridgeline_comparison_kde_vs_hist(rng): hist=True, bins=15, ) - axs[1].format(title="Histogram Ridgeline", xlabel="Value", grid=False) + axs[1].format(title="Histogram Ridgeline", grid=False) - fig.format(suptitle="KDE vs Histogram Ridgeline Comparison") + fig.format(xlabel="Value", suptitle="KDE vs Histogram Ridgeline Comparison") return fig @@ -466,16 +466,15 @@ def test_ridgeline_continuous_vs_categorical(rng): # Categorical mode axs[0].ridgeline(data, labels=labels, overlap=0.6, cmap="viridis", alpha=0.7) - axs[0].format(title="Categorical Positioning", xlabel="Value", grid=False) + axs[0].format(title="Categorical Positioning", grid=False) # Continuous mode positions = [0, 5, 15, 30] axs[1].ridgeline( data, labels=labels, positions=positions, height=4, cmap="viridis", alpha=0.7 ) - axs[1].format( - title="Continuous Positioning", xlabel="Value", ylabel="Coordinate", grid=True - ) + axs[1].format(title="Continuous Positioning", ylabel="Coordinate", grid=True) + axs.format(xlabel="Value") return fig From cbb5e48f1a823ff2f5f5f5341bd04379a81e85aa Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 4 Sep 2026 19:09:00 +1000 Subject: [PATCH 07/11] Fix local sharing detection and legacy image tests --- ultraplot/axes/cartesian.py | 2 +- ultraplot/axes/geo.py | 2 +- ultraplot/figure.py | 61 +++++++++++++++++--- ultraplot/tests/test_format.py | 25 ++++++++ ultraplot/tests/test_geographic.py | 8 ++- ultraplot/tests/test_imshow.py | 11 ++-- ultraplot/tests/test_statistical_plotting.py | 4 +- ultraplot/tests/test_subplots.py | 12 ++-- 8 files changed, 101 insertions(+), 24 deletions(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 455de0ae7..41f6035e3 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -1751,7 +1751,7 @@ def format( | AXIS_TICKLABEL_SHARING_FORMAT_KEYS["y"] ) } - self.figure._update_sharing_for_format_keys(format_keys) + self.figure._update_sharing_for_format_keys(format_keys, axes=(self,)) if format_keys & AXIS_LABEL_FORMAT_KEYS["x"]: self.xaxis.label.set_visible(True) if format_keys & AXIS_LABEL_FORMAT_KEYS["y"]: diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index 41530eafd..107987978 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -3066,7 +3066,7 @@ def format( format_keys.difference_update( {"labelpad", "labelcolor", "labelsize", "labelweight"} ) - self.figure._update_sharing_for_format_keys(format_keys) + self.figure._update_sharing_for_format_keys(format_keys, axes=(self,)) if format_keys & AXIS_LABEL_FORMAT_KEYS["x"]: self.xaxis.label.set_visible(True) if format_keys & AXIS_LABEL_FORMAT_KEYS["y"]: diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 89c922641..65636ed4c 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -2546,33 +2546,72 @@ def _update_axis_sharing_for_format( if (limits or ticklabels) and restore_ticklabels: self._restore_axis_ticklabels(which) - def _update_sharing_for_format_keys(self, keys): + def _axes_participate_in_sharing(self, axes, which): + """Return whether any axes belongs to an actual sharing group.""" + main_axes = tuple(self._iter_subplots()) + main_set = set(main_axes) + for ax in axes: + if ax not in main_set: + continue + get_shared = getattr(ax, f"get_shared_{which}_axes", None) + if get_shared is not None: + try: + if len(get_shared().get_siblings(ax)) > 1: + return True + except (AttributeError, TypeError): + pass + # Label-only sharing does not necessarily register with + # Matplotlib's limit-sharing grouper, so also inspect UltraPlot's + # directional parent links in both directions. + parent = getattr(ax, f"_share{which}", None) + if parent in main_set or any( + getattr(other, f"_share{which}", None) is ax for other in main_axes + ): + return True + return False + + def _update_sharing_for_format_keys(self, keys, *, axes=None): """Update sharing for axes-specific format keyword names.""" keys = set(keys) for which in "xy": + participates = axes is None or self._axes_participate_in_sharing( + axes, which + ) self._update_axis_sharing_for_format( which, labels=bool(keys & AXIS_LABEL_FORMAT_KEYS[which]), - limits=bool(keys & AXIS_SHARED_STATE_FORMAT_KEYS[which]), - ticklabels=bool(keys & AXIS_TICKLABEL_SHARING_FORMAT_KEYS[which]), + limits=bool( + participates and keys & AXIS_SHARED_STATE_FORMAT_KEYS[which] + ), + ticklabels=bool( + participates and keys & AXIS_TICKLABEL_SHARING_FORMAT_KEYS[which] + ), ) def _restore_axis_ticklabels(self, which): """Restore labels hidden only by subplot tick-label sharing.""" sides = ("bottom", "top") if which == "x" else ("left", "right") - labels = tuple(f"label{side}" for side in sides) for ax in self._iter_axes(hidden=False, children=False, panels=False): axis = getattr(ax, f"{which}axis", None) if axis is None or not hasattr(axis, "get_tick_params"): continue + # Matplotlib <= 3.9 reports x-axis tick parameters using the + # generic ``left``/``right`` names, while newer versions use + # ``bottom``/``top``. Use the same compatibility mapping as the + # sharing code both to inspect the tick side and to restore its + # corresponding label side. + labels = tuple(ax._label_key(f"label{side}") for side in sides) + tick_sides = tuple(label.removeprefix("label") for label in labels) params = axis.get_tick_params() state_getter = getattr(ax, "_get_axis_style_state", None) state = state_getter(which) if state_getter is not None else {} loc = state.get("ticklabelloc") if loc is None: visibility = { - label: bool(params.get(label, False) or params.get(side, False)) - for side, label in zip(sides, labels) + label: bool( + params.get(label, False) or params.get(tick_side, False) + ) + for tick_side, label in zip(tick_sides, labels) } else: aliases = {side[0]: side for side in sides} @@ -3904,7 +3943,13 @@ def _selector_numbers(selector): formatted_main_axes = set(axs) & all_axes can_reduce_sharing = len(all_axes) > 1 and bool(formatted_main_axes) if axis_mappings and can_reduce_sharing: - self._update_sharing_for_format_keys(axis_mappings) + for key, mapping in axis_mappings.items(): + targets = [ + axs[number - 1] + for number, value in mapping.items() + if value is not None + ] + self._update_sharing_for_format_keys({key}, axes=targets) is_subset = bool(axs) and all_axes and set(axs) != all_axes if is_subset and can_reduce_sharing: local_keys = { @@ -3924,7 +3969,7 @@ def _selector_numbers(selector): for key, value in kwargs.items() if value is not None and key in local_keys } - self._update_sharing_for_format_keys(local_state_keys) + self._update_sharing_for_format_keys(local_state_keys, axes=axs) # Unlike titles, axis labels are normally forwarded verbatim to every # axes. Accept a sequence of strings here as a request for one label per diff --git a/ultraplot/tests/test_format.py b/ultraplot/tests/test_format.py index 57589c26a..78dc9a6e4 100644 --- a/ultraplot/tests/test_format.py +++ b/ultraplot/tests/test_format.py @@ -494,6 +494,31 @@ def test_indexed_limit_format_restores_interior_ticklabels(): assert "1" in [label.get_text() for label in axs[0].get_xticklabels()] +def test_indexed_formatter_restores_interior_ticklabels(): + """Local formatters restore labels hidden by sharing on every mpl version.""" + fig, axs = uplt.subplots(nrows=2, share=True) + fig.canvas.draw() + assert not axs[0]._is_ticklabel_on("labelbottom") + + axs[1].format(xformatter="null") + fig.canvas.draw() + + assert axs[0]._is_ticklabel_on("labelbottom") + assert axs[1]._is_ticklabel_on("labelbottom") + assert all(not label.get_text() for label in axs[1].get_xticklabels()) + + +def test_indexed_unshared_direction_preserves_figure_sharing(): + """Local formatting only reduces a direction with actual shared siblings.""" + fig, axs = uplt.subplots(ncols=2, share=True) + before = fig.get_axis_sharing("x") + + axs[1].format(xformatter="null") + + assert fig.get_axis_sharing("x") == before + assert len(axs[1].get_shared_x_axes().get_siblings(axs[1])) == 1 + + def test_indexed_ylim_restores_interior_ticklabels(): """The ticker-detachment behavior is symmetric for y axes.""" fig, axs = uplt.subplots(nrows=2, ncols=2, share=True) diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index 5eb2dfb0b..93338ac6c 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -804,7 +804,9 @@ def test_format_shared_ticks_sync(): before_lon = ax[0]._get_lonticklocs() before_lat = ax[0]._get_latticklocs() - ax[1].format(lonlines=2, latlines=1) + # This test exercises synchronized shared ticks, so format the whole grid. + # Formatting ax[1] now deliberately requests independent local tick state. + ax.format(lonlines=2, latlines=1) after_left_lon = ax[0]._get_lonticklocs() after_left_lat = ax[0]._get_latticklocs() @@ -829,7 +831,7 @@ def test_format_shared_ticks_sync(): assert np.allclose(left_gridliner.xlocator.tick_values(100, 105), after_left_lon) assert np.allclose(left_gridliner.ylocator.tick_values(30, 35), after_left_lat) - ax[1].format(lonminorlines=0.5, latminorlines=0.5) + ax.format(lonminorlines=0.5, latminorlines=0.5) assert np.allclose( ax[0]._lonaxis.get_minorticklocs(), ax[1]._lonaxis.get_minorticklocs() ) @@ -838,7 +840,7 @@ def test_format_shared_ticks_sync(): ) formatter = mticker.FormatStrFormatter("%.1f") - ax[1].format(lonformatter=formatter, latformatter=formatter) + ax.format(lonformatter=formatter, latformatter=formatter) lonformatter = ax[1]._lonaxis.get_major_formatter() latformatter = ax[1]._lataxis.get_major_formatter() assert ax[0]._lonaxis.get_major_formatter() is lonformatter diff --git a/ultraplot/tests/test_imshow.py b/ultraplot/tests/test_imshow.py index 5cc111ce2..156401971 100644 --- a/ultraplot/tests/test_imshow.py +++ b/ultraplot/tests/test_imshow.py @@ -70,11 +70,12 @@ def test_inbounds_data(rng): inbounds = i == 1 title = f"Restricted lims inbounds={inbounds}" title += " (default)" if inbounds else "" - ax.format( - xlim=(None if i == 0 else xlim), - ylim=(None if i == 0 else ylim), - title=("Default axis limits" if i == 0 else title), - ) + ax.format(title=("Default axis limits" if i == 0 else title)) + if i != 0: + # Preserve the bottom axes' existing sharing group. Indexed + # format(xlim=..., ylim=...) now deliberately requests local axes. + ax.set_xlim(xlim) + ax.set_ylim(ylim) ax.pcolor(x, y, data, cmap=cmap, inbounds=inbounds) fig.format( xlabel="xlabel", diff --git a/ultraplot/tests/test_statistical_plotting.py b/ultraplot/tests/test_statistical_plotting.py index b6f1bdb7f..dca6c6bdd 100644 --- a/ultraplot/tests/test_statistical_plotting.py +++ b/ultraplot/tests/test_statistical_plotting.py @@ -473,8 +473,8 @@ def test_ridgeline_continuous_vs_categorical(rng): axs[1].ridgeline( data, labels=labels, positions=positions, height=4, cmap="viridis", alpha=0.7 ) - axs[1].format(title="Continuous Positioning", ylabel="Coordinate", grid=True) - axs.format(xlabel="Value") + axs[1].format(title="Continuous Positioning", grid=True) + axs.format(xlabel="Value", ylabel="Coordinate") return fig diff --git a/ultraplot/tests/test_subplots.py b/ultraplot/tests/test_subplots.py index 7e610e6e5..9cbf37859 100644 --- a/ultraplot/tests/test_subplots.py +++ b/ultraplot/tests/test_subplots.py @@ -25,7 +25,9 @@ def test_align_labels(): [[2, 1, 4], [2, 3, 5]], refnum=2, refwidth=1.5, align=1, span=0 ) fig.format(xlabel="xlabel", ylabel="ylabel", abc="A.", abcloc="ul") - axs[0].format(ylim=(10000, 20000)) + # This is plotting setup for the label-alignment comparison, so retain the + # existing y-sharing group instead of requesting a local format override. + axs[0].set_ylim(10000, 20000) axs[-1].panel_axes("bottom", share=False) return fig @@ -98,19 +100,21 @@ def test_complex_ticks(): axs[0].format( xtickloc="both", xticklabelloc="top", - xlabelloc="top", title="title", - xlabel="xlabel", suptitle="Test", ) axs[1].format( xtickloc="both", xticklabelloc="top", # xlabelloc='top', - xlabel="xlabel", title="title", suptitle="Test", ) + # Use direct setters because this test exercises title positioning, not + # indexed format() unsharing. Keep the legacy shared-label layout. + axs[0].xaxis.set_label_position("top") + for ax in axs: + ax.set_xlabel("xlabel") return fig From 453a25ff82fa3ff0c73233ceaa5a0666c4598cc7 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 4 Sep 2026 20:31:39 +1000 Subject: [PATCH 08/11] Refactor format sharing state transitions --- ultraplot/_sharing.py | 499 ++++++++++++++++++++++++++++ ultraplot/axes/_formatting.py | 100 ------ ultraplot/axes/base.py | 8 +- ultraplot/axes/cartesian.py | 47 +-- ultraplot/axes/container.py | 7 +- ultraplot/axes/geo.py | 60 +--- ultraplot/axes/plot.py | 22 +- ultraplot/axes/plot_types/ribbon.py | 3 +- ultraplot/axes/polar.py | 7 +- ultraplot/axes/shared.py | 51 +++ ultraplot/axes/taylor.py | 8 +- ultraplot/figure.py | 249 +++----------- ultraplot/gridspec.py | 38 +-- ultraplot/tests/test_figure.py | 36 ++ ultraplot/tests/test_format.py | 105 +++++- 15 files changed, 774 insertions(+), 466 deletions(-) create mode 100644 ultraplot/_sharing.py diff --git a/ultraplot/_sharing.py b/ultraplot/_sharing.py new file mode 100644 index 000000000..39d7aff2e --- /dev/null +++ b/ultraplot/_sharing.py @@ -0,0 +1,499 @@ +#!/usr/bin/env python3 +"""Axis-sharing policy and format-plan construction.""" + +from dataclasses import dataclass +from numbers import Integral + +import numpy as np + +_GENERIC_AXIS_LABEL_FORMAT_KEYS = { + "labelpad", + "labelcolor", + "labelsize", + "labelweight", +} + +AXIS_LABEL_FORMAT_KEYS = { + axis: { + f"{axis}label", + f"{axis}labelloc", + f"{axis}labelpad", + f"{axis}labelcolor", + f"{axis}labelsize", + f"{axis}labelweight", + f"{axis}label_kw", + } + | _GENERIC_AXIS_LABEL_FORMAT_KEYS + for axis in "xy" +} +AXIS_SHARED_STATE_FORMAT_KEYS = { + axis: { + f"{axis}lim", + f"{axis}min", + f"{axis}max", + f"{axis}scale", + f"{axis}reverse", + f"{axis}margin", + f"{axis}formatter", + f"{axis}ticklabels", + f"{axis}ticks", + f"{axis}locator", + f"{axis}minorticks", + f"{axis}minorlocator", + f"{axis}tickrange", + f"{axis}wraprange", + f"{axis}scale_kw", + f"{axis}locator_kw", + f"{axis}formatter_kw", + f"{axis}minorlocator_kw", + } + for axis in "xy" +} +AXIS_TICKLABEL_SHARING_FORMAT_KEYS = { + axis: { + f"{axis}loc", + f"{axis}spineloc", + f"{axis}tickloc", + f"{axis}ticklabelloc", + } + for axis in "xy" +} + +# Geographic axes use longitude as their x-like coordinate and latitude as +# their y-like coordinate. Sparse figure calls and direct axes calls therefore +# make the same sharing decision. +AXIS_SHARED_STATE_FORMAT_KEYS["x"].update( + { + "extent", + "lonlim", + "lonlocator", + "lonlines", + "lonminorlocator", + "lonminorlines", + "lonformatter", + "lonlocator_kw", + "lonlines_kw", + "lonminorlocator_kw", + "lonminorlines_kw", + "lonformatter_kw", + "dms", + } +) +AXIS_SHARED_STATE_FORMAT_KEYS["y"].update( + { + "extent", + "latlim", + "boundinglat", + "latmax", + "latlocator", + "latlines", + "latminorlocator", + "latminorlines", + "latformatter", + "latlocator_kw", + "latlines_kw", + "latminorlocator_kw", + "latminorlines_kw", + "latformatter_kw", + "dms", + } +) +AXIS_TICKLABEL_SHARING_FORMAT_KEYS["x"].update( + {"labels", "lonlabels", "loninline", "inlinelabels"} +) +AXIS_TICKLABEL_SHARING_FORMAT_KEYS["y"].update( + {"labels", "latlabels", "latinline", "inlinelabels"} +) + +AXIS_SHARING_FORMAT_KEYS = frozenset( + key + for mapping in ( + AXIS_LABEL_FORMAT_KEYS, + AXIS_SHARED_STATE_FORMAT_KEYS, + AXIS_TICKLABEL_SHARING_FORMAT_KEYS, + ) + for keys in mapping.values() + for key in keys +) + +_LIMIT_KEYS = {"xlim", "ylim", "lonlim", "latlim"} + + +def get_axis_sharing_format_keys(*mappings, exclude=()): + """Return non-null format keys that can contradict axis sharing.""" + values = {} + for mapping in mappings: + values.update(mapping) + excluded = set(exclude) + return { + key + for key, value in values.items() + if value is not None and key in AXIS_SHARING_FORMAT_KEYS and key not in excluded + } + + +def axis_supports_format_key(ax, key): + """Return whether the concrete axis accepts a sharing-sensitive key.""" + if key in _GENERIC_AXIS_LABEL_FORMAT_KEYS: + return True + return any( + isinstance(ax, axis_class) and key in signature.parameters + for axis_class, signature in ax._format_signatures.items() + ) + + +def _selector_numbers(selector): + """Return normalized one-based axis numbers for a mapping selector.""" + if isinstance(selector, Integral) and not isinstance(selector, bool): + return (int(selector),) + if isinstance(selector, (tuple, list, range)) and all( + isinstance(item, Integral) and not isinstance(item, bool) for item in selector + ): + return tuple(int(item) for item in selector) + return None + + +def _validate_limit(key, value): + """Validate a single two-value Cartesian or geographic limit.""" + if value is None: + return + try: + valid = not isinstance(value, str) and np.iterable(value) and len(value) == 2 + except TypeError: + valid = False + if not valid: + raise ValueError(f"Invalid {key}={value!r}. Must be 2-tuple of values.") + + +def validate_axis_format_values(values): + """Validate sharing-sensitive values before changing sharing state.""" + for key in _LIMIT_KEYS & values.keys(): + _validate_limit(key, values[key]) + + +def axes_participate(figure, axes, which): + """Return whether any selected main axis belongs to a directional group.""" + main_axes = tuple(figure._iter_subplots()) + main_set = set(main_axes) + for ax in axes: + if ax not in main_set: + continue + get_shared = getattr(ax, f"get_shared_{which}_axes", None) + if get_shared is not None: + try: + siblings = get_shared().get_siblings(ax) + if any( + sibling in main_set and sibling is not ax for sibling in siblings + ): + return True + except (AttributeError, TypeError): + pass + # Label-only sharing uses UltraPlot parent links rather than Matplotlib's + # limit-sharing grouper, so inspect links in both directions. + parent = getattr(ax, f"_share{which}", None) + if parent in main_set or any( + getattr(other, f"_share{which}", None) is ax for other in main_axes + ): + return True + return False + + +def update_sharing_for_format_keys(figure, keys, *, axes=None): + """Apply sharing changes implied by validated, supported format keys.""" + keys = set(keys) + for which in "xy": + participates = axes is None or axes_participate(figure, axes, which) + figure._update_axis_sharing_for_format( + which, + labels=bool(participates and keys & AXIS_LABEL_FORMAT_KEYS[which]), + limits=bool(participates and keys & AXIS_SHARED_STATE_FORMAT_KEYS[which]), + ticklabels=bool( + participates and keys & AXIS_TICKLABEL_SHARING_FORMAT_KEYS[which] + ), + ) + + +def snapshot_axis_sharing(figure): + """Capture figure sharing policy so a failed format call can restore it.""" + state = {} + for which in "xy": + state[which] = { + name: getattr(figure, f"_share{which}_{name}") + for name in ("labels", "limits", "ticklabels", "auto") + } + state[which]["level"] = getattr(figure, f"_share{which}") + state[which]["span"] = getattr(figure, f"_span{which}") + state[which]["groups"] = { + key: { + **group, + "axes": list(group["axes"]), + "props": None if group.get("props") is None else dict(group["props"]), + } + for key, group in figure._share_label_groups[which].items() + } + return state + + +def restore_axis_sharing(figure, state): + """Restore sharing policy and topology after a failed format call.""" + for which, values in state.items(): + setattr(figure, f"_share{which}", values["level"]) + setattr(figure, f"_span{which}", values["span"]) + for name in ("labels", "limits", "ticklabels", "auto"): + setattr(figure, f"_share{which}_{name}", values[name]) + figure._share_label_groups[which] = values["groups"] + rebuild_axis_sharing(figure, which) + + +def rebuild_axis_sharing(figure, which): + """Rebuild main-axis sharing while preserving intrinsic twin relations.""" + axes = list(figure._iter_axes(hidden=False, children=False, panels=False)) + parents = list(figure._iter_axes(hidden=True, children=False, panels=True)) + intrinsic = [] + for parent in parents: + for child in parent.child_axes: + if ( + which == "y" + and getattr(child, "_altx_parent", None) is parent + or which == "x" + and getattr(child, "_alty_parent", None) is parent + ): + intrinsic.append((parent, child)) + + for ax in axes: + if hasattr(ax, "_unshare"): + ax._unshare(which=which) + for ax in axes: + if hasattr(ax, "_apply_auto_share"): + ax._apply_auto_share() + + # Alternate/twin axes intrinsically share their orthogonal coordinate with + # their parent. This relationship is independent of figure-wide sharing. + for parent, child in intrinsic: + child._share_axis_with(parent, which=which) + setattr(child, f"_share{which}", parent) + + +@dataclass +class AxisFormatPlan: + """A validated plan for dispatching one ``Figure.format`` call.""" + + axes: tuple + kwargs: dict + mappings: dict + label_sequences: dict + limit_sequences: dict + signatures: dict + generic_keys: frozenset + + @classmethod + def build(cls, axes, kwargs, signatures, generic_keys): + """Normalize mappings and per-axis sequences without changing state.""" + axes = tuple(axes) + kwargs = dict(kwargs) + signatures = dict(signatures) + generic_keys = frozenset(generic_keys) + axis_format_keys = generic_keys | { + key for signature in signatures.values() for key in signature.parameters + } + + mappings = {} + for key, value in tuple(kwargs.items()): + if key not in axis_format_keys or not isinstance(value, dict) or not value: + continue + parsed = [ + (_selector_numbers(selector), item) for selector, item in value.items() + ] + if not any(numbers is not None for numbers, _ in parsed): + continue + if any(numbers is None for numbers, _ in parsed): + raise ValueError(f"Invalid mixed axes mapping for {key!r}: {value!r}.") + mapping = {} + for numbers, item in parsed: + for number in numbers: + if number not in range(1, len(axes) + 1): + raise ValueError( + f"Invalid axes number {number} for {key!r}; " + f"expected 1 through {len(axes)}." + ) + mapping[number] = item + mappings[key] = mapping + kwargs[key] = next( + (item for item in mapping.values() if item is not None), None + ) + + label_sequences = {} + for key in ("xlabel", "ylabel"): + value = kwargs.get(key) + if key in mappings or isinstance(value, str) or not np.iterable(value): + continue + value = tuple(value) + if not all(isinstance(item, str) for item in value): + continue + if len(value) != len(axes): + raise ValueError( + f"Invalid {key} list length {len(value)} " + f"for {len(axes)} formatted axes." + ) + label_sequences[key] = value + kwargs[key] = value + + limit_sequences = {} + for key in ("xlim", "ylim"): + value = kwargs.get(key) + if key in mappings or value is None or isinstance(value, str): + continue + if not np.iterable(value): + continue + value = tuple(value) + if not all( + np.iterable(item) and not isinstance(item, str) for item in value + ): + continue + if len(value) != len(axes): + raise ValueError( + f"Invalid {key} list length {len(value)} " + f"for {len(axes)} formatted axes." + ) + for item in value: + _validate_limit(key, item) + limit_sequences[key] = value + kwargs[key] = value + + plan = cls( + axes, + kwargs, + mappings, + label_sequences, + limit_sequences, + signatures, + generic_keys, + ) + plan.validate() + return plan + + def supports(self, ax, key): + """Return whether an axis accepts a format key.""" + return key in self.generic_keys or any( + isinstance(ax, axis_class) and key in signature.parameters + for axis_class, signature in self.signatures.items() + ) + + def affects_sharing(self, ax, key): + """Return whether a supported key has sharing semantics for an axis.""" + return self.supports(ax, key) and key not in getattr( + ax, "_format_sharing_exclude", () + ) + + def validate(self): + """Validate values that will actually be dispatched to an axis.""" + for key, mapping in self.mappings.items(): + for number, value in mapping.items(): + if self.supports(self.axes[number - 1], key): + validate_axis_format_values({key: value}) + ordinary = { + key: value + for key, value in self.kwargs.items() + if key not in self.mappings + and key not in self.limit_sequences + and any(self.supports(ax, key) for ax in self.axes) + } + validate_axis_format_values(ordinary) + + def update_sharing(self, figure): + """Apply all sharing changes after successful plan validation.""" + all_axes = set(figure._iter_subplots()) + if len(all_axes) < 2 or not (set(self.axes) & all_axes): + return + + for key, mapping in self.mappings.items(): + targets = tuple( + self.axes[number - 1] + for number, value in mapping.items() + if value is not None + and self.affects_sharing(self.axes[number - 1], key) + ) + if targets: + update_sharing_for_format_keys(figure, {key}, axes=targets) + + is_subset = bool(self.axes) and set(self.axes) != all_axes + if is_subset: + local_keys = { + key + for keys in ( + *AXIS_SHARED_STATE_FORMAT_KEYS.values(), + *AXIS_TICKLABEL_SHARING_FORMAT_KEYS.values(), + ) + for key in keys + } + if len(self.axes) == 1: + local_keys.update( + key for keys in AXIS_LABEL_FORMAT_KEYS.values() for key in keys + ) + keys = { + key + for key, value in self.kwargs.items() + if value is not None + and key not in self.mappings + and key not in self.label_sequences + and key not in self.limit_sequences + and key in local_keys + } + for key in keys: + targets = tuple(ax for ax in self.axes if self.affects_sharing(ax, key)) + if targets: + update_sharing_for_format_keys(figure, {key}, axes=targets) + + for key in self.label_sequences: + update_sharing_for_format_keys( + figure, + {key}, + axes=tuple(ax for ax in self.axes if self.affects_sharing(ax, key)), + ) + for key in self.limit_sequences: + update_sharing_for_format_keys( + figure, + {key}, + axes=tuple(ax for ax in self.axes if self.affects_sharing(ax, key)), + ) + + def apply_overrides(self, number, ax, projection_kw, generic_kw): + """Apply mapped and sequential values for one dispatch target.""" + for key, mapping in self.mappings.items(): + destination = generic_kw if key in self.generic_keys else projection_kw + if number in mapping and self.supports(ax, key): + destination[key] = mapping[number] + if key in ("xlabel", "ylabel"): + getattr(ax, f"{key[0]}axis").label.set_visible(True) + else: + destination.pop(key, None) + for sequences in (self.label_sequences, self.limit_sequences): + for key, values in sequences.items(): + if self.supports(ax, key): + projection_kw[key] = values[number - 1] + if key in ("xlabel", "ylabel"): + getattr(ax, f"{key[0]}axis").label.set_visible(True) + + def implicit_label_directions(self, figure): + """Return scalar labels that should span this multi-axis subset.""" + all_axes = set(figure._iter_subplots()) + if len(self.axes) < 2 or not all_axes or set(self.axes) == all_axes: + return () + directions = [] + compatible_sides = {"x": {"top", "bottom"}, "y": {"left", "right"}} + for which in "xy": + key = f"{which}label" + if ( + self.kwargs.get(key) is None + or key in self.mappings + or key in self.label_sequences + or self.kwargs.get(f"share_{which}labels") is not None + or any( + getattr(ax, "_panel_side", None) + not in (None, *compatible_sides[which]) + for ax in self.axes + ) + ): + continue + directions.append(which) + return tuple(directions) diff --git a/ultraplot/axes/_formatting.py b/ultraplot/axes/_formatting.py index 46bba510e..f0489c1da 100644 --- a/ultraplot/axes/_formatting.py +++ b/ultraplot/axes/_formatting.py @@ -5,106 +5,6 @@ import inspect -_GENERIC_AXIS_LABEL_FORMAT_KEYS = { - "labelpad", - "labelcolor", - "labelsize", - "labelweight", -} - -AXIS_LABEL_FORMAT_KEYS = { - axis: { - f"{axis}label", - f"{axis}labelloc", - f"{axis}labelpad", - f"{axis}labelcolor", - f"{axis}labelsize", - f"{axis}labelweight", - f"{axis}label_kw", - } - | _GENERIC_AXIS_LABEL_FORMAT_KEYS - for axis in "xy" -} -AXIS_SHARED_STATE_FORMAT_KEYS = { - axis: { - f"{axis}lim", - f"{axis}min", - f"{axis}max", - f"{axis}scale", - f"{axis}reverse", - f"{axis}margin", - f"{axis}formatter", - f"{axis}ticklabels", - f"{axis}ticks", - f"{axis}locator", - f"{axis}minorticks", - f"{axis}minorlocator", - f"{axis}tickrange", - f"{axis}wraprange", - f"{axis}scale_kw", - f"{axis}locator_kw", - f"{axis}formatter_kw", - f"{axis}minorlocator_kw", - } - for axis in "xy" -} -AXIS_TICKLABEL_SHARING_FORMAT_KEYS = { - axis: { - f"{axis}loc", - f"{axis}spineloc", - f"{axis}tickloc", - f"{axis}ticklabelloc", - } - for axis in "xy" -} - -# Geographic axes use longitude as their x-like coordinate and latitude as -# their y-like coordinate. Keep these aliases in the shared classifier so -# sparse Figure.format() calls and direct GeoAxes.format() calls make the same -# sharing decision as their Cartesian counterparts. -AXIS_SHARED_STATE_FORMAT_KEYS["x"].update( - { - "extent", - "lonlim", - "lonlocator", - "lonlines", - "lonminorlocator", - "lonminorlines", - "lonformatter", - "lonlocator_kw", - "lonlines_kw", - "lonminorlocator_kw", - "lonminorlines_kw", - "lonformatter_kw", - "dms", - } -) -AXIS_SHARED_STATE_FORMAT_KEYS["y"].update( - { - "extent", - "latlim", - "boundinglat", - "latmax", - "latlocator", - "latlines", - "latminorlocator", - "latminorlines", - "latformatter", - "latlocator_kw", - "latlines_kw", - "latminorlocator_kw", - "latminorlines_kw", - "latformatter_kw", - "dms", - } -) -AXIS_TICKLABEL_SHARING_FORMAT_KEYS["x"].update( - {"labels", "lonlabels", "loninline", "inlinelabels"} -) -AXIS_TICKLABEL_SHARING_FORMAT_KEYS["y"].update( - {"labels", "latlabels", "latinline", "inlinelabels"} -) - _AXIS_STYLE_FIELD_TEMPLATES = { "color": ( "{axis}color", diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index db1635a6f..6eea68936 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -3289,7 +3289,7 @@ def _update_share_labels(self, axes=None, target="x"): ax.yaxis.label = label @docstring._snippet_manager - def format( + def _format_impl( self, *, title=None, @@ -4753,8 +4753,10 @@ def use_sticky_edges(self, value): # Apply signature obfuscation after storing previous signature # NOTE: This is needed for __init__ -Axes._format_signatures = {Axes: inspect.signature(Axes.format)} -Axes.format = docstring._obfuscate_kwargs(Axes.format) +Axes._format_signatures = {Axes: inspect.signature(Axes._format_impl)} +Axes.format = docstring._obfuscate_kwargs(Axes._format_impl) +Axes.format.__name__ = "format" +Axes.format.__qualname__ = f"{Axes.__qualname__}.format" def _get_pos_from_locator( diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 41f6035e3..766e3843d 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -31,9 +31,6 @@ ) from ..utils import units from ._formatting import ( - AXIS_LABEL_FORMAT_KEYS, - AXIS_SHARED_STATE_FORMAT_KEYS, - AXIS_TICKLABEL_SHARING_FORMAT_KEYS, CARTESIAN_PARENT_FILTER_KEYS, axis_format_requires_layout, get_axis_style_fields, @@ -721,8 +718,7 @@ def _add_alt(self, sx, **kwargs): self._twinned_axes.join(self, ax) # Format parent and child axes - self.format( - _skip_share_update=True, + self._format_impl( **{f"{sx}loc": OPPOSITE_SIDE.get(kwargs[f"{sx}loc"], None)}, ) setattr(ax, f"_alt{sx}_parent", self) @@ -1586,7 +1582,7 @@ def get(name): return _AxisFormatConfig(**config_kwargs) @docstring._snippet_manager - def format( + def _format_impl( self, *, aspect=None, @@ -1723,41 +1719,7 @@ def format( # such as a set_ylabel() call since the last draw. format() has always # flushed those into the layout, so keep doing that. pending_layout = bool(self.stale) - skip_share_update = kwargs.pop("_skip_share_update", False) - main_subplots = ( - tuple(self.figure._iter_subplots()) if self.figure is not None else () - ) - can_reduce_sharing = len(main_subplots) > 1 and any( - self is ax for ax in main_subplots - ) - if ( - can_reduce_sharing - and not skip_share_update - and not kwargs.get("skip_figure", False) - ): - format_values = locals().copy() - format_values.update(kwargs) - format_keys = { - key - for key, value in format_values.items() - if value is not None - and key - in ( - AXIS_LABEL_FORMAT_KEYS["x"] - | AXIS_LABEL_FORMAT_KEYS["y"] - | AXIS_SHARED_STATE_FORMAT_KEYS["x"] - | AXIS_SHARED_STATE_FORMAT_KEYS["y"] - | AXIS_TICKLABEL_SHARING_FORMAT_KEYS["x"] - | AXIS_TICKLABEL_SHARING_FORMAT_KEYS["y"] - ) - } - self.figure._update_sharing_for_format_keys(format_keys, axes=(self,)) - if format_keys & AXIS_LABEL_FORMAT_KEYS["x"]: - self.xaxis.label.set_visible(True) - if format_keys & AXIS_LABEL_FORMAT_KEYS["y"]: - self.yaxis.label.set_visible(True) explicit_format_keys = set(kwargs.pop("_explicit_format_keys", ())) - explicit_format_keys.discard("_skip_share_update") signature_axis_kwargs, generic_axis_kwargs = pop_axis_format_kwargs( kwargs, self._format_signatures[CartesianAxes] ) @@ -1821,7 +1783,7 @@ def format( or axis_format_requires_layout(explicit_format_keys) ) try: - super().format(rc_kw=rc_kw, rc_mode=rc_mode, **base_kwargs) + super()._format_impl(rc_kw=rc_kw, rc_mode=rc_mode, **base_kwargs) finally: if previous is sentinel: del self._format_layout_required @@ -1915,7 +1877,8 @@ def wrapper(self, *args, **kwargs): # Apply signature obfuscation after storing previous signature # NOTE: This is needed for __init__, altx, and alty CartesianAxes._format_signatures[CartesianAxes] = inspect.signature( - CartesianAxes.format + CartesianAxes._format_impl ) # noqa: E501 +CartesianAxes.format = shared._format_wrapper(CartesianAxes._format_impl) CartesianAxes.format = _capture_explicit_format_keys(CartesianAxes.format) CartesianAxes.format = docstring._obfuscate_kwargs(CartesianAxes.format) diff --git a/ultraplot/axes/container.py b/ultraplot/axes/container.py index 98bbcba88..cfcd76e4c 100644 --- a/ultraplot/axes/container.py +++ b/ultraplot/axes/container.py @@ -13,6 +13,7 @@ from ..config import rc from ..internals import _pop_rc, warnings +from . import shared from .cartesian import CartesianAxes __all__ = ["ExternalAxesContainer"] @@ -710,7 +711,7 @@ def clear(self): if self._external_axes is not None: self._external_axes.clear() - def format(self, **kwargs): + def _format_impl(self, **kwargs): """ Format the container and delegate to external axes where appropriate. @@ -754,12 +755,14 @@ def format(self, **kwargs): # Apply container formatting (for ultraplot-specific features) if container_kwargs: - super().format(**container_kwargs) + super()._format_impl(**container_kwargs) # Apply external axes formatting if external_kwargs and self._external_axes is not None: self._external_axes.set(**external_kwargs) + format = shared._format_wrapper(_format_impl) + def draw(self, renderer): """Override draw to render container (with abc/titles) and external axes.""" # Draw external axes first - it may adjust its own position for labels diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index 107987978..36cd7548f 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -48,11 +48,6 @@ ) from ..utils import units from . import plot, shared -from ._formatting import ( - AXIS_LABEL_FORMAT_KEYS, - AXIS_SHARED_STATE_FORMAT_KEYS, - AXIS_TICKLABEL_SHARING_FORMAT_KEYS, -) try: import cartopy.crs as ccrs @@ -1568,6 +1563,10 @@ class GeoAxes(shared._SharedAxes, plot.PlotAxes): `map_projection` keyword argument. """ + _format_sharing_exclude = frozenset( + {"labelpad", "labelcolor", "labelsize", "labelweight"} + ) + @docstring._snippet_manager def __init__(self, *args: Any, **kwargs: Any) -> None: """ @@ -2957,7 +2956,7 @@ def _format_apply_ticklen( # 3) apply extent, features, and gridlines # 4) apply tick lengths and defer to parent format @docstring._snippet_manager - def format( + def _format_impl( self, *, aspect: str | float | None = None, @@ -3032,45 +3031,6 @@ def format( ultraplot.axes.Axes.format ultraplot.config.Configurator.context """ - skip_share_update = kwargs.pop("_skip_share_update", False) - main_subplots = ( - tuple(self.figure._iter_subplots()) if self.figure is not None else () - ) - can_reduce_sharing = len(main_subplots) > 1 and any( - self is ax for ax in main_subplots - ) - if ( - can_reduce_sharing - and not skip_share_update - and not kwargs.get("skip_figure", False) - ): - format_values = locals().copy() - format_values.update(kwargs) - format_keys = { - key - for key, value in format_values.items() - if value is not None - and key - in ( - AXIS_LABEL_FORMAT_KEYS["x"] - | AXIS_LABEL_FORMAT_KEYS["y"] - | AXIS_SHARED_STATE_FORMAT_KEYS["x"] - | AXIS_SHARED_STATE_FORMAT_KEYS["y"] - | AXIS_TICKLABEL_SHARING_FORMAT_KEYS["x"] - | AXIS_TICKLABEL_SHARING_FORMAT_KEYS["y"] - ) - } - # These generic names style geographic gridline labels rather than - # Cartesian x/y axis-title text, so they do not contradict shared - # xlabel or ylabel state. - format_keys.difference_update( - {"labelpad", "labelcolor", "labelsize", "labelweight"} - ) - self.figure._update_sharing_for_format_keys(format_keys, axes=(self,)) - if format_keys & AXIS_LABEL_FORMAT_KEYS["x"]: - self.xaxis.label.set_visible(True) - if format_keys & AXIS_LABEL_FORMAT_KEYS["y"]: - self.yaxis.label.set_visible(True) self._format_init_basemap_boundary() lonlabels, latlabels = self._format_normalize_label_inputs( labels=labels, @@ -3212,7 +3172,7 @@ def format( self._abc_anchor = abcanchor # Parent format method - super().format(rc_kw=rc_kw, rc_mode=rc_mode, **kwargs) + super()._format_impl(rc_kw=rc_kw, rc_mode=rc_mode, **kwargs) @docstring._snippet_manager def choropleth( @@ -4792,7 +4752,13 @@ def _choropleth_edge_collection_kw( # Apply signature obfuscation after storing previous signature -GeoAxes._format_signatures[GeoAxes] = inspect.signature(GeoAxes.format) +GeoAxes._format_signatures[GeoAxes] = inspect.signature(GeoAxes._format_impl) +# Generic label style names affect geographic gridline labels, not Cartesian +# axis-title text, and therefore do not contradict xlabel/ylabel sharing. +GeoAxes.format = shared._format_wrapper( + GeoAxes._format_impl, + exclude=GeoAxes._format_sharing_exclude, +) GeoAxes.format = docstring._obfuscate_kwargs(GeoAxes.format) diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index 399398b9b..8d33f629f 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -3994,7 +3994,7 @@ def _parse_1d_format( # pandas DataFrame specifically is passed to hist, boxplot, or violinplot, rows # of data assumed! Converting to ndarray necessary. if kw_format: - self.format(_skip_share_update=True, **kw_format) + self._format_impl(**kw_format) ys = tuple(map(inputs._to_numpy_array, ys)) if x is not None: # pie() and hist() x = inputs._to_numpy_array(x) @@ -4102,7 +4102,7 @@ def _parse_2d_format( # Apply formatting if kw_format: - self.format(_skip_share_update=True, **kw_format) + self._format_impl(**kw_format) # Apply title for legend or colorbar if autoguide and autoformat: @@ -5346,11 +5346,7 @@ def loglog(self, *args, **kwargs): """ objs = self._call_native("loglog", *args, **kwargs) if rc["formatter.log"]: - self.format( - _skip_share_update=True, - xformatter="log", - yformatter="log", - ) + self._format_impl(xformatter="log", yformatter="log") return objs @docstring._snippet_manager @@ -5361,10 +5357,7 @@ def semilogy(self, *args, **kwargs): objs = self._call_native("semilogy", *args, **kwargs) if rc["formatter.log"]: - self.format( - _skip_share_update=True, - yformatter="log", - ) + self._format_impl(yformatter="log") return objs @docstring._snippet_manager @@ -5374,10 +5367,7 @@ def semilogx(self, *args, **kwargs): """ objs = self._call_native("semilogx", *args, **kwargs) if rc["formatter.log"]: - self.format( - _skip_share_update=True, - xformatter="log", - ) + self._format_impl(xformatter="log") return objs @inputs._preprocess_or_redirect("x", "y", allow_extra=True) @@ -7448,7 +7438,7 @@ def heatmap(self, *args, aspect=None, **kwargs): kw["xtickminor"] = False if self.yaxis.isDefault_minloc: kw["ytickminor"] = False - self.format(_skip_share_update=True, **kw) + self._format_impl(**kw) return obj @inputs._preprocess_or_redirect("x", "y", "u", "v", ("c", "color", "colors")) diff --git a/ultraplot/axes/plot_types/ribbon.py b/ultraplot/axes/plot_types/ribbon.py index dd5135499..6ec960bbd 100644 --- a/ultraplot/axes/plot_types/ribbon.py +++ b/ultraplot/axes/plot_types/ribbon.py @@ -329,11 +329,10 @@ def ribbon_diagram( ) period_text.append(text) - ax.format( + ax._format_impl( xlim=(0, 1), ylim=(0, 1), grid=False, - _skip_share_update=True, ) ax.axis("off") return { diff --git a/ultraplot/axes/polar.py b/ultraplot/axes/polar.py index c56b2bc98..9122f94c4 100644 --- a/ultraplot/axes/polar.py +++ b/ultraplot/axes/polar.py @@ -500,7 +500,7 @@ def get_tightbbox(self, renderer, *args, **kwargs): return super().get_tightbbox(renderer, *args, **kwargs) @docstring._snippet_manager - def format( + def _format_impl( self, *, r0=None, @@ -738,10 +738,11 @@ def format( self._update_polar_label(kind, text, **kw) # Parent format method - super().format(rc_kw=rc_kw, rc_mode=rc_mode, **kwargs) + super()._format_impl(rc_kw=rc_kw, rc_mode=rc_mode, **kwargs) # Apply signature obfuscation after storing previous signature # NOTE: This is needed for __init__ -PolarAxes._format_signatures[PolarAxes] = inspect.signature(PolarAxes.format) +PolarAxes._format_signatures[PolarAxes] = inspect.signature(PolarAxes._format_impl) +PolarAxes.format = shared._format_wrapper(PolarAxes._format_impl) PolarAxes.format = docstring._obfuscate_kwargs(PolarAxes.format) diff --git a/ultraplot/axes/shared.py b/ultraplot/axes/shared.py index dadc33b77..fd6fa3b9e 100644 --- a/ultraplot/axes/shared.py +++ b/ultraplot/axes/shared.py @@ -5,9 +5,20 @@ # NOTE: We could define these in base.py but idea is projection-specific formatters # should never be defined on the base class. Might add to this class later anyway. +import functools + import numpy as np from ..config import rc +from .._sharing import ( + AXIS_LABEL_FORMAT_KEYS, + axis_supports_format_key, + get_axis_sharing_format_keys, + restore_axis_sharing, + snapshot_axis_sharing, + update_sharing_for_format_keys, + validate_axis_format_values, +) from ..internals import ic # noqa: F401 from ..internals import _pop_kwargs from ..utils import _fontsize_to_pt, _not_none, units @@ -21,12 +32,52 @@ from typing_extensions import override +def _format_wrapper(method, *, exclude=()): + """Wrap a format implementation with explicit-user sharing updates.""" + + @functools.wraps(method) + def format(self, *args, **kwargs): + validate_axis_format_values(kwargs) + keys = { + key + for key in get_axis_sharing_format_keys(kwargs, exclude=exclude) + if axis_supports_format_key(self, key) + } + if kwargs.get("skip_figure", False): + keys.clear() + figure = self.figure + state = snapshot_axis_sharing(figure) if figure is not None and keys else None + try: + self._update_format_sharing(keys) + return method(self, *args, **kwargs) + except Exception: + if state is not None: + restore_axis_sharing(figure, state) + raise + + format.__name__ = "format" + format.__qualname__ = f"{method.__qualname__.rsplit('.', 1)[0]}.format" + return format + + class _SharedAxes(object): """ Mix-in class with methods shared between `~ultraplot.axes.CartesianAxes` and :class:`~ultraplot.axes.PolarAxes`. """ + def _update_format_sharing(self, format_keys): + """Apply sharing effects for one explicit axes-level format call.""" + if self.figure is None or not format_keys: + return + main_subplots = tuple(self.figure._iter_subplots()) + if len(main_subplots) < 2 or not any(self is ax for ax in main_subplots): + return + update_sharing_for_format_keys(self.figure, format_keys, axes=(self,)) + for which in "xy": + if format_keys & AXIS_LABEL_FORMAT_KEYS[which]: + getattr(self, f"{which}axis").label.set_visible(True) + @staticmethod def _min_max_lim(key, min_=None, max_=None, lim=None): """ diff --git a/ultraplot/axes/taylor.py b/ultraplot/axes/taylor.py index 02f8c93cb..302d083d4 100644 --- a/ultraplot/axes/taylor.py +++ b/ultraplot/axes/taylor.py @@ -12,6 +12,7 @@ from ..config import rc from ..internals import _not_none, _pop_rc, docstring +from . import shared from .polar import PolarAxes __all__ = ["TaylorAxes"] @@ -492,7 +493,7 @@ def draw(self, renderer=None, *args, **kwargs): super().draw(renderer, *args, **kwargs) @docstring._snippet_manager - def format( + def _format_impl( self, *, xlabel=None, @@ -564,7 +565,7 @@ def format( corrlabel_kw=corrlabel_kw, ) - super().format( + super()._format_impl( rc_kw=rc_kw, rc_mode=rc_mode, labelpad=labelpad, @@ -579,5 +580,6 @@ def format( self._update_taylor_std_ticklabels() -TaylorAxes._format_signatures[TaylorAxes] = inspect.signature(TaylorAxes.format) +TaylorAxes._format_signatures[TaylorAxes] = inspect.signature(TaylorAxes._format_impl) +TaylorAxes.format = shared._format_wrapper(TaylorAxes._format_impl) TaylorAxes.format = docstring._obfuscate_kwargs(TaylorAxes.format) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 65636ed4c..42fabd7b5 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -7,7 +7,6 @@ import inspect import os from contextlib import ExitStack -from numbers import Integral try: from typing import Any, Iterable, List, Optional, Tuple, Union @@ -27,13 +26,11 @@ from . import axes as paxes from .axes._formatting import ( - AXIS_LABEL_FORMAT_KEYS, - AXIS_SHARED_STATE_FORMAT_KEYS, - AXIS_TICKLABEL_SHARING_FORMAT_KEYS, GENERIC_AXIS_FORMAT_KEYS, axis_format_requires_layout, pop_axis_format_kwargs, ) +from . import _sharing as psharing from . import constructor from . import gridspec as pgridspec from . import legend as plegend @@ -2546,48 +2543,6 @@ def _update_axis_sharing_for_format( if (limits or ticklabels) and restore_ticklabels: self._restore_axis_ticklabels(which) - def _axes_participate_in_sharing(self, axes, which): - """Return whether any axes belongs to an actual sharing group.""" - main_axes = tuple(self._iter_subplots()) - main_set = set(main_axes) - for ax in axes: - if ax not in main_set: - continue - get_shared = getattr(ax, f"get_shared_{which}_axes", None) - if get_shared is not None: - try: - if len(get_shared().get_siblings(ax)) > 1: - return True - except (AttributeError, TypeError): - pass - # Label-only sharing does not necessarily register with - # Matplotlib's limit-sharing grouper, so also inspect UltraPlot's - # directional parent links in both directions. - parent = getattr(ax, f"_share{which}", None) - if parent in main_set or any( - getattr(other, f"_share{which}", None) is ax for other in main_axes - ): - return True - return False - - def _update_sharing_for_format_keys(self, keys, *, axes=None): - """Update sharing for axes-specific format keyword names.""" - keys = set(keys) - for which in "xy": - participates = axes is None or self._axes_participate_in_sharing( - axes, which - ) - self._update_axis_sharing_for_format( - which, - labels=bool(keys & AXIS_LABEL_FORMAT_KEYS[which]), - limits=bool( - participates and keys & AXIS_SHARED_STATE_FORMAT_KEYS[which] - ), - ticklabels=bool( - participates and keys & AXIS_TICKLABEL_SHARING_FORMAT_KEYS[which] - ), - ) - def _restore_axis_ticklabels(self, which): """Restore labels hidden only by subplot tick-label sharing.""" sides = ("bottom", "top") if which == "x" else ("left", "right") @@ -2632,13 +2587,7 @@ def _restore_axis_ticklabels(self, which): def _rebuild_axis_sharing(self, which): """Rebuild axis relationships from the orthogonal sharing flags.""" - axes = list(self._iter_axes(hidden=False, children=False, panels=False)) - for ax in axes: - if hasattr(ax, "_unshare"): - ax._unshare(which=which) - for ax in axes: - if hasattr(ax, "_apply_auto_share"): - ax._apply_auto_share() + psharing.rebuild_axis_sharing(self, which) def _toggle_axis_sharing( self, @@ -3893,128 +3842,18 @@ def format( pending_layout = kwargs.pop("_pending_layout", None) if pending_layout is None: pending_layout = bool(self.stale) - axs = list(axs or self._iter_subplots()) - # Parse per-axes dictionaries using the same one-based selector syntax as - # subplot projection dictionaries: {1: value, (2, 3): value}. Dictionaries - # with ordinary string keys remain native format/style dictionaries. - axis_format_keys = { - key - for signature in paxes.Axes._format_signatures.values() - for key in signature.parameters - } - axis_format_keys.update(GENERIC_AXIS_FORMAT_KEYS) - - def _selector_numbers(selector): - if isinstance(selector, Integral) and not isinstance(selector, bool): - return (int(selector),) - if isinstance(selector, (tuple, list, range)) and all( - isinstance(item, Integral) and not isinstance(item, bool) - for item in selector - ): - return tuple(int(item) for item in selector) - return None + # Phase 1: normalize and validate every per-axis request. + axs = list(axs or self._iter_subplots()) + plan = psharing.AxisFormatPlan.build( + axs, + kwargs, + paxes.Axes._format_signatures, + GENERIC_AXIS_FORMAT_KEYS, + ) + kwargs = plan.kwargs - axis_mappings = {} - for key, value in tuple(kwargs.items()): - if key not in axis_format_keys or not isinstance(value, dict) or not value: - continue - parsed = [ - (_selector_numbers(selector), item) for selector, item in value.items() - ] - if not any(numbers is not None for numbers, _ in parsed): - continue - if any(numbers is None for numbers, _ in parsed): - raise ValueError(f"Invalid mixed axes mapping for {key!r}: {value!r}.") - mapping = {} - for numbers, item in parsed: - for number in numbers: - if number not in range(1, len(axs) + 1): - raise ValueError( - f"Invalid axes number {number} for {key!r}; " - f"expected 1 through {len(axs)}." - ) - mapping[number] = item - axis_mappings[key] = mapping - kwargs[key] = next( - (item for item in mapping.values() if item is not None), None - ) - all_axes = set(self._iter_subplots()) - formatted_main_axes = set(axs) & all_axes - can_reduce_sharing = len(all_axes) > 1 and bool(formatted_main_axes) - if axis_mappings and can_reduce_sharing: - for key, mapping in axis_mappings.items(): - targets = [ - axs[number - 1] - for number, value in mapping.items() - if value is not None - ] - self._update_sharing_for_format_keys({key}, axes=targets) - is_subset = bool(axs) and all_axes and set(axs) != all_axes - if is_subset and can_reduce_sharing: - local_keys = { - key - for keys in ( - *AXIS_SHARED_STATE_FORMAT_KEYS.values(), - *AXIS_TICKLABEL_SHARING_FORMAT_KEYS.values(), - ) - for key in keys - } - if len(axs) == 1: - local_keys.update( - key for keys in AXIS_LABEL_FORMAT_KEYS.values() for key in keys - ) - local_state_keys = { - key - for key, value in kwargs.items() - if value is not None and key in local_keys - } - self._update_sharing_for_format_keys(local_state_keys, axes=axs) - - # Unlike titles, axis labels are normally forwarded verbatim to every - # axes. Accept a sequence of strings here as a request for one label per - # formatted axes. Distinct labels are incompatible with label sharing, so - # disable sharing in that direction and trust the explicit request. - label_sequences = {} - for key, axis in (("xlabel", "x"), ("ylabel", "y")): - value = kwargs.get(key) - if isinstance(value, str) or not np.iterable(value): - continue - value = tuple(value) - if not all(isinstance(item, str) for item in value): - continue - if len(value) != len(axs): - raise ValueError( - f"Invalid {key} list length {len(value)} " - f"for {len(axs)} formatted axes." - ) - label_sequences[key] = value - kwargs[key] = value - if len(value) > 1: - self._update_axis_sharing_for_format(axis, labels=True) - - # A sequence of limit pairs requests independent numeric axes in that - # direction. Preserve axis-title sharing, but detach limits/tickers and - # stop suppressing interior tick labels. - limit_sequences = {} - for key, axis in (("xlim", "x"), ("ylim", "y")): - value = kwargs.get(key) - if value is None or isinstance(value, str) or not np.iterable(value): - continue - value = tuple(value) - if not all( - np.iterable(item) and not isinstance(item, str) for item in value - ): - continue - if len(value) != len(axs): - raise ValueError( - f"Invalid {key} list length {len(value)} " - f"for {len(axs)} formatted axes." - ) - limit_sequences[key] = value - kwargs[key] = value - if len(value) > 1: - self._update_axis_sharing_for_format(axis, limits=True) + implicit_label_directions = plan.implicit_label_directions(self) skip_axes = kwargs.pop("skip_axes", False) # internal keyword arg explicit_format_keys = set(kwargs) signature_axis_kwargs, generic_axis_kwargs = pop_axis_format_kwargs( @@ -4023,6 +3862,8 @@ def _selector_numbers(selector): explicit_format_keys.update(signature_axis_kwargs) explicit_format_keys.update(generic_axis_kwargs) rc_kw, rc_mode = _pop_rc(kwargs) + + # Phase 2: update figure-owned layout and label state. figure_layout_requested = _any_not_none( figtitle, suptitle, @@ -4101,10 +3942,15 @@ def _selector_numbers(selector): **toplabels_kw, ) - # Update the main axes + # Phase 3: reconcile sharing and dispatch to each projection. if skip_axes: # avoid recursion return + # Reconcile sharing immediately before axes mutation. If projection-level + # formatting rejects a value, restore the original policy and topology. + sharing_state = psharing.snapshot_axis_sharing(self) + plan.update_sharing(self) + # Collect each class's matching kwargs without popping, then drop the union — # shared params (e.g. xlabel/ylabel, accepted by both CartesianAxes and # PolarAxes) need to reach every matching class. @@ -4139,33 +3985,7 @@ def _axis_has_label_text(ax, axis): if isinstance(ax, cls) and not classes.add(cls) } generic_kw = generic_axis_kwargs.copy() - for key, mapping in axis_mappings.items(): - is_generic = key in generic_axis_kwargs - supports_value = is_generic or any( - key in cls_kw for cls, cls_kw in kws.items() if isinstance(ax, cls) - ) - if number in mapping and supports_value: - (generic_kw if is_generic else kw)[key] = mapping[number] - if key in ("xlabel", "ylabel"): - getattr(ax, f"{key[0]}axis").label.set_visible(True) - else: - (generic_kw if is_generic else kw).pop(key, None) - # Titles already support this convention in Axes._update_title(). - # Labels need dispatch here because Matplotlib otherwise treats a - # sequence as one label object and converts it to its repr. - for key, values in label_sequences.items(): - supports_label = any( - key in cls_kw for cls, cls_kw in kws.items() if isinstance(ax, cls) - ) - if supports_label: - kw[key] = values[number - 1] - getattr(ax, f"{key[0]}axis").label.set_visible(True) - for key, values in limit_sequences.items(): - supports_limit = any( - key in cls_kw for cls, cls_kw in kws.items() if isinstance(ax, cls) - ) - if supports_limit: - kw[key] = values[number - 1] + plan.apply_overrides(number, ax, kw, generic_kw) if kw.get("xlabel") is not None and self._has_share_label_groups("x"): if _axis_has_share_label_text(ax, "x") or _axis_has_label_text(ax, "x"): kw.pop("xlabel", None) @@ -4175,16 +3995,23 @@ def _axis_has_label_text(ax, axis): explicit_kw = {} if isinstance(ax, paxes.CartesianAxes): explicit_kw["_explicit_format_keys"] = explicit_format_keys - ax.format( - rc_kw=rc_kw, - rc_mode=rc_mode, - skip_figure=True, - **explicit_kw, - **kw, - **kwargs, - **generic_kw, - ) - ax.number = store_old_number + try: + ax.format( + rc_kw=rc_kw, + rc_mode=rc_mode, + skip_figure=True, + **explicit_kw, + **kw, + **kwargs, + **generic_kw, + ) + except Exception: + psharing.restore_axis_sharing(self, sharing_state) + raise + finally: + ax.number = store_old_number + for which in implicit_label_directions: + self._register_share_label_group(axs, target=which) # Warn unused keyword argument(s). Shared params (those in multiple # signatures) are considered "used" if any matched class consumed them. used_keys = {k for cls in classes for k in kws[cls]} diff --git a/ultraplot/gridspec.py b/ultraplot/gridspec.py index 4cb214004..cb1b0e3fd 100644 --- a/ultraplot/gridspec.py +++ b/ultraplot/gridspec.py @@ -2097,20 +2097,8 @@ def format(self, **kwargs): ultraplot.config.Configurator.context """ - def _supports_implicit_label_share(target): - compatible_sides = { - "x": {"top", "bottom"}, - "y": {"left", "right"}, - } - for ax in axes: - side = getattr(ax, "_panel_side", None) - if side is None: - continue - if side not in compatible_sides[target]: - return False - return True - - # Implicit label sharing for subset format calls + # Explicit group clearing is grid-owned. Figure.format handles implicit + # scalar subset groups after it successfully dispatches all axes values. share_xlabels = kwargs.get("share_xlabels", None) share_ylabels = kwargs.get("share_ylabels", None) xlabel = kwargs.get("xlabel", None) @@ -2138,18 +2126,6 @@ def _supports_implicit_label_share(target): kwargs.update(signature_axis_kwargs) kwargs.update(generic_axis_kwargs) with rc.context(rc_kw, mode=rc_mode): - implicit_share_xlabels = ( - is_subset - and share_xlabels is None - and xlabel is not None - and _supports_implicit_label_share("x") - ) - implicit_share_ylabels = ( - is_subset - and share_ylabels is None - and ylabel is not None - and _supports_implicit_label_share("y") - ) if len(self) > 1: if share_xlabels is False: self.figure._clear_share_label_groups(self, target="x") @@ -2159,10 +2135,6 @@ def _supports_implicit_label_share(target): self.figure._clear_share_label_groups(self, target="x") if not is_subset and share_ylabels is None and ylabel is not None: self.figure._clear_share_label_groups(self, target="y") - if implicit_share_xlabels: - self.figure._register_share_label_group(self, target="x") - if implicit_share_ylabels: - self.figure._register_share_label_group(self, target="y") self.figure.format(axs=self, **kwargs) if shared_subset_title: self.figure._update_subset_title( @@ -2172,12 +2144,6 @@ def _supports_implicit_label_share(target): pad=shared_title_pad, **(shared_title_kw or {}), ) - # Refresh groups after labels are set - if len(self) > 1: - if implicit_share_xlabels: - self.figure._register_share_label_group(self, target="x") - if implicit_share_ylabels: - self.figure._register_share_label_group(self, target="y") def share_labels(self, *, axis="x"): """ diff --git a/ultraplot/tests/test_figure.py b/ultraplot/tests/test_figure.py index cd0820b4d..9a59fe315 100644 --- a/ultraplot/tests/test_figure.py +++ b/ultraplot/tests/test_figure.py @@ -167,6 +167,42 @@ def test_internal_plot_formatting_does_not_reduce_sharing(): assert fig.get_axis_sharing() == before +@pytest.mark.parametrize( + ("alternate", "which", "limits"), + (("altx", "y", (-2, -1)), ("alty", "x", (2, 3))), +) +def test_rebuilding_sharing_preserves_alternate_axis_link(alternate, which, limits): + """Figure sharing changes must not detach a twin from its parent.""" + fig, axs = uplt.subplots(nrows=2, share=True) + child = getattr(axs[0], alternate)() + shared = getattr(axs[0], f"get_shared_{which}_axes")() + + axs[0].format(**{f"{which}lim": limits}) + + assert shared.joined(axs[0], child) + assert getattr(child, f"get_{which}lim")() == limits + shifted = tuple(value + 2 for value in limits) + getattr(axs[0], f"set_{which}lim")(*shifted) + assert getattr(child, f"get_{which}lim")() == shifted + + fig.set_axis_sharing(which, level=3) + assert shared.joined(axs[0], child) + + +def test_rebuilding_sharing_preserves_panel_alternate_axis_link(): + """Rebuilding a main group must preserve twins owned by its panels.""" + fig, axs = uplt.subplots(nrows=2, share=True) + panel = axs[0].panel_axes("bottom") + child = panel.alty() + shared = panel.get_shared_x_axes() + + axs[0].format(xlim=(-1, 0)) + + assert shared.joined(panel, child) + panel.set_xlim(2, 3) + assert child.get_xlim() == (2, 3) + + def test_unsharing_different_rectilinear(): """ Even if the projections are rectilinear, the coordinates systems may be different, as such we only allow sharing for the same kind of projections. diff --git a/ultraplot/tests/test_format.py b/ultraplot/tests/test_format.py index 78dc9a6e4..7517a6322 100644 --- a/ultraplot/tests/test_format.py +++ b/ultraplot/tests/test_format.py @@ -460,6 +460,106 @@ def test_format_axes_mapping_uses_one_based_selectors(): assert fig._sharey == 0 +def test_figure_format_subset_scalar_label_creates_shared_group(): + """Direct figure subset formatting shares one label across the subset.""" + fig, axs = uplt.subplots(nrows=3, share=True, span=False) + + fig.format(axs=axs[:2], xlabel="Subset x") + fig.canvas.draw() + + assert all(ax.get_xlabel().strip() == "" for ax in axs[:2]) + assert any(label.get_text() == "Subset x" for label in fig._supxlabel_dict.values()) + assert fig._sharex_labels + + +@pytest.mark.parametrize( + ("projection", "key"), + ((None, "lonlim"), ("cyl", "xlim")), +) +def test_unsupported_sparse_mapping_preserves_sharing(projection, key): + """Ignored projection-specific mappings cannot change sharing state.""" + kwargs = {} if projection is None else {"proj": projection} + fig, axs = uplt.subplots(nrows=2, share=True, **kwargs) + before = fig.get_axis_sharing() + + with pytest.warns(uplt.warnings.UltraPlotWarning, match="Ignoring unused"): + fig.format(**{key: {1: (0, 1)}}) + + assert fig.get_axis_sharing() == before + + +@pytest.mark.parametrize( + ("projection", "key"), + ((None, "lonlim"), ("cyl", "xlim")), +) +def test_unsupported_direct_format_preserves_sharing(projection, key): + """Projection-specific filtering also applies to direct axes calls.""" + kwargs = {} if projection is None else {"proj": projection} + fig, axs = uplt.subplots(nrows=2, share=True, **kwargs) + before = fig.get_axis_sharing() + + with pytest.raises(TypeError): + axs[0].format(**{key: (0, 1)}) + + assert fig.get_axis_sharing() == before + + +def test_mixed_projection_subset_only_considers_supported_targets(): + """An unsupported selected axis cannot detach another projection's group.""" + fig, axs = uplt.subplots(nrows=3, proj=(None, "cyl", "cyl"), share=True) + before = fig.get_axis_sharing("x") + + fig.format(axs=axs[:2], xlim=(2, 3)) + + assert axs[0].get_xlim() == (2, 3) + assert fig.get_axis_sharing("x") == before + assert axs[1].get_shared_x_axes().joined(axs[1], axs[2]) + + +@pytest.mark.parametrize("syntax", ("direct", "mapping", "sequence")) +def test_invalid_limit_preserves_sharing(syntax): + """Validation precedes every sharing state transition.""" + fig, axs = uplt.subplots(nrows=2, share=True) + before = fig.get_axis_sharing() + + with pytest.raises(ValueError, match="Must be 2-tuple"): + if syntax == "direct": + axs[0].format(xlim=(0, 1, 2)) + elif syntax == "mapping": + fig.format(xlim={1: (0, 1, 2)}) + else: + fig.format(xlim=((0, 1), (0, 1, 2))) + + assert fig.get_axis_sharing() == before + + +@pytest.mark.parametrize("syntax", ("direct", "figure")) +def test_failed_scale_format_restores_sharing(syntax): + """Projection errors roll back sharing policy and topology.""" + fig, axs = uplt.subplots(nrows=2, share=True) + before = fig.get_axis_sharing() + + with pytest.raises(ValueError): + if syntax == "direct": + axs[0].format(xscale="definitely-not-a-scale") + else: + fig.format(axs=axs[:1], xscale="definitely-not-a-scale") + + assert fig.get_axis_sharing() == before + assert axs[0].get_shared_x_axes().joined(axs[0], axs[1]) + + +def test_local_label_in_singleton_direction_preserves_sharing_state(): + """A local label only reduces a direction with actual shared siblings.""" + fig, axs = uplt.subplots(ncols=2, share=True) + before = fig.get_axis_sharing("x") + + axs[0].format(xlabel="Local x") + + assert fig.get_axis_sharing("x") == before + assert axs[0].get_xlabel() == "Local x" + + def test_indexed_format_updates_axis_sharing(): """Formatting one axes updates sharing like a sparse figure-level mapping.""" fig, axs = uplt.subplots(nrows=2, ncols=2, share=True) @@ -641,7 +741,10 @@ def test_singleton_grid_format_updates_axis_sharing(): axs[:1].format(xlim=(2, 3), ylabel="Local y") assert not fig._sharex_limits - assert not fig._sharey_labels + # These vertically stacked axes do not participate in a y-sharing group, + # so a local ylabel does not contradict the nominal y-sharing setting. + assert fig._sharey_labels + assert axs[0].get_ylabel() == "Local y" assert axs[0].get_xlim() == (2, 3) assert axs[1].get_xlim() != (2, 3) From e6f8189c35cdf1840c84d5a8026f342cfcd66de2 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 4 Sep 2026 21:12:22 +1000 Subject: [PATCH 09/11] Preserve explicit public format methods --- ultraplot/axes/base.py | 9 +++---- ultraplot/axes/cartesian.py | 21 +++------------ ultraplot/axes/container.py | 8 +++--- ultraplot/axes/geo.py | 10 +++---- ultraplot/axes/polar.py | 5 ++-- ultraplot/axes/shared.py | 18 ++++++++----- ultraplot/axes/taylor.py | 5 ++-- ultraplot/tests/test_format.py | 49 ++++++++++++++++++++++++++++++++++ 8 files changed, 82 insertions(+), 43 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 6eea68936..39eb6d841 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -3289,7 +3289,7 @@ def _update_share_labels(self, axes=None, target="x"): ax.yaxis.label = label @docstring._snippet_manager - def _format_impl( + def format( self, *, title=None, @@ -4753,10 +4753,9 @@ def use_sticky_edges(self, value): # Apply signature obfuscation after storing previous signature # NOTE: This is needed for __init__ -Axes._format_signatures = {Axes: inspect.signature(Axes._format_impl)} -Axes.format = docstring._obfuscate_kwargs(Axes._format_impl) -Axes.format.__name__ = "format" -Axes.format.__qualname__ = f"{Axes.__qualname__}.format" +Axes._format_impl = Axes.format +Axes._format_signatures = {Axes: inspect.signature(Axes.format)} +Axes.format = docstring._obfuscate_kwargs(Axes.format) def _get_pos_from_locator( diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 766e3843d..aba370f89 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -4,7 +4,6 @@ """ import copy -import functools import inspect from dataclasses import dataclass, field from typing import Any, Dict, Optional, Tuple, Union @@ -1581,8 +1580,9 @@ def get(name): return _AxisFormatConfig(**config_kwargs) + @shared._format_wrapper(capture_explicit=True) @docstring._snippet_manager - def _format_impl( + def format( self, *, aspect=None, @@ -1860,25 +1860,10 @@ def get_tightbbox(self, renderer, *args, **kwargs): return super().get_tightbbox(renderer, *args, **kwargs) -def _capture_explicit_format_keys(func): - """ - Preserve raw keyword names before Python binds them to the format signature. - """ - - @functools.wraps(func) - def wrapper(self, *args, **kwargs): - kwargs.setdefault("_explicit_format_keys", set(kwargs)) - return func(self, *args, **kwargs) - - return wrapper - - -# tmp # Apply signature obfuscation after storing previous signature # NOTE: This is needed for __init__, altx, and alty +CartesianAxes._format_impl = CartesianAxes.format.__wrapped__ CartesianAxes._format_signatures[CartesianAxes] = inspect.signature( CartesianAxes._format_impl ) # noqa: E501 -CartesianAxes.format = shared._format_wrapper(CartesianAxes._format_impl) -CartesianAxes.format = _capture_explicit_format_keys(CartesianAxes.format) CartesianAxes.format = docstring._obfuscate_kwargs(CartesianAxes.format) diff --git a/ultraplot/axes/container.py b/ultraplot/axes/container.py index cfcd76e4c..84924b4b9 100644 --- a/ultraplot/axes/container.py +++ b/ultraplot/axes/container.py @@ -711,7 +711,8 @@ def clear(self): if self._external_axes is not None: self._external_axes.clear() - def _format_impl(self, **kwargs): + @shared._format_wrapper + def format(self, **kwargs): """ Format the container and delegate to external axes where appropriate. @@ -761,8 +762,6 @@ def _format_impl(self, **kwargs): if external_kwargs and self._external_axes is not None: self._external_axes.set(**external_kwargs) - format = shared._format_wrapper(_format_impl) - def draw(self, renderer): """Override draw to render container (with abc/titles) and external axes.""" # Draw external axes first - it may adjust its own position for labels @@ -873,6 +872,9 @@ def __dir__(self): return sorted(attrs) +ExternalAxesContainer._format_impl = ExternalAxesContainer.format.__wrapped__ + + def create_external_axes_container(external_axes_class, projection_name=None): """ Factory function to create a container class for a specific external axes type. diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index 36cd7548f..aee86b9e2 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -2955,8 +2955,9 @@ def _format_apply_ticklen( # 2) enter rc context and resolve label/locator/formatter inputs # 3) apply extent, features, and gridlines # 4) apply tick lengths and defer to parent format + @shared._format_wrapper(exclude=_format_sharing_exclude) @docstring._snippet_manager - def _format_impl( + def format( self, *, aspect: str | float | None = None, @@ -4752,13 +4753,8 @@ def _choropleth_edge_collection_kw( # Apply signature obfuscation after storing previous signature +GeoAxes._format_impl = GeoAxes.format.__wrapped__ GeoAxes._format_signatures[GeoAxes] = inspect.signature(GeoAxes._format_impl) -# Generic label style names affect geographic gridline labels, not Cartesian -# axis-title text, and therefore do not contradict xlabel/ylabel sharing. -GeoAxes.format = shared._format_wrapper( - GeoAxes._format_impl, - exclude=GeoAxes._format_sharing_exclude, -) GeoAxes.format = docstring._obfuscate_kwargs(GeoAxes.format) diff --git a/ultraplot/axes/polar.py b/ultraplot/axes/polar.py index 9122f94c4..443f5b106 100644 --- a/ultraplot/axes/polar.py +++ b/ultraplot/axes/polar.py @@ -499,8 +499,9 @@ def get_tightbbox(self, renderer, *args, **kwargs): self._refresh_polar_label_geometry("r") return super().get_tightbbox(renderer, *args, **kwargs) + @shared._format_wrapper @docstring._snippet_manager - def _format_impl( + def format( self, *, r0=None, @@ -743,6 +744,6 @@ def _format_impl( # Apply signature obfuscation after storing previous signature # NOTE: This is needed for __init__ +PolarAxes._format_impl = PolarAxes.format.__wrapped__ PolarAxes._format_signatures[PolarAxes] = inspect.signature(PolarAxes._format_impl) -PolarAxes.format = shared._format_wrapper(PolarAxes._format_impl) PolarAxes.format = docstring._obfuscate_kwargs(PolarAxes.format) diff --git a/ultraplot/axes/shared.py b/ultraplot/axes/shared.py index fd6fa3b9e..7423e0434 100644 --- a/ultraplot/axes/shared.py +++ b/ultraplot/axes/shared.py @@ -32,11 +32,19 @@ from typing_extensions import override -def _format_wrapper(method, *, exclude=()): - """Wrap a format implementation with explicit-user sharing updates.""" +def _format_wrapper(method=None, *, exclude=(), capture_explicit=False): + """Decorate a public format method with transactional sharing updates.""" + if method is None: + return lambda method: _format_wrapper( + method, + exclude=exclude, + capture_explicit=capture_explicit, + ) @functools.wraps(method) - def format(self, *args, **kwargs): + def wrapper(self, *args, **kwargs): + if capture_explicit: + kwargs.setdefault("_explicit_format_keys", set(kwargs)) validate_axis_format_values(kwargs) keys = { key @@ -55,9 +63,7 @@ def format(self, *args, **kwargs): restore_axis_sharing(figure, state) raise - format.__name__ = "format" - format.__qualname__ = f"{method.__qualname__.rsplit('.', 1)[0]}.format" - return format + return wrapper class _SharedAxes(object): diff --git a/ultraplot/axes/taylor.py b/ultraplot/axes/taylor.py index 302d083d4..713edaf3a 100644 --- a/ultraplot/axes/taylor.py +++ b/ultraplot/axes/taylor.py @@ -492,8 +492,9 @@ def draw(self, renderer=None, *args, **kwargs): self._update_taylor_std_ticklabels() super().draw(renderer, *args, **kwargs) + @shared._format_wrapper @docstring._snippet_manager - def _format_impl( + def format( self, *, xlabel=None, @@ -580,6 +581,6 @@ def _format_impl( self._update_taylor_std_ticklabels() +TaylorAxes._format_impl = TaylorAxes.format.__wrapped__ TaylorAxes._format_signatures[TaylorAxes] = inspect.signature(TaylorAxes._format_impl) -TaylorAxes.format = shared._format_wrapper(TaylorAxes._format_impl) TaylorAxes.format = docstring._obfuscate_kwargs(TaylorAxes.format) diff --git a/ultraplot/tests/test_format.py b/ultraplot/tests/test_format.py index 7517a6322..5e890a1c5 100644 --- a/ultraplot/tests/test_format.py +++ b/ultraplot/tests/test_format.py @@ -6,6 +6,55 @@ import locale, numpy as np, ultraplot as uplt, pytest import warnings + +def test_format_public_entry_point_owns_internal_implementation(): + """Classes define public format; private implementations are generated aliases.""" + from ultraplot.axes import ( + Axes, + CartesianAxes, + ExternalAxesContainer, + GeoAxes, + PolarAxes, + TaylorAxes, + ) + + for cls in ( + Axes, + CartesianAxes, + ExternalAxesContainer, + GeoAxes, + PolarAxes, + TaylorAxes, + ): + assert "format" in cls.__dict__ + assert cls.format.__name__ == "format" + assert cls.format.__qualname__ == f"{cls.__qualname__}.format" + assert cls.format.__doc__ == cls._format_impl.__doc__ + if cls is Axes: + assert cls._format_impl is cls.format + else: + assert cls._format_impl is cls.format.__wrapped__ + + fig, axs = uplt.subplots() + axs[0].format(title="Public format") + assert axs[0].get_title() == "Public format" + + +def test_super_format_uses_exact_base_implementation(): + """A base-qualified call cannot bypass sharing through dynamic dispatch.""" + from ultraplot.axes import CartesianAxes + + fig, axs = uplt.subplots(nrows=2, share=True) + before = fig.get_axis_sharing() + + with pytest.raises(TypeError, match="Unexpected keyword"): + super(CartesianAxes, axs[0]).format(xlim=(2, 3)) + + assert fig.get_axis_sharing() == before + assert axs[0].get_xlim() == (0, 1) + assert axs[1].get_xlim() == (0, 1) + + # def test_colormap_assign(): # """ # Test below line is possible and naming schemes. From 7c485e0bc310ee300b81c09dcaacc9f1a8ece701 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 4 Sep 2026 21:28:27 +1000 Subject: [PATCH 10/11] Use a single format entry point --- ultraplot/_sharing.py | 18 ++++++++++++++++++ ultraplot/axes/base.py | 1 - ultraplot/axes/cartesian.py | 13 +++++++------ ultraplot/axes/container.py | 5 +---- ultraplot/axes/geo.py | 5 ++--- ultraplot/axes/plot.py | 19 +++++++++++++------ ultraplot/axes/plot_types/ribbon.py | 13 ++++++++----- ultraplot/axes/polar.py | 5 ++--- ultraplot/axes/shared.py | 14 +++++++++++++- ultraplot/axes/taylor.py | 5 ++--- ultraplot/tests/test_format.py | 26 +++++++++++++++++++------- 11 files changed, 85 insertions(+), 39 deletions(-) diff --git a/ultraplot/_sharing.py b/ultraplot/_sharing.py index 39d7aff2e..bbe8620ac 100644 --- a/ultraplot/_sharing.py +++ b/ultraplot/_sharing.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 """Axis-sharing policy and format-plan construction.""" +import contextlib +import contextvars from dataclasses import dataclass from numbers import Integral @@ -117,6 +119,22 @@ ) _LIMIT_KEYS = {"xlim", "ylim", "lonlim", "latlim"} +_axis_sharing_updates = contextvars.ContextVar("axis_sharing_updates", default=True) + + +@contextlib.contextmanager +def preserve_axis_sharing(): + """Prevent automatic formatting from changing the declared sharing policy.""" + token = _axis_sharing_updates.set(False) + try: + yield + finally: + _axis_sharing_updates.reset(token) + + +def axis_sharing_updates_enabled(): + """Return whether the current format call may update axis sharing.""" + return _axis_sharing_updates.get() def get_axis_sharing_format_keys(*mappings, exclude=()): diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 39eb6d841..db1635a6f 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -4753,7 +4753,6 @@ def use_sticky_edges(self, value): # Apply signature obfuscation after storing previous signature # NOTE: This is needed for __init__ -Axes._format_impl = Axes.format Axes._format_signatures = {Axes: inspect.signature(Axes.format)} Axes.format = docstring._obfuscate_kwargs(Axes.format) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index aba370f89..229e35267 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -14,6 +14,7 @@ import numpy as np from packaging import version +from .. import _sharing as psharing from .. import constructor from .. import scale as pscale from .. import ticker as pticker @@ -717,9 +718,10 @@ def _add_alt(self, sx, **kwargs): self._twinned_axes.join(self, ax) # Format parent and child axes - self._format_impl( - **{f"{sx}loc": OPPOSITE_SIDE.get(kwargs[f"{sx}loc"], None)}, - ) + with psharing.preserve_axis_sharing(): + self.format( + **{f"{sx}loc": OPPOSITE_SIDE.get(kwargs[f"{sx}loc"], None)}, + ) setattr(ax, f"_alt{sx}_parent", self) getattr(ax, f"{sy}axis").set_visible(False) getattr(ax, "patch").set_visible(False) @@ -1783,7 +1785,7 @@ def format( or axis_format_requires_layout(explicit_format_keys) ) try: - super()._format_impl(rc_kw=rc_kw, rc_mode=rc_mode, **base_kwargs) + super().format(rc_kw=rc_kw, rc_mode=rc_mode, **base_kwargs) finally: if previous is sentinel: del self._format_layout_required @@ -1862,8 +1864,7 @@ def get_tightbbox(self, renderer, *args, **kwargs): # Apply signature obfuscation after storing previous signature # NOTE: This is needed for __init__, altx, and alty -CartesianAxes._format_impl = CartesianAxes.format.__wrapped__ CartesianAxes._format_signatures[CartesianAxes] = inspect.signature( - CartesianAxes._format_impl + CartesianAxes.format ) # noqa: E501 CartesianAxes.format = docstring._obfuscate_kwargs(CartesianAxes.format) diff --git a/ultraplot/axes/container.py b/ultraplot/axes/container.py index 84924b4b9..4a806e041 100644 --- a/ultraplot/axes/container.py +++ b/ultraplot/axes/container.py @@ -756,7 +756,7 @@ def format(self, **kwargs): # Apply container formatting (for ultraplot-specific features) if container_kwargs: - super()._format_impl(**container_kwargs) + super().format(**container_kwargs) # Apply external axes formatting if external_kwargs and self._external_axes is not None: @@ -872,9 +872,6 @@ def __dir__(self): return sorted(attrs) -ExternalAxesContainer._format_impl = ExternalAxesContainer.format.__wrapped__ - - def create_external_axes_container(external_axes_class, projection_name=None): """ Factory function to create a container class for a specific external axes type. diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index aee86b9e2..b6febdb60 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -3173,7 +3173,7 @@ def format( self._abc_anchor = abcanchor # Parent format method - super()._format_impl(rc_kw=rc_kw, rc_mode=rc_mode, **kwargs) + super().format(rc_kw=rc_kw, rc_mode=rc_mode, **kwargs) @docstring._snippet_manager def choropleth( @@ -4753,8 +4753,7 @@ def _choropleth_edge_collection_kw( # Apply signature obfuscation after storing previous signature -GeoAxes._format_impl = GeoAxes.format.__wrapped__ -GeoAxes._format_signatures[GeoAxes] = inspect.signature(GeoAxes._format_impl) +GeoAxes._format_signatures[GeoAxes] = inspect.signature(GeoAxes.format) GeoAxes.format = docstring._obfuscate_kwargs(GeoAxes.format) diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index 8d33f629f..57db6d223 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -32,6 +32,7 @@ from numpy.typing import ArrayLike from packaging import version +from .. import _sharing as psharing from .. import colors as pcolors from .. import constructor, utils from ..config import rc @@ -3994,7 +3995,8 @@ def _parse_1d_format( # pandas DataFrame specifically is passed to hist, boxplot, or violinplot, rows # of data assumed! Converting to ndarray necessary. if kw_format: - self._format_impl(**kw_format) + with psharing.preserve_axis_sharing(): + self.format(**kw_format) ys = tuple(map(inputs._to_numpy_array, ys)) if x is not None: # pie() and hist() x = inputs._to_numpy_array(x) @@ -4102,7 +4104,8 @@ def _parse_2d_format( # Apply formatting if kw_format: - self._format_impl(**kw_format) + with psharing.preserve_axis_sharing(): + self.format(**kw_format) # Apply title for legend or colorbar if autoguide and autoformat: @@ -5346,7 +5349,8 @@ def loglog(self, *args, **kwargs): """ objs = self._call_native("loglog", *args, **kwargs) if rc["formatter.log"]: - self._format_impl(xformatter="log", yformatter="log") + with psharing.preserve_axis_sharing(): + self.format(xformatter="log", yformatter="log") return objs @docstring._snippet_manager @@ -5357,7 +5361,8 @@ def semilogy(self, *args, **kwargs): objs = self._call_native("semilogy", *args, **kwargs) if rc["formatter.log"]: - self._format_impl(yformatter="log") + with psharing.preserve_axis_sharing(): + self.format(yformatter="log") return objs @docstring._snippet_manager @@ -5367,7 +5372,8 @@ def semilogx(self, *args, **kwargs): """ objs = self._call_native("semilogx", *args, **kwargs) if rc["formatter.log"]: - self._format_impl(xformatter="log") + with psharing.preserve_axis_sharing(): + self.format(xformatter="log") return objs @inputs._preprocess_or_redirect("x", "y", allow_extra=True) @@ -7438,7 +7444,8 @@ def heatmap(self, *args, aspect=None, **kwargs): kw["xtickminor"] = False if self.yaxis.isDefault_minloc: kw["ytickminor"] = False - self._format_impl(**kw) + with psharing.preserve_axis_sharing(): + self.format(**kw) return obj @inputs._preprocess_or_redirect("x", "y", "u", "v", ("c", "color", "colors")) diff --git a/ultraplot/axes/plot_types/ribbon.py b/ultraplot/axes/plot_types/ribbon.py index 6ec960bbd..ef654b1f8 100644 --- a/ultraplot/axes/plot_types/ribbon.py +++ b/ultraplot/axes/plot_types/ribbon.py @@ -14,6 +14,8 @@ from matplotlib import patches as mpatches from matplotlib import path as mpath +from ... import _sharing as psharing + def _ribbon_path( x0: float, @@ -329,11 +331,12 @@ def ribbon_diagram( ) period_text.append(text) - ax._format_impl( - xlim=(0, 1), - ylim=(0, 1), - grid=False, - ) + with psharing.preserve_axis_sharing(): + ax.format( + xlim=(0, 1), + ylim=(0, 1), + grid=False, + ) ax.axis("off") return { "node_patches": node_patches, diff --git a/ultraplot/axes/polar.py b/ultraplot/axes/polar.py index 443f5b106..460d2ad2f 100644 --- a/ultraplot/axes/polar.py +++ b/ultraplot/axes/polar.py @@ -739,11 +739,10 @@ def format( self._update_polar_label(kind, text, **kw) # Parent format method - super()._format_impl(rc_kw=rc_kw, rc_mode=rc_mode, **kwargs) + super().format(rc_kw=rc_kw, rc_mode=rc_mode, **kwargs) # Apply signature obfuscation after storing previous signature # NOTE: This is needed for __init__ -PolarAxes._format_impl = PolarAxes.format.__wrapped__ -PolarAxes._format_signatures[PolarAxes] = inspect.signature(PolarAxes._format_impl) +PolarAxes._format_signatures[PolarAxes] = inspect.signature(PolarAxes.format) PolarAxes.format = docstring._obfuscate_kwargs(PolarAxes.format) diff --git a/ultraplot/axes/shared.py b/ultraplot/axes/shared.py index 7423e0434..f7805d548 100644 --- a/ultraplot/axes/shared.py +++ b/ultraplot/axes/shared.py @@ -5,6 +5,7 @@ # NOTE: We could define these in base.py but idea is projection-specific formatters # should never be defined on the base class. Might add to this class later anyway. +import contextvars import functools import numpy as np @@ -13,6 +14,7 @@ from .._sharing import ( AXIS_LABEL_FORMAT_KEYS, axis_supports_format_key, + axis_sharing_updates_enabled, get_axis_sharing_format_keys, restore_axis_sharing, snapshot_axis_sharing, @@ -32,6 +34,9 @@ from typing_extensions import override +_active_format_axes = contextvars.ContextVar("active_format_axes", default=()) + + def _format_wrapper(method=None, *, exclude=(), capture_explicit=False): """Decorate a public format method with transactional sharing updates.""" if method is None: @@ -46,15 +51,20 @@ def wrapper(self, *args, **kwargs): if capture_explicit: kwargs.setdefault("_explicit_format_keys", set(kwargs)) validate_axis_format_values(kwargs) + active = _active_format_axes.get() + if any(ax is self for ax in active): + return method(self, *args, **kwargs) + keys = { key for key in get_axis_sharing_format_keys(kwargs, exclude=exclude) if axis_supports_format_key(self, key) } - if kwargs.get("skip_figure", False): + if not axis_sharing_updates_enabled() or kwargs.get("skip_figure", False): keys.clear() figure = self.figure state = snapshot_axis_sharing(figure) if figure is not None and keys else None + token = _active_format_axes.set((*active, self)) try: self._update_format_sharing(keys) return method(self, *args, **kwargs) @@ -62,6 +72,8 @@ def wrapper(self, *args, **kwargs): if state is not None: restore_axis_sharing(figure, state) raise + finally: + _active_format_axes.reset(token) return wrapper diff --git a/ultraplot/axes/taylor.py b/ultraplot/axes/taylor.py index 713edaf3a..9c7d74e07 100644 --- a/ultraplot/axes/taylor.py +++ b/ultraplot/axes/taylor.py @@ -566,7 +566,7 @@ def format( corrlabel_kw=corrlabel_kw, ) - super()._format_impl( + super().format( rc_kw=rc_kw, rc_mode=rc_mode, labelpad=labelpad, @@ -581,6 +581,5 @@ def format( self._update_taylor_std_ticklabels() -TaylorAxes._format_impl = TaylorAxes.format.__wrapped__ -TaylorAxes._format_signatures[TaylorAxes] = inspect.signature(TaylorAxes._format_impl) +TaylorAxes._format_signatures[TaylorAxes] = inspect.signature(TaylorAxes.format) TaylorAxes.format = docstring._obfuscate_kwargs(TaylorAxes.format) diff --git a/ultraplot/tests/test_format.py b/ultraplot/tests/test_format.py index 5e890a1c5..a62ef3469 100644 --- a/ultraplot/tests/test_format.py +++ b/ultraplot/tests/test_format.py @@ -7,8 +7,8 @@ import warnings -def test_format_public_entry_point_owns_internal_implementation(): - """Classes define public format; private implementations are generated aliases.""" +def test_format_is_the_only_entry_point(): + """Axes classes define and compose the public format method directly.""" from ultraplot.axes import ( Axes, CartesianAxes, @@ -27,13 +27,9 @@ def test_format_public_entry_point_owns_internal_implementation(): TaylorAxes, ): assert "format" in cls.__dict__ + assert not hasattr(cls, "_format_impl") assert cls.format.__name__ == "format" assert cls.format.__qualname__ == f"{cls.__qualname__}.format" - assert cls.format.__doc__ == cls._format_impl.__doc__ - if cls is Axes: - assert cls._format_impl is cls.format - else: - assert cls._format_impl is cls.format.__wrapped__ fig, axs = uplt.subplots() axs[0].format(title="Public format") @@ -55,6 +51,22 @@ def test_super_format_uses_exact_base_implementation(): assert axs[1].get_xlim() == (0, 1) +def test_nested_format_uses_one_sharing_transaction(monkeypatch): + """Projection inheritance composes format without repeating sharing updates.""" + fig, axs = uplt.subplots(proj="taylor") + calls = [] + update = axs[0]._update_format_sharing + + def record(keys): + calls.append(keys) + return update(keys) + + monkeypatch.setattr(axs[0], "_update_format_sharing", record) + axs[0].format(title="Taylor diagram") + + assert len(calls) == 1 + + # def test_colormap_assign(): # """ # Test below line is possible and naming schemes. From 64c09a101ca0662c06c849fe02277464ba2ec1de Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 4 Sep 2026 22:03:48 +1000 Subject: [PATCH 11/11] Restore manual sharing after format failures --- ultraplot/_sharing.py | 37 ++++++++++++++++++++++++++++++++++ ultraplot/tests/test_format.py | 15 ++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/ultraplot/_sharing.py b/ultraplot/_sharing.py index bbe8620ac..d78c4d354 100644 --- a/ultraplot/_sharing.py +++ b/ultraplot/_sharing.py @@ -233,8 +233,25 @@ def update_sharing_for_format_keys(figure, keys, *, axes=None): def snapshot_axis_sharing(figure): """Capture figure sharing policy so a failed format call can restore it.""" + axes = tuple( + dict.fromkeys(figure._iter_axes(hidden=True, children=True, panels=True)) + ) + axis_set = set(axes) state = {} for which in "xy": + seen = set() + topology = [] + for ax in axes: + if ax in seen: + continue + siblings = tuple( + sibling + for sibling in ax._shared_axes[which].get_siblings(ax) + if sibling in axis_set + ) + seen.update(siblings) + if len(siblings) > 1: + topology.append(siblings) state[which] = { name: getattr(figure, f"_share{which}_{name}") for name in ("labels", "limits", "ticklabels", "auto") @@ -249,6 +266,14 @@ def snapshot_axis_sharing(figure): } for key, group in figure._share_label_groups[which].items() } + state[which]["topology"] = topology + state[which]["axis_states"] = { + ax: { + "limits": getattr(ax, f"get_{which}lim")(), + "autoscale": getattr(ax, f"get_autoscale{which}_on")(), + } + for ax in axes + } return state @@ -261,6 +286,18 @@ def restore_axis_sharing(figure, state): setattr(figure, f"_share{which}_{name}", values[name]) figure._share_label_groups[which] = values["groups"] rebuild_axis_sharing(figure, which) + for siblings in values["topology"]: + anchor = siblings[0] + shared = anchor._shared_axes[which] + for ax in siblings[1:]: + if not shared.joined(anchor, ax): + ax._share_axis_with(anchor, which=which) + for ax, axis_state in values["axis_states"].items(): + getattr(ax, f"set_{which}lim")( + *axis_state["limits"], + emit=False, + auto=axis_state["autoscale"], + ) def rebuild_axis_sharing(figure, which): diff --git a/ultraplot/tests/test_format.py b/ultraplot/tests/test_format.py index a62ef3469..51cc8b3fe 100644 --- a/ultraplot/tests/test_format.py +++ b/ultraplot/tests/test_format.py @@ -610,6 +610,21 @@ def test_failed_scale_format_restores_sharing(syntax): assert axs[0].get_shared_x_axes().joined(axs[0], axs[1]) +def test_failed_format_restores_manual_sharing_and_limits(): + """Late failures restore manual grouper topology and pre-call limits.""" + fig, axs = uplt.subplots(nrows=2, share=0) + axs[1].sharex(axs[0]) + before = fig.get_axis_sharing() + limits = [ax.get_xlim() for ax in axs] + + with pytest.raises(ValueError): + axs[0].format(xlim=(2, 3), xtickloc="bogus") + + assert fig.get_axis_sharing() == before + assert axs[0].get_shared_x_axes().joined(axs[0], axs[1]) + assert [ax.get_xlim() for ax in axs] == limits + + def test_local_label_in_singleton_direction_preserves_sharing_state(): """A local label only reduces a direction with actual shared siblings.""" fig, axs = uplt.subplots(ncols=2, share=True)