Skip to content

Implement support for PEP 764 (inline typed dictionaries) #580

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.

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
merged 7 commits into from
Apr 25, 2025
Merged
Show file tree
Hide file tree
Changes from all 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 CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Unreleased

- Drop support for Python 3.8 (including PyPy-3.8). Patch by [Victorien Plot](https://github.com/Viicos).
- Add support for inline typed dictionaries ([PEP 764](https://peps.python.org/pep-0764/)).
Patch by [Victorien Plot](https://github.com/Viicos).

# Release 4.13.2 (April 10, 2025)

Expand Down
57 changes: 57 additions & 0 deletions src/test_typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5066,6 +5066,63 @@ def test_cannot_combine_closed_and_extra_items(self):
class TD(TypedDict, closed=True, extra_items=range):
x: str

def test_typed_dict_signature(self):
self.assertListEqual(
list(inspect.signature(TypedDict).parameters),
['typename', 'fields', 'total', 'closed', 'extra_items', 'kwargs']
)

def test_inline_too_many_arguments(self):
with self.assertRaises(TypeError):
TypedDict[{"a": int}, "extra"]

def test_inline_not_a_dict(self):
with self.assertRaises(TypeError):
TypedDict["not_a_dict"]

# a tuple of elements isn't allowed, even if the first element is a dict:
with self.assertRaises(TypeError):
TypedDict[({"key": int},)]

def test_inline_empty(self):
TD = TypedDict[{}]
self.assertIs(TD.__total__, True)
self.assertIs(TD.__closed__, True)
self.assertEqual(TD.__extra_items__, NoExtraItems)
self.assertEqual(TD.__required_keys__, set())
self.assertEqual(TD.__optional_keys__, set())
self.assertEqual(TD.__readonly_keys__, set())
self.assertEqual(TD.__mutable_keys__, set())

def test_inline(self):
TD = TypedDict[{
"a": int,
"b": Required[int],
"c": NotRequired[int],
"d": ReadOnly[int],
}]
self.assertIsSubclass(TD, dict)
self.assertIsSubclass(TD, typing.MutableMapping)
self.assertNotIsSubclass(TD, collections.abc.Sequence)
self.assertTrue(is_typeddict(TD))
self.assertEqual(TD.__name__, "<inline TypedDict>")
self.assertEqual(
TD.__annotations__,
{"a": int, "b": Required[int], "c": NotRequired[int], "d": ReadOnly[int]},
)
self.assertEqual(TD.__module__, __name__)
self.assertEqual(TD.__bases__, (dict,))
self.assertIs(TD.__total__, True)
self.assertIs(TD.__closed__, True)
self.assertEqual(TD.__extra_items__, NoExtraItems)
self.assertEqual(TD.__required_keys__, {"a", "b", "d"})
self.assertEqual(TD.__optional_keys__, {"c"})
self.assertEqual(TD.__readonly_keys__, {"d"})
self.assertEqual(TD.__mutable_keys__, {"a", "b", "c"})

inst = TD(a=1, b=2, d=3)
self.assertIs(type(inst), dict)
self.assertEqual(inst["a"], 1)

class AnnotatedTests(BaseTestCase):

Expand Down
157 changes: 98 additions & 59 deletions src/typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -846,13 +846,6 @@ def __round__(self, ndigits: int = 0) -> T_co:
pass


def _ensure_subclassable(mro_entries):
def inner(obj):
obj.__mro_entries__ = mro_entries
return obj
return inner


_NEEDS_SINGLETONMETA = (
not hasattr(typing, "NoDefault") or not hasattr(typing, "NoExtraItems")
)
Expand Down Expand Up @@ -1078,17 +1071,94 @@ def __subclasscheck__(cls, other):

_TypedDict = type.__new__(_TypedDictMeta, 'TypedDict', (), {})

@_ensure_subclassable(lambda bases: (_TypedDict,))
def TypedDict(
def _create_typeddict(
typename,
fields=_marker,
fields,
/,
*,
total=True,
closed=None,
extra_items=NoExtraItems,
**kwargs
typing_is_inline,
total,
closed,
extra_items,
**kwargs,
):
if fields is _marker or fields is None:
if fields is _marker:
deprecated_thing = (
"Failing to pass a value for the 'fields' parameter"
)
else:
deprecated_thing = "Passing `None` as the 'fields' parameter"

example = f"`{typename} = TypedDict({typename!r}, {{}})`"
deprecation_msg = (
f"{deprecated_thing} is deprecated and will be disallowed in "
"Python 3.15. To create a TypedDict class with 0 fields "
"using the functional syntax, pass an empty dictionary, e.g. "
) + example + "."
warnings.warn(deprecation_msg, DeprecationWarning, stacklevel=2)
# Support a field called "closed"
if closed is not False and closed is not True and closed is not None:
kwargs["closed"] = closed
closed = None
# Or "extra_items"
if extra_items is not NoExtraItems:
kwargs["extra_items"] = extra_items
extra_items = NoExtraItems
fields = kwargs
elif kwargs:
raise TypeError("TypedDict takes either a dict or keyword arguments,"
" but not both")
if kwargs:
if sys.version_info >= (3, 13):
raise TypeError("TypedDict takes no keyword arguments")
warnings.warn(
"The kwargs-based syntax for TypedDict definitions is deprecated "
"in Python 3.11, will be removed in Python 3.13, and may not be "
"understood by third-party type checkers.",
DeprecationWarning,
stacklevel=2,
)

ns = {'__annotations__': dict(fields)}
module = _caller(depth=5 if typing_is_inline else 3)
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, closed=closed,
extra_items=extra_items)
td.__orig_bases__ = (TypedDict,)
return td

class _TypedDictSpecialForm(_ExtensionsSpecialForm, _root=True):
def __call__(
self,
typename,
fields=_marker,
/,
*,
total=True,
closed=None,
extra_items=NoExtraItems,
**kwargs
):
return _create_typeddict(
typename,
fields,
typing_is_inline=False,
total=total,
closed=closed,
extra_items=extra_items,
**kwargs,
)

def __mro_entries__(self, bases):
return (_TypedDict,)

@_TypedDictSpecialForm
def TypedDict(self, args):
"""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 @@ -1135,52 +1205,20 @@ class Point2D(TypedDict):

See PEP 655 for more details on Required and NotRequired.
"""
if fields is _marker or fields is None:
if fields is _marker:
deprecated_thing = "Failing to pass a value for the 'fields' parameter"
else:
deprecated_thing = "Passing `None` as the 'fields' parameter"

example = f"`{typename} = TypedDict({typename!r}, {{}})`"
deprecation_msg = (
f"{deprecated_thing} is deprecated and will be disallowed in "
"Python 3.15. To create a TypedDict class with 0 fields "
"using the functional syntax, pass an empty dictionary, e.g. "
) + example + "."
warnings.warn(deprecation_msg, DeprecationWarning, stacklevel=2)
# Support a field called "closed"
if closed is not False and closed is not True and closed is not None:
kwargs["closed"] = closed
closed = None
# Or "extra_items"
if extra_items is not NoExtraItems:
kwargs["extra_items"] = extra_items
extra_items = NoExtraItems
fields = kwargs
elif kwargs:
raise TypeError("TypedDict takes either a dict or keyword arguments,"
" but not both")
if kwargs:
if sys.version_info >= (3, 13):
raise TypeError("TypedDict takes no keyword arguments")
warnings.warn(
"The kwargs-based syntax for TypedDict definitions is deprecated "
"in Python 3.11, will be removed in Python 3.13, and may not be "
"understood by third-party type checkers.",
DeprecationWarning,
stacklevel=2,
# This runs when creating inline TypedDicts:
if not isinstance(args, dict):
raise TypeError(
"TypedDict[...] should be used with a single dict argument"
)

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, closed=closed,
extra_items=extra_items)
td.__orig_bases__ = (TypedDict,)
return td
return _create_typeddict(
"<inline TypedDict>",
args,
typing_is_inline=True,
total=True,
closed=True,
extra_items=NoExtraItems,
)

_TYPEDDICT_TYPES = (typing._TypedDictMeta, _TypedDictMeta)

Expand Down Expand Up @@ -3194,7 +3232,6 @@ def _namedtuple_mro_entries(bases):
assert NamedTuple in bases
return (_NamedTuple,)

@_ensure_subclassable(_namedtuple_mro_entries)
def NamedTuple(typename, fields=_marker, /, **kwargs):
"""Typed version of namedtuple.

Expand Down Expand Up @@ -3260,6 +3297,8 @@ class Employee(NamedTuple):
nt.__orig_bases__ = (NamedTuple,)
return nt

NamedTuple.__mro_entries__ = _namedtuple_mro_entries


if hasattr(collections.abc, "Buffer"):
Buffer = collections.abc.Buffer
Expand Down
Loading