Skip to content

Commit 5a806b1

Browse files
committed
NoExtraItems
1 parent 61f06f1 commit 5a806b1

File tree

2 files changed

+108
-49
lines changed

2 files changed

+108
-49
lines changed

src/test_typing_extensions.py

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
Never,
5555
NewType,
5656
NoDefault,
57+
NoExtraItems,
5758
NoReturn,
5859
NotRequired,
5960
Optional,
@@ -4030,7 +4031,7 @@ def test_keywords_syntax_raises_on_3_13(self):
40304031
with self.assertRaises(TypeError), self.assertWarns(DeprecationWarning):
40314032
TypedDict('Emp', name=str, id=int)
40324033

4033-
@skipIf(sys.version_info >= (3, 13), "3.13 removes support for kwargs")
4034+
@skipIf(sys.version_info >= (3, 15), "3.15 removes support for kwargs")
40344035
def test_basics_keywords_syntax(self):
40354036
with self.assertWarns(DeprecationWarning):
40364037
Emp = TypedDict('Emp', name=str, id=int)
@@ -4047,14 +4048,18 @@ def test_basics_keywords_syntax(self):
40474048
self.assertEqual(Emp.__annotations__, {'name': str, 'id': int})
40484049
self.assertEqual(Emp.__total__, True)
40494050

4050-
@skipIf(sys.version_info >= (3, 13), "3.13 removes support for kwargs")
4051+
@skipIf(sys.version_info >= (3, 15), "3.15 removes support for kwargs")
40514052
def test_typeddict_special_keyword_names(self):
40524053
with self.assertWarns(DeprecationWarning):
40534054
TD = TypedDict("TD", cls=type, self=object, typename=str, _typename=int,
4054-
fields=list, _fields=dict)
4055+
fields=list, _fields=dict,
4056+
closed=bool, extra_items=bool)
40554057
self.assertEqual(TD.__name__, 'TD')
40564058
self.assertEqual(TD.__annotations__, {'cls': type, 'self': object, 'typename': str,
4057-
'_typename': int, 'fields': list, '_fields': dict})
4059+
'_typename': int, 'fields': list, '_fields': dict,
4060+
'closed': bool, 'extra_items': bool})
4061+
self.assertIs(TD.__closed__, False)
4062+
self.assertIs(TD.__extra_items__, NoExtraItems)
40584063
a = TD(cls=str, self=42, typename='foo', _typename=53,
40594064
fields=[('bar', tuple)], _fields={'baz', set})
40604065
self.assertEqual(a['cls'], str)
@@ -4323,14 +4328,31 @@ class ChildUnclosed(Closed, Unclosed):
43234328
...
43244329

43254330
self.assertFalse(ChildUnclosed.__closed__)
4326-
self.assertEqual(ChildUnclosed.__extra_items__, type(None))
4331+
self.assertEqual(ChildUnclosed.__extra_items__, NoExtraItems)
43274332

43284333
with self.assertWarns(DeprecationWarning):
43294334
class ChildClosed(Unclosed, Closed):
43304335
...
43314336

43324337
self.assertFalse(ChildClosed.__closed__)
4333-
self.assertEqual(ChildClosed.__extra_items__, type(None))
4338+
self.assertEqual(ChildClosed.__extra_items__, NoExtraItems)
4339+
4340+
def test_extra_items_class_arg(self):
4341+
class TD(TypedDict, extra_items=int):
4342+
a: str
4343+
4344+
self.assertEqual(TD.__extra_items__, int)
4345+
self.assertEqual(TD.__annotations__, {'a': str})
4346+
self.assertEqual(TD.__required_keys__, frozenset({'a'}))
4347+
self.assertEqual(TD.__optional_keys__, frozenset())
4348+
4349+
class NoExtra(TypedDict):
4350+
a: str
4351+
4352+
self.assertIs(NoExtra.__extra_items__, NoExtraItems)
4353+
self.assertEqual(NoExtra.__annotations__, {'a': str})
4354+
self.assertEqual(NoExtra.__required_keys__, frozenset({'a'}))
4355+
self.assertEqual(NoExtra.__optional_keys__, frozenset())
43344356

43354357
def test_is_typeddict(self):
43364358
self.assertIs(is_typeddict(Point2D), True)
@@ -4748,7 +4770,7 @@ class ExtraReadOnly(TypedDict):
47484770
self.assertEqual(ExtraReadOnly.__optional_keys__, frozenset({}))
47494771
self.assertEqual(ExtraReadOnly.__readonly_keys__, frozenset({'__extra_items__'}))
47504772
self.assertEqual(ExtraReadOnly.__mutable_keys__, frozenset({}))
4751-
self.assertEqual(ExtraReadOnly.__extra_items__, None)
4773+
self.assertEqual(ExtraReadOnly.__extra_items__, NoExtraItems)
47524774
self.assertFalse(ExtraReadOnly.__closed__)
47534775

47544776
class ExtraRequired(TypedDict):
@@ -4758,7 +4780,7 @@ class ExtraRequired(TypedDict):
47584780
self.assertEqual(ExtraRequired.__optional_keys__, frozenset({}))
47594781
self.assertEqual(ExtraRequired.__readonly_keys__, frozenset({}))
47604782
self.assertEqual(ExtraRequired.__mutable_keys__, frozenset({'__extra_items__'}))
4761-
self.assertEqual(ExtraRequired.__extra_items__, None)
4783+
self.assertEqual(ExtraRequired.__extra_items__, NoExtraItems)
47624784
self.assertFalse(ExtraRequired.__closed__)
47634785

47644786
class ExtraNotRequired(TypedDict):
@@ -4768,7 +4790,7 @@ class ExtraNotRequired(TypedDict):
47684790
self.assertEqual(ExtraNotRequired.__optional_keys__, frozenset({'__extra_items__'}))
47694791
self.assertEqual(ExtraNotRequired.__readonly_keys__, frozenset({}))
47704792
self.assertEqual(ExtraNotRequired.__mutable_keys__, frozenset({'__extra_items__'}))
4771-
self.assertEqual(ExtraNotRequired.__extra_items__, None)
4793+
self.assertEqual(ExtraNotRequired.__extra_items__, NoExtraItems)
47724794
self.assertFalse(ExtraNotRequired.__closed__)
47734795

47744796
def test_closed_inheritance(self):
@@ -4810,7 +4832,7 @@ def test_implicit_extra_items(self):
48104832
class Base(TypedDict):
48114833
a: int
48124834

4813-
self.assertEqual(Base.__extra_items__, None)
4835+
self.assertEqual(Base.__extra_items__, NoExtraItems)
48144836
self.assertFalse(Base.__closed__)
48154837

48164838
class ChildA(Base, closed=True):

src/typing_extensions.py

Lines changed: 76 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@
9595
'ReadOnly',
9696
'Required',
9797
'NotRequired',
98+
'NoDefault',
99+
'NoExtraItems',
98100

99101
# Pure aliases, have always been in typing
100102
'AbstractSet',
@@ -121,7 +123,6 @@
121123
'MutableMapping',
122124
'MutableSequence',
123125
'MutableSet',
124-
'NoDefault',
125126
'Optional',
126127
'Pattern',
127128
'Reversible',
@@ -871,6 +872,59 @@ def inner(func):
871872
return inner
872873

873874

875+
if not hasattr(typing, "NoDefault") or not hasattr(typing, "NoExtraItems"):
876+
class SingletonMeta(type):
877+
def __setattr__(cls, attr, value):
878+
# TypeError is consistent with the behavior of NoneType
879+
raise TypeError(
880+
f"cannot set {attr!r} attribute of immutable type {cls.__name__!r}"
881+
)
882+
883+
884+
if hasattr(typing, "NoDefault"):
885+
NoDefault = typing.NoDefault
886+
else:
887+
class NoDefaultType(metaclass=SingletonMeta):
888+
"""The type of the NoDefault singleton."""
889+
890+
__slots__ = ()
891+
892+
def __new__(cls):
893+
return globals().get("NoDefault") or object.__new__(cls)
894+
895+
def __repr__(self):
896+
return "typing_extensions.NoDefault"
897+
898+
def __reduce__(self):
899+
return "NoDefault"
900+
901+
NoDefault = NoDefaultType()
902+
del NoDefaultType
903+
904+
if hasattr(typing, "NoExtraItems"):
905+
NoExtraItems = typing.NoExtraItems
906+
else:
907+
class NoExtraItemsType(metaclass=SingletonMeta):
908+
"""The type of the NoExtraItems singleton."""
909+
910+
__slots__ = ()
911+
912+
def __new__(cls):
913+
return globals().get("NoExtraItems") or object.__new__(cls)
914+
915+
def __repr__(self):
916+
return "typing_extensions.NoExtraItems"
917+
918+
def __reduce__(self):
919+
return "NoExtraItems"
920+
921+
NoExtraItems = NoExtraItemsType()
922+
del NoExtraItemsType
923+
924+
if not hasattr(typing, "NoDefault") or not hasattr(typing, "NoExtraItems"):
925+
del SingletonMeta
926+
927+
874928
# Update this to something like >=3.13.0b1 if and when
875929
# PEP 728 is implemented in CPython
876930
_PEP_728_IMPLEMENTED = False
@@ -917,7 +971,7 @@ def _get_typeddict_qualifiers(annotation_type):
917971
break
918972

919973
class _TypedDictMeta(type):
920-
def __new__(cls, name, bases, ns, *, total=True, closed=None, extra_items=None):
974+
def __new__(cls, name, bases, ns, *, total=True, closed=None, extra_items=NoExtraItems):
921975
"""Create new typed dict class object.
922976
923977
This method is called when TypedDict is subclassed,
@@ -929,8 +983,8 @@ def __new__(cls, name, bases, ns, *, total=True, closed=None, extra_items=None):
929983
if type(base) is not _TypedDictMeta and base is not typing.Generic:
930984
raise TypeError('cannot inherit from both a TypedDict type '
931985
'and a non-TypedDict base class')
932-
if closed is not None and extra_items is not None:
933-
raise TypeError("Cannot combine closed=True and extra_items")
986+
if closed is not None and extra_items is not NoExtraItems:
987+
raise TypeError(f"Cannot combine closed={closed!r} and extra_items")
934988
elif closed is None:
935989
closed = False
936990

@@ -982,9 +1036,6 @@ def __new__(cls, name, bases, ns, *, total=True, closed=None, extra_items=None):
9821036
optional_keys.update(base_dict.get('__optional_keys__', ()))
9831037
readonly_keys.update(base_dict.get('__readonly_keys__', ()))
9841038
mutable_keys.update(base_dict.get('__mutable_keys__', ()))
985-
base_extra_items_type = getattr(base, '__extra_items__', None)
986-
if base_extra_items_type is not None:
987-
extra_items_type = base_extra_items_type
9881039
if getattr(base, "__closed__", False) and not closed:
9891040
if sys.version_info < (3, 14):
9901041
# PEP 728 wants this to be an error, but that is not
@@ -997,7 +1048,7 @@ def __new__(cls, name, bases, ns, *, total=True, closed=None, extra_items=None):
9971048
else:
9981049
raise TypeError("Child of a closed TypedDict must also be closed")
9991050

1000-
if closed and extra_items_type is None:
1051+
if closed and extra_items_type is NoExtraItems:
10011052
extra_items_type = Never
10021053

10031054
# This was specified in an earlier version of PEP 728. Support
@@ -1059,7 +1110,16 @@ def __subclasscheck__(cls, other):
10591110
_TypedDict = type.__new__(_TypedDictMeta, 'TypedDict', (), {})
10601111

10611112
@_ensure_subclassable(lambda bases: (_TypedDict,))
1062-
def TypedDict(typename, fields=_marker, /, *, total=True, closed=False, **kwargs):
1113+
def TypedDict(
1114+
typename,
1115+
fields=_marker,
1116+
/,
1117+
*,
1118+
total=True,
1119+
closed=False,
1120+
extra_items=NoExtraItems,
1121+
**kwargs
1122+
):
10631123
"""A simple typed namespace. At runtime it is equivalent to a plain dict.
10641124
10651125
TypedDict creates a dictionary type such that a type checker will expect all
@@ -1119,9 +1179,14 @@ class Point2D(TypedDict):
11191179
"using the functional syntax, pass an empty dictionary, e.g. "
11201180
) + example + "."
11211181
warnings.warn(deprecation_msg, DeprecationWarning, stacklevel=2)
1182+
# Support a field called "closed"
11221183
if closed is not False and closed is not True:
11231184
kwargs["closed"] = closed
1124-
closed = False
1185+
closed = None
1186+
# Or "extra_items"
1187+
if extra_items is not NoExtraItems:
1188+
kwargs["extra_items"] = extra_items
1189+
extra_items = NoExtraItems
11251190
fields = kwargs
11261191
elif kwargs:
11271192
raise TypeError("TypedDict takes either a dict or keyword arguments,"
@@ -1143,7 +1208,7 @@ class Point2D(TypedDict):
11431208
# Setting correct module is necessary to make typed dict classes pickleable.
11441209
ns['__module__'] = module
11451210

1146-
td = _TypedDictMeta(typename, (), ns, total=total, closed=closed)
1211+
td = _TypedDictMeta(typename, (), ns, total=total, closed=closed, extra_items=extra_items)
11471212
td.__orig_bases__ = (TypedDict,)
11481213
return td
11491214

@@ -1466,34 +1531,6 @@ def TypeAlias(self, parameters):
14661531
)
14671532

14681533

1469-
if hasattr(typing, "NoDefault"):
1470-
NoDefault = typing.NoDefault
1471-
else:
1472-
class NoDefaultTypeMeta(type):
1473-
def __setattr__(cls, attr, value):
1474-
# TypeError is consistent with the behavior of NoneType
1475-
raise TypeError(
1476-
f"cannot set {attr!r} attribute of immutable type {cls.__name__!r}"
1477-
)
1478-
1479-
class NoDefaultType(metaclass=NoDefaultTypeMeta):
1480-
"""The type of the NoDefault singleton."""
1481-
1482-
__slots__ = ()
1483-
1484-
def __new__(cls):
1485-
return globals().get("NoDefault") or object.__new__(cls)
1486-
1487-
def __repr__(self):
1488-
return "typing_extensions.NoDefault"
1489-
1490-
def __reduce__(self):
1491-
return "NoDefault"
1492-
1493-
NoDefault = NoDefaultType()
1494-
del NoDefaultType, NoDefaultTypeMeta
1495-
1496-
14971534
def _set_default(type_param, default):
14981535
type_param.has_default = lambda: default is not NoDefault
14991536
type_param.__default__ = default

0 commit comments

Comments
 (0)