From 95a4187b80099f056194270db1b0d851d080c234 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Mon, 15 Sep 2025 13:09:36 -0700 Subject: [PATCH 1/2] 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. --- Lib/test/test_typing.py | 23 +++++++++++-- Lib/typing.py | 32 +++++++++++++------ ...-09-15-13-09-19.gh-issue-137226.HH3_ik.rst | 2 ++ 3 files changed, 45 insertions(+), 12 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-09-15-13-09-19.gh-issue-137226.HH3_ik.rst diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py index 8238c62f0715f8..b959b843145e9e 100644 --- a/Lib/test/test_typing.py +++ b/Lib/test/test_typing.py @@ -7172,6 +7172,25 @@ def func(x: MyClass['int'], y: MyClass[Annotated[int, ...]]): ... assert isinstance(get_type_hints(func)['x'], MyAlias) assert isinstance(get_type_hints(func)['y'], MyAlias) + def test_stringified_typeddict(self): + ns = run_code( + """ + from __future__ import annotations + from typing import TypedDict + class TD[UniqueT](TypedDict): + a: UniqueT + """ + ) + TD = ns['TD'] + self.assertEqual(TD.__annotations__, {'a': EqualToForwardRef('UniqueT', owner=TD, module=TD.__module__)}) + self.assertEqual(get_type_hints(TD), {'a': TD.__type_params__[0]}) + + class TD2(TD): + pass + + self.assertEqual(TD2.__annotations__, {'a': EqualToForwardRef('UniqueT', owner=TD, module=TD.__module__)}) + self.assertEqual(get_type_hints(TD2), {'a': TD.__type_params__[0]}) + class GetUtilitiesTestCase(TestCase): def test_get_origin(self): @@ -8657,8 +8676,8 @@ def _make_td(future, class_name, annos, base, extra_names=None): child = _make_td( child_future, "Child", {"child": "int"}, "Base", {"Base": base} ) - base_anno = ForwardRef("int", module="builtins") if base_future else int - child_anno = ForwardRef("int", module="builtins") if child_future else int + base_anno = ForwardRef("int", module="builtins", owner=base) if base_future else int + child_anno = ForwardRef("int", module="builtins", owner=child) if child_future else int self.assertEqual(base.__annotations__, {'base': base_anno}) self.assertEqual( child.__annotations__, {'child': child_anno, 'base': base_anno} diff --git a/Lib/typing.py b/Lib/typing.py index babe3c44d9dc55..0554343c8e3a0e 100644 --- a/Lib/typing.py +++ b/Lib/typing.py @@ -171,16 +171,16 @@ def __getattr__(self, attr): _lazy_annotationlib = _LazyAnnotationLib() -def _type_convert(arg, module=None, *, allow_special_forms=False): +def _type_convert(arg, module=None, *, allow_special_forms=False, owner=None): """For converting None to type(None), and strings to ForwardRef.""" if arg is None: return type(None) if isinstance(arg, str): - return _make_forward_ref(arg, module=module, is_class=allow_special_forms) + return _make_forward_ref(arg, module=module, is_class=allow_special_forms, owner=owner) return arg -def _type_check(arg, msg, is_argument=True, module=None, *, allow_special_forms=False): +def _type_check(arg, msg, is_argument=True, module=None, *, allow_special_forms=False, owner=None): """Check that the argument is a type, and return it (internal helper). 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= if is_argument: invalid_generic_forms += (Final,) - arg = _type_convert(arg, module=module, allow_special_forms=allow_special_forms) + arg = _type_convert(arg, module=module, allow_special_forms=allow_special_forms, owner=owner) if (isinstance(arg, _GenericAlias) and arg.__origin__ in invalid_generic_forms): 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: def _eval_type(t, globalns, localns, type_params, *, recursive_guard=frozenset(), - format=None, owner=None, parent_fwdref=None): + format=None, owner=None, parent_fwdref=None, prefer_fwd_module=False): """Evaluate all forward references in the given type t. 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() if isinstance(t, _lazy_annotationlib.ForwardRef): # If the forward_ref has __forward_module__ set, evaluate() infers the globals # from the module, and it will probably pick better than the globals we have here. - if t.__forward_module__ is not None: + # We do this only for calls from get_type_hints() (which opts in through the + # prefer_fwd_module flag), so that the default behavior remains more straightforward. + if prefer_fwd_module and t.__forward_module__ is not None: globalns = None + # If there are type params on the owner, we need to add them back, because + # annotationlib won't. + if owner_type_params := getattr(owner, "__type_params__", None): + globalns = getattr( + sys.modules.get(t.__forward_module__, None), "__dict__", None + ) + if globalns is not None: + globalns = dict(globalns) + for type_param in owner_type_params: + globalns[type_param.__name__] = type_param return evaluate_forward_ref(t, globals=globalns, locals=localns, type_params=type_params, owner=owner, _recursive_guard=recursive_guard, format=format) @@ -481,7 +493,7 @@ def _eval_type(t, globalns, localns, type_params, *, recursive_guard=frozenset() ev_args = tuple( _eval_type( a, globalns, localns, type_params, recursive_guard=recursive_guard, - format=format, owner=owner, + format=format, owner=owner, prefer_fwd_module=prefer_fwd_module, ) for a in args ) @@ -2369,7 +2381,7 @@ def get_type_hints(obj, globalns=None, localns=None, include_extras=False, if isinstance(value, str): value = _make_forward_ref(value, is_argument=False, is_class=True) value = _eval_type(value, base_globals, base_locals, (), - format=format, owner=obj) + format=format, owner=obj, prefer_fwd_module=True) if value is None: value = type(None) hints[name] = value @@ -2414,7 +2426,7 @@ def get_type_hints(obj, globalns=None, localns=None, include_extras=False, is_argument=not isinstance(obj, types.ModuleType), is_class=False, ) - value = _eval_type(value, globalns, localns, (), format=format, owner=obj) + value = _eval_type(value, globalns, localns, (), format=format, owner=obj, prefer_fwd_module=True) if value is None: value = type(None) hints[name] = value @@ -3111,7 +3123,7 @@ def __new__(cls, name, bases, ns, total=True): own_annotations = {} msg = "TypedDict('Name', {f0: t0, f1: t1, ...}); each t must be a type" own_checked_annotations = { - n: _type_check(tp, msg, module=tp_dict.__module__) + n: _type_check(tp, msg, owner=tp_dict, module=tp_dict.__module__) for n, tp in own_annotations.items() } required_keys = set() diff --git a/Misc/NEWS.d/next/Library/2025-09-15-13-09-19.gh-issue-137226.HH3_ik.rst b/Misc/NEWS.d/next/Library/2025-09-15-13-09-19.gh-issue-137226.HH3_ik.rst new file mode 100644 index 00000000000000..38683c845dec33 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-09-15-13-09-19.gh-issue-137226.HH3_ik.rst @@ -0,0 +1,2 @@ +Fix :func:`typing.get_type_hints` calls on generic :class:`typing.TypedDict` +classes defined with string annotations. From 58f97cd0110a6f87bb3aa0e62d731649c1a71548 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Mon, 15 Sep 2025 13:22:15 -0700 Subject: [PATCH 2/2] remove still-broken test --- Lib/test/test_typing.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py index b959b843145e9e..d776a019795582 100644 --- a/Lib/test/test_typing.py +++ b/Lib/test/test_typing.py @@ -7185,12 +7185,6 @@ class TD[UniqueT](TypedDict): self.assertEqual(TD.__annotations__, {'a': EqualToForwardRef('UniqueT', owner=TD, module=TD.__module__)}) self.assertEqual(get_type_hints(TD), {'a': TD.__type_params__[0]}) - class TD2(TD): - pass - - self.assertEqual(TD2.__annotations__, {'a': EqualToForwardRef('UniqueT', owner=TD, module=TD.__module__)}) - self.assertEqual(get_type_hints(TD2), {'a': TD.__type_params__[0]}) - class GetUtilitiesTestCase(TestCase): def test_get_origin(self):