diff --git a/CHANGELOG.md b/CHANGELOG.md index fcd2db5f..da8f070d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Python 3.9. The `typing` implementation has always raised an error, and the `typing_extensions` implementation has raised an error on Python 3.10+ since `typing_extensions` v4.6.0. Patch by Brian Schubert. +- Add `bound` and variance parameters to `TypeVarTuple`. # Release 4.15.0 (August 25, 2025) diff --git a/doc/index.rst b/doc/index.rst index 85a11ec7..5e94ee4b 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -458,7 +458,8 @@ Special typing primitives See :py:class:`typing.ParamSpec` and :pep:`612`. In ``typing`` since 3.10. The ``typing_extensions`` version adds support for the - ``default=`` argument from :pep:`696`. + ``default=`` argument from :pep:`696`, and for the ``infer_variance=``, + ``covariant=`` and ``contravariant=`` arguments that were added in Python 3.15. On older Python versions, ``typing_extensions.ParamSpec`` may not work correctly with introspection tools like :func:`get_args` and @@ -492,6 +493,10 @@ Special typing primitives ParamSpecs now have a ``has_default()`` method, for compatibility with :py:class:`typing.ParamSpec` on Python 3.13+. + .. versionchanged:: 4.16.0 + + The ``infer_variance``, ``covariant``, and ``contravariant`` arguments are now supported. + .. class:: ParamSpecArgs ParamSpecKwargs @@ -719,7 +724,8 @@ Special typing primitives See :py:class:`typing.TypeVarTuple` and :pep:`646`. In ``typing`` since 3.11. The ``typing_extensions`` version adds support for the - ``default=`` argument from :pep:`696`. + ``default=`` argument from :pep:`696`, and for the ``infer_variance=``, + ``covariant=`` and ``contravariant=`` arguments that were added in Python 3.15. .. versionadded:: 4.1.0 @@ -751,6 +757,10 @@ Special typing primitives `TypeVarTuple` in a type parameter list. This matches the CPython implementation of PEP 696 on Python 3.13+. + .. versionchanged:: 4.16.0 + + The ``infer_variance``, ``covariant``, and ``contravariant`` arguments are now supported. + .. data:: Unpack See :py:data:`typing.Unpack` and :pep:`646`. In ``typing`` since 3.11. diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 4149eebe..fb7d8e29 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -138,6 +138,22 @@ TYPING_3_15_0_BETA_1 = sys.version_info[:5] == (3, 15, 0, 'beta', 1) +# We cannot control the repr of `TypeVarTuple` on versions of Python +# where `typing_extensions.TypeVarTuple()` does not return an instance +# of `typing_extensions.TypeVarTuple`. At time of writing, that's Python +# versions 3.11-3.14 inclusive (but not 3.10 or 3.15+). The exact version +# range has changed in the past and may do so again in the future. +# +# Note that we do not do an `isinstance()` check here because +# `typing_extensions.TypeVarTuple` does some trickery to pretend that +# instances of `typing.TypeVar` are also instances of +# `typing_extensions.TypeVarTuple` on Python 3.11-3.14. +# (Possibly we're being a little too clever for our own good there.) +GOOD_TYPEVARTUPLE_REPR_EXPECTED = ( + type(typing_extensions.TypeVarTuple("Ts")) + is typing_extensions.TypeVarTuple +) + # 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 @@ -1762,7 +1778,7 @@ def test_annotation_and_optional_default(self): annotation : annotation, Optional[int] : Optional[int], Optional[List[str]] : Optional[List[str]], - Optional[annotation] : Optional[annotation], + Optional[annotation] : Optional[annotation], Union[str, None, str] : Optional[str], Unpack[Tuple[int, None]]: Unpack[Tuple[int, None]], } @@ -1780,6 +1796,8 @@ def test_annotation_and_optional_default(self): Union[str, "Union[None, StrAlias]"]: Optional[str], Union["annotation", T_default] : Union[annotation, T_default], Annotated["annotation", "nested"] : Annotated[Union[int, None], "data", "nested"], + # Note: A starred *Ts will use typing.Unpack in 3.11+ see Issue #485 + Unpack[Ts] : Unpack[Ts], } # Note: A starred *Ts will use typing.Unpack in 3.11+ see Issue #485 if TYPING_3_15_0: @@ -6607,12 +6625,12 @@ def test_basic_plain(self): with self.assertRaises(TypeError): Unpack() - @skipIf(TYPING_3_15_0, "repr changed in 3.15") + @skipIf(GOOD_TYPEVARTUPLE_REPR_EXPECTED, "TypeVarTuples have a bad repr on this version") def test_repr(self): Ts = TypeVarTuple('Ts') self.assertEqual(repr(Unpack[Ts]), f'{Unpack.__module__}.Unpack[Ts]') - @skipUnless(TYPING_3_15_0, "repr changed in 3.15") + @skipUnless(GOOD_TYPEVARTUPLE_REPR_EXPECTED, "TypeVarTuples have a bad repr on this version") def test_repr_py315(self): Ts = TypeVarTuple('Ts') self.assertEqual(repr(Unpack[Ts]), f'{Unpack.__module__}.Unpack[~Ts]') @@ -6811,15 +6829,50 @@ def test_basic_plain(self): Ys = TypeVarTuple('Ys') self.assertNotEqual(Xs, Ys) - @skipIf(TYPING_3_15_0, "repr changed in 3.15") + @skipIf(GOOD_TYPEVARTUPLE_REPR_EXPECTED, "TypeVarTuples have a bad repr on this version") def test_repr(self): Ts = TypeVarTuple('Ts') + Ts_co = TypeVarTuple('Ts_co', covariant=True) + Ts_contra = TypeVarTuple('Ts_contra', contravariant=True) + Ts_infer = TypeVarTuple('Ts_infer', infer_variance=True) + Ts_2 = TypeVarTuple('Ts_2') self.assertEqual(repr(Ts), 'Ts') + self.assertEqual(repr(Ts_2), 'Ts_2') - @skipUnless(TYPING_3_15_0, "repr changed in 3.15") + self.assertEqual(repr(Ts_co), 'Ts_co') + self.assertEqual(repr(Ts_contra), 'Ts_contra') + self.assertEqual(repr(Ts_infer), 'Ts_infer') + + @skipUnless(GOOD_TYPEVARTUPLE_REPR_EXPECTED, "TypeVarTuples have a bad repr on this version") def test_repr_py315(self): Ts = TypeVarTuple('Ts') + Ts_co = TypeVarTuple('Ts_co', covariant=True) + Ts_contra = TypeVarTuple('Ts_contra', contravariant=True) + Ts_infer = TypeVarTuple('Ts_infer', infer_variance=True) + Ts_2 = TypeVarTuple('Ts_2') self.assertEqual(repr(Ts), '~Ts') + self.assertEqual(repr(Ts_2), '~Ts_2') + + self.assertEqual(repr(Ts_co), '+Ts_co') + self.assertEqual(repr(Ts_contra), '-Ts_contra') + self.assertEqual(repr(Ts_infer), 'Ts_infer') + + def test_variance(self): + Ts_co = TypeVarTuple('Ts_co', covariant=True) + Ts_contra = TypeVarTuple('Ts_contra', contravariant=True) + Ts_infer = TypeVarTuple('Ts_infer', infer_variance=True) + + self.assertIs(Ts_co.__covariant__, True) + self.assertIs(Ts_co.__contravariant__, False) + self.assertIs(Ts_co.__infer_variance__, False) + + self.assertIs(Ts_contra.__covariant__, False) + self.assertIs(Ts_contra.__contravariant__, True) + self.assertIs(Ts_contra.__infer_variance__, False) + + self.assertIs(Ts_infer.__covariant__, False) + self.assertIs(Ts_infer.__contravariant__, False) + self.assertIs(Ts_infer.__infer_variance__, True) def test_no_redefinition(self): self.assertNotEqual(TypeVarTuple('Ts'), TypeVarTuple('Ts')) @@ -7145,6 +7198,10 @@ def test_typing_extensions_defers_when_possible(self): exclude |= { 'TypeAliasType', 'Protocol' } + if sys.version_info < (3, 15): + exclude |= { + 'TypeVarTuple' + } if not typing_extensions._PEP_728_IMPLEMENTED: exclude |= {'TypedDict', 'is_typeddict'} for item in typing_extensions.__all__: diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 64b2676b..59f4849c 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -1903,7 +1903,7 @@ def __new__(cls, name, *, bound=None, paramspec = typing.ParamSpec(name, bound=bound, covariant=covariant, contravariant=contravariant) - paramspec.__infer_variance__ = infer_variance + paramspec.__infer_variance__ = bool(infer_variance) _set_default(paramspec, default) _set_module(paramspec) @@ -1999,10 +1999,7 @@ def __init__(self, name, *, bound=None, covariant=False, contravariant=False, self.__covariant__ = bool(covariant) self.__contravariant__ = bool(contravariant) self.__infer_variance__ = bool(infer_variance) - if bound: - self.__bound__ = typing._type_check(bound, 'Bound must be a type.') - else: - self.__bound__ = None + self.__bound__ = bound _DefaultMixin.__init__(self, default) # for pickling: @@ -2650,20 +2647,33 @@ def _unpack_args(*args): return newargs -if _PEP_696_IMPLEMENTED: +if sys.version_info >= (3, 15): from typing import TypeVarTuple elif hasattr(typing, "TypeVarTuple"): # 3.11+ - # Add default parameter - PEP 696 + # Add default parameter - PEP 696 and bound/variance parameters class TypeVarTuple(metaclass=_TypeVarLikeMeta): """Type variable tuple.""" _backported_typevarlike = typing.TypeVarTuple - def __new__(cls, name, *, default=NoDefault): - tvt = typing.TypeVarTuple(name) - _set_default(tvt, default) + def __new__(cls, name, *, bound=None, + covariant=False, contravariant=False, + infer_variance=False, default=NoDefault): + + if _PEP_696_IMPLEMENTED: + # can pass default argument + tvt = typing.TypeVarTuple(name, default=default) + else: + tvt = typing.TypeVarTuple(name) + _set_default(tvt, default) + + tvt.__bound__ = bound + tvt.__covariant__ = bool(covariant) + tvt.__contravariant__ = bool(contravariant) + tvt.__infer_variance__ = bool(infer_variance) + _set_module(tvt) def _typevartuple_prepare_subst(alias, args): @@ -2768,8 +2778,13 @@ def get_shape(self) -> Tuple[*Ts]: def __iter__(self): yield self.__unpacked__ - def __init__(self, name, *, default=NoDefault): + def __init__(self, name, *, bound=None, covariant=False, contravariant=False, + infer_variance=False, default=NoDefault): self.__name__ = name + self.__covariant__ = bool(covariant) + self.__contravariant__ = bool(contravariant) + self.__infer_variance__ = bool(infer_variance) + self.__bound__ = bound _DefaultMixin.__init__(self, default) # for pickling: @@ -2780,7 +2795,15 @@ def __init__(self, name, *, default=NoDefault): self.__unpacked__ = Unpack[self] def __repr__(self): - return self.__name__ + if self.__infer_variance__: + prefix = '' + elif self.__covariant__: + prefix = '+' + elif self.__contravariant__: + prefix = '-' + else: + prefix = '~' + return prefix + self.__name__ def __hash__(self): return object.__hash__(self)