Skip to content

Commit 6d6aba2

Browse files
gh-137226: Fix get_type_hints() on generic TypedDict with stringified annotations (#138953)
This issue appears specifically for TypedDicts because the TypedDict constructor code converts string annotations to ForwardRef objects, and those are not evaluated properly by the get_type_hints() stack because of other shenanigans with type parameters. This issue does not affect normal generic classes because their annotations are not pre-converted to ForwardRefs. The fix attempts to restore the pre- #137227 behavior in the narrow scenario where the issue manifests. It mostly makes changes only in the paths accessible from get_type_hints(), ensuring that newer APIs (such as evaluate_forward_ref() and annotationlib) are not affected by get_type_hints()'s past odd choices. This PR does not fix issue #138949, an older issue I discovered while playing around with this one; we'll need a separate and perhaps more invasive fix for that, but it should wait until after 3.14.0.
1 parent ab6893a commit 6d6aba2

File tree

3 files changed

+39
-12
lines changed

3 files changed

+39
-12
lines changed

Lib/test/test_typing.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7172,6 +7172,19 @@ def func(x: MyClass['int'], y: MyClass[Annotated[int, ...]]): ...
71727172
assert isinstance(get_type_hints(func)['x'], MyAlias)
71737173
assert isinstance(get_type_hints(func)['y'], MyAlias)
71747174

7175+
def test_stringified_typeddict(self):
7176+
ns = run_code(
7177+
"""
7178+
from __future__ import annotations
7179+
from typing import TypedDict
7180+
class TD[UniqueT](TypedDict):
7181+
a: UniqueT
7182+
"""
7183+
)
7184+
TD = ns['TD']
7185+
self.assertEqual(TD.__annotations__, {'a': EqualToForwardRef('UniqueT', owner=TD, module=TD.__module__)})
7186+
self.assertEqual(get_type_hints(TD), {'a': TD.__type_params__[0]})
7187+
71757188

71767189
class GetUtilitiesTestCase(TestCase):
71777190
def test_get_origin(self):
@@ -8657,8 +8670,8 @@ def _make_td(future, class_name, annos, base, extra_names=None):
86578670
child = _make_td(
86588671
child_future, "Child", {"child": "int"}, "Base", {"Base": base}
86598672
)
8660-
base_anno = ForwardRef("int", module="builtins") if base_future else int
8661-
child_anno = ForwardRef("int", module="builtins") if child_future else int
8673+
base_anno = ForwardRef("int", module="builtins", owner=base) if base_future else int
8674+
child_anno = ForwardRef("int", module="builtins", owner=child) if child_future else int
86628675
self.assertEqual(base.__annotations__, {'base': base_anno})
86638676
self.assertEqual(
86648677
child.__annotations__, {'child': child_anno, 'base': base_anno}

Lib/typing.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -171,16 +171,16 @@ def __getattr__(self, attr):
171171
_lazy_annotationlib = _LazyAnnotationLib()
172172

173173

174-
def _type_convert(arg, module=None, *, allow_special_forms=False):
174+
def _type_convert(arg, module=None, *, allow_special_forms=False, owner=None):
175175
"""For converting None to type(None), and strings to ForwardRef."""
176176
if arg is None:
177177
return type(None)
178178
if isinstance(arg, str):
179-
return _make_forward_ref(arg, module=module, is_class=allow_special_forms)
179+
return _make_forward_ref(arg, module=module, is_class=allow_special_forms, owner=owner)
180180
return arg
181181

182182

183-
def _type_check(arg, msg, is_argument=True, module=None, *, allow_special_forms=False):
183+
def _type_check(arg, msg, is_argument=True, module=None, *, allow_special_forms=False, owner=None):
184184
"""Check that the argument is a type, and return it (internal helper).
185185
186186
As a special case, accept None and return type(None) instead. Also wrap strings
@@ -198,7 +198,7 @@ def _type_check(arg, msg, is_argument=True, module=None, *, allow_special_forms=
198198
if is_argument:
199199
invalid_generic_forms += (Final,)
200200

201-
arg = _type_convert(arg, module=module, allow_special_forms=allow_special_forms)
201+
arg = _type_convert(arg, module=module, allow_special_forms=allow_special_forms, owner=owner)
202202
if (isinstance(arg, _GenericAlias) and
203203
arg.__origin__ in invalid_generic_forms):
204204
raise TypeError(f"{arg} is not valid as type argument")
@@ -454,7 +454,7 @@ def _deprecation_warning_for_no_type_params_passed(funcname: str) -> None:
454454

455455

456456
def _eval_type(t, globalns, localns, type_params, *, recursive_guard=frozenset(),
457-
format=None, owner=None, parent_fwdref=None):
457+
format=None, owner=None, parent_fwdref=None, prefer_fwd_module=False):
458458
"""Evaluate all forward references in the given type t.
459459
460460
For use of globalns and localns see the docstring for get_type_hints().
@@ -464,8 +464,20 @@ def _eval_type(t, globalns, localns, type_params, *, recursive_guard=frozenset()
464464
if isinstance(t, _lazy_annotationlib.ForwardRef):
465465
# If the forward_ref has __forward_module__ set, evaluate() infers the globals
466466
# from the module, and it will probably pick better than the globals we have here.
467-
if t.__forward_module__ is not None:
467+
# We do this only for calls from get_type_hints() (which opts in through the
468+
# prefer_fwd_module flag), so that the default behavior remains more straightforward.
469+
if prefer_fwd_module and t.__forward_module__ is not None:
468470
globalns = None
471+
# If there are type params on the owner, we need to add them back, because
472+
# annotationlib won't.
473+
if owner_type_params := getattr(owner, "__type_params__", None):
474+
globalns = getattr(
475+
sys.modules.get(t.__forward_module__, None), "__dict__", None
476+
)
477+
if globalns is not None:
478+
globalns = dict(globalns)
479+
for type_param in owner_type_params:
480+
globalns[type_param.__name__] = type_param
469481
return evaluate_forward_ref(t, globals=globalns, locals=localns,
470482
type_params=type_params, owner=owner,
471483
_recursive_guard=recursive_guard, format=format)
@@ -481,7 +493,7 @@ def _eval_type(t, globalns, localns, type_params, *, recursive_guard=frozenset()
481493
ev_args = tuple(
482494
_eval_type(
483495
a, globalns, localns, type_params, recursive_guard=recursive_guard,
484-
format=format, owner=owner,
496+
format=format, owner=owner, prefer_fwd_module=prefer_fwd_module,
485497
)
486498
for a in args
487499
)
@@ -2369,7 +2381,7 @@ def get_type_hints(obj, globalns=None, localns=None, include_extras=False,
23692381
if isinstance(value, str):
23702382
value = _make_forward_ref(value, is_argument=False, is_class=True)
23712383
value = _eval_type(value, base_globals, base_locals, (),
2372-
format=format, owner=obj)
2384+
format=format, owner=obj, prefer_fwd_module=True)
23732385
if value is None:
23742386
value = type(None)
23752387
hints[name] = value
@@ -2414,7 +2426,7 @@ def get_type_hints(obj, globalns=None, localns=None, include_extras=False,
24142426
is_argument=not isinstance(obj, types.ModuleType),
24152427
is_class=False,
24162428
)
2417-
value = _eval_type(value, globalns, localns, (), format=format, owner=obj)
2429+
value = _eval_type(value, globalns, localns, (), format=format, owner=obj, prefer_fwd_module=True)
24182430
if value is None:
24192431
value = type(None)
24202432
hints[name] = value
@@ -3111,7 +3123,7 @@ def __new__(cls, name, bases, ns, total=True):
31113123
own_annotations = {}
31123124
msg = "TypedDict('Name', {f0: t0, f1: t1, ...}); each t must be a type"
31133125
own_checked_annotations = {
3114-
n: _type_check(tp, msg, module=tp_dict.__module__)
3126+
n: _type_check(tp, msg, owner=tp_dict, module=tp_dict.__module__)
31153127
for n, tp in own_annotations.items()
31163128
}
31173129
required_keys = set()
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fix :func:`typing.get_type_hints` calls on generic :class:`typing.TypedDict`
2+
classes defined with string annotations.

0 commit comments

Comments
 (0)