diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py index 6317d4657619f0..8362664f101055 100644 --- a/Lib/test/test_typing.py +++ b/Lib/test/test_typing.py @@ -34,7 +34,7 @@ from typing import dataclass_transform from typing import no_type_check, no_type_check_decorator from typing import Type -from typing import NamedTuple, NotRequired, Required, ReadOnly, TypedDict +from typing import NamedTuple, NotRequired, Required, ReadOnly, TypedDict, NoExtraItems from typing import IO, TextIO, BinaryIO from typing import Pattern, Match from typing import Annotated, ForwardRef @@ -8770,6 +8770,32 @@ class ChildWithInlineAndOptional(Untotal, Inline): class Wrong(*bases): pass + def test_closed_values(self): + class Implicit(TypedDict): ... + class ExplicitTrue(TypedDict, closed=True): ... + class ExplicitFalse(TypedDict, closed=False): ... + + self.assertIsNone(Implicit.__closed__) + self.assertIs(ExplicitTrue.__closed__, True) + self.assertIs(ExplicitFalse.__closed__, False) + + def test_extra_items_class_arg(self): + class TD(TypedDict, extra_items=int): + a: str + + self.assertIs(TD.__extra_items__, int) + self.assertEqual(TD.__annotations__, {'a': str}) + self.assertEqual(TD.__required_keys__, frozenset({'a'})) + self.assertEqual(TD.__optional_keys__, frozenset()) + + class NoExtra(TypedDict): + a: str + + self.assertIs(NoExtra.__extra_items__, NoExtraItems) + self.assertEqual(NoExtra.__annotations__, {'a': str}) + self.assertEqual(NoExtra.__required_keys__, frozenset({'a'})) + self.assertEqual(NoExtra.__optional_keys__, frozenset()) + def test_is_typeddict(self): self.assertIs(is_typeddict(Point2D), True) self.assertIs(is_typeddict(Union[str, int]), False) @@ -9097,6 +9123,71 @@ class AllTheThings(TypedDict): }, ) + def test_closed_inheritance(self): + class Base(TypedDict, extra_items=ReadOnly[Union[str, None]]): + a: int + + self.assertEqual(Base.__required_keys__, frozenset({"a"})) + self.assertEqual(Base.__optional_keys__, frozenset({})) + self.assertEqual(Base.__readonly_keys__, frozenset({})) + self.assertEqual(Base.__mutable_keys__, frozenset({"a"})) + self.assertEqual(Base.__annotations__, {"a": int}) + self.assertEqual(Base.__extra_items__, ReadOnly[Union[str, None]]) + self.assertIsNone(Base.__closed__) + + class Child(Base, extra_items=int): + a: str + + self.assertEqual(Child.__required_keys__, frozenset({'a'})) + self.assertEqual(Child.__optional_keys__, frozenset({})) + self.assertEqual(Child.__readonly_keys__, frozenset({})) + self.assertEqual(Child.__mutable_keys__, frozenset({'a'})) + self.assertEqual(Child.__annotations__, {"a": str}) + self.assertIs(Child.__extra_items__, int) + self.assertIsNone(Child.__closed__) + + class GrandChild(Child, closed=True): + a: float + + self.assertEqual(GrandChild.__required_keys__, frozenset({'a'})) + self.assertEqual(GrandChild.__optional_keys__, frozenset({})) + self.assertEqual(GrandChild.__readonly_keys__, frozenset({})) + self.assertEqual(GrandChild.__mutable_keys__, frozenset({'a'})) + self.assertEqual(GrandChild.__annotations__, {"a": float}) + self.assertIs(GrandChild.__extra_items__, NoExtraItems) + self.assertIs(GrandChild.__closed__, True) + + class GrandGrandChild(GrandChild): + ... + self.assertEqual(GrandGrandChild.__required_keys__, frozenset({'a'})) + self.assertEqual(GrandGrandChild.__optional_keys__, frozenset({})) + self.assertEqual(GrandGrandChild.__readonly_keys__, frozenset({})) + self.assertEqual(GrandGrandChild.__mutable_keys__, frozenset({'a'})) + self.assertEqual(GrandGrandChild.__annotations__, {"a": float}) + self.assertIs(GrandGrandChild.__extra_items__, NoExtraItems) + self.assertIsNone(GrandGrandChild.__closed__) + + def test_implicit_extra_items(self): + class Base(TypedDict): + a: int + + self.assertIs(Base.__extra_items__, NoExtraItems) + self.assertIsNone(Base.__closed__) + + class ChildA(Base, closed=True): + ... + + self.assertEqual(ChildA.__extra_items__, NoExtraItems) + self.assertIs(ChildA.__closed__, True) + + def test_cannot_combine_closed_and_extra_items(self): + with self.assertRaisesRegex( + TypeError, + "Cannot combine closed=True and extra_items" + ): + class TD(TypedDict, closed=True, extra_items=range): + x: str + def test_annotations(self): # _type_check is applied with self.assertRaisesRegex(TypeError, "Plain typing.Final is not valid as type argument"): @@ -9326,6 +9417,12 @@ class A(typing.Match): class B(typing.Pattern): pass + def test_typed_dict_signature(self): + self.assertListEqual( + list(inspect.signature(TypedDict).parameters), + ['typename', 'fields', 'total', 'closed', 'extra_items'] + ) + class AnnotatedTests(BaseTestCase): diff --git a/Lib/typing.py b/Lib/typing.py index 8c1d265019bb94..cc506e6338b13d 100644 --- a/Lib/typing.py +++ b/Lib/typing.py @@ -141,6 +141,7 @@ 'no_type_check', 'no_type_check_decorator', 'NoDefault', + 'NoExtraItems', 'NoReturn', 'NotRequired', 'overload', @@ -3055,6 +3056,33 @@ def _namedtuple_mro_entries(bases): NamedTuple.__mro_entries__ = _namedtuple_mro_entries +class _SingletonMeta(type): + def __setattr__(cls, attr, value): + # TypeError is consistent with the behavior of NoneType + raise TypeError( + f"cannot set {attr!r} attribute of immutable type {cls.__name__!r}" + ) + + +class _NoExtraItemsType(metaclass=_SingletonMeta): + """The type of the NoExtraItems singleton.""" + + __slots__ = () + + def __new__(cls): + return globals().get("NoExtraItems") or object.__new__(cls) + + def __repr__(self): + return 'typing.NoExtraItems' + + def __reduce__(self): + return 'NoExtraItems' + +NoExtraItems = _NoExtraItemsType() +del _NoExtraItemsType +del _SingletonMeta + + def _get_typeddict_qualifiers(annotation_type): while True: annotation_origin = get_origin(annotation_type) @@ -3078,7 +3106,8 @@ def _get_typeddict_qualifiers(annotation_type): class _TypedDictMeta(type): - def __new__(cls, name, bases, ns, total=True): + def __new__(cls, name, bases, ns, total=True, closed=None, + extra_items=NoExtraItems): """Create a new typed dict class object. This method is called when TypedDict is subclassed, @@ -3090,6 +3119,8 @@ def __new__(cls, name, bases, ns, total=True): if type(base) is not _TypedDictMeta and base is not Generic: raise TypeError('cannot inherit from both a TypedDict type ' 'and a non-TypedDict base class') + if closed is not None and extra_items is not NoExtraItems: + raise TypeError(f"Cannot combine closed={closed!r} and extra_items") if any(issubclass(b, Generic) for b in bases): generic_base = (Generic,) @@ -3201,6 +3232,8 @@ def __annotate__(format): tp_dict.__readonly_keys__ = frozenset(readonly_keys) tp_dict.__mutable_keys__ = frozenset(mutable_keys) tp_dict.__total__ = total + tp_dict.__closed__ = closed + tp_dict.__extra_items__ = extra_items return tp_dict __call__ = dict # static method @@ -3212,7 +3245,8 @@ def __subclasscheck__(cls, other): __instancecheck__ = __subclasscheck__ -def TypedDict(typename, fields, /, *, total=True): +def TypedDict(typename, fields, /, *, total=True, closed=None, + extra_items=NoExtraItems): """A simple typed namespace. At runtime it is equivalent to a plain dict. TypedDict creates a dictionary type such that a type checker will expect all @@ -3266,6 +3300,32 @@ class DatabaseUser(TypedDict): id: ReadOnly[int] # the "id" key must not be modified username: str # the "username" key can be changed + The closed argument controls whether the TypedDict allows additional + non-required items during inheritance and assignability checks. + If closed=True, the TypedDict does not allow additional items:: + + Point2D = TypedDict('Point2D', {'x': int, 'y': int}, closed=True) + class Point3D(Point2D): + z: int # Type checker error + + Passing closed=False explicitly requests TypedDict's default open behavior. + If closed is not provided, the behavior is inherited from the superclass. + A type checker is only expected to support a literal False or True as the + value of the closed argument. + + The extra_items argument can instead be used to specify the assignable type + of unknown non-required keys:: + + Point2D = TypedDict('Point2D', {'x': int, 'y': int}, extra_items=int) + class Point3D(Point2D): + z: int # OK + label: str # Type checker error + + The extra_items argument is also inherited through subclassing. It is unset + by default, and it may not be used with the closed argument at the same + time. + + See PEP 728 for more information about closed and extra_items. """ ns = {'__annotations__': dict(fields)} module = _caller() @@ -3273,7 +3333,8 @@ class DatabaseUser(TypedDict): # Setting correct module is necessary to make typed dict classes pickleable. ns['__module__'] = module - td = _TypedDictMeta(typename, (), ns, total=total) + td = _TypedDictMeta(typename, (), ns, total=total, closed=closed, + extra_items=extra_items) td.__orig_bases__ = (TypedDict,) return td diff --git a/Misc/NEWS.d/next/Library/2025-08-18-07-10-55.gh-issue-137840.9b7AnG.rst b/Misc/NEWS.d/next/Library/2025-08-18-07-10-55.gh-issue-137840.9b7AnG.rst new file mode 100644 index 00000000000000..5ea25a86204318 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-08-18-07-10-55.gh-issue-137840.9b7AnG.rst @@ -0,0 +1,3 @@ +:class:`typing.TypedDict` now supports the ``closed`` and ``extra_items`` +keyword arguments (as described in :pep:`728`) to control whether additional +non-required keys are allowed and to specify their value type.