From 9a14d28cd79090607515d6054c9ffb0d872221da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20M=C3=A9ndez=20Bravo?= Date: Fri, 16 Jul 2021 20:49:30 -0700 Subject: [PATCH 1/2] 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 <28750310+Fidget-Spinner@users.noreply.github.com> --- Lib/test/_typed_dict_helper.py | 18 +++++++++++++++++ Lib/test/test_typing.py | 16 +++++++++++++++ Lib/typing.py | 20 ++++++++++++------- .../2021-07-04-11-33-34.bpo-41249.sHdwBE.rst | 2 ++ 4 files changed, 49 insertions(+), 7 deletions(-) create mode 100644 Lib/test/_typed_dict_helper.py create mode 100644 Misc/NEWS.d/next/Library/2021-07-04-11-33-34.bpo-41249.sHdwBE.rst diff --git a/Lib/test/_typed_dict_helper.py b/Lib/test/_typed_dict_helper.py new file mode 100644 index 00000000000000..d333db193183eb --- /dev/null +++ b/Lib/test/_typed_dict_helper.py @@ -0,0 +1,18 @@ +"""Used to test `get_type_hints()` on a cross-module inherited `TypedDict` class + +This script uses future annotations to postpone a type that won't be available +on the module inheriting from to `Foo`. The subclass in the other module should +look something like this: + + class Bar(_typed_dict_helper.Foo, total=False): + b: int +""" + +from __future__ import annotations + +from typing import Optional, TypedDict + +OptionalIntType = Optional[int] + +class Foo(TypedDict): + a: OptionalIntType diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py index 4bdb2a0fad6c76..a65a3c7ed3929e 100644 --- a/Lib/test/test_typing.py +++ b/Lib/test/test_typing.py @@ -29,6 +29,7 @@ import types from test import mod_generics_cache +from test import _typed_dict_helper class BaseTestCase(TestCase): @@ -2808,6 +2809,9 @@ class Point2D(TypedDict): x: int y: int +class Bar(_typed_dict_helper.Foo, total=False): + b: int + class LabelPoint2D(Point2D, Label): ... class Options(TypedDict, total=False): @@ -3944,6 +3948,18 @@ class Cat(Animal): 'voice': str, } + def test_is_typeddict(self): + assert is_typeddict(Point2D) is True + assert is_typeddict(Union[str, int]) is False + # classes, not instances + assert is_typeddict(Point2D()) is False + + def test_get_type_hints(self): + self.assertEqual( + get_type_hints(Bar), + {'a': typing.Optional[int], 'b': int} + ) + class IOTests(BaseTestCase): diff --git a/Lib/typing.py b/Lib/typing.py index 123fbc2c450107..5f75a2728055a8 100644 --- a/Lib/typing.py +++ b/Lib/typing.py @@ -125,16 +125,16 @@ # legitimate imports of those modules. -def _type_convert(arg): +def _type_convert(arg, module=None): """For converting None to type(None), and strings to ForwardRef.""" if arg is None: return type(None) if isinstance(arg, str): - return ForwardRef(arg) + return ForwardRef(arg, module=module) return arg -def _type_check(arg, msg, is_argument=True): +def _type_check(arg, msg, is_argument=True, module=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 @@ -150,7 +150,7 @@ def _type_check(arg, msg, is_argument=True): if is_argument: invalid_generic_forms = invalid_generic_forms + (ClassVar, Final) - arg = _type_convert(arg) + arg = _type_convert(arg, module=module) if (isinstance(arg, _GenericAlias) and arg.__origin__ in invalid_generic_forms): raise TypeError(f"{arg} is not valid as type argument") @@ -517,9 +517,9 @@ class ForwardRef(_Final, _root=True): __slots__ = ('__forward_arg__', '__forward_code__', '__forward_evaluated__', '__forward_value__', - '__forward_is_argument__') + '__forward_is_argument__', '__forward_module__') - def __init__(self, arg, is_argument=True): + def __init__(self, arg, is_argument=True, module=None): if not isinstance(arg, str): raise TypeError(f"Forward reference must be a string -- got {arg!r}") try: @@ -531,6 +531,7 @@ def __init__(self, arg, is_argument=True): self.__forward_evaluated__ = False self.__forward_value__ = None self.__forward_is_argument__ = is_argument + self.__forward_module__ = module def _evaluate(self, globalns, localns, recursive_guard): if self.__forward_arg__ in recursive_guard: @@ -542,6 +543,10 @@ def _evaluate(self, globalns, localns, recursive_guard): globalns = localns elif localns is None: localns = globalns + if self.__forward_module__ is not None: + globalns = getattr( + sys.modules.get(self.__forward_module__, None), '__dict__', globalns + ) type_ =_type_check( eval(self.__forward_code__, globalns, localns), "Forward references must evaluate to types.", @@ -1912,7 +1917,8 @@ def __new__(cls, name, bases, ns, total=True): own_annotation_keys = set(own_annotations.keys()) msg = "TypedDict('Name', {f0: t0, f1: t1, ...}); each t must be a type" own_annotations = { - n: _type_check(tp, msg) for n, tp in own_annotations.items() + n: _type_check(tp, msg, module=tp_dict.__module__) + for n, tp in own_annotations.items() } required_keys = set() optional_keys = set() diff --git a/Misc/NEWS.d/next/Library/2021-07-04-11-33-34.bpo-41249.sHdwBE.rst b/Misc/NEWS.d/next/Library/2021-07-04-11-33-34.bpo-41249.sHdwBE.rst new file mode 100644 index 00000000000000..06dae4a6e93565 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2021-07-04-11-33-34.bpo-41249.sHdwBE.rst @@ -0,0 +1,2 @@ +Fixes ``TypedDict`` to work with ``typing.get_type_hints()`` and postponed evaluation of +annotations across modules. From f787d295171551b3f1d2f3c8bdad3c50375a8a5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Sat, 17 Jul 2021 10:55:30 +0200 Subject: [PATCH 2/2] Remove test for is_typeddict (that was only added in 3.9) --- 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 a65a3c7ed3929e..6abd07ec3f540d 100644 --- a/Lib/test/test_typing.py +++ b/Lib/test/test_typing.py @@ -3948,12 +3948,6 @@ class Cat(Animal): 'voice': str, } - def test_is_typeddict(self): - assert is_typeddict(Point2D) is True - assert is_typeddict(Union[str, int]) is False - # classes, not instances - assert is_typeddict(Point2D()) is False - def test_get_type_hints(self): self.assertEqual( get_type_hints(Bar),