Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Include/internal/pycore_typevarobject.h
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ extern int _Py_typing_type_repr(PyUnicodeWriter *, PyObject *);
extern PyTypeObject _PyTypeAlias_Type;
extern PyTypeObject _PyNoDefault_Type;
extern PyObject _Py_NoDefaultStruct;
extern PyTypeObject _PyNoExtraItems_Type;
extern PyObject _Py_NoExtraItemsStruct;

#ifdef __cplusplus
}
Expand Down
99 changes: 98 additions & 1 deletion Lib/test/test_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
from typing import Self, LiteralString
from typing import TypeAlias
from typing import ParamSpec, Concatenate, ParamSpecArgs, ParamSpecKwargs
from typing import TypeGuard, TypeIs, NoDefault
from typing import TypeGuard, TypeIs, NoDefault, NoExtraItems
import abc
import textwrap
import typing
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is range the builtin here?
That's a bit strange.

Copy link
Contributor Author

@angela-tarantula angela-tarantula Aug 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree it looks a bit odd as an arbitrary extra_items value, but it's valid, since it just means extra keys must have values assignable to range. For context, this test is copied from typing_extensions, where it was used to cover the same case. We could edit it for readability, but I kind of like it as a reminder for maintainers that extra_items accepts any type, not just the obvious ones.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That could be valid for a regular test that validates stored values.

this however is a negative test, isn’t it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's still valid/correct as a negative test. By "valid" do you mean "correct" or "readable?" Passing range into extra_items is valid, and passing closed=True at the same time should raise a runtime error.

Copy link
Contributor

@dimaqq dimaqq Aug 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I was being terse.

I'm trying to say that this test:

        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__)

Could have an extra_items=range counterpart.

That would make a solid test, both understandable and useful.

Meanwhile, the negative test, class TD(TypedDict, closed=True, extra_items=range): --> error would be better served with a simpler, more straightforward extra_items=int argument.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P.S. my comment overall is minor, please don't let me stop your work!

Copy link
Contributor Author

@angela-tarantula angela-tarantula Aug 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for clarifying. I thought about it some more.

I don't think introducing an extra_items=range counterpart would actually widen the test coverage, since it wouldn't be exercising any new behavior. PEP 728 splits responsibilities between runtime and type checker behavior. While extra_items is only supposed to accept a valid type expression, validating that is the type checker's job (e.g. MyPy's valid-type error), not the runtime's. The runtime just stores whatever is passed in.

So the real subject under test is simply:

Child.__extra_items__ must correctly store the value passed into extra_items.

We don't need multiple values to prove that behavior.

And although range is a less obvious type, I think it makes sense in the negative test. That test is specifically asserting the error message “Cannot combine closed=True and extra_items”. Using range highlights that the failure comes from the combination, not from range itself being an invalid value of extra_items.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tend to use range sometimes as it's a builtin type that isn't generic (like list) and doesn't participate in promotion weirdness (like float and historically bytes), so it's a good basic type to test with.

Plus, people who forget that range is a type get to learn something :)

x: str

def test_annotations(self):
# _type_check is applied
with self.assertRaisesRegex(TypeError, "Plain typing.Final is not valid as type argument"):
Expand Down Expand Up @@ -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):

Expand Down
41 changes: 38 additions & 3 deletions Lib/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
Generic,
Union,
NoDefault,
NoExtraItems,
)

# Please keep __all__ alphabetized within each category.
Expand Down Expand Up @@ -141,6 +142,7 @@
'no_type_check',
'no_type_check_decorator',
'NoDefault',
'NoExtraItems',
'NoReturn',
'NotRequired',
'overload',
Expand Down Expand Up @@ -3078,7 +3080,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,
Expand All @@ -3090,6 +3093,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,)
Expand Down Expand Up @@ -3201,6 +3206,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
Expand All @@ -3212,7 +3219,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
Expand Down Expand Up @@ -3266,14 +3274,41 @@ 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 is closed to 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()
if module is not None:
# 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

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions Modules/_typingmodule.c
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ _typing_exec(PyObject *m)
if (PyModule_AddObjectRef(m, "NoDefault", (PyObject *)&_Py_NoDefaultStruct) < 0) {
return -1;
}
if (PyModule_AddObjectRef(m, "NoExtraItems", (PyObject *)&_Py_NoExtraItemsStruct) < 0) {
return -1;
}
return 0;
}

Expand Down
57 changes: 57 additions & 0 deletions Objects/typevarobject.c
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,63 @@ PyTypeObject _PyNoDefault_Type = {

PyObject _Py_NoDefaultStruct = _PyObject_HEAD_INIT(&_PyNoDefault_Type);

/* NoExtraItems: a marker object for TypedDict extra_items when it's unset. */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this implemented in C just for analogy with NoDefault? NoDefault is in C because TypeVar uses it and TypeVar has to be in C because it has native syntax, but TypedDict is all Python code, so I'd rather keep NoExtraItems also just Python.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also if we get PEP 661 implemented in 3.15 (I hope we will), this can and should use it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My bad, I thought we wanted it in C like NoDefault, but yeah since TypedDict is all Python we can keep it like that. I'll reverse course on this. PEP 661 looks interesting! Must being annoying to maintain so many ad-hoc sentinels.


static PyObject *
NoExtraItems_repr(PyObject *op)
{
return PyUnicode_FromString("typing.NoExtraItems");
}

static PyObject *
NoExtraItems_reduce(PyObject *op, PyObject *Py_UNUSED(ignored))
{
return PyUnicode_FromString("NoExtraItems");
}

static PyMethodDef noextraitems_methods[] = {
{"__reduce__", NoExtraItems_reduce, METH_NOARGS, NULL},
{NULL, NULL}
};

static PyObject *
noextraitems_new(PyTypeObject *type, PyObject *args, PyObject *kwargs)
{
if (PyTuple_GET_SIZE(args) || (kwargs && PyDict_GET_SIZE(kwargs))) {
PyErr_SetString(PyExc_TypeError, "NoExtraItemsType takes no arguments");
return NULL;
}
return (PyObject *)&_Py_NoExtraItemsStruct;
}

static void
noextraitems_dealloc(PyObject *obj)
{
/* This should never get called, but we also don't want to SEGV if
* we accidentally decref NoExtraItems out of existence. Instead,
* since NoExtraItems is an immortal object, re-set the reference count.
*/
_Py_SetImmortal(obj);
}

PyDoc_STRVAR(noextraitems_doc,
"NoExtraItemsType()\n"
"--\n\n"
"The type of the NoExtraItems singleton.");

PyTypeObject _PyNoExtraItems_Type = {
PyVarObject_HEAD_INIT(&PyType_Type, 0)
"NoExtraItemsType",
.tp_dealloc = noextraitems_dealloc,
.tp_repr = NoExtraItems_repr,
.tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = noextraitems_doc,
.tp_methods = noextraitems_methods,
.tp_new = noextraitems_new,
};

PyObject _Py_NoExtraItemsStruct = _PyObject_HEAD_INIT(&_PyNoExtraItems_Type);

typedef struct {
PyObject_HEAD
PyObject *value;
Expand Down
2 changes: 2 additions & 0 deletions Tools/c-analyzer/cpython/ignored.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,8 @@ Objects/obmalloc.c - obmalloc_state_main -
Objects/obmalloc.c - obmalloc_state_initialized -
Objects/typeobject.c - name_op -
Objects/typeobject.c - slotdefs -
Objects/typevarobject.c - _PyNoExtraItems_Type -
Objects/typevarobject.c - _Py_NoExtraItemsStruct -
Objects/unicodeobject.c - stripfuncnames -
Objects/unicodeobject.c - utf7_category -
Objects/unicodeobject.c unicode_decode_call_errorhandler_wchar argparse -
Expand Down
Loading