-
Notifications
You must be signed in to change notification settings - Fork 1.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[flake8-pyi
] Implement duplicate types in unions (PYI016
)
#3922
Changes from 14 commits
05282d6
6e31ec7
0cccb24
69a45bd
9d22b5a
df965dc
177578a
336a250
03e7a97
64aa831
b11af21
8f97c76
22fe136
a71bb48
37825d9
ef4d9ce
5a5f633
d4a67a1
c6bd903
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
# Shouldn't affect non-union field types | ||
field1: str | ||
|
||
# Should emit for duplicate field types | ||
field2: str | str # PYI016 Duplicate union member `str` | ||
|
||
|
||
# Should emit for union types in arguments | ||
def func1(arg1: int | int): # PYI016 Duplicate union member `int` | ||
print(arg1) | ||
|
||
|
||
# Should emit for unions in return types | ||
def func2() -> str | str: # PYI016 Duplicate union member `str` | ||
return "my string" | ||
|
||
|
||
# Should emit in longer unions, even if not directly adjacent | ||
field3: str | str | int # PYI016 Duplicate union member `str` | ||
field4: int | int | str # PYI016 Duplicate union member `int` | ||
field5: str | int | str # PYI016 Duplicate union member `str` | ||
field6: int | bool | str | int # PYI016 Duplicate union member `int` | ||
|
||
# Shouldn't emit for non-type unions | ||
field7 = str | str | ||
|
||
# Should emit for strangely-bracketed unions | ||
field8: int | (str | int) # PYI016 Duplicate union member `int` | ||
|
||
# Should handle user brackets when autorixing | ||
field9: int | (int | str) # PYI016 Duplicate union member `int` | ||
field10: (str | int) | str # PYI016 Duplicate union member `str` |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
# Shouldn't affect non-union field types | ||
field1: str | ||
|
||
# Should emit for duplicate field types | ||
field2: str | str # PYI016 Duplicate union member `str` | ||
|
||
# Should emit for union types in arguments | ||
def func1(arg1: int | int): # PYI016 Duplicate union member `int` | ||
print(arg1) | ||
|
||
# Should emit for unions in return types | ||
def func2() -> str | str: # PYI016 Duplicate union member `str` | ||
return "my string" | ||
|
||
# Should emit in longer unions, even if not directly adjacent | ||
field3: str | str | int # PYI016 Duplicate union member `str` | ||
field4: int | int | str # PYI016 Duplicate union member `int` | ||
field5: str | int | str # PYI016 Duplicate union member `str` | ||
field6: int | bool | str | int # PYI016 Duplicate union member `int` | ||
|
||
# Shouldn't emit for non-type unions | ||
field7 = str | str | ||
|
||
# Should emit for strangely-bracketed unions | ||
field8: int | (str | int) # PYI016 Duplicate union member `int` | ||
|
||
# Should handle user brackets when autorixing | ||
field9: int | (int | str) # PYI016 Duplicate union member `int` | ||
field10: (str | int) | str # PYI016 Duplicate union member `str` |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,92 @@ | ||
use rustc_hash::FxHashSet; | ||
use rustpython_parser::ast::{Expr, ExprKind, Operator}; | ||
|
||
use ruff_diagnostics::{AlwaysAutofixableViolation, Diagnostic, Edit}; | ||
use ruff_macros::{derive_message_formats, violation}; | ||
use ruff_python_ast::comparable::ComparableExpr; | ||
use ruff_python_ast::helpers::unparse_expr; | ||
use ruff_python_ast::types::Range; | ||
|
||
use crate::checkers::ast::Checker; | ||
use crate::registry::AsRule; | ||
|
||
#[violation] | ||
pub struct DuplicateUnionMember { | ||
pub duplicate_name: String, | ||
} | ||
|
||
impl AlwaysAutofixableViolation for DuplicateUnionMember { | ||
#[derive_message_formats] | ||
fn message(&self) -> String { | ||
format!("Duplicate union member `{}`", self.duplicate_name) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Changed this to use the same message as |
||
} | ||
|
||
fn autofix_title(&self) -> String { | ||
format!("Remove duplicate union member `{}`", self.duplicate_name) | ||
} | ||
} | ||
|
||
/// PYI016 | ||
pub fn duplicate_union_member(checker: &mut Checker, expr: &Expr) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I tweaked this to use an inner helper, so that the caller doesn't have to worry or know about (e.g.) creating a hash set for internal use. |
||
let mut seen_nodes = FxHashSet::default(); | ||
traverse_union(&mut seen_nodes, checker, expr, None); | ||
} | ||
|
||
fn traverse_union<'a>( | ||
seen_nodes: &mut FxHashSet<ComparableExpr<'a>>, | ||
checker: &mut Checker, | ||
expr: &'a Expr, | ||
parent: Option<&'a Expr>, | ||
) { | ||
// The union data structure usually looks like this: | ||
// a | b | c -> (a | b) | c | ||
// | ||
// However, parenthesized expressions can coerce it into any structure: | ||
// a | (b | c) | ||
// | ||
// So we have to traverse both branches in order (left, then right), to report duplicates | ||
// in the order they appear in the source code. | ||
if let ExprKind::BinOp { | ||
op: Operator::BitOr, | ||
left, | ||
right, | ||
} = &expr.node | ||
{ | ||
// Traverse left subtree, then the right subtree, propagating the previous node. | ||
traverse_union(seen_nodes, checker, left, Some(expr)); | ||
traverse_union(seen_nodes, checker, right, Some(expr)); | ||
} | ||
|
||
// If we've already seen this union member, raise a violation. | ||
if !seen_nodes.insert(expr.into()) { | ||
let mut diagnostic = Diagnostic::new( | ||
DuplicateUnionMember { | ||
duplicate_name: unparse_expr(expr, checker.stylist), | ||
charliermarsh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
}, | ||
Range::from(expr), | ||
); | ||
if checker.patch(diagnostic.kind.rule()) { | ||
// Delete the "|" character as well as the duplicate value by reconstructing the | ||
// parent without the duplicate | ||
|
||
// SAFETY: impossible to have a duplicate without a `parent` node. | ||
let parent = parent.unwrap(); | ||
|
||
// SAFETY: Parent node must have been a BinOp in order for us to have traversed it | ||
let ExprKind::BinOp { left, right, .. } = &parent.node | ||
else { | ||
unreachable!(); | ||
}; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't know if there's a better way to do this unwrapping. |
||
|
||
// Replace parent with the non-duplicate of its children | ||
let fixed_parent = if expr.node == left.node { right } else { left }; | ||
|
||
diagnostic.set_fix(Edit::replacement( | ||
unparse_expr(fixed_parent, checker.stylist), | ||
parent.location, | ||
parent.end_location.unwrap(), | ||
)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this can lead to syntax errors in the presence of parentheses. E.g., if you try to autofix this, we throw a syntax error: field2: (str | int) | str # PYI016 Duplicate name in union I'm guessing the issue is that the previous field2: (str | int # PYI016 Duplicate name in union (I'm guessing here, I haven't looked at the erroneous source code.) As an alternative strategy, we could use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hm, and there is also: field: str | (str | int) Which would presumably get corrected to field: str | int) In the above case, we would either want to:
I just saw your edit about There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've hopefully fixed this issue, and I've added a couple test cases to the fixture for parentheses cases, which work as I'd expect: field9: int | (int | str) # PYI016 Duplicate union member `int`
field10: (str | int) | str # PYI016 Duplicate union member `str`
# Gets auto fixed to
field9: int | (str) # PYI016 Duplicate union member `int`
field10: str | int # PYI016 Duplicate union member `str` There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cool, this looks good to me! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, the approach here makes sense. I guess I was suggesting taking the top-level expression (the thing we pass into |
||
} | ||
checker.diagnostics.push(diagnostic); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
--- | ||
source: crates/ruff/src/rules/flake8_pyi/mod.rs | ||
expression: diagnostics | ||
--- | ||
[] | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh sorry -- I also changed this to
.pyi
only for consistency with the otherflake8-pyi
rules. I want to enable these for non-.pyi
files, but I want to do it all at once for the plugin.