From d8265618f2a32c1dff7b2a3594cd2842fb8b6828 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 9 Jun 2023 07:34:01 -0700 Subject: [PATCH] Reimplement TypedDict in a similar way to Python (#191) Co-authored-by: Alex Waygood --- CHANGELOG.md | 6 + doc/index.rst | 12 ++ src/_typed_dict_test_helper.py | 6 +- src/test_typing_extensions.py | 312 +++++++++++++++++++++++++++++---- src/typing_extensions.py | 201 ++++++++++----------- 5 files changed, 392 insertions(+), 145 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a424b723..7f2bd903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Unreleased +- 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 + `TypedDict` itself as the argument. Patch by Jelle Zijlstra. - Declare support for Python 3.12. Patch by Jelle Zijlstra. - Fix tests on Python 3.13, which removes support for creating `TypedDict` classes through the keyword-argument syntax. Patch by diff --git a/doc/index.rst b/doc/index.rst index f076ae33..60497375 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -347,6 +347,12 @@ Special typing primitives Support for the ``__orig_bases__`` attribute was added. + .. versionchanged:: 4.7.0 + + ``TypedDict`` is now a function rather than a class. + 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, contravariant=False, infer_variance=False, default=...) @@ -680,6 +686,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 of :py:func:`typing.is_typeddict`. + .. function:: reveal_type(obj) See :py:func:`typing.reveal_type`. In ``typing`` since 3.11. 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 4bec45f6..129cdcaa 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -38,7 +38,7 @@ 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 +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. @@ -1148,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 @@ -3036,8 +3052,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) @@ -3091,7 +3106,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__() @@ -3101,12 +3115,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('Emp', name=str, id=int) self.assertEqual(Emp.__name__, 'Emp') self.assertEqual(Emp.__annotations__, {'name': str, 'id': int}) @@ -3135,7 +3144,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) @@ -3147,11 +3156,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) @@ -3159,17 +3166,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]) @@ -3189,25 +3199,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, @@ -3215,19 +3230,168 @@ 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): - 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 = {"TypedDict": TypedDict} + 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) + 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): @@ -3266,6 +3430,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 = {"TypedDict": TypedDict} + 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 @@ -3331,11 +3513,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): @@ -3370,6 +3552,64 @@ 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] + 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): diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 9e33402d..e8ce9ee1 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -918,113 +918,56 @@ 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, - ) + # 3.10.0 and later + _TAKES_MODULE = "module" in inspect.signature(typing._type_check).parameters - 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 + if sys.version_info >= (3, 8): + _fake_name = "Protocol" + else: + _fake_name = "_Protocol" - return _TypedDictMeta(typename, (), ns, total=total) + class _TypedDictMeta(type): + def __new__(cls, name, bases, ns, total=True): + """Create new typed dict class object. - _typeddict_new.__text_signature__ = ('($cls, _typename, _fields=None,' - ' /, *, total=True, **kwargs)') + 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') - _TAKES_MODULE = "module" in inspect.signature(typing._type_check).parameters + if any(issubclass(b, typing.Generic) for b in bases): + generic_base = (typing.Generic,) + else: + generic_base = () - class _TypedDictMeta(type): - def __init__(cls, name, bases, ns, total=True): - super().__init__(name, bases, ns) + # typing.py generally doesn't let you inherit from plain Generic, unless + # 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__ == _fake_name: + tp_dict.__qualname__ = name - 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) - else: - # generic TypedDicts get __orig_bases__ from Generic - tp_dict.__orig_bases__ = bases or (TypedDict,) + 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() @@ -1058,15 +1001,19 @@ def __new__(cls, name, bases, ns, total=True): tp_dict.__total__ = total return tp_dict - __instancecheck__ = __subclasscheck__ = _check_fails + __call__ = dict # static method - TypedDict = _TypedDictMeta('TypedDict', (dict,), {}) - TypedDict.__module__ = __name__ - TypedDict.__doc__ = \ - """A simple typed name space. At runtime it is equivalent to a plain dict. + def __subclasscheck__(cls, other): + # Typed dicts are only for static structural subtyping. + raise TypeError('TypedDict does not support instance and class checks') + + __instancecheck__ = __subclasscheck__ + + 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:: @@ -1083,14 +1030,49 @@ 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. """ + 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) @@ -1108,7 +1090,10 @@ class Film(TypedDict): is_typeddict(Film) # => True is_typeddict(Union[list, str]) # => False """ - return isinstance(tp, tuple(_TYPEDDICT_TYPES)) + # On 3.8, this would otherwise return True + if hasattr(typing, "TypedDict") and tp is typing.TypedDict: + return False + return isinstance(tp, _TYPEDDICT_TYPES) if hasattr(typing, "assert_type"):