From 12d7a459800613352a2f90abf651c052ea89ef28 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Fri, 17 Apr 2026 12:52:27 +0100 Subject: [PATCH 1/5] Add tests for replacement of extra names in repr and string evaluation --- Lib/test/test_annotationlib.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 50cf8fcb6b4ed6..63751274c1c697 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1961,6 +1961,11 @@ def test_forward_repr(self): "typing.List[ForwardRef('int', owner='class')]", ) + def test_forward_repr_extra_names(self): + fr = ForwardRef("__annotationlib_name_1__") + fr.__extra_names__ = {"__annotationlib_name_1__": list[str]} + self.assertEqual(repr(fr), "ForwardRef('list[str]')") + def test_forward_recursion_actually(self): def namespace1(): a = ForwardRef("A") @@ -2037,6 +2042,20 @@ def test_evaluate_string_format(self): fr = ForwardRef("set[Any]") self.assertEqual(fr.evaluate(format=Format.STRING), "set[Any]") + def test_evaluate_string_format_extra_names(self): + # Test that internal extra_names are replaced when evaluating as strings + + # As identifier + fr = ForwardRef("__annotationlib_name_1__") + fr.__extra_names__ = {"__annotationlib_name_1__": str} + self.assertEqual(fr.evaluate(format=Format.STRING), "str") + + # Via AST visitor + def f(a: ref | str): ... + + fr = get_annotations(f, format=Format.FORWARDREF)['a'] + self.assertEqual(fr.evaluate(format=Format.STRING), "ref | str") + def test_evaluate_forwardref_format(self): fr = ForwardRef("undef") evaluated = fr.evaluate(format=Format.FORWARDREF) From e1e258efe58fc6ed4f358d4b1aea972ac48de5dc Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Fri, 17 Apr 2026 12:52:54 +0100 Subject: [PATCH 2/5] Replace names from __extra_names__ when evaluating forward references as strings --- Lib/annotationlib.py | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 9fee2564114339..a28a3fb98dcd2a 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -113,7 +113,7 @@ def evaluate( """ match format: case Format.STRING: - return self.__forward_arg__ + return self.__resolved_forward_str__ case Format.VALUE: is_forwardref_format = False case Format.FORWARDREF: @@ -258,6 +258,25 @@ def __forward_arg__(self): "Attempted to access '__forward_arg__' on an uninitialized ForwardRef" ) + @property + def __resolved_forward_str__(self): + # __forward_arg__ but with __extra_names__ resolved as strings + resolved_str = self.__forward_arg__ + names = self.__extra_names__ + + if names: + # identifiers can be replaced directly + if resolved_str.isidentifier(): + if (name_obj := names.get(resolved_str), _sentinel) is not _sentinel: + resolved_str = type_repr(name_obj) + else: + visitor = _ExtraNameFixer(names) + ast_expr = ast.parse(resolved_str, mode="eval").body + node = visitor.visit(ast_expr) + resolved_str = ast.unparse(node) + + return resolved_str + @property def __forward_code__(self): if self.__code__ is not None: @@ -321,7 +340,7 @@ def __repr__(self): extra.append(", is_class=True") if self.__owner__ is not None: extra.append(f", owner={self.__owner__!r}") - return f"ForwardRef({self.__forward_arg__!r}{''.join(extra)})" + return f"ForwardRef({self.__resolved_forward_str__!r}{''.join(extra)})" _Template = type(t"") @@ -1163,3 +1182,16 @@ def _get_dunder_annotations(obj): if not isinstance(ann, dict): raise ValueError(f"{obj!r}.__annotations__ is neither a dict nor None") return ann + + +class _ExtraNameFixer(ast.NodeTransformer): + """Fixer for __extra_names__ items in ForwardRef __repr__ and string evaluation""" + def __init__(self, extra_names): + self.extra_names = extra_names + + def visit_Name(self, node: ast.Name): + if (new_name := self.extra_names.get(node.id, _sentinel)) is not _sentinel: + new_node = ast.Name(id=type_repr(new_name)) + ast.copy_location(node, new_node) + node = new_node + return node From ade5f8ef5cfba3cf472a8b9f1aeb87223c68fcd3 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Fri, 17 Apr 2026 13:01:38 +0100 Subject: [PATCH 3/5] Try to write a clearer comment --- Lib/annotationlib.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index a28a3fb98dcd2a..db3684a9491194 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -260,7 +260,8 @@ def __forward_arg__(self): @property def __resolved_forward_str__(self): - # __forward_arg__ but with __extra_names__ resolved as strings + # __forward_arg__ with any names from __extra_names__ replaced + # with the type_repr of the value they represent resolved_str = self.__forward_arg__ names = self.__extra_names__ From f3b6293c683d70669bc8ce33948f65ad5167e57b Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Fri, 17 Apr 2026 16:15:57 +0100 Subject: [PATCH 4/5] Make the repr test use retrieved rather than 'artificial' forward references --- Lib/test/test_annotationlib.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 63751274c1c697..90785a3bbf917a 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1962,9 +1962,13 @@ def test_forward_repr(self): ) def test_forward_repr_extra_names(self): - fr = ForwardRef("__annotationlib_name_1__") - fr.__extra_names__ = {"__annotationlib_name_1__": list[str]} - self.assertEqual(repr(fr), "ForwardRef('list[str]')") + def f(a: undefined | str): ... + + annos = get_annotations(f, format=Format.FORWARDREF) + + self.assertRegex( + repr(annos['a']), r"ForwardRef\('undefined \| str'.*\)" + ) def test_forward_recursion_actually(self): def namespace1(): From 675738ac804f4b89200c673b2a1155797438fd56 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Mon, 20 Apr 2026 11:33:13 +0100 Subject: [PATCH 5/5] Add a cache for resolved str, test the cache is used, shorten names --- Lib/annotationlib.py | 40 +++++++++++++++++++--------------- Lib/test/test_annotationlib.py | 4 ++++ 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index db3684a9491194..ddbb7705bb1e21 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -47,6 +47,7 @@ class Format(enum.IntEnum): "__cell__", "__owner__", "__stringifier_dict__", + "__resolved_str_cache__", ) @@ -94,6 +95,7 @@ def __init__( # value later. self.__code__ = None self.__ast_node__ = None + self.__resolved_str_cache__ = None def __init_subclass__(cls, /, *args, **kwds): raise TypeError("Cannot subclass ForwardRef") @@ -113,7 +115,7 @@ def evaluate( """ match format: case Format.STRING: - return self.__resolved_forward_str__ + return self.__resolved_str__ case Format.VALUE: is_forwardref_format = False case Format.FORWARDREF: @@ -259,24 +261,27 @@ def __forward_arg__(self): ) @property - def __resolved_forward_str__(self): + def __resolved_str__(self): # __forward_arg__ with any names from __extra_names__ replaced # with the type_repr of the value they represent - resolved_str = self.__forward_arg__ - names = self.__extra_names__ - - if names: - # identifiers can be replaced directly - if resolved_str.isidentifier(): - if (name_obj := names.get(resolved_str), _sentinel) is not _sentinel: - resolved_str = type_repr(name_obj) - else: - visitor = _ExtraNameFixer(names) - ast_expr = ast.parse(resolved_str, mode="eval").body - node = visitor.visit(ast_expr) - resolved_str = ast.unparse(node) + if self.__resolved_str_cache__ is None: + resolved_str = self.__forward_arg__ + names = self.__extra_names__ + + if names: + # identifiers can be replaced directly + if resolved_str.isidentifier(): + if (name_obj := names.get(resolved_str), _sentinel) is not _sentinel: + resolved_str = type_repr(name_obj) + else: + visitor = _ExtraNameFixer(names) + ast_expr = ast.parse(resolved_str, mode="eval").body + node = visitor.visit(ast_expr) + resolved_str = ast.unparse(node) + + self.__resolved_str_cache__ = resolved_str - return resolved_str + return self.__resolved_str_cache__ @property def __forward_code__(self): @@ -341,7 +346,7 @@ def __repr__(self): extra.append(", is_class=True") if self.__owner__ is not None: extra.append(f", owner={self.__owner__!r}") - return f"ForwardRef({self.__resolved_forward_str__!r}{''.join(extra)})" + return f"ForwardRef({self.__resolved_str__!r}{''.join(extra)})" _Template = type(t"") @@ -377,6 +382,7 @@ def __init__( self.__cell__ = cell self.__owner__ = owner self.__stringifier_dict__ = stringifier_dict + self.__resolved_str_cache__ = None # Needed for ForwardRef def __convert_to_ast(self, other): if isinstance(other, _Stringifier): diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 90785a3bbf917a..5d313abdfc5e04 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -2058,7 +2058,11 @@ def test_evaluate_string_format_extra_names(self): def f(a: ref | str): ... fr = get_annotations(f, format=Format.FORWARDREF)['a'] + # Test the cache is not populated before access + self.assertIsNone(fr.__resolved_str_cache__) + self.assertEqual(fr.evaluate(format=Format.STRING), "ref | str") + self.assertEqual(fr.__resolved_str_cache__, "ref | str") def test_evaluate_forwardref_format(self): fr = ForwardRef("undef")