Skip to content

Commit

Permalink
Make the attribute non-optional and support kwarg compat.
Browse files Browse the repository at this point in the history
Also reorganize the test cases and add coverage.

Signed-off-by: Zixuan James Li <p359101898@gmail.com>
  • Loading branch information
PIG208 committed Feb 17, 2024
1 parent 6be0a11 commit 2ca6e85
Show file tree
Hide file tree
Showing 3 changed files with 81 additions and 43 deletions.
19 changes: 12 additions & 7 deletions doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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__``.
Expand All @@ -408,16 +408,21 @@ 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 its 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

Expand Down Expand Up @@ -455,7 +460,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,
Expand Down
85 changes: 58 additions & 27 deletions src/test_typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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):
Expand Down
20 changes: 11 additions & 9 deletions src/typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,"
Expand Down

0 comments on commit 2ca6e85

Please sign in to comment.