Skip to content

Commit 1333138

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

File tree

4 files changed

+123
-3
lines changed

4 files changed

+123
-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: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,15 @@ 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__``.
397406

398407
.. versionchanged:: 4.3.0
399408

@@ -427,6 +436,11 @@ Special typing primitives
427436

428437
Support for the :data:`ReadOnly` qualifier was added.
429438

439+
.. versionchanged:: 4.10.0
440+
441+
The keyword argument ``closed`` and the special key ``"__extra_items__"``
442+
when ``closed=True`` is given were supported.
443+
430444
.. class:: TypeVar(name, *constraints, bound=None, covariant=False,
431445
contravariant=False, infer_variance=False, default=...)
432446

src/test_typing_extensions.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4177,7 +4177,103 @@ 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: int
4187+
4188+
self.assertEqual(Child.__required_keys__, frozenset({'a'}))
4189+
self.assertEqual(Child.__optional_keys__, frozenset({}))
4190+
self.assertEqual(Child.__readonly_keys__, frozenset({}))
4191+
self.assertEqual(Child.__mutable_keys__, frozenset({'a'}))
41804192

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: int
4199+
4200+
self.assertEqual(Child.__required_keys__, frozenset({'a'}))
4201+
self.assertEqual(Child.__optional_keys__, frozenset({}))
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+
4259+
self.assertEqual(Child.__required_keys__, frozenset({'a', "__extra_items__"}))
4260+
self.assertEqual(Child.__optional_keys__, frozenset({}))
4261+
self.assertEqual(Child.__readonly_keys__, frozenset({}))
4262+
self.assertEqual(Child.__mutable_keys__, frozenset({'a', "__extra_items__"}))
4263+
4264+
self.assertEqual(GrandChild.__required_keys__, frozenset({'a', "__extra_items__"}))
4265+
self.assertEqual(GrandChild.__optional_keys__, frozenset({}))
4266+
self.assertEqual(GrandChild.__readonly_keys__, frozenset({}))
4267+
self.assertEqual(GrandChild.__mutable_keys__, frozenset({'a', "__extra_items__"}))
4268+
4269+
self.assertEqual(Base.__annotations__, {"__extra_items__": ReadOnly[Union[str, None]]})
4270+
self.assertEqual(Child.__annotations__, {"__extra_items__": int, "a": int})
4271+
self.assertEqual(GrandChild.__annotations__, {"__extra_items__": str, "a": int})
4272+
4273+
self.assertTrue(Base.__closed__)
4274+
self.assertFalse(Child.__closed__)
4275+
self.assertTrue(GrandChild.__closed__)
4276+
41814277

41824278
class AnnotatedTests(BaseTestCase):
41834279

src/typing_extensions.py

Lines changed: 11 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,
@@ -934,6 +934,13 @@ def __new__(cls, name, bases, ns, *, total=True):
934934
for annotation_key, annotation_type in own_annotations.items():
935935
qualifiers = set(_get_typeddict_qualifiers(annotation_type))
936936

937+
if closed and annotation_key == "__extra_items__":
938+
if Required in qualifiers or NotRequired in qualifiers:
939+
raise TypeError(
940+
f"Special key __extra_items__ does not support"
941+
" Required and NotRequired"
942+
)
943+
continue
937944
if Required in qualifiers:
938945
required_keys.add(annotation_key)
939946
elif NotRequired in qualifiers:
@@ -956,6 +963,7 @@ def __new__(cls, name, bases, ns, *, total=True):
956963
tp_dict.__mutable_keys__ = frozenset(mutable_keys)
957964
if not hasattr(tp_dict, '__total__'):
958965
tp_dict.__total__ = total
966+
tp_dict.__closed__ = closed
959967
return tp_dict
960968

961969
__call__ = dict # static method
@@ -969,7 +977,7 @@ def __subclasscheck__(cls, other):
969977
_TypedDict = type.__new__(_TypedDictMeta, 'TypedDict', (), {})
970978

971979
@_ensure_subclassable(lambda bases: (_TypedDict,))
972-
def TypedDict(typename, fields=_marker, /, *, total=True, **kwargs):
980+
def TypedDict(typename, fields=_marker, /, *, total=True, closed=False, **kwargs):
973981
"""A simple typed namespace. At runtime it is equivalent to a plain dict.
974982
975983
TypedDict creates a dictionary type such that a type checker will expect all
@@ -1050,7 +1058,7 @@ class Point2D(TypedDict):
10501058
# Setting correct module is necessary to make typed dict classes pickleable.
10511059
ns['__module__'] = module
10521060

1053-
td = _TypedDictMeta(typename, (), ns, total=total)
1061+
td = _TypedDictMeta(typename, (), ns, total=total, closed=closed)
10541062
td.__orig_bases__ = (TypedDict,)
10551063
return td
10561064

0 commit comments

Comments
 (0)