Skip to content

[pyupgrade] Make the fix for UP040 always unsafe - #28526

Merged
ntBre merged 1 commit into
brent/pep695-typevartuple-boundfrom
brent/pep695-fixes
Sep 14, 2026
Merged

ntBre merged 1 commit into
brent/pep695-typevartuple-boundfrom
brent/pep695-fixes

Conversation

@ntBre

@ntBre ntBre commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This turned into a bit of a rabbit hole, and the very short summary is that the fix for UP040 is now always unsafe and documented as such. Read on for more details.

UP040, UP046, and UP047 all transform code using legacy type variables (typing.{TypeVar, TypeVarTuple, ParamSpec}) to use PEP-695 syntax instead (UP040 also applies to non-generic type aliases but generics are the problem here). This means transformations like this:

 from typing import Generic, TypeVar, TypeAlias, TypeAliasType

 T = TypeVar("T")

 # UP040
-Alias: TypeAlias = list[T]
+type Alias[T] = list[T]

 # UP040
-AliasType = TypeAliasType("AliasType", set[T], type_params=(T,))
+type AliasType[T] = set[T]

 # UP046
-class C(Generic[T]): ...
+class C[T]: ...

 # UP047
-def f(x: T): ...
+def f[T](x: T): ...

This works very well if the type variable is defined in the same file. However, if T is imported from another file, it could have bounds, constraints, defaults, or a different type entirely that is mishandled by the autofix.

As it turns out, UP046 and UP047 already handle this pretty gracefully. Their fixes are always unsafe, and the limitations around imports are documented. UP047 can't tell if an imported type is generic at all, so it simply doesn't emit a diagnostic for imported annotation types. UP046 offers a diagnostic but not a fix. We could consider an unsafe or display-only fix, perhaps, but I think we omitted the fix as being even more unsafe than the normally unsafe fix.

That leaves UP040 as the problematic case. Its fix for TypeAliases was also already unsafe in .py files but safe in .pyi files. Unfortunately, the fix can still cause problems in stub files in a case like this:

from typing import TypeAlias

from somewhere import MyTypeVar

ListOfInt: TypeAlias = list[MyTypeVar]

The proposed safe fix is:

-ListOfInt: TypeAlias = list[MyTypeVar]
+type ListOfInt = list[MyTypeVar]

which is invalid: https://play.ty.dev/6385facd-2a61-4f3e-8961-9124c645643a because it mixes the old-style type variable with the new type statement instead of making the type statement itself generic:

type ListOfInt[MyTypeVar] = list[MyTypeVar]

The example here uses .py files and blocks for convenience, but the same safe fix is offered for stub files. Similarly, the playground link uses a bounded type variable, but the problem occurs even without bounds or other restraints.

Second, the fix for TypeAliasType was also safe, even for imported type variables. The problem here is very similar to that above. For imported type variables, the safe fix could lose bounds and constraints or even the kind of type variable. For example:

 from typing import TypeAliasType

 from somewhere import Ts

 # somewhere.py
 # from typing import TypeVarTuple
 # Ts = TypeVarTuple("Ts", bound=int)

-MyAliasType = TypeAliasType("MyAliasType", tuple[*Ts], type_params=(Ts,))
+type MyAliasType[Ts] = tuple[*Ts]

This presents Ts as a regular TypeVar with no bound instead of a bounded TypeVarTuple.

I tried following UP046 and omitting the fix for imported type variables, but it was a little bit awkward for the current structure of UP040. I think it's sufficient and less intrusive into the current implementation to mark it unsafe.

I also considered trying to retain safe fixes for UP040 in stub files when no imported types are involved, but making the fix always unsafe again seemed like the more consistent and simpler approach. From what I remember, none of these rules typically has much ecosystem impact anyway.

Test Plan

New mdtests and also the ecosystem check here

Summary
--

This turned into a bit of a rabbit hole, and the very short summary is that the fix for `UP040` is
now always unsafe and documented as such. Read on for more details.

`UP040`, `UP046`, and `UP047` all transform code using legacy type variables (`typing.{TypeVar,
TypeVarTuple, ParamSpec}`) to use PEP-695 syntax instead (`UP040` also applies to non-generic type
aliases but generics are the problem here). This means transformations like this:

```diff
 from typing import Generic, TypeVar, TypeAlias, TypeAliasType

 T = TypeVar("T")

 # UP040
-Alias: TypeAlias = list[T]
+type Alias[T] = list[T]

 # UP040
-AliasType = TypeAliasType("AliasType", set[T], type_params=(T,))  # UP040
+type AliasType[T] = set[T]

 # UP046
-class C(Generic[T]): ...
+class C[T]: ...

 # UP047
-def f(x: T): ...
+def f[T](x: T): ...
```

This works very well if the type variable is defined in the same file. However, if `T` is imported
from another file, it could have bounds, constraints, defaults, or a different type entirely that is
mishandled by the autofix.

As it turns out, `UP046` and `UP047` already handle this pretty gracefully. Their fixes are _always_
unsafe, and the limitations around imports are documented. `UP047` can't tell if an imported type is
generic at all, so it simply doesn't emit a diagnostic for imported annotation types. `UP046` offers
a diagnostic but not a fix. We _could_ consider an unsafe or display-only fix, perhaps, but I think
we omitted the fix as being even more unsafe than the normally unsafe fix.

That leaves `UP040` as the problematic case. Its fix for `TypeAlias`es was also already unsafe in
`.py` files but safe in `.pyi` files. Unfortunately, the fix can still cause problems in stub files
in a case like this:

```py
from typing import TypeAlias

from somewhere import MyTypeVar

ListOfInt: TypeAlias = list[MyTypeVar]
```

The proposed safe fix is:

```diff
-ListOfInt: TypeAlias = list[MyTypeVar]
+type ListOfInt = list[MyTypeVar]
```

which is invalid: https://play.ty.dev/6385facd-2a61-4f3e-8961-9124c645643a because it mixes the
old-style type variable with the new `type` statement instead of making the `type` statement itself
generic:

```py
type ListOfInt[MyTypeVar] = list[MyTypeVar]
```

The example here uses `.py` files and blocks for convenience, but the same safe fix is offered for
stub files. Similarly, the playground link uses a bounded type variable, but the problem occurs even
without bounds or other restraints.

Second, the fix for `TypeAliasType` was also safe, even for imported type variables. The problem
here is very similar to that above. For imported type variables, the safe fix could lose bounds and
constraints or even the kind of type variable.

I tried following `UP046` and omitting the fix for imported type variables, but it was a little bit
awkward for the current structure of `UP040`. I think it's sufficient and less intrusive into the
current implementation to mark it unsafe.

I also considered trying to retain safe fixes for `UP040` in stub files when no imported types are
involved, but making the fix always unsafe again seemed like the more consistent and simpler
approach. From what I remember, none of these rules typically has much ecosystem impact.

[#28505]: #28505
[PEP 695]: https://peps.python.org/pep-0695/

Test Plan
--

New mdtests and also the ecosystem check here
@ntBre
ntBre added this pull request to stack #28527 September 11, 2026 17:34
@ntBre ntBre added the fixes Related to suggested fixes for violations label Sep 11, 2026
@codspeed

codspeed Bot commented Sep 11, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 5.02%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 4 improved benchmarks
✅ 56 untouched benchmarks
⏩ 142 skipped benchmarks1

Performance Changes

Mode Benchmark BASE HEAD Efficiency
⚡ Memory parser[large/dataset.py] 682.7 KB 645.3 KB +5.79%
⚡ Memory parser[pydantic/types.py] 296.8 KB 281.3 KB +5.52%
⚡ Memory parser[unicode/pypinyin.py] 46.3 KB 44.3 KB +4.64%
⚡ Memory parser[numpy/ctypeslib.py] 140.4 KB 134.8 KB +4.16%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing brent/pep695-fixes (ec74b32) with brent/pep695-typevartuple-bound (58862d9)

Open in CodSpeed

Footnotes

  1. 142 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@astral-sh-bot

astral-sh-bot Bot commented Sep 11, 2026

Copy link
Copy Markdown

ruff-ecosystem results

Linter (stable)

✅ ecosystem check detected no linter changes.

Linter (preview)

✅ ecosystem check detected no linter changes.

@ntBre
ntBre marked this pull request as ready for review September 11, 2026 17:41
@ntBre
ntBre requested a review from MichaReiser September 11, 2026 17:43
@ntBre
ntBre merged commit 3ecea10 into main Sep 14, 2026
50 checks passed
@ntBre
ntBre deleted the brent/pep695-fixes branch September 14, 2026 13:14
carljm added a commit that referenced this pull request Sep 14, 2026
…aliases

* origin/main: (21 commits)
  [ty] Share attribute write compatibility checks (#28555)
  [ty] Prefer `ty.toml` and `pyproject.toml` over playground `ty.json` (#28449)
  [`ruff`, `flake8-use-pathlib`] Support `parent_mode` (`RUF064`, `PTH103`) (#28528)
  [`pyupgrade`] Make the fix for `UP040` always unsafe (#28526)
  [`pyupgrade`] Skip `TypeVarTuple` and `ParamSpec` conversions with bounds or constraints (`UP040`, `UP046`, `UP047`) (#28505)
  [ty] Escape glob characters in anchored directory paths (#28518)
  [ty] Handle gradual metaclass ancestry and conflicts (#28474)
  [ty] Preserve recursive metadata in union transformations (#28497)
  [ty] Refactor "desperate" import resolution to use candidate operations that will also apply to module enumeration. (#28543)
  [ty] Report override conflicts introduced by new bases (#28530)
  [ty] Resolve dependencies within correlated inference alternatives (#28252)
  [ty] Extract shared abstract-method analysis (#28537)
  [ty] Reject reassignment of enum members (#28462)
  [ty] Preserve runtime comparison semantics when narrowing tagged unions (#28053)
  [ty] Extract a method on `NameResolver` to advance resolution by single module name component. (#28540)
  [ty] Return root discovery to `NameResolver`, but retain necessary interface changes (#28536)
  [ty] Default-specialize class objects in meta-protocol checks (#28265)
  [ty] Extract module root discovery from `NameResolver`. (#28531)
  [ty] Fix MRO ordering for generic bases (#28172)
  [ty] Run benchmarks for auto-imports in CI (#28490)
  ...

# Conflicts:
#	crates/ty_python_semantic/src/types/narrow.rs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fixes Related to suggested fixes for violations

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants