Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
54 changes: 49 additions & 5 deletions src/test_typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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'))
Expand Down Expand Up @@ -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__:
Expand Down
50 changes: 41 additions & 9 deletions src/typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does this branch call bool() on the variance-related arguments and _type_check on bound and the above one doesn't? We should have things behave the same way across versions.

Copy link
Copy Markdown
Author

@KotlinIsland KotlinIsland Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the implementation of ParamSpec also has this discrepancy, i will update it accordingly

should _set_default also invoke _type_check?

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:
Expand All @@ -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)
Expand Down
Loading