GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — gh-137840: Implement PEP 728 (closed and extra_items in typing.TypedDict) by angela-tarantula · Pull Request #137933 · python/cpython

gh-137840: Implement PEP 728 (closed and extra_items in typing.TypedDict) #137933

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

Sign up for GitHub

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
JelleZijlstra merged 20 commits into python:main from angela-tarantula:fix-issue-137840
Oct 3, 2025

Uh oh!

There was an error while loading. Please reload this page.

Merged

gh-137840: Implement PEP 728 (closed and extra_items in typing.TypedDict) #137933

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
cbea6f8
Introduce NoExtraItems as a C-level singleton
angela-tarantula Aug 17, 2025
4cde2f9
expose NoExtraItems via _typing
angela-tarantula Aug 17, 2025
06de7a9
Clearer comments in the C internals
angela-tarantula Aug 17, 2025
407bf74
Import NoExtraItems into typing
angela-tarantula Aug 17, 2025
f9755d3
Update _TypedDictMeta and TypedDict
angela-tarantula Aug 17, 2025
385812a
fix typo
angela-tarantula Aug 17, 2025
bdbae49
Port over the relevant tests
angela-tarantula Aug 17, 2025
e68c2ed
Updated docstring and error messages
angela-tarantula Aug 18, 2025
8203bc1
NEWS entry
angela-tarantula Aug 18, 2025
1b3ce52
New draft of docstring and error message
angela-tarantula Aug 19, 2025
2f5c42e
just use the original error message
angela-tarantula Aug 19, 2025
1bd3a6d
fix typo
angela-tarantula Aug 19, 2025
42a1aac
undo capitalization
angela-tarantula Aug 19, 2025
36ee4d2
fix typo x2
angela-tarantula Aug 19, 2025
503b825
fix typo x3
angela-tarantula Aug 20, 2025
1f23caa
Hush the c-analyzer: NoExtraItems is a singleton
angela-tarantula Aug 20, 2025
9a9c5c5
remove c-level NoExtraItems singleton
angela-tarantula Aug 26, 2025
18b929b
Update Lib/typing.py
angela-tarantula Aug 26, 2025
611b3e6
define NoExtraItems singleton in Python
angela-tarantula Aug 26, 2025
a6250f4
enforce singleton qualities on NoExtraItems
angela-tarantula Aug 26, 2025
File filter
Filter by extension

Clear filters
Conversations
Failed to load comments.
Loading

Uh oh!

There was an error while loading. Please reload this page.

Jump to
Jump to file
Failed to load files.
Loading

Uh oh!

There was an error while loading. Please reload this page.

Diff view
Diff view

There are no files selected for viewing

99 changes: 98 additions & 1 deletion 99 Lib/test/test_typing.py
Show comments
View file
Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -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
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):
Comment thread

@dimaqq dimaqq Aug 19, 2025

Copy link
Copy Markdown
Contributor

There was a problem hiding this comment.

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.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions

@angela-tarantula angela-tarantula Aug 19, 2025 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Contributor Author

There was a problem hiding this comment.

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.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions

@dimaqq dimaqq Aug 19, 2025

Copy link
Copy Markdown
Contributor

There was a problem hiding this comment.

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?

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions

@angela-tarantula angela-tarantula Aug 19, 2025

Copy link
Copy Markdown
Contributor Author

There was a problem hiding this comment.

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.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions

@dimaqq dimaqq Aug 19, 2025 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Contributor

There was a problem hiding this comment.

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.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions

@dimaqq dimaqq Aug 19, 2025

Copy link
Copy Markdown
Contributor

There was a problem hiding this comment.

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!

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions

@angela-tarantula angela-tarantula Aug 20, 2025 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Contributor Author

There was a problem hiding this comment.

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.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions

@JelleZijlstra JelleZijlstra Aug 20, 2025

Copy link
Copy Markdown
Member

There was a problem hiding this comment.

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

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

brianschubert, AlexWaygood, and angela-tarantula reacted with heart emoji
All reactions
  • ❤️ 3 reactions
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
67 changes: 64 additions & 3 deletions 67 Lib/typing.py
Show comments
View file
Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@
'no_type_check',
'no_type_check_decorator',
'NoDefault',
'NoExtraItems',
'NoReturn',
'NotRequired',
'overload',
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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,)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -3266,14 +3300,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 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()
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
3 changes: 3 additions & 0 deletions 3 Misc/NEWS.d/next/Library/2025-08-18-07-10-55.gh-issue-137840.9b7AnG.rst
Show comments
View file
Open in desktop
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.
Add this suggestion to a batch that can be applied as a single commit. This suggestion is invalid because no changes were made to the code. Suggestions cannot be applied while the pull request is closed. Suggestions cannot be applied while viewing a subset of changes. Only one suggestion per line can be applied in a batch. Add this suggestion to a batch that can be applied as a single commit. Applying suggestions on deleted lines is not supported. You must change the existing code in this line in order to create a valid suggestion. Outdated suggestions cannot be applied. This suggestion has been applied or marked resolved. Suggestions cannot be applied from pending reviews. Suggestions cannot be applied on multi-line comments. Suggestions cannot be applied while the pull request is queued to merge. Suggestion cannot be applied right now. Please check back later.
You are visitor 01402503 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.