From 12d7a459800613352a2f90abf651c052ea89ef28 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Fri, 17 Apr 2026 12:52:27 +0100 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] 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") From 5a36751da10b5dfdd5d4a830c3fe46a7f2aa13e7 Mon Sep 17 00:00:00 2001 From: David Ellis Date: Mon, 20 Apr 2026 22:48:37 +0100 Subject: [PATCH 06/11] Update Lib/annotationlib.py Fix a misplaced parenthesis that incorrectly caused a statement to evaluate as truthy Co-authored-by: Shamil --- Lib/annotationlib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index ddbb7705bb1e21..7c39e85d581377 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -271,7 +271,7 @@ def __resolved_str__(self): if names: # identifiers can be replaced directly if resolved_str.isidentifier(): - if (name_obj := names.get(resolved_str), _sentinel) is not _sentinel: + if (name_obj := names.get(resolved_str, _sentinel)) is not _sentinel: resolved_str = type_repr(name_obj) else: visitor = _ExtraNameFixer(names) From 3d9b64a3da5b189b3391a4c0337398cd2b3542ac Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Mon, 20 Apr 2026 22:52:56 +0100 Subject: [PATCH 07/11] remove unnecessary and incorrect node 'fixup'. --- Lib/annotationlib.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 7c39e85d581377..0af28fb866670a 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -1198,7 +1198,5 @@ def __init__(self, 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 + node = ast.Name(id=type_repr(new_name)) return node From 57bfa411455344082dc6572689288fb52ae1840c Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Mon, 20 Apr 2026 23:14:42 +0100 Subject: [PATCH 08/11] Remove optimization for a condition that should never be hit normally --- Lib/annotationlib.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 0af28fb866670a..5c9a0812646f81 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -269,15 +269,10 @@ def __resolved_str__(self): 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) + 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 From 3d08f8f949b807176e6d9bf5f81c76ea418aa22f Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Mon, 20 Apr 2026 23:15:13 +0100 Subject: [PATCH 09/11] Remove 'synthetic' test, make the regular test replace additional values --- Lib/test/test_annotationlib.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 5d313abdfc5e04..77f2a77882fce2 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -2048,21 +2048,14 @@ def test_evaluate_string_format(self): 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): ... + def f(a: unknown | str | int | list[str] | tuple[int, ...]): ... 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") + self.assertEqual(fr.evaluate(format=Format.STRING), "unknown | str | int | list[str] | tuple[int, ...]") + self.assertEqual(fr.__resolved_str_cache__, "unknown | str | int | list[str] | tuple[int, ...]") def test_evaluate_forwardref_format(self): fr = ForwardRef("undef") From 5de5e01fd7611221bcf44ac42559b2ded341261c Mon Sep 17 00:00:00 2001 From: "blurb-it[bot]" <43283697+blurb-it[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 07:38:10 +0000 Subject: [PATCH 10/11] =?UTF-8?q?=F0=9F=93=9C=F0=9F=A4=96=20Added=20by=20b?= =?UTF-8?q?lurb=5Fit.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../next/Library/2026-04-23-07-38-04.gh-issue-148680.___ePl.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Library/2026-04-23-07-38-04.gh-issue-148680.___ePl.rst diff --git a/Misc/NEWS.d/next/Library/2026-04-23-07-38-04.gh-issue-148680.___ePl.rst b/Misc/NEWS.d/next/Library/2026-04-23-07-38-04.gh-issue-148680.___ePl.rst new file mode 100644 index 00000000000000..e213a0f6b17b88 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-04-23-07-38-04.gh-issue-148680.___ePl.rst @@ -0,0 +1 @@ +``ForwardRef``s that contain internal names to represent known objects now show the ``type_repr`` of the object rather than the internal ``__annotationlib_name_x__`` name when evaluated as strings. From 4ff6eeb3d2344d9aecd1639fe21fa9dacff2fa63 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Thu, 23 Apr 2026 08:43:18 +0100 Subject: [PATCH 11/11] Fix linting issue (maybe) --- .../next/Library/2026-04-23-07-38-04.gh-issue-148680.___ePl.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Library/2026-04-23-07-38-04.gh-issue-148680.___ePl.rst b/Misc/NEWS.d/next/Library/2026-04-23-07-38-04.gh-issue-148680.___ePl.rst index e213a0f6b17b88..d3790079545a07 100644 --- a/Misc/NEWS.d/next/Library/2026-04-23-07-38-04.gh-issue-148680.___ePl.rst +++ b/Misc/NEWS.d/next/Library/2026-04-23-07-38-04.gh-issue-148680.___ePl.rst @@ -1 +1 @@ -``ForwardRef``s that contain internal names to represent known objects now show the ``type_repr`` of the object rather than the internal ``__annotationlib_name_x__`` name when evaluated as strings. +``ForwardRef`` objects that contain internal names to represent known objects now show the ``type_repr`` of the known object rather than the internal ``__annotationlib_name_x__`` name when evaluated as strings.