diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index 8fc9e3e3cf4d..12ac19a5d7c8 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -1302,9 +1302,14 @@ def __clear(self): self._get_patches_for_fill = _process_plot_var_args('fill') self._gridOn = mpl.rcParams['axes.grid'] + # Swap children to minimize time we spend in an invalid state old_children, self._children = self._children, [] for chld in old_children: - chld.axes = chld._parent_figure = None + chld._remove_method = None + chld._parent_figure = None + chld.axes = None + # Use list.clear to break the `artist._remove_method` reference cycle + old_children.clear() self._mouseover_set = _OrderedSet() self.child_axes = [] self._current_image = None # strictly for pyplot via _sci, _gci diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 5d50df4ffd20..39adfb1c445e 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -1,12 +1,14 @@ import contextlib -from collections import namedtuple +from collections import namedtuple, deque import datetime from decimal import Decimal from functools import partial +import gc import inspect import io from itertools import product import platform +import sys from types import SimpleNamespace import dateutil.tz @@ -23,6 +25,8 @@ import matplotlib.dates as mdates from matplotlib.figure import Figure from matplotlib.axes import Axes +from matplotlib.lines import Line2D +from matplotlib.collections import PathCollection import matplotlib.font_manager as mfont_manager import matplotlib.markers as mmarkers import matplotlib.patches as mpatches @@ -9209,6 +9213,49 @@ def test_axes_clear_behavior(fig_ref, fig_test, which): ax_test.grid(True) +@pytest.mark.skipif( + sys.version_info[:3] == (3, 13, 0) and sys.version_info.releaselevel != "final", + reason="https://github.com/python/cpython/issues/124538", +) +def test_axes_clear_reference_cycle(): + def assert_not_in_reference_cycle(start): + # Breadth first search. Return True if we encounter the starting node + to_visit = deque([start]) + explored = set() + while len(to_visit) > 0: + parent = to_visit.popleft() + for child in gc.get_referents(parent): + if id(child) in explored: + continue + assert child is not start + explored.add(id(child)) + to_visit.append(child) + + fig = Figure() + ax = fig.add_subplot() + points = np.random.rand(1000) + ax.plot(points, points) + ax.scatter(points, points) + ax_children = ax.get_children() + fig.clear() # This should break the reference cycle + + # Care most about the objects that scale with number of points + big_artists = [ + a for a in ax_children + if isinstance(a, (Line2D, PathCollection)) + ] + assert len(big_artists) > 0 + for big_artist in big_artists: + assert_not_in_reference_cycle(big_artist) + assert len(ax_children) > 0 + for child in ax_children: + # Make sure this doesn't raise because the child is already removed. + try: + child.remove() + except NotImplementedError: + pass # not implemented is expected for some artists + + def test_boxplot_tick_labels(): # Test the renamed `tick_labels` parameter. # Test for deprecation of old name `labels`.