From 55e07ae86be204569f1a6ffbce095b5a15f1e788 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 14 Jan 2026 20:56:56 +0100 Subject: [PATCH 01/12] Hash and compare cells by identity --- Lib/annotationlib.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 4085cc6bef7954..ea28037c7a2b99 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -279,7 +279,8 @@ def __eq__(self, other): # because dictionaries are not hashable. and self.__globals__ is other.__globals__ and self.__forward_is_class__ == other.__forward_is_class__ - and self.__cell__ == other.__cell__ + # Two separate cells are always considered unequal in forward refs. + and self.__cell__ is other.__cell__ and self.__owner__ == other.__owner__ and ( (tuple(sorted(self.__extra_names__.items())) if self.__extra_names__ else None) == @@ -293,7 +294,10 @@ def __hash__(self): self.__forward_module__, id(self.__globals__), # dictionaries are not hashable, so hash by identity self.__forward_is_class__, - tuple(sorted(self.__cell__.items())) if isinstance(self.__cell__, dict) else self.__cell__, + ( # cells are mutable and not hashable as well + tuple(sorted([(name, id(cell)) for name, cell in self.__cell__.items()])) + if isinstance(self.__cell__, dict) else id(self.__cell__), + ), self.__owner__, tuple(sorted(self.__extra_names__.items())) if self.__extra_names__ else None, )) From ff81d6a838d17e3a33100069dd48bfba5ceb2cf6 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Fri, 16 Jan 2026 04:38:35 +0100 Subject: [PATCH 02/12] Use appropriate semantics in `__eq__` --- Lib/annotationlib.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index ea28037c7a2b99..cbfe62f23fbedd 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -280,7 +280,12 @@ def __eq__(self, other): and self.__globals__ is other.__globals__ and self.__forward_is_class__ == other.__forward_is_class__ # Two separate cells are always considered unequal in forward refs. - and self.__cell__ is other.__cell__ + and ( + dict(zip(self.__cell__, map(id, self.__cell__.values()))) + == dict(zip(other.__cell__, map(id, other.__cell__.values()))) + if isinstance(self.__cell__, dict) and isinstance(other.__cell__, dict) + else self.__cell__ is other.__cell__ + ) and self.__owner__ == other.__owner__ and ( (tuple(sorted(self.__extra_names__.items())) if self.__extra_names__ else None) == @@ -294,7 +299,7 @@ def __hash__(self): self.__forward_module__, id(self.__globals__), # dictionaries are not hashable, so hash by identity self.__forward_is_class__, - ( # cells are mutable and not hashable as well + ( # cells are not hashable as well tuple(sorted([(name, id(cell)) for name, cell in self.__cell__.items()])) if isinstance(self.__cell__, dict) else id(self.__cell__), ), From 6c36442fbd9e49e2f9b717d5ce4e069e5df5e43b Mon Sep 17 00:00:00 2001 From: johnslavik Date: Fri, 16 Jan 2026 06:01:26 +0100 Subject: [PATCH 03/12] Add base test --- Lib/test/test_annotationlib.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index a8537871d294cf..79e1273accf3bb 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1862,6 +1862,29 @@ def foo(a: c1_gth, b: c2_gth): self.assertNotEqual(hash(c3), hash(c4)) self.assertEqual(hash(c3), hash(ForwardRef("int", module=__name__))) + def test_forward_equality_and_hash_with_cells(self): + """Regression test for GH-143831.""" + + class C[T]: + def one(self) -> C: # one cell: C + pass + + one_f = ForwardRef("C", owner=one) + one_f_ga = get_annotations(one, format=Format.FORWARDREF)["return"] + + def two(self) -> C[T]: # two cells: C, T + pass + + two_f_ga1 = get_annotations(two, format=Format.FORWARDREF)["return"] + two_f_ga2 = get_annotations(two, format=Format.FORWARDREF)["return"] + + self.assertNotEqual(C.one_f, C.one_f_ga) + self.assertNotEqual(hash(C.one_f), hash(C.one_f_ga)) + + self.assertIsNot(C.two_f_ga1, C.two_f_ga2) # self-test + self.assertEqual(C.two_f_ga1, C.two_f_ga2) # same cell + self.assertEqual(hash(C.two_f_ga1), hash(C.two_f_ga2)) + def test_forward_equality_namespace(self): def namespace1(): a = ForwardRef("A") From d8f5bc8a6816c3ea6c2a8f9df2d7acddbc738f23 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Fri, 16 Jan 2026 06:18:35 +0100 Subject: [PATCH 04/12] Test two paths --- Lib/test/test_annotationlib.py | 38 +++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 79e1273accf3bb..3bcd5855304b7b 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -8,6 +8,7 @@ import itertools import pickle from string.templatelib import Template, Interpolation +import types import typing import sys import unittest @@ -1864,26 +1865,39 @@ def foo(a: c1_gth, b: c2_gth): def test_forward_equality_and_hash_with_cells(self): """Regression test for GH-143831.""" - - class C[T]: - def one(self) -> C: # one cell: C - pass + class A: + def one(_) -> C1: + """One cell.""" one_f = ForwardRef("C", owner=one) - one_f_ga = get_annotations(one, format=Format.FORWARDREF)["return"] + one_f_ga1 = get_annotations(one, format=Format.FORWARDREF)["return"] + one_f_ga2 = get_annotations(one, format=Format.FORWARDREF)["return"] - def two(self) -> C[T]: # two cells: C, T - pass + def two(_) -> C1 | C2: + """Two cells.""" two_f_ga1 = get_annotations(two, format=Format.FORWARDREF)["return"] two_f_ga2 = get_annotations(two, format=Format.FORWARDREF)["return"] - self.assertNotEqual(C.one_f, C.one_f_ga) - self.assertNotEqual(hash(C.one_f), hash(C.one_f_ga)) + type C1 = None + type C2 = None + + self.assertNotEqual(A.one_f, A.one_f_ga1) + self.assertNotEqual(hash(A.one_f), hash(A.one_f_ga1)) + + self.assertIs(A.one_f_ga1.__cell__, A.one_f_ga1.__cell__) + self.assertIsInstance(A.one_f_ga1.__cell__, types.CellType) + self.assertIsInstance(A.one_f_ga1.__cell__, types.CellType) + + self.assertEqual(A.one_f_ga1, A.one_f_ga2) + self.assertEqual(hash(A.one_f_ga1), hash(A.one_f_ga2)) + + self.assertIsNot(A.two_f_ga1.__cell__, A.two_f_ga2.__cell__) + self.assertIsInstance(A.two_f_ga1.__cell__, dict) + self.assertIsInstance(A.two_f_ga1.__cell__, dict) - self.assertIsNot(C.two_f_ga1, C.two_f_ga2) # self-test - self.assertEqual(C.two_f_ga1, C.two_f_ga2) # same cell - self.assertEqual(hash(C.two_f_ga1), hash(C.two_f_ga2)) + self.assertEqual(A.two_f_ga1, A.two_f_ga2) + self.assertEqual(hash(A.two_f_ga1), hash(A.two_f_ga2)) def test_forward_equality_namespace(self): def namespace1(): From 348127fe4b92583e84eb39d78d565b5d0beee630 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Fri, 16 Jan 2026 06:18:54 +0100 Subject: [PATCH 05/12] Simplify first self-test --- Lib/test/test_annotationlib.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 3bcd5855304b7b..c5f874fa16063d 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1885,9 +1885,8 @@ def two(_) -> C1 | C2: self.assertNotEqual(A.one_f, A.one_f_ga1) self.assertNotEqual(hash(A.one_f), hash(A.one_f_ga1)) - self.assertIs(A.one_f_ga1.__cell__, A.one_f_ga1.__cell__) - self.assertIsInstance(A.one_f_ga1.__cell__, types.CellType) self.assertIsInstance(A.one_f_ga1.__cell__, types.CellType) + self.assertIs(A.one_f_ga1.__cell__, A.one_f_ga1.__cell__) self.assertEqual(A.one_f_ga1, A.one_f_ga2) self.assertEqual(hash(A.one_f_ga1), hash(A.one_f_ga2)) From bc86ed28159817f0e93759acd2eb80a93ab39798 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Fri, 16 Jan 2026 06:19:23 +0100 Subject: [PATCH 06/12] Fix first self-test (typo) --- Lib/test/test_annotationlib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index c5f874fa16063d..37bd7c1c253777 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1886,7 +1886,7 @@ def two(_) -> C1 | C2: self.assertNotEqual(hash(A.one_f), hash(A.one_f_ga1)) self.assertIsInstance(A.one_f_ga1.__cell__, types.CellType) - self.assertIs(A.one_f_ga1.__cell__, A.one_f_ga1.__cell__) + self.assertIs(A.one_f_ga1.__cell__, A.one_f_ga2.__cell__) self.assertEqual(A.one_f_ga1, A.one_f_ga2) self.assertEqual(hash(A.one_f_ga1), hash(A.one_f_ga2)) From d434a40a8502b170140f856c40c3f4b8698d2778 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Fri, 16 Jan 2026 06:19:40 +0100 Subject: [PATCH 07/12] Fix second self-test (typo) --- Lib/test/test_annotationlib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 37bd7c1c253777..067877bde605be 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1893,7 +1893,7 @@ def two(_) -> C1 | C2: self.assertIsNot(A.two_f_ga1.__cell__, A.two_f_ga2.__cell__) self.assertIsInstance(A.two_f_ga1.__cell__, dict) - self.assertIsInstance(A.two_f_ga1.__cell__, dict) + self.assertIsInstance(A.two_f_ga2.__cell__, dict) self.assertEqual(A.two_f_ga1, A.two_f_ga2) self.assertEqual(hash(A.two_f_ga1), hash(A.two_f_ga2)) From 1f36f76765511f7dab3d5ca88c7c0a2238f92b76 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Fri, 16 Jan 2026 06:22:14 +0100 Subject: [PATCH 08/12] Add news entry --- .../Library/2026-01-16-06-22-10.gh-issue-143831.VLBTLp.rst | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-01-16-06-22-10.gh-issue-143831.VLBTLp.rst diff --git a/Misc/NEWS.d/next/Library/2026-01-16-06-22-10.gh-issue-143831.VLBTLp.rst b/Misc/NEWS.d/next/Library/2026-01-16-06-22-10.gh-issue-143831.VLBTLp.rst new file mode 100644 index 00000000000000..620adea1b6d782 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-01-16-06-22-10.gh-issue-143831.VLBTLp.rst @@ -0,0 +1,3 @@ +:class:`annotationlib.ForwardRef` objects are now hashable when created from +annotation scopes with closures. Previously, hashing such objects would +throw an exception. Patch by Bartosz Sławecki. From 32a3bef0fe7ebf5252ce9c6500b29b978263bab1 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Fri, 16 Jan 2026 06:25:42 +0100 Subject: [PATCH 09/12] Re-run CI From 866aef183f1511bf6b0cca25a239bcdd271624e7 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Fri, 16 Jan 2026 06:45:47 +0100 Subject: [PATCH 10/12] Move self-tests to class level --- Lib/test/test_annotationlib.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 067877bde605be..87376073f3726b 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1872,12 +1872,17 @@ def one(_) -> C1: one_f = ForwardRef("C", owner=one) one_f_ga1 = get_annotations(one, format=Format.FORWARDREF)["return"] one_f_ga2 = get_annotations(one, format=Format.FORWARDREF)["return"] + self.assertIsInstance(one_f_ga1.__cell__, types.CellType) + self.assertIs(one_f_ga1.__cell__, one_f_ga2.__cell__) def two(_) -> C1 | C2: """Two cells.""" two_f_ga1 = get_annotations(two, format=Format.FORWARDREF)["return"] two_f_ga2 = get_annotations(two, format=Format.FORWARDREF)["return"] + self.assertIsNot(two_f_ga1.__cell__, two_f_ga2.__cell__) + self.assertIsInstance(two_f_ga1.__cell__, dict) + self.assertIsInstance(two_f_ga2.__cell__, dict) type C1 = None type C2 = None @@ -1885,16 +1890,9 @@ def two(_) -> C1 | C2: self.assertNotEqual(A.one_f, A.one_f_ga1) self.assertNotEqual(hash(A.one_f), hash(A.one_f_ga1)) - self.assertIsInstance(A.one_f_ga1.__cell__, types.CellType) - self.assertIs(A.one_f_ga1.__cell__, A.one_f_ga2.__cell__) - self.assertEqual(A.one_f_ga1, A.one_f_ga2) self.assertEqual(hash(A.one_f_ga1), hash(A.one_f_ga2)) - self.assertIsNot(A.two_f_ga1.__cell__, A.two_f_ga2.__cell__) - self.assertIsInstance(A.two_f_ga1.__cell__, dict) - self.assertIsInstance(A.two_f_ga2.__cell__, dict) - self.assertEqual(A.two_f_ga1, A.two_f_ga2) self.assertEqual(hash(A.two_f_ga1), hash(A.two_f_ga2)) From 094f0f8e6b45bb1d7647199870cacef2e210a8b4 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Fri, 16 Jan 2026 13:53:02 +0100 Subject: [PATCH 11/12] Fix typo in a test forwardref --- Lib/test/test_annotationlib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 87376073f3726b..6b75da32fa944a 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1869,7 +1869,7 @@ class A: def one(_) -> C1: """One cell.""" - one_f = ForwardRef("C", owner=one) + one_f = ForwardRef("C1", owner=one) one_f_ga1 = get_annotations(one, format=Format.FORWARDREF)["return"] one_f_ga2 = get_annotations(one, format=Format.FORWARDREF)["return"] self.assertIsInstance(one_f_ga1.__cell__, types.CellType) From 416c501a3806154b93c298d080e08e570ac17ba2 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Sun, 18 Jan 2026 03:57:50 +0100 Subject: [PATCH 12/12] Use dict comprehensions instead of dict+zip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dict comprehensions are more readable and faster: ❯ ./python -m timeit -s 'import types; c = dict(zip("abcdefghi", iter(types.CellType, None)))' 'dict(zip(c, map(id, c.values())))' 100000 loops, best of 5: 3.15 usec per loop ❯ ./python -m timeit -s 'import types; c = dict(zip("abcdefghi", iter(types.CellType, None)))' '{name: id(cell) for name, cell in c.items()}' 100000 loops, best of 5: 2.86 usec per loop --- Lib/annotationlib.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index cbfe62f23fbedd..832d160de7f4e5 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -281,8 +281,8 @@ def __eq__(self, other): and self.__forward_is_class__ == other.__forward_is_class__ # Two separate cells are always considered unequal in forward refs. and ( - dict(zip(self.__cell__, map(id, self.__cell__.values()))) - == dict(zip(other.__cell__, map(id, other.__cell__.values()))) + {name: id(cell) for name, cell in self.__cell__.items()} + == {name: id(cell) for name, cell in other.__cell__.items()} if isinstance(self.__cell__, dict) and isinstance(other.__cell__, dict) else self.__cell__ is other.__cell__ )