From 50ce1b84ff7452a1b92eeba45faeb134f91673b0 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 24 May 2023 11:52:30 -0700 Subject: [PATCH 01/19] Backport new is_typeddict tests --- CHANGELOG.md | 6 ++++-- src/test_typing_extensions.py | 31 ++++++++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 853c211c..a10390df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,10 @@ # Unreleased -- Fix regression in version 4.6.1 where comparing a generic class against a +- Add additional test cases for `is_typeddict` (backport of + https://github.com/python/cpython/pull/104884). Patch by Jelle Zijlstra. +- Fix regression in version 4.6.1 where comparing a generic class against a runtime-checkable protocol using `isinstance()` would cause `AttributeError` - to be raised if using Python 3.7 + to be raised if using Python 3.7. # Release 4.6.1 (May 23, 2023) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 736b46b4..c5cb1b34 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -2886,11 +2886,36 @@ def test_keys_inheritance(self): } def test_is_typeddict(self): - assert is_typeddict(Point2D) is True - assert is_typeddict(Point2Dor3D) is True - assert is_typeddict(Union[str, int]) is False + self.assertIs(is_typeddict(Point2D), True) + self.assertIs(is_typeddict(Point2Dor3D), True) + self.assertIs(is_typeddict(Union[str, int]), False) # classes, not instances assert is_typeddict(Point2D()) is False + self.assertIs(is_typeddict(Point2D()), False) + call_based = TypedDict('call_based', {'a': int}) + self.assertIs(is_typeddict(call_based), True) + self.assertIs(is_typeddict(call_based()), False) + + T = TypeVar("T") + class BarGeneric(TypedDict, Generic[T]): + a: T + self.assertIs(is_typeddict(BarGeneric), True) + self.assertIs(is_typeddict(BarGeneric[int]), False) + self.assertIs(is_typeddict(BarGeneric()), False) + + if hasattr(typing, "TypeAliasType"): + ns = {} + exec("""if True: + class NewGeneric[T](TypedDict): + a: T + """, ns) + NewGeneric = ns["NewGeneric"] + self.assertIs(is_typeddict(NewGeneric), True) + self.assertIs(is_typeddict(NewGeneric[int]), False) + self.assertIs(is_typeddict(NewGeneric()), False) + + # The TypedDict constructor is not itself a TypedDict + self.assertIs(is_typeddict(TypedDict), False) @skipUnless(TYPING_3_8_0, "Python 3.8+ required") def test_is_typeddict_against_typeddict_from_typing(self): From af38e91b6f2813b6eeec0ba0cf7b2fc7b823849b Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 24 May 2023 11:54:40 -0700 Subject: [PATCH 02/19] Check both TypedDicts --- src/test_typing_extensions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index c5cb1b34..189ea9e2 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -2916,6 +2916,8 @@ class NewGeneric[T](TypedDict): # The TypedDict constructor is not itself a TypedDict self.assertIs(is_typeddict(TypedDict), False) + if hasattr(typing, "TypedDict"): + self.assertIs(is_typeddict(typing.TypedDict), False) @skipUnless(TYPING_3_8_0, "Python 3.8+ required") def test_is_typeddict_against_typeddict_from_typing(self): From 21643dcbd140cb088e45ed2eff91a60fe4f77404 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Thu, 25 May 2023 19:12:38 -0700 Subject: [PATCH 03/19] Reimplement TypedDict --- doc/index.rst | 11 +++ src/typing_extensions.py | 197 ++++++++++++++++++--------------------- 2 files changed, 100 insertions(+), 108 deletions(-) diff --git a/doc/index.rst b/doc/index.rst index b38e6477..b20c8755 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -258,6 +258,11 @@ Special typing primitives Support for the ``__orig_bases__`` attribute was added. + .. versionchanged:: 4.7.0 + + The implementation of ``TypedDict`` now follows that in Python 3.9 and higher, + where ``TypedDict`` is a function rather than a class. + .. class:: TypeVar(name, *constraints, bound=None, covariant=False, contravariant=False, infer_variance=False, default=...) @@ -591,6 +596,12 @@ Functions .. versionadded:: 4.1.0 + .. versionchanged:: 4.7.0 + + :func:`is_typeddict` now returns ``False`` when called with + :data:`TypedDict` itself as the argument, consistent with the + behavior in :mod:`typing`. + .. function:: reveal_type(obj) See :py:func:`typing.reveal_type`. In ``typing`` since 3.11. diff --git a/src/typing_extensions.py b/src/typing_extensions.py index a3f45daa..2444ca22 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -912,113 +912,51 @@ def __round__(self, ndigits: int = 0) -> T_co: _TypedDictMeta = typing._TypedDictMeta is_typeddict = typing.is_typeddict else: - def _check_fails(cls, other): - try: - if _caller() not in {'abc', 'functools', 'typing'}: - # Typed dicts are only for static structural subtyping. - raise TypeError('TypedDict does not support instance and class checks') - except (AttributeError, ValueError): - pass - return False - - def _dict_new(*args, **kwargs): - if not args: - raise TypeError('TypedDict.__new__(): not enough arguments') - _, args = args[0], args[1:] # allow the "cls" keyword be passed - return dict(*args, **kwargs) - - _dict_new.__text_signature__ = '($cls, _typename, _fields=None, /, **kwargs)' - - def _typeddict_new(*args, total=True, **kwargs): - if not args: - raise TypeError('TypedDict.__new__(): not enough arguments') - _, args = args[0], args[1:] # allow the "cls" keyword be passed - if args: - typename, args = args[0], args[1:] # allow the "_typename" keyword be passed - elif '_typename' in kwargs: - typename = kwargs.pop('_typename') - warnings.warn("Passing '_typename' as keyword argument is deprecated", - DeprecationWarning, stacklevel=2) - else: - raise TypeError("TypedDict.__new__() missing 1 required positional " - "argument: '_typename'") - if args: - try: - fields, = args # allow the "_fields" keyword be passed - except ValueError: - raise TypeError('TypedDict.__new__() takes from 2 to 3 ' - f'positional arguments but {len(args) + 2} ' - 'were given') - elif '_fields' in kwargs and len(kwargs) == 1: - fields = kwargs.pop('_fields') - warnings.warn("Passing '_fields' as keyword argument is deprecated", - DeprecationWarning, stacklevel=2) - else: - fields = None - - if fields is None: - fields = kwargs - elif kwargs: - raise TypeError("TypedDict takes either a dict or keyword arguments," - " but not both") - - if kwargs: - warnings.warn( - "The kwargs-based syntax for TypedDict definitions is deprecated, " - "may be removed in a future version, and may not be " - "understood by third-party type checkers.", - DeprecationWarning, - stacklevel=2, - ) - - ns = {'__annotations__': dict(fields)} - module = _caller() - if module is not None: - # Setting correct module is necessary to make typed dict classes pickleable. - ns['__module__'] = module - - return _TypedDictMeta(typename, (), ns, total=total) - - _typeddict_new.__text_signature__ = ('($cls, _typename, _fields=None,' - ' /, *, total=True, **kwargs)') - + # 3.10.0 and later _TAKES_MODULE = "module" in inspect.signature(typing._type_check).parameters class _TypedDictMeta(type): - def __init__(cls, name, bases, ns, total=True): - super().__init__(name, bases, ns) - def __new__(cls, name, bases, ns, total=True): - # Create new typed dict class object. - # This method is called directly when TypedDict is subclassed, - # or via _typeddict_new when TypedDict is instantiated. This way - # TypedDict supports all three syntaxes described in its docstring. - # Subclasses and instances of TypedDict return actual dictionaries - # via _dict_new. - ns['__new__'] = _typeddict_new if name == 'TypedDict' else _dict_new - # Don't insert typing.Generic into __bases__ here, - # or Generic.__init_subclass__ will raise TypeError - # in the super().__new__() call. - # Instead, monkey-patch __bases__ onto the class after it's been created. - tp_dict = super().__new__(cls, name, (dict,), ns) - - is_generic = any(issubclass(base, typing.Generic) for base in bases) - - if is_generic: - tp_dict.__bases__ = (typing.Generic, dict) - _maybe_adjust_parameters(tp_dict) + """Create new typed dict class object. + + This method is called when TypedDict is subclassed, + or when TypedDict is instantiated. This way + TypedDict supports all three syntax forms described in its docstring. + Subclasses and instances of TypedDict return actual dictionaries. + """ + for base in bases: + if type(base) is not _TypedDictMeta and base is not typing.Generic: + raise TypeError('cannot inherit from both a TypedDict type ' + 'and a non-TypedDict base class') + + if any(issubclass(b, typing.Generic) for b in bases): + generic_base = (typing.Generic,) else: - # generic TypedDicts get __orig_bases__ from Generic - tp_dict.__orig_bases__ = bases or (TypedDict,) + generic_base = () + + # typing.py generally doesn't let you inherit from plain Generic, unless + # the name of the class happens to be "Protocol". + tp_dict = type.__new__(_TypedDictMeta, "Protocol", (*generic_base, dict), ns) + tp_dict.__name__ = name + if tp_dict.__qualname__ == "Protocol": + tp_dict.__qualname__ = name + + if not hasattr(tp_dict, '__orig_bases__'): + tp_dict.__orig_bases__ = bases annotations = {} own_annotations = ns.get('__annotations__', {}) msg = "TypedDict('Name', {f0: t0, f1: t1, ...}); each t must be a type" - kwds = {"module": tp_dict.__module__} if _TAKES_MODULE else {} - own_annotations = { - n: typing._type_check(tp, msg, **kwds) - for n, tp in own_annotations.items() - } + if _TAKES_MODULE: + own_annotations = { + n: typing._type_check(tp, msg, module=tp_dict.__module__) + for n, tp in own_annotations.items() + } + else: + own_annotations = { + n: typing._type_check(tp, msg) + for n, tp in own_annotations.items() + } required_keys = set() optional_keys = set() @@ -1052,15 +990,20 @@ def __new__(cls, name, bases, ns, total=True): tp_dict.__total__ = total return tp_dict - __instancecheck__ = __subclasscheck__ = _check_fails + __call__ = dict # static method + + def __subclasscheck__(cls, other): + # Typed dicts are only for static structural subtyping. + raise TypeError('TypedDict does not support instance and class checks') + + __instancecheck__ = __subclasscheck__ - TypedDict = _TypedDictMeta('TypedDict', (dict,), {}) - TypedDict.__module__ = __name__ - TypedDict.__doc__ = \ - """A simple typed name space. At runtime it is equivalent to a plain dict. + + def TypedDict(typename, fields=None, /, *, total=True, **kwargs): + """A simple typed namespace. At runtime it is equivalent to a plain dict. TypedDict creates a dictionary type that expects all of its - instances to have a certain set of keys, with each key + instances to have a certain set of keys, where each key is associated with a value of a consistent type. This expectation is not checked at runtime but is only enforced by type checkers. Usage:: @@ -1077,14 +1020,52 @@ class Point2D(TypedDict): The type info can be accessed via the Point2D.__annotations__ dict, and the Point2D.__required_keys__ and Point2D.__optional_keys__ frozensets. - TypedDict supports two additional equivalent forms:: + TypedDict supports an additional equivalent form:: - Point2D = TypedDict('Point2D', x=int, y=int, label=str) Point2D = TypedDict('Point2D', {'x': int, 'y': int, 'label': str}) - The class syntax is only supported in Python 3.6+, while two other - syntax forms work for Python 2.7 and 3.2+ + By default, all keys must be present in a TypedDict. It is possible + to override this by specifying totality. + Usage:: + + class point2D(TypedDict, total=False): + x: int + y: int + + This means that a point2D TypedDict can have any of the keys omitted. A type + checker is only expected to support a literal False or True as the value of + the total argument. True is the default, and makes all items defined in the + class body be required. + + The class syntax is only supported in Python 3.6+, while the other + syntax form works for Python 2.7 and 3.2+ """ + if fields is None: + fields = kwargs + elif kwargs: + raise TypeError("TypedDict takes either a dict or keyword arguments," + " but not both") + if kwargs: + warnings.warn( + "The kwargs-based syntax for TypedDict definitions is deprecated " + "in Python 3.11, will be removed in Python 3.13, and may not be " + "understood by third-party type checkers.", + DeprecationWarning, + stacklevel=2, + ) + + ns = {'__annotations__': dict(fields)} + module = _caller() + if module is not None: + # Setting correct module is necessary to make typed dict classes pickleable. + ns['__module__'] = module + + td = _TypedDictMeta(typename, (), ns, total=total) + td.__orig_bases__ = (TypedDict,) + return td + + _TypedDict = type.__new__(_TypedDictMeta, 'TypedDict', (), {}) + TypedDict.__mro_entries__ = lambda bases: (_TypedDict,) if hasattr(typing, "_TypedDictMeta"): _TYPEDDICT_TYPES = (typing._TypedDictMeta, _TypedDictMeta) From 1053f1e46dc7a5b3369960c053fa846b4ba7f510 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Thu, 25 May 2023 19:15:43 -0700 Subject: [PATCH 04/19] Remove useless sentence from docstring --- src/typing_extensions.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index eebeeb00..48018430 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -1036,9 +1036,6 @@ class point2D(TypedDict, total=False): checker is only expected to support a literal False or True as the value of the total argument. True is the default, and makes all items defined in the class body be required. - - The class syntax is only supported in Python 3.6+, while the other - syntax form works for Python 2.7 and 3.2+ """ if fields is None: fields = kwargs From a5e531decf036739987966fbc4efa473a0e291d6 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Thu, 25 May 2023 19:16:43 -0700 Subject: [PATCH 05/19] lint --- src/typing_extensions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 48018430..8e27380d 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -998,7 +998,6 @@ def __subclasscheck__(cls, other): __instancecheck__ = __subclasscheck__ - def TypedDict(typename, fields=None, /, *, total=True, **kwargs): """A simple typed namespace. At runtime it is equivalent to a plain dict. From 43ac0c4b4ca1d8602e73f7e3d705d86b0a6c4912 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Thu, 25 May 2023 19:17:22 -0700 Subject: [PATCH 06/19] yay 3.7 --- src/typing_extensions.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 8e27380d..07db977d 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -998,7 +998,7 @@ def __subclasscheck__(cls, other): __instancecheck__ = __subclasscheck__ - def TypedDict(typename, fields=None, /, *, total=True, **kwargs): + def TypedDict(__typename, __fields=None, *, total=True, **kwargs): """A simple typed namespace. At runtime it is equivalent to a plain dict. TypedDict creates a dictionary type that expects all of its @@ -1036,8 +1036,8 @@ class point2D(TypedDict, total=False): the total argument. True is the default, and makes all items defined in the class body be required. """ - if fields is None: - fields = kwargs + if __fields is None: + __fields = kwargs elif kwargs: raise TypeError("TypedDict takes either a dict or keyword arguments," " but not both") @@ -1050,13 +1050,13 @@ class body be required. stacklevel=2, ) - ns = {'__annotations__': dict(fields)} + ns = {'__annotations__': dict(__fields)} module = _caller() if module is not None: # Setting correct module is necessary to make typed dict classes pickleable. ns['__module__'] = module - td = _TypedDictMeta(typename, (), ns, total=total) + td = _TypedDictMeta(__typename, (), ns, total=total) td.__orig_bases__ = (TypedDict,) return td From ad5145e909ad052def20b266b82c38fb8708bbb2 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Thu, 25 May 2023 19:18:55 -0700 Subject: [PATCH 07/19] fix 3.8 --- src/typing_extensions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 07db977d..92e7f603 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -1079,6 +1079,9 @@ class Film(TypedDict): is_typeddict(Film) # => True is_typeddict(Union[list, str]) # => False """ + # On 3.8, this would otherwise return True + if hasattr(typing, "TypedDict") and tp is typing.TypedDict: + return False return isinstance(tp, tuple(_TYPEDDICT_TYPES)) From 9cd6d8bf2eb44cc19b306e4f3aeb485ef2a89ee9 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Thu, 25 May 2023 19:36:50 -0700 Subject: [PATCH 08/19] Carry over more tests from typing --- src/_typed_dict_test_helper.py | 6 +- src/test_typing_extensions.py | 284 +++++++++++++++++++++++++++++---- 2 files changed, 256 insertions(+), 34 deletions(-) diff --git a/src/_typed_dict_test_helper.py b/src/_typed_dict_test_helper.py index 7ffc5e1d..c5582b15 100644 --- a/src/_typed_dict_test_helper.py +++ b/src/_typed_dict_test_helper.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import Generic, Optional, T -from typing_extensions import TypedDict +from typing_extensions import TypedDict, Annotated, Required # this class must not be imported into test_typing_extensions.py at top level, otherwise @@ -16,3 +16,7 @@ class Foo(TypedDict): class FooGeneric(TypedDict, Generic[T]): a: Optional[T] + + +class VeryAnnotated(TypedDict, total=False): + a: Annotated[Annotated[Annotated[Required[int], "a"], "b"], "c"] diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 0336c823..22d81770 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -37,7 +37,8 @@ from typing_extensions import clear_overloads, get_overloads, overload from typing_extensions import NamedTuple from typing_extensions import override, deprecated, Buffer, TypeAliasType, TypeVar -from _typed_dict_test_helper import Foo, FooGeneric +import _typed_dict_test_helper +from _typed_dict_test_helper import Foo, FooGeneric, VeryAnnotated # Flags used to mark tests that only apply after a specific # version of the typing module. @@ -1147,10 +1148,26 @@ class NontotalMovie(TypedDict, total=False): title: Required[str] year: int +class ParentNontotalMovie(TypedDict, total=False): + title: Required[str] + +class ChildTotalMovie(ParentNontotalMovie): + year: NotRequired[int] + +class ParentDeeplyAnnotatedMovie(TypedDict): + title: Annotated[Annotated[Required[str], "foobar"], "another level"] + +class ChildDeeplyAnnotatedMovie(ParentDeeplyAnnotatedMovie): + year: NotRequired[Annotated[int, 2000]] + class AnnotatedMovie(TypedDict): title: Annotated[Required[str], "foobar"] year: NotRequired[Annotated[int, 2000]] +class WeirdlyQuotedMovie(TypedDict): + title: Annotated['Annotated[Required[str], "foobar"]', "another level"] + year: NotRequired['Annotated[int, 2000]'] + gth = get_type_hints @@ -2727,8 +2744,7 @@ class BarGeneric(FooGeneric[T], total=False): class TypedDictTests(BaseTestCase): - - def test_basics_iterable_syntax(self): + def test_basics_functional_syntax(self): Emp = TypedDict('Emp', {'name': str, 'id': int}) self.assertIsSubclass(Emp, dict) self.assertIsSubclass(Emp, typing.MutableMapping) @@ -2775,7 +2791,6 @@ def test_typeddict_special_keyword_names(self): self.assertEqual(a['fields'], [('bar', tuple)]) self.assertEqual(a['_fields'], {'baz', set}) - @skipIf(hasattr(typing, 'TypedDict'), "Should be tested by upstream") def test_typeddict_create_errors(self): with self.assertRaises(TypeError): TypedDict.__new__() @@ -2785,12 +2800,7 @@ def test_typeddict_create_errors(self): TypedDict('Emp', [('name', str)], None) with self.assertWarns(DeprecationWarning): - Emp = TypedDict(_typename='Emp', name=str, id=int) - self.assertEqual(Emp.__name__, 'Emp') - self.assertEqual(Emp.__annotations__, {'name': str, 'id': int}) - - with self.assertWarns(DeprecationWarning): - Emp = TypedDict('Emp', _fields={'name': str, 'id': int}) + Emp = TypedDict(__typename='Emp', name=str, id=int) self.assertEqual(Emp.__name__, 'Emp') self.assertEqual(Emp.__annotations__, {'name': str, 'id': int}) @@ -2819,7 +2829,7 @@ def test_typeddict_errors(self): def test_py36_class_syntax_usage(self): self.assertEqual(LabelPoint2D.__name__, 'LabelPoint2D') self.assertEqual(LabelPoint2D.__module__, __name__) - self.assertEqual(get_type_hints(LabelPoint2D), {'x': int, 'y': int, 'label': str}) + self.assertEqual(LabelPoint2D.__annotations__, {'x': int, 'y': int, 'label': str}) self.assertEqual(LabelPoint2D.__bases__, (dict,)) self.assertEqual(LabelPoint2D.__total__, True) self.assertNotIsSubclass(LabelPoint2D, typing.Sequence) @@ -2831,11 +2841,9 @@ def test_py36_class_syntax_usage(self): def test_pickle(self): global EmpD # pickle wants to reference the class by name - EmpD = TypedDict('EmpD', {"name": str, "id": int}) + EmpD = TypedDict('EmpD', {'name': str, 'id': int}) jane = EmpD({'name': 'jane', 'id': 37}) - point = Point2DGeneric(a=5.0, b=3.0) for proto in range(pickle.HIGHEST_PROTOCOL + 1): - # Test non-generic TypedDict z = pickle.dumps(jane, proto) jane2 = pickle.loads(z) self.assertEqual(jane2, jane) @@ -2843,17 +2851,20 @@ def test_pickle(self): ZZ = pickle.dumps(EmpD, proto) EmpDnew = pickle.loads(ZZ) self.assertEqual(EmpDnew({'name': 'jane', 'id': 37}), jane) - # and generic TypedDict - y = pickle.dumps(point, proto) - point2 = pickle.loads(y) - self.assertEqual(point, point2) + + def test_pickle_generic(self): + point = Point2DGeneric(a=5.0, b=3.0) + for proto in range(pickle.HIGHEST_PROTOCOL + 1): + z = pickle.dumps(point, proto) + point2 = pickle.loads(z) + self.assertEqual(point2, point) self.assertEqual(point2, {'a': 5.0, 'b': 3.0}) - YY = pickle.dumps(Point2DGeneric, proto) - Point2DGenericNew = pickle.loads(YY) + ZZ = pickle.dumps(Point2DGeneric, proto) + Point2DGenericNew = pickle.loads(ZZ) self.assertEqual(Point2DGenericNew({'a': 5.0, 'b': 3.0}), point) def test_optional(self): - EmpD = TypedDict('EmpD', {"name": str, "id": int}) + EmpD = TypedDict('EmpD', {'name': str, 'id': int}) self.assertEqual(typing.Optional[EmpD], typing.Union[None, EmpD]) self.assertNotEqual(typing.List[EmpD], typing.Tuple[EmpD]) @@ -2873,25 +2884,30 @@ def test_total(self): self.assertEqual(Options.__optional_keys__, {'log_level', 'log_path'}) def test_optional_keys(self): + class Point2Dor3D(Point2D, total=False): + z: int + assert Point2Dor3D.__required_keys__ == frozenset(['x', 'y']) assert Point2Dor3D.__optional_keys__ == frozenset(['z']) - def test_required_notrequired_keys(self): - assert NontotalMovie.__required_keys__ == frozenset({'title'}) - assert NontotalMovie.__optional_keys__ == frozenset({'year'}) + def test_keys_inheritance(self): + class BaseAnimal(TypedDict): + name: str - assert TotalMovie.__required_keys__ == frozenset({'title'}) - assert TotalMovie.__optional_keys__ == frozenset({'year'}) + class Animal(BaseAnimal, total=False): + voice: str + tail: bool + class Cat(Animal): + fur_color: str - def test_keys_inheritance(self): assert BaseAnimal.__required_keys__ == frozenset(['name']) assert BaseAnimal.__optional_keys__ == frozenset([]) - assert get_type_hints(BaseAnimal) == {'name': str} + assert BaseAnimal.__annotations__ == {'name': str} assert Animal.__required_keys__ == frozenset(['name']) assert Animal.__optional_keys__ == frozenset(['tail', 'voice']) - assert get_type_hints(Animal) == { + assert Animal.__annotations__ == { 'name': str, 'tail': bool, 'voice': str, @@ -2899,19 +2915,141 @@ def test_keys_inheritance(self): assert Cat.__required_keys__ == frozenset(['name', 'fur_color']) assert Cat.__optional_keys__ == frozenset(['tail', 'voice']) - assert get_type_hints(Cat) == { + assert Cat.__annotations__ == { 'fur_color': str, 'name': str, 'tail': bool, 'voice': str, } + def test_required_notrequired_keys(self): + self.assertEqual(NontotalMovie.__required_keys__, + frozenset({"title"})) + self.assertEqual(NontotalMovie.__optional_keys__, + frozenset({"year"})) + + self.assertEqual(TotalMovie.__required_keys__, + frozenset({"title"})) + self.assertEqual(TotalMovie.__optional_keys__, + frozenset({"year"})) + + self.assertEqual(VeryAnnotated.__required_keys__, + frozenset()) + self.assertEqual(VeryAnnotated.__optional_keys__, + frozenset({"a"})) + + self.assertEqual(AnnotatedMovie.__required_keys__, + frozenset({"title"})) + self.assertEqual(AnnotatedMovie.__optional_keys__, + frozenset({"year"})) + + self.assertEqual(WeirdlyQuotedMovie.__required_keys__, + frozenset({"title"})) + self.assertEqual(WeirdlyQuotedMovie.__optional_keys__, + frozenset({"year"})) + + self.assertEqual(ChildTotalMovie.__required_keys__, + frozenset({"title"})) + self.assertEqual(ChildTotalMovie.__optional_keys__, + frozenset({"year"})) + + self.assertEqual(ChildDeeplyAnnotatedMovie.__required_keys__, + frozenset({"title"})) + self.assertEqual(ChildDeeplyAnnotatedMovie.__optional_keys__, + frozenset({"year"})) + + def test_multiple_inheritance(self): + class One(TypedDict): + one: int + class Two(TypedDict): + two: str + class Untotal(TypedDict, total=False): + untotal: str + Inline = TypedDict('Inline', {'inline': bool}) + class Regular: + pass + + class Child(One, Two): + child: bool + self.assertEqual( + Child.__required_keys__, + frozenset(['one', 'two', 'child']), + ) + self.assertEqual( + Child.__optional_keys__, + frozenset([]), + ) + self.assertEqual( + Child.__annotations__, + {'one': int, 'two': str, 'child': bool}, + ) + + class ChildWithOptional(One, Untotal): + child: bool + self.assertEqual( + ChildWithOptional.__required_keys__, + frozenset(['one', 'child']), + ) + self.assertEqual( + ChildWithOptional.__optional_keys__, + frozenset(['untotal']), + ) + self.assertEqual( + ChildWithOptional.__annotations__, + {'one': int, 'untotal': str, 'child': bool}, + ) + + class ChildWithTotalFalse(One, Untotal, total=False): + child: bool + self.assertEqual( + ChildWithTotalFalse.__required_keys__, + frozenset(['one']), + ) + self.assertEqual( + ChildWithTotalFalse.__optional_keys__, + frozenset(['untotal', 'child']), + ) + self.assertEqual( + ChildWithTotalFalse.__annotations__, + {'one': int, 'untotal': str, 'child': bool}, + ) + + class ChildWithInlineAndOptional(Untotal, Inline): + child: bool + self.assertEqual( + ChildWithInlineAndOptional.__required_keys__, + frozenset(['inline', 'child']), + ) + self.assertEqual( + ChildWithInlineAndOptional.__optional_keys__, + frozenset(['untotal']), + ) + self.assertEqual( + ChildWithInlineAndOptional.__annotations__, + {'inline': bool, 'untotal': str, 'child': bool}, + ) + + wrong_bases = [ + (One, Regular), + (Regular, One), + (One, Two, Regular), + (Inline, Regular), + (Untotal, Regular), + ] + for bases in wrong_bases: + with self.subTest(bases=bases): + with self.assertRaisesRegex( + TypeError, + 'cannot inherit from both a TypedDict type and a non-TypedDict', + ): + class Wrong(*bases): + pass + def test_is_typeddict(self): self.assertIs(is_typeddict(Point2D), True) self.assertIs(is_typeddict(Point2Dor3D), True) self.assertIs(is_typeddict(Union[str, int]), False) # classes, not instances - assert is_typeddict(Point2D()) is False self.assertIs(is_typeddict(Point2D()), False) call_based = TypedDict('call_based', {'a': int}) self.assertIs(is_typeddict(call_based), True) @@ -2963,6 +3101,12 @@ def test_get_type_hints_cross_module_subclass(self): {'a': "_DoNotImport", 'b': "int"} ) + def test_get_type_hints(self): + self.assertEqual( + get_type_hints(Bar), + {'a': _typed_dict_test_helper._DoNotImport, 'b': int} + ) + def test_get_type_hints_generic(self): self.assertEqual( get_type_hints(BarGeneric), @@ -2977,6 +3121,24 @@ class FooBarGeneric(BarGeneric[int]): {'a': typing.Optional[T], 'b': int, 'c': str} ) + @skipUnless(TYPING_3_12_0, "PEP 695 required") + def test_pep695_generic_typeddict(self): + ns = {} + exec("""if True: + class A[T](TypedDict): + a: T + """, ns) + A = ns["A"] + T, = A.__type_params__ + self.assertIsInstance(T, TypeVar) + self.assertEqual(T.__name__, 'T') + self.assertEqual(A.__bases__, (Generic, dict)) + self.assertEqual(A.__orig_bases__, (TypedDict, Generic[T])) + self.assertEqual(A.__mro__, (A, Generic, dict, object)) + self.assertEqual(A.__parameters__, (T,)) + self.assertEqual(A[str].__parameters__, ()) + self.assertEqual(A[str].__args__, (str,)) + def test_generic_inheritance(self): class A(TypedDict, Generic[T]): a: T @@ -3042,11 +3204,11 @@ class Point3D(Point2DGeneric[T], Generic[T, KT]): self.assertEqual(Point3D.__total__, True) self.assertEqual(Point3D.__optional_keys__, frozenset()) self.assertEqual(Point3D.__required_keys__, frozenset(['a', 'b', 'c'])) - assert Point3D.__annotations__ == { + self.assertEqual(Point3D.__annotations__, { 'a': T, 'b': T, 'c': KT, - } + }) self.assertEqual(Point3D[int, str].__origin__, Point3D) with self.assertRaises(TypeError): @@ -3081,6 +3243,62 @@ class WithImplicitAny(B): with self.assertRaises(TypeError): WithImplicitAny[str] + def test_non_generic_subscript(self): + # For backward compatibility, subscription works + # on arbitrary TypedDict types. + class TD(TypedDict): + a: T + A = TD[int] + self.assertEqual(A.__origin__, TD) + self.assertEqual(A.__parameters__, ()) + self.assertEqual(A.__args__, (int,)) + a = A(a = 1) + self.assertIs(type(a), dict) + self.assertEqual(a, {'a': 1}) + + def test_orig_bases(self): + T = TypeVar('T') + + class Parent(TypedDict): + pass + + class Child(Parent): + pass + + class OtherChild(Parent): + pass + + class MixedChild(Child, OtherChild, Parent): + pass + + class GenericParent(TypedDict, Generic[T]): + pass + + class GenericChild(GenericParent[int]): + pass + + class OtherGenericChild(GenericParent[str]): + pass + + class MixedGenericChild(GenericChild, OtherGenericChild, GenericParent[float]): + pass + + class MultipleGenericBases(GenericParent[int], GenericParent[float]): + pass + + CallTypedDict = TypedDict('CallTypedDict', {}) + + self.assertEqual(Parent.__orig_bases__, (TypedDict,)) + self.assertEqual(Child.__orig_bases__, (Parent,)) + self.assertEqual(OtherChild.__orig_bases__, (Parent,)) + self.assertEqual(MixedChild.__orig_bases__, (Child, OtherChild, Parent,)) + self.assertEqual(GenericParent.__orig_bases__, (TypedDict, Generic[T])) + self.assertEqual(GenericChild.__orig_bases__, (GenericParent[int],)) + self.assertEqual(OtherGenericChild.__orig_bases__, (GenericParent[str],)) + self.assertEqual(MixedGenericChild.__orig_bases__, (GenericChild, OtherGenericChild, GenericParent[float])) + self.assertEqual(MultipleGenericBases.__orig_bases__, (GenericParent[int], GenericParent[float])) + self.assertEqual(CallTypedDict.__orig_bases__, (TypedDict,)) + class AnnotatedTests(BaseTestCase): From cd8eca10045aa26d376bbb4f810f6a07cc206fbb Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Thu, 25 May 2023 19:39:29 -0700 Subject: [PATCH 09/19] Fix 3.7 --- src/typing_extensions.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 92e7f603..8bf9eece 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -915,6 +915,11 @@ def __round__(self, ndigits: int = 0) -> T_co: # 3.10.0 and later _TAKES_MODULE = "module" in inspect.signature(typing._type_check).parameters + if sys.version_info >= (3, 8): + _fake_name = "Protocol" + else: + _fake_name = "_Protocol" + class _TypedDictMeta(type): def __new__(cls, name, bases, ns, total=True): """Create new typed dict class object. @@ -935,10 +940,10 @@ def __new__(cls, name, bases, ns, total=True): generic_base = () # typing.py generally doesn't let you inherit from plain Generic, unless - # the name of the class happens to be "Protocol". - tp_dict = type.__new__(_TypedDictMeta, "Protocol", (*generic_base, dict), ns) + # the name of the class happens to be "Protocol" (or "_Protocol" on 3.7). + tp_dict = type.__new__(_TypedDictMeta, _fake_name, (*generic_base, dict), ns) tp_dict.__name__ = name - if tp_dict.__qualname__ == "Protocol": + if tp_dict.__qualname__ == _fake_name: tp_dict.__qualname__ = name if not hasattr(tp_dict, '__orig_bases__'): From 884d46fea1b2ab1de6ea430fcfde2a4505ed3d84 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Thu, 25 May 2023 19:40:56 -0700 Subject: [PATCH 10/19] lint --- src/test_typing_extensions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 22d81770..d5939624 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -3252,7 +3252,7 @@ class TD(TypedDict): self.assertEqual(A.__origin__, TD) self.assertEqual(A.__parameters__, ()) self.assertEqual(A.__args__, (int,)) - a = A(a = 1) + a = A(a=1) self.assertIs(type(a), dict) self.assertEqual(a, {'a': 1}) From 0cbea8c7f6304050f023c81bf20fdc813401b92d Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Thu, 25 May 2023 19:41:54 -0700 Subject: [PATCH 11/19] deduplicate test --- src/test_typing_extensions.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index d5939624..05bb6a6a 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -37,7 +37,6 @@ from typing_extensions import clear_overloads, get_overloads, overload from typing_extensions import NamedTuple from typing_extensions import override, deprecated, Buffer, TypeAliasType, TypeVar -import _typed_dict_test_helper from _typed_dict_test_helper import Foo, FooGeneric, VeryAnnotated # Flags used to mark tests that only apply after a specific @@ -3101,12 +3100,6 @@ def test_get_type_hints_cross_module_subclass(self): {'a': "_DoNotImport", 'b': "int"} ) - def test_get_type_hints(self): - self.assertEqual( - get_type_hints(Bar), - {'a': _typed_dict_test_helper._DoNotImport, 'b': int} - ) - def test_get_type_hints_generic(self): self.assertEqual( get_type_hints(BarGeneric), From ceaae14ba41ab7ed0735997544995cb30dbaf2dc Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Thu, 25 May 2023 19:44:24 -0700 Subject: [PATCH 12/19] Ignore --- src/test_typing_extensions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 05bb6a6a..c24db130 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -3236,9 +3236,11 @@ class WithImplicitAny(B): with self.assertRaises(TypeError): WithImplicitAny[str] + @skipUnless(TYPING_3_9_0, "Was changed in 3.9") def test_non_generic_subscript(self): # For backward compatibility, subscription works # on arbitrary TypedDict types. + # (But we don't attempt to backport this misfeature onto 3.7 and 3.8.) class TD(TypedDict): a: T A = TD[int] From 3f6b632c09e17b6df587c01524cb324f36157203 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Thu, 25 May 2023 19:50:19 -0700 Subject: [PATCH 13/19] More changelog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7c64400..2f359312 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,8 @@ - Align the implementation of `TypedDict` with that in Python 3.9 and higher. `typing_extensions.TypedDict` is now a function instead of a class. The private functions `_check_fails`, `_dict_new`, and `_typeddict_new` - have been removed. Patch by Jelle Zijlstra. + have been removed. `is_typeddict` now returns `False` when called with + `TypedDict` itself as the argument. Patch by Jelle Zijlstra. # Release 4.6.2 (May 25, 2023) From ceac4d79d0f527baea51209f6157e202e882b946 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 26 May 2023 08:58:31 -0700 Subject: [PATCH 14/19] Apply suggestions from code review Co-authored-by: Alex Waygood --- CHANGELOG.md | 3 ++- doc/index.rst | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13ec1e4e..5cc61164 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Release 4.7.0 (unreleased) -- Align the implementation of `TypedDict` with that in Python 3.9 and higher. +- Align the implementation of `TypedDict` with the implementation in the + standard library on Python 3.9 and higher. `typing_extensions.TypedDict` is now a function instead of a class. The private functions `_check_fails`, `_dict_new`, and `_typeddict_new` have been removed. `is_typeddict` now returns `False` when called with diff --git a/doc/index.rst b/doc/index.rst index 35d3a2c5..e92ad73a 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -620,7 +620,7 @@ Functions :func:`is_typeddict` now returns ``False`` when called with :data:`TypedDict` itself as the argument, consistent with the - behavior in :mod:`typing`. + behavior of :py:func:`typing.is_typeddict`. .. function:: reveal_type(obj) From 765d133445926254e60a77bd24b54b671edfb87e Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 26 May 2023 17:02:58 -0700 Subject: [PATCH 15/19] Update doc/index.rst Co-authored-by: Alex Waygood --- doc/index.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/doc/index.rst b/doc/index.rst index e92ad73a..95e71a54 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -280,8 +280,9 @@ Special typing primitives .. versionchanged:: 4.7.0 - The implementation of ``TypedDict`` now follows that in Python 3.9 and higher, - where ``TypedDict`` is a function rather than a class. + The implementation of ``TypedDict`` now follows the implementation in the + standard library on Python 3.9 and higher, where :class:`typing.TypedDict` + is a function rather than a class. .. class:: TypeVar(name, *constraints, bound=None, covariant=False, contravariant=False, infer_variance=False, default=...) From 61a475e0cd5c07ae22ef5af3048d4af35b93cbdd Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 26 May 2023 17:04:23 -0700 Subject: [PATCH 16/19] Stop tupling the tuple --- src/typing_extensions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 8bf9eece..e51fed60 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -1087,7 +1087,7 @@ class Film(TypedDict): # On 3.8, this would otherwise return True if hasattr(typing, "TypedDict") and tp is typing.TypedDict: return False - return isinstance(tp, tuple(_TYPEDDICT_TYPES)) + return isinstance(tp, _TYPEDDICT_TYPES) if hasattr(typing, "assert_type"): From f9ba8c5c5fab4b9d8e4d9f700d00fb74f5c0b60b Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 26 May 2023 17:13:36 -0700 Subject: [PATCH 17/19] Fix 3.12 tests --- src/test_typing_extensions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 2fa40c32..76ff2e07 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -2855,7 +2855,7 @@ def test_typeddict_create_errors(self): TypedDict('Emp', [('name', str)], None) with self.assertWarns(DeprecationWarning): - Emp = TypedDict(__typename='Emp', name=str, id=int) + Emp = TypedDict('Emp', name=str, id=int) self.assertEqual(Emp.__name__, 'Emp') self.assertEqual(Emp.__annotations__, {'name': str, 'id': int}) @@ -3118,7 +3118,7 @@ class BarGeneric(TypedDict, Generic[T]): self.assertIs(is_typeddict(BarGeneric()), False) if hasattr(typing, "TypeAliasType"): - ns = {} + ns = {"TypedDict": TypedDict} exec("""if True: class NewGeneric[T](TypedDict): a: T @@ -3172,7 +3172,7 @@ class FooBarGeneric(BarGeneric[int]): @skipUnless(TYPING_3_12_0, "PEP 695 required") def test_pep695_generic_typeddict(self): - ns = {} + ns = {"TypedDict": TypedDict} exec("""if True: class A[T](TypedDict): a: T From bc2f60031b4b0a157b82ee26924f1c5ab86799a3 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 26 May 2023 17:17:33 -0700 Subject: [PATCH 18/19] Better wording --- doc/index.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/index.rst b/doc/index.rst index 95e71a54..469b3683 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -280,9 +280,9 @@ Special typing primitives .. versionchanged:: 4.7.0 - The implementation of ``TypedDict`` now follows the implementation in the - standard library on Python 3.9 and higher, where :class:`typing.TypedDict` - is a function rather than a class. + ``TypedDict`` is now a function rather than a class. + This brings the ``typing_extensions.TypedDict`` closer to the implementation + of :py:mod:`typing.TypedDict` on Python 3.9 and higher. .. class:: TypeVar(name, *constraints, bound=None, covariant=False, contravariant=False, infer_variance=False, default=...) From 854208fd46ded8f58f5932be3aebedaa846a1252 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 26 May 2023 17:23:03 -0700 Subject: [PATCH 19/19] Update doc/index.rst Co-authored-by: Alex Waygood --- doc/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/index.rst b/doc/index.rst index 469b3683..4c297c49 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -281,7 +281,7 @@ Special typing primitives .. versionchanged:: 4.7.0 ``TypedDict`` is now a function rather than a class. - This brings the ``typing_extensions.TypedDict`` closer to the implementation + This brings ``typing_extensions.TypedDict`` closer to the implementation of :py:mod:`typing.TypedDict` on Python 3.9 and higher. .. class:: TypeVar(name, *constraints, bound=None, covariant=False,