Skip to content

Commit fd7dac0

Browse files
gh-137840: Implement PEP 728 (closed and extra_items in typing.TypedDict) (#137933)
Co-authored-by: Jelle Zijlstra <[email protected]>
1 parent 4e7e2dd commit fd7dac0

File tree

3 files changed

+165
-4
lines changed

3 files changed

+165
-4
lines changed

Lib/test/test_typing.py

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
from typing import dataclass_transform
3636
from typing import no_type_check, no_type_check_decorator
3737
from typing import Type
38-
from typing import NamedTuple, NotRequired, Required, ReadOnly, TypedDict
38+
from typing import NamedTuple, NotRequired, Required, ReadOnly, TypedDict, NoExtraItems
3939
from typing import IO, TextIO, BinaryIO
4040
from typing import Pattern, Match
4141
from typing import Annotated, ForwardRef
@@ -8820,6 +8820,32 @@ class ChildWithInlineAndOptional(Untotal, Inline):
88208820
class Wrong(*bases):
88218821
pass
88228822

8823+
def test_closed_values(self):
8824+
class Implicit(TypedDict): ...
8825+
class ExplicitTrue(TypedDict, closed=True): ...
8826+
class ExplicitFalse(TypedDict, closed=False): ...
8827+
8828+
self.assertIsNone(Implicit.__closed__)
8829+
self.assertIs(ExplicitTrue.__closed__, True)
8830+
self.assertIs(ExplicitFalse.__closed__, False)
8831+
8832+
def test_extra_items_class_arg(self):
8833+
class TD(TypedDict, extra_items=int):
8834+
a: str
8835+
8836+
self.assertIs(TD.__extra_items__, int)
8837+
self.assertEqual(TD.__annotations__, {'a': str})
8838+
self.assertEqual(TD.__required_keys__, frozenset({'a'}))
8839+
self.assertEqual(TD.__optional_keys__, frozenset())
8840+
8841+
class NoExtra(TypedDict):
8842+
a: str
8843+
8844+
self.assertIs(NoExtra.__extra_items__, NoExtraItems)
8845+
self.assertEqual(NoExtra.__annotations__, {'a': str})
8846+
self.assertEqual(NoExtra.__required_keys__, frozenset({'a'}))
8847+
self.assertEqual(NoExtra.__optional_keys__, frozenset())
8848+
88238849
def test_is_typeddict(self):
88248850
self.assertIs(is_typeddict(Point2D), True)
88258851
self.assertIs(is_typeddict(Union[str, int]), False)
@@ -9147,6 +9173,71 @@ class AllTheThings(TypedDict):
91479173
},
91489174
)
91499175

9176+
def test_closed_inheritance(self):
9177+
class Base(TypedDict, extra_items=ReadOnly[Union[str, None]]):
9178+
a: int
9179+
9180+
self.assertEqual(Base.__required_keys__, frozenset({"a"}))
9181+
self.assertEqual(Base.__optional_keys__, frozenset({}))
9182+
self.assertEqual(Base.__readonly_keys__, frozenset({}))
9183+
self.assertEqual(Base.__mutable_keys__, frozenset({"a"}))
9184+
self.assertEqual(Base.__annotations__, {"a": int})
9185+
self.assertEqual(Base.__extra_items__, ReadOnly[Union[str, None]])
9186+
self.assertIsNone(Base.__closed__)
9187+
9188+
class Child(Base, extra_items=int):
9189+
a: str
9190+
9191+
self.assertEqual(Child.__required_keys__, frozenset({'a'}))
9192+
self.assertEqual(Child.__optional_keys__, frozenset({}))
9193+
self.assertEqual(Child.__readonly_keys__, frozenset({}))
9194+
self.assertEqual(Child.__mutable_keys__, frozenset({'a'}))
9195+
self.assertEqual(Child.__annotations__, {"a": str})
9196+
self.assertIs(Child.__extra_items__, int)
9197+
self.assertIsNone(Child.__closed__)
9198+
9199+
class GrandChild(Child, closed=True):
9200+
a: float
9201+
9202+
self.assertEqual(GrandChild.__required_keys__, frozenset({'a'}))
9203+
self.assertEqual(GrandChild.__optional_keys__, frozenset({}))
9204+
self.assertEqual(GrandChild.__readonly_keys__, frozenset({}))
9205+
self.assertEqual(GrandChild.__mutable_keys__, frozenset({'a'}))
9206+
self.assertEqual(GrandChild.__annotations__, {"a": float})
9207+
self.assertIs(GrandChild.__extra_items__, NoExtraItems)
9208+
self.assertIs(GrandChild.__closed__, True)
9209+
9210+
class GrandGrandChild(GrandChild):
9211+
...
9212+
self.assertEqual(GrandGrandChild.__required_keys__, frozenset({'a'}))
9213+
self.assertEqual(GrandGrandChild.__optional_keys__, frozenset({}))
9214+
self.assertEqual(GrandGrandChild.__readonly_keys__, frozenset({}))
9215+
self.assertEqual(GrandGrandChild.__mutable_keys__, frozenset({'a'}))
9216+
self.assertEqual(GrandGrandChild.__annotations__, {"a": float})
9217+
self.assertIs(GrandGrandChild.__extra_items__, NoExtraItems)
9218+
self.assertIsNone(GrandGrandChild.__closed__)
9219+
9220+
def test_implicit_extra_items(self):
9221+
class Base(TypedDict):
9222+
a: int
9223+
9224+
self.assertIs(Base.__extra_items__, NoExtraItems)
9225+
self.assertIsNone(Base.__closed__)
9226+
9227+
class ChildA(Base, closed=True):
9228+
...
9229+
9230+
self.assertEqual(ChildA.__extra_items__, NoExtraItems)
9231+
self.assertIs(ChildA.__closed__, True)
9232+
9233+
def test_cannot_combine_closed_and_extra_items(self):
9234+
with self.assertRaisesRegex(
9235+
TypeError,
9236+
"Cannot combine closed=True and extra_items"
9237+
):
9238+
class TD(TypedDict, closed=True, extra_items=range):
9239+
x: str
9240+
91509241
def test_annotations(self):
91519242
# _type_check is applied
91529243
with self.assertRaisesRegex(TypeError, "Plain typing.Final is not valid as type argument"):
@@ -9376,6 +9467,12 @@ class A(typing.Match):
93769467
class B(typing.Pattern):
93779468
pass
93789469

9470+
def test_typed_dict_signature(self):
9471+
self.assertListEqual(
9472+
list(inspect.signature(TypedDict).parameters),
9473+
['typename', 'fields', 'total', 'closed', 'extra_items']
9474+
)
9475+
93799476

93809477
class AnnotatedTests(BaseTestCase):
93819478

Lib/typing.py

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@
141141
'no_type_check',
142142
'no_type_check_decorator',
143143
'NoDefault',
144+
'NoExtraItems',
144145
'NoReturn',
145146
'NotRequired',
146147
'overload',
@@ -3063,6 +3064,33 @@ def _namedtuple_mro_entries(bases):
30633064
NamedTuple.__mro_entries__ = _namedtuple_mro_entries
30643065

30653066

3067+
class _SingletonMeta(type):
3068+
def __setattr__(cls, attr, value):
3069+
# TypeError is consistent with the behavior of NoneType
3070+
raise TypeError(
3071+
f"cannot set {attr!r} attribute of immutable type {cls.__name__!r}"
3072+
)
3073+
3074+
3075+
class _NoExtraItemsType(metaclass=_SingletonMeta):
3076+
"""The type of the NoExtraItems singleton."""
3077+
3078+
__slots__ = ()
3079+
3080+
def __new__(cls):
3081+
return globals().get("NoExtraItems") or object.__new__(cls)
3082+
3083+
def __repr__(self):
3084+
return 'typing.NoExtraItems'
3085+
3086+
def __reduce__(self):
3087+
return 'NoExtraItems'
3088+
3089+
NoExtraItems = _NoExtraItemsType()
3090+
del _NoExtraItemsType
3091+
del _SingletonMeta
3092+
3093+
30663094
def _get_typeddict_qualifiers(annotation_type):
30673095
while True:
30683096
annotation_origin = get_origin(annotation_type)
@@ -3086,7 +3114,8 @@ def _get_typeddict_qualifiers(annotation_type):
30863114

30873115

30883116
class _TypedDictMeta(type):
3089-
def __new__(cls, name, bases, ns, total=True):
3117+
def __new__(cls, name, bases, ns, total=True, closed=None,
3118+
extra_items=NoExtraItems):
30903119
"""Create a new typed dict class object.
30913120
30923121
This method is called when TypedDict is subclassed,
@@ -3098,6 +3127,8 @@ def __new__(cls, name, bases, ns, total=True):
30983127
if type(base) is not _TypedDictMeta and base is not Generic:
30993128
raise TypeError('cannot inherit from both a TypedDict type '
31003129
'and a non-TypedDict base class')
3130+
if closed is not None and extra_items is not NoExtraItems:
3131+
raise TypeError(f"Cannot combine closed={closed!r} and extra_items")
31013132

31023133
if any(issubclass(b, Generic) for b in bases):
31033134
generic_base = (Generic,)
@@ -3209,6 +3240,8 @@ def __annotate__(format):
32093240
tp_dict.__readonly_keys__ = frozenset(readonly_keys)
32103241
tp_dict.__mutable_keys__ = frozenset(mutable_keys)
32113242
tp_dict.__total__ = total
3243+
tp_dict.__closed__ = closed
3244+
tp_dict.__extra_items__ = extra_items
32123245
return tp_dict
32133246

32143247
__call__ = dict # static method
@@ -3220,7 +3253,8 @@ def __subclasscheck__(cls, other):
32203253
__instancecheck__ = __subclasscheck__
32213254

32223255

3223-
def TypedDict(typename, fields, /, *, total=True):
3256+
def TypedDict(typename, fields, /, *, total=True, closed=None,
3257+
extra_items=NoExtraItems):
32243258
"""A simple typed namespace. At runtime it is equivalent to a plain dict.
32253259
32263260
TypedDict creates a dictionary type such that a type checker will expect all
@@ -3274,14 +3308,41 @@ class DatabaseUser(TypedDict):
32743308
id: ReadOnly[int] # the "id" key must not be modified
32753309
username: str # the "username" key can be changed
32763310
3311+
The closed argument controls whether the TypedDict allows additional
3312+
non-required items during inheritance and assignability checks.
3313+
If closed=True, the TypedDict does not allow additional items::
3314+
3315+
Point2D = TypedDict('Point2D', {'x': int, 'y': int}, closed=True)
3316+
class Point3D(Point2D):
3317+
z: int # Type checker error
3318+
3319+
Passing closed=False explicitly requests TypedDict's default open behavior.
3320+
If closed is not provided, the behavior is inherited from the superclass.
3321+
A type checker is only expected to support a literal False or True as the
3322+
value of the closed argument.
3323+
3324+
The extra_items argument can instead be used to specify the assignable type
3325+
of unknown non-required keys::
3326+
3327+
Point2D = TypedDict('Point2D', {'x': int, 'y': int}, extra_items=int)
3328+
class Point3D(Point2D):
3329+
z: int # OK
3330+
label: str # Type checker error
3331+
3332+
The extra_items argument is also inherited through subclassing. It is unset
3333+
by default, and it may not be used with the closed argument at the same
3334+
time.
3335+
3336+
See PEP 728 for more information about closed and extra_items.
32773337
"""
32783338
ns = {'__annotations__': dict(fields)}
32793339
module = _caller()
32803340
if module is not None:
32813341
# Setting correct module is necessary to make typed dict classes pickleable.
32823342
ns['__module__'] = module
32833343

3284-
td = _TypedDictMeta(typename, (), ns, total=total)
3344+
td = _TypedDictMeta(typename, (), ns, total=total, closed=closed,
3345+
extra_items=extra_items)
32853346
td.__orig_bases__ = (TypedDict,)
32863347
return td
32873348

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
:class:`typing.TypedDict` now supports the ``closed`` and ``extra_items``
2+
keyword arguments (as described in :pep:`728`) to control whether additional
3+
non-required keys are allowed and to specify their value type.

0 commit comments

Comments
 (0)