From 6be0a115c5bcb634aba64137f6e4e52a8a36b241 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Sat, 17 Feb 2024 01:55:38 -0500 Subject: [PATCH 1/3] Support __closed__ and __extra_items__ for PEP 728. Signed-off-by: Zixuan James Li --- CHANGELOG.md | 2 + doc/index.rst | 31 +++++++++ src/test_typing_extensions.py | 115 ++++++++++++++++++++++++++++++++++ src/typing_extensions.py | 24 ++++++- 4 files changed, 169 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02416f46..cd38e40f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Unreleased +- Add support for PEP 728, supporting the `closed` keyword argument and the + special `__extra_items__` key for TypedDict. Patch by Zixuan James Li. - Add support for PEP 742, adding `typing_extensions.TypeIs`. Patch by Jelle Zijlstra. - Drop runtime error when a read-only `TypedDict` item overrides a mutable diff --git a/doc/index.rst b/doc/index.rst index b1e2477b..3e76b223 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -394,6 +394,32 @@ Special typing primitives are mutable if they do not carry the :data:`ReadOnly` qualifier. .. versionadded:: 4.9.0 + + The experimental ``closed`` keyword argument and the special key + ``"__extra_items__"`` proposed in :pep:`728` are supported. + + When ``closed`` is unspecified or ``closed=False`` is given, + ``"__extra_items__"`` behave like a regular key. Otherwise, this becomes a + special key that does not show up in ``__readonly_keys__``, + ``__mutable_keys__``, ``__required_keys__``, ``__optional_keys``, or + ``__annotations__``. + + For runtime introspection, two attributes can be looked at: + + .. attribute:: __closed__ + + A boolean flag indicating the value of the keyword argument ``closed`` + on the current ``TypedDict``. + + .. versionadded:: 4.10.0 + + .. attribute:: __extra_items__ + + The type annotation of the extra items allowed on the ``TypedDict``. + This attribute does not appear on a TypedDict that has itself and all + its bases non-closed. + + .. versionadded:: 4.10.0 .. versionchanged:: 4.3.0 @@ -427,6 +453,11 @@ Special typing primitives Support for the :data:`ReadOnly` qualifier was added. + .. versionchanged:: 4.10.0 + + The keyword argument ``closed`` and the special key ``"__extra_items__"`` + when ``closed=True`` is given were supported. + .. class:: TypeVar(name, *constraints, bound=None, covariant=False, contravariant=False, infer_variance=False, default=...) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 53d905e0..ed70a9e1 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -4177,6 +4177,121 @@ class AllTheThings(TypedDict): self.assertEqual(AllTheThings.__optional_keys__, frozenset({'c', 'd'})) self.assertEqual(AllTheThings.__readonly_keys__, frozenset({'a', 'b', 'c'})) self.assertEqual(AllTheThings.__mutable_keys__, frozenset({'d'})) + + def test_extra_keys_non_readonly(self): + class Base(TypedDict, closed=True): + __extra_items__: str + + class Child(Base): + a: NotRequired[int] + + self.assertEqual(Child.__required_keys__, frozenset({})) + self.assertEqual(Child.__optional_keys__, frozenset({'a'})) + self.assertEqual(Child.__readonly_keys__, frozenset({})) + self.assertEqual(Child.__mutable_keys__, frozenset({'a'})) + + def test_extra_keys_readonly(self): + class Base(TypedDict, closed=True): + __extra_items__: ReadOnly[str] + + class Child(Base): + a: NotRequired[str] + + self.assertEqual(Child.__required_keys__, frozenset({})) + self.assertEqual(Child.__optional_keys__, frozenset({'a'})) + self.assertEqual(Child.__readonly_keys__, frozenset({})) + self.assertEqual(Child.__mutable_keys__, frozenset({'a'})) + + def test_extra_key_required(self): + with self.assertRaisesRegex( + TypeError, + "Special key __extra_items__ does not support Required and NotRequired" + ): + TypedDict("A", {"__extra_items__": Required[int]}, closed=True) + + with self.assertRaisesRegex( + TypeError, + "Special key __extra_items__ does not support Required and NotRequired" + ): + TypedDict("A", {"__extra_items__": NotRequired[int]}, closed=True) + + def test_regular_extra_items(self): + class ExtraReadOnly(TypedDict): + __extra_items__: ReadOnly[str] + + class ExtraRequired(TypedDict): + __extra_items__: Required[str] + + class ExtraNotRequired(TypedDict): + __extra_items__: NotRequired[str] + + self.assertEqual(ExtraReadOnly.__required_keys__, frozenset({'__extra_items__'})) + self.assertEqual(ExtraReadOnly.__optional_keys__, frozenset({})) + self.assertEqual(ExtraReadOnly.__readonly_keys__, frozenset({'__extra_items__'})) + self.assertEqual(ExtraReadOnly.__mutable_keys__, frozenset({})) + + self.assertEqual(ExtraRequired.__required_keys__, frozenset({'__extra_items__'})) + self.assertEqual(ExtraRequired.__optional_keys__, frozenset({})) + self.assertEqual(ExtraRequired.__readonly_keys__, frozenset({})) + self.assertEqual(ExtraRequired.__mutable_keys__, frozenset({'__extra_items__'})) + + self.assertEqual(ExtraNotRequired.__required_keys__, frozenset({})) + self.assertEqual(ExtraNotRequired.__optional_keys__, frozenset({'__extra_items__'})) + self.assertEqual(ExtraNotRequired.__readonly_keys__, frozenset({})) + self.assertEqual(ExtraNotRequired.__mutable_keys__, frozenset({'__extra_items__'})) + + def test_closed_inheritance(self): + class Base(TypedDict, closed=True): + __extra_items__: ReadOnly[Union[str, None]] + + class Child(Base): + a: int + __extra_items__: int + + class GrandChild(Child, closed=True): + __extra_items__: str + + self.assertEqual(Base.__required_keys__, frozenset({})) + self.assertEqual(Base.__optional_keys__, frozenset({})) + self.assertEqual(Base.__readonly_keys__, frozenset({})) + self.assertEqual(Base.__mutable_keys__, frozenset({})) + self.assertEqual(Base.__extra_items__, ReadOnly[Union[str, None]]) + + self.assertEqual(Child.__required_keys__, frozenset({'a', "__extra_items__"})) + self.assertEqual(Child.__optional_keys__, frozenset({})) + self.assertEqual(Child.__readonly_keys__, frozenset({})) + self.assertEqual(Child.__mutable_keys__, frozenset({'a', "__extra_items__"})) + self.assertEqual(Child.__extra_items__, ReadOnly[Union[str, None]]) + + self.assertEqual(GrandChild.__required_keys__, frozenset({'a', "__extra_items__"})) + self.assertEqual(GrandChild.__optional_keys__, frozenset({})) + self.assertEqual(GrandChild.__readonly_keys__, frozenset({})) + self.assertEqual(GrandChild.__mutable_keys__, frozenset({'a', "__extra_items__"})) + self.assertEqual(GrandChild.__extra_items__, str) + + self.assertEqual(Base.__annotations__, {}) + self.assertEqual(Child.__annotations__, {"__extra_items__": int, "a": int}) + self.assertEqual(GrandChild.__annotations__, {"__extra_items__": int, "a": int}) + + self.assertTrue(Base.__closed__) + self.assertFalse(Child.__closed__) + self.assertTrue(GrandChild.__closed__) + + def test_absent_extra_items(self): + class Base(TypedDict): + a: int + + class ChildA(Base, closed=True): + ... + + class ChildB(Base, closed=True): + __extra_items__: None + + self.assertNotIn("__extra_items__", Base.__dict__) + self.assertIn("__extra_items__", ChildA.__dict__) + self.assertIn("__extra_items__", ChildB.__dict__) + self.assertEqual(ChildA.__extra_items__, Never) + self.assertEqual(ChildB.__extra_items__, type(None)) class AnnotatedTests(BaseTestCase): diff --git a/src/typing_extensions.py b/src/typing_extensions.py index f39d4c7f..2b5d79cc 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -875,7 +875,7 @@ def _get_typeddict_qualifiers(annotation_type): break class _TypedDictMeta(type): - def __new__(cls, name, bases, ns, *, total=True): + def __new__(cls, name, bases, ns, *, total=True, closed=False): """Create new typed dict class object. This method is called when TypedDict is subclassed, @@ -920,6 +920,7 @@ def __new__(cls, name, bases, ns, *, total=True): optional_keys = set() readonly_keys = set() mutable_keys = set() + extra_items_type = _marker for base in bases: base_dict = base.__dict__ @@ -929,6 +930,20 @@ def __new__(cls, name, bases, ns, *, total=True): optional_keys.update(base_dict.get('__optional_keys__', ())) readonly_keys.update(base_dict.get('__readonly_keys__', ())) mutable_keys.update(base_dict.get('__mutable_keys__', ())) + if '__extra_items__' in base_dict: + extra_items_type = base_dict['__extra_items__'] + + if closed and extra_items_type is _marker: + extra_items_type = Never + if closed and "__extra_items__" in own_annotations: + annotation_type = own_annotations.pop("__extra_items__") + qualifiers = set(_get_typeddict_qualifiers(annotation_type)) + if Required in qualifiers or NotRequired in qualifiers: + raise TypeError( + f"Special key __extra_items__ does not support" + " Required and NotRequired" + ) + extra_items_type = annotation_type annotations.update(own_annotations) for annotation_key, annotation_type in own_annotations.items(): @@ -956,6 +971,9 @@ def __new__(cls, name, bases, ns, *, total=True): tp_dict.__mutable_keys__ = frozenset(mutable_keys) if not hasattr(tp_dict, '__total__'): tp_dict.__total__ = total + tp_dict.__closed__ = closed + if extra_items_type is not _marker: + tp_dict.__extra_items__ = extra_items_type return tp_dict __call__ = dict # static method @@ -969,7 +987,7 @@ def __subclasscheck__(cls, other): _TypedDict = type.__new__(_TypedDictMeta, 'TypedDict', (), {}) @_ensure_subclassable(lambda bases: (_TypedDict,)) - def TypedDict(typename, fields=_marker, /, *, total=True, **kwargs): + def TypedDict(typename, fields=_marker, /, *, total=True, closed=False, **kwargs): """A simple typed namespace. At runtime it is equivalent to a plain dict. TypedDict creates a dictionary type such that a type checker will expect all @@ -1050,7 +1068,7 @@ class Point2D(TypedDict): # 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, closed=closed) td.__orig_bases__ = (TypedDict,) return td From 572531b9d9c0242cf989e548adc7d431f5e707ac Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Sat, 17 Feb 2024 15:34:30 -0500 Subject: [PATCH 2/3] Make the attribute non-optional and support kwarg compat. Also reorganize the test cases and add coverage. Signed-off-by: Zixuan James Li --- doc/index.rst | 20 ++++++--- src/test_typing_extensions.py | 85 ++++++++++++++++++++++++----------- src/typing_extensions.py | 20 +++++---- 3 files changed, 82 insertions(+), 43 deletions(-) diff --git a/doc/index.rst b/doc/index.rst index 3e76b223..4bd8c702 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -396,10 +396,10 @@ Special typing primitives .. versionadded:: 4.9.0 The experimental ``closed`` keyword argument and the special key - ``"__extra_items__"`` proposed in :pep:`728` are supported. + ``__extra_items__`` proposed in :pep:`728` are supported. When ``closed`` is unspecified or ``closed=False`` is given, - ``"__extra_items__"`` behave like a regular key. Otherwise, this becomes a + ``__extra_items__`` behaves like a regular key. Otherwise, this becomes a special key that does not show up in ``__readonly_keys__``, ``__mutable_keys__``, ``__required_keys__``, ``__optional_keys``, or ``__annotations__``. @@ -408,16 +408,22 @@ Special typing primitives .. attribute:: __closed__ - A boolean flag indicating the value of the keyword argument ``closed`` - on the current ``TypedDict``. + A boolean flag indicating whether the current ``TypedDict`` is + considered closed. This is not inherited by the ``TypedDict``'s + subclasses. .. versionadded:: 4.10.0 .. attribute:: __extra_items__ The type annotation of the extra items allowed on the ``TypedDict``. - This attribute does not appear on a TypedDict that has itself and all - its bases non-closed. + This attribute defaults to ``None`` on a TypedDict that has itself and + all its bases non-closed. This default is different from ``type(None)`` + that represents ``__extra_items__: None`` defined on a closed + ``TypedDict``. + + If ``__extra_items__`` is not defined or inherited on a closed + ``TypedDict``, this defaults to ``Never``. .. versionadded:: 4.10.0 @@ -455,7 +461,7 @@ Special typing primitives .. versionchanged:: 4.10.0 - The keyword argument ``closed`` and the special key ``"__extra_items__"`` + The keyword argument ``closed`` and the special key ``__extra_items__`` when ``closed=True`` is given were supported. .. class:: TypeVar(name, *constraints, bound=None, covariant=False, diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index ed70a9e1..ecda9cf3 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -3820,6 +3820,24 @@ class ChildWithInlineAndOptional(Untotal, Inline): {'inline': bool, 'untotal': str, 'child': bool}, ) + class Closed(TypedDict, closed=True): + __extra_items__: None + + class Unclosed(TypedDict, closed=False): + ... + + class ChildUnclosed(Closed, Unclosed): + ... + + self.assertFalse(ChildUnclosed.__closed__) + self.assertEqual(ChildUnclosed.__extra_items__, type(None)) + + class ChildClosed(Unclosed, Closed): + ... + + self.assertFalse(ChildClosed.__closed__) + self.assertEqual(ChildClosed.__extra_items__, type(None)) + wrong_bases = [ (One, Regular), (Regular, One), @@ -4219,79 +4237,92 @@ def test_regular_extra_items(self): class ExtraReadOnly(TypedDict): __extra_items__: ReadOnly[str] - class ExtraRequired(TypedDict): - __extra_items__: Required[str] - - class ExtraNotRequired(TypedDict): - __extra_items__: NotRequired[str] - self.assertEqual(ExtraReadOnly.__required_keys__, frozenset({'__extra_items__'})) self.assertEqual(ExtraReadOnly.__optional_keys__, frozenset({})) self.assertEqual(ExtraReadOnly.__readonly_keys__, frozenset({'__extra_items__'})) self.assertEqual(ExtraReadOnly.__mutable_keys__, frozenset({})) + self.assertEqual(ExtraReadOnly.__extra_items__, None) + self.assertFalse(ExtraReadOnly.__closed__) + + class ExtraRequired(TypedDict): + __extra_items__: Required[str] self.assertEqual(ExtraRequired.__required_keys__, frozenset({'__extra_items__'})) self.assertEqual(ExtraRequired.__optional_keys__, frozenset({})) self.assertEqual(ExtraRequired.__readonly_keys__, frozenset({})) self.assertEqual(ExtraRequired.__mutable_keys__, frozenset({'__extra_items__'})) + self.assertEqual(ExtraRequired.__extra_items__, None) + self.assertFalse(ExtraRequired.__closed__) + + class ExtraNotRequired(TypedDict): + __extra_items__: NotRequired[str] self.assertEqual(ExtraNotRequired.__required_keys__, frozenset({})) self.assertEqual(ExtraNotRequired.__optional_keys__, frozenset({'__extra_items__'})) self.assertEqual(ExtraNotRequired.__readonly_keys__, frozenset({})) self.assertEqual(ExtraNotRequired.__mutable_keys__, frozenset({'__extra_items__'})) + self.assertEqual(ExtraNotRequired.__extra_items__, None) + self.assertFalse(ExtraNotRequired.__closed__) def test_closed_inheritance(self): class Base(TypedDict, closed=True): __extra_items__: ReadOnly[Union[str, None]] - - class Child(Base): - a: int - __extra_items__: int - - class GrandChild(Child, closed=True): - __extra_items__: str self.assertEqual(Base.__required_keys__, frozenset({})) self.assertEqual(Base.__optional_keys__, frozenset({})) self.assertEqual(Base.__readonly_keys__, frozenset({})) self.assertEqual(Base.__mutable_keys__, frozenset({})) + self.assertEqual(Base.__annotations__, {}) self.assertEqual(Base.__extra_items__, ReadOnly[Union[str, None]]) + self.assertTrue(Base.__closed__) + + class Child(Base): + a: int + __extra_items__: int self.assertEqual(Child.__required_keys__, frozenset({'a', "__extra_items__"})) self.assertEqual(Child.__optional_keys__, frozenset({})) self.assertEqual(Child.__readonly_keys__, frozenset({})) self.assertEqual(Child.__mutable_keys__, frozenset({'a', "__extra_items__"})) + self.assertEqual(Child.__annotations__, {"__extra_items__": int, "a": int}) self.assertEqual(Child.__extra_items__, ReadOnly[Union[str, None]]) + self.assertFalse(Child.__closed__) + + class GrandChild(Child, closed=True): + __extra_items__: str self.assertEqual(GrandChild.__required_keys__, frozenset({'a', "__extra_items__"})) self.assertEqual(GrandChild.__optional_keys__, frozenset({})) self.assertEqual(GrandChild.__readonly_keys__, frozenset({})) self.assertEqual(GrandChild.__mutable_keys__, frozenset({'a', "__extra_items__"})) - self.assertEqual(GrandChild.__extra_items__, str) - - self.assertEqual(Base.__annotations__, {}) - self.assertEqual(Child.__annotations__, {"__extra_items__": int, "a": int}) self.assertEqual(GrandChild.__annotations__, {"__extra_items__": int, "a": int}) - - self.assertTrue(Base.__closed__) - self.assertFalse(Child.__closed__) + self.assertEqual(GrandChild.__extra_items__, str) self.assertTrue(GrandChild.__closed__) - def test_absent_extra_items(self): + def test_implicit_extra_items(self): class Base(TypedDict): a: int - + + self.assertEqual(Base.__extra_items__, None) + self.assertFalse(Base.__closed__) + class ChildA(Base, closed=True): ... + self.assertEqual(ChildA.__extra_items__, Never) + self.assertTrue(ChildA.__closed__) + class ChildB(Base, closed=True): __extra_items__: None - - self.assertNotIn("__extra_items__", Base.__dict__) - self.assertIn("__extra_items__", ChildA.__dict__) - self.assertIn("__extra_items__", ChildB.__dict__) - self.assertEqual(ChildA.__extra_items__, Never) + self.assertEqual(ChildB.__extra_items__, type(None)) + self.assertTrue(ChildB.__closed__) + + def test_backwards_compatibility(self): + with self.assertWarns(DeprecationWarning): + TD = TypedDict("TD", closed=int) + self.assertFalse(TD.__closed__) + self.assertEqual(TD.__annotations__, {"closed": int}) class AnnotatedTests(BaseTestCase): diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 2b5d79cc..6ea99b47 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -920,7 +920,7 @@ def __new__(cls, name, bases, ns, *, total=True, closed=False): optional_keys = set() readonly_keys = set() mutable_keys = set() - extra_items_type = _marker + extra_items_type = None for base in bases: base_dict = base.__dict__ @@ -930,18 +930,18 @@ def __new__(cls, name, bases, ns, *, total=True, closed=False): optional_keys.update(base_dict.get('__optional_keys__', ())) readonly_keys.update(base_dict.get('__readonly_keys__', ())) mutable_keys.update(base_dict.get('__mutable_keys__', ())) - if '__extra_items__' in base_dict: - extra_items_type = base_dict['__extra_items__'] - - if closed and extra_items_type is _marker: + if (base_extra_items_type := base_dict.get('__extra_items__', None)) is not None: + extra_items_type = base_extra_items_type + + if closed and extra_items_type is None: extra_items_type = Never if closed and "__extra_items__" in own_annotations: annotation_type = own_annotations.pop("__extra_items__") qualifiers = set(_get_typeddict_qualifiers(annotation_type)) if Required in qualifiers or NotRequired in qualifiers: raise TypeError( - f"Special key __extra_items__ does not support" - " Required and NotRequired" + "Special key __extra_items__ does not support " + "Required and NotRequired" ) extra_items_type = annotation_type @@ -972,8 +972,7 @@ def __new__(cls, name, bases, ns, *, total=True, closed=False): if not hasattr(tp_dict, '__total__'): tp_dict.__total__ = total tp_dict.__closed__ = closed - if extra_items_type is not _marker: - tp_dict.__extra_items__ = extra_items_type + tp_dict.__extra_items__ = extra_items_type return tp_dict __call__ = dict # static method @@ -1047,6 +1046,9 @@ class Point2D(TypedDict): "using the functional syntax, pass an empty dictionary, e.g. " ) + example + "." warnings.warn(deprecation_msg, DeprecationWarning, stacklevel=2) + if closed is not False and closed is not True: + kwargs["closed"] = closed + closed = False fields = kwargs elif kwargs: raise TypeError("TypedDict takes either a dict or keyword arguments," From 1fb2c64107451f4fb08d070c593fdfd468109e29 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Sat, 17 Feb 2024 18:57:20 -0500 Subject: [PATCH 3/3] Better error handling and skip compat test cases in 3.13+. This also fixes the lint errors. Signed-off-by: Zixuan James Li --- src/test_typing_extensions.py | 36 +++++++++++++++++++++-------------- src/typing_extensions.py | 14 ++++++++++---- 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index ecda9cf3..79c1b881 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -52,6 +52,9 @@ # 3.12 changes the representation of Unpack[] (PEP 692) TYPING_3_12_0 = sys.version_info[:3] >= (3, 12, 0) +# 3.13 drops support for the keyword argument syntax of TypedDict +TYPING_3_13_0 = sys.version_info[:3] >= (3, 13, 0) + # https://github.com/python/cpython/pull/27017 was backported into some 3.9 and 3.10 # versions, but not all HAS_FORWARD_MODULE = "module" in inspect.signature(typing._type_check).parameters @@ -3822,10 +3825,10 @@ class ChildWithInlineAndOptional(Untotal, Inline): class Closed(TypedDict, closed=True): __extra_items__: None - + class Unclosed(TypedDict, closed=False): ... - + class ChildUnclosed(Closed, Unclosed): ... @@ -3834,7 +3837,7 @@ class ChildUnclosed(Closed, Unclosed): class ChildClosed(Unclosed, Closed): ... - + self.assertFalse(ChildClosed.__closed__) self.assertEqual(ChildClosed.__extra_items__, type(None)) @@ -4195,14 +4198,14 @@ class AllTheThings(TypedDict): self.assertEqual(AllTheThings.__optional_keys__, frozenset({'c', 'd'})) self.assertEqual(AllTheThings.__readonly_keys__, frozenset({'a', 'b', 'c'})) self.assertEqual(AllTheThings.__mutable_keys__, frozenset({'d'})) - + def test_extra_keys_non_readonly(self): class Base(TypedDict, closed=True): __extra_items__: str - + class Child(Base): a: NotRequired[int] - + self.assertEqual(Child.__required_keys__, frozenset({})) self.assertEqual(Child.__optional_keys__, frozenset({'a'})) self.assertEqual(Child.__readonly_keys__, frozenset({})) @@ -4211,28 +4214,28 @@ class Child(Base): def test_extra_keys_readonly(self): class Base(TypedDict, closed=True): __extra_items__: ReadOnly[str] - + class Child(Base): a: NotRequired[str] - + self.assertEqual(Child.__required_keys__, frozenset({})) self.assertEqual(Child.__optional_keys__, frozenset({'a'})) self.assertEqual(Child.__readonly_keys__, frozenset({})) self.assertEqual(Child.__mutable_keys__, frozenset({'a'})) - + def test_extra_key_required(self): with self.assertRaisesRegex( TypeError, - "Special key __extra_items__ does not support Required and NotRequired" + "Special key __extra_items__ does not support Required" ): TypedDict("A", {"__extra_items__": Required[int]}, closed=True) with self.assertRaisesRegex( TypeError, - "Special key __extra_items__ does not support Required and NotRequired" + "Special key __extra_items__ does not support NotRequired" ): TypedDict("A", {"__extra_items__": NotRequired[int]}, closed=True) - + def test_regular_extra_items(self): class ExtraReadOnly(TypedDict): __extra_items__: ReadOnly[str] @@ -4263,7 +4266,7 @@ class ExtraNotRequired(TypedDict): self.assertEqual(ExtraNotRequired.__mutable_keys__, frozenset({'__extra_items__'})) self.assertEqual(ExtraNotRequired.__extra_items__, None) self.assertFalse(ExtraNotRequired.__closed__) - + def test_closed_inheritance(self): class Base(TypedDict, closed=True): __extra_items__: ReadOnly[Union[str, None]] @@ -4298,7 +4301,7 @@ class GrandChild(Child, closed=True): self.assertEqual(GrandChild.__annotations__, {"__extra_items__": int, "a": int}) self.assertEqual(GrandChild.__extra_items__, str) self.assertTrue(GrandChild.__closed__) - + def test_implicit_extra_items(self): class Base(TypedDict): a: int @@ -4318,6 +4321,11 @@ class ChildB(Base, closed=True): self.assertEqual(ChildB.__extra_items__, type(None)) self.assertTrue(ChildB.__closed__) + @skipIf( + TYPING_3_13_0, + "The keyword argument alternative to define a " + "TypedDict type using the functional syntax is no longer supported" + ) def test_backwards_compatibility(self): with self.assertWarns(DeprecationWarning): TD = TypedDict("TD", closed=int) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 6ea99b47..f3132ea4 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -930,18 +930,24 @@ def __new__(cls, name, bases, ns, *, total=True, closed=False): optional_keys.update(base_dict.get('__optional_keys__', ())) readonly_keys.update(base_dict.get('__readonly_keys__', ())) mutable_keys.update(base_dict.get('__mutable_keys__', ())) - if (base_extra_items_type := base_dict.get('__extra_items__', None)) is not None: + base_extra_items_type = base_dict.get('__extra_items__', None) + if base_extra_items_type is not None: extra_items_type = base_extra_items_type - + if closed and extra_items_type is None: extra_items_type = Never if closed and "__extra_items__" in own_annotations: annotation_type = own_annotations.pop("__extra_items__") qualifiers = set(_get_typeddict_qualifiers(annotation_type)) - if Required in qualifiers or NotRequired in qualifiers: + if Required in qualifiers: + raise TypeError( + "Special key __extra_items__ does not support " + "Required" + ) + if NotRequired in qualifiers: raise TypeError( "Special key __extra_items__ does not support " - "Required and NotRequired" + "NotRequired" ) extra_items_type = annotation_type