From 7379a2a3e249e50995725ca73523d2f54f559306 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Mon, 7 Apr 2025 21:17:10 +0200 Subject: [PATCH 1/7] Implement support for PEP 764 (inline typed dictionaries) --- src/test_typing_extensions.py | 35 ++++++++++ src/typing_extensions.py | 128 ++++++++++++++++++++-------------- 2 files changed, 109 insertions(+), 54 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index a6948951..197f73a8 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -5066,6 +5066,41 @@ def test_cannot_combine_closed_and_extra_items(self): class TD(TypedDict, closed=True, extra_items=range): x: str + def test_inlined_too_many_arguments(self): + with self.assertRaises(TypeError): + TypedDict[{"a": int}, "extra"] + + def test_inlined_not_a_dict(self): + with self.assertRaises(TypeError): + TypedDict["not_a_dict"] + + def test_inlined_empty(self): + TD = TypedDict[{}] + self.assertEqual(TD.__required_keys__, set()) + + def test_inlined(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__, "") + self.assertEqual(TD.__module__, __name__) + self.assertEqual(TD.__bases__, (dict,)) + self.assertEqual(TD.__total__, True) + 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): diff --git a/src/typing_extensions.py b/src/typing_extensions.py index f8b2f76e..9c727a80 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -1078,17 +1078,73 @@ def __subclasscheck__(cls, other): _TypedDict = type.__new__(_TypedDictMeta, 'TypedDict', (), {}) + + class _TypedDictSpecialForm(_ExtensionsSpecialForm, _root=True): + def __call__( + self, + typename, + fields=_marker, + /, + *, + total=True, + closed=None, + extra_items=NoExtraItems, + __typing_is_inline__=False, + **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 2) + 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 + @_ensure_subclassable(lambda bases: (_TypedDict,)) - def TypedDict( - typename, - fields=_marker, - /, - *, - total=True, - closed=None, - extra_items=NoExtraItems, - **kwargs - ): + @_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 @@ -1135,52 +1191,16 @@ 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, tuple): + args = (args,) + if len(args) != 1 or not isinstance(args[0], 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 + # Delegate to _TypedDictSpecialForm.__call__: + return self("", args[0], __typing_is_inline__=True) _TYPEDDICT_TYPES = (typing._TypedDictMeta, _TypedDictMeta) From 9532f119ad440080c41f88c2bb5981737006c2ac Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Wed, 23 Apr 2025 09:54:09 +0200 Subject: [PATCH 2/7] Define `_create_typeddict()`, change `__name__` --- src/test_typing_extensions.py | 2 +- src/typing_extensions.py | 129 ++++++++++++++++++++-------------- 2 files changed, 78 insertions(+), 53 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 197f73a8..061cd7ca 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -5089,7 +5089,7 @@ def test_inlined(self): self.assertIsSubclass(TD, typing.MutableMapping) self.assertNotIsSubclass(TD, collections.abc.Sequence) self.assertTrue(is_typeddict(TD)) - self.assertEqual(TD.__name__, "") + self.assertEqual(TD.__name__, "") self.assertEqual(TD.__module__, __name__) self.assertEqual(TD.__bases__, (dict,)) self.assertEqual(TD.__total__, True) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 9c727a80..4cda0656 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -1078,6 +1078,66 @@ def __subclasscheck__(cls, other): _TypedDict = type.__new__(_TypedDictMeta, 'TypedDict', (), {}) + def _create_typeddict( + typename, + fields, + /, + *, + 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__( @@ -1089,58 +1149,17 @@ def __call__( total=True, closed=None, extra_items=NoExtraItems, - __typing_is_inline__=False, **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 2) - 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( + typename, + fields, + typing_is_inline=False, + total=total, + closed=closed, + extra_items=extra_items, + **kwargs, + ) @_ensure_subclassable(lambda bases: (_TypedDict,)) @_TypedDictSpecialForm @@ -1199,8 +1218,14 @@ class Point2D(TypedDict): "TypedDict[...] should be used with a single dict argument" ) - # Delegate to _TypedDictSpecialForm.__call__: - return self("", args[0], __typing_is_inline__=True) + return _create_typeddict( + "", + args[0], + typing_is_inline=True, + total=True, + closed=None, + extra_items=NoExtraItems, + ) _TYPEDDICT_TYPES = (typing._TypedDictMeta, _TypedDictMeta) From 3cd8f9edcf10a5037271ead2dfa6c2431f364006 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Wed, 23 Apr 2025 09:58:25 +0200 Subject: [PATCH 3/7] Get rid of `_ensure_subclassable()` --- src/typing_extensions.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 4cda0656..4a8c656a 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -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") ) @@ -1161,7 +1154,9 @@ def __call__( **kwargs, ) - @_ensure_subclassable(lambda bases: (_TypedDict,)) + 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. @@ -3239,7 +3234,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. @@ -3305,6 +3299,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 From 27e8a5de4609f46fe870c7d4797f3ca987cd95e6 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Thu, 24 Apr 2025 10:51:36 +0200 Subject: [PATCH 4/7] Feedback --- src/test_typing_extensions.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 061cd7ca..fc3e3502 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -5066,19 +5066,29 @@ def test_cannot_combine_closed_and_extra_items(self): class TD(TypedDict, closed=True, extra_items=range): x: str - def test_inlined_too_many_arguments(self): + 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_inlined_not_a_dict(self): + def test_inline_not_a_dict(self): with self.assertRaises(TypeError): TypedDict["not_a_dict"] - def test_inlined_empty(self): + def test_inline_empty(self): TD = TypedDict[{}] self.assertEqual(TD.__required_keys__, set()) - def test_inlined(self): + def test_inline_argument_as_tuple(self): + TD = TypedDict[({},)] + self.assertEqual(TD.__required_keys__, set()) + + def test_inline(self): TD = TypedDict[{ "a": int, "b": Required[int], From 7676cd78f1287ed472f4c75dd0744634bd4e2636 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Thu, 24 Apr 2025 20:26:48 +0200 Subject: [PATCH 5/7] Do not support tuple --- src/test_typing_extensions.py | 4 ---- src/typing_extensions.py | 6 ++---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index fc3e3502..9c2dcb36 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -5084,10 +5084,6 @@ def test_inline_empty(self): TD = TypedDict[{}] self.assertEqual(TD.__required_keys__, set()) - def test_inline_argument_as_tuple(self): - TD = TypedDict[({},)] - self.assertEqual(TD.__required_keys__, set()) - def test_inline(self): TD = TypedDict[{ "a": int, diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 4a8c656a..4c5efeb0 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -1206,16 +1206,14 @@ class Point2D(TypedDict): See PEP 655 for more details on Required and NotRequired. """ # This runs when creating inline TypedDicts: - if not isinstance(args, tuple): - args = (args,) - if len(args) != 1 or not isinstance(args[0], dict): + if not isinstance(args, dict): raise TypeError( "TypedDict[...] should be used with a single dict argument" ) return _create_typeddict( "", - args[0], + args, typing_is_inline=True, total=True, closed=None, From 22ccb8f71b05bfa86f0db674c28bd7d6911f4b9f Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Fri, 25 Apr 2025 16:04:43 +0200 Subject: [PATCH 6/7] Feedback --- src/test_typing_extensions.py | 18 +++++++++++++++++- src/typing_extensions.py | 2 +- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 9c2dcb36..4ef8b740 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -5080,9 +5080,19 @@ 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.assertTrue(TD.__total__) + self.assertTrue(TD.__closed__) + 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[{ @@ -5096,9 +5106,15 @@ def test_inline(self): self.assertNotIsSubclass(TD, collections.abc.Sequence) self.assertTrue(is_typeddict(TD)) self.assertEqual(TD.__name__, "") + 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.assertEqual(TD.__total__, True) + self.assertTrue(TD.__total__) + self.assertTrue(TD.__closed__) + 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"}) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 4c5efeb0..15cdd6e6 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -1216,7 +1216,7 @@ class Point2D(TypedDict): args, typing_is_inline=True, total=True, - closed=None, + closed=True, extra_items=NoExtraItems, ) From a3807a13ee28e196fd744a765b94c714b27a651d Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Fri, 25 Apr 2025 16:31:40 +0200 Subject: [PATCH 7/7] assert, changelog --- CHANGELOG.md | 2 ++ src/test_typing_extensions.py | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 560971ad..2ea7c833 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 4ef8b740..8b350208 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -5086,8 +5086,8 @@ def test_inline_not_a_dict(self): def test_inline_empty(self): TD = TypedDict[{}] - self.assertTrue(TD.__total__) - self.assertTrue(TD.__closed__) + 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()) @@ -5112,8 +5112,8 @@ def test_inline(self): ) self.assertEqual(TD.__module__, __name__) self.assertEqual(TD.__bases__, (dict,)) - self.assertTrue(TD.__total__) - self.assertTrue(TD.__closed__) + 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"})