Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions Lib/annotationlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import keyword
import sys
import types
lazy import typing

__all__ = [
"Format",
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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
)
93 changes: 93 additions & 0 deletions Lib/test/test_annotationlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Loading