Add support for PEP 728 (#329)

Signed-off-by: Zixuan James Li <[email protected]>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 02416f4..cd38e40 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 b1e2477..4bd8c70 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -394,6 +394,38 @@
       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__`` 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__``.
+
+    For runtime introspection, two attributes can be looked at:
+
+    .. attribute:: __closed__
+
+        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 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
 
    .. versionchanged:: 4.3.0
 
@@ -427,6 +459,11 @@
 
       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 53d905e..79c1b88 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
@@ -3820,6 +3823,24 @@
             {'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),
@@ -4178,6 +4199,139 @@
         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"
+        ):
+            TypedDict("A", {"__extra_items__": Required[int]}, closed=True)
+
+        with self.assertRaisesRegex(
+            TypeError,
+            "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]
+
+        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]]
+
+        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.__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
+
+        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.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)
+        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 f39d4c7..f3132ea 100644
--- a/src/typing_extensions.py
+++ b/src/typing_extensions.py
@@ -875,7 +875,7 @@
                 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 @@
             optional_keys = set()
             readonly_keys = set()
             mutable_keys = set()
+            extra_items_type = None
 
             for base in bases:
                 base_dict = base.__dict__
@@ -929,6 +930,26 @@
                 optional_keys.update(base_dict.get('__optional_keys__', ()))
                 readonly_keys.update(base_dict.get('__readonly_keys__', ()))
                 mutable_keys.update(base_dict.get('__mutable_keys__', ()))
+                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:
+                    raise TypeError(
+                        "Special key __extra_items__ does not support "
+                        "Required"
+                    )
+                if NotRequired in qualifiers:
+                    raise TypeError(
+                        "Special key __extra_items__ does not support "
+                        "NotRequired"
+                    )
+                extra_items_type = annotation_type
 
             annotations.update(own_annotations)
             for annotation_key, annotation_type in own_annotations.items():
@@ -956,6 +977,8 @@
             tp_dict.__mutable_keys__ = frozenset(mutable_keys)
             if not hasattr(tp_dict, '__total__'):
                 tp_dict.__total__ = total
+            tp_dict.__closed__ = closed
+            tp_dict.__extra_items__ = extra_items_type
             return tp_dict
 
         __call__ = dict  # static method
@@ -969,7 +992,7 @@
     _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
@@ -1029,6 +1052,9 @@
                 "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,"
@@ -1050,7 +1076,7 @@
             # 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