Skip to content

Commit b7bf949

Browse files
authored
Add support for PEP 728 (#329)
Signed-off-by: Zixuan James Li <[email protected]>
1 parent 9f040ab commit b7bf949

File tree

4 files changed

+222
-3
lines changed

4 files changed

+222
-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: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,38 @@ 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__`` behaves 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 whether the current ``TypedDict`` is
412+
considered closed. This is not inherited by the ``TypedDict``'s
413+
subclasses.
414+
415+
.. versionadded:: 4.10.0
416+
417+
.. attribute:: __extra_items__
418+
419+
The type annotation of the extra items allowed on the ``TypedDict``.
420+
This attribute defaults to ``None`` on a TypedDict that has itself and
421+
all its bases non-closed. This default is different from ``type(None)``
422+
that represents ``__extra_items__: None`` defined on a closed
423+
``TypedDict``.
424+
425+
If ``__extra_items__`` is not defined or inherited on a closed
426+
``TypedDict``, this defaults to ``Never``.
427+
428+
.. versionadded:: 4.10.0
397429

398430
.. versionchanged:: 4.3.0
399431

@@ -427,6 +459,11 @@ Special typing primitives
427459

428460
Support for the :data:`ReadOnly` qualifier was added.
429461

462+
.. versionchanged:: 4.10.0
463+
464+
The keyword argument ``closed`` and the special key ``__extra_items__``
465+
when ``closed=True`` is given were supported.
466+
430467
.. class:: TypeVar(name, *constraints, bound=None, covariant=False,
431468
contravariant=False, infer_variance=False, default=...)
432469

src/test_typing_extensions.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@
5252
# 3.12 changes the representation of Unpack[] (PEP 692)
5353
TYPING_3_12_0 = sys.version_info[:3] >= (3, 12, 0)
5454

55+
# 3.13 drops support for the keyword argument syntax of TypedDict
56+
TYPING_3_13_0 = sys.version_info[:3] >= (3, 13, 0)
57+
5558
# https://github.com/python/cpython/pull/27017 was backported into some 3.9 and 3.10
5659
# versions, but not all
5760
HAS_FORWARD_MODULE = "module" in inspect.signature(typing._type_check).parameters
@@ -3820,6 +3823,24 @@ class ChildWithInlineAndOptional(Untotal, Inline):
38203823
{'inline': bool, 'untotal': str, 'child': bool},
38213824
)
38223825

3826+
class Closed(TypedDict, closed=True):
3827+
__extra_items__: None
3828+
3829+
class Unclosed(TypedDict, closed=False):
3830+
...
3831+
3832+
class ChildUnclosed(Closed, Unclosed):
3833+
...
3834+
3835+
self.assertFalse(ChildUnclosed.__closed__)
3836+
self.assertEqual(ChildUnclosed.__extra_items__, type(None))
3837+
3838+
class ChildClosed(Unclosed, Closed):
3839+
...
3840+
3841+
self.assertFalse(ChildClosed.__closed__)
3842+
self.assertEqual(ChildClosed.__extra_items__, type(None))
3843+
38233844
wrong_bases = [
38243845
(One, Regular),
38253846
(Regular, One),
@@ -4178,6 +4199,139 @@ class AllTheThings(TypedDict):
41784199
self.assertEqual(AllTheThings.__readonly_keys__, frozenset({'a', 'b', 'c'}))
41794200
self.assertEqual(AllTheThings.__mutable_keys__, frozenset({'d'}))
41804201

4202+
def test_extra_keys_non_readonly(self):
4203+
class Base(TypedDict, closed=True):
4204+
__extra_items__: str
4205+
4206+
class Child(Base):
4207+
a: NotRequired[int]
4208+
4209+
self.assertEqual(Child.__required_keys__, frozenset({}))
4210+
self.assertEqual(Child.__optional_keys__, frozenset({'a'}))
4211+
self.assertEqual(Child.__readonly_keys__, frozenset({}))
4212+
self.assertEqual(Child.__mutable_keys__, frozenset({'a'}))
4213+
4214+
def test_extra_keys_readonly(self):
4215+
class Base(TypedDict, closed=True):
4216+
__extra_items__: ReadOnly[str]
4217+
4218+
class Child(Base):
4219+
a: NotRequired[str]
4220+
4221+
self.assertEqual(Child.__required_keys__, frozenset({}))
4222+
self.assertEqual(Child.__optional_keys__, frozenset({'a'}))
4223+
self.assertEqual(Child.__readonly_keys__, frozenset({}))
4224+
self.assertEqual(Child.__mutable_keys__, frozenset({'a'}))
4225+
4226+
def test_extra_key_required(self):
4227+
with self.assertRaisesRegex(
4228+
TypeError,
4229+
"Special key __extra_items__ does not support Required"
4230+
):
4231+
TypedDict("A", {"__extra_items__": Required[int]}, closed=True)
4232+
4233+
with self.assertRaisesRegex(
4234+
TypeError,
4235+
"Special key __extra_items__ does not support NotRequired"
4236+
):
4237+
TypedDict("A", {"__extra_items__": NotRequired[int]}, closed=True)
4238+
4239+
def test_regular_extra_items(self):
4240+
class ExtraReadOnly(TypedDict):
4241+
__extra_items__: ReadOnly[str]
4242+
4243+
self.assertEqual(ExtraReadOnly.__required_keys__, frozenset({'__extra_items__'}))
4244+
self.assertEqual(ExtraReadOnly.__optional_keys__, frozenset({}))
4245+
self.assertEqual(ExtraReadOnly.__readonly_keys__, frozenset({'__extra_items__'}))
4246+
self.assertEqual(ExtraReadOnly.__mutable_keys__, frozenset({}))
4247+
self.assertEqual(ExtraReadOnly.__extra_items__, None)
4248+
self.assertFalse(ExtraReadOnly.__closed__)
4249+
4250+
class ExtraRequired(TypedDict):
4251+
__extra_items__: Required[str]
4252+
4253+
self.assertEqual(ExtraRequired.__required_keys__, frozenset({'__extra_items__'}))
4254+
self.assertEqual(ExtraRequired.__optional_keys__, frozenset({}))
4255+
self.assertEqual(ExtraRequired.__readonly_keys__, frozenset({}))
4256+
self.assertEqual(ExtraRequired.__mutable_keys__, frozenset({'__extra_items__'}))
4257+
self.assertEqual(ExtraRequired.__extra_items__, None)
4258+
self.assertFalse(ExtraRequired.__closed__)
4259+
4260+
class ExtraNotRequired(TypedDict):
4261+
__extra_items__: NotRequired[str]
4262+
4263+
self.assertEqual(ExtraNotRequired.__required_keys__, frozenset({}))
4264+
self.assertEqual(ExtraNotRequired.__optional_keys__, frozenset({'__extra_items__'}))
4265+
self.assertEqual(ExtraNotRequired.__readonly_keys__, frozenset({}))
4266+
self.assertEqual(ExtraNotRequired.__mutable_keys__, frozenset({'__extra_items__'}))
4267+
self.assertEqual(ExtraNotRequired.__extra_items__, None)
4268+
self.assertFalse(ExtraNotRequired.__closed__)
4269+
4270+
def test_closed_inheritance(self):
4271+
class Base(TypedDict, closed=True):
4272+
__extra_items__: ReadOnly[Union[str, None]]
4273+
4274+
self.assertEqual(Base.__required_keys__, frozenset({}))
4275+
self.assertEqual(Base.__optional_keys__, frozenset({}))
4276+
self.assertEqual(Base.__readonly_keys__, frozenset({}))
4277+
self.assertEqual(Base.__mutable_keys__, frozenset({}))
4278+
self.assertEqual(Base.__annotations__, {})
4279+
self.assertEqual(Base.__extra_items__, ReadOnly[Union[str, None]])
4280+
self.assertTrue(Base.__closed__)
4281+
4282+
class Child(Base):
4283+
a: int
4284+
__extra_items__: int
4285+
4286+
self.assertEqual(Child.__required_keys__, frozenset({'a', "__extra_items__"}))
4287+
self.assertEqual(Child.__optional_keys__, frozenset({}))
4288+
self.assertEqual(Child.__readonly_keys__, frozenset({}))
4289+
self.assertEqual(Child.__mutable_keys__, frozenset({'a', "__extra_items__"}))
4290+
self.assertEqual(Child.__annotations__, {"__extra_items__": int, "a": int})
4291+
self.assertEqual(Child.__extra_items__, ReadOnly[Union[str, None]])
4292+
self.assertFalse(Child.__closed__)
4293+
4294+
class GrandChild(Child, closed=True):
4295+
__extra_items__: str
4296+
4297+
self.assertEqual(GrandChild.__required_keys__, frozenset({'a', "__extra_items__"}))
4298+
self.assertEqual(GrandChild.__optional_keys__, frozenset({}))
4299+
self.assertEqual(GrandChild.__readonly_keys__, frozenset({}))
4300+
self.assertEqual(GrandChild.__mutable_keys__, frozenset({'a', "__extra_items__"}))
4301+
self.assertEqual(GrandChild.__annotations__, {"__extra_items__": int, "a": int})
4302+
self.assertEqual(GrandChild.__extra_items__, str)
4303+
self.assertTrue(GrandChild.__closed__)
4304+
4305+
def test_implicit_extra_items(self):
4306+
class Base(TypedDict):
4307+
a: int
4308+
4309+
self.assertEqual(Base.__extra_items__, None)
4310+
self.assertFalse(Base.__closed__)
4311+
4312+
class ChildA(Base, closed=True):
4313+
...
4314+
4315+
self.assertEqual(ChildA.__extra_items__, Never)
4316+
self.assertTrue(ChildA.__closed__)
4317+
4318+
class ChildB(Base, closed=True):
4319+
__extra_items__: None
4320+
4321+
self.assertEqual(ChildB.__extra_items__, type(None))
4322+
self.assertTrue(ChildB.__closed__)
4323+
4324+
@skipIf(
4325+
TYPING_3_13_0,
4326+
"The keyword argument alternative to define a "
4327+
"TypedDict type using the functional syntax is no longer supported"
4328+
)
4329+
def test_backwards_compatibility(self):
4330+
with self.assertWarns(DeprecationWarning):
4331+
TD = TypedDict("TD", closed=int)
4332+
self.assertFalse(TD.__closed__)
4333+
self.assertEqual(TD.__annotations__, {"closed": int})
4334+
41814335

41824336
class AnnotatedTests(BaseTestCase):
41834337

src/typing_extensions.py

Lines changed: 29 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 = None
923924

924925
for base in bases:
925926
base_dict = base.__dict__
@@ -929,6 +930,26 @@ 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+
base_extra_items_type = base_dict.get('__extra_items__', None)
934+
if base_extra_items_type is not None:
935+
extra_items_type = base_extra_items_type
936+
937+
if closed and extra_items_type is None:
938+
extra_items_type = Never
939+
if closed and "__extra_items__" in own_annotations:
940+
annotation_type = own_annotations.pop("__extra_items__")
941+
qualifiers = set(_get_typeddict_qualifiers(annotation_type))
942+
if Required in qualifiers:
943+
raise TypeError(
944+
"Special key __extra_items__ does not support "
945+
"Required"
946+
)
947+
if NotRequired in qualifiers:
948+
raise TypeError(
949+
"Special key __extra_items__ does not support "
950+
"NotRequired"
951+
)
952+
extra_items_type = annotation_type
932953

933954
annotations.update(own_annotations)
934955
for annotation_key, annotation_type in own_annotations.items():
@@ -956,6 +977,8 @@ def __new__(cls, name, bases, ns, *, total=True):
956977
tp_dict.__mutable_keys__ = frozenset(mutable_keys)
957978
if not hasattr(tp_dict, '__total__'):
958979
tp_dict.__total__ = total
980+
tp_dict.__closed__ = closed
981+
tp_dict.__extra_items__ = extra_items_type
959982
return tp_dict
960983

961984
__call__ = dict # static method
@@ -969,7 +992,7 @@ def __subclasscheck__(cls, other):
969992
_TypedDict = type.__new__(_TypedDictMeta, 'TypedDict', (), {})
970993

971994
@_ensure_subclassable(lambda bases: (_TypedDict,))
972-
def TypedDict(typename, fields=_marker, /, *, total=True, **kwargs):
995+
def TypedDict(typename, fields=_marker, /, *, total=True, closed=False, **kwargs):
973996
"""A simple typed namespace. At runtime it is equivalent to a plain dict.
974997
975998
TypedDict creates a dictionary type such that a type checker will expect all
@@ -1029,6 +1052,9 @@ class Point2D(TypedDict):
10291052
"using the functional syntax, pass an empty dictionary, e.g. "
10301053
) + example + "."
10311054
warnings.warn(deprecation_msg, DeprecationWarning, stacklevel=2)
1055+
if closed is not False and closed is not True:
1056+
kwargs["closed"] = closed
1057+
closed = False
10321058
fields = kwargs
10331059
elif kwargs:
10341060
raise TypeError("TypedDict takes either a dict or keyword arguments,"
@@ -1050,7 +1076,7 @@ class Point2D(TypedDict):
10501076
# Setting correct module is necessary to make typed dict classes pickleable.
10511077
ns['__module__'] = module
10521078

1053-
td = _TypedDictMeta(typename, (), ns, total=total)
1079+
td = _TypedDictMeta(typename, (), ns, total=total, closed=closed)
10541080
td.__orig_bases__ = (TypedDict,)
10551081
return td
10561082

0 commit comments

Comments
 (0)