From 17c0eb924ddc2295bfcff753ac936d3cb41aa5f6 Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Fri, 24 Jul 2026 15:42:02 +0800 Subject: [PATCH 1/3] gh-154594: Fix deepcopy memo lookup when __deepcopy__ returns None --- Lib/copy.py | 6 ++++-- Lib/test/test_copy.py | 13 +++++++++++++ .../2026-07-24-00-02-00.gh-issue-154594.Xk7mQ2.rst | 3 +++ 3 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-24-00-02-00.gh-issue-154594.Xk7mQ2.rst diff --git a/Lib/copy.py b/Lib/copy.py index 6149301ad1389e2..ac15c439f22ebe5 100644 --- a/Lib/copy.py +++ b/Lib/copy.py @@ -59,6 +59,8 @@ class Error(Exception): __all__ = ["Error", "copy", "deepcopy", "replace"] +_MEMO_MISS = object() + def copy(x): """Shallow copy operation on arbitrary Python objects. @@ -122,8 +124,8 @@ def deepcopy(x, memo=None): if memo is None: memo = {} else: - y = memo.get(d, None) - if y is not None: + y = memo.get(d, _MEMO_MISS) + if y is not _MEMO_MISS: return y copier = _deepcopy_dispatch.get(cls) diff --git a/Lib/test/test_copy.py b/Lib/test/test_copy.py index 9455c9e00514ca4..42560d0342d5855 100644 --- a/Lib/test/test_copy.py +++ b/Lib/test/test_copy.py @@ -939,6 +939,19 @@ def m(self): self.assertIs(g.b.__self__, g) g.b() + def test_deepcopy_memo_none_result(self): + # Objects whose deepcopy result is None must still be memoized. + call_count = 0 + class C: + def __deepcopy__(self, memo): + nonlocal call_count + call_count += 1 + memo[id(self)] = None + return None + obj = C() + copy.deepcopy([obj, obj, obj]) + self.assertEqual(call_count, 1) + class TestReplace(unittest.TestCase): diff --git a/Misc/NEWS.d/next/Library/2026-07-24-00-02-00.gh-issue-154594.Xk7mQ2.rst b/Misc/NEWS.d/next/Library/2026-07-24-00-02-00.gh-issue-154594.Xk7mQ2.rst new file mode 100644 index 000000000000000..f04b06e82c55db2 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-24-00-02-00.gh-issue-154594.Xk7mQ2.rst @@ -0,0 +1,3 @@ +Fix :func:`copy.deepcopy` memo lookup using ``None`` as the miss sentinel, +which prevented memoization of objects whose deep copy result is ``None``. +Use a private sentinel object instead. Patch by tonghuaroot. From abc037ef514d60d094a8324ec06272a7a4264cbd Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Thu, 30 Jul 2026 09:17:48 +0800 Subject: [PATCH 2/3] Use 'd in memo' lookup instead of a sentinel Addresses review: clearer, no sentinel, and the test treats memo as opaque. --- Lib/copy.py | 7 ++----- Lib/test/test_copy.py | 1 - .../Library/2026-07-24-00-02-00.gh-issue-154594.Xk7mQ2.rst | 7 ++++--- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/Lib/copy.py b/Lib/copy.py index ac15c439f22ebe5..f7fd680657b85a5 100644 --- a/Lib/copy.py +++ b/Lib/copy.py @@ -59,8 +59,6 @@ class Error(Exception): __all__ = ["Error", "copy", "deepcopy", "replace"] -_MEMO_MISS = object() - def copy(x): """Shallow copy operation on arbitrary Python objects. @@ -124,9 +122,8 @@ def deepcopy(x, memo=None): if memo is None: memo = {} else: - y = memo.get(d, _MEMO_MISS) - if y is not _MEMO_MISS: - return y + if d in memo: + return memo[d] copier = _deepcopy_dispatch.get(cls) if copier is not None: diff --git a/Lib/test/test_copy.py b/Lib/test/test_copy.py index 42560d0342d5855..12e934139d65bb7 100644 --- a/Lib/test/test_copy.py +++ b/Lib/test/test_copy.py @@ -946,7 +946,6 @@ class C: def __deepcopy__(self, memo): nonlocal call_count call_count += 1 - memo[id(self)] = None return None obj = C() copy.deepcopy([obj, obj, obj]) diff --git a/Misc/NEWS.d/next/Library/2026-07-24-00-02-00.gh-issue-154594.Xk7mQ2.rst b/Misc/NEWS.d/next/Library/2026-07-24-00-02-00.gh-issue-154594.Xk7mQ2.rst index f04b06e82c55db2..273263a4db5c795 100644 --- a/Misc/NEWS.d/next/Library/2026-07-24-00-02-00.gh-issue-154594.Xk7mQ2.rst +++ b/Misc/NEWS.d/next/Library/2026-07-24-00-02-00.gh-issue-154594.Xk7mQ2.rst @@ -1,3 +1,4 @@ -Fix :func:`copy.deepcopy` memo lookup using ``None`` as the miss sentinel, -which prevented memoization of objects whose deep copy result is ``None``. -Use a private sentinel object instead. Patch by tonghuaroot. +Fix :func:`copy.deepcopy` so that an object whose deep copy is ``None`` is +still memoized. The memo miss was detected with ``None``, which is a valid +result, so such objects were copied again on every reference. Patch by +tonghuaroot. From 37b18f042762213da2fe5380e66625bca03eab20 Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Thu, 30 Jul 2026 20:38:25 +0800 Subject: [PATCH 3/3] Apply review suggestions: shorten NEWS, use class attribute in test --- Lib/test/test_copy.py | 7 +++---- .../Library/2026-07-24-00-02-00.gh-issue-154594.Xk7mQ2.rst | 4 +--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/Lib/test/test_copy.py b/Lib/test/test_copy.py index 12e934139d65bb7..0e7ed8fe48f9df0 100644 --- a/Lib/test/test_copy.py +++ b/Lib/test/test_copy.py @@ -941,15 +941,14 @@ def m(self): def test_deepcopy_memo_none_result(self): # Objects whose deepcopy result is None must still be memoized. - call_count = 0 class C: + call_count = 0 def __deepcopy__(self, memo): - nonlocal call_count - call_count += 1 + C.call_count += 1 return None obj = C() copy.deepcopy([obj, obj, obj]) - self.assertEqual(call_count, 1) + self.assertEqual(C.call_count, 1) class TestReplace(unittest.TestCase): diff --git a/Misc/NEWS.d/next/Library/2026-07-24-00-02-00.gh-issue-154594.Xk7mQ2.rst b/Misc/NEWS.d/next/Library/2026-07-24-00-02-00.gh-issue-154594.Xk7mQ2.rst index 273263a4db5c795..5f96bba97aa19f4 100644 --- a/Misc/NEWS.d/next/Library/2026-07-24-00-02-00.gh-issue-154594.Xk7mQ2.rst +++ b/Misc/NEWS.d/next/Library/2026-07-24-00-02-00.gh-issue-154594.Xk7mQ2.rst @@ -1,4 +1,2 @@ Fix :func:`copy.deepcopy` so that an object whose deep copy is ``None`` is -still memoized. The memo miss was detected with ``None``, which is a valid -result, so such objects were copied again on every reference. Patch by -tonghuaroot. +still memoized. Patch by tonghuaroot.