Skip to content

Commit 6be0a11

Browse files
committed
Support __closed__ and __extra_items__ for PEP 728.
Signed-off-by: Zixuan James Li <[email protected]>
1 parent 9f040ab commit 6be0a11

File tree

4 files changed

+169
-3
lines changed

4 files changed

+169
-3
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Unreleased
22

3+
- Add support for PEP 728, supporting the `closed` keyword argument and the
4+
special `__extra_items__` key for TypedDict. Patch by Zixuan James Li.
35
- Add support for PEP 742, adding `typing_extensions.TypeIs`. Patch
46
by Jelle Zijlstra.
57
- Drop runtime error when a read-only `TypedDict` item overrides a mutable

doc/index.rst

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,32 @@ Special typing primitives
394394
are mutable if they do not carry the :data:`ReadOnly` qualifier.
395395

396396
.. versionadded:: 4.9.0
397+
398+
The experimental ``closed`` keyword argument and the special key
399+
``"__extra_items__"`` proposed in :pep:`728` are supported.
400+
401+
When ``closed`` is unspecified or ``closed=False`` is given,
402+
``"__extra_items__"`` behave like a regular key. Otherwise, this becomes a
403+
special key that does not show up in ``__readonly_keys__``,
404+
``__mutable_keys__``, ``__required_keys__``, ``__optional_keys``, or
405+
``__annotations__``.
406+
407+
For runtime introspection, two attributes can be looked at:
408+
409+
.. attribute:: __closed__
410+
411+
A boolean flag indicating the value of the keyword argument ``closed``
412+
on the current ``TypedDict``.
413+
414+
.. versionadded:: 4.10.0
415+
416+
.. attribute:: __extra_items__
417+
418+
The type annotation of the extra items allowed on the ``TypedDict``.
419+
This attribute does not appear on a TypedDict that has itself and all
420+
its bases non-closed.
421+
422+
.. versionadded:: 4.10.0
397423

398424
.. versionchanged:: 4.3.0
399425

@@ -427,6 +453,11 @@ Special typing primitives
427453

428454
Support for the :data:`ReadOnly` qualifier was added.
429455

456+
.. versionchanged:: 4.10.0
457+
458+
The keyword argument ``closed`` and the special key ``"__extra_items__"``
459+
when ``closed=True`` is given were supported.
460+
430461
.. class:: TypeVar(name, *constraints, bound=None, covariant=False,
431462
contravariant=False, infer_variance=False, default=...)
432463

src/test_typing_extensions.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4177,6 +4177,121 @@ class AllTheThings(TypedDict):
41774177
self.assertEqual(AllTheThings.__optional_keys__, frozenset({'c', 'd'}))
41784178
self.assertEqual(AllTheThings.__readonly_keys__, frozenset({'a', 'b', 'c'}))
41794179
self.assertEqual(AllTheThings.__mutable_keys__, frozenset({'d'}))
4180+
4181+
def test_extra_keys_non_readonly(self):
4182+
class Base(TypedDict, closed=True):
4183+
__extra_items__: str
4184+
4185+
class Child(Base):
4186+
a: NotRequired[int]
4187+
4188+
self.assertEqual(Child.__required_keys__, frozenset({}))
4189+
self.assertEqual(Child.__optional_keys__, frozenset({'a'}))
4190+
self.assertEqual(Child.__readonly_keys__, frozenset({}))
4191+
self.assertEqual(Child.__mutable_keys__, frozenset({'a'}))
4192+
4193+
def test_extra_keys_readonly(self):
4194+
class Base(TypedDict, closed=True):
4195+
__extra_items__: ReadOnly[str]
4196+
4197+
class Child(Base):
4198+
a: NotRequired[str]
4199+
4200+
self.assertEqual(Child.__required_keys__, frozenset({}))
4201+
self.assertEqual(Child.__optional_keys__, frozenset({'a'}))
4202+
self.assertEqual(Child.__readonly_keys__, frozenset({}))
4203+
self.assertEqual(Child.__mutable_keys__, frozenset({'a'}))
4204+
4205+
def test_extra_key_required(self):
4206+
with self.assertRaisesRegex(
4207+
TypeError,
4208+
"Special key __extra_items__ does not support Required and NotRequired"
4209+
):
4210+
TypedDict("A", {"__extra_items__": Required[int]}, closed=True)
4211+
4212+
with self.assertRaisesRegex(
4213+
TypeError,
4214+
"Special key __extra_items__ does not support Required and NotRequired"
4215+
):
4216+
TypedDict("A", {"__extra_items__": NotRequired[int]}, closed=True)
4217+
4218+
def test_regular_extra_items(self):
4219+
class ExtraReadOnly(TypedDict):
4220+
__extra_items__: ReadOnly[str]
4221+
4222+
class ExtraRequired(TypedDict):
4223+
__extra_items__: Required[str]
4224+
4225+
class ExtraNotRequired(TypedDict):
4226+
__extra_items__: NotRequired[str]
4227+
4228+
self.assertEqual(ExtraReadOnly.__required_keys__, frozenset({'__extra_items__'}))
4229+
self.assertEqual(ExtraReadOnly.__optional_keys__, frozenset({}))
4230+
self.assertEqual(ExtraReadOnly.__readonly_keys__, frozenset({'__extra_items__'}))
4231+
self.assertEqual(ExtraReadOnly.__mutable_keys__, frozenset({}))
4232+
4233+
self.assertEqual(ExtraRequired.__required_keys__, frozenset({'__extra_items__'}))
4234+
self.assertEqual(ExtraRequired.__optional_keys__, frozenset({}))
4235+
self.assertEqual(ExtraRequired.__readonly_keys__, frozenset({}))
4236+
self.assertEqual(ExtraRequired.__mutable_keys__, frozenset({'__extra_items__'}))
4237+
4238+
self.assertEqual(ExtraNotRequired.__required_keys__, frozenset({}))
4239+
self.assertEqual(ExtraNotRequired.__optional_keys__, frozenset({'__extra_items__'}))
4240+
self.assertEqual(ExtraNotRequired.__readonly_keys__, frozenset({}))
4241+
self.assertEqual(ExtraNotRequired.__mutable_keys__, frozenset({'__extra_items__'}))
4242+
4243+
def test_closed_inheritance(self):
4244+
class Base(TypedDict, closed=True):
4245+
__extra_items__: ReadOnly[Union[str, None]]
4246+
4247+
class Child(Base):
4248+
a: int
4249+
__extra_items__: int
4250+
4251+
class GrandChild(Child, closed=True):
4252+
__extra_items__: str
4253+
4254+
self.assertEqual(Base.__required_keys__, frozenset({}))
4255+
self.assertEqual(Base.__optional_keys__, frozenset({}))
4256+
self.assertEqual(Base.__readonly_keys__, frozenset({}))
4257+
self.assertEqual(Base.__mutable_keys__, frozenset({}))
4258+
self.assertEqual(Base.__extra_items__, ReadOnly[Union[str, None]])
4259+
4260+
self.assertEqual(Child.__required_keys__, frozenset({'a', "__extra_items__"}))
4261+
self.assertEqual(Child.__optional_keys__, frozenset({}))
4262+
self.assertEqual(Child.__readonly_keys__, frozenset({}))
4263+
self.assertEqual(Child.__mutable_keys__, frozenset({'a', "__extra_items__"}))
4264+
self.assertEqual(Child.__extra_items__, ReadOnly[Union[str, None]])
4265+
4266+
self.assertEqual(GrandChild.__required_keys__, frozenset({'a', "__extra_items__"}))
4267+
self.assertEqual(GrandChild.__optional_keys__, frozenset({}))
4268+
self.assertEqual(GrandChild.__readonly_keys__, frozenset({}))
4269+
self.assertEqual(GrandChild.__mutable_keys__, frozenset({'a', "__extra_items__"}))
4270+
self.assertEqual(GrandChild.__extra_items__, str)
4271+
4272+
self.assertEqual(Base.__annotations__, {})
4273+
self.assertEqual(Child.__annotations__, {"__extra_items__": int, "a": int})
4274+
self.assertEqual(GrandChild.__annotations__, {"__extra_items__": int, "a": int})
4275+
4276+
self.assertTrue(Base.__closed__)
4277+
self.assertFalse(Child.__closed__)
4278+
self.assertTrue(GrandChild.__closed__)
4279+
4280+
def test_absent_extra_items(self):
4281+
class Base(TypedDict):
4282+
a: int
4283+
4284+
class ChildA(Base, closed=True):
4285+
...
4286+
4287+
class ChildB(Base, closed=True):
4288+
__extra_items__: None
4289+
4290+
self.assertNotIn("__extra_items__", Base.__dict__)
4291+
self.assertIn("__extra_items__", ChildA.__dict__)
4292+
self.assertIn("__extra_items__", ChildB.__dict__)
4293+
self.assertEqual(ChildA.__extra_items__, Never)
4294+
self.assertEqual(ChildB.__extra_items__, type(None))
41804295

41814296

41824297
class AnnotatedTests(BaseTestCase):

src/typing_extensions.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -875,7 +875,7 @@ def _get_typeddict_qualifiers(annotation_type):
875875
break
876876

877877
class _TypedDictMeta(type):
878-
def __new__(cls, name, bases, ns, *, total=True):
878+
def __new__(cls, name, bases, ns, *, total=True, closed=False):
879879
"""Create new typed dict class object.
880880
881881
This method is called when TypedDict is subclassed,
@@ -920,6 +920,7 @@ def __new__(cls, name, bases, ns, *, total=True):
920920
optional_keys = set()
921921
readonly_keys = set()
922922
mutable_keys = set()
923+
extra_items_type = _marker
923924

924925
for base in bases:
925926
base_dict = base.__dict__
@@ -929,6 +930,20 @@ def __new__(cls, name, bases, ns, *, total=True):
929930
optional_keys.update(base_dict.get('__optional_keys__', ()))
930931
readonly_keys.update(base_dict.get('__readonly_keys__', ()))
931932
mutable_keys.update(base_dict.get('__mutable_keys__', ()))
933+
if '__extra_items__' in base_dict:
934+
extra_items_type = base_dict['__extra_items__']
935+
936+
if closed and extra_items_type is _marker:
937+
extra_items_type = Never
938+
if closed and "__extra_items__" in own_annotations:
939+
annotation_type = own_annotations.pop("__extra_items__")
940+
qualifiers = set(_get_typeddict_qualifiers(annotation_type))
941+
if Required in qualifiers or NotRequired in qualifiers:
942+
raise TypeError(
943+
f"Special key __extra_items__ does not support"
944+
" Required and NotRequired"
945+
)
946+
extra_items_type = annotation_type
932947

933948
annotations.update(own_annotations)
934949
for annotation_key, annotation_type in own_annotations.items():
@@ -956,6 +971,9 @@ def __new__(cls, name, bases, ns, *, total=True):
956971
tp_dict.__mutable_keys__ = frozenset(mutable_keys)
957972
if not hasattr(tp_dict, '__total__'):
958973
tp_dict.__total__ = total
974+
tp_dict.__closed__ = closed
975+
if extra_items_type is not _marker:
976+
tp_dict.__extra_items__ = extra_items_type
959977
return tp_dict
960978

961979
__call__ = dict # static method
@@ -969,7 +987,7 @@ def __subclasscheck__(cls, other):
969987
_TypedDict = type.__new__(_TypedDictMeta, 'TypedDict', (), {})
970988

971989
@_ensure_subclassable(lambda bases: (_TypedDict,))
972-
def TypedDict(typename, fields=_marker, /, *, total=True, **kwargs):
990+
def TypedDict(typename, fields=_marker, /, *, total=True, closed=False, **kwargs):
973991
"""A simple typed namespace. At runtime it is equivalent to a plain dict.
974992
975993
TypedDict creates a dictionary type such that a type checker will expect all
@@ -1050,7 +1068,7 @@ class Point2D(TypedDict):
10501068
# Setting correct module is necessary to make typed dict classes pickleable.
10511069
ns['__module__'] = module
10521070

1053-
td = _TypedDictMeta(typename, (), ns, total=total)
1071+
td = _TypedDictMeta(typename, (), ns, total=total, closed=closed)
10541072
td.__orig_bases__ = (TypedDict,)
10551073
return td
10561074

0 commit comments

Comments
 (0)