Skip to content

Commit bd30701

Browse files
authored
[flake8-pyi] Improve autofix for nested and mixed type unions unnecessary-type-union (PYI055) (#14272)
## Summary This PR improves the fix for `PYI055` to be able to handle nested and mixed type unions. It also marks the fix as unsafe when comments are present. <!-- What's the purpose of the change? What does it do, and why? --> ## Test Plan <!-- How was it tested? -->
1 parent 2b6d66b commit bd30701

File tree

5 files changed

+237
-136
lines changed

5 files changed

+237
-136
lines changed

crates/ruff_linter/resources/test/fixtures/flake8_pyi/PYI055.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ def func():
3030
# PYI055
3131
x: type[requests_mock.Mocker] | type[httpretty] | type[str] = requests_mock.Mocker
3232
y: Union[type[requests_mock.Mocker], type[httpretty], type[str]] = requests_mock.Mocker
33+
z: Union[ # comment
34+
type[requests_mock.Mocker], # another comment
35+
type[httpretty], type[str]] = requests_mock.Mocker
3336

3437

3538
def func():

crates/ruff_linter/resources/test/fixtures/flake8_pyi/PYI055.pyi

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,13 @@ z: Union[float, complex]
1616

1717
def func(arg: type[int, float] | str) -> None: ...
1818

19-
# OK
19+
# PYI055
2020
item: type[requests_mock.Mocker] | type[httpretty] = requests_mock.Mocker
2121

2222
def func():
2323
# PYI055
2424
item: type[requests_mock.Mocker] | type[httpretty] | type[str] = requests_mock.Mocker
2525
item2: Union[type[requests_mock.Mocker], type[httpretty], type[str]] = requests_mock.Mocker
26+
item3: Union[ # comment
27+
type[requests_mock.Mocker], # another comment
28+
type[httpretty], type[str]] = requests_mock.Mocker

crates/ruff_linter/src/rules/flake8_pyi/rules/unnecessary_type_union.rs

Lines changed: 104 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use ast::ExprContext;
2-
use ruff_diagnostics::{Diagnostic, Edit, Fix, FixAvailability, Violation};
2+
use ruff_diagnostics::{Applicability, Diagnostic, Edit, Fix, FixAvailability, Violation};
33
use ruff_macros::{derive_message_formats, violation};
44
use ruff_python_ast::helpers::pep_604_union;
55
use ruff_python_ast::name::Name;
@@ -25,21 +25,28 @@ use crate::checkers::ast::Checker;
2525
/// ```pyi
2626
/// field: type[int | float] | str
2727
/// ```
28+
///
29+
/// ## Fix safety
30+
///
31+
/// This rule's fix is marked as safe in most cases; however, the fix will
32+
/// flatten nested unions type expressions into a single top-level union.
33+
///
34+
/// The fix is marked as unsafe when comments are present within the type
35+
/// expression.
2836
#[violation]
2937
pub struct UnnecessaryTypeUnion {
3038
members: Vec<Name>,
31-
is_pep604_union: bool,
39+
union_kind: UnionKind,
3240
}
3341

3442
impl Violation for UnnecessaryTypeUnion {
3543
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes;
3644

3745
#[derive_message_formats]
3846
fn message(&self) -> String {
39-
let union_str = if self.is_pep604_union {
40-
self.members.join(" | ")
41-
} else {
42-
format!("Union[{}]", self.members.join(", "))
47+
let union_str = match self.union_kind {
48+
UnionKind::PEP604 => self.members.join(" | "),
49+
UnionKind::TypingUnion => format!("Union[{}]", self.members.join(", ")),
4350
};
4451

4552
format!(
@@ -63,43 +70,85 @@ pub(crate) fn unnecessary_type_union<'a>(checker: &mut Checker, union: &'a Expr)
6370

6471
// Check if `union` is a PEP604 union (e.g. `float | int`) or a `typing.Union[float, int]`
6572
let subscript = union.as_subscript_expr();
66-
if subscript.is_some_and(|subscript| !semantic.match_typing_expr(&subscript.value, "Union")) {
67-
return;
68-
}
73+
let mut union_kind = match subscript {
74+
Some(subscript) => {
75+
if !semantic.match_typing_expr(&subscript.value, "Union") {
76+
return;
77+
}
78+
UnionKind::TypingUnion
79+
}
80+
None => UnionKind::PEP604,
81+
};
6982

7083
let mut type_exprs: Vec<&Expr> = Vec::new();
7184
let mut other_exprs: Vec<&Expr> = Vec::new();
7285

73-
let mut collect_type_exprs = |expr: &'a Expr, _parent: &'a Expr| match expr {
74-
Expr::Subscript(ast::ExprSubscript { slice, value, .. }) => {
75-
if semantic.match_builtin_expr(value, "type") {
76-
type_exprs.push(slice);
77-
} else {
78-
other_exprs.push(expr);
86+
let mut collect_type_exprs = |expr: &'a Expr, parent: &'a Expr| {
87+
// If a PEP604-style union is used within a `typing.Union`, then the fix can
88+
// use PEP604-style unions.
89+
if matches!(parent, Expr::BinOp(_)) {
90+
union_kind = UnionKind::PEP604;
91+
}
92+
match expr {
93+
Expr::Subscript(ast::ExprSubscript { slice, value, .. }) => {
94+
if semantic.match_builtin_expr(value, "type") {
95+
type_exprs.push(slice);
96+
} else {
97+
other_exprs.push(expr);
98+
}
7999
}
100+
_ => other_exprs.push(expr),
80101
}
81-
_ => other_exprs.push(expr),
82102
};
83103

84104
traverse_union(&mut collect_type_exprs, semantic, union);
85105

86-
if type_exprs.len() > 1 {
87-
let type_members: Vec<Name> = type_exprs
88-
.clone()
89-
.into_iter()
90-
.map(|type_expr| Name::new(checker.locator().slice(type_expr)))
91-
.collect();
92-
93-
let mut diagnostic = Diagnostic::new(
94-
UnnecessaryTypeUnion {
95-
members: type_members.clone(),
96-
is_pep604_union: subscript.is_none(),
97-
},
98-
union.range(),
99-
);
100-
101-
if semantic.has_builtin_binding("type") {
102-
let content = if let Some(subscript) = subscript {
106+
// Return if zero or one `type` expressions are found.
107+
if type_exprs.len() <= 1 {
108+
return;
109+
}
110+
111+
let type_members: Vec<Name> = type_exprs
112+
.iter()
113+
.map(|type_expr| Name::new(checker.locator().slice(type_expr)))
114+
.collect();
115+
116+
let mut diagnostic = Diagnostic::new(
117+
UnnecessaryTypeUnion {
118+
members: type_members.clone(),
119+
union_kind,
120+
},
121+
union.range(),
122+
);
123+
124+
if semantic.has_builtin_binding("type") {
125+
// Construct the content for the [`Fix`] based on if we encountered a PEP604 union.
126+
let content = match union_kind {
127+
UnionKind::PEP604 => {
128+
let elts: Vec<Expr> = type_exprs.into_iter().cloned().collect();
129+
let types = Expr::Subscript(ast::ExprSubscript {
130+
value: Box::new(Expr::Name(ast::ExprName {
131+
id: Name::new_static("type"),
132+
ctx: ExprContext::Load,
133+
range: TextRange::default(),
134+
})),
135+
slice: Box::new(pep_604_union(&elts)),
136+
ctx: ExprContext::Load,
137+
range: TextRange::default(),
138+
});
139+
140+
if other_exprs.is_empty() {
141+
checker.generator().expr(&types)
142+
} else {
143+
let elts: Vec<Expr> = std::iter::once(types)
144+
.chain(other_exprs.into_iter().cloned())
145+
.collect();
146+
checker.generator().expr(&pep_604_union(&elts))
147+
}
148+
}
149+
UnionKind::TypingUnion => {
150+
// When subscript is None, it uses the pervious match case.
151+
let subscript = subscript.unwrap();
103152
let types = &Expr::Subscript(ast::ExprSubscript {
104153
value: Box::new(Expr::Name(ast::ExprName {
105154
id: Name::new_static("type"),
@@ -151,35 +200,29 @@ pub(crate) fn unnecessary_type_union<'a>(checker: &mut Checker, union: &'a Expr)
151200

152201
checker.generator().expr(&union)
153202
}
154-
} else {
155-
let elts: Vec<Expr> = type_exprs.into_iter().cloned().collect();
156-
let types = Expr::Subscript(ast::ExprSubscript {
157-
value: Box::new(Expr::Name(ast::ExprName {
158-
id: Name::new_static("type"),
159-
ctx: ExprContext::Load,
160-
range: TextRange::default(),
161-
})),
162-
slice: Box::new(pep_604_union(&elts)),
163-
ctx: ExprContext::Load,
164-
range: TextRange::default(),
165-
});
166-
167-
if other_exprs.is_empty() {
168-
checker.generator().expr(&types)
169-
} else {
170-
let elts: Vec<Expr> = std::iter::once(types)
171-
.chain(other_exprs.into_iter().cloned())
172-
.collect();
173-
checker.generator().expr(&pep_604_union(&elts))
174-
}
175-
};
203+
}
204+
};
176205

177-
diagnostic.set_fix(Fix::safe_edit(Edit::range_replacement(
178-
content,
179-
union.range(),
180-
)));
181-
}
206+
// Mark [`Fix`] as unsafe when comments are in range.
207+
let applicability = if checker.comment_ranges().intersects(union.range()) {
208+
Applicability::Unsafe
209+
} else {
210+
Applicability::Safe
211+
};
182212

183-
checker.diagnostics.push(diagnostic);
213+
diagnostic.set_fix(Fix::applicable_edit(
214+
Edit::range_replacement(content, union.range()),
215+
applicability,
216+
));
184217
}
218+
219+
checker.diagnostics.push(diagnostic);
220+
}
221+
222+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223+
enum UnionKind {
224+
/// E.g., `typing.Union[int, str]`
225+
TypingUnion,
226+
/// E.g., `int | str`
227+
PEP604,
185228
}

0 commit comments

Comments
 (0)