From 42ae3722e0e55789a9f6c92c64618439e9ad9b89 Mon Sep 17 00:00:00 2001 From: Arpit Khandelwal <71580150+arpittkhandelwal@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:05:03 +0530 Subject: [PATCH 1/2] gh-160948: Fix annotationlib.ForwardRef.evaluate() ignoring explicit globals/locals for closure-cell references --- Lib/annotationlib.py | 19 ++++++- Lib/test/test_annotationlib.py | 49 +++++++++++++++++++ ...-10-30-10-03-32.gh-issue-160948.k9Wd3s.rst | 4 ++ 3 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-10-30-10-03-32.gh-issue-160948.k9Wd3s.rst diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index c41df197eee7d5..00efaf86d5d619 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -122,7 +122,15 @@ def evaluate( is_forwardref_format = True case _: raise NotImplementedError(format) - if isinstance(self.__cell__, types.CellType): + if ( + globals is None + and locals is None + and isinstance(self.__cell__, types.CellType) + ): + # The cell holds the value of the name from the enclosing scope. + # Explicitly passed namespaces take precedence over it, so use + # this shortcut only if neither was provided; otherwise, the + # cell value is used as a fallback during name resolution below. try: return self.__cell__.cell_contents except ValueError: @@ -196,7 +204,14 @@ def evaluate( resolved = locals[arg] elif arg in globals: resolved = globals[arg] - elif hasattr(builtins, arg): + elif isinstance(self.__cell__, types.CellType): + # Fall back to the value from the enclosing scope if the + # name is not in either of the provided namespaces. + try: + resolved = self.__cell__.cell_contents + except ValueError: + pass + if resolved is _sentinel and hasattr(builtins, arg): resolved = getattr(builtins, arg) if resolved is not _sentinel: diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 56e5731849cbd7..b7b3cca09a9c8f 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -2525,6 +2525,55 @@ class C[T]: alias = list + def test_evaluate_cell_with_explicit_namespaces(self): + def inner(arg: x): + pass + + fwdref = get_annotations(inner, format=Format.FORWARDREF)["arg"] + self.assertIsInstance(fwdref, ForwardRef) + self.assertIsInstance(fwdref.__cell__, types.CellType) + + x = 1 + # With no explicit namespaces, the value from the enclosing + # scope, held in the cell, is used. + self.assertEqual(fwdref.evaluate(), 1) + # Explicitly passed namespaces take precedence over the cell. + self.assertEqual(fwdref.evaluate(locals={"x": 2}), 2) + self.assertEqual(fwdref.evaluate(globals={"x": 3}), 3) + self.assertEqual(fwdref.evaluate(globals={"x": 3}, locals={"x": 2}), 2) + self.assertEqual( + fwdref.evaluate(locals={"x": 2}, format=Format.FORWARDREF), 2 + ) + # If the name is not in the explicit namespaces, the cell + # value is used as a fallback. + self.assertEqual(fwdref.evaluate(locals={}), 1) + self.assertEqual(fwdref.evaluate(globals={}), 1) + self.assertEqual(fwdref.evaluate(locals={"y": 2}), 1) + + def test_evaluate_empty_cell_with_explicit_namespaces(self): + def inner(arg: x): + pass + + fwdref = get_annotations(inner, format=Format.FORWARDREF)["arg"] + self.assertIsInstance(fwdref, ForwardRef) + self.assertIsInstance(fwdref.__cell__, types.CellType) + + x = 1 + del x + # The cell is empty, so the name cannot be resolved without + # explicit namespaces. + with self.assertRaises(NameError): + fwdref.evaluate() + # Explicitly passed namespaces are still consulted. + self.assertEqual(fwdref.evaluate(locals={"x": 2}), 2) + self.assertEqual(fwdref.evaluate(globals={"x": 3}), 3) + + # Refilling the cell restores it as the fallback, but explicit + # namespaces still take precedence over it. + x = 7 + self.assertEqual(fwdref.evaluate(), 7) + self.assertEqual(fwdref.evaluate(locals={"x": 2}), 2) + def test_fwdref_final_class(self): with self.assertRaises(TypeError): class C(ForwardRef): diff --git a/Misc/NEWS.d/next/Library/2026-10-30-10-03-32.gh-issue-160948.k9Wd3s.rst b/Misc/NEWS.d/next/Library/2026-10-30-10-03-32.gh-issue-160948.k9Wd3s.rst new file mode 100644 index 00000000000000..5ccf2b2d645544 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-30-10-03-32.gh-issue-160948.k9Wd3s.rst @@ -0,0 +1,4 @@ +Fixed :meth:`annotationlib.ForwardRef.evaluate` silently ignoring +explicitly passed *globals* and *locals* when the reference holds a +single closure cell. Explicit namespaces now take precedence, as on the +other resolution paths, and the cell value is used as a fallback. From 73556faa89fff6dd5478fca78575266aa10b48f4 Mon Sep 17 00:00:00 2001 From: Arpit Khandelwal <71580150+arpittkhandelwal@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:04:27 +0530 Subject: [PATCH 2/2] gh-160948: Only an explicitly passed locals takes precedence over the closure cell --- Lib/annotationlib.py | 19 +++++++++++-------- Lib/test/test_annotationlib.py | 16 ++++++++++++---- ...-10-30-10-03-32.gh-issue-160948.k9Wd3s.rst | 9 +++++---- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 00efaf86d5d619..ba5902cfac8c30 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -128,9 +128,10 @@ def evaluate( and isinstance(self.__cell__, types.CellType) ): # The cell holds the value of the name from the enclosing scope. - # Explicitly passed namespaces take precedence over it, so use - # this shortcut only if neither was provided; otherwise, the - # cell value is used as a fallback during name resolution below. + # Take this shortcut only if no namespaces were provided; + # otherwise, the resolution below gives an explicitly passed + # locals precedence over the cell, and the cell precedence + # over globals. try: return self.__cell__.cell_contents except ValueError: @@ -202,16 +203,18 @@ def evaluate( resolved = _sentinel if arg in locals: resolved = locals[arg] - elif arg in globals: - resolved = globals[arg] elif isinstance(self.__cell__, types.CellType): - # Fall back to the value from the enclosing scope if the - # name is not in either of the provided namespaces. + # The cell holds the value of the name from the enclosing + # scope. Like the dict-of-cells path above, which merges + # cells into locals with setdefault, it takes precedence + # over globals, but not over locals. try: resolved = self.__cell__.cell_contents except ValueError: pass - if resolved is _sentinel and hasattr(builtins, arg): + if resolved is _sentinel and arg in globals: + resolved = globals[arg] + elif resolved is _sentinel and hasattr(builtins, arg): resolved = getattr(builtins, arg) if resolved is not _sentinel: diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index b7b3cca09a9c8f..61073043e0c933 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -2537,13 +2537,20 @@ def inner(arg: x): # With no explicit namespaces, the value from the enclosing # scope, held in the cell, is used. self.assertEqual(fwdref.evaluate(), 1) - # Explicitly passed namespaces take precedence over the cell. + # An explicitly passed locals takes precedence over the cell, + # but globals does not: the cell holds a nonlocal binding, which + # sits between locals and globals in the lookup order (as in + # the dict-of-cells path, where the cells are merged into the + # locals with setdefault). self.assertEqual(fwdref.evaluate(locals={"x": 2}), 2) - self.assertEqual(fwdref.evaluate(globals={"x": 3}), 3) + self.assertEqual(fwdref.evaluate(globals={"x": 3}), 1) self.assertEqual(fwdref.evaluate(globals={"x": 3}, locals={"x": 2}), 2) self.assertEqual( fwdref.evaluate(locals={"x": 2}, format=Format.FORWARDREF), 2 ) + self.assertEqual( + fwdref.evaluate(globals={"x": 3}, format=Format.FORWARDREF), 1 + ) # If the name is not in the explicit namespaces, the cell # value is used as a fallback. self.assertEqual(fwdref.evaluate(locals={}), 1) @@ -2568,11 +2575,12 @@ def inner(arg: x): self.assertEqual(fwdref.evaluate(locals={"x": 2}), 2) self.assertEqual(fwdref.evaluate(globals={"x": 3}), 3) - # Refilling the cell restores it as the fallback, but explicit - # namespaces still take precedence over it. + # Refilling the cell restores it: an explicitly passed locals + # still takes precedence over it, but globals does not. x = 7 self.assertEqual(fwdref.evaluate(), 7) self.assertEqual(fwdref.evaluate(locals={"x": 2}), 2) + self.assertEqual(fwdref.evaluate(globals={"x": 3}), 7) def test_fwdref_final_class(self): with self.assertRaises(TypeError): diff --git a/Misc/NEWS.d/next/Library/2026-10-30-10-03-32.gh-issue-160948.k9Wd3s.rst b/Misc/NEWS.d/next/Library/2026-10-30-10-03-32.gh-issue-160948.k9Wd3s.rst index 5ccf2b2d645544..19bcd76a815a39 100644 --- a/Misc/NEWS.d/next/Library/2026-10-30-10-03-32.gh-issue-160948.k9Wd3s.rst +++ b/Misc/NEWS.d/next/Library/2026-10-30-10-03-32.gh-issue-160948.k9Wd3s.rst @@ -1,4 +1,5 @@ -Fixed :meth:`annotationlib.ForwardRef.evaluate` silently ignoring -explicitly passed *globals* and *locals* when the reference holds a -single closure cell. Explicit namespaces now take precedence, as on the -other resolution paths, and the cell value is used as a fallback. +Fixed :meth:`annotationlib.ForwardRef.evaluate` silently ignoring an +explicitly passed *locals* when the reference holds a single closure +cell. An explicit *locals* now takes precedence over the cell, as on +the other resolution paths; the cell still takes precedence over +*globals*, since it holds a nonlocal binding.