[pyupgrade] Skip TypeVarTuple and ParamSpec conversions with bounds or constraints (UP040, UP046, UP047) (#28505)

Summary
--

The main 3.15-related change here is that `TypeVarTuple` now supports
the `bound` argument, which
was previously only allowed for `TypeVar`s (and `ParamSpec`s, see
below):

```py
from typing import TypeVar, TypeVarTuple

T = TypeVar("T", bound=int)
Ts = TypeVarTuple("Ts", bound=int)  # new in 3.15
```

But PEP 695 doesn't include syntax for these bounds:

```py
def foo[T: int](): ...  # valid TypeVar version
def foo[*Ts: int](): ... # analogous but invalid TypeVarTuple syntax
```

This was actually a preexisting bug, where we'd introduce a syntax error
if someone included an
invalid `bound` on their `TypeVarTuple`:
https://play.ruff.rs/f984fc13-8936-446c-bfde-35be960a448d

but this is a bigger issue on 3.15 now that `bound` is a supported
argument without corresponding
PEP-695 syntax.

While reviewing this, I realized that the same issue applies to
`ParamSpec`, which has actually
supported `bound` since 3.10, but again, there is no analogous PEP-695
syntax:

```py
def foo[**P: int](): ... # analogous but also invalid
```

The same issues also apply to constraints, thus the new
`has_unsupported_restriction` applies to both `TypeVarTuple` and
`ParamSpec` with either bounds or constraints.

Test Plan
--

New mdtests for the affected rules
This commit is contained in:
Brent Westbrook authored and GitHub committed 2026-09-14 09:14:46 -04:00
1 parent a65f3d6a54
commit 677be7d4ef
3 files changed
+67

No files matched your search

@@ -165,3 +165,54 @@ help: Use type parameters
|
note: This is an unsafe fix and may change runtime behavior
```
## Bounded `TypeVarTuple`
Python 3.15 adds a `bound` argument to `TypeVarTuple`, but PEP 695 has no syntax to express it. These
rules emit no diagnostic for type aliases, classes, or functions that use a bounded `TypeVarTuple`.
```toml
target-version = "py315"
[lint]
select = ["UP040", "UP046", "UP047"]
```
```py
from typing import Generic, TypeAlias, TypeAliasType, TypeVarTuple
Ts = TypeVarTuple("Ts", bound=int)
BoundedAlias: TypeAlias = tuple[*Ts] # no diagnostic
BoundedAliasType = TypeAliasType("BoundedAliasType", tuple[*Ts], type_params=(Ts,)) # no diagnostic
class Bounded(Generic[*Ts]): ... # no diagnostic
def bounded(ts: tuple[*Ts]): ... # no diagnostic
```
## Bounded `ParamSpec`
`ParamSpec` accepts a `bound` argument starting in Python 3.10, but PEP 695 has no syntax to express it.
These rules emit no diagnostic for type aliases, classes, or functions that use a bounded `ParamSpec`.
```toml
target-version = "py312"
[lint]
select = ["UP040", "UP046", "UP047"]
```
```py
from collections.abc import Callable
from typing import Generic, ParamSpec, TypeAlias, TypeAliasType
P = ParamSpec("P", bound=int)
BoundedAlias: TypeAlias = Callable[P, int] # no diagnostic
BoundedAliasType = TypeAliasType("BoundedAliasType", Callable[P, int], type_params=(P,)) # no diagnostic
class Bounded(Generic[P]): ... # no diagnostic
def bounded(callback: Callable[P, int]): ... # no diagnostic
```
@@ -87,6 +87,14 @@ pub(crate) struct DisplayTypeVar<'a> {
}
impl TypeVar<'_> {
/// PEP 695 has no syntax for bounds or constraints on `TypeVarTuple` or `ParamSpec`.
fn has_unsupported_restriction(&self) -> bool {
matches!(
self.kind,
TypeParamKind::TypeVarTuple | TypeParamKind::ParamSpec
) && self.restriction.is_some()
}
fn display<'a>(&'a self, source: &'a str) -> DisplayTypeVar<'a> {
DisplayTypeVar {
type_var: self,
@@ -390,6 +398,10 @@ fn check_type_vars<'a>(vars: Vec<TypeVar<'a>>, checker: &Checker) -> Option<Vec<
return None;
}
if vars.iter().any(TypeVar::has_unsupported_restriction) {
return None;
}
// If any type variables have defaults, skip the rule unless
// running with preview mode enabled and targeting Python 3.13+.
if vars.iter().any(|tv| tv.default.is_some())
@@ -259,6 +259,10 @@ fn create_diagnostic(
type_vars: &[TypeVar],
type_alias_kind: TypeAliasKind,
) {
if type_vars.iter().any(TypeVar::has_unsupported_restriction) {
return;
}
// If any type variables have defaults, skip the rule unless
// running with preview mode enabled and targeting Python 3.13+.
if (checker.target_version() < PythonVersion::PY313