Repository navigation
[pyupgrade] Make the fix for UP040 always unsafe - #28526
Merged
Merged
Conversation
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
Merging this PR will improve performance by 5.02%
|
| 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)
Footnotes
-
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. ↩
|
ntBre
marked this pull request as ready for review
September 11, 2026 17:41
MichaReiser
approved these changes
Sep 14, 2026
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
1 task
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This turned into a bit of a rabbit hole, and the very short summary is that the fix for
UP040is now always unsafe and documented as such. Read on for more details.UP040,UP046, andUP047all transform code using legacy type variables (typing.{TypeVar, TypeVarTuple, ParamSpec}) to use PEP-695 syntax instead (UP040also 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
Tis 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,
UP046andUP047already handle this pretty gracefully. Their fixes are always unsafe, and the limitations around imports are documented.UP047can't tell if an imported type is generic at all, so it simply doesn't emit a diagnostic for imported annotation types.UP046offers 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
UP040as the problematic case. Its fix forTypeAliases was also already unsafe in.pyfiles but safe in.pyifiles. Unfortunately, the fix can still cause problems in stub files in a case like this:The proposed safe fix is:
which is invalid: https://play.ty.dev/6385facd-2a61-4f3e-8961-9124c645643a because it mixes the old-style type variable with the new
typestatement instead of making thetypestatement itself generic:The example here uses
.pyfiles 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
TypeAliasTypewas 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
Tsas a regularTypeVarwith no bound instead of a boundedTypeVarTuple.I tried following
UP046and omitting the fix for imported type variables, but it was a little bit awkward for the current structure ofUP040. 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
UP040in 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