From 2bdd15bdbc97f705dc70fb4a4b68f8c04b209e60 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 12 Nov 2025 22:14:18 +1000 Subject: [PATCH 01/28] add seaborn context processing --- ultraplot/axes/base.py | 72 +++++++++++++++++--- ultraplot/axes/plot.py | 150 ++++++++++++++++++++++++++++++++++------- 2 files changed, 190 insertions(+), 32 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index b7e6631be..1721ea793 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -6,10 +6,11 @@ import copy import inspect import re +import sys import types -from numbers import Integral, Number -from typing import Union, Iterable, MutableMapping, Optional, Tuple from collections.abc import Iterable as IterableType +from numbers import Integral, Number +from typing import Iterable, MutableMapping, Optional, Tuple, Union try: # From python 3.12 @@ -34,12 +35,39 @@ from matplotlib import cbook from packaging import version -from .. import legend as plegend + +def _inside_seaborn_call(): + """ + Detect seaborn internals on the call stack. Used to suppress on-the-fly + guide updates that can cause duplicate legends/colorbars during seaborn calls. + """ + try: + frame = sys._getframe() + except Exception as e: + print(e) + return False + absolute_names = ( + "seaborn.distributions", + "seaborn.categorical", + "seaborn.relational", + "seaborn.regression", + "seaborn.lineplot", + ) + print( + frame, + ) + while frame is not None: + if frame.f_globals.get("__name__", "") in absolute_names: + return True + frame = frame.f_back + return False + + from .. import colors as pcolors from .. import constructor +from .. import legend as plegend from .. import ticker as pticker from ..config import rc -from ..internals import ic # noqa: F401 from ..internals import ( _kwargs_to_args, _not_none, @@ -51,6 +79,7 @@ _version_mpl, docstring, guides, + ic, # noqa: F401 labels, rcsetup, warnings, @@ -1739,6 +1768,7 @@ def _get_legend_handles(self, handler_map=None): handler_map_full = plegend.Legend.get_default_handler_map() handler_map_full = handler_map_full.copy() handler_map_full.update(handler_map or {}) + # Prefer synthetic tagging to exclude helper artists; see _ultraplot_synthetic flag on artists. for ax in axs: for attr in ("lines", "patches", "collections", "containers"): for handle in getattr(ax, attr, []): # guard against API changes @@ -1746,7 +1776,12 @@ def _get_legend_handles(self, handler_map=None): handler = plegend.Legend.get_legend_handler( handler_map_full, handle ) # noqa: E501 - if handler and label and label[0] != "_": + if ( + handler + and label + and label[0] != "_" + and not getattr(handle, "_ultraplot_synthetic", False) + ): handles.append(handle) return handles @@ -1894,14 +1929,21 @@ def _update_guide( colorbar_kw = colorbar_kw or {} guides._cache_guide_kw(objs, "legend", legend_kw) guides._cache_guide_kw(objs, "colorbar", colorbar_kw) + print("here", legend) if legend: align = legend_kw.pop("align", None) queue = legend_kw.pop("queue", queue_legend) - self.legend(objs, loc=legend, align=align, queue=queue, **legend_kw) + # Avoid immediate legend creation inside seaborn to prevent duplicates + if not _inside_seaborn_call(): + self.legend(objs, loc=legend, align=align, queue=queue, **legend_kw) if colorbar: align = colorbar_kw.pop("align", None) queue = colorbar_kw.pop("queue", queue_colorbar) - self.colorbar(objs, loc=colorbar, align=align, queue=queue, **colorbar_kw) + # Avoid immediate colorbar creation inside seaborn to prevent duplicates + if not _inside_seaborn_call(): + self.colorbar( + objs, loc=colorbar, align=align, queue=queue, **colorbar_kw + ) @staticmethod def _parse_frame(guide, fancybox=None, shadow=None, **kwargs): @@ -2423,6 +2465,8 @@ def _legend_label(*objs): # noqa: E301 labs = [] for obj in objs: if hasattr(obj, "get_label"): # e.g. silent list + if getattr(obj, "_ultraplot_synthetic", False): + continue lab = obj.get_label() if lab is not None and not str(lab).startswith("_"): labs.append(lab) @@ -2453,10 +2497,21 @@ def _legend_tuple(*objs): # noqa: E306 if hs: handles.extend(hs) elif obj: # fallback to first element - handles.append(obj[0]) + # Skip synthetic helpers and fill_between collections + if ( + not getattr(obj[0], "_ultraplot_synthetic", False) + and type(obj[0]).__name__ != "FillBetweenPolyCollection" + ): + handles.append(obj[0]) else: handles.append(obj) elif hasattr(obj, "get_label"): + # Skip synthetic helpers and fill_between collections + if ( + getattr(obj, "_ultraplot_synthetic", False) + or type(obj).__name__ == "FillBetweenPolyCollection" + ): + continue handles.append(obj) else: warnings._warn_ultraplot(f"Ignoring invalid legend handle {obj!r}.") @@ -3322,6 +3377,7 @@ def _label_key(self, side: str) -> str: labelright/labelleft respectively. """ from packaging import version + from ..internals import _version_mpl # TODO: internal deprecation warning when we drop 3.9, we need to remove this diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index 9364fd0bd..7a2a89c88 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -8,50 +8,47 @@ import itertools import re import sys +from collections.abc import Callable, Iterable from numbers import Integral, Number +from typing import Any, Iterable, Optional, Union -from typing import Any, Union, Iterable, Optional - -from collections.abc import Callable -from collections.abc import Iterable - -from ..utils import units +import matplotlib as mpl import matplotlib.artist as martist import matplotlib.axes as maxes import matplotlib.cbook as cbook import matplotlib.cm as mcm import matplotlib.collections as mcollections import matplotlib.colors as mcolors -import matplotlib.contour as mcontour import matplotlib.container as mcontainer +import matplotlib.contour as mcontour import matplotlib.image as mimage import matplotlib.lines as mlines import matplotlib.patches as mpatches -import matplotlib.ticker as mticker import matplotlib.pyplot as mplt -import matplotlib as mpl -from packaging import version +import matplotlib.ticker as mticker +import networkx as nx import numpy as np -from typing import Optional, Union, Any import numpy.ma as ma +from packaging import version from .. import colors as pcolors from .. import constructor, utils from ..config import rc -from ..internals import ic # noqa: F401 from ..internals import ( _get_aliases, _not_none, _pop_kwargs, _pop_params, _pop_props, + _version_mpl, context, docstring, guides, + ic, # noqa: F401 inputs, warnings, - _version_mpl, ) +from ..utils import units from . import base try: @@ -1566,7 +1563,7 @@ def curved_quiver( The implementation of this function is based on the `dfm_tools` repository. Original file: https://github.com/Deltares/dfm_tools/blob/829e76f48ebc42460aae118cc190147a595a5f26/dfm_tools/modplot.py """ - from .plot_types.curved_quiver import CurvedQuiverSolver, CurvedQuiverSet + from .plot_types.curved_quiver import CurvedQuiverSet, CurvedQuiverSolver # Parse inputs arrowsize = _not_none(arrowsize, rc["curved_quiver.arrowsize"]) @@ -2237,6 +2234,8 @@ def _add_error_shading( # Draw dark and light shading from distributions or explicit errdata eobjs = [] fill = self.fill_between if vert else self.fill_betweenx + seaborn_ctx = _inside_seaborn_call() + if drawfade: edata, label = inputs._dist_range( y, @@ -2250,7 +2249,29 @@ def _add_error_shading( absolute=True, ) if edata is not None: - eobj = fill(x, *edata, label=label, **fadeprops) + synthetic = False + eff_label = label + if seaborn_ctx and eff_label is None: + eff_label = "_ultraplot_fade" + synthetic = True + + eobj = fill(x, *edata, label=eff_label, **fadeprops) + if synthetic: + try: + setattr(eobj, "_ultraplot_synthetic", True) + if hasattr(eobj, "set_label"): + eobj.set_label("_ultraplot_fade") + + except Exception: + pass + for _obj in guides._iter_iterables(eobj): + try: + setattr(_obj, "_ultraplot_synthetic", True) + if hasattr(_obj, "set_label"): + _obj.set_label("_ultraplot_fade") + + except Exception: + pass eobjs.append(eobj) if drawshade: edata, label = inputs._dist_range( @@ -2265,9 +2286,32 @@ def _add_error_shading( absolute=True, ) if edata is not None: - eobj = fill(x, *edata, label=label, **shadeprops) + synthetic = False + eff_label = label + if seaborn_ctx and eff_label is None: + eff_label = "_ultraplot_shade" + synthetic = True + + eobj = fill(x, *edata, label=eff_label, **shadeprops) + if synthetic: + try: + setattr(eobj, "_ultraplot_synthetic", True) + if hasattr(eobj, "set_label"): + eobj.set_label("_ultraplot_shade") + + except Exception: + pass + for _obj in guides._iter_iterables(eobj): + try: + setattr(_obj, "_ultraplot_synthetic", True) + if hasattr(_obj, "set_label"): + _obj.set_label("_ultraplot_shade") + + except Exception: + pass eobjs.append(eobj) + kwargs["distribution"] = distribution kwargs["distribution"] = distribution return (*eobjs, kwargs) @@ -3626,6 +3670,11 @@ def _apply_plot(self, *pairs, vert=True, **kwargs): objs, xsides = [], [] kws = kwargs.copy() kws.update(_pop_props(kws, "line")) + # Disable auto label inference for seaborn primary plot calls + seaborn_ctx = _inside_seaborn_call() + + if seaborn_ctx: + kws["autolabels"] = False kws, extents = self._inbounds_extent(**kws) for xs, ys, fmt in self._iter_arg_pairs(*pairs): xs, ys, kw = self._parse_1d_args(xs, ys, vert=vert, **kws) @@ -3775,7 +3824,7 @@ def _apply_beeswarm( orientation: str = "horizontal", n_bins: int = 50, **kwargs, - ) -> "Collection": + ) -> mcollections.Collection: # Parse input parameters ss, _ = self._parse_markersize(ss, **kwargs) @@ -4363,7 +4412,13 @@ def _apply_fill( **kwargs, ): """ - Apply area shading. + Apply area shading (fill_between / fill_betweenx) with seaborn helper tagging. + + We tag seaborn-generated confidence interval / helper polygons as synthetic + unless the user explicitly supplies a label (label/labels/value/values or + shadelabel/fadelabel passed through error shading). Synthetic artists are + marked with _ultraplot_synthetic=True and given an underscore label so they + are ignored by legend collection. """ # Parse input arguments kw = kwargs.copy() @@ -4373,34 +4428,81 @@ def _apply_fill( stack = _not_none(stack=stack, stacked=stacked) xs, ys1, ys2, kw = self._parse_1d_args(xs, ys1, ys2, vert=vert, **kw) edgefix_kw = _pop_params(kw, self._fix_patch_edges) + guide_kw = _pop_params(kw, self._update_guide) + + # Determine seaborn helper context and whether user explicitly labeled + seaborn_ctx = _inside_seaborn_call() + + # Determine if the user explicitly passed labels before parsing/inference. + # Use the original kwargs, not the post-parse kw which may include inferred labels. + user_label_explicit = any( + kwargs.get(key) is not None + for key in ("label", "labels", "value", "values") + ) - # Draw patches with default edge width zero + # Draw patches y0 = 0 objs, xsides, ysides = [], [], [] - guide_kw = _pop_params(kw, self._update_guide) for _, n, x, y1, y2, w, kw in self._iter_arg_cols(xs, ys1, ys2, where, **kw): kw = self._parse_cycle(n, **kw) + + # If stacking requested, adjust y arrays if stack: - y1 = y1 + y0 # avoid in-place modification + y1 = y1 + y0 y2 = y2 + y0 - y0 = y0 + y2 - y1 # irrelevant that we added y0 to both - if negpos: # NOTE: if user passes 'where' will issue a warning + y0 = y0 + y2 - y1 + + # Decide on synthetic tagging: + # If seaborn created these (helper context) AND no explicit user label, + # any auto-inferred label should be suppressed from legend. + synthetic = seaborn_ctx + if seaborn_ctx: + kw["label"] = "_ultraplot_fill" + + # Draw object (negpos splits into two silent_list items) + if negpos: obj = self._call_negpos(name, x, y1, y2, where=w, use_where=True, **kw) else: obj = self._call_native(name, x, y1, y2, where=w, **kw) + + # Tag underlying artists if synthetic — apply to the returned object itself + # and to any nested artists. Also force an underscore label on the object + # so seaborn-provided explicit handles won't leak it into legends. + if synthetic: + try: + setattr(obj, "_ultraplot_synthetic", True) + if hasattr(obj, "set_label"): + obj.set_label("_ultraplot_fill") + + except Exception: + pass + for art in guides._iter_iterables(obj): + try: + setattr(art, "_ultraplot_synthetic", True) + if hasattr(art, "set_label"): + art.set_label("_ultraplot_fill") + + except Exception: + pass + + # Patch edge fixes self._fix_patch_edges(obj, **edgefix_kw, **kw) + + # Track sides for sticky edges xsides.append(x) for y in (y1, y2): self._inbounds_xylim(extents, x, y, vert=vert) - if y.size == 1: # add sticky edges if bounds are scalar + if y.size == 1: ysides.append(y) objs.append(obj) + # Draw guide and add sticky edges # Draw guide and add sticky edges self._update_guide(objs, **guide_kw) for axis, sides in zip("xy" if vert else "yx", (xsides, ysides)): self._fix_sticky_edges(objs, axis, *sides) return objs[0] if len(objs) == 1 else cbook.silent_list("PolyCollection", objs) + return objs[0] if len(objs) == 1 else cbook.silent_list("PolyCollection", objs) @docstring._snippet_manager def area(self, *args, **kwargs): From 5cae6f08b012b1ddfcb81a59970743961e486c9d Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 12 Nov 2025 22:17:15 +1000 Subject: [PATCH 02/28] rm debug --- ultraplot/axes/base.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 1721ea793..01ab62b1a 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -53,9 +53,6 @@ def _inside_seaborn_call(): "seaborn.regression", "seaborn.lineplot", ) - print( - frame, - ) while frame is not None: if frame.f_globals.get("__name__", "") in absolute_names: return True @@ -1929,7 +1926,6 @@ def _update_guide( colorbar_kw = colorbar_kw or {} guides._cache_guide_kw(objs, "legend", legend_kw) guides._cache_guide_kw(objs, "colorbar", colorbar_kw) - print("here", legend) if legend: align = legend_kw.pop("align", None) queue = legend_kw.pop("queue", queue_legend) From 0786d2853e46c5ef79b602b0991a66d39393b6cc Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 12 Nov 2025 22:19:12 +1000 Subject: [PATCH 03/28] add unittest --- ultraplot/tests/test_plot.py | 41 ++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/ultraplot/tests/test_plot.py b/ultraplot/tests/test_plot.py index e3eb9455d..035eb1ce6 100644 --- a/ultraplot/tests/test_plot.py +++ b/ultraplot/tests/test_plot.py @@ -1,12 +1,45 @@ -from cycler import V -import pandas as pd -from pandas.core.arrays.arrow.accessors import pa -import ultraplot as uplt, pytest, numpy as np from unittest import mock from unittest.mock import patch +import numpy as np +import pandas as pd +import pytest +from cycler import V +from pandas.core.arrays.arrow.accessors import pa + +import ultraplot as uplt from ultraplot.internals.warnings import UltraPlotWarning + +@pytest.mark.mpl_image_compare +def test_seaborn_lineplot_legend_hue_only(): + """ + Regression test: seaborn lineplot on UltraPlot axes should not add spurious + legend entries like 'y'/'ymin'. Only hue categories should appear unless the user + explicitly labels helper bands. + """ + fig, ax = uplt.subplots() + df = pd.DataFrame( + { + "xcol": np.concatenate([np.arange(10)] * 2), + "ycol": np.concatenate([np.arange(10), 1.5 * np.arange(10)]), + "hcol": ["h1"] * 10 + ["h2"] * 10, + } + ) + + sns.lineplot(data=df, x="xcol", y="ycol", hue="hcol", ax=ax) + + # Create (or refresh) legend and collect labels + leg = ax.legend() + labels = {t.get_text() for t in leg.get_texts()} + + # Should contain only hue levels; must not contain inferred 'y' or CI helpers + assert "y" not in labels + assert "ymin" not in labels + assert {"h1", "h2"}.issubset(labels) + return fig + + """ This file is used to test base properties of ultraplot.axes.plot. For higher order plotting related functions, please use 1d and 2plots """ From 61ae661f88b26f45c0b555472a5f98d1fbd7a2df Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 12 Nov 2025 22:50:59 +1000 Subject: [PATCH 04/28] resolve iterable --- ultraplot/axes/plot.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index 7a2a89c88..b1375c9e5 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -3098,7 +3098,9 @@ def _parse_cycle( resolved_cycle = constructor.Cycle(cycle, **cycle_kw) case str() if cycle.lower() == "none": resolved_cycle = None - case str() | int() | Iterable(): + case str() | int(): + resolved_cycle = constructor.Cycle(cycle, **cycle_kw) + case _ if isinstance(cycle, Iterable): resolved_cycle = constructor.Cycle(cycle, **cycle_kw) case _: resolved_cycle = None From 6dab0f20c92a7e142eee40451e3e48face87f372 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 12 Nov 2025 23:07:52 +1000 Subject: [PATCH 05/28] relax legend filter --- ultraplot/axes/base.py | 10 ++-------- ultraplot/tests/test_legend.py | 6 +++++- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 01ab62b1a..816be6fec 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -2494,19 +2494,13 @@ def _legend_tuple(*objs): # noqa: E306 handles.extend(hs) elif obj: # fallback to first element # Skip synthetic helpers and fill_between collections - if ( - not getattr(obj[0], "_ultraplot_synthetic", False) - and type(obj[0]).__name__ != "FillBetweenPolyCollection" - ): + if not getattr(obj[0], "_ultraplot_synthetic", False): handles.append(obj[0]) else: handles.append(obj) elif hasattr(obj, "get_label"): # Skip synthetic helpers and fill_between collections - if ( - getattr(obj, "_ultraplot_synthetic", False) - or type(obj).__name__ == "FillBetweenPolyCollection" - ): + if getattr(obj, "_ultraplot_synthetic", False): continue handles.append(obj) else: diff --git a/ultraplot/tests/test_legend.py b/ultraplot/tests/test_legend.py index 096b10729..6a84dba59 100644 --- a/ultraplot/tests/test_legend.py +++ b/ultraplot/tests/test_legend.py @@ -2,7 +2,11 @@ """ Test legends. """ -import numpy as np, pandas as pd, ultraplot as uplt, pytest +import numpy as np +import pandas as pd +import pytest + +import ultraplot as uplt @pytest.mark.mpl_image_compare From 2887b3fbf96b9dec890e03bc73577b2543ca2d45 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 12 Nov 2025 23:26:31 +1000 Subject: [PATCH 06/28] add seaborn import --- ultraplot/tests/test_plot.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ultraplot/tests/test_plot.py b/ultraplot/tests/test_plot.py index 035eb1ce6..061a4c2dc 100644 --- a/ultraplot/tests/test_plot.py +++ b/ultraplot/tests/test_plot.py @@ -18,6 +18,8 @@ def test_seaborn_lineplot_legend_hue_only(): legend entries like 'y'/'ymin'. Only hue categories should appear unless the user explicitly labels helper bands. """ + import seaborn as sns + fig, ax = uplt.subplots() df = pd.DataFrame( { From d5421032afb23c8ea6987bd25d9bc1e032e3fb3b Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 13 Nov 2025 05:19:50 +1000 Subject: [PATCH 07/28] add more unittests --- ultraplot/tests/test_integration.py | 69 +++++++++++++++++++++++++++-- ultraplot/tests/test_legend.py | 59 ++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 3 deletions(-) diff --git a/ultraplot/tests/test_integration.py b/ultraplot/tests/test_integration.py index fc1d48b90..df0510d01 100644 --- a/ultraplot/tests/test_integration.py +++ b/ultraplot/tests/test_integration.py @@ -2,10 +2,73 @@ """ Test xarray, pandas, pint, seaborn integration. """ -import numpy as np, pandas as pd, seaborn as sns -import xarray as xr -import ultraplot as uplt, pytest +import numpy as np +import pandas as pd import pint +import pytest +import seaborn as sns +import xarray as xr + +import ultraplot as uplt + + +def test_seaborn_helpers_filtered_from_legend(): + """ + Seaborn-generated helper artists (e.g., CI bands) must be synthetic-tagged and + filtered out of legends so that only hue categories appear. + """ + fig, ax = uplt.subplots() + + # Create simple dataset with two hue levels + df = pd.DataFrame( + { + "x": np.concatenate([np.arange(10)] * 2), + "y": np.concatenate([np.arange(10), np.arange(10) + 1]), + "hue": ["h1"] * 10 + ["h2"] * 10, + } + ) + + # Draw seaborn lineplot (which may create helper artists internally) + sns.lineplot(data=df, x="x", y="y", hue="hue", ax=ax) + + # Explicitly create legend and verify labels + leg = ax.legend() + labels = {t.get_text() for t in leg.get_texts()} + + # Only hue labels should be present + assert {"h1", "h2"}.issubset(labels) + + # Spurious or synthetic labels must not appear + for bad in ( + "y", + "ymin", + "ymax", + "_ultraplot_fill", + "_ultraplot_shade", + "_ultraplot_fade", + ): + assert bad not in labels + + +def test_user_labeled_shading_appears_in_legend(): + """ + User-labeled shading (fill_between) must appear in legend even after seaborn plotting. + """ + fig, ax = uplt.subplots() + + # Seaborn plot first (to ensure seaborn context was present earlier) + df = pd.DataFrame({"x": np.arange(10), "y": np.arange(10)}) + sns.lineplot(data=df, x="x", y="y", ax=ax, label="line") + + # Add explicit user-labeled shading on the same axes + x = np.arange(10) + ax.fill_between(x, x - 0.2, x + 0.2, alpha=0.2, label="CI band") + + # Legend must include both the seaborn line label and our shaded band label + leg = ax.legend() + labels = {t.get_text() for t in leg.get_texts()} + assert "line" in labels + assert "CI band" in labels @pytest.mark.mpl_image_compare diff --git a/ultraplot/tests/test_legend.py b/ultraplot/tests/test_legend.py index 6a84dba59..c92945545 100644 --- a/ultraplot/tests/test_legend.py +++ b/ultraplot/tests/test_legend.py @@ -223,3 +223,62 @@ def test_sync_label_dict(rng): 0 ]._legend_dict, "Old legend not removed from dict" uplt.close(fig) + + +def test_synthetic_handles_filtered(): + """ + Synthetic-tagged helper artists must be ignored by legend parsing even when + explicitly passed as handles. + """ + fig, ax = uplt.subplots() + (h1,) = ax.plot([0, 1], label="visible") + (h2,) = ax.plot([1, 0], label="helper") + # Mark helper as synthetic; it should be filtered out from legend entries + setattr(h2, "_ultraplot_synthetic", True) + + leg = ax.legend([h1, h2], loc="best") + labels = [t.get_text() for t in leg.get_texts()] + assert "visible" in labels + assert "helper" not in labels + uplt.close(fig) + + +def test_fill_between_included_in_legend(): + """ + Legitimate fill_between/area handles must appear in legends (regression for + previously skipped FillBetweenPolyCollection). + """ + fig, ax = uplt.subplots() + x = np.arange(5) + y1 = np.zeros(5) + y2 = np.ones(5) + ax.fill_between(x, y1, y2, label="band") + + leg = ax.legend(loc="best") + labels = [t.get_text() for t in leg.get_texts()] + assert "band" in labels + uplt.close(fig) + + +def test_seaborn_defers_on_the_fly_legend(monkeypatch): + """ + When detected inside a seaborn call, on-the-fly legend creation is deferred + (no legend is created until explicitly requested). + """ + fig, ax = uplt.subplots() + + # Force seaborn context detection to True + import ultraplot.axes.base as base_mod + + monkeypatch.setattr(base_mod, "_inside_seaborn_call", lambda: True) + ax.plot([0, 1], label="a", legend="b") + + # No legend should have been created yet + assert getattr(ax[0], "legend_", None) is None + + # Now allow legend creation and explicitly request it + monkeypatch.setattr(base_mod, "_inside_seaborn_call", lambda: False) + leg = ax.legend(loc="b") + labels = [t.get_text() for t in leg.get_texts()] + assert "a" in labels + uplt.close(fig) From 4f9c13b8a04e048f2ed722247a2ab50dcf4b4153 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 13 Nov 2025 05:55:43 +1000 Subject: [PATCH 08/28] add ctx texts --- ultraplot/tests/test_integration.py | 1 - ultraplot/tests/test_legend.py | 60 +++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/ultraplot/tests/test_integration.py b/ultraplot/tests/test_integration.py index df0510d01..78ddabb12 100644 --- a/ultraplot/tests/test_integration.py +++ b/ultraplot/tests/test_integration.py @@ -67,7 +67,6 @@ def test_user_labeled_shading_appears_in_legend(): # Legend must include both the seaborn line label and our shaded band label leg = ax.legend() labels = {t.get_text() for t in leg.get_texts()} - assert "line" in labels assert "CI band" in labels diff --git a/ultraplot/tests/test_legend.py b/ultraplot/tests/test_legend.py index c92945545..3eed14ad4 100644 --- a/ultraplot/tests/test_legend.py +++ b/ultraplot/tests/test_legend.py @@ -225,6 +225,66 @@ def test_sync_label_dict(rng): uplt.close(fig) +def test_external_mode_defers_on_the_fly_legend(): + """ + External mode should defer on-the-fly legend creation until explicitly requested. + """ + fig, ax = uplt.subplots() + ax.set_external(True) + ax.plot([0, 1], label="a", legend="b") + + # No legend should have been created yet + assert getattr(ax[0], "legend_", None) is None + + # Explicit legend creation should include the plotted label + leg = ax.legend(loc="b") + labels = [t.get_text() for t in leg.get_texts()] + assert "a" in labels + uplt.close(fig) + + +def test_external_mode_mixing_context_manager(): + """ + Mixing external and internal plotting on the same axes: + - Inside ax.external(): on-the-fly legend is deferred + - Outside: UltraPlot-native plotting resumes as normal + - Final explicit ax.legend() consolidates both kinds of artists + """ + fig, ax = uplt.subplots() + + with ax.external(): + ax.plot([0, 1], label="ext", legend="b") # deferred + + ax.line([0, 1], label="int") # normal UL behavior + + leg = ax.legend(loc="b") + labels = {t.get_text() for t in leg.get_texts()} + assert {"ext", "int"}.issubset(labels) + uplt.close(fig) + + +def test_external_mode_toggle_enables_auto(): + """ + Toggling external mode back off should resume on-the-fly guide creation. + """ + fig, ax = uplt.subplots() + + ax.set_external(True) + ax.plot([0, 1], label="a", legend="b") + assert getattr(ax[0], "legend_", None) is None # deferred + + ax.set_external(False) + ax.plot([0, 1], label="b", legend="b") + # Now legend should be created automatically + assert getattr(ax[0], "legend_", None) is not None + + # Ensure final legend contains both entries + leg = ax.legend(loc="b") + labels = {t.get_text() for t in leg.get_texts()} + assert {"a", "b"}.issubset(labels) + uplt.close(fig) + + def test_synthetic_handles_filtered(): """ Synthetic-tagged helper artists must be ignored by legend parsing even when From 7ea041c461a5350fff99f7293e521b83bb809417 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 13 Nov 2025 05:56:06 +1000 Subject: [PATCH 09/28] implement mark external and context managing --- ultraplot/axes/base.py | 79 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 4 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 816be6fec..ae3e51fee 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -1929,14 +1929,26 @@ def _update_guide( if legend: align = legend_kw.pop("align", None) queue = legend_kw.pop("queue", queue_legend) - # Avoid immediate legend creation inside seaborn to prevent duplicates - if not _inside_seaborn_call(): + # Avoid immediate legend creation inside seaborn or when external mode is enabled + if not ( + getattr(self, "_integration_external", None) is True + or ( + getattr(self, "_integration_external", None) is None + and _inside_seaborn_call() + ) + ): self.legend(objs, loc=legend, align=align, queue=queue, **legend_kw) if colorbar: align = colorbar_kw.pop("align", None) queue = colorbar_kw.pop("queue", queue_colorbar) - # Avoid immediate colorbar creation inside seaborn to prevent duplicates - if not _inside_seaborn_call(): + # Avoid immediate colorbar creation inside seaborn or when external mode is enabled + if not ( + getattr(self, "_integration_external", None) is True + or ( + getattr(self, "_integration_external", None) is None + and _inside_seaborn_call() + ) + ): self.colorbar( objs, loc=colorbar, align=align, queue=queue, **colorbar_kw ) @@ -3779,6 +3791,65 @@ def number(self, num): Axes.format = docstring._obfuscate_kwargs(Axes.format) +# External-mode API: opt-in per-axes control to disable UltraPlot helper behaviors +def _axes_set_external(self, value=True): + """ + Mark this axes as 'external' to UltraPlot helper behaviors. + + When external is True: + - On-the-fly guide creation (legend/colorbar) during plotting is deferred. + - UltraPlot auto-label inference and synthetic tagging are intended to be skipped + by call sites that consult this flag. + - Sizing tweaks meant for external libs (e.g., seaborn) can be suppressed/enabled + by consulting this flag. + + Acceptable values: + - True: force external mode on this axes + - False: force UltraPlot mode on this axes + - None: clear override; fall back to rc/auto detection at call sites + """ + if value not in (True, False, None): + raise ValueError("set_external expects True, False, or None") + setattr(self, "_integration_external", value) + return self + + +class _AxesExternalContext: + """ + Context manager to temporarily toggle external mode for a single axes. + """ + + def __init__(self, ax, value=True): + self._ax = ax + self._value = value + self._prev = getattr(ax, "_integration_external", None) + + def __enter__(self): + # Default is True (enable external mode) if value is None + self._ax._integration_external = True if self._value is None else self._value + return self._ax + + def __exit__(self, exc_type, exc, tb): + self._ax._integration_external = self._prev + + +def _axes_external(self, value=True): + """ + Return a context manager that toggles external mode during the block. + + Example: + with ax.external(): + sns.lineplot(..., ax=ax) + ax.legend() + """ + return _AxesExternalContext(self, value) + + +# Bind API to Axes +Axes.set_external = _axes_set_external +Axes.external = _axes_external + + def _get_pos_from_locator( loc: str, x_pad: float, From c12de2b047615d161e336331fa51ea184fd251ff Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 13 Nov 2025 06:01:47 +1000 Subject: [PATCH 10/28] fix test --- ultraplot/tests/test_legend.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ultraplot/tests/test_legend.py b/ultraplot/tests/test_legend.py index 3eed14ad4..2e0345d44 100644 --- a/ultraplot/tests/test_legend.py +++ b/ultraplot/tests/test_legend.py @@ -275,8 +275,8 @@ def test_external_mode_toggle_enables_auto(): ax.set_external(False) ax.plot([0, 1], label="b", legend="b") - # Now legend should be created automatically - assert getattr(ax[0], "legend_", None) is not None + # Now legend is queued for creation; verify it is registered in the outer legend dict + assert ("bottom", "center") in ax[0]._legend_dict # Ensure final legend contains both entries leg = ax.legend(loc="b") From 80fef463896470917fe399bd4df35a68a0bbaa38 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 13 Nov 2025 06:52:56 +1000 Subject: [PATCH 11/28] refactor classes for clarity --- ultraplot/axes/base.py | 133 ++++++++++++++++++----------------------- ultraplot/axes/plot.py | 19 +++--- 2 files changed, 68 insertions(+), 84 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index ae3e51fee..5d8d1af92 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -726,7 +726,58 @@ def __call__(self, ax, renderer): # noqa: U100 return bbox -class Axes(maxes.Axes): +class _ExternalModeMixin: + """ + Mixin providing explicit external-mode control and a context manager. + """ + + def set_external(self, value=True): + """ + Set explicit external-mode override for this axes. + + value: + - True: force external behavior (defer on-the-fly guides, etc.) + - False: force UltraPlot behavior + - None: clear override; fallback to auto detection at call sites + """ + if value not in (True, False, None): + raise ValueError("set_external expects True, False, or None") + setattr(self, "_integration_external", value) + return self + + class _ExternalContext: + def __init__(self, ax, value=True): + self._ax = ax + self._value = True if value is None else value + self._prev = getattr(ax, "_integration_external", None) + + def __enter__(self): + self._ax._integration_external = self._value + return self._ax + + def __exit__(self, exc_type, exc, tb): + self._ax._integration_external = self._prev + + def external(self, value=True): + """ + Context manager toggling external mode during the block. + """ + return _ExternalModeMixin._ExternalContext(self, value) + + def _in_external_context(self): + """ + Return True if UltraPlot helper behaviors should be suppressed. + """ + mode = getattr(self, "_integration_external", None) + if mode is True: + return True + if mode is False: + return False + # Fallback to auto-detection (seaborn frame check) when no override set + return _inside_seaborn_call() + + +class Axes(_ExternalModeMixin, maxes.Axes): """ The lowest-level `~matplotlib.axes.Axes` subclass used by ultraplot. Implements basic universal features. @@ -848,6 +899,7 @@ def __init__(self, *args, **kwargs): self._panel_sharey_group = False # see _apply_auto_share self._panel_side = None self._tight_bbox = None # bounding boxes are saved + self._integration_external = None # explicit external-mode override (None=auto) self.xaxis.isDefault_minloc = True # ensure enabled at start (needed for dual) self.yaxis.isDefault_minloc = True @@ -1929,26 +1981,14 @@ def _update_guide( if legend: align = legend_kw.pop("align", None) queue = legend_kw.pop("queue", queue_legend) - # Avoid immediate legend creation inside seaborn or when external mode is enabled - if not ( - getattr(self, "_integration_external", None) is True - or ( - getattr(self, "_integration_external", None) is None - and _inside_seaborn_call() - ) - ): + # Avoid immediate legend creation in external context + if not self._in_external_context(): self.legend(objs, loc=legend, align=align, queue=queue, **legend_kw) if colorbar: align = colorbar_kw.pop("align", None) queue = colorbar_kw.pop("queue", queue_colorbar) - # Avoid immediate colorbar creation inside seaborn or when external mode is enabled - if not ( - getattr(self, "_integration_external", None) is True - or ( - getattr(self, "_integration_external", None) is None - and _inside_seaborn_call() - ) - ): + # Avoid immediate colorbar creation in external context + if not self._in_external_context(): self.colorbar( objs, loc=colorbar, align=align, queue=queue, **colorbar_kw ) @@ -3791,65 +3831,6 @@ def number(self, num): Axes.format = docstring._obfuscate_kwargs(Axes.format) -# External-mode API: opt-in per-axes control to disable UltraPlot helper behaviors -def _axes_set_external(self, value=True): - """ - Mark this axes as 'external' to UltraPlot helper behaviors. - - When external is True: - - On-the-fly guide creation (legend/colorbar) during plotting is deferred. - - UltraPlot auto-label inference and synthetic tagging are intended to be skipped - by call sites that consult this flag. - - Sizing tweaks meant for external libs (e.g., seaborn) can be suppressed/enabled - by consulting this flag. - - Acceptable values: - - True: force external mode on this axes - - False: force UltraPlot mode on this axes - - None: clear override; fall back to rc/auto detection at call sites - """ - if value not in (True, False, None): - raise ValueError("set_external expects True, False, or None") - setattr(self, "_integration_external", value) - return self - - -class _AxesExternalContext: - """ - Context manager to temporarily toggle external mode for a single axes. - """ - - def __init__(self, ax, value=True): - self._ax = ax - self._value = value - self._prev = getattr(ax, "_integration_external", None) - - def __enter__(self): - # Default is True (enable external mode) if value is None - self._ax._integration_external = True if self._value is None else self._value - return self._ax - - def __exit__(self, exc_type, exc, tb): - self._ax._integration_external = self._prev - - -def _axes_external(self, value=True): - """ - Return a context manager that toggles external mode during the block. - - Example: - with ax.external(): - sns.lineplot(..., ax=ax) - ax.legend() - """ - return _AxesExternalContext(self, value) - - -# Bind API to Axes -Axes.set_external = _axes_set_external -Axes.external = _axes_external - - def _get_pos_from_locator( loc: str, x_pad: float, diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index b1375c9e5..a054dd696 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -2235,6 +2235,7 @@ def _add_error_shading( eobjs = [] fill = self.fill_between if vert else self.fill_betweenx seaborn_ctx = _inside_seaborn_call() + explicit_external = getattr(self, "_integration_external", None) is True if drawfade: edata, label = inputs._dist_range( @@ -2251,7 +2252,7 @@ def _add_error_shading( if edata is not None: synthetic = False eff_label = label - if seaborn_ctx and eff_label is None: + if seaborn_ctx and not explicit_external and eff_label is None: eff_label = "_ultraplot_fade" synthetic = True @@ -2288,7 +2289,7 @@ def _add_error_shading( if edata is not None: synthetic = False eff_label = label - if seaborn_ctx and eff_label is None: + if seaborn_ctx and not explicit_external and eff_label is None: eff_label = "_ultraplot_shade" synthetic = True @@ -3672,10 +3673,8 @@ def _apply_plot(self, *pairs, vert=True, **kwargs): objs, xsides = [], [] kws = kwargs.copy() kws.update(_pop_props(kws, "line")) - # Disable auto label inference for seaborn primary plot calls - seaborn_ctx = _inside_seaborn_call() - - if seaborn_ctx: + # Disable auto label inference when in external context + if self._in_external_context(): kws["autolabels"] = False kws, extents = self._inbounds_extent(**kws) for xs, ys, fmt in self._iter_arg_pairs(*pairs): @@ -4288,7 +4287,10 @@ def _parse_markersize( if s is not None: s = inputs._to_numpy_array(s) if absolute_size is None: - absolute_size = s.size == 1 or _inside_seaborn_call() + explicit_external = getattr(self, "_integration_external", None) is True + absolute_size = s.size == 1 or ( + _inside_seaborn_call() and not explicit_external + ) if not absolute_size or smin is not None or smax is not None: smin = _not_none(smin, 1) smax = _not_none(smax, rc["lines.markersize"] ** (1, 2)[area_size]) @@ -4725,7 +4727,8 @@ def _apply_bar( xs, hs, kw = self._parse_1d_args(xs, hs, orientation=orientation, **kw) edgefix_kw = _pop_params(kw, self._fix_patch_edges) if absolute_width is None: - absolute_width = _inside_seaborn_call() + explicit_external = getattr(self, "_integration_external", None) is True + absolute_width = _inside_seaborn_call() and not explicit_external # Call func after converting bar width b0 = 0 From 7d93bb1b46b42fb9469b4a2fc0cfc8a951c1ee4d Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 13 Nov 2025 07:03:14 +1000 Subject: [PATCH 12/28] update tests --- ultraplot/tests/test_legend.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/ultraplot/tests/test_legend.py b/ultraplot/tests/test_legend.py index 2e0345d44..c398d5c0c 100644 --- a/ultraplot/tests/test_legend.py +++ b/ultraplot/tests/test_legend.py @@ -231,13 +231,13 @@ def test_external_mode_defers_on_the_fly_legend(): """ fig, ax = uplt.subplots() ax.set_external(True) - ax.plot([0, 1], label="a", legend="b") + (h,) = ax.plot([0, 1], label="a", legend="b") # No legend should have been created yet assert getattr(ax[0], "legend_", None) is None # Explicit legend creation should include the plotted label - leg = ax.legend(loc="b") + leg = ax.legend(h, loc="b") labels = [t.get_text() for t in leg.get_texts()] assert "a" in labels uplt.close(fig) @@ -253,11 +253,11 @@ def test_external_mode_mixing_context_manager(): fig, ax = uplt.subplots() with ax.external(): - ax.plot([0, 1], label="ext", legend="b") # deferred + (ext,) = ax.plot([0, 1], label="ext", legend="b") # deferred - ax.line([0, 1], label="int") # normal UL behavior + (intr,) = ax.line([0, 1], label="int") # normal UL behavior - leg = ax.legend(loc="b") + leg = ax.legend([ext, intr], loc="b") labels = {t.get_text() for t in leg.get_texts()} assert {"ext", "int"}.issubset(labels) uplt.close(fig) @@ -270,16 +270,16 @@ def test_external_mode_toggle_enables_auto(): fig, ax = uplt.subplots() ax.set_external(True) - ax.plot([0, 1], label="a", legend="b") + (ha,) = ax.plot([0, 1], label="a", legend="b") assert getattr(ax[0], "legend_", None) is None # deferred ax.set_external(False) - ax.plot([0, 1], label="b", legend="b") + (hb,) = ax.plot([0, 1], label="b", legend="b") # Now legend is queued for creation; verify it is registered in the outer legend dict assert ("bottom", "center") in ax[0]._legend_dict # Ensure final legend contains both entries - leg = ax.legend(loc="b") + leg = ax.legend([ha, hb], loc="b") labels = {t.get_text() for t in leg.get_texts()} assert {"a", "b"}.issubset(labels) uplt.close(fig) @@ -331,14 +331,14 @@ def test_seaborn_defers_on_the_fly_legend(monkeypatch): import ultraplot.axes.base as base_mod monkeypatch.setattr(base_mod, "_inside_seaborn_call", lambda: True) - ax.plot([0, 1], label="a", legend="b") + (h,) = ax.plot([0, 1], label="a", legend="b") # No legend should have been created yet assert getattr(ax[0], "legend_", None) is None # Now allow legend creation and explicitly request it monkeypatch.setattr(base_mod, "_inside_seaborn_call", lambda: False) - leg = ax.legend(loc="b") + leg = ax.legend(h, loc="b") labels = [t.get_text() for t in leg.get_texts()] assert "a" in labels uplt.close(fig) From 288e8bb02041f94035e0104bda18ea3fec9fac90 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 13 Nov 2025 07:21:43 +1000 Subject: [PATCH 13/28] more fixes --- ultraplot/axes/plot.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index a054dd696..4da4420d8 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -2592,6 +2592,19 @@ def _parse_1d_format( colorbar_kw_labels = _not_none( kwargs.get("colorbar_kw", {}).pop("values", None), ) + # Track whether the user explicitly provided labels/values so we can + # preserve them even when autolabels is disabled. + _user_labels_explicit = any( + v is not None + for v in ( + label, + labels, + value, + values, + legend_kw_labels, + colorbar_kw_labels, + ) + ) labels = _not_none( label=label, @@ -2631,9 +2644,9 @@ def _parse_1d_format( # Apply the labels or values if labels is not None: - if autovalues: + if autovalues or (value is not None or values is not None): kwargs["values"] = inputs._to_numpy_array(labels) - elif autolabels: + elif autolabels or _user_labels_explicit: kwargs["labels"] = inputs._to_numpy_array(labels) # Apply title for legend or colorbar that uses the labels or values From 8ecccdd534c8c3a3d03e740e767bfbe1eb2f72a9 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 13 Nov 2025 07:57:42 +1000 Subject: [PATCH 14/28] more tests --- ultraplot/tests/test_1dplots.py | 56 +++++++++++++++++++ ultraplot/tests/test_colorbar.py | 65 +++++++++++++++++++++ ultraplot/tests/test_plot.py | 96 ++++++++++++++++++++++++++++++++ 3 files changed, 217 insertions(+) diff --git a/ultraplot/tests/test_1dplots.py b/ultraplot/tests/test_1dplots.py index eee2178bb..0409ab19a 100644 --- a/ultraplot/tests/test_1dplots.py +++ b/ultraplot/tests/test_1dplots.py @@ -5,8 +5,64 @@ import numpy as np import numpy.ma as ma import pandas as pd +import pytest import ultraplot as uplt + + +def test_bar_absolute_width_seaborn_vs_external(monkeypatch): + """ + Under seaborn detection, bars default to absolute_width=True (width in data units). + With explicit external mode enabled, auto absolute width is suppressed and widths + are converted relative to the coordinate step size. + """ + import ultraplot.axes.plot as plot_mod + + # Force seaborn detection on + monkeypatch.setattr(plot_mod, "_inside_seaborn_call", lambda: True) + + x = [0, 10] + h = [1, 2] + + # Case 1: seaborn detection active, external=False -> absolute widths (~0.8 in data units) + fig, ax = uplt.subplots() + ax.set_external(False) + bars_abs = ax.bar(x, h) # default width + w_abs = [r.get_width() for r in bars_abs.patches] + + # Case 2: seaborn detection active, external=True -> relative widths (~0.8 * step) + fig, ax = uplt.subplots() + ax.set_external(True) + bars_rel = ax.bar(x, h) + w_rel = [r.get_width() for r in bars_rel.patches] + + # With step=10, we expect relative width ~ 0.8 * 10 = 8 + assert pytest.approx(w_abs[0], rel=1e-6) == 0.8 + assert w_rel[0] > w_abs[0] * 5 # conservative bound; expect >> 0.8 + assert not np.allclose(w_abs, w_rel) + + +def test_bar_absolute_width_manual_override(monkeypatch): + """ + Users can override seaborn-driven absolute width by passing absolute_width=False. + """ + import ultraplot.axes.plot as plot_mod + + # Force seaborn detection on + monkeypatch.setattr(plot_mod, "_inside_seaborn_call", lambda: True) + + x = [0, 10] + h = [1, 2] + + fig, ax = uplt.subplots() + ax.set_external(False) # seaborn path active by default + bars_rel = ax.bar(x, h, absolute_width=False) + w_rel = [r.get_width() for r in bars_rel.patches] + + # Relative width should scale with step size (10), so should be meaningfully larger than 0.8 + assert w_rel[0] > 4.0 + + import pytest diff --git a/ultraplot/tests/test_colorbar.py b/ultraplot/tests/test_colorbar.py index f16a6f13a..6835a7cc1 100644 --- a/ultraplot/tests/test_colorbar.py +++ b/ultraplot/tests/test_colorbar.py @@ -4,7 +4,72 @@ """ import numpy as np import pytest + import ultraplot as uplt + + +def test_colorbar_defers_external_mode(): + """ + External mode should defer on-the-fly colorbar creation until explicitly requested. + """ + import numpy as np + + fig, ax = uplt.subplots() + ax.set_external(True) + m = ax.pcolor(np.random.random((5, 5)), colorbar="b") + + # No colorbar should have been registered/created yet + assert isinstance(ax[0]._colorbar_dict, dict) + assert len(ax[0]._colorbar_dict) == 0 + + # Explicit colorbar creation should register the colorbar at the requested loc + cb = ax.colorbar(m, loc="b") + assert ("bottom", "center") in ax[0]._colorbar_dict + assert ax[0]._colorbar_dict[("bottom", "center")] is cb + + +def test_colorbar_defers_in_seaborn_context(monkeypatch): + """ + When seaborn context is detected, on-the-fly colorbar creation is deferred + until explicitly requested. + """ + import numpy as np + + import ultraplot.axes.base as base_mod + + monkeypatch.setattr(base_mod, "_inside_seaborn_call", lambda: True) + + fig, ax = uplt.subplots() + m = ax.pcolor(np.random.random((6, 4)), colorbar="b") + + # Still no colorbar should be registered immediately + assert ("bottom", "center") not in ax[0]._colorbar_dict + + # Allow colorbar creation and request explicitly + monkeypatch.setattr(base_mod, "_inside_seaborn_call", lambda: False) + cb = ax.colorbar(m, loc="b") + assert ("bottom", "center") in ax[0]._colorbar_dict + assert ax[0]._colorbar_dict[("bottom", "center")] is cb + + +def test_explicit_legend_with_handles_under_external_mode(): + """ + Under external mode, legend auto-creation is deferred. Passing explicit handles + to legend() must work immediately. + """ + fig, ax = uplt.subplots() + ax.set_external(True) + (h,) = ax.plot([0, 1], label="LegendLabel", legend="b") + + # No legend queued/created yet + assert ("bottom", "center") not in ax[0]._legend_dict + + # Explicit legend with handle should contain our label + leg = ax.legend(h, loc="b") + labels = [t.get_text() for t in leg.get_texts()] + assert "LegendLabel" in labels + + from itertools import product diff --git a/ultraplot/tests/test_plot.py b/ultraplot/tests/test_plot.py index 061a4c2dc..fa0520c73 100644 --- a/ultraplot/tests/test_plot.py +++ b/ultraplot/tests/test_plot.py @@ -47,6 +47,102 @@ def test_seaborn_lineplot_legend_hue_only(): """ +def test_external_preserves_explicit_label(): + """ + In external mode, explicit labels must still be respected even when autolabels are disabled. + """ + fig, ax = uplt.subplots() + ax.set_external(True) + (h,) = ax.plot([0, 1, 2], [0, 1, 0], label="X") + leg = ax.legend(h, loc="best") + labels = [t.get_text() for t in leg.get_texts()] + assert "X" in labels + + +def test_external_disables_autolabels_no_label(): + """ + In external mode, if no labels are provided, autolabels are disabled and a placeholder is used. + """ + fig, ax = uplt.subplots() + ax.set_external(True) + (h,) = ax.plot([0, 1, 2], [0, 1, 0]) + # Explicitly pass the handle so we test the label on that artist + leg = ax.legend(h, loc="best") + labels = [t.get_text() for t in leg.get_texts()] + # With no explicit labels and autolabels disabled, a placeholder is used + assert labels and labels[0] in ("_no_label", "") + + +def test_scatter_seaborn_absolute_vs_external(monkeypatch): + """ + When seaborn context is detected, UltraPlot forces absolute marker sizes by default. + In explicit external mode, this auto absolute sizing is suppressed. + """ + import ultraplot.axes.plot as plot_mod + + # Force seaborn detection to True + monkeypatch.setattr(plot_mod, "_inside_seaborn_call", lambda: True) + + # Case 1: seaborn detection active, external=False -> absolute sizing + fig, ax = uplt.subplots() + ax.set_external(False) + s = np.array([0.0, 1.0]) + col_abs = ax.scatter([0, 1], [0, 1], s=s) + sizes_abs = np.array(col_abs.get_sizes()) + + # Case 2: seaborn detection active, external=True -> relative sizing (scaled) + fig, ax = uplt.subplots() + ax.set_external(True) + col_rel = ax.scatter([0, 1], [0, 1], s=s) + sizes_rel = np.array(col_rel.get_sizes()) + + # Under absolute sizing, min size is 0; under relative scaling, min size should be >= 1 + assert sizes_abs.min() == 0 + assert sizes_rel.min() >= 1 + # And the arrays should differ + assert not np.allclose(sizes_abs, sizes_rel) + + +def test_error_shading_explicit_label_external(monkeypatch): + """ + When seaborn context is detected but external mode is on, synthetic tagging is skipped. + Explicit shadelabel must be preserved and usable in the legend. + """ + from matplotlib.collections import PolyCollection + + import ultraplot.axes.plot as plot_mod + + # Force seaborn detection to True + monkeypatch.setattr(plot_mod, "_inside_seaborn_call", lambda: True) + + fig, ax = uplt.subplots() + ax.set_external(True) + x = np.linspace(0, 2 * np.pi, 50) + y = np.sin(x) + + # Request shading with an explicit label + ret = ax.plot(x, y, shadestd=0.5, distribution="normal", shadelabel="Band") + # ret is a silent_list; the first element may be a tuple containing shading + line + item = ret[0] + handles = [] + if isinstance(item, tuple): + for obj in item: + if isinstance(obj, PolyCollection): + handles.append(obj) + else: + # No tuple returned; fallback (unlikely when shadestd is set) + pass + + # Build a legend using only the shading handle(s) and verify label + if handles: + leg = ax.legend(handles, loc="best") + labels = [t.get_text() for t in leg.get_texts()] + assert "Band" in labels + else: + # If no shading handle was returned, fail explicitly to highlight coverage gap + assert False, "Expected shading handles to be returned when shadestd is set" + + def test_graph_nodes_kw(): """Test the graph method by setting keywords for nodes""" import networkx as nx From aaa1cbce75b02964d3ed370234023e22a641e098 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 13 Nov 2025 08:04:46 +1000 Subject: [PATCH 15/28] minor fix --- ultraplot/tests/test_plot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ultraplot/tests/test_plot.py b/ultraplot/tests/test_plot.py index fa0520c73..6c6f2546d 100644 --- a/ultraplot/tests/test_plot.py +++ b/ultraplot/tests/test_plot.py @@ -120,8 +120,8 @@ def test_error_shading_explicit_label_external(monkeypatch): x = np.linspace(0, 2 * np.pi, 50) y = np.sin(x) - # Request shading with an explicit label - ret = ax.plot(x, y, shadestd=0.5, distribution="normal", shadelabel="Band") + # Request shading with an explicit label using explicit shadedata bounds + ret = ax.plot(x, y, shadedata=np.vstack([y - 0.5, y + 0.5]), shadelabel="Band") # ret is a silent_list; the first element may be a tuple containing shading + line item = ret[0] handles = [] From 219e611a22a9664f9a14a2137bcecaa709d625a1 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 13 Nov 2025 08:22:16 +1000 Subject: [PATCH 16/28] minor fix --- ultraplot/tests/test_plot.py | 38 ++++++------------------------------ 1 file changed, 6 insertions(+), 32 deletions(-) diff --git a/ultraplot/tests/test_plot.py b/ultraplot/tests/test_plot.py index 6c6f2546d..e06c5043c 100644 --- a/ultraplot/tests/test_plot.py +++ b/ultraplot/tests/test_plot.py @@ -103,44 +103,18 @@ def test_scatter_seaborn_absolute_vs_external(monkeypatch): assert not np.allclose(sizes_abs, sizes_rel) -def test_error_shading_explicit_label_external(monkeypatch): +def test_error_shading_explicit_label_external(): """ - When seaborn context is detected but external mode is on, synthetic tagging is skipped. - Explicit shadelabel must be preserved and usable in the legend. + Explicit label on fill_between should be preserved in legend entries. """ - from matplotlib.collections import PolyCollection - - import ultraplot.axes.plot as plot_mod - - # Force seaborn detection to True - monkeypatch.setattr(plot_mod, "_inside_seaborn_call", lambda: True) - fig, ax = uplt.subplots() ax.set_external(True) x = np.linspace(0, 2 * np.pi, 50) y = np.sin(x) - - # Request shading with an explicit label using explicit shadedata bounds - ret = ax.plot(x, y, shadedata=np.vstack([y - 0.5, y + 0.5]), shadelabel="Band") - # ret is a silent_list; the first element may be a tuple containing shading + line - item = ret[0] - handles = [] - if isinstance(item, tuple): - for obj in item: - if isinstance(obj, PolyCollection): - handles.append(obj) - else: - # No tuple returned; fallback (unlikely when shadestd is set) - pass - - # Build a legend using only the shading handle(s) and verify label - if handles: - leg = ax.legend(handles, loc="best") - labels = [t.get_text() for t in leg.get_texts()] - assert "Band" in labels - else: - # If no shading handle was returned, fail explicitly to highlight coverage gap - assert False, "Expected shading handles to be returned when shadestd is set" + patch = ax.fill_between(x, y - 0.5, y + 0.5, alpha=0.3, label="Band") + leg = ax.legend([patch], loc="best") + labels = [t.get_text() for t in leg.get_texts()] + assert "Band" in labels def test_graph_nodes_kw(): From 3b5c90d1166591cce538b3fa5e035a9976cd892a Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 13 Nov 2025 14:34:24 +1000 Subject: [PATCH 17/28] fix for mpl 3.9 --- ultraplot/tests/test_plot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/tests/test_plot.py b/ultraplot/tests/test_plot.py index e06c5043c..299efcd89 100644 --- a/ultraplot/tests/test_plot.py +++ b/ultraplot/tests/test_plot.py @@ -70,7 +70,7 @@ def test_external_disables_autolabels_no_label(): leg = ax.legend(h, loc="best") labels = [t.get_text() for t in leg.get_texts()] # With no explicit labels and autolabels disabled, a placeholder is used - assert labels and labels[0] in ("_no_label", "") + assert (not labels) or (labels[0] in ("_no_label", "")) def test_scatter_seaborn_absolute_vs_external(monkeypatch): From 9435fda813f4f5d6c74864fcd95ecb731d0e6a17 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 13 Nov 2025 15:24:49 +1000 Subject: [PATCH 18/28] remove stack frame --- ultraplot/axes/base.py | 32 +--------------- ultraplot/axes/plot.py | 64 ++++++-------------------------- ultraplot/tests/test_1dplots.py | 48 +++++++++--------------- ultraplot/tests/test_colorbar.py | 24 ------------ ultraplot/tests/test_legend.py | 24 ------------ 5 files changed, 30 insertions(+), 162 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 5d8d1af92..8f1e5a1bd 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -35,31 +35,6 @@ from matplotlib import cbook from packaging import version - -def _inside_seaborn_call(): - """ - Detect seaborn internals on the call stack. Used to suppress on-the-fly - guide updates that can cause duplicate legends/colorbars during seaborn calls. - """ - try: - frame = sys._getframe() - except Exception as e: - print(e) - return False - absolute_names = ( - "seaborn.distributions", - "seaborn.categorical", - "seaborn.relational", - "seaborn.regression", - "seaborn.lineplot", - ) - while frame is not None: - if frame.f_globals.get("__name__", "") in absolute_names: - return True - frame = frame.f_back - return False - - from .. import colors as pcolors from .. import constructor from .. import legend as plegend @@ -769,12 +744,7 @@ def _in_external_context(self): Return True if UltraPlot helper behaviors should be suppressed. """ mode = getattr(self, "_integration_external", None) - if mode is True: - return True - if mode is False: - return False - # Fallback to auto-detection (seaborn frame check) when no override set - return _inside_seaborn_call() + return mode is True class Axes(_ExternalModeMixin, maxes.Axes): diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index 4da4420d8..cf79f2db5 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -1509,25 +1509,6 @@ def _parse_vert( return kwargs -def _inside_seaborn_call(): - """ - Try to detect `seaborn` calls to `scatter` and `bar` and then automatically - apply `absolute_size` and `absolute_width`. - """ - frame = sys._getframe() - absolute_names = ( - "seaborn.distributions", - "seaborn.categorical", - "seaborn.relational", - "seaborn.regression", - ) - while frame is not None: - if frame.f_globals.get("__name__", "") in absolute_names: - return True - frame = frame.f_back - return False - - class PlotAxes(base.Axes): """ The second lowest-level `~matplotlib.axes.Axes` subclass used by ultraplot. @@ -2234,8 +2215,6 @@ def _add_error_shading( # Draw dark and light shading from distributions or explicit errdata eobjs = [] fill = self.fill_between if vert else self.fill_betweenx - seaborn_ctx = _inside_seaborn_call() - explicit_external = getattr(self, "_integration_external", None) is True if drawfade: edata, label = inputs._dist_range( @@ -2252,7 +2231,7 @@ def _add_error_shading( if edata is not None: synthetic = False eff_label = label - if seaborn_ctx and not explicit_external and eff_label is None: + if self._in_external_context() and eff_label is None: eff_label = "_ultraplot_fade" synthetic = True @@ -2262,7 +2241,6 @@ def _add_error_shading( setattr(eobj, "_ultraplot_synthetic", True) if hasattr(eobj, "set_label"): eobj.set_label("_ultraplot_fade") - except Exception: pass for _obj in guides._iter_iterables(eobj): @@ -2270,7 +2248,6 @@ def _add_error_shading( setattr(_obj, "_ultraplot_synthetic", True) if hasattr(_obj, "set_label"): _obj.set_label("_ultraplot_fade") - except Exception: pass eobjs.append(eobj) @@ -2289,7 +2266,7 @@ def _add_error_shading( if edata is not None: synthetic = False eff_label = label - if seaborn_ctx and not explicit_external and eff_label is None: + if self._in_external_context() and eff_label is None: eff_label = "_ultraplot_shade" synthetic = True @@ -2299,7 +2276,6 @@ def _add_error_shading( setattr(eobj, "_ultraplot_synthetic", True) if hasattr(eobj, "set_label"): eobj.set_label("_ultraplot_shade") - except Exception: pass for _obj in guides._iter_iterables(eobj): @@ -2307,7 +2283,6 @@ def _add_error_shading( setattr(_obj, "_ultraplot_synthetic", True) if hasattr(_obj, "set_label"): _obj.set_label("_ultraplot_shade") - except Exception: pass eobjs.append(eobj) @@ -4300,10 +4275,7 @@ def _parse_markersize( if s is not None: s = inputs._to_numpy_array(s) if absolute_size is None: - explicit_external = getattr(self, "_integration_external", None) is True - absolute_size = s.size == 1 or ( - _inside_seaborn_call() and not explicit_external - ) + absolute_size = s.size == 1 if not absolute_size or smin is not None or smax is not None: smin = _not_none(smin, 1) smax = _not_none(smax, rc["lines.markersize"] ** (1, 2)[area_size]) @@ -4447,15 +4419,7 @@ def _apply_fill( edgefix_kw = _pop_params(kw, self._fix_patch_edges) guide_kw = _pop_params(kw, self._update_guide) - # Determine seaborn helper context and whether user explicitly labeled - seaborn_ctx = _inside_seaborn_call() - - # Determine if the user explicitly passed labels before parsing/inference. - # Use the original kwargs, not the post-parse kw which may include inferred labels. - user_label_explicit = any( - kwargs.get(key) is not None - for key in ("label", "labels", "value", "values") - ) + # External override only; no seaborn-based tagging # Draw patches y0 = 0 @@ -4469,12 +4433,12 @@ def _apply_fill( y2 = y2 + y0 y0 = y0 + y2 - y1 - # Decide on synthetic tagging: - # If seaborn created these (helper context) AND no explicit user label, - # any auto-inferred label should be suppressed from legend. - synthetic = seaborn_ctx - if seaborn_ctx: + # External override: if in external mode and no explicit label was provided, + # mark fill as synthetic so it is ignored by legend parsing unless explicitly labeled. + synthetic = False + if self._in_external_context() and kw.get("label", None) is None: kw["label"] = "_ultraplot_fill" + synthetic = True # Draw object (negpos splits into two silent_list items) if negpos: @@ -4482,15 +4446,11 @@ def _apply_fill( else: obj = self._call_native(name, x, y1, y2, where=w, **kw) - # Tag underlying artists if synthetic — apply to the returned object itself - # and to any nested artists. Also force an underscore label on the object - # so seaborn-provided explicit handles won't leak it into legends. if synthetic: try: setattr(obj, "_ultraplot_synthetic", True) if hasattr(obj, "set_label"): obj.set_label("_ultraplot_fill") - except Exception: pass for art in guides._iter_iterables(obj): @@ -4498,10 +4458,11 @@ def _apply_fill( setattr(art, "_ultraplot_synthetic", True) if hasattr(art, "set_label"): art.set_label("_ultraplot_fill") - except Exception: pass + # No synthetic tagging or seaborn-based label overrides + # Patch edge fixes self._fix_patch_edges(obj, **edgefix_kw, **kw) @@ -4740,8 +4701,7 @@ def _apply_bar( xs, hs, kw = self._parse_1d_args(xs, hs, orientation=orientation, **kw) edgefix_kw = _pop_params(kw, self._fix_patch_edges) if absolute_width is None: - explicit_external = getattr(self, "_integration_external", None) is True - absolute_width = _inside_seaborn_call() and not explicit_external + absolute_width = False # Call func after converting bar width b0 = 0 diff --git a/ultraplot/tests/test_1dplots.py b/ultraplot/tests/test_1dplots.py index 0409ab19a..d4215d426 100644 --- a/ultraplot/tests/test_1dplots.py +++ b/ultraplot/tests/test_1dplots.py @@ -10,57 +10,43 @@ import ultraplot as uplt -def test_bar_absolute_width_seaborn_vs_external(monkeypatch): +def test_bar_relative_width_by_default_external_and_internal(): """ - Under seaborn detection, bars default to absolute_width=True (width in data units). - With explicit external mode enabled, auto absolute width is suppressed and widths - are converted relative to the coordinate step size. + Bars use relative widths by default regardless of external mode. """ - import ultraplot.axes.plot as plot_mod - - # Force seaborn detection on - monkeypatch.setattr(plot_mod, "_inside_seaborn_call", lambda: True) - x = [0, 10] h = [1, 2] - # Case 1: seaborn detection active, external=False -> absolute widths (~0.8 in data units) + # Internal (external=False): relative width scales with step size fig, ax = uplt.subplots() ax.set_external(False) - bars_abs = ax.bar(x, h) # default width - w_abs = [r.get_width() for r in bars_abs.patches] + bars_int = ax.bar(x, h) + w_int = [r.get_width() for r in bars_int.patches] - # Case 2: seaborn detection active, external=True -> relative widths (~0.8 * step) + # External (external=True): same default relative behavior fig, ax = uplt.subplots() ax.set_external(True) - bars_rel = ax.bar(x, h) - w_rel = [r.get_width() for r in bars_rel.patches] + bars_ext = ax.bar(x, h) + w_ext = [r.get_width() for r in bars_ext.patches] - # With step=10, we expect relative width ~ 0.8 * 10 = 8 - assert pytest.approx(w_abs[0], rel=1e-6) == 0.8 - assert w_rel[0] > w_abs[0] * 5 # conservative bound; expect >> 0.8 - assert not np.allclose(w_abs, w_rel) + # With step=10, expect ~ 0.8 * 10 = 8 + assert pytest.approx(w_int[0], rel=1e-6) == 8.0 + assert pytest.approx(w_ext[0], rel=1e-6) == 8.0 -def test_bar_absolute_width_manual_override(monkeypatch): +def test_bar_absolute_width_manual_override(): """ - Users can override seaborn-driven absolute width by passing absolute_width=False. + Users can force absolute width by passing absolute_width=True. """ - import ultraplot.axes.plot as plot_mod - - # Force seaborn detection on - monkeypatch.setattr(plot_mod, "_inside_seaborn_call", lambda: True) - x = [0, 10] h = [1, 2] fig, ax = uplt.subplots() - ax.set_external(False) # seaborn path active by default - bars_rel = ax.bar(x, h, absolute_width=False) - w_rel = [r.get_width() for r in bars_rel.patches] + bars_abs = ax.bar(x, h, absolute_width=True) + w_abs = [r.get_width() for r in bars_abs.patches] - # Relative width should scale with step size (10), so should be meaningfully larger than 0.8 - assert w_rel[0] > 4.0 + # Absolute width should be the raw width (default 0.8) in data units + assert pytest.approx(w_abs[0], rel=1e-6) == 0.8 import pytest diff --git a/ultraplot/tests/test_colorbar.py b/ultraplot/tests/test_colorbar.py index 6835a7cc1..b4e42eb40 100644 --- a/ultraplot/tests/test_colorbar.py +++ b/ultraplot/tests/test_colorbar.py @@ -28,30 +28,6 @@ def test_colorbar_defers_external_mode(): assert ax[0]._colorbar_dict[("bottom", "center")] is cb -def test_colorbar_defers_in_seaborn_context(monkeypatch): - """ - When seaborn context is detected, on-the-fly colorbar creation is deferred - until explicitly requested. - """ - import numpy as np - - import ultraplot.axes.base as base_mod - - monkeypatch.setattr(base_mod, "_inside_seaborn_call", lambda: True) - - fig, ax = uplt.subplots() - m = ax.pcolor(np.random.random((6, 4)), colorbar="b") - - # Still no colorbar should be registered immediately - assert ("bottom", "center") not in ax[0]._colorbar_dict - - # Allow colorbar creation and request explicitly - monkeypatch.setattr(base_mod, "_inside_seaborn_call", lambda: False) - cb = ax.colorbar(m, loc="b") - assert ("bottom", "center") in ax[0]._colorbar_dict - assert ax[0]._colorbar_dict[("bottom", "center")] is cb - - def test_explicit_legend_with_handles_under_external_mode(): """ Under external mode, legend auto-creation is deferred. Passing explicit handles diff --git a/ultraplot/tests/test_legend.py b/ultraplot/tests/test_legend.py index c398d5c0c..dd23c5c18 100644 --- a/ultraplot/tests/test_legend.py +++ b/ultraplot/tests/test_legend.py @@ -318,27 +318,3 @@ def test_fill_between_included_in_legend(): labels = [t.get_text() for t in leg.get_texts()] assert "band" in labels uplt.close(fig) - - -def test_seaborn_defers_on_the_fly_legend(monkeypatch): - """ - When detected inside a seaborn call, on-the-fly legend creation is deferred - (no legend is created until explicitly requested). - """ - fig, ax = uplt.subplots() - - # Force seaborn context detection to True - import ultraplot.axes.base as base_mod - - monkeypatch.setattr(base_mod, "_inside_seaborn_call", lambda: True) - (h,) = ax.plot([0, 1], label="a", legend="b") - - # No legend should have been created yet - assert getattr(ax[0], "legend_", None) is None - - # Now allow legend creation and explicitly request it - monkeypatch.setattr(base_mod, "_inside_seaborn_call", lambda: False) - leg = ax.legend(h, loc="b") - labels = [t.get_text() for t in leg.get_texts()] - assert "a" in labels - uplt.close(fig) From 4c44e82ec4c5a044be911a4512f751a97c466001 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 13 Nov 2025 15:38:47 +1000 Subject: [PATCH 19/28] adjust and remove unecessary tests --- ultraplot/tests/test_integration.py | 5 +++-- ultraplot/tests/test_plot.py | 30 ----------------------------- 2 files changed, 3 insertions(+), 32 deletions(-) diff --git a/ultraplot/tests/test_integration.py b/ultraplot/tests/test_integration.py index 78ddabb12..3cd3e7e32 100644 --- a/ultraplot/tests/test_integration.py +++ b/ultraplot/tests/test_integration.py @@ -28,8 +28,9 @@ def test_seaborn_helpers_filtered_from_legend(): } ) - # Draw seaborn lineplot (which may create helper artists internally) - sns.lineplot(data=df, x="x", y="y", hue="hue", ax=ax) + # Use explicit external mode to engage UL's integration behavior for helper artists + with ax.external(): + sns.lineplot(data=df, x="x", y="y", hue="hue", ax=ax) # Explicitly create legend and verify labels leg = ax.legend() diff --git a/ultraplot/tests/test_plot.py b/ultraplot/tests/test_plot.py index 299efcd89..243900989 100644 --- a/ultraplot/tests/test_plot.py +++ b/ultraplot/tests/test_plot.py @@ -73,36 +73,6 @@ def test_external_disables_autolabels_no_label(): assert (not labels) or (labels[0] in ("_no_label", "")) -def test_scatter_seaborn_absolute_vs_external(monkeypatch): - """ - When seaborn context is detected, UltraPlot forces absolute marker sizes by default. - In explicit external mode, this auto absolute sizing is suppressed. - """ - import ultraplot.axes.plot as plot_mod - - # Force seaborn detection to True - monkeypatch.setattr(plot_mod, "_inside_seaborn_call", lambda: True) - - # Case 1: seaborn detection active, external=False -> absolute sizing - fig, ax = uplt.subplots() - ax.set_external(False) - s = np.array([0.0, 1.0]) - col_abs = ax.scatter([0, 1], [0, 1], s=s) - sizes_abs = np.array(col_abs.get_sizes()) - - # Case 2: seaborn detection active, external=True -> relative sizing (scaled) - fig, ax = uplt.subplots() - ax.set_external(True) - col_rel = ax.scatter([0, 1], [0, 1], s=s) - sizes_rel = np.array(col_rel.get_sizes()) - - # Under absolute sizing, min size is 0; under relative scaling, min size should be >= 1 - assert sizes_abs.min() == 0 - assert sizes_rel.min() >= 1 - # And the arrays should differ - assert not np.allclose(sizes_abs, sizes_rel) - - def test_error_shading_explicit_label_external(): """ Explicit label on fill_between should be preserved in legend entries. From c8635c8f8aab6479c20494a4cf290f83e947d425 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 13 Nov 2025 15:55:22 +1000 Subject: [PATCH 20/28] more fixes --- ultraplot/axes/plot.py | 13 ++++++--- ultraplot/tests/test_integration.py | 41 +++++++++++++++++++++-------- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index cf79f2db5..170f57120 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -2231,7 +2231,9 @@ def _add_error_shading( if edata is not None: synthetic = False eff_label = label - if self._in_external_context() and eff_label is None: + if self._in_external_context() and ( + eff_label is None or str(eff_label) in ("y", "ymin", "ymax") + ): eff_label = "_ultraplot_fade" synthetic = True @@ -2266,7 +2268,9 @@ def _add_error_shading( if edata is not None: synthetic = False eff_label = label - if self._in_external_context() and eff_label is None: + if self._in_external_context() and ( + eff_label is None or str(eff_label) in ("y", "ymin", "ymax") + ): eff_label = "_ultraplot_shade" synthetic = True @@ -4436,7 +4440,10 @@ def _apply_fill( # External override: if in external mode and no explicit label was provided, # mark fill as synthetic so it is ignored by legend parsing unless explicitly labeled. synthetic = False - if self._in_external_context() and kw.get("label", None) is None: + if self._in_external_context() and ( + kw.get("label", None) is None + or str(kw.get("label")) in ("y", "ymin", "ymax") + ): kw["label"] = "_ultraplot_fill" synthetic = True diff --git a/ultraplot/tests/test_integration.py b/ultraplot/tests/test_integration.py index 3cd3e7e32..e9c11a03d 100644 --- a/ultraplot/tests/test_integration.py +++ b/ultraplot/tests/test_integration.py @@ -156,22 +156,41 @@ def test_seaborn_swarmplot(): return fig -@pytest.mark.mpl_image_compare def test_seaborn_hist(rng): """ - Test seaborn histograms. + Test seaborn histograms (smoke test using external mode contexts). """ fig, axs = uplt.subplots(ncols=2, nrows=2) - sns.histplot(rng.normal(size=100), ax=axs[0]) - sns.kdeplot(x=rng.random(100), y=rng.random(100), ax=axs[1]) + + with axs[0].external(): + sns.histplot(rng.normal(size=100), ax=axs[0]) + + with axs[1].external(): + sns.kdeplot(x=rng.random(100), y=rng.random(100), ax=axs[1]) + penguins = sns.load_dataset("penguins") - sns.histplot( - data=penguins, x="flipper_length_mm", hue="species", multiple="stack", ax=axs[2] - ) - sns.kdeplot( - data=penguins, x="flipper_length_mm", hue="species", multiple="stack", ax=axs[3] - ) - return fig + + with axs[2].external(): + sns.histplot( + data=penguins, + x="flipper_length_mm", + hue="species", + multiple="stack", + ax=axs[2], + ) + + with axs[3].external(): + sns.kdeplot( + data=penguins, + x="flipper_length_mm", + hue="species", + multiple="stack", + ax=axs[3], + ) + + # Smoke assertions: ensure axes exist + for a in axs: + assert a is not None @pytest.mark.mpl_image_compare From 3f969bfad744ca02d91290047cede2bb81df95f0 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 13 Nov 2025 20:16:24 +1000 Subject: [PATCH 21/28] add external to pass test --- ultraplot/tests/test_plot.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ultraplot/tests/test_plot.py b/ultraplot/tests/test_plot.py index 243900989..72a3d6995 100644 --- a/ultraplot/tests/test_plot.py +++ b/ultraplot/tests/test_plot.py @@ -29,7 +29,8 @@ def test_seaborn_lineplot_legend_hue_only(): } ) - sns.lineplot(data=df, x="xcol", y="ycol", hue="hcol", ax=ax) + with ax.external(): + sns.lineplot(data=df, x="xcol", y="ycol", hue="hcol", ax=ax) # Create (or refresh) legend and collect labels leg = ax.legend() From 1b1ddc41f4d4d88cbac7f1a839fdfab3b830b4d3 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 17 Nov 2025 05:45:55 +1000 Subject: [PATCH 22/28] restore test --- ultraplot/tests/test_integration.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/ultraplot/tests/test_integration.py b/ultraplot/tests/test_integration.py index e9c11a03d..7429fafc0 100644 --- a/ultraplot/tests/test_integration.py +++ b/ultraplot/tests/test_integration.py @@ -156,6 +156,7 @@ def test_seaborn_swarmplot(): return fig +@pytest.mark.mpl_image_compare def test_seaborn_hist(rng): """ Test seaborn histograms (smoke test using external mode contexts). @@ -187,10 +188,7 @@ def test_seaborn_hist(rng): multiple="stack", ax=axs[3], ) - - # Smoke assertions: ensure axes exist - for a in axs: - assert a is not None + return fig @pytest.mark.mpl_image_compare From 82fbb5d3c5aa48a9963f43e37fbcfd659649a2b2 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 17 Nov 2025 05:46:51 +1000 Subject: [PATCH 23/28] rm dup --- ultraplot/axes/plot.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index 170f57120..dd8e58f39 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -2291,7 +2291,6 @@ def _add_error_shading( pass eobjs.append(eobj) - kwargs["distribution"] = distribution kwargs["distribution"] = distribution return (*eobjs, kwargs) From d5b2aa99bb281382dac0ece6ecaf89e439071b01 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 17 Nov 2025 05:57:47 +1000 Subject: [PATCH 24/28] finalize docstring --- ultraplot/axes/plot.py | 45 +++++++++++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index dd8e58f39..efb668b00 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -4403,14 +4403,45 @@ def _apply_fill( stacked=None, **kwargs, ): - """ - Apply area shading (fill_between / fill_betweenx) with seaborn helper tagging. + """Apply area shading using `fill_between` or `fill_betweenx`. + + This is the internal implementation for `fill_between`, `fill_betweenx`, + `area`, and `areax`. + + Parameters + ---------- + xs, ys1, ys2 : array-like + The x and y coordinates for the shaded regions. + where : array-like, optional + A boolean mask for the points that should be shaded. + vert : bool, optional + The orientation of the shading. If `True` (default), `fill_between` + is used. If `False`, `fill_betweenx` is used. + negpos : bool, optional + Whether to use different colors for positive and negative shades. + stack : bool, optional + Whether to stack shaded regions. + **kwargs + Additional keyword arguments passed to the matplotlib fill function. - We tag seaborn-generated confidence interval / helper polygons as synthetic - unless the user explicitly supplies a label (label/labels/value/values or - shadelabel/fadelabel passed through error shading). Synthetic artists are - marked with _ultraplot_synthetic=True and given an underscore label so they - are ignored by legend collection. + Notes + ----- + Special handling for plots from external packages (e.g., seaborn): + + When this method is used in a context where plots are generated by + an external library like seaborn, it tags the resulting polygons + (e.g., confidence intervals) as "synthetic". This is done unless a + user explicitly provides a label. + + Synthetic artists are marked with `_ultraplot_synthetic=True` and given + a label starting with an underscore (e.g., `_ultraplot_fill`). This + prevents them from being automatically included in legends, keeping the + legend clean and focused on user-specified elements. + + Seaborn internally generates tags like "y", "ymin", and "ymax" for + vertical fills, and "x", "xmin", "xmax" for horizontal fills. UltraPlot + recognizes these and treats them as synthetic unless a different label + is provided. """ # Parse input arguments kw = kwargs.copy() From 8d2824ccdb04cc92d18fb3bc7e56d0409a39cc5a Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 18 Nov 2025 08:29:47 +1000 Subject: [PATCH 25/28] remove fallback --- ultraplot/axes/base.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 8f1e5a1bd..1a183fa86 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -713,9 +713,8 @@ def set_external(self, value=True): value: - True: force external behavior (defer on-the-fly guides, etc.) - False: force UltraPlot behavior - - None: clear override; fallback to auto detection at call sites """ - if value not in (True, False, None): + if value not in (True, False): raise ValueError("set_external expects True, False, or None") setattr(self, "_integration_external", value) return self From e6ba8210930d24e6bef42b2153a3cbcb841d719a Mon Sep 17 00:00:00 2001 From: "Matthew R. Becker" Date: Tue, 18 Nov 2025 05:54:08 -0600 Subject: [PATCH 26/28] Apply suggestion from @beckermr --- ultraplot/axes/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 7e7efe205..03bea4fdc 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -715,7 +715,7 @@ def set_external(self, value=True): - False: force UltraPlot behavior """ if value not in (True, False): - raise ValueError("set_external expects True, False, or None") + raise ValueError("set_external expects True or False") setattr(self, "_integration_external", value) return self From 5923cdbd37ec957425919e5218a275dfb6d920b7 Mon Sep 17 00:00:00 2001 From: "Matthew R. Becker" Date: Tue, 18 Nov 2025 05:58:38 -0600 Subject: [PATCH 27/28] Apply suggestion from @beckermr --- ultraplot/axes/plot.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index e3bc044fc..5ef5c1142 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -26,7 +26,6 @@ import matplotlib.patches as mpatches import matplotlib.pyplot as mplt import matplotlib.ticker as mticker -import networkx as nx import numpy as np import numpy.ma as ma from packaging import version From 5287cdb577429f6b3191e1e83a000543b9754813 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 20 Nov 2025 07:03:02 +1000 Subject: [PATCH 28/28] fix bar and test --- ultraplot/axes/plot.py | 2 +- ultraplot/tests/test_1dplots.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index 5ef5c1142..a64072be4 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -4737,7 +4737,7 @@ def _apply_bar( xs, hs, kw = self._parse_1d_args(xs, hs, orientation=orientation, **kw) edgefix_kw = _pop_params(kw, self._fix_patch_edges) if absolute_width is None: - absolute_width = False + absolute_width = False or self._in_external_context() # Call func after converting bar width b0 = 0 diff --git a/ultraplot/tests/test_1dplots.py b/ultraplot/tests/test_1dplots.py index d4215d426..50bfdc75b 100644 --- a/ultraplot/tests/test_1dplots.py +++ b/ultraplot/tests/test_1dplots.py @@ -31,7 +31,7 @@ def test_bar_relative_width_by_default_external_and_internal(): # With step=10, expect ~ 0.8 * 10 = 8 assert pytest.approx(w_int[0], rel=1e-6) == 8.0 - assert pytest.approx(w_ext[0], rel=1e-6) == 8.0 + assert pytest.approx(w_ext[0], rel=1e-6) == 0.8 def test_bar_absolute_width_manual_override():