From 3b4211866c457a641f2171d226d1e73c1a890411 Mon Sep 17 00:00:00 2001 From: Justin Hendrick Date: Sat, 21 Sep 2024 14:28:18 -0700 Subject: [PATCH 01/10] Break Artist._remove_method reference cycle --- lib/matplotlib/axes/_base.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index 6aa5ef1efb7b..107d92ecd61c 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -1298,9 +1298,11 @@ def __clear(self): self._get_patches_for_fill = _process_plot_var_args('fill') self._gridOn = mpl.rcParams['axes.grid'] - old_children, self._children = self._children, [] - for chld in old_children: + for chld in self._children: chld.axes = chld._parent_figure = None + # Use list.clear instead of setting _children to empty list to + # break the `artist._remove_method` reference cycle + self._children.clear() self._mouseover_set = _OrderedSet() self.child_axes = [] self._current_image = None # strictly for pyplot via _sci, _gci From 867701d757db6b3d9247885df697d57574c6e994 Mon Sep 17 00:00:00 2001 From: Justin Hendrick Date: Mon, 23 Sep 2024 07:56:38 -0700 Subject: [PATCH 02/10] keep old_children swap --- lib/matplotlib/axes/_base.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index 107d92ecd61c..14e83f2aae30 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -1298,11 +1298,12 @@ def __clear(self): self._get_patches_for_fill = _process_plot_var_args('fill') self._gridOn = mpl.rcParams['axes.grid'] - for chld in self._children: + # 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 - # Use list.clear instead of setting _children to empty list to - # break the `artist._remove_method` reference cycle - self._children.clear() + # 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 From 9d7a5057f9bda2714e87abc5e1f0d3b6d990872b Mon Sep 17 00:00:00 2001 From: Justin Hendrick Date: Tue, 24 Sep 2024 08:31:12 -0700 Subject: [PATCH 03/10] set _remove_method to None. Add test --- lib/matplotlib/axes/_base.py | 4 ++- lib/matplotlib/tests/test_axes.py | 45 ++++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index 14e83f2aae30..3ff3753eeb5b 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -1301,7 +1301,9 @@ def __clear(self): # 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._parent_figure = None + chld.axes = None + chld._remove_method = None # Use list.clear to break the `artist._remove_method` reference cycle old_children.clear() self._mouseover_set = _OrderedSet() diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 6cb761ea02e1..fabd8e540ee6 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -1,8 +1,9 @@ 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 @@ -23,6 +24,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 @@ -9166,6 +9169,46 @@ def test_axes_clear_behavior(fig_ref, fig_test, which): ax_test.grid(True) +def test_axes_clear_reference_cycle(): + def is_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 + if child is start: + return True + explored.add(id(child)) + to_visit.append(child) + return False + fig = Figure() + ax = fig.add_subplot() + ax.plot(np.random.rand(1000)) + ax_children = ax.get_children() + fig.clear() # This should break the reference cycle + + # Care most about the objects that scale with number of points + line_artists = list( + filter( + lambda a: isinstance(a, Line2D) or isinstance(a, PathCollection), + ax_children, + ) + ) + assert len(line_artists) > 0 + for line_artist in line_artists: + assert not is_in_reference_cycle(line_artist) + assert len(ax_children) > 0 + for child in ax_children: + # make sure this doesn't raise a ValueError because the list is empty + 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`. From 8a904abcc3132e8b7ceb36f303907acb51866643 Mon Sep 17 00:00:00 2001 From: Justin Hendrick Date: Tue, 24 Sep 2024 08:44:10 -0700 Subject: [PATCH 04/10] add scatter to test --- lib/matplotlib/tests/test_axes.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index fabd8e540ee6..97ac8aa6df0d 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -9186,20 +9186,22 @@ def is_in_reference_cycle(start): return False fig = Figure() ax = fig.add_subplot() - ax.plot(np.random.rand(1000)) + 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 - line_artists = list( + big_artists = list( filter( lambda a: isinstance(a, Line2D) or isinstance(a, PathCollection), ax_children, ) ) - assert len(line_artists) > 0 - for line_artist in line_artists: - assert not is_in_reference_cycle(line_artist) + assert len(big_artists) > 0 + for big_artist in big_artists: + assert not is_in_reference_cycle(big_artist) assert len(ax_children) > 0 for child in ax_children: # make sure this doesn't raise a ValueError because the list is empty From ff55687b770bce1930d91b925948f18ce5c193c3 Mon Sep 17 00:00:00 2001 From: Justin Hendrick Date: Tue, 24 Sep 2024 21:06:17 -0700 Subject: [PATCH 05/10] incorporate feedback. disable gc during graph traversal --- lib/matplotlib/tests/test_axes.py | 40 ++++++++++++++++--------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 97ac8aa6df0d..66dc392b45c6 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -9172,18 +9172,22 @@ def test_axes_clear_behavior(fig_ref, fig_test, which): def test_axes_clear_reference_cycle(): def is_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 - if child is start: - return True - explored.add(id(child)) - to_visit.append(child) - return False + try: + gc.disable() + 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 + if child is start: + return True + explored.add(id(child)) + to_visit.append(child) + return False + finally: + gc.enable() fig = Figure() ax = fig.add_subplot() points = np.random.rand(1000) @@ -9193,18 +9197,16 @@ def is_in_reference_cycle(start): fig.clear() # This should break the reference cycle # Care most about the objects that scale with number of points - big_artists = list( - filter( - lambda a: isinstance(a, Line2D) or isinstance(a, PathCollection), - ax_children, - ) - ) + 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 is_in_reference_cycle(big_artist) assert len(ax_children) > 0 for child in ax_children: - # make sure this doesn't raise a ValueError because the list is empty + # Make sure this doesn't raise because the child is already removed. try: child.remove() except NotImplementedError: From ba883a1f932f649da3e386df0765d453b26f33b3 Mon Sep 17 00:00:00 2001 From: Justin Hendrick Date: Tue, 24 Sep 2024 22:44:02 -0700 Subject: [PATCH 06/10] try gc disable for whole test --- lib/matplotlib/tests/test_axes.py | 79 ++++++++++++++++--------------- 1 file changed, 40 insertions(+), 39 deletions(-) diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index fc536041eb33..5ed5af4d4091 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -9194,45 +9194,46 @@ def test_axes_clear_behavior(fig_ref, fig_test, which): def test_axes_clear_reference_cycle(): def is_in_reference_cycle(start): # Breadth first search. Return True if we encounter the starting node - try: - gc.disable() - 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 - if child is start: - return True - explored.add(id(child)) - to_visit.append(child) - return False - finally: - gc.enable() - 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 is_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 + 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 + if child is start: + return True + explored.add(id(child)) + to_visit.append(child) + return False + + try: + gc.disable() + 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 is_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 + finally: + gc.enable() def test_boxplot_tick_labels(): From 2139853e918bd74333843bf8796e88c376653756 Mon Sep 17 00:00:00 2001 From: Justin Hendrick Date: Wed, 25 Sep 2024 20:18:34 -0700 Subject: [PATCH 07/10] skip test on non-final 3.13.0 --- lib/matplotlib/tests/test_axes.py | 58 +++++++++++++++++-------------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 5ed5af4d4091..da99e0632093 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -8,6 +8,7 @@ import io from itertools import product import platform +import sys from types import SimpleNamespace import dateutil.tz @@ -9191,6 +9192,13 @@ def test_axes_clear_behavior(fig_ref, fig_test, which): ax_test.grid(True) +@pytest.mark.skipif( + sys.version_info.major == 3 and + sys.version_info.minor == 13 and + sys.version_info.micro == 0 and + sys.version_info.releaselevel != "final", + reason="https://github.com/python/cpython/issues/124538", +) def test_axes_clear_reference_cycle(): def is_in_reference_cycle(start): # Breadth first search. Return True if we encounter the starting node @@ -9207,33 +9215,29 @@ def is_in_reference_cycle(start): to_visit.append(child) return False - try: - gc.disable() - 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 is_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 - finally: - gc.enable() + 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 is_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(): From 3f6f591f099fcddd3bf82533468087bb56aeff2e Mon Sep 17 00:00:00 2001 From: Justin Hendrick Date: Wed, 25 Sep 2024 21:58:20 -0700 Subject: [PATCH 08/10] simplify skip condition Co-authored-by: Elliott Sales de Andrade --- lib/matplotlib/tests/test_axes.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index da99e0632093..3a0f33310f6d 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -9193,10 +9193,7 @@ def test_axes_clear_behavior(fig_ref, fig_test, which): @pytest.mark.skipif( - sys.version_info.major == 3 and - sys.version_info.minor == 13 and - sys.version_info.micro == 0 and - sys.version_info.releaselevel != "final", + 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(): From 74c207d0aa90b4d5ac1132fb4d446d4781316d02 Mon Sep 17 00:00:00 2001 From: Justin Hendrick Date: Sat, 28 Sep 2024 17:09:08 -0700 Subject: [PATCH 09/10] make all lines in test reachable --- lib/matplotlib/tests/test_axes.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 3a0f33310f6d..755934009c95 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -9197,7 +9197,7 @@ def test_axes_clear_behavior(fig_ref, fig_test, which): reason="https://github.com/python/cpython/issues/124538", ) def test_axes_clear_reference_cycle(): - def is_in_reference_cycle(start): + def assert_not_in_reference_cycle(start): # Breadth first search. Return True if we encounter the starting node to_visit = deque([start]) explored = set() @@ -9206,11 +9206,9 @@ def is_in_reference_cycle(start): for child in gc.get_referents(parent): if id(child) in explored: continue - if child is start: - return True + assert child is not start explored.add(id(child)) to_visit.append(child) - return False fig = Figure() ax = fig.add_subplot() @@ -9227,7 +9225,7 @@ def is_in_reference_cycle(start): ] assert len(big_artists) > 0 for big_artist in big_artists: - assert not is_in_reference_cycle(big_artist) + 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. From 5d84a76e7c96c2c799f488fb5bce0a29f4b0fb73 Mon Sep 17 00:00:00 2001 From: Justin Hendrick Date: Wed, 23 Oct 2024 11:19:40 -0700 Subject: [PATCH 10/10] Update lib/matplotlib/axes/_base.py Co-authored-by: Elliott Sales de Andrade --- lib/matplotlib/axes/_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index eb49a344ff75..12ac19a5d7c8 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -1305,9 +1305,9 @@ def __clear(self): # Swap children to minimize time we spend in an invalid state old_children, self._children = self._children, [] for chld in old_children: + chld._remove_method = None chld._parent_figure = None chld.axes = None - chld._remove_method = None # Use list.clear to break the `artist._remove_method` reference cycle old_children.clear() self._mouseover_set = _OrderedSet()