From e556762ecb6cfe12210d1d51c4e0913bc3aa4c1f Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 10 Dec 2024 19:14:25 -0800 Subject: [PATCH 01/16] Start PEP 728 implementation --- src/test_typing_extensions.py | 39 ++++++++++++++++++++--------------- src/typing_extensions.py | 18 ++++++++++++---- 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 1b43f90f..972550c7 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -126,6 +126,8 @@ # 3.13.0.rc1 fixes a problem with @deprecated TYPING_3_13_0_RC = sys.version_info[:4] >= (3, 13, 0, "candidate") +TYPING_3_14_0 = sys.version_info[:3] >= (3, 14, 0) + # https://github.com/python/cpython/pull/27017 was backported into some 3.9 and 3.10 # versions, but not all HAS_FORWARD_MODULE = "module" in inspect.signature(typing._type_check).parameters @@ -4288,6 +4290,24 @@ class ChildWithInlineAndOptional(Untotal, Inline): {'inline': bool, 'untotal': str, 'child': bool}, ) + wrong_bases = [ + (One, Regular), + (Regular, One), + (One, Two, Regular), + (Inline, Regular), + (Untotal, Regular), + ] + for bases in wrong_bases: + with self.subTest(bases=bases): + with self.assertRaisesRegex( + TypeError, + 'cannot inherit from both a TypedDict type and a non-TypedDict', + ): + class Wrong(*bases): + pass + + @skipIf(TYPING_3_14_0, "only supported on older versions") + def test_closed_typeddict_compat(self): class Closed(TypedDict, closed=True): __extra_items__: None @@ -4306,22 +4326,6 @@ class ChildClosed(Unclosed, Closed): self.assertFalse(ChildClosed.__closed__) self.assertEqual(ChildClosed.__extra_items__, type(None)) - wrong_bases = [ - (One, Regular), - (Regular, One), - (One, Two, Regular), - (Inline, Regular), - (Untotal, Regular), - ] - for bases in wrong_bases: - with self.subTest(bases=bases): - with self.assertRaisesRegex( - TypeError, - 'cannot inherit from both a TypedDict type and a non-TypedDict', - ): - class Wrong(*bases): - pass - def test_is_typeddict(self): self.assertIs(is_typeddict(Point2D), True) self.assertIs(is_typeddict(Point2Dor3D), True) @@ -4677,7 +4681,8 @@ class AllTheThings(TypedDict): }, ) - def test_extra_keys_non_readonly(self): + @skipIf(TYPING_3_14_0, "Old syntax only supported on <3.14") + def test_extra_keys_non_readonly_compat(self): class Base(TypedDict, closed=True): __extra_items__: str diff --git a/src/typing_extensions.py b/src/typing_extensions.py index c5e84b31..c67c5d9e 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -917,7 +917,7 @@ def _get_typeddict_qualifiers(annotation_type): break class _TypedDictMeta(type): - def __new__(cls, name, bases, ns, *, total=True, closed=False): + def __new__(cls, name, bases, ns, *, total=True, closed=None, extra_items=None): """Create new typed dict class object. This method is called when TypedDict is subclassed, @@ -929,6 +929,10 @@ def __new__(cls, name, bases, ns, *, total=True, closed=False): if type(base) is not _TypedDictMeta and base is not typing.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 None: + raise TypeError("Cannot combine closed=True and extra_items") + elif closed is None: + closed = False if any(issubclass(b, typing.Generic) for b in bases): generic_base = (typing.Generic,) @@ -968,7 +972,7 @@ def __new__(cls, name, bases, ns, *, total=True, closed=False): optional_keys = set() readonly_keys = set() mutable_keys = set() - extra_items_type = None + extra_items_type = extra_items for base in bases: base_dict = base.__dict__ @@ -978,13 +982,19 @@ def __new__(cls, name, bases, ns, *, total=True, closed=False): optional_keys.update(base_dict.get('__optional_keys__', ())) readonly_keys.update(base_dict.get('__readonly_keys__', ())) mutable_keys.update(base_dict.get('__mutable_keys__', ())) - base_extra_items_type = base_dict.get('__extra_items__', None) + base_extra_items_type = getattr(base, '__extra_items__', None) if base_extra_items_type is not None: extra_items_type = base_extra_items_type + if getattr(base, "__closed__", False) and not closed: + raise TypeError("Child of a closed TypedDict must also be closed") if closed and extra_items_type is None: extra_items_type = Never - if closed and "__extra_items__" in own_annotations: + + # This was specified in an earlier version of PEP 728. Support + # is retained for backwards compatibility, but only for Python 3.13 + # and lower. + if closed and sys.version_info < (3, 14) and "__extra_items__" in own_annotations: annotation_type = own_annotations.pop("__extra_items__") qualifiers = set(_get_typeddict_qualifiers(annotation_type)) if Required in qualifiers: From 61f06f167051e19f1ad1d05b3841cc0e7a8e5620 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Thu, 12 Dec 2024 21:03:46 -0800 Subject: [PATCH 02/16] Fix tests --- src/test_typing_extensions.py | 49 +++++++++++++++++++++++------------ src/typing_extensions.py | 11 +++++++- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 85eeff17..1e11c41e 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -4318,14 +4318,16 @@ class Closed(TypedDict, closed=True): class Unclosed(TypedDict, closed=False): ... - class ChildUnclosed(Closed, Unclosed): - ... + with self.assertWarns(DeprecationWarning): + class ChildUnclosed(Closed, Unclosed): + ... self.assertFalse(ChildUnclosed.__closed__) self.assertEqual(ChildUnclosed.__extra_items__, type(None)) - class ChildClosed(Unclosed, Closed): - ... + with self.assertWarns(DeprecationWarning): + class ChildClosed(Unclosed, Closed): + ... self.assertFalse(ChildClosed.__closed__) self.assertEqual(ChildClosed.__extra_items__, type(None)) @@ -4690,19 +4692,34 @@ def test_extra_keys_non_readonly_compat(self): class Base(TypedDict, closed=True): __extra_items__: str - class Child(Base): - a: NotRequired[int] + with self.assertWarns(DeprecationWarning): + class Child(Base): + a: NotRequired[int] self.assertEqual(Child.__required_keys__, frozenset({})) self.assertEqual(Child.__optional_keys__, frozenset({'a'})) self.assertEqual(Child.__readonly_keys__, frozenset({})) self.assertEqual(Child.__mutable_keys__, frozenset({'a'})) + @skipIf(TYPING_3_14_0, "Only supported on <3.14") def test_extra_keys_readonly(self): class Base(TypedDict, closed=True): __extra_items__: ReadOnly[str] - class Child(Base): + with self.assertWarns(DeprecationWarning): + class Child(Base): + a: NotRequired[str] + + self.assertEqual(Child.__required_keys__, frozenset({})) + self.assertEqual(Child.__optional_keys__, frozenset({'a'})) + self.assertEqual(Child.__readonly_keys__, frozenset({})) + self.assertEqual(Child.__mutable_keys__, frozenset({'a'})) + + def test_extra_keys_readonly_explicit_closed(self): + class Base(TypedDict, closed=True): + __extra_items__: ReadOnly[str] + + class Child(Base, closed=True): a: NotRequired[str] self.assertEqual(Child.__required_keys__, frozenset({})) @@ -4766,26 +4783,26 @@ class Base(TypedDict, closed=True): self.assertEqual(Base.__extra_items__, ReadOnly[Union[str, None]]) self.assertTrue(Base.__closed__) - class Child(Base): + class Child(Base, closed=True): a: int __extra_items__: int - self.assertEqual(Child.__required_keys__, frozenset({'a', "__extra_items__"})) + 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', "__extra_items__"})) - self.assertEqual(Child.__annotations__, {"__extra_items__": int, "a": int}) - self.assertEqual(Child.__extra_items__, ReadOnly[Union[str, None]]) - self.assertFalse(Child.__closed__) + self.assertEqual(Child.__mutable_keys__, frozenset({'a'})) + self.assertEqual(Child.__annotations__, {"a": int}) + self.assertEqual(Child.__extra_items__, int) + self.assertTrue(Child.__closed__) class GrandChild(Child, closed=True): __extra_items__: str - self.assertEqual(GrandChild.__required_keys__, frozenset({'a', "__extra_items__"})) + 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', "__extra_items__"})) - self.assertEqual(GrandChild.__annotations__, {"__extra_items__": int, "a": int}) + self.assertEqual(GrandChild.__mutable_keys__, frozenset({'a'})) + self.assertEqual(GrandChild.__annotations__, {"a": int}) self.assertEqual(GrandChild.__extra_items__, str) self.assertTrue(GrandChild.__closed__) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index c73dfd0c..0311ba24 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -986,7 +986,16 @@ def __new__(cls, name, bases, ns, *, total=True, closed=None, extra_items=None): if base_extra_items_type is not None: extra_items_type = base_extra_items_type if getattr(base, "__closed__", False) and not closed: - raise TypeError("Child of a closed TypedDict must also be closed") + if sys.version_info < (3, 14): + # PEP 728 wants this to be an error, but that is not + # compatible with previous versions of typing-extensions. + warnings.warn( + "Child of a closed TypedDict must also be closed. This will " + "be an error in Python 3.14.", + DeprecationWarning, + ) + else: + raise TypeError("Child of a closed TypedDict must also be closed") if closed and extra_items_type is None: extra_items_type = Never From 5a806b1a778a5ef1e9942f7646c4d163df8c86fa Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Thu, 12 Dec 2024 21:32:31 -0800 Subject: [PATCH 03/16] NoExtraItems --- src/test_typing_extensions.py | 42 ++++++++++--- src/typing_extensions.py | 115 ++++++++++++++++++++++------------ 2 files changed, 108 insertions(+), 49 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 1e11c41e..58ff969a 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -54,6 +54,7 @@ Never, NewType, NoDefault, + NoExtraItems, NoReturn, NotRequired, Optional, @@ -4030,7 +4031,7 @@ def test_keywords_syntax_raises_on_3_13(self): with self.assertRaises(TypeError), self.assertWarns(DeprecationWarning): TypedDict('Emp', name=str, id=int) - @skipIf(sys.version_info >= (3, 13), "3.13 removes support for kwargs") + @skipIf(sys.version_info >= (3, 15), "3.15 removes support for kwargs") def test_basics_keywords_syntax(self): with self.assertWarns(DeprecationWarning): Emp = TypedDict('Emp', name=str, id=int) @@ -4047,14 +4048,18 @@ def test_basics_keywords_syntax(self): self.assertEqual(Emp.__annotations__, {'name': str, 'id': int}) self.assertEqual(Emp.__total__, True) - @skipIf(sys.version_info >= (3, 13), "3.13 removes support for kwargs") + @skipIf(sys.version_info >= (3, 15), "3.15 removes support for kwargs") def test_typeddict_special_keyword_names(self): with self.assertWarns(DeprecationWarning): TD = TypedDict("TD", cls=type, self=object, typename=str, _typename=int, - fields=list, _fields=dict) + fields=list, _fields=dict, + closed=bool, extra_items=bool) self.assertEqual(TD.__name__, 'TD') self.assertEqual(TD.__annotations__, {'cls': type, 'self': object, 'typename': str, - '_typename': int, 'fields': list, '_fields': dict}) + '_typename': int, 'fields': list, '_fields': dict, + 'closed': bool, 'extra_items': bool}) + self.assertIs(TD.__closed__, False) + self.assertIs(TD.__extra_items__, NoExtraItems) a = TD(cls=str, self=42, typename='foo', _typename=53, fields=[('bar', tuple)], _fields={'baz', set}) self.assertEqual(a['cls'], str) @@ -4323,14 +4328,31 @@ class ChildUnclosed(Closed, Unclosed): ... self.assertFalse(ChildUnclosed.__closed__) - self.assertEqual(ChildUnclosed.__extra_items__, type(None)) + self.assertEqual(ChildUnclosed.__extra_items__, NoExtraItems) with self.assertWarns(DeprecationWarning): class ChildClosed(Unclosed, Closed): ... self.assertFalse(ChildClosed.__closed__) - self.assertEqual(ChildClosed.__extra_items__, type(None)) + self.assertEqual(ChildClosed.__extra_items__, NoExtraItems) + + def test_extra_items_class_arg(self): + class TD(TypedDict, extra_items=int): + a: str + + self.assertEqual(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) @@ -4748,7 +4770,7 @@ class ExtraReadOnly(TypedDict): self.assertEqual(ExtraReadOnly.__optional_keys__, frozenset({})) self.assertEqual(ExtraReadOnly.__readonly_keys__, frozenset({'__extra_items__'})) self.assertEqual(ExtraReadOnly.__mutable_keys__, frozenset({})) - self.assertEqual(ExtraReadOnly.__extra_items__, None) + self.assertEqual(ExtraReadOnly.__extra_items__, NoExtraItems) self.assertFalse(ExtraReadOnly.__closed__) class ExtraRequired(TypedDict): @@ -4758,7 +4780,7 @@ class ExtraRequired(TypedDict): self.assertEqual(ExtraRequired.__optional_keys__, frozenset({})) self.assertEqual(ExtraRequired.__readonly_keys__, frozenset({})) self.assertEqual(ExtraRequired.__mutable_keys__, frozenset({'__extra_items__'})) - self.assertEqual(ExtraRequired.__extra_items__, None) + self.assertEqual(ExtraRequired.__extra_items__, NoExtraItems) self.assertFalse(ExtraRequired.__closed__) class ExtraNotRequired(TypedDict): @@ -4768,7 +4790,7 @@ class ExtraNotRequired(TypedDict): self.assertEqual(ExtraNotRequired.__optional_keys__, frozenset({'__extra_items__'})) self.assertEqual(ExtraNotRequired.__readonly_keys__, frozenset({})) self.assertEqual(ExtraNotRequired.__mutable_keys__, frozenset({'__extra_items__'})) - self.assertEqual(ExtraNotRequired.__extra_items__, None) + self.assertEqual(ExtraNotRequired.__extra_items__, NoExtraItems) self.assertFalse(ExtraNotRequired.__closed__) def test_closed_inheritance(self): @@ -4810,7 +4832,7 @@ def test_implicit_extra_items(self): class Base(TypedDict): a: int - self.assertEqual(Base.__extra_items__, None) + self.assertEqual(Base.__extra_items__, NoExtraItems) self.assertFalse(Base.__closed__) class ChildA(Base, closed=True): diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 0311ba24..5dbcdd5e 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -95,6 +95,8 @@ 'ReadOnly', 'Required', 'NotRequired', + 'NoDefault', + 'NoExtraItems', # Pure aliases, have always been in typing 'AbstractSet', @@ -121,7 +123,6 @@ 'MutableMapping', 'MutableSequence', 'MutableSet', - 'NoDefault', 'Optional', 'Pattern', 'Reversible', @@ -871,6 +872,59 @@ def inner(func): return inner +if not hasattr(typing, "NoDefault") or not hasattr(typing, "NoExtraItems"): + 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}" + ) + + +if hasattr(typing, "NoDefault"): + NoDefault = typing.NoDefault +else: + class NoDefaultType(metaclass=SingletonMeta): + """The type of the NoDefault singleton.""" + + __slots__ = () + + def __new__(cls): + return globals().get("NoDefault") or object.__new__(cls) + + def __repr__(self): + return "typing_extensions.NoDefault" + + def __reduce__(self): + return "NoDefault" + + NoDefault = NoDefaultType() + del NoDefaultType + +if hasattr(typing, "NoExtraItems"): + NoExtraItems = typing.NoExtraItems +else: + 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_extensions.NoExtraItems" + + def __reduce__(self): + return "NoExtraItems" + + NoExtraItems = NoExtraItemsType() + del NoExtraItemsType + +if not hasattr(typing, "NoDefault") or not hasattr(typing, "NoExtraItems"): + del SingletonMeta + + # Update this to something like >=3.13.0b1 if and when # PEP 728 is implemented in CPython _PEP_728_IMPLEMENTED = False @@ -917,7 +971,7 @@ def _get_typeddict_qualifiers(annotation_type): break class _TypedDictMeta(type): - def __new__(cls, name, bases, ns, *, total=True, closed=None, extra_items=None): + def __new__(cls, name, bases, ns, *, total=True, closed=None, extra_items=NoExtraItems): """Create new typed dict class object. This method is called when TypedDict is subclassed, @@ -929,8 +983,8 @@ def __new__(cls, name, bases, ns, *, total=True, closed=None, extra_items=None): if type(base) is not _TypedDictMeta and base is not typing.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 None: - raise TypeError("Cannot combine closed=True and extra_items") + if closed is not None and extra_items is not NoExtraItems: + raise TypeError(f"Cannot combine closed={closed!r} and extra_items") elif closed is None: closed = False @@ -982,9 +1036,6 @@ def __new__(cls, name, bases, ns, *, total=True, closed=None, extra_items=None): optional_keys.update(base_dict.get('__optional_keys__', ())) readonly_keys.update(base_dict.get('__readonly_keys__', ())) mutable_keys.update(base_dict.get('__mutable_keys__', ())) - base_extra_items_type = getattr(base, '__extra_items__', None) - if base_extra_items_type is not None: - extra_items_type = base_extra_items_type if getattr(base, "__closed__", False) and not closed: if sys.version_info < (3, 14): # PEP 728 wants this to be an error, but that is not @@ -997,7 +1048,7 @@ def __new__(cls, name, bases, ns, *, total=True, closed=None, extra_items=None): else: raise TypeError("Child of a closed TypedDict must also be closed") - if closed and extra_items_type is None: + if closed and extra_items_type is NoExtraItems: extra_items_type = Never # This was specified in an earlier version of PEP 728. Support @@ -1059,7 +1110,16 @@ def __subclasscheck__(cls, other): _TypedDict = type.__new__(_TypedDictMeta, 'TypedDict', (), {}) @_ensure_subclassable(lambda bases: (_TypedDict,)) - def TypedDict(typename, fields=_marker, /, *, total=True, closed=False, **kwargs): + def TypedDict( + typename, + fields=_marker, + /, + *, + total=True, + closed=False, + extra_items=NoExtraItems, + **kwargs + ): """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 @@ -1119,9 +1179,14 @@ class Point2D(TypedDict): "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: kwargs["closed"] = closed - closed = False + 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," @@ -1143,7 +1208,7 @@ class Point2D(TypedDict): # Setting correct module is necessary to make typed dict classes pickleable. ns['__module__'] = module - td = _TypedDictMeta(typename, (), ns, total=total, closed=closed) + td = _TypedDictMeta(typename, (), ns, total=total, closed=closed, extra_items=extra_items) td.__orig_bases__ = (TypedDict,) return td @@ -1466,34 +1531,6 @@ def TypeAlias(self, parameters): ) -if hasattr(typing, "NoDefault"): - NoDefault = typing.NoDefault -else: - class NoDefaultTypeMeta(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 NoDefaultType(metaclass=NoDefaultTypeMeta): - """The type of the NoDefault singleton.""" - - __slots__ = () - - def __new__(cls): - return globals().get("NoDefault") or object.__new__(cls) - - def __repr__(self): - return "typing_extensions.NoDefault" - - def __reduce__(self): - return "NoDefault" - - NoDefault = NoDefaultType() - del NoDefaultType, NoDefaultTypeMeta - - def _set_default(type_param, default): type_param.has_default = lambda: default is not NoDefault type_param.__default__ = default From c1dc7a8d81a94521d092f1ee4b3990c595287331 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Thu, 12 Dec 2024 21:34:59 -0800 Subject: [PATCH 04/16] it was actually 3.13 --- src/test_typing_extensions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 58ff969a..2df6c1d5 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -4031,7 +4031,7 @@ def test_keywords_syntax_raises_on_3_13(self): with self.assertRaises(TypeError), self.assertWarns(DeprecationWarning): TypedDict('Emp', name=str, id=int) - @skipIf(sys.version_info >= (3, 15), "3.15 removes support for kwargs") + @skipIf(sys.version_info >= (3, 13), "3.13 removes support for kwargs") def test_basics_keywords_syntax(self): with self.assertWarns(DeprecationWarning): Emp = TypedDict('Emp', name=str, id=int) @@ -4048,7 +4048,7 @@ def test_basics_keywords_syntax(self): self.assertEqual(Emp.__annotations__, {'name': str, 'id': int}) self.assertEqual(Emp.__total__, True) - @skipIf(sys.version_info >= (3, 15), "3.15 removes support for kwargs") + @skipIf(sys.version_info >= (3, 13), "3.13 removes support for kwargs") def test_typeddict_special_keyword_names(self): with self.assertWarns(DeprecationWarning): TD = TypedDict("TD", cls=type, self=object, typename=str, _typename=int, From b3474d711f1771155661c399b19e44feee97a9c9 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Mon, 10 Feb 2025 19:23:10 -0800 Subject: [PATCH 05/16] Update src/test_typing_extensions.py Co-authored-by: Daraan --- src/test_typing_extensions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 2df6c1d5..2926cd25 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -4736,7 +4736,7 @@ class Child(Base): self.assertEqual(Child.__optional_keys__, frozenset({'a'})) self.assertEqual(Child.__readonly_keys__, frozenset({})) self.assertEqual(Child.__mutable_keys__, frozenset({'a'})) - + @skipIf(TYPING_3_14_0, "Only supported on <3.14") def test_extra_keys_readonly_explicit_closed(self): class Base(TypedDict, closed=True): __extra_items__: ReadOnly[str] From f143bf664f2879462a7b4e22a19f8c5f3f58ceca Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Mon, 10 Feb 2025 19:36:47 -0800 Subject: [PATCH 06/16] lint --- src/typing_extensions.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 5dbcdd5e..77ef5b50 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -971,7 +971,8 @@ def _get_typeddict_qualifiers(annotation_type): break class _TypedDictMeta(type): - def __new__(cls, name, bases, ns, *, total=True, closed=None, extra_items=NoExtraItems): + def __new__(cls, name, bases, ns, *, total=True, closed=None, + extra_items=NoExtraItems): """Create new typed dict class object. This method is called when TypedDict is subclassed, @@ -1044,6 +1045,7 @@ def __new__(cls, name, bases, ns, *, total=True, closed=None, extra_items=NoExtr "Child of a closed TypedDict must also be closed. This will " "be an error in Python 3.14.", DeprecationWarning, + stacklevel=2, ) else: raise TypeError("Child of a closed TypedDict must also be closed") @@ -1052,9 +1054,10 @@ def __new__(cls, name, bases, ns, *, total=True, closed=None, extra_items=NoExtr extra_items_type = Never # This was specified in an earlier version of PEP 728. Support - # is retained for backwards compatibility, but only for Python 3.13 - # and lower. - if closed and sys.version_info < (3, 14) and "__extra_items__" in own_annotations: + # is retained for backwards compatibility, but only for Python + # 3.13 and lower. + if (closed and sys.version_info < (3, 14) + and "__extra_items__" in own_annotations): annotation_type = own_annotations.pop("__extra_items__") qualifiers = set(_get_typeddict_qualifiers(annotation_type)) if Required in qualifiers: @@ -1208,7 +1211,8 @@ class Point2D(TypedDict): # 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 = _TypedDictMeta(typename, (), ns, total=total, closed=closed, + extra_items=extra_items) td.__orig_bases__ = (TypedDict,) return td From 22c2b6ea141648abed367009419c1693bd5da82b Mon Sep 17 00:00:00 2001 From: Daraan Date: Tue, 11 Feb 2025 17:20:49 +0100 Subject: [PATCH 07/16] Pep 728 Assure if closed is not passed, the value of __closed__ is None. (#1) --- src/test_typing_extensions.py | 21 +++++++++++++++++++-- src/typing_extensions.py | 9 ++++----- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 4d22af5e..ca3e4ace 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -4060,16 +4060,19 @@ def test_typeddict_special_keyword_names(self): self.assertEqual(TD.__annotations__, {'cls': type, 'self': object, 'typename': str, '_typename': int, 'fields': list, '_fields': dict, 'closed': bool, 'extra_items': bool}) - self.assertIs(TD.__closed__, False) + self.assertIs(TD.__closed__, None) self.assertIs(TD.__extra_items__, NoExtraItems) a = TD(cls=str, self=42, typename='foo', _typename=53, - fields=[('bar', tuple)], _fields={'baz', set}) + fields=[('bar', tuple)], _fields={'baz', set}, + closed=None, extra_items="tea pot") self.assertEqual(a['cls'], str) self.assertEqual(a['self'], 42) self.assertEqual(a['typename'], 'foo') self.assertEqual(a['_typename'], 53) self.assertEqual(a['fields'], [('bar', tuple)]) self.assertEqual(a['_fields'], {'baz', set}) + self.assertEqual(a['closed'], None) + self.assertEqual(a['extra_items'], "tea pot") def test_typeddict_create_errors(self): with self.assertRaises(TypeError): @@ -4317,6 +4320,16 @@ 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.assertIs(Implicit.__closed__, None) + self.assertIs(ExplicitTrue.__closed__, True) + self.assertIs(ExplicitFalse.__closed__, False) + + @skipIf(TYPING_3_14_0, "only supported on older versions") def test_closed_typeddict_compat(self): class Closed(TypedDict, closed=True): @@ -4843,6 +4856,10 @@ class ChildA(Base, closed=True): self.assertEqual(ChildA.__extra_items__, Never) self.assertTrue(ChildA.__closed__) + @skipIf(TYPING_3_14_0, "Backwards compatibility only for Python 3.13") + def test_implicit_extra_items_before_3_14(self): + class Base(TypedDict): + a: int class ChildB(Base, closed=True): __extra_items__: None diff --git a/src/typing_extensions.py b/src/typing_extensions.py index b17615ca..d620d8a1 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -977,6 +977,7 @@ def _get_typeddict_qualifiers(annotation_type): break class _TypedDictMeta(type): + def __new__(cls, name, bases, ns, *, total=True, closed=None, extra_items=NoExtraItems): """Create new typed dict class object. @@ -992,8 +993,6 @@ def __new__(cls, name, bases, ns, *, total=True, closed=None, '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") - elif closed is None: - closed = False if any(issubclass(b, typing.Generic) for b in bases): generic_base = (typing.Generic,) @@ -1043,7 +1042,7 @@ def __new__(cls, name, bases, ns, *, total=True, closed=None, optional_keys.update(base_dict.get('__optional_keys__', ())) readonly_keys.update(base_dict.get('__readonly_keys__', ())) mutable_keys.update(base_dict.get('__mutable_keys__', ())) - if getattr(base, "__closed__", False) and not closed: + if getattr(base, "__closed__", None) and not closed: if sys.version_info < (3, 14): # PEP 728 wants this to be an error, but that is not # compatible with previous versions of typing-extensions. @@ -1125,7 +1124,7 @@ def TypedDict( /, *, total=True, - closed=False, + closed=None, extra_items=NoExtraItems, **kwargs ): @@ -1189,7 +1188,7 @@ class Point2D(TypedDict): ) + example + "." warnings.warn(deprecation_msg, DeprecationWarning, stacklevel=2) # Support a field called "closed" - if closed is not False and closed is not True: + if closed is not False and closed is not True and closed is not None: kwargs["closed"] = closed closed = None # Or "extra_items" From 539b16733b79772b93b0b5e30b0906b0857df371 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Thu, 13 Feb 2025 21:48:35 -0800 Subject: [PATCH 08/16] feedback --- src/test_typing_extensions.py | 25 ++++++++++++------------- src/typing_extensions.py | 12 ------------ 2 files changed, 12 insertions(+), 25 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index ca3e4ace..97ce0300 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -4338,16 +4338,14 @@ class Closed(TypedDict, closed=True): class Unclosed(TypedDict, closed=False): ... - with self.assertWarns(DeprecationWarning): - class ChildUnclosed(Closed, Unclosed): - ... + class ChildUnclosed(Closed, Unclosed): + ... self.assertFalse(ChildUnclosed.__closed__) self.assertEqual(ChildUnclosed.__extra_items__, NoExtraItems) - with self.assertWarns(DeprecationWarning): - class ChildClosed(Unclosed, Closed): - ... + class ChildClosed(Unclosed, Closed): + ... self.assertFalse(ChildClosed.__closed__) self.assertEqual(ChildClosed.__extra_items__, NoExtraItems) @@ -4729,9 +4727,8 @@ def test_extra_keys_non_readonly_compat(self): class Base(TypedDict, closed=True): __extra_items__: str - with self.assertWarns(DeprecationWarning): - class Child(Base): - a: NotRequired[int] + class Child(Base): + a: NotRequired[int] self.assertEqual(Child.__required_keys__, frozenset({})) self.assertEqual(Child.__optional_keys__, frozenset({'a'})) @@ -4743,14 +4740,14 @@ def test_extra_keys_readonly(self): class Base(TypedDict, closed=True): __extra_items__: ReadOnly[str] - with self.assertWarns(DeprecationWarning): - class Child(Base): - a: NotRequired[str] + class Child(Base): + a: NotRequired[str] self.assertEqual(Child.__required_keys__, frozenset({})) self.assertEqual(Child.__optional_keys__, frozenset({'a'})) self.assertEqual(Child.__readonly_keys__, frozenset({})) self.assertEqual(Child.__mutable_keys__, frozenset({'a'})) + @skipIf(TYPING_3_14_0, "Only supported on <3.14") def test_extra_keys_readonly_explicit_closed(self): class Base(TypedDict, closed=True): @@ -4764,6 +4761,7 @@ class Child(Base, closed=True): self.assertEqual(Child.__readonly_keys__, frozenset({})) self.assertEqual(Child.__mutable_keys__, frozenset({'a'})) + @skipIf(TYPING_3_14_0, "Only supported on <3.14") def test_extra_key_required(self): with self.assertRaisesRegex( TypeError, @@ -4808,6 +4806,7 @@ class ExtraNotRequired(TypedDict): self.assertEqual(ExtraNotRequired.__extra_items__, NoExtraItems) self.assertFalse(ExtraNotRequired.__closed__) + @skipIf(TYPING_3_14_0, "Only supported on <3.14") def test_closed_inheritance(self): class Base(TypedDict, closed=True): __extra_items__: ReadOnly[Union[str, None]] @@ -4830,7 +4829,7 @@ class Child(Base, closed=True): self.assertEqual(Child.__mutable_keys__, frozenset({'a'})) self.assertEqual(Child.__annotations__, {"a": int}) self.assertEqual(Child.__extra_items__, int) - self.assertTrue(Child.__closed__) + self.assertIs(Child.__closed__, True) class GrandChild(Child, closed=True): __extra_items__: str diff --git a/src/typing_extensions.py b/src/typing_extensions.py index d620d8a1..8b849bfa 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -1042,18 +1042,6 @@ def __new__(cls, name, bases, ns, *, total=True, closed=None, optional_keys.update(base_dict.get('__optional_keys__', ())) readonly_keys.update(base_dict.get('__readonly_keys__', ())) mutable_keys.update(base_dict.get('__mutable_keys__', ())) - if getattr(base, "__closed__", None) and not closed: - if sys.version_info < (3, 14): - # PEP 728 wants this to be an error, but that is not - # compatible with previous versions of typing-extensions. - warnings.warn( - "Child of a closed TypedDict must also be closed. This will " - "be an error in Python 3.14.", - DeprecationWarning, - stacklevel=2, - ) - else: - raise TypeError("Child of a closed TypedDict must also be closed") if closed and extra_items_type is NoExtraItems: extra_items_type = Never From 916f9d006775675ac08b827c9f8859be9d3c0465 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Thu, 13 Feb 2025 21:52:09 -0800 Subject: [PATCH 09/16] another test --- src/test_typing_extensions.py | 36 ++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 97ce0300..bd71b5e5 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -4807,7 +4807,7 @@ class ExtraNotRequired(TypedDict): self.assertFalse(ExtraNotRequired.__closed__) @skipIf(TYPING_3_14_0, "Only supported on <3.14") - def test_closed_inheritance(self): + def test_closed_inheritance_legacy(self): class Base(TypedDict, closed=True): __extra_items__: ReadOnly[Union[str, None]] @@ -4842,6 +4842,40 @@ class GrandChild(Child, closed=True): self.assertEqual(GrandChild.__extra_items__, str) self.assertTrue(GrandChild.__closed__) + 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.assertFalse(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.assertEqual(Child.__extra_items__, int) + self.assertFalse(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.assertEqual(GrandChild.__extra_items__, Never) + self.assertTrue(GrandChild.__closed__) + def test_implicit_extra_items(self): class Base(TypedDict): a: int From b66f18ee010b9d7b892ba9087982e0193b446468 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Thu, 13 Feb 2025 21:54:45 -0800 Subject: [PATCH 10/16] always be total --- src/typing_extensions.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 8b849bfa..7f929898 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -1089,8 +1089,7 @@ def __new__(cls, name, bases, ns, *, total=True, closed=None, tp_dict.__optional_keys__ = frozenset(optional_keys) tp_dict.__readonly_keys__ = frozenset(readonly_keys) tp_dict.__mutable_keys__ = frozenset(mutable_keys) - if not hasattr(tp_dict, '__total__'): - tp_dict.__total__ = total + tp_dict.__total__ = total tp_dict.__closed__ = closed tp_dict.__extra_items__ = extra_items_type return tp_dict From a95fd4616281b848b9077c5f1f391be813af2c47 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 21 Feb 2025 21:31:14 -0800 Subject: [PATCH 11/16] Feedback --- src/test_typing_extensions.py | 58 ++++++++++++++++++++--------------- src/typing_extensions.py | 3 -- 2 files changed, 34 insertions(+), 27 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index bd71b5e5..f80b66a4 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -4341,13 +4341,13 @@ class Unclosed(TypedDict, closed=False): class ChildUnclosed(Closed, Unclosed): ... - self.assertFalse(ChildUnclosed.__closed__) + self.assertIs(ChildUnclosed.__closed__, None) self.assertEqual(ChildUnclosed.__extra_items__, NoExtraItems) class ChildClosed(Unclosed, Closed): ... - self.assertFalse(ChildClosed.__closed__) + self.assertIs(ChildClosed.__closed__, None) self.assertEqual(ChildClosed.__extra_items__, NoExtraItems) def test_extra_items_class_arg(self): @@ -4783,8 +4783,8 @@ class ExtraReadOnly(TypedDict): self.assertEqual(ExtraReadOnly.__optional_keys__, frozenset({})) self.assertEqual(ExtraReadOnly.__readonly_keys__, frozenset({'__extra_items__'})) self.assertEqual(ExtraReadOnly.__mutable_keys__, frozenset({})) - self.assertEqual(ExtraReadOnly.__extra_items__, NoExtraItems) - self.assertFalse(ExtraReadOnly.__closed__) + self.assertIs(ExtraReadOnly.__extra_items__, NoExtraItems) + self.assertIs(ExtraReadOnly.__closed__, None) class ExtraRequired(TypedDict): __extra_items__: Required[str] @@ -4793,8 +4793,8 @@ class ExtraRequired(TypedDict): self.assertEqual(ExtraRequired.__optional_keys__, frozenset({})) self.assertEqual(ExtraRequired.__readonly_keys__, frozenset({})) self.assertEqual(ExtraRequired.__mutable_keys__, frozenset({'__extra_items__'})) - self.assertEqual(ExtraRequired.__extra_items__, NoExtraItems) - self.assertFalse(ExtraRequired.__closed__) + self.assertIs(ExtraRequired.__extra_items__, NoExtraItems) + self.assertIs(ExtraRequired.__closed__, None) class ExtraNotRequired(TypedDict): __extra_items__: NotRequired[str] @@ -4803,8 +4803,8 @@ class ExtraNotRequired(TypedDict): self.assertEqual(ExtraNotRequired.__optional_keys__, frozenset({'__extra_items__'})) self.assertEqual(ExtraNotRequired.__readonly_keys__, frozenset({})) self.assertEqual(ExtraNotRequired.__mutable_keys__, frozenset({'__extra_items__'})) - self.assertEqual(ExtraNotRequired.__extra_items__, NoExtraItems) - self.assertFalse(ExtraNotRequired.__closed__) + self.assertIs(ExtraNotRequired.__extra_items__, NoExtraItems) + self.assertIs(ExtraNotRequired.__closed__, None) @skipIf(TYPING_3_14_0, "Only supported on <3.14") def test_closed_inheritance_legacy(self): @@ -4817,7 +4817,7 @@ class Base(TypedDict, closed=True): self.assertEqual(Base.__mutable_keys__, frozenset({})) self.assertEqual(Base.__annotations__, {}) self.assertEqual(Base.__extra_items__, ReadOnly[Union[str, None]]) - self.assertTrue(Base.__closed__) + self.assertIs(Base.__closed__, True) class Child(Base, closed=True): a: int @@ -4828,7 +4828,7 @@ class Child(Base, closed=True): self.assertEqual(Child.__readonly_keys__, frozenset({})) self.assertEqual(Child.__mutable_keys__, frozenset({'a'})) self.assertEqual(Child.__annotations__, {"a": int}) - self.assertEqual(Child.__extra_items__, int) + self.assertIs(Child.__extra_items__, int) self.assertIs(Child.__closed__, True) class GrandChild(Child, closed=True): @@ -4839,8 +4839,8 @@ class GrandChild(Child, closed=True): self.assertEqual(GrandChild.__readonly_keys__, frozenset({})) self.assertEqual(GrandChild.__mutable_keys__, frozenset({'a'})) self.assertEqual(GrandChild.__annotations__, {"a": int}) - self.assertEqual(GrandChild.__extra_items__, str) - self.assertTrue(GrandChild.__closed__) + self.assertIs(GrandChild.__extra_items__, str) + self.assertIs(GrandChild.__closed__, True) def test_closed_inheritance(self): class Base(TypedDict, extra_items=ReadOnly[Union[str, None]]): @@ -4852,7 +4852,7 @@ class Base(TypedDict, extra_items=ReadOnly[Union[str, None]]): self.assertEqual(Base.__mutable_keys__, frozenset({"a"})) self.assertEqual(Base.__annotations__, {"a": int}) self.assertEqual(Base.__extra_items__, ReadOnly[Union[str, None]]) - self.assertFalse(Base.__closed__) + self.assertIs(Base.__closed__, None) class Child(Base, extra_items=int): a: str @@ -4862,8 +4862,8 @@ class Child(Base, extra_items=int): self.assertEqual(Child.__readonly_keys__, frozenset({})) self.assertEqual(Child.__mutable_keys__, frozenset({'a'})) self.assertEqual(Child.__annotations__, {"a": str}) - self.assertEqual(Child.__extra_items__, int) - self.assertFalse(Child.__closed__) + self.assertIs(Child.__extra_items__, int) + self.assertIs(Child.__closed__, None) class GrandChild(Child, closed=True): a: float @@ -4873,21 +4873,31 @@ class GrandChild(Child, closed=True): self.assertEqual(GrandChild.__readonly_keys__, frozenset({})) self.assertEqual(GrandChild.__mutable_keys__, frozenset({'a'})) self.assertEqual(GrandChild.__annotations__, {"a": float}) - self.assertEqual(GrandChild.__extra_items__, Never) - self.assertTrue(GrandChild.__closed__) + 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.assertIs(GrandGrandChild.__closed__, None) def test_implicit_extra_items(self): class Base(TypedDict): a: int - self.assertEqual(Base.__extra_items__, NoExtraItems) - self.assertFalse(Base.__closed__) + self.assertIs(Base.__extra_items__, NoExtraItems) + self.assertIs(Base.__closed__, None) class ChildA(Base, closed=True): ... - self.assertEqual(ChildA.__extra_items__, Never) - self.assertTrue(ChildA.__closed__) + self.assertEqual(ChildA.__extra_items__, NoExtraItems) + self.assertIs(ChildA.__closed__, True) @skipIf(TYPING_3_14_0, "Backwards compatibility only for Python 3.13") def test_implicit_extra_items_before_3_14(self): @@ -4896,8 +4906,8 @@ class Base(TypedDict): class ChildB(Base, closed=True): __extra_items__: None - self.assertEqual(ChildB.__extra_items__, type(None)) - self.assertTrue(ChildB.__closed__) + self.assertIs(ChildB.__extra_items__, type(None)) + self.assertIs(ChildB.__closed__, True) @skipIf( TYPING_3_13_0, @@ -4907,7 +4917,7 @@ class ChildB(Base, closed=True): def test_backwards_compatibility(self): with self.assertWarns(DeprecationWarning): TD = TypedDict("TD", closed=int) - self.assertFalse(TD.__closed__) + self.assertIs(TD.__closed__, None) self.assertEqual(TD.__annotations__, {"closed": int}) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 7f929898..9a9aea45 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -1043,9 +1043,6 @@ def __new__(cls, name, bases, ns, *, total=True, closed=None, readonly_keys.update(base_dict.get('__readonly_keys__', ())) mutable_keys.update(base_dict.get('__mutable_keys__', ())) - if closed and extra_items_type is NoExtraItems: - extra_items_type = Never - # This was specified in an earlier version of PEP 728. Support # is retained for backwards compatibility, but only for Python # 3.13 and lower. From 4c20f9a62395b3d334e051b2854d5e17d82e1252 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 18 Mar 2025 08:07:15 -0700 Subject: [PATCH 12/16] mark tests as legacy --- src/test_typing_extensions.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index d3420e7a..559fb372 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -4843,7 +4843,7 @@ class AllTheThings(TypedDict): ) @skipIf(TYPING_3_14_0, "Old syntax only supported on <3.14") - def test_extra_keys_non_readonly_compat(self): + def test_extra_keys_non_readonly_legacy(self): class Base(TypedDict, closed=True): __extra_items__: str @@ -4856,7 +4856,7 @@ class Child(Base): self.assertEqual(Child.__mutable_keys__, frozenset({'a'})) @skipIf(TYPING_3_14_0, "Only supported on <3.14") - def test_extra_keys_readonly(self): + def test_extra_keys_readonly_legacy(self): class Base(TypedDict, closed=True): __extra_items__: ReadOnly[str] @@ -4869,7 +4869,7 @@ class Child(Base): self.assertEqual(Child.__mutable_keys__, frozenset({'a'})) @skipIf(TYPING_3_14_0, "Only supported on <3.14") - def test_extra_keys_readonly_explicit_closed(self): + def test_extra_keys_readonly_explicit_closed_legacy(self): class Base(TypedDict, closed=True): __extra_items__: ReadOnly[str] @@ -4882,7 +4882,7 @@ class Child(Base, closed=True): self.assertEqual(Child.__mutable_keys__, frozenset({'a'})) @skipIf(TYPING_3_14_0, "Only supported on <3.14") - def test_extra_key_required(self): + def test_extra_key_required_legacy(self): with self.assertRaisesRegex( TypeError, "Special key __extra_items__ does not support Required" @@ -4895,7 +4895,7 @@ def test_extra_key_required(self): ): TypedDict("A", {"__extra_items__": NotRequired[int]}, closed=True) - def test_regular_extra_items(self): + def test_regular_extra_items_legacy(self): class ExtraReadOnly(TypedDict): __extra_items__: ReadOnly[str] From 0bc7fe6e46cc4d2b4e8a527e71451c12df10a7f9 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 18 Mar 2025 09:51:41 -0700 Subject: [PATCH 13/16] Apply suggestions from code review Co-authored-by: Alex Waygood --- src/test_typing_extensions.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 559fb372..67d98a5f 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -4149,7 +4149,7 @@ def test_typeddict_special_keyword_names(self): self.assertEqual(TD.__annotations__, {'cls': type, 'self': object, 'typename': str, '_typename': int, 'fields': list, '_fields': dict, 'closed': bool, 'extra_items': bool}) - self.assertIs(TD.__closed__, None) + self.assertIsNone(TD.__closed__) self.assertIs(TD.__extra_items__, NoExtraItems) a = TD(cls=str, self=42, typename='foo', _typename=53, fields=[('bar', tuple)], _fields={'baz', set}, @@ -4160,7 +4160,7 @@ def test_typeddict_special_keyword_names(self): self.assertEqual(a['_typename'], 53) self.assertEqual(a['fields'], [('bar', tuple)]) self.assertEqual(a['_fields'], {'baz', set}) - self.assertEqual(a['closed'], None) + self.assertIsNone(a['closed']) self.assertEqual(a['extra_items'], "tea pot") def test_typeddict_create_errors(self): @@ -4445,7 +4445,7 @@ class Implicit(TypedDict): ... class ExplicitTrue(TypedDict, closed=True): ... class ExplicitFalse(TypedDict, closed=False): ... - self.assertIs(Implicit.__closed__, None) + self.assertIsNone(Implicit.__closed__) self.assertIs(ExplicitTrue.__closed__, True) self.assertIs(ExplicitFalse.__closed__, False) @@ -4461,20 +4461,20 @@ class Unclosed(TypedDict, closed=False): class ChildUnclosed(Closed, Unclosed): ... - self.assertIs(ChildUnclosed.__closed__, None) + self.assertIsNone(ChildUnclosed.__closed__) self.assertEqual(ChildUnclosed.__extra_items__, NoExtraItems) class ChildClosed(Unclosed, Closed): ... - self.assertIs(ChildClosed.__closed__, None) + self.assertIsNone(ChildClosed.__closed__) self.assertEqual(ChildClosed.__extra_items__, NoExtraItems) def test_extra_items_class_arg(self): class TD(TypedDict, extra_items=int): a: str - self.assertEqual(TD.__extra_items__, int) + 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()) @@ -4904,7 +4904,7 @@ class ExtraReadOnly(TypedDict): self.assertEqual(ExtraReadOnly.__readonly_keys__, frozenset({'__extra_items__'})) self.assertEqual(ExtraReadOnly.__mutable_keys__, frozenset({})) self.assertIs(ExtraReadOnly.__extra_items__, NoExtraItems) - self.assertIs(ExtraReadOnly.__closed__, None) + self.assertIsNone(ExtraReadOnly.__closed__) class ExtraRequired(TypedDict): __extra_items__: Required[str] @@ -4914,7 +4914,7 @@ class ExtraRequired(TypedDict): self.assertEqual(ExtraRequired.__readonly_keys__, frozenset({})) self.assertEqual(ExtraRequired.__mutable_keys__, frozenset({'__extra_items__'})) self.assertIs(ExtraRequired.__extra_items__, NoExtraItems) - self.assertIs(ExtraRequired.__closed__, None) + self.assertIsNone(ExtraRequired.__closed__) class ExtraNotRequired(TypedDict): __extra_items__: NotRequired[str] @@ -4924,7 +4924,7 @@ class ExtraNotRequired(TypedDict): self.assertEqual(ExtraNotRequired.__readonly_keys__, frozenset({})) self.assertEqual(ExtraNotRequired.__mutable_keys__, frozenset({'__extra_items__'})) self.assertIs(ExtraNotRequired.__extra_items__, NoExtraItems) - self.assertIs(ExtraNotRequired.__closed__, None) + self.assertIsNone(ExtraNotRequired.__closed__) @skipIf(TYPING_3_14_0, "Only supported on <3.14") def test_closed_inheritance_legacy(self): @@ -4972,7 +4972,7 @@ class Base(TypedDict, extra_items=ReadOnly[Union[str, None]]): self.assertEqual(Base.__mutable_keys__, frozenset({"a"})) self.assertEqual(Base.__annotations__, {"a": int}) self.assertEqual(Base.__extra_items__, ReadOnly[Union[str, None]]) - self.assertIs(Base.__closed__, None) + self.assertIsNone(Base.__closed__) class Child(Base, extra_items=int): a: str @@ -4983,7 +4983,7 @@ class Child(Base, extra_items=int): self.assertEqual(Child.__mutable_keys__, frozenset({'a'})) self.assertEqual(Child.__annotations__, {"a": str}) self.assertIs(Child.__extra_items__, int) - self.assertIs(Child.__closed__, None) + self.assertIsNone(Child.__closed__) class GrandChild(Child, closed=True): a: float @@ -5004,14 +5004,14 @@ class GrandGrandChild(GrandChild): self.assertEqual(GrandGrandChild.__mutable_keys__, frozenset({'a'})) self.assertEqual(GrandGrandChild.__annotations__, {"a": float}) self.assertIs(GrandGrandChild.__extra_items__, NoExtraItems) - self.assertIs(GrandGrandChild.__closed__, None) + self.assertIsNone(GrandGrandChild.__closed__) def test_implicit_extra_items(self): class Base(TypedDict): a: int self.assertIs(Base.__extra_items__, NoExtraItems) - self.assertIs(Base.__closed__, None) + self.assertIsNone(Base.__closed__) class ChildA(Base, closed=True): ... From 35f53fe8ba4044e73688a3d0daa93d710ea0f43b Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 18 Mar 2025 10:14:25 -0700 Subject: [PATCH 14/16] factor out condition --- src/typing_extensions.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index ad6664fd..833bb7be 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -878,7 +878,9 @@ def inner(func): return inner -if not hasattr(typing, "NoDefault") or not hasattr(typing, "NoExtraItems"): +_NEEDS_SINGLETONMETA = not hasattr(typing, "NoDefault") or not hasattr(typing, "NoExtraItems") + +if _NEEDS_SINGLETONMETA: class SingletonMeta(type): def __setattr__(cls, attr, value): # TypeError is consistent with the behavior of NoneType @@ -927,7 +929,7 @@ def __reduce__(self): NoExtraItems = NoExtraItemsType() del NoExtraItemsType -if not hasattr(typing, "NoDefault") or not hasattr(typing, "NoExtraItems"): +if _NEEDS_SINGLETONMETA: del SingletonMeta From ed170830a2cbaee1670718d2ae6219effc67683a Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 18 Mar 2025 10:16:58 -0700 Subject: [PATCH 15/16] add test case --- src/test_typing_extensions.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 67d98a5f..4e3520fc 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -5040,6 +5040,11 @@ def test_backwards_compatibility(self): self.assertIs(TD.__closed__, None) self.assertEqual(TD.__annotations__, {"closed": int}) + with self.assertWarns(DeprecationWarning): + TD = TypedDict("TD", extra_items=int) + self.assertIs(TD.__extra_items__, NoExtraItems) + self.assertEqual(TD.__annotations__, {"extra_items": int}) + class AnnotatedTests(BaseTestCase): From e9fe4623547b88aa0b110653b10d8abacfb9c0da Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 18 Mar 2025 10:18:35 -0700 Subject: [PATCH 16/16] lint --- src/typing_extensions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 833bb7be..d2fb245b 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -878,7 +878,9 @@ def inner(func): return inner -_NEEDS_SINGLETONMETA = not hasattr(typing, "NoDefault") or not hasattr(typing, "NoExtraItems") +_NEEDS_SINGLETONMETA = ( + not hasattr(typing, "NoDefault") or not hasattr(typing, "NoExtraItems") +) if _NEEDS_SINGLETONMETA: class SingletonMeta(type):