From 572531b9d9c0242cf989e548adc7d431f5e707ac Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Sat, 17 Feb 2024 15:34:30 -0500 Subject: [PATCH] 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,"