diff --git a/CHANGELOG.md b/CHANGELOG.md index 733505a5..c4a2232e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,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/src/test_typing_extensions.py b/src/test_typing_extensions.py index f07e1eb0..27932b45 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -1756,11 +1756,9 @@ 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]], - # Note: A starred *Ts will use typing.Unpack in 3.11+ see Issue #485 - Unpack[Ts] : Unpack[Ts], } # contains a ForwardRef, TypeVar(~prefix) or no expression do_not_stringify_cases = { @@ -1776,6 +1774,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], } if TYPING_3_10_0: # cannot construct UnionTypes before 3.10 do_not_stringify_cases["str | NoneAlias | StrAlias"] = str | None @@ -6552,7 +6552,10 @@ def test_basic_plain(self): def test_repr(self): Ts = TypeVarTuple('Ts') - self.assertEqual(repr(Unpack[Ts]), f'{Unpack.__module__}.Unpack[Ts]') + if not hasattr(typing, 'TypeVarTuple') or sys.version_info >= (3, 15): + self.assertEqual(repr(Unpack[Ts]), f'{Unpack.__module__}.Unpack[~Ts]') + else: + self.assertEqual(repr(Unpack[Ts]), f'{Unpack.__module__}.Unpack[Ts]') def test_cannot_subclass_vars(self): with self.assertRaises(TypeError): @@ -6750,7 +6753,44 @@ def test_basic_plain(self): def test_repr(self): Ts = TypeVarTuple('Ts') - self.assertEqual(repr(Ts), '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') + if not hasattr(typing, 'TypeVarTuple') or sys.version_info >= (3, 15): + 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') + else: + # On other versions we use typing.TypeVarTuple, but it is not aware of + # variance. Not worth creating our own version of TypeVarTuple + # for this. + 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')) @@ -7076,6 +7116,10 @@ def test_typing_extensions_defers_when_possible(self): exclude |= { 'TypeAliasType' } + 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 20c331ee..633d3991 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -1671,7 +1671,10 @@ def TypeAlias(self, parameters): def _set_default(type_param, default): type_param.has_default = lambda: default is not NoDefault - type_param.__default__ = default + if default is NoDefault: + type_param.__default__ = default + else: + type_param.__default__ = typing._type_check(default, "Default must be a type.") def _set_module(typevarlike): @@ -1824,7 +1827,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) @@ -2571,20 +2574,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__ = typing._type_check(bound, "Bound must be a type.") + tvt.__covariant__ = bool(covariant) + tvt.__contravariant__ = bool(contravariant) + tvt.__infer_variance__ = bool(infer_variance) + _set_module(tvt) def _typevartuple_prepare_subst(alias, args): @@ -2689,8 +2705,16 @@ 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) + if bound: + self.__bound__ = typing._type_check(bound, 'Bound must be a type.') + else: + self.__bound__ = None _DefaultMixin.__init__(self, default) # for pickling: @@ -2701,7 +2725,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)