Skip to content

Commit 6104de5

Browse files
authored
Implement deepcopy support (#662)
1 parent a19b9da commit 6104de5

File tree

4 files changed

+148
-0
lines changed

4 files changed

+148
-0
lines changed

CHANGES/659.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add deepcopy support to FrozenList -- by :user:`bdraco`.

docs/spelling_wordlist.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ cythonized
7979
Cythonize
8080
de
8181
deduplicate
82+
deepcopy
8283
# de-facto:
8384
deprecations
8485
DER

frozenlist/_frozenlist.pyx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from cpython.bool cimport PyBool_FromLong
55
from libcpp.atomic cimport atomic
66

7+
import copy
78
import types
89
from collections.abc import MutableSequence
910

@@ -122,5 +123,26 @@ cdef class FrozenList:
122123
else:
123124
raise RuntimeError("Cannot hash unfrozen list.")
124125

126+
def __deepcopy__(self, memo):
127+
cdef FrozenList new_list
128+
obj_id = id(self)
129+
130+
# Return existing copy if already processed (circular reference)
131+
if obj_id in memo:
132+
return memo[obj_id]
133+
134+
# Create new instance and register immediately
135+
new_list = self.__class__([])
136+
memo[obj_id] = new_list
137+
138+
# Deep copy items
139+
new_list._items[:] = [copy.deepcopy(item, memo) for item in self._items]
140+
141+
# Preserve frozen state
142+
if self._frozen.load():
143+
new_list.freeze()
144+
145+
return new_list
146+
125147

126148
MutableSequence.register(FrozenList)

tests/test_frozenlist.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# mypy: disable-error-code="misc"
33

44
from collections.abc import MutableSequence
5+
from copy import deepcopy
56

67
import pytest
78

@@ -248,6 +249,129 @@ def test_count(self) -> None:
248249
_list = self.FrozenList([1, 2])
249250
assert _list.count(1) == 1
250251

252+
def test_deepcopy_unfrozen(self) -> None:
253+
orig = self.FrozenList([1, 2, 3])
254+
copied = deepcopy(orig)
255+
assert copied == orig
256+
assert copied is not orig
257+
assert list(copied) == list(orig)
258+
assert not copied.frozen
259+
# Verify the copy is mutable
260+
copied.append(4)
261+
assert len(copied) == 4
262+
assert len(orig) == 3
263+
264+
def test_deepcopy_frozen(self) -> None:
265+
orig = self.FrozenList([1, 2, 3])
266+
orig.freeze()
267+
copied = deepcopy(orig)
268+
assert copied == orig
269+
assert copied is not orig
270+
assert list(copied) == list(orig)
271+
assert copied.frozen
272+
# Verify the copy is also frozen
273+
with pytest.raises(RuntimeError):
274+
copied.append(4)
275+
276+
def test_deepcopy_nested(self) -> None:
277+
inner = self.FrozenList([1, 2])
278+
orig = self.FrozenList([inner, 3])
279+
copied = deepcopy(orig)
280+
assert copied == orig
281+
assert copied[0] is not orig[0]
282+
assert isinstance(copied[0], self.FrozenList)
283+
# Modify the inner list in the copy
284+
copied[0].append(3)
285+
assert len(copied[0]) == 3
286+
assert len(orig[0]) == 2
287+
288+
def test_deepcopy_circular(self) -> None:
289+
orig = self.FrozenList([1, 2])
290+
orig.append(orig) # Create circular reference
291+
292+
copied = deepcopy(orig)
293+
294+
# Check structure is preserved
295+
assert len(copied) == 3
296+
assert copied[0] == 1
297+
assert copied[1] == 2
298+
assert copied[2] is copied # Circular reference preserved
299+
300+
# Verify they are different objects
301+
assert copied is not orig
302+
assert copied[2] is not orig
303+
304+
# Modify the copy
305+
copied.append(3)
306+
assert len(copied) == 4
307+
assert len(orig) == 3
308+
309+
def test_deepcopy_circular_frozen(self) -> None:
310+
orig = self.FrozenList([1, 2])
311+
orig.append(orig) # Create circular reference
312+
orig.freeze()
313+
314+
copied = deepcopy(orig)
315+
316+
# Check structure is preserved
317+
assert len(copied) == 3
318+
assert copied[0] == 1
319+
assert copied[1] == 2
320+
assert copied[2] is copied # Circular reference preserved
321+
assert copied.frozen
322+
323+
# Verify frozen state
324+
with pytest.raises(RuntimeError):
325+
copied.append(3)
326+
327+
def test_deepcopy_nested_circular(self) -> None:
328+
# Create a complex nested structure with circular references
329+
inner1 = self.FrozenList([1, 2])
330+
inner2 = self.FrozenList([3, 4])
331+
orig = self.FrozenList([inner1, inner2])
332+
333+
# Add circular references
334+
inner1.append(inner2) # inner1 -> inner2
335+
inner2.append(orig) # inner2 -> orig (outer list)
336+
orig.append(orig) # orig -> orig (self reference)
337+
338+
copied = deepcopy(orig)
339+
340+
# Verify structure
341+
assert len(copied) == 3
342+
assert isinstance(copied[0], self.FrozenList)
343+
assert isinstance(copied[1], self.FrozenList)
344+
assert copied[2] is copied # Self reference preserved
345+
346+
# Verify nested circular references
347+
assert len(copied[0]) == 3
348+
assert copied[0][2] is copied[1] # inner1 -> inner2 preserved
349+
assert len(copied[1]) == 3
350+
assert copied[1][2] is copied # inner2 -> orig preserved
351+
352+
# All objects should be new instances
353+
assert copied is not orig
354+
assert copied[0] is not orig[0]
355+
assert copied[1] is not orig[1]
356+
357+
def test_deepcopy_multiple_references(self) -> None:
358+
# Test that multiple references to the same object are preserved
359+
shared = self.FrozenList([1, 2])
360+
orig = self.FrozenList([shared, shared, 3])
361+
362+
copied = deepcopy(orig)
363+
364+
# Both references should point to the same copied object
365+
assert copied[0] is copied[1]
366+
assert copied[0] is not shared
367+
assert isinstance(copied[0], self.FrozenList)
368+
369+
# Modify through one reference
370+
copied[0].append(3)
371+
assert len(copied[0]) == 3
372+
assert len(copied[1]) == 3 # Should see the change
373+
assert len(shared) == 2 # Original unchanged
374+
251375

252376
class TestFrozenList(FrozenListMixin):
253377
FrozenList = FrozenList # type: ignore[assignment] # FIXME

0 commit comments

Comments
 (0)