diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index c41df197eee7d5e..2be779a5f18b62c 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -6,6 +6,7 @@ import keyword import sys import types +lazy import typing __all__ = [ "Format", @@ -410,6 +411,48 @@ def __convert_to_ast(self, other): elts.append(new_elt) ast_class = {list: ast.List, tuple: ast.Tuple, set: ast.Set}[type(other)] return ast_class(elts), extra_names + elif type(other) is types.GenericAlias or _is_annotated_alias(other): + if _is_annotated_alias(other): + annotated_alias = True + origin = typing.get_origin(other) + args = typing.get_args(other) + else: + annotated_alias = False + origin = other.__origin__ + args = other.__args__ + + extra_names = {} + elts = [] + + origin_ast, origin_names = self.__convert_to_ast(origin) + if origin_names is not None: + extra_names |= origin_names + + for elt in args: + new_elt, new_extra_names = self.__convert_to_ast(elt) + if new_extra_names is not None: + extra_names |= new_extra_names + elts.append(new_elt) + + if len(elts) == 1: + elts = elts[0] + else: + elts = ast.Tuple(elts=elts) + + if annotated_alias: + new_ast = ast.Subscript( + value=origin_ast, + slice=elts, + ) + else: + # Generic Alias is not guaranteed to be from subscript + alias_ast, alias_names = self.__convert_to_ast(types.GenericAlias) + extra_names |= alias_names + new_ast = ast.Call( + func=alias_ast, + args=[origin_ast, elts], + ) + return new_ast, extra_names else: name = self.__stringifier_dict__.create_unique_name() return ast.Name(id=name), {name: other} @@ -1191,7 +1234,30 @@ class _ExtraNameFixer(ast.NodeTransformer): def __init__(self, extra_names): self.extra_names = extra_names + def visit_Call(self, node: ast.Call): + # Convert calls to `GenericAlias` to their string representation + if ( + isinstance((func := node.func), ast.Name) + and self.extra_names.get(func.id) is types.GenericAlias + ): + new_node = ast.Subscript( + value=node.args[0], + slice=node.args[1], + ) + self.generic_visit(new_node) + return new_node + + self.generic_visit(node) + return node + def visit_Name(self, node: ast.Name): if (new_name := self.extra_names.get(node.id, _sentinel)) is not _sentinel: node = ast.Name(id=type_repr(new_name)) return node + + +def _is_annotated_alias(obj): + return ( + (typing := sys.modules.get("typing")) + and typing.get_origin(obj) is typing.Annotated + ) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 56e5731849cbd7c..522dd7a660293de 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -237,6 +237,99 @@ class UnionForwardrefs: arg2, support.EqualToForwardRef("undefined", is_class=True, owner=UnionForwardrefs) ) + def test_nested_deferred(self): + # Test that ForwardReferences inside generic aliases and + # typing.Annotated are correctly caught and resolved. + + class NestedForwardRefs: + genericalias: Unknown1[str, list[Unknown2]] + annotated: Unknown1[str, typing.Annotated[Unknown2, '']] + extra_nested: Unknown3[tuple[Unknown4[list[Unknown5]]]] + + annos = get_annotations(NestedForwardRefs, format=Format.FORWARDREF) + + gen = annos['genericalias'] + annotated = annos['annotated'] + nested = annos['extra_nested'] + + self.assertEqual( + gen.evaluate(format=Format.STRING), + "Unknown1[str, list[Unknown2]]" + ) + + self.assertEqual( + annotated.evaluate(format=Format.STRING), + "Unknown1[str, typing.Annotated[Unknown2, '']]", + ) + + self.assertEqual( + nested.evaluate(format=Format.STRING), + "Unknown3[tuple[Unknown4[list[Unknown5]]]]", + ) + + Unknown1 = tuple + # Unknown2 is still undefined, these should raise + with self.assertRaises(NameError): + gen.evaluate() + with self.assertRaises(NameError): + annotated.evaluate() + + # Test that the annotations fully evaluate now both unknowns are defined + Unknown2 = str + self.assertEqual( + gen.evaluate(), + tuple[str, list[str]], + ) + + self.assertEqual( + annotated.evaluate(), + tuple[str, typing.Annotated[str, '']], + ) + + Unknown3 = list + Unknown4 = set + Unknown5 = int + self.assertEqual( + nested.evaluate(), + list[tuple[set[list[int]]]], + ) + + def test_nested_special_generic(self): + # Test that ForwardRef works correctly for generic aliases that aren't + # created through subscripting + class NonSubscriptableGeneric: + @classmethod + def get_generic(cls, *args): + return types.GenericAlias(cls, args) + + nsrepr = type_repr(NonSubscriptableGeneric) + + with self.assertRaises(TypeError): + _ = NonSubscriptableGeneric[str] + + class C: + x: Unknown1[NonSubscriptableGeneric.get_generic(str, Unknown2)] + + ann = get_annotations(C, format=Format.FORWARDREF)['x'] + + Unknown1 = list + + with self.assertRaises(NameError): + ann.evaluate() + + Unknown2 = int + + self.assertEqual( + ann.evaluate(), + list[NonSubscriptableGeneric.get_generic(str, int)], + ) + + # The string representation should look like the __repr__ for GenericAlias + self.assertEqual( + ann.evaluate(format=Format.STRING), + f"Unknown1[{nsrepr}[str, Unknown2]]", + ) + class TestStringFormat(unittest.TestCase): def test_closure(self): diff --git a/Misc/NEWS.d/next/Library/2026-10-28-10-57-05.gh-issue-160766.wqI7qo.rst b/Misc/NEWS.d/next/Library/2026-10-28-10-57-05.gh-issue-160766.wqI7qo.rst new file mode 100644 index 000000000000000..df2d0db2ec8cf7a --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-28-10-57-05.gh-issue-160766.wqI7qo.rst @@ -0,0 +1 @@ +Fix :mod:`annotationlib` forward references inside a :class:`types.GenericAlias` or :class:`typing.Annotated` inside an outer :class:`annotationlib.ForwardRef` no longer evade evaluation.