From 91b00471c5af1458f6b50b3ecb4bded445bc6594 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Sun, 6 Apr 2025 20:27:24 +0200 Subject: [PATCH 1/2] Implement support for PEP 764 (inlined typed dictionaries) --- src/test_typing_extensions.py | 34 +++++++- src/typing_extensions.py | 145 ++++++++++++++++++++-------------- 2 files changed, 117 insertions(+), 62 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index a6948951..bc105cf0 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -5066,6 +5066,38 @@ 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(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): @@ -6629,7 +6661,7 @@ def test_typing_extensions_defers_when_possible(self): exclude |= { 'TypeAliasType' } - if not typing_extensions._PEP_728_IMPLEMENTED: + if not typing_extensions._PEP_728_OR_764_IMPLEMENTED: exclude |= {'TypedDict', 'is_typeddict'} for item in typing_extensions.__all__: if item not in exclude and hasattr(typing, item): diff --git a/src/typing_extensions.py b/src/typing_extensions.py index f8b2f76e..446d2ec2 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -910,11 +910,11 @@ def __reduce__(self): del SingletonMeta -# Update this to something like >=3.13.0b1 if and when -# PEP 728 is implemented in CPython -_PEP_728_IMPLEMENTED = False +# Update this to something like >=3.14 if and when +# PEP 728/PEP 764 is implemented in CPython +_PEP_728_OR_764_IMPLEMENTED = False -if _PEP_728_IMPLEMENTED: +if _PEP_728_OR_764_IMPLEMENTED: # The standard library TypedDict in Python 3.9.0/1 does not honour the "total" # keyword with old-style TypedDict(). See https://bugs.python.org/issue42059 # The standard library TypedDict below Python 3.11 does not store runtime @@ -924,7 +924,7 @@ def __reduce__(self): # to enable better runtime introspection. # On 3.13 we deprecate some odd ways of creating TypedDicts. # Also on 3.13, PEP 705 adds the ReadOnly[] qualifier. - # PEP 728 (still pending) makes more changes. + # PEP 728 and PEP 764 (still pending) makes more changes. TypedDict = typing.TypedDict _TypedDictMeta = typing._TypedDictMeta is_typeddict = typing.is_typeddict @@ -1068,7 +1068,11 @@ def __new__(cls, name, bases, ns, *, total=True, closed=None, tp_dict.__extra_items__ = extra_items_type return tp_dict - __call__ = dict # static method + def __call__(cls, /, *args, **kwargs): + if cls is TypedDict: + # Functional syntax, let `TypedDict.__new__` handle it: + return super().__call__(*args, **kwargs) + return dict(*args, **kwargs) def __subclasscheck__(cls, other): # Typed dicts are only for static structural subtyping. @@ -1078,17 +1082,7 @@ def __subclasscheck__(cls, other): _TypedDict = type.__new__(_TypedDictMeta, 'TypedDict', (), {}) - @_ensure_subclassable(lambda bases: (_TypedDict,)) - def TypedDict( - typename, - fields=_marker, - /, - *, - total=True, - closed=None, - extra_items=NoExtraItems, - **kwargs - ): + class TypedDict(metaclass=_TypedDictMeta): """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 +1129,77 @@ 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, - ) + def __new__( + cls, + typename, + fields=_marker, + /, + *, + total=True, + closed=None, + extra_items=NoExtraItems, + **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=3) + if module is not None: + # Setting correct module is necessary to make typed dict classes + # pickleable. + ns['__module__'] = module - 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 - td = _TypedDictMeta(typename, (), ns, total=total, closed=closed, - extra_items=extra_items) - td.__orig_bases__ = (TypedDict,) - return td + def __class_getitem__(cls, args): + 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" + ) + + return cls.__new__(cls, "", args[0]) _TYPEDDICT_TYPES = (typing._TypedDictMeta, _TypedDictMeta) @@ -1195,7 +1214,11 @@ class Film(TypedDict): is_typeddict(Film) # => True is_typeddict(Union[list, str]) # => False """ - return isinstance(tp, _TYPEDDICT_TYPES) + return ( + tp is not TypedDict + and tp is not typing.TypedDict + and isinstance(tp, _TYPEDDICT_TYPES) + ) if hasattr(typing, "assert_type"): From 88e802d2fea61e89df850d702cac531a68cfed98 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Sun, 6 Apr 2025 20:44:07 +0200 Subject: [PATCH 2/2] Add empty test --- src/test_typing_extensions.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index bc105cf0..cd234a81 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -5074,6 +5074,10 @@ 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,