Skip to content

Commit 889036f

Browse files
bpo-41249: Fix postponed annotations for TypedDict (GH-27017)
This fixes TypedDict to work with get_type_hints and postponed evaluation of annotations across modules. This is done by adding the module name to ForwardRef at the time the object is created and using that to resolve the globals during the evaluation. Co-authored-by: Ken Jin <[email protected]>
1 parent bf89ff9 commit 889036f

File tree

4 files changed

+43
-7
lines changed

4 files changed

+43
-7
lines changed

Lib/test/_typed_dict_helper.py

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""Used to test `get_type_hints()` on a cross-module inherited `TypedDict` class
2+
3+
This script uses future annotations to postpone a type that won't be available
4+
on the module inheriting from to `Foo`. The subclass in the other module should
5+
look something like this:
6+
7+
class Bar(_typed_dict_helper.Foo, total=False):
8+
b: int
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from typing import Optional, TypedDict
14+
15+
OptionalIntType = Optional[int]
16+
17+
class Foo(TypedDict):
18+
a: OptionalIntType

Lib/test/test_typing.py

+10
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import types
3535

3636
from test import mod_generics_cache
37+
from test import _typed_dict_helper
3738

3839

3940
class BaseTestCase(TestCase):
@@ -2819,6 +2820,9 @@ class Point2D(TypedDict):
28192820
x: int
28202821
y: int
28212822

2823+
class Bar(_typed_dict_helper.Foo, total=False):
2824+
b: int
2825+
28222826
class LabelPoint2D(Point2D, Label): ...
28232827

28242828
class Options(TypedDict, total=False):
@@ -3995,6 +3999,12 @@ def test_is_typeddict(self):
39953999
# classes, not instances
39964000
assert is_typeddict(Point2D()) is False
39974001

4002+
def test_get_type_hints(self):
4003+
self.assertEqual(
4004+
get_type_hints(Bar),
4005+
{'a': typing.Optional[int], 'b': int}
4006+
)
4007+
39984008

39994009
class IOTests(BaseTestCase):
40004010

Lib/typing.py

+13-7
Original file line numberDiff line numberDiff line change
@@ -135,16 +135,16 @@
135135
# legitimate imports of those modules.
136136

137137

138-
def _type_convert(arg):
138+
def _type_convert(arg, module=None):
139139
"""For converting None to type(None), and strings to ForwardRef."""
140140
if arg is None:
141141
return type(None)
142142
if isinstance(arg, str):
143-
return ForwardRef(arg)
143+
return ForwardRef(arg, module=module)
144144
return arg
145145

146146

147-
def _type_check(arg, msg, is_argument=True):
147+
def _type_check(arg, msg, is_argument=True, module=None):
148148
"""Check that the argument is a type, and return it (internal helper).
149149
150150
As a special case, accept None and return type(None) instead. Also wrap strings
@@ -160,7 +160,7 @@ def _type_check(arg, msg, is_argument=True):
160160
if is_argument:
161161
invalid_generic_forms = invalid_generic_forms + (ClassVar, Final)
162162

163-
arg = _type_convert(arg)
163+
arg = _type_convert(arg, module=module)
164164
if (isinstance(arg, _GenericAlias) and
165165
arg.__origin__ in invalid_generic_forms):
166166
raise TypeError(f"{arg} is not valid as type argument")
@@ -633,9 +633,9 @@ class ForwardRef(_Final, _root=True):
633633

634634
__slots__ = ('__forward_arg__', '__forward_code__',
635635
'__forward_evaluated__', '__forward_value__',
636-
'__forward_is_argument__')
636+
'__forward_is_argument__', '__forward_module__')
637637

638-
def __init__(self, arg, is_argument=True):
638+
def __init__(self, arg, is_argument=True, module=None):
639639
if not isinstance(arg, str):
640640
raise TypeError(f"Forward reference must be a string -- got {arg!r}")
641641
try:
@@ -647,6 +647,7 @@ def __init__(self, arg, is_argument=True):
647647
self.__forward_evaluated__ = False
648648
self.__forward_value__ = None
649649
self.__forward_is_argument__ = is_argument
650+
self.__forward_module__ = module
650651

651652
def _evaluate(self, globalns, localns, recursive_guard):
652653
if self.__forward_arg__ in recursive_guard:
@@ -658,6 +659,10 @@ def _evaluate(self, globalns, localns, recursive_guard):
658659
globalns = localns
659660
elif localns is None:
660661
localns = globalns
662+
if self.__forward_module__ is not None:
663+
globalns = getattr(
664+
sys.modules.get(self.__forward_module__, None), '__dict__', globalns
665+
)
661666
type_ =_type_check(
662667
eval(self.__forward_code__, globalns, localns),
663668
"Forward references must evaluate to types.",
@@ -2242,7 +2247,8 @@ def __new__(cls, name, bases, ns, total=True):
22422247
own_annotation_keys = set(own_annotations.keys())
22432248
msg = "TypedDict('Name', {f0: t0, f1: t1, ...}); each t must be a type"
22442249
own_annotations = {
2245-
n: _type_check(tp, msg) for n, tp in own_annotations.items()
2250+
n: _type_check(tp, msg, module=tp_dict.__module__)
2251+
for n, tp in own_annotations.items()
22462252
}
22472253
required_keys = set()
22482254
optional_keys = set()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fixes ``TypedDict`` to work with ``typing.get_type_hints()`` and postponed evaluation of
2+
annotations across modules.

0 commit comments

Comments
 (0)