From d17c456d367e88adee4a4e3bef48f81f7e2df473 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 3 Jun 2025 12:26:40 -0700 Subject: [PATCH 1/4] allow TypedDict as a type argument (#614) --- CHANGELOG.md | 6 +++ src/test_typing_extensions.py | 6 +++ src/typing_extensions.py | 94 +++++++++++++++++++---------------- 3 files changed, 63 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2e833be..5d949cc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# Unreleased + +- Fix usage of `typing_extensions.TypedDict` nested inside other types + (e.g., `typing.Type[typing_extensions.TypedDict]`). This is not allowed by the + type system but worked on older versions, so we maintain support. + # Release 4.14.0 (June 2, 2025) Changes since 4.14.0rc1: diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 7fb748bb..6bc3de5a 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -4202,6 +4202,12 @@ def test_basics_functional_syntax(self): self.assertEqual(Emp.__annotations__, {'name': str, 'id': int}) self.assertEqual(Emp.__total__, True) + def test_allowed_as_type_argument(self): + # https://github.com/python/typing_extensions/issues/613 + obj = typing.Type[typing_extensions.TypedDict] + self.assertIs(typing_extensions.get_origin(obj), type) + self.assertEqual(typing_extensions.get_args(obj), (typing_extensions.TypedDict,)) + @skipIf(sys.version_info < (3, 13), "Change in behavior in 3.13") def test_keywords_syntax_raises_on_3_13(self): with self.assertRaises(TypeError), self.assertWarns(DeprecationWarning): diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 5d5a5c7f..b97acf80 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -221,7 +221,55 @@ def __new__(cls, *args, **kwargs): ClassVar = typing.ClassVar +# Vendored from cpython typing._SpecialFrom +# Having a separate class means that instances will not be rejected by +# typing._type_check. +class _SpecialForm(typing._Final, _root=True): + __slots__ = ('_name', '__doc__', '_getitem') + + def __init__(self, getitem): + self._getitem = getitem + self._name = getitem.__name__ + self.__doc__ = getitem.__doc__ + + def __getattr__(self, item): + if item in {'__name__', '__qualname__'}: + return self._name + + raise AttributeError(item) + + def __mro_entries__(self, bases): + raise TypeError(f"Cannot subclass {self!r}") + + def __repr__(self): + return f'typing_extensions.{self._name}' + + def __reduce__(self): + return self._name + + def __call__(self, *args, **kwds): + raise TypeError(f"Cannot instantiate {self!r}") + + def __or__(self, other): + return typing.Union[self, other] + + def __ror__(self, other): + return typing.Union[other, self] + + def __instancecheck__(self, obj): + raise TypeError(f"{self} cannot be used with isinstance()") + + def __subclasscheck__(self, cls): + raise TypeError(f"{self} cannot be used with issubclass()") + + @typing._tp_cache + def __getitem__(self, parameters): + return self._getitem(self, parameters) + +# Note that inheriting from this class means that the object will be +# rejected by typing._type_check, so do not use it if the special form +# is arguably valid as a type by itself. class _ExtensionsSpecialForm(typing._SpecialForm, _root=True): def __repr__(self): return 'typing_extensions.' + self._name @@ -1223,7 +1271,9 @@ def _create_typeddict( td.__orig_bases__ = (TypedDict,) return td - class _TypedDictSpecialForm(_ExtensionsSpecialForm, _root=True): + class _TypedDictSpecialForm(_SpecialForm, _root=True): + __slots__ = ('_name', '__doc__', '_getitem') + def __call__( self, typename, @@ -2201,48 +2251,6 @@ def cast[T](typ: TypeForm[T], value: Any) -> T: ... return typing._GenericAlias(self, (item,)) -# Vendored from cpython typing._SpecialFrom -class _SpecialForm(typing._Final, _root=True): - __slots__ = ('_name', '__doc__', '_getitem') - - def __init__(self, getitem): - self._getitem = getitem - self._name = getitem.__name__ - self.__doc__ = getitem.__doc__ - - def __getattr__(self, item): - if item in {'__name__', '__qualname__'}: - return self._name - - raise AttributeError(item) - - def __mro_entries__(self, bases): - raise TypeError(f"Cannot subclass {self!r}") - - def __repr__(self): - return f'typing_extensions.{self._name}' - - def __reduce__(self): - return self._name - - def __call__(self, *args, **kwds): - raise TypeError(f"Cannot instantiate {self!r}") - - def __or__(self, other): - return typing.Union[self, other] - - def __ror__(self, other): - return typing.Union[other, self] - - def __instancecheck__(self, obj): - raise TypeError(f"{self} cannot be used with isinstance()") - - def __subclasscheck__(self, cls): - raise TypeError(f"{self} cannot be used with issubclass()") - - @typing._tp_cache - def __getitem__(self, parameters): - return self._getitem(self, parameters) if hasattr(typing, "LiteralString"): # 3.11+ From 40e22ebb2ca5747eaa9405b152c43a294ac3af37 Mon Sep 17 00:00:00 2001 From: Victorien <65306057+Viicos@users.noreply.github.com> Date: Wed, 4 Jun 2025 11:02:14 +0200 Subject: [PATCH 2/4] Do not use slots for `_TypedDictSpecialForm` (#616) --- src/test_typing_extensions.py | 2 ++ src/typing_extensions.py | 2 -- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 6bc3de5a..5de161f9 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -5290,6 +5290,8 @@ class A(TypedDict): 'z': 'Required[undefined]'}, ) + def test_dunder_dict(self): + self.assertIsInstance(TypedDict.__dict__, dict) class AnnotatedTests(BaseTestCase): diff --git a/src/typing_extensions.py b/src/typing_extensions.py index b97acf80..efa09d55 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -1272,8 +1272,6 @@ def _create_typeddict( return td class _TypedDictSpecialForm(_SpecialForm, _root=True): - __slots__ = ('_name', '__doc__', '_getitem') - def __call__( self, typename, From 59d2c20858ac527516ebad5a89c05af514dac94a Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Wed, 4 Jun 2025 18:36:38 -0700 Subject: [PATCH 3/4] Fix off by one in pickle protocol tests (#618) I've noticed several tests which I assume are meant to test all pickle protocols but are missing the `+ 1` needed to test the highest protocol in a range. This adds the highest protocol to these tests. --- src/test_typing_extensions.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 5de161f9..3ef29474 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -525,7 +525,7 @@ def test_cannot_instantiate(self): type(self.bottom_type)() def test_pickle(self): - for proto in range(pickle.HIGHEST_PROTOCOL): + for proto in range(pickle.HIGHEST_PROTOCOL + 1): pickled = pickle.dumps(self.bottom_type, protocol=proto) self.assertIs(self.bottom_type, pickle.loads(pickled)) @@ -5904,7 +5904,7 @@ def test_pickle(self): P_co = ParamSpec('P_co', covariant=True) P_contra = ParamSpec('P_contra', contravariant=True) P_default = ParamSpec('P_default', default=[int]) - for proto in range(pickle.HIGHEST_PROTOCOL): + for proto in range(pickle.HIGHEST_PROTOCOL + 1): with self.subTest(f'Pickle protocol {proto}'): for paramspec in (P, P_co, P_contra, P_default): z = pickle.loads(pickle.dumps(paramspec, proto)) @@ -6327,7 +6327,7 @@ def test_typevar(self): self.assertIs(StrT.__bound__, LiteralString) def test_pickle(self): - for proto in range(pickle.HIGHEST_PROTOCOL): + for proto in range(pickle.HIGHEST_PROTOCOL + 1): pickled = pickle.dumps(LiteralString, protocol=proto) self.assertIs(LiteralString, pickle.loads(pickled)) @@ -6374,7 +6374,7 @@ def return_tuple(self) -> TupleSelf: return (self, self) def test_pickle(self): - for proto in range(pickle.HIGHEST_PROTOCOL): + for proto in range(pickle.HIGHEST_PROTOCOL + 1): pickled = pickle.dumps(Self, protocol=proto) self.assertIs(Self, pickle.loads(pickled)) @@ -6586,7 +6586,7 @@ def test_pickle(self): Ts = TypeVarTuple('Ts') Ts_default = TypeVarTuple('Ts_default', default=Unpack[Tuple[int, str]]) - for proto in range(pickle.HIGHEST_PROTOCOL): + for proto in range(pickle.HIGHEST_PROTOCOL + 1): for typevartuple in (Ts, Ts_default): z = pickle.loads(pickle.dumps(typevartuple, proto)) self.assertEqual(z.__name__, typevartuple.__name__) @@ -7597,7 +7597,7 @@ def test_pickle(self): U_co = typing_extensions.TypeVar('U_co', covariant=True) U_contra = typing_extensions.TypeVar('U_contra', contravariant=True) U_default = typing_extensions.TypeVar('U_default', default=int) - for proto in range(pickle.HIGHEST_PROTOCOL): + for proto in range(pickle.HIGHEST_PROTOCOL + 1): for typevar in (U, U_co, U_contra, U_default): z = pickle.loads(pickle.dumps(typevar, proto)) self.assertEqual(z.__name__, typevar.__name__) @@ -7746,7 +7746,7 @@ def test_pickle(self): global U, U_infer # pickle wants to reference the class by name U = typing_extensions.TypeVar('U') U_infer = typing_extensions.TypeVar('U_infer', infer_variance=True) - for proto in range(pickle.HIGHEST_PROTOCOL): + for proto in range(pickle.HIGHEST_PROTOCOL + 1): for typevar in (U, U_infer): z = pickle.loads(pickle.dumps(typevar, proto)) self.assertEqual(z.__name__, typevar.__name__) @@ -8351,7 +8351,7 @@ def test_equality(self): def test_pickle(self): doc_info = Doc("Who to say hi to") - for proto in range(pickle.HIGHEST_PROTOCOL): + for proto in range(pickle.HIGHEST_PROTOCOL + 1): pickled = pickle.dumps(doc_info, protocol=proto) self.assertEqual(doc_info, pickle.loads(pickled)) From 42027aba3558c9d9133a90bca17f6fecaecc48d8 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 4 Jul 2025 06:26:34 -0700 Subject: [PATCH 4/4] Prepare release 4.14.1 (#620) --- CHANGELOG.md | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d949cc8..8855595e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# Unreleased +# Release 4.14.1 (July 4, 2025) - Fix usage of `typing_extensions.TypedDict` nested inside other types (e.g., `typing.Type[typing_extensions.TypedDict]`). This is not allowed by the diff --git a/pyproject.toml b/pyproject.toml index a8f3d525..38475b93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "flit_core.buildapi" # Project metadata [project] name = "typing_extensions" -version = "4.14.0" +version = "4.14.1" description = "Backported and Experimental Type Hints for Python 3.9+" readme = "README.md" requires-python = ">=3.9"