Skip to content

Conversation

@cvanelteren
Copy link
Collaborator

Closes #405
This PR resolves spurious legend entries when plotting with seaborn on UltraPlot axes by tagging seaborn-generated helper artists (e.g., CI bands) as synthetic and excluding them during legend collection, suppressing mid-call legend/colorbar creation within seaborn to avoid duplicates, and disabling auto label inference for primary lines in seaborn contexts so legends show only hue categories unless users explicitly label additional bands.

Key changes

  • Tag seaborn-generated helper artists (CI bands via fill_between, error shading) as synthetic:
    • Force underscore labels (e.g., _ultraplot_fill, _ultraplot_shade, _ultraplot_fade) unless user explicitly provides a label.
    • Mark artists with _ultraplot_synthetic=True.
  • Legend collection rejects synthetic helper artists (including when seaborn passes explicit handles).
  • Suppress on-the-fly legend/colorbar creation during seaborn calls to avoid duplicates.
  • Disable auto label inference for primary line plots when called from seaborn to prevent stray “y” labels.

Behavior

  • For seaborn lineplot(..., hue=...), legend now shows only hue categories.
  • CI/error bands are hidden unless the user requests labels (shadelabel/fadelabel or label=).
  • User-specified labels are respected and shown.

Compatibility

  • No changes for non-seaborn workflows.
  • Users can explicitly opt-in to show shaded bands by passing labels.

@cvanelteren
Copy link
Collaborator Author

Note that ideally we just ask the seaborn developers to make their private labels available with _ but this would be easier and sets a precedent for interfacing with other packages to control how it behaves within ultraplot.

@cvanelteren cvanelteren marked this pull request as draft November 12, 2025 12:38
@cvanelteren
Copy link
Collaborator Author

Note to self -- run the suite before pushing PRs!

@cvanelteren
Copy link
Collaborator Author

The import statements are handled by my editor -- bit annoying.

@codecov
Copy link

codecov bot commented Nov 12, 2025

Codecov Report

❌ Patch coverage is 82.22997% with 51 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
ultraplot/axes/plot.py 44.44% 39 Missing and 6 partials ⚠️
ultraplot/axes/base.py 84.21% 4 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

@cvanelteren
Copy link
Collaborator Author

Will add a few more tests to reach the target and then open it for review

Copy link
Collaborator

@beckermr beckermr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The frame detection seems fragile, to say the least.

Can we use an alternate mechanism where a user can mark a subplot as external to ultraplot and ultraplot uses this mark internally to basically turn itself off?

This would be more general since it could be used with other plotting packages and it would be more robust since we would not rely on automated detection of arbitrary external plotting code.

@cvanelteren
Copy link
Collaborator Author

You’re right that stack-frame detection is brittle. I’ve added an explicit, opt‑in “external mode” so users can mark an axes as being driven by an external plotting library. When external mode is on, we defer on-the-fly guide creation (legends/colorbars) during plotting; users explicitly call ax.legend() or ax.colorbar() once at the end. This avoids depending on stack inspection and generalizes to any library, not just seaborn.

A few important points:

  • Backward compatibility: Default remains unchanged — we still use our current auto-detection by default. External mode is additive and opt‑in, so out-of-the-box behavior isn’t affected.
  • Scope and mixing: External mode is per-axes and can be scoped with a context manager. That makes it easy to mix external and UltraPlot-native calls on the same subplot; just wrap the external calls in with ax.external(): ... and finish with ax.legend().
  • Extensibility: We can later thread this flag through more integration points if we want to “turn more of UltraPlot off” while the flag is set (e.g., skipping our automatic label inference or disabling synthetic tagging). For now, we’ve taken the least intrusive step that addresses the fragility concern: make guide creation deterministic and user-controlled.

If you’re happy with this direction, we can also add:

  • A figure-wide helper (fig.set_external(True|False)) and an rcParam to force external mode for newly created axes in a context. Both would be non-breaking additions.

Want me to expand this to cover auto-label inference and synthetic tagging when external mode is set, or leave that for a follow-up?

@beckermr
Copy link
Collaborator

Yes let's do this all in one go. We should do the following.

  • Remove all of the stack-frame stuff (I don't want to ship that and then have to maintain it. People should use the generic mechanism.)
  • Implement quto-label inference and synthetic tagging when external mode is set.

I don't think a fully external figure instance is that useful since in that case why not just use the external library? So we can skip that feature for now.

@cvanelteren cvanelteren requested a review from beckermr November 13, 2025 09:45
@cvanelteren
Copy link
Collaborator Author

Didn't mean to ping my bad!

@cvanelteren cvanelteren marked this pull request as ready for review November 13, 2025 12:19
@cvanelteren
Copy link
Collaborator Author

Tests pass. Just need to doc this properly. Pinged you for review @beckermr

Copy link
Collaborator

@beckermr beckermr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few random things. Looking great!

return kwargs


def _inside_seaborn_call():
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this will be a breaking change. We should bump the minor version on the next release.

@beckermr beckermr changed the title Add seaborn context processing Add external context mode for axes Nov 18, 2025
@beckermr
Copy link
Collaborator

pre-commit.ci autofix

@beckermr
Copy link
Collaborator

Something is not right with this failed test:

Screenshot 2025-11-18 at 6 56 46 AM

Copy link
Collaborator

@beckermr beckermr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The image diff test failure needs addressing.

@cvanelteren
Copy link
Collaborator Author

hmm thought I fixed that. Will address

Copy link
Collaborator

@beckermr beckermr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am confused by the latest commit. If an axes is marked as external, it should defer all drawing etc to the external package. Why do we need seaborn-specific code on how bars are made in ultraplot?

@cvanelteren
Copy link
Collaborator Author

It was like that already -- not sure why either.

@cvanelteren
Copy link
Collaborator Author

Ah wait I see what you mean -- corrected.

edgefix_kw = _pop_params(kw, self._fix_patch_edges)
if absolute_width is None:
absolute_width = _inside_seaborn_call()
absolute_width = False or bar_semantics == "seaborn"
Copy link
Collaborator

@beckermr beckermr Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still confused by this bit. External mode should not have to know anything about seaborn at all.

The idea with the external mode is when you set an axis to "external", it should behave as if it was a standard matploitlib axis.

There are two ways to do this that I can see:

  1. (what is done in this PR) Pass the ultraplot axis direcly to the other library after marking it as external and modify all of the ultraplot internals to turn itself off. In this case, the code should never need info about the settings of another library (e.g., seaborn). Instead it should put back the equivalent matplotlib defaults and let the other library deal with its own overrides. In the code above, the variable absolute_width should be set to the matplotlib default when in external model. There should be no need for a separate bar configuration mode that is used to monkey patch the internals.

  2. In external mode, defer all calls to methods on the axis directly to the methods on the parent via something like direct invocation through the class itself (i.e., getattr(matplotlib.axes.Axes, <name of method>)(self, ...). IDK how feasible this approach is. It might be as simple an override of __getattribute__ when in external mode to defer all attribute access to the parent.

In case 2, the PR might look like adding something like adding

def __getattribute__(self, name):
    orig_attr = object.__getattribute__(self, name)

    # only defer callable methods and they have to be on the parent axis
    if object.__getattribute__(self, "_in_external_context")() and callable(obj_attr):
        mpl_attr = getattr(matplotlib.axes.Axes, name, None)
        if mpl_attr is None:
            return obj_attr
        else:
            # we may need to use functools.partial to insert
            # self at the start of the args here...
            return mpl_attr
    else:
        return object.__getattribute__(self, name)

to the class with ~no changes anywhere else. (Be careful with infinite recursion here. See https://stackoverflow.com/questions/371753/how-do-i-implement-getattribute-without-an-infinite-recursion-error.)

Does this make sense to you or did I miss something?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approach 2 might fail if MPL has data containers that are also themselves callable.

@beckermr beckermr merged commit 477e187 into Ultraplot:main Nov 20, 2025
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Superfluous legend entries when plotting with seaplot

2 participants