diff --git a/lib/matplotlib/artist.py b/lib/matplotlib/artist.py index b79d3cc62338..89827bc4ea9b 100644 --- a/lib/matplotlib/artist.py +++ b/lib/matplotlib/artist.py @@ -20,6 +20,9 @@ _log = logging.getLogger(__name__) +# a tombstone sentinel to ensure we never reset the axes or figure on an Artist +_tombstone = object() + def _prevent_rasterization(draw): # We assume that by default artists are not allowed to rasterize (unless @@ -181,7 +184,7 @@ def __init__(self): self._stale = True self.stale_callback = None self._axes = None - self.figure = None + self._figure = None self._transform = None self._transformSet = False @@ -293,18 +296,60 @@ def convert_yunits(self, y): @property def axes(self): """The `~.axes.Axes` instance the artist resides in, or *None*.""" - return self._axes + return self._axes if self._axes is not _tombstone else None @axes.setter def axes(self, new_axes): if (new_axes is not None and self._axes is not None and new_axes != self._axes): - raise ValueError("Can not reset the Axes. You are probably trying to reuse " - "an artist in more than one Axes which is not supported") + if self._axes is _tombstone: + extra = ( + f"The artist {self!r} has had its Axes reset to None " + + "but was previously included in one." + ) + else: + extra = ( + f"The artist {self!r} is currently in {self._axes!r} " + + f"in the figure {self._axes.figure!r}." + ) + raise ValueError("Can not reset the Axes. You are probably " + + "trying to re-use an artist in more than one " + + "Axes which is not supported. " + + extra + ) + if self._axes is not None and new_axes is None: + new_axes = _tombstone self._axes = new_axes if new_axes is not None and new_axes is not self: self.stale_callback = _stale_axes_callback + @property + def figure(self): + """The `~.figure.Figure` instance the artist resides in, or *None*.""" + return self._figure if self._figure is not _tombstone else None + + @figure.setter + def figure(self, new_figure): + if (new_figure is not None and self._figure is not None + and new_figure != self._figure and new_figure is not True): + if self._figure is _tombstone: + extra = ( + f"The artist {self!r} has had its Figure reset to None " + + "but was previously included in one." + ) + else: + extra = ( + f"The artist {self!r} is currently in {self._figure!r}." + ) + raise ValueError("Can not reset the figure. You are probably " + + "trying to re-use an artist in more than one " + + "Figure which is not supported. " + + extra + ) + if self._figure is not None and new_figure is None: + new_figure = _tombstone + self._figure = new_figure + @property def stale(self): """ @@ -738,14 +783,6 @@ def set_figure(self, fig): # if this is a no-op just return if self.figure is fig: return - # if we currently have a figure (the case of both `self.figure` - # and *fig* being none is taken care of above) we then user is - # trying to change the figure an artist is associated with which - # is not allowed for the same reason as adding the same instance - # to more than one Axes - if self.figure is not None: - raise RuntimeError("Can not put single artist in " - "more than one figure") self.figure = fig if self.figure and self.figure is not self: self.pchanged() diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index 087c193d48c3..0371eaa3ccf1 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -137,6 +137,7 @@ def __init__(self, **kwargs): # axis._get_tick_boxes_siblings self._align_label_groups = {"x": cbook.Grouper(), "y": cbook.Grouper()} + self._figure = self self._localaxes = [] # track all Axes self.artists = [] self.lines = [] @@ -2162,7 +2163,7 @@ def __init__(self, parent, subplotspec, *, self._subplotspec = subplotspec self._parent = parent - self.figure = parent.figure + self._figure = parent.figure # subfigures use the parent axstack self._axstack = parent._axstack diff --git a/lib/matplotlib/tests/test_artist.py b/lib/matplotlib/tests/test_artist.py index dbb5dd2305e0..41eca92fdcfc 100644 --- a/lib/matplotlib/tests/test_artist.py +++ b/lib/matplotlib/tests/test_artist.py @@ -562,3 +562,36 @@ def draw(self, renderer, extra): assert 'aardvark' == art.draw(renderer, 'aardvark') assert 'aardvark' == art.draw(renderer, extra='aardvark') + + +def test_no_reset(): + fig, (ax1, ax2) = plt.subplots(2) + fig2 = plt.figure() + ln, = ax1.plot(range(5)) + with pytest.raises( + ValueError, match=f"The artist {ln!r} is currently in {ax1!r}" + ): + ax2.add_line(ln) + with pytest.raises( + ValueError, match=f"The artist {ln!r} is currently in {ax1!r}" + ): + ln.axes = ax2 + with pytest.raises( + ValueError, match=f"The artist {ln!r} is currently in {fig!r}" + ): + ln.figure = fig2 + + ln.remove() + assert ln.figure is None + assert ln.axes is None + with pytest.raises( + ValueError, match=f"The artist {ln!r} has had its Figure reset " + ): + ax2.add_line(ln) + with pytest.raises( + ValueError, match=f"The artist {ln!r} has had its Axes reset " + ): + ln.axes = ax2 + + assert ln.axes is None + assert ln.figure is None diff --git a/lib/matplotlib/tests/test_offsetbox.py b/lib/matplotlib/tests/test_offsetbox.py index f18fa7c777d1..1c1e6255b86f 100644 --- a/lib/matplotlib/tests/test_offsetbox.py +++ b/lib/matplotlib/tests/test_offsetbox.py @@ -103,8 +103,9 @@ def test_offsetbox_loc_codes(): 'center': 10, } fig, ax = plt.subplots() - da = DrawingArea(100, 100) + for code in codes: + da = DrawingArea(100, 100) anchored_box = AnchoredOffsetbox(loc=code, child=da) ax.add_artist(anchored_box) fig.canvas.draw()