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 @@ -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)

Expand Down
52 changes: 49 additions & 3 deletions src/test_typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1762,7 +1762,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]],
}
Expand All @@ -1780,6 +1780,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:
Expand Down Expand Up @@ -6610,7 +6612,10 @@ def test_basic_plain(self):
@skipIf(TYPING_3_15_0, "repr changed in 3.15")
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]')

@skipUnless(TYPING_3_15_0, "repr changed in 3.15")
def test_repr_py315(self):
Expand Down Expand Up @@ -6814,7 +6819,44 @@ def test_basic_plain(self):
@skipIf(TYPING_3_15_0, "repr changed in 3.15")
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)

@skipUnless(TYPING_3_15_0, "repr changed in 3.15")
def test_repr_py315(self):
Expand Down Expand Up @@ -7145,6 +7187,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__:
Expand Down
45 changes: 37 additions & 8 deletions src/typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -2650,20 +2650,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 @@ -2768,8 +2781,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.

@KotlinIsland KotlinIsland Apr 24, 2026

Copy link
Copy Markdown
Author

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 @@ -2780,7 +2801,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