From 9cebbaec5a213eab7d95db8916b0c3e29499d99c Mon Sep 17 00:00:00 2001 From: amir Date: Sat, 29 Aug 2026 23:08:21 +0330 Subject: [PATCH 1/2] [`pyupgrade`] Preserve grouping in multiline `UP040` fixes Rewriting a `TypeAliasType` assignment to a `type` statement drops the call, so a value that relied on the call's parentheses to span several lines became a syntax error: LongAlias = TypeAliasType( "LongAlias", int | str ) Build the right-hand side with `unwrapped_call_argument`, which restores the parentheses when the argument needs them and leaves the argument's own parentheses alone when it already has them. Fixes #28102 --- .../test/fixtures/pyupgrade/UP040.py | 17 ++++++ .../rules/pep695/non_pep695_type_alias.rs | 38 ++++++++----- ...er__rules__pyupgrade__tests__UP040.py.snap | 54 +++++++++++++++++++ ...pgrade__tests__UP040.py__preview_diff.snap | 4 ++ 4 files changed, 101 insertions(+), 12 deletions(-) diff --git a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP040.py b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP040.py index 1d7ed4faaba6b..81fe8f79d3856 100644 --- a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP040.py +++ b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP040.py @@ -132,3 +132,20 @@ class Foo: # Test case for TypeVar with default - should be converted when preview mode is enabled T_default = TypeVar("T_default", default=int) DefaultList: TypeAlias = list[T_default] + +# Preserve parentheses around a multiline union in a TypeAliasType call. +LongAlias = TypeAliasType( + "LongAlias", + int + | str + | float +) + +# An already parenthesized value keeps its own parentheses. +ParenAlias = TypeAliasType( + "ParenAlias", + ( + int + | str + ), +) diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_type_alias.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_type_alias.rs index b664b5e9d2cfc..c71a4746fe4db 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_type_alias.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_type_alias.rs @@ -5,6 +5,7 @@ use ruff_python_ast::name::Name; use ruff_python_ast::token::parenthesized_range; use ruff_python_ast::visitor::Visitor; use ruff_python_ast::{Expr, ExprCall, ExprName, Keyword, StmtAnnAssign, StmtAssign, StmtRef}; +use ruff_python_edits::unwrapped_call_argument; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; @@ -131,9 +132,11 @@ pub(crate) fn non_pep695_type_alias_type(checker: &Checker, stmt: &StmtAssign) { let StmtAssign { targets, value, .. } = stmt; - let Expr::Call(ExprCall { - func, arguments, .. - }) = value.as_ref() + let Expr::Call( + call @ ExprCall { + func, arguments, .. + }, + ) = value.as_ref() else { return; }; @@ -190,9 +193,20 @@ pub(crate) fn non_pep695_type_alias_type(checker: &Checker, stmt: &StmtAssign) { checker, stmt.into(), &target_name.id, - value, &vars, TypeAliasKind::TypeAliasType, + parenthesized_range(value.into(), arguments.into(), checker.tokens()) + .unwrap_or(value.range()), + // The call is dropped, so the argument may need parentheses of its own + // to keep its grouping once it spans several lines on the right-hand + // side of the `type` statement. + &unwrapped_call_argument( + call, + value, + Some(stmt.into()), + checker.tokens(), + checker.source(), + ), ); } @@ -240,13 +254,17 @@ pub(crate) fn non_pep695_type_alias(checker: &Checker, stmt: &StmtAnnAssign) { .unique_by(|tvar| tvar.name) .collect::>(); + let range_with_parentheses = + parenthesized_range(value.into(), stmt.into(), checker.tokens()).unwrap_or(value.range()); + create_diagnostic( checker, stmt.into(), name, - value, &vars, TypeAliasKind::TypeAlias, + range_with_parentheses, + &checker.source()[range_with_parentheses], ); } @@ -255,9 +273,10 @@ fn create_diagnostic( checker: &Checker, stmt: StmtRef, name: &Name, - value: &Expr, type_vars: &[TypeVar], type_alias_kind: TypeAliasKind, + range_with_parentheses: TextRange, + value_source: &str, ) { // If any type variables have defaults, skip the rule unless // running with preview mode enabled and targeting Python 3.13+. @@ -273,16 +292,11 @@ fn create_diagnostic( } let source = checker.source(); - let tokens = checker.tokens(); let comment_ranges = checker.comment_ranges(); - let range_with_parentheses = - parenthesized_range(value.into(), stmt.into(), tokens).unwrap_or(value.range()); - let content = format!( - "type {name}{type_params} = {value}", + "type {name}{type_params} = {value_source}", type_params = DisplayTypeVars { type_vars, source }, - value = &source[range_with_parentheses] ); let edit = Edit::range_replacement(content, stmt.range()); diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.py.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.py.snap index 2baabf904c267..56f3fc663832e 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.py.snap +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.py.snap @@ -436,3 +436,57 @@ help: Use the `type` keyword 123 | # comment1 | note: This is an unsafe fix and may change runtime behavior + +UP040 [*] Type alias `LongAlias` uses `TypeAliasType` assignment instead of the `type` keyword + --> UP040.py:137:1 + | +136 | # Preserve parentheses around a multiline union in a TypeAliasType call. +137 | / LongAlias = TypeAliasType( +138 | | "LongAlias", +139 | | int +140 | | | str +141 | | | float +142 | | ) + | |_^ +143 | +144 | # An already parenthesized value keeps its own parentheses. + | +help: Use the `type` keyword + | +136 | # Preserve parentheses around a multiline union in a TypeAliasType call. + - LongAlias = TypeAliasType( + - "LongAlias", + - int +137 + type LongAlias = (int +138 | | str + - | float + - ) +139 + | float) +140 | + | + +UP040 [*] Type alias `ParenAlias` uses `TypeAliasType` assignment instead of the `type` keyword + --> UP040.py:145:1 + | +144 | # An already parenthesized value keeps its own parentheses. +145 | / ParenAlias = TypeAliasType( +146 | | "ParenAlias", +147 | | ( +148 | | int +149 | | | str +150 | | ), +151 | | ) + | |_^ +help: Use the `type` keyword + | +144 | # An already parenthesized value keeps its own parentheses. + - ParenAlias = TypeAliasType( + - "ParenAlias", + - ( +145 + type ParenAlias = ( +146 | int +147 | | str + - ), + - ) +148 + ) + | diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.py__preview_diff.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.py__preview_diff.snap index 81fb3d356d626..39f91d74c4281 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.py__preview_diff.snap +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.py__preview_diff.snap @@ -56,10 +56,14 @@ UP040 [*] Type alias `DefaultList` uses `TypeAlias` annotation instead of the `t 133 | T_default = TypeVar("T_default", default=int) 134 | DefaultList: TypeAlias = list[T_default] | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +135 | +136 | # Preserve parentheses around a multiline union in a TypeAliasType call. + | help: Use the `type` keyword | 133 | T_default = TypeVar("T_default", default=int) - DefaultList: TypeAlias = list[T_default] 134 + type DefaultList[T_default = int] = list[T_default] +135 | | note: This is an unsafe fix and may change runtime behavior From fbb2e354066a44aa84d41dfc23d852739acae7b0 Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Tue, 15 Sep 2026 16:18:16 -0400 Subject: [PATCH 2/2] trim comment --- .../src/rules/pyupgrade/rules/pep695/non_pep695_type_alias.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_type_alias.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_type_alias.rs index 9171ae8e2c7c1..41bb1ffb91e2a 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_type_alias.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_type_alias.rs @@ -200,9 +200,6 @@ pub(crate) fn non_pep695_type_alias_type(checker: &Checker, stmt: &StmtAssign) { &target_name.id, &vars, TypeAliasKind::TypeAliasType, - // The call is dropped, so the argument may need parentheses of its own - // to keep its grouping once it spans several lines on the right-hand - // side of the `type` statement. &unwrapped_call_argument( call, value,