diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index c41df197eee7d5..ba5902cfac8c30 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -122,7 +122,16 @@ 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. + # 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: @@ -194,9 +203,18 @@ def evaluate( resolved = _sentinel if arg in locals: resolved = locals[arg] - elif arg in globals: + elif isinstance(self.__cell__, types.CellType): + # 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 arg in globals: resolved = globals[arg] - elif hasattr(builtins, 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 56e5731849cbd7..61073043e0c933 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -2525,6 +2525,63 @@ 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) + # 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}), 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) + 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: 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): 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..19bcd76a815a39 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-30-10-03-32.gh-issue-160948.k9Wd3s.rst @@ -0,0 +1,5 @@ +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.