Skip to content

Commit 95a4187

Browse files
committed
gh-137226: Fix get_type_hints() on generic TypedDict with stringified annotations
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 #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 537133d commit 95a4187

File tree

3 files changed

+45
-12
lines changed

3 files changed

+45
-12
lines changed

Lib/test/test_typing.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7172,6 +7172,25 @@ 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+
7188+
class TD2(TD):
7189+
pass
7190+
7191+
self.assertEqual(TD2.__annotations__, {'a': EqualToForwardRef('UniqueT', owner=TD, module=TD.__module__)})
7192+
self.assertEqual(get_type_hints(TD2), {'a': TD.__type_params__[0]})
7193+
71757194

71767195
class GetUtilitiesTestCase(TestCase):
71777196
def test_get_origin(self):
@@ -8657,8 +8676,8 @@ def _make_td(future, class_name, annos, base, extra_names=None):
86578676
child = _make_td(
86588677
child_future, "Child", {"child": "int"}, "Base", {"Base": base}
86598678
)
8660-
base_anno = ForwardRef("int", module="builtins") if base_future else int
8661-
child_anno = ForwardRef("int", module="builtins") if child_future else int
8679+
base_anno = ForwardRef("int", module="builtins", owner=base) if base_future else int
8680+
child_anno = ForwardRef("int", module="builtins", owner=child) if child_future else int
86628681
self.assertEqual(base.__annotations__, {'base': base_anno})
86638682
self.assertEqual(
86648683
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)