From 8708272e86ba62767a5eb23b171633a205728b24 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Tue, 4 Nov 2025 10:28:07 +1030 Subject: [PATCH 01/10] Extract type param scope instead of copying globals dict --- Lib/annotationlib.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 2166dbff0ee70c..ff47151d6e5361 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -161,14 +161,17 @@ def evaluate( # as a way of emulating annotation scopes when calling `eval()` type_params = getattr(owner, "__type_params__", None) - # Type parameters exist in their own scope, which is logically - # between the locals and the globals. We simulate this by adding - # them to the globals. Similar reasoning applies to nonlocals stored in cells. - if type_params is not None or isinstance(self.__cell__, dict): + # Nonlocals logically sit between the locals and the globals. We simulate this + # by overriding the globals. + if isinstance(self.__cell__, dict): globals = dict(globals) + + # Type parameters exist in their own scope, which is logically + # between the locals and the globals. + type_param_scope = {} if type_params is not None: for param in type_params: - globals[param.__name__] = param + type_param_scope[param.__name__] = param if isinstance(self.__cell__, dict): for cell_name, cell_value in self.__cell__.items(): try: @@ -182,6 +185,8 @@ def evaluate( if arg.isidentifier() and not keyword.iskeyword(arg): if arg in locals: return locals[arg] + elif arg in type_param_scope: + return type_param_scope[arg] elif arg in globals: return globals[arg] elif hasattr(builtins, arg): @@ -193,7 +198,7 @@ def evaluate( else: code = self.__forward_code__ try: - return eval(code, globals=globals, locals=locals) + return eval(code, globals=globals, locals={**type_param_scope, **locals}) except Exception: if not is_forwardref_format: raise @@ -201,7 +206,7 @@ def evaluate( # All variables, in scoping order, should be checked before # triggering __missing__ to create a _Stringifier. new_locals = _StringifierDict( - {**builtins.__dict__, **globals, **locals}, + {**builtins.__dict__, **globals, **type_param_scope, **locals}, globals=globals, owner=owner, is_class=self.__forward_is_class__, From fc00d21991a943b6ef32468a7f069c09098c3c88 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Tue, 4 Nov 2025 13:52:04 +1030 Subject: [PATCH 02/10] Add test for re-evaluating ForwardRef generics --- Lib/test/test_annotationlib.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index fd5d43b09b9702..8c7db955a5b125 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1926,6 +1926,26 @@ def test_fwdref_invalid_syntax(self): with self.assertRaises(SyntaxError): fr.evaluate() + def test_re_evaluate_generics(self): + global global_alias + + # If we've already run this test before, + # ensure the variable is still undefined + if "global_alias" in globals(): + del global_alias + + class C: + x: global_alias[int] + + # Evaluate the ForwardRef once + evaluated = get_annotations(C, format=Format.FORWARDREF)["x"].evaluate( + format=Format.FORWARDREF + ) + + # Now define the global and ensure that the ForwardRef evaluates + global_alias = list + self.assertEqual(evaluated.evaluate(), list[int]) + class TestAnnotationLib(unittest.TestCase): def test__all__(self): From 71c9074780370447e0e3a4cf68c05fcfe94c2c33 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Tue, 4 Nov 2025 14:42:03 +1030 Subject: [PATCH 03/10] Simplify scope logic in `ref.evaluate()` to ensure double-evaluation works --- Lib/annotationlib.py | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index ff47151d6e5361..cac791a861f61e 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -156,37 +156,33 @@ def evaluate( locals.update(vars(owner)) if type_params is None and owner is not None: - # "Inject" type parameters into the local namespace - # (unless they are shadowed by assignments *in* the local namespace), - # as a way of emulating annotation scopes when calling `eval()` type_params = getattr(owner, "__type_params__", None) - # Nonlocals logically sit between the locals and the globals. We simulate this - # by overriding the globals. - if isinstance(self.__cell__, dict): - globals = dict(globals) - - # Type parameters exist in their own scope, which is logically - # between the locals and the globals. - type_param_scope = {} + # "Inject" type parameters into the local namespace + # (unless they are shadowed by assignments *in* the local namespace), + # as a way of emulating annotation scopes when calling `eval()` if type_params is not None: for param in type_params: - type_param_scope[param.__name__] = param + if param.__name__ not in locals: + locals[param.__name__] = param + + # Similar logic can be used for nonlocals, which should not + # override locals. if isinstance(self.__cell__, dict): for cell_name, cell_value in self.__cell__.items(): try: - globals[cell_name] = cell_value.cell_contents + if cell_name not in locals: + locals[cell_name] = cell_value.cell_contents except ValueError: pass + if self.__extra_names__: - locals = {**locals, **self.__extra_names__} + locals.update(self.__extra_names__) arg = self.__forward_arg__ if arg.isidentifier() and not keyword.iskeyword(arg): if arg in locals: return locals[arg] - elif arg in type_param_scope: - return type_param_scope[arg] elif arg in globals: return globals[arg] elif hasattr(builtins, arg): @@ -198,7 +194,7 @@ def evaluate( else: code = self.__forward_code__ try: - return eval(code, globals=globals, locals={**type_param_scope, **locals}) + return eval(code, globals=globals, locals=locals) except Exception: if not is_forwardref_format: raise @@ -206,7 +202,7 @@ def evaluate( # All variables, in scoping order, should be checked before # triggering __missing__ to create a _Stringifier. new_locals = _StringifierDict( - {**builtins.__dict__, **globals, **type_param_scope, **locals}, + {**builtins.__dict__, **globals, **locals}, globals=globals, owner=owner, is_class=self.__forward_is_class__, From 1c79ac7a461e23018bdf52ceb3ea03434fcedcc5 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Tue, 4 Nov 2025 15:45:31 +1030 Subject: [PATCH 04/10] Add NEWS entry --- .../Library/2025-11-04-15-40-35.gh-issue-137969.9VZQVt.rst | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2025-11-04-15-40-35.gh-issue-137969.9VZQVt.rst diff --git a/Misc/NEWS.d/next/Library/2025-11-04-15-40-35.gh-issue-137969.9VZQVt.rst b/Misc/NEWS.d/next/Library/2025-11-04-15-40-35.gh-issue-137969.9VZQVt.rst new file mode 100644 index 00000000000000..dfa582bdbc8825 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-11-04-15-40-35.gh-issue-137969.9VZQVt.rst @@ -0,0 +1,3 @@ +Fix :meth:`annotationlib.ForwardRef.evaluate` returning +:class:`~annotationlib.ForwardRef` objects which don't update with new +globals. From 26308028eeff1cfeb7fcf7f62d22d5b6c2213904 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Tue, 4 Nov 2025 21:56:12 +1030 Subject: [PATCH 05/10] Update `del` to end of test_re_evaluate_generics --- Lib/test/test_annotationlib.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 8c7db955a5b125..195320b70a197a 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1929,11 +1929,6 @@ def test_fwdref_invalid_syntax(self): def test_re_evaluate_generics(self): global global_alias - # If we've already run this test before, - # ensure the variable is still undefined - if "global_alias" in globals(): - del global_alias - class C: x: global_alias[int] @@ -1946,6 +1941,9 @@ class C: global_alias = list self.assertEqual(evaluated.evaluate(), list[int]) + # If we run this test again, ensure the type is still undefined + del global_alias + class TestAnnotationLib(unittest.TestCase): def test__all__(self): From d02e6352ffc4dd3d59ce1651b42ad3d7651eca0b Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Wed, 5 Nov 2025 08:26:01 +1030 Subject: [PATCH 06/10] Create new locals dict when necessary --- Lib/annotationlib.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index cac791a861f61e..8f29bcb7be29ca 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -150,13 +150,21 @@ def evaluate( if globals is None: globals = {} + if type_params is None and owner is not None: + type_params = getattr(owner, "__type_params__", None) + if locals is None: locals = {} if isinstance(owner, type): locals.update(vars(owner)) - - if type_params is None and owner is not None: - type_params = getattr(owner, "__type_params__", None) + elif ( + type_params is not None + or isinstance(self.__cell__, dict) + or self.__extra_names__ + ): + # Create a new locals dict if necessary, + # to avoid mutating the argument. + locals = dict(locals) # "Inject" type parameters into the local namespace # (unless they are shadowed by assignments *in* the local namespace), From 02b8a835fa504dc4f594345c63c271ab7b337895 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Thu, 6 Nov 2025 22:02:17 +1030 Subject: [PATCH 07/10] Revert "Update `del` to end of test_re_evaluate_generics" This reverts commit 26308028eeff1cfeb7fcf7f62d22d5b6c2213904. --- Lib/test/test_annotationlib.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 195320b70a197a..8c7db955a5b125 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1929,6 +1929,11 @@ def test_fwdref_invalid_syntax(self): def test_re_evaluate_generics(self): global global_alias + # If we've already run this test before, + # ensure the variable is still undefined + if "global_alias" in globals(): + del global_alias + class C: x: global_alias[int] @@ -1941,9 +1946,6 @@ class C: global_alias = list self.assertEqual(evaluated.evaluate(), list[int]) - # If we run this test again, ensure the type is still undefined - del global_alias - class TestAnnotationLib(unittest.TestCase): def test__all__(self): From e20da27194a6a0147725c3dca6c96086c019b5d6 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Thu, 6 Nov 2025 22:04:20 +1030 Subject: [PATCH 08/10] Use locals.setdefault() instead of manually checking if key in dict --- Lib/annotationlib.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 8f29bcb7be29ca..3ac7e2a012fbf1 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -171,16 +171,14 @@ def evaluate( # as a way of emulating annotation scopes when calling `eval()` if type_params is not None: for param in type_params: - if param.__name__ not in locals: - locals[param.__name__] = param + locals.setdefault(param.__name__, param) # Similar logic can be used for nonlocals, which should not # override locals. if isinstance(self.__cell__, dict): for cell_name, cell_value in self.__cell__.items(): try: - if cell_name not in locals: - locals[cell_name] = cell_value.cell_contents + locals.setdefault(cell_name, cell_value.cell_contents) except ValueError: pass From 65c40d3e87e363ef4246dc02c137e33dd6b5c8bf Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Thu, 6 Nov 2025 22:31:21 +1030 Subject: [PATCH 09/10] Add test to check if arguments to `ref.evaluate()` are mutated --- Lib/test/test_annotationlib.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 8c7db955a5b125..e8134534a3d0a2 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1946,6 +1946,31 @@ class C: global_alias = list self.assertEqual(evaluated.evaluate(), list[int]) + def test_fwdref_evaluate_argument_mutation(self): + class C[T]: + nonlocal alias + x: alias[T] + + # Mutable arguments + globals_ = globals() + globals_copy = globals_.copy() + locals_ = locals() + locals_copy = locals_.copy() + + # Evaluate the ForwardRef, ensuring we use __cell__ and type params + get_annotations(C, format=Format.FORWARDREF)["x"].evaluate( + globals=globals_, + locals=locals_, + type_params=C.__type_params__, + format=Format.FORWARDREF, + ) + + # Check if the passed in mutable arguments equal the originals + self.assertEqual(globals_, globals_copy) + self.assertEqual(locals_, locals_copy) + + alias = list + class TestAnnotationLib(unittest.TestCase): def test__all__(self): From 41c3e64fdebc99bdbf7030b1cd33307ad0ccd2b5 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Thu, 13 Nov 2025 15:38:38 +1030 Subject: [PATCH 10/10] Include less code within `cell.cell_contents` try-except block --- Lib/annotationlib.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 3ac7e2a012fbf1..33907b1fc2a53a 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -176,11 +176,13 @@ def evaluate( # Similar logic can be used for nonlocals, which should not # override locals. if isinstance(self.__cell__, dict): - for cell_name, cell_value in self.__cell__.items(): + for cell_name, cell in self.__cell__.items(): try: - locals.setdefault(cell_name, cell_value.cell_contents) + cell_value = cell.cell_contents except ValueError: pass + else: + locals.setdefault(cell_name, cell_value) if self.__extra_names__: locals.update(self.__extra_names__)