Skip to content

Commit 317db84

Browse files
authored
Fix Figure.clear leaving stale state and decouple ui kwarg routing (#760)
Matplotlib's `clear()` detaches every axes and artist and sets `_suptitle` to None, but ultraplot kept its own bookkeeping pointing at the destroyed objects: a cleared figure went on handing out dead axes through `subplotgrid`, `_get_subplot`, `_iter_subplots` and `_iter_axes`, kept a stale gridspec and label counter, leaked figure-level panels, and raised `AttributeError` from the next `format(suptitle=...)` because the suptitle artist was gone, so this adds `SubplotManager.reset()` and overrides `Figure.clear()` to call it, empty the panel dict, reset the layout flags, and rebuild the label artists via the extracted `_init_super_labels()` (which also covers `clf()`, matplotlib's alias for `clear()`). It further removes a footgun in `ui.py`, which split figure keywords from subplot keywords by introspecting the signatures of `Figure._parse_proj` and `Figure._add_subplots` even though those are pure pass-throughs whose only remaining job was to mirror `SubplotManager`'s parameter list -- a contract nothing enforced, and one that already fired once when collapsing `_parse_proj` to `(*args, **kwargs)` made `_pop_params` see no projection parameters and silently routed `proj` to the figure, raising from `Figure.set()`; `_pop_params` now introspects `SubplotManager.parse_proj` and `.add_subplots`, which actually own those parameters, so the delegators are free to collapse back to `(*args, **kwargs)`. Closes #755, closes #756
1 parent c8147c3 commit 317db84

5 files changed

Lines changed: 178 additions & 60 deletions

File tree

ultraplot/_subplots.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,18 @@ def __init__(self, figure: "Figure"):
3636
self.counter: int = 0
3737
self._gridspec = None
3838

39+
def reset(self):
40+
"""
41+
Forget every subplot and release the gridspec.
42+
43+
Called by `~ultraplot.figure.Figure.clear`, which destroys the axes this
44+
manager tracks. Without this the figure keeps handing out axes that are no
45+
longer attached to it.
46+
"""
47+
self.subplot_dict.clear()
48+
self.counter = 0
49+
self._gridspec = None
50+
3951
@property
4052
def gridspec(self):
4153
"""The single GridSpec used for all subplots in the figure."""

ultraplot/figure.py

Lines changed: 42 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,7 +1034,19 @@ def _init_figure_state(self, figwidth, figheight, kwargs):
10341034
with self._context_authorized():
10351035
super().__init__(**kwargs)
10361036

1037-
# Super labels
1037+
self._init_super_labels()
1038+
1039+
# Apply initial formatting (ignores user-input rc_mode)
1040+
self.format(rc_kw=rc_kw, rc_mode=1, skip_axes=True, **kw_format)
1041+
1042+
def _init_super_labels(self):
1043+
"""
1044+
Create the figure-level label artists and their style state.
1045+
1046+
NOTE: Also called by `clear`, which discards every artist on the figure and
1047+
sets ``_suptitle`` to None. The labels must be rebuilt there or the next
1048+
``format(suptitle=...)`` raises on the missing artist.
1049+
"""
10381050
self._suptitle = self.text(0.5, 0.95, "", ha="center", va="bottom")
10391051
self._supxlabel_dict = {}
10401052
self._supylabel_dict = {}
@@ -1053,8 +1065,30 @@ def _init_figure_state(self, figwidth, figheight, kwargs):
10531065
d["bottom"] = rc["bottomlabel.pad"]
10541066
d["top"] = rc["toplabel.pad"]
10551067

1056-
# Apply initial formatting (ignores user-input rc_mode)
1057-
self.format(rc_kw=rc_kw, rc_mode=1, skip_axes=True, **kw_format)
1068+
@_clear_border_cache
1069+
def clear(self, keep_observers=False):
1070+
"""
1071+
Clear the figure, discarding all subplots, panels, and figure-level labels.
1072+
1073+
Parameters
1074+
----------
1075+
keep_observers : bool, default: False
1076+
Whether to retain the figure's observers, e.g. a GUI widget tracking
1077+
the axes.
1078+
1079+
See also
1080+
--------
1081+
matplotlib.figure.Figure.clear
1082+
"""
1083+
# Matplotlib removes every axes and artist, so the ultraplot state that
1084+
# points at them is now dangling. Rebuild it rather than leaving the figure
1085+
# handing out axes it no longer owns.
1086+
super().clear(keep_observers=keep_observers)
1087+
self._subplots.reset()
1088+
self._panel_dict = {"left": [], "right": [], "bottom": [], "top": []}
1089+
self._layout_initialized = False
1090+
self._layout_dirty = True
1091+
self._init_super_labels()
10581092

10591093
@override
10601094
def draw(self, renderer):
@@ -1700,34 +1734,9 @@ def _parse_backend(backend=None, basemap=None):
17001734
"""Delegate to SubplotManager."""
17011735
return SubplotManager.parse_backend(backend, basemap)
17021736

1703-
def _parse_proj(
1704-
self,
1705-
proj=None,
1706-
projection=None,
1707-
proj_kw=None,
1708-
projection_kw=None,
1709-
backend=None,
1710-
basemap=None,
1711-
**kwargs,
1712-
):
1713-
"""
1714-
Delegate to SubplotManager.
1715-
1716-
NOTE: The parameters must stay spelled out rather than collapsing into
1717-
``**kwargs``. `~ultraplot.ui.subplot` uses ``_pop_params`` to introspect
1718-
this signature and decide which keywords belong to the subplot instead of
1719-
the figure; a ``*args, **kwargs`` signature silently routes them to the
1720-
figure and raises on ``Figure.set()``.
1721-
"""
1722-
return self._subplots.parse_proj(
1723-
proj=proj,
1724-
projection=projection,
1725-
proj_kw=proj_kw,
1726-
projection_kw=projection_kw,
1727-
backend=backend,
1728-
basemap=basemap,
1729-
**kwargs,
1730-
)
1737+
def _parse_proj(self, *args, **kwargs):
1738+
"""Delegate to SubplotManager."""
1739+
return self._subplots.parse_proj(*args, **kwargs)
17311740

17321741
def _get_align_axes(self, side):
17331742
"""
@@ -2233,34 +2242,9 @@ def get_key(ax):
22332242
else:
22342243
ref._shared_axes[which].join(ref, other)
22352244

2236-
def _add_subplots(
2237-
self,
2238-
array=None,
2239-
nrows=1,
2240-
ncols=1,
2241-
order="C",
2242-
proj=None,
2243-
projection=None,
2244-
proj_kw=None,
2245-
projection_kw=None,
2246-
backend=None,
2247-
basemap=None,
2248-
**kwargs,
2249-
):
2245+
def _add_subplots(self, *args, **kwargs):
22502246
"""Delegate to SubplotManager."""
2251-
return self._subplots.add_subplots(
2252-
array=array,
2253-
nrows=nrows,
2254-
ncols=ncols,
2255-
order=order,
2256-
proj=proj,
2257-
projection=projection,
2258-
proj_kw=proj_kw,
2259-
projection_kw=projection_kw,
2260-
backend=backend,
2261-
basemap=basemap,
2262-
**kwargs,
2263-
)
2247+
return self._subplots.add_subplots(*args, **kwargs)
22642248

22652249
def _align_axis_label(self, x):
22662250
"""

ultraplot/tests/test_figure.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -920,3 +920,80 @@ def test_refaspect_as_tuple():
920920
fig, axs = uplt.subplots(refaspect=(16, 9))
921921
fig.canvas.draw()
922922
uplt.close(fig)
923+
924+
925+
def test_clear_drops_subplot_state():
926+
"""
927+
clear() must forget the subplots it destroyed. Otherwise the figure keeps
928+
handing out axes that matplotlib already detached from it.
929+
"""
930+
fig, axs = uplt.subplots(nrows=1, ncols=2)
931+
fig.clear()
932+
assert fig.axes == []
933+
assert len(fig.subplotgrid) == 0
934+
assert fig._get_subplot(1) is None
935+
assert list(fig._iter_subplots()) == []
936+
assert fig.gridspec is None
937+
uplt.close(fig)
938+
939+
940+
def test_clear_drops_panel_state():
941+
"""clear() also forgets figure-level panels created by e.g. fig.colorbar."""
942+
fig, axs = uplt.subplots(nrows=1, ncols=2)
943+
m = axs[0].pcolormesh(np.arange(16).reshape(4, 4))
944+
fig.colorbar(m, loc="r")
945+
assert fig._panel_dict["right"]
946+
fig.clear()
947+
assert not any(fig._panel_dict.values())
948+
assert list(fig._iter_axes(panels=True)) == []
949+
uplt.close(fig)
950+
951+
952+
def test_clear_resets_subplot_numbering():
953+
"""
954+
The label counter restarts after clear(), so a reused figure numbers its
955+
subplots from 1 rather than continuing from the destroyed ones.
956+
"""
957+
fig, axs = uplt.subplots(nrows=1, ncols=2)
958+
fig.clear()
959+
ax = fig.add_subplot(111)
960+
assert ax.number == 1
961+
assert list(fig._iter_subplots()) == [ax]
962+
uplt.close(fig)
963+
964+
965+
def test_clear_allows_suptitle():
966+
"""
967+
Matplotlib's clear() sets _suptitle to None, so ultraplot must rebuild its
968+
label artists or the next format(suptitle=...) raises AttributeError.
969+
"""
970+
fig, axs = uplt.subplots(nrows=1, ncols=2)
971+
fig.clear()
972+
fig.add_subplot(111)
973+
fig.format(suptitle="after clear")
974+
fig.canvas.draw()
975+
assert fig._suptitle.get_text() == "after clear"
976+
assert fig._suptitle in fig.texts # detached artists never render
977+
uplt.close(fig)
978+
979+
980+
def test_clf_alias_clears_subplot_state():
981+
"""clf() is matplotlib's alias for clear() and must reset the same state."""
982+
fig, axs = uplt.subplots(nrows=1, ncols=2)
983+
fig.clf()
984+
assert len(fig.subplotgrid) == 0
985+
assert fig.gridspec is None
986+
uplt.close(fig)
987+
988+
989+
def test_figure_is_reusable_after_clear():
990+
"""A cleared figure can be drawn again from scratch."""
991+
fig, axs = uplt.subplots(nrows=2, ncols=2)
992+
fig.canvas.draw()
993+
fig.clear()
994+
axs = fig.add_subplots(nrows=1, ncols=3)
995+
axs[0].plot([1, 2, 3])
996+
fig.canvas.draw()
997+
assert len(fig.subplotgrid) == 3
998+
assert fig.gridspec.get_geometry() == (1, 3)
999+
uplt.close(fig)

ultraplot/tests/test_subplot_manager.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22
Tests for SubplotManager (ultraplot._subplots).
33
"""
44

5+
import inspect
6+
57
import matplotlib.projections as mproj
68
import numpy as np
79
import pytest
810

911
import ultraplot as uplt
12+
from ultraplot import figure as pfigure
1013
from ultraplot import gridspec as pgridspec
1114
from ultraplot._subplots import SubplotManager
1215
from ultraplot.axes.container import ExternalAxesContainer
@@ -363,3 +366,41 @@ def test_ui_subplots_routes_projection_kwargs():
363366
fig, axs = uplt.subplots(nrows=1, ncols=2, proj="polar")
364367
assert all(isinstance(ax, uplt.axes.PolarAxes) for ax in axs)
365368
uplt.close(fig)
369+
370+
371+
def test_ui_introspects_manager_not_figure_delegator():
372+
"""
373+
``ui.subplot``/``ui.subplots`` split figure keywords from subplot keywords by
374+
introspecting the manager, which owns these parameters -- not the thin
375+
``Figure`` delegators, whose signatures are free to change.
376+
377+
These names are therefore load-bearing: collapsing them into ``**kwargs``
378+
silently routes ``proj`` to the figure, which then raises from ``Figure.set()``.
379+
"""
380+
proj_params = set(inspect.signature(SubplotManager.parse_proj).parameters)
381+
assert {
382+
"proj",
383+
"projection",
384+
"proj_kw",
385+
"projection_kw",
386+
"backend",
387+
"basemap",
388+
} <= proj_params
389+
390+
subplots_params = set(inspect.signature(SubplotManager.add_subplots).parameters)
391+
assert {"array", "nrows", "ncols", "order"} <= subplots_params
392+
assert {"proj", "projection", "proj_kw", "projection_kw"} <= subplots_params
393+
394+
395+
def test_figure_delegator_signature_is_not_load_bearing():
396+
"""
397+
The routing above must survive the Figure pass-throughs being collapsed --
398+
that collapse is exactly the regression fixed in #755.
399+
"""
400+
for name in ("_parse_proj", "_add_subplots"):
401+
params = set(inspect.signature(getattr(pfigure.Figure, name)).parameters)
402+
assert params == {"self", "args", "kwargs"}
403+
404+
fig, ax = uplt.subplot(proj="polar")
405+
assert isinstance(ax, uplt.axes.PolarAxes)
406+
uplt.close(fig)

ultraplot/ui.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from . import axes as paxes
99
from . import figure as pfigure
1010
from . import gridspec as pgridspec
11+
from ._subplots import SubplotManager
1112
from .internals import (
1213
_not_none,
1314
_pop_params,
@@ -182,7 +183,10 @@ def subplot(**kwargs):
182183
_parse_figsize(kwargs)
183184
rc_kw, rc_mode = _pop_rc(kwargs)
184185
kwsub = _pop_props(kwargs, "patch") # e.g. 'color'
185-
kwsub.update(_pop_params(kwargs, pfigure.Figure._parse_proj))
186+
# NOTE: Introspect the manager, which owns these parameters, rather than the
187+
# thin Figure delegator. Pointing this at a pass-through means any cleanup of
188+
# that pass-through's signature silently routes 'proj' to the figure instead.
189+
kwsub.update(_pop_params(kwargs, SubplotManager.parse_proj))
186190
for sig in paxes.Axes._format_signatures.values():
187191
kwsub.update(_pop_params(kwargs, sig))
188192
kwargs["aspect"] = kwsub.pop("aspect", None) # keyword conflict
@@ -227,7 +231,7 @@ def subplots(*args, **kwargs):
227231
_parse_figsize(kwargs)
228232
rc_kw, rc_mode = _pop_rc(kwargs)
229233
kwsubs = _pop_props(kwargs, "patch") # e.g. 'color'
230-
kwsubs.update(_pop_params(kwargs, pfigure.Figure._add_subplots))
234+
kwsubs.update(_pop_params(kwargs, SubplotManager.add_subplots))
231235
kwsubs.update(_pop_params(kwargs, pgridspec.GridSpec._update_params))
232236
for sig in paxes.Axes._format_signatures.values():
233237
kwsubs.update(_pop_params(kwargs, sig))

0 commit comments

Comments
 (0)