From c7edce3820d918474fadb42e55818d1c40be1979 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jouni=20K=2E=20Sepp=C3=A4nen?= Date: Sat, 21 Jun 2025 09:56:00 +0300 Subject: [PATCH 1/3] Implement Path.__deepcopy__ avoiding infinite recursion To deep copy an object without calling deepcopy on the object itself, create a new object of the correct class and iterate calling deepcopy on its __dict__. Closes #29157 without relying on private CPython methods. Does not fix the other issue with TransformNode.__copy__. Co-authored-by: Serhiy Storchaka --- lib/matplotlib/path.py | 26 ++++++++++++++++++--- lib/matplotlib/path.pyi | 4 ++-- lib/matplotlib/tests/test_path.py | 38 +++++++++++++++++++++++++++++-- 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/lib/matplotlib/path.py b/lib/matplotlib/path.py index a021706fb1e5..f65ade669167 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -275,17 +275,37 @@ def copy(self): """ return copy.copy(self) - def __deepcopy__(self, memo=None): + def __deepcopy__(self, memo): """ Return a deepcopy of the `Path`. The `Path` will not be readonly, even if the source `Path` is. """ # Deepcopying arrays (vertices, codes) strips the writeable=False flag. - p = copy.deepcopy(super(), memo) + cls = type(self) + memo[id(self)] = p = cls.__new__(cls) + + for k, v in self.__dict__.items(): + setattr(p, k, copy.deepcopy(v, memo)) + p._readonly = False return p - deepcopy = __deepcopy__ + def deepcopy(self, memo=None): + """ + Return a deep copy of the `Path`. The `Path` will not be readonly, + even if the source `Path` is. + + Parameters + ---------- + memo : dict, optional + A dictionary to use for memoizing, passed to `copy.deepcopy`. + + Returns + ------- + Path + A deep copy of the `Path`, but not readonly. + """ + return copy.deepcopy(self, memo) @classmethod def make_compound_path_from_polys(cls, XY): diff --git a/lib/matplotlib/path.pyi b/lib/matplotlib/path.pyi index 464fc6d9a912..8a5a5c03792e 100644 --- a/lib/matplotlib/path.pyi +++ b/lib/matplotlib/path.pyi @@ -44,8 +44,8 @@ class Path: @property def readonly(self) -> bool: ... def copy(self) -> Path: ... - def __deepcopy__(self, memo: dict[int, Any] | None = ...) -> Path: ... - deepcopy = __deepcopy__ + def __deepcopy__(self, memo: dict[int, Any]) -> Path: ... + def deepcopy(self, memo: dict[int, Any] | None = None) -> Path: ... @classmethod def make_compound_path_from_polys(cls, XY: ArrayLike) -> Path: ... diff --git a/lib/matplotlib/tests/test_path.py b/lib/matplotlib/tests/test_path.py index d4dc0141e63b..5dde1d876b04 100644 --- a/lib/matplotlib/tests/test_path.py +++ b/lib/matplotlib/tests/test_path.py @@ -355,15 +355,49 @@ def test_path_deepcopy(): # Should not raise any error verts = [[0, 0], [1, 1]] codes = [Path.MOVETO, Path.LINETO] - path1 = Path(verts) - path2 = Path(verts, codes) + path1 = Path(verts, readonly=True) + path2 = Path(verts, codes, readonly=True) path1_copy = path1.deepcopy() path2_copy = path2.deepcopy() assert path1 is not path1_copy assert path1.vertices is not path1_copy.vertices + assert np.all(path1.vertices == path1_copy.vertices) + assert path1.readonly + assert not path1_copy.readonly assert path2 is not path2_copy assert path2.vertices is not path2_copy.vertices + assert np.all(path2.vertices == path2_copy.vertices) assert path2.codes is not path2_copy.codes + assert all(path2.codes == path2_copy.codes) + assert path2.readonly + assert not path2_copy.readonly + + +def test_path_deepcopy_cycle(): + class PathWithCycle(Path): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.x = self + + p = PathWithCycle([[0, 0], [1, 1]], readonly=True) + p_copy = p.deepcopy() + assert p_copy is not p + assert p.readonly + assert not p_copy.readonly + assert p_copy.x is p_copy + + class PathWithCycle2(Path): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.x = [self] * 2 + + p2 = PathWithCycle2([[0, 0], [1, 1]], readonly=True) + p2_copy = p2.deepcopy() + assert p2_copy is not p2 + assert p2.readonly + assert not p2_copy.readonly + assert p2_copy.x[0] is p2_copy + assert p2_copy.x[1] is p2_copy def test_path_shallowcopy(): From b20cf20024a5836e06e38f58bc4e88f3caec5423 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jouni=20K=2E=20Sepp=C3=A4nen?= Date: Tue, 24 Jun 2025 07:40:20 +0300 Subject: [PATCH 2/3] Compare arrays with assert_array_equal Co-authored-by: Elliott Sales de Andrade --- lib/matplotlib/tests/test_path.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/tests/test_path.py b/lib/matplotlib/tests/test_path.py index 5dde1d876b04..a61f01c0d48a 100644 --- a/lib/matplotlib/tests/test_path.py +++ b/lib/matplotlib/tests/test_path.py @@ -361,14 +361,14 @@ def test_path_deepcopy(): path2_copy = path2.deepcopy() assert path1 is not path1_copy assert path1.vertices is not path1_copy.vertices - assert np.all(path1.vertices == path1_copy.vertices) + assert_array_equal(path1.vertices, path1_copy.vertices) assert path1.readonly assert not path1_copy.readonly assert path2 is not path2_copy assert path2.vertices is not path2_copy.vertices - assert np.all(path2.vertices == path2_copy.vertices) + assert_array_equal(path2.vertices, path2_copy.vertices) assert path2.codes is not path2_copy.codes - assert all(path2.codes == path2_copy.codes) + assert_array_equal(path2.codes, path2_copy.codes) assert path2.readonly assert not path2_copy.readonly From 5c7c91542127adf03fa2f401dcb1d3c38b9a9a9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jouni=20K=2E=20Sepp=C3=A4nen?= Date: Sat, 21 Jun 2025 17:18:38 +0300 Subject: [PATCH 3/3] Fix TransformNode.__copy__ without calling copy.copy --- lib/matplotlib/transforms.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/transforms.py b/lib/matplotlib/transforms.py index 7228f05bcf9e..350113c56170 100644 --- a/lib/matplotlib/transforms.py +++ b/lib/matplotlib/transforms.py @@ -35,7 +35,6 @@ # `np.minimum` instead of the builtin `min`, and likewise for `max`. This is # done so that `nan`s are propagated, instead of being silently dropped. -import copy import functools import itertools import textwrap @@ -139,7 +138,9 @@ def __setstate__(self, data_dict): for k, v in self._parents.items() if v is not None} def __copy__(self): - other = copy.copy(super()) + cls = type(self) + other = cls.__new__(cls) + other.__dict__.update(self.__dict__) # If `c = a + b; a1 = copy(a)`, then modifications to `a1` do not # propagate back to `c`, i.e. we need to clear the parents of `a1`. other._parents = {}