diff --git a/ultraplot/_subplots.py b/ultraplot/_subplots.py index 4cd34ef03..cf0f0a609 100644 --- a/ultraplot/_subplots.py +++ b/ultraplot/_subplots.py @@ -3,7 +3,7 @@ """ from numbers import Integral -from typing import TYPE_CHECKING +from typing import Protocol import matplotlib.axes as maxes import matplotlib.gridspec as mgridspec @@ -15,22 +15,136 @@ from . import gridspec as pgridspec from .internals import _not_none, _pop_params, warnings -if TYPE_CHECKING: - from .figure import Figure + +def parse_backend(backend=None, basemap=None): + """ + Handle deprecation of basemap and cartopy package. + """ + if backend == "basemap": + constructor._warn_basemap_deprecated() + return backend + + +def parse_proj( + proj=None, + projection=None, + proj_kw=None, + projection_kw=None, + backend=None, + basemap=None, + **kwargs, +): + """ + Translate user-input projection into a registered matplotlib axes class. + """ + # Parse arguments + proj = _not_none(proj=proj, projection=projection, default="cartesian") + proj_kw = _not_none(proj_kw=proj_kw, projection_kw=projection_kw, default={}) + backend = parse_backend(backend, basemap) + if isinstance(proj, str): + proj = proj.lower() + + # Search axes projections + name = None + + # Handle cartopy/basemap Projection objects directly + # These should be converted to Ultraplot GeoAxes + if not isinstance(proj, str): + if constructor.Projection is not object and isinstance( + proj, constructor.Projection + ): + name = "ultraplot_cartopy" + kwargs["map_projection"] = proj + elif constructor.Basemap is not object and isinstance( + proj, constructor.Basemap + ): + name = "ultraplot_basemap" + kwargs["map_projection"] = proj + constructor._warn_basemap_deprecated() + + if name is None and isinstance(proj, str): + try: + mproj.get_projection_class("ultraplot_" + proj) + except (KeyError, ValueError): + pass + else: + name = "ultraplot_" + proj + if name is None and isinstance(proj, str): + # Try geographic projections first if cartopy/basemap available + if constructor.Projection is not object or constructor.Basemap is not object: + try: + proj_obj = constructor.Proj( + proj, backend=backend, include_axes=True, **proj_kw + ) + name = "ultraplot_" + proj_obj._proj_backend + kwargs["map_projection"] = proj_obj + except ValueError: + pass # not a geographic projection, try matplotlib registry below + + # If not geographic, check if registered globally in matplotlib + # (e.g., 'ternary', 'polar', '3d') + if name is None and proj in mproj.get_projection_names(): + name = proj + + if name is None and isinstance(proj, str): + raise ValueError( + f"Invalid projection name {proj!r}. If you are trying to generate a " + "GeoAxes with a cartopy.crs.Projection or mpl_toolkits.basemap.Basemap " + "then cartopy or basemap must be installed. Otherwise the known axes " + f"subclasses are:\n{paxes._cls_table}" + ) + + if name is not None: + kwargs["projection"] = name + return kwargs + + +class FigureHost(Protocol): + """ + The members of `~ultraplot.figure.Figure` that `SubplotManager` calls *directly*. + + Naming them keeps the upward dependency deliberate: a change to `Figure` reaches + the manager through these four names and nowhere else, and adding a fifth is a + visible edit here rather than a new attribute poke buried in a method. + + WARNING: This is not a sufficient interface -- satisfying it is not enough to + drive a `SubplotManager`. `gridspec` assigns ``gs.figure = self.figure``, and + `~ultraplot.gridspec.GridSpec.figure` rejects anything that is not a real + `Figure`, then reads ``_gridspec_params`` and calls ``set_size_inches``. So the + manager still requires a concrete `Figure`; that requirement simply arrives + through `GridSpec` rather than through this protocol. + """ + + def _native_add_subplot(self, *args, **kwargs): + """Create an axes with matplotlib's ``add_subplot``, skipping overrides.""" + + def _mark_layout_dirty(self) -> None: + """Record that the layout must be recomputed before the next draw.""" + + def _pop_format_params(self, kwargs: dict) -> dict: + """Remove and return the figure-level ``format`` keywords in *kwargs*.""" + + def format(self, **kwargs) -> None: + """Apply figure-level formatting.""" class SubplotManager: """ - Manages subplot creation, gridspec ownership, and projection parsing - for a Figure instance. + Manages subplot creation and gridspec ownership for a figure. Parameters ---------- figure : `~ultraplot.figure.Figure` - The parent figure. + The parent figure. `FigureHost` documents which of its members are used. """ - def __init__(self, figure: "Figure"): + # Projection parsing is a pure function of its arguments -- aliased here + # because `ultraplot.ui` introspects these signatures to split figure + # keywords from subplot keywords. + parse_backend = staticmethod(parse_backend) + parse_proj = staticmethod(parse_proj) + + def __init__(self, figure: FigureHost): self.figure = figure self.subplot_dict: dict = {} self.counter: int = 0 @@ -60,99 +174,13 @@ def gridspec(self, gs): self._gridspec = gs gs.figure = self.figure # gridspec.figure should reference the real Figure - @staticmethod - def parse_backend(backend=None, basemap=None): - """ - Handle deprecation of basemap and cartopy package. - """ - if backend == "basemap": - constructor._warn_basemap_deprecated() - return backend - - def parse_proj( - self, - proj=None, - projection=None, - proj_kw=None, - projection_kw=None, - backend=None, - basemap=None, - **kwargs, - ): - """ - Translate user-input projection into a registered matplotlib axes class. - """ - # Parse arguments - proj = _not_none(proj=proj, projection=projection, default="cartesian") - proj_kw = _not_none(proj_kw=proj_kw, projection_kw=projection_kw, default={}) - backend = self.parse_backend(backend, basemap) - if isinstance(proj, str): - proj = proj.lower() - - # Search axes projections - name = None - - # Handle cartopy/basemap Projection objects directly - # These should be converted to Ultraplot GeoAxes - if not isinstance(proj, str): - if constructor.Projection is not object and isinstance( - proj, constructor.Projection - ): - name = "ultraplot_cartopy" - kwargs["map_projection"] = proj - elif constructor.Basemap is not object and isinstance( - proj, constructor.Basemap - ): - name = "ultraplot_basemap" - kwargs["map_projection"] = proj - constructor._warn_basemap_deprecated() - - if name is None and isinstance(proj, str): - try: - mproj.get_projection_class("ultraplot_" + proj) - except (KeyError, ValueError): - pass - else: - name = "ultraplot_" + proj - if name is None and isinstance(proj, str): - # Try geographic projections first if cartopy/basemap available - if ( - constructor.Projection is not object - or constructor.Basemap is not object - ): - try: - proj_obj = constructor.Proj( - proj, backend=backend, include_axes=True, **proj_kw - ) - name = "ultraplot_" + proj_obj._proj_backend - kwargs["map_projection"] = proj_obj - except ValueError: - pass # not a geographic projection, try matplotlib registry below - - # If not geographic, check if registered globally in matplotlib - # (e.g., 'ternary', 'polar', '3d') - if name is None and proj in mproj.get_projection_names(): - name = proj - - if name is None and isinstance(proj, str): - raise ValueError( - f"Invalid projection name {proj!r}. If you are trying to generate a " - "GeoAxes with a cartopy.crs.Projection or mpl_toolkits.basemap.Basemap " - "then cartopy or basemap must be installed. Otherwise the known axes " - f"subclasses are:\n{paxes._cls_table}" - ) - - if name is not None: - kwargs["projection"] = name - return kwargs - def add_subplot(self, *args, **kwargs): """ The driver function for adding single subplots. """ fig = self.figure - fig._layout_dirty = True - kwargs = self.parse_proj(**kwargs) + fig._mark_layout_dirty() + kwargs = parse_proj(**kwargs) args = args or (1, 1, 1) gs = self.gridspec @@ -259,13 +287,9 @@ def add_subplot(self, *args, **kwargs): kwargs.pop("_subplot_spec", None) - # NOTE: Skip past Figure.add_subplot (which routes back here) to the - # matplotlib implementation. Using super() rather than naming the - # matplotlib class keeps any mixin between Figure and matplotlib's - # Figure in a subclass MRO from being bypassed. - from .figure import Figure - - ax = super(Figure, fig).add_subplot(ss, **kwargs) + # NOTE: The host skips past its own add_subplot (which routes back here) + # to the matplotlib implementation. + ax = fig._native_add_subplot(ss, **kwargs) if ax.number: self.subplot_dict[ax.number] = ax return ax @@ -350,7 +374,7 @@ def _axes_dict(naxs, input, kw=False, default=None): proj = _axes_dict(naxs, proj, kw=False, default="cartesian") proj_kw = _not_none(projection_kw=projection_kw, proj_kw=proj_kw) or {} proj_kw = _axes_dict(naxs, proj_kw, kw=True) - backend = self.parse_backend(backend, basemap) + backend = parse_backend(backend, basemap) backend = _axes_dict(naxs, backend, kw=False) axes_kw = { num: {"proj": proj[num], "proj_kw": proj_kw[num], "backend": backend[num]} @@ -365,7 +389,7 @@ def _axes_dict(naxs, input, kw=False, default=None): "parameters as keyword arguments instead." ) kwargs.update(kw or {}) - figure_kw = _pop_params(kwargs, fig._format_signature) + figure_kw = fig._pop_format_params(kwargs) gridspec_kw = _pop_params(kwargs, pgridspec.GridSpec._update_params) # Create or update the gridspec and add subplots with subplotspecs @@ -386,7 +410,7 @@ def _axes_dict(naxs, input, kw=False, default=None): y0, y1 = axrows[idx, 0], axrows[idx, 1] ss = gs[y0 : y1 + 1, x0 : x1 + 1] kw = {**kwargs, **axes_kw[num], "number": num} - axs[idx] = fig.add_subplot(ss, **kw) + axs[idx] = self.add_subplot(ss, **kw) fig.format(skip_axes=True, **figure_kw) return pgridspec.SubplotGrid(axs) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 19a429d15..a6999c7b1 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -3340,7 +3340,7 @@ def format( ultraplot.config.Configurator.context """ if self.figure is not None: - self.figure._layout_dirty = True + self.figure._mark_layout_dirty() skip_figure = kwargs.pop("skip_figure", False) # internal keyword arg params = _pop_params(kwargs, self.figure._format_signature) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 60cf99014..d24a65524 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -41,7 +41,7 @@ labels, warnings, ) -from ._subplots import SubplotManager +from ._subplots import SubplotManager, parse_backend, parse_proj from .utils import _Crawler, units __all__ = [ @@ -1082,7 +1082,7 @@ def clear(self, keep_observers=False): self._subplots.reset() self._panel_dict = {"left": [], "right": [], "bottom": [], "top": []} self._layout_initialized = False - self._layout_dirty = True + self._mark_layout_dirty() self._init_super_labels() @override @@ -1726,12 +1726,33 @@ def _context_authorized(self): @staticmethod def _parse_backend(backend=None, basemap=None): - """Delegate to SubplotManager.""" - return SubplotManager.parse_backend(backend, basemap) + """Delegate to the projection parser.""" + return parse_backend(backend, basemap) def _parse_proj(self, *args, **kwargs): - """Delegate to SubplotManager.""" - return self._subplots.parse_proj(*args, **kwargs) + """Delegate to the projection parser.""" + return parse_proj(*args, **kwargs) + + # Implementation of `ultraplot._subplots.FigureHost` -- the only members + # `SubplotManager` is permitted to reach for. Keep this seam narrow: anything + # else the manager needs should become an explicit method here first. + def _native_add_subplot(self, *args, **kwargs): + """ + Add a subplot with matplotlib's implementation, skipping this class's + `add_subplot` (which routes back into `SubplotManager`). + + NOTE: Uses ``super()`` rather than naming `matplotlib.figure.Figure` so a + mixin between this class and matplotlib's in a subclass MRO is not bypassed. + """ + return super().add_subplot(*args, **kwargs) + + def _mark_layout_dirty(self): + """Record that the layout must be recomputed before the next draw.""" + self._layout_dirty = True + + def _pop_format_params(self, kwargs): + """Remove and return the figure-level `format` keywords in *kwargs*.""" + return _pop_params(kwargs, self._format_signature) def _get_align_axes(self, side): """ @@ -2145,7 +2166,7 @@ def _add_figure_panel( """ Add a figure panel. """ - self._layout_dirty = True + self._mark_layout_dirty() # Interpret args and enforce sensible keyword args side = _translate_loc(side, "panel", default="right") if side in ("left", "right"): @@ -2258,6 +2279,10 @@ def get_key(ax): else: ref._shared_axes[which].join(ref, other) + # NOTE: The manager builds each subplot through its own `add_subplot`, not + # this class's, so the border cache is invalidated once here rather than + # once per subplot. + @_clear_border_cache def _add_subplots(self, *args, **kwargs): """Delegate to SubplotManager.""" return self._subplots.add_subplots(*args, **kwargs) @@ -3414,7 +3439,7 @@ def format( ultraplot.gridspec.SubplotGrid.format ultraplot.config.Configurator.context """ - self._layout_dirty = True + self._mark_layout_dirty() # Initiate context block axs = axs or self._iter_subplots() skip_axes = kwargs.pop("skip_axes", False) # internal keyword arg @@ -4106,7 +4131,7 @@ def set_size_inches(self, w, h=None, *, forward=True, internal=False, eps=None): if not samesize: # gridspec positions will resolve differently self.gridspec.update() if not backend and not internal: - self._layout_dirty = True + self._mark_layout_dirty() def _iter_axes(self, hidden=False, children=False, panels=True): """