Skip to content
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

Implement PYI030: Unnecessary literal union #5570

Merged
merged 16 commits into from
Jul 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions crates/ruff/resources/test/fixtures/flake8_pyi/PYI030.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from typing import Literal
# Shouldn't emit for any cases in the non-stub file for compatibility with flake8-pyi.
# Note that this rule could be applied here in the future.

field1: Literal[1] # OK
field2: Literal[1] | Literal[2] # OK

def func1(arg1: Literal[1] | Literal[2]): # OK
print(arg1)


def func2() -> Literal[1] | Literal[2]: # OK
return "my Literal[1]ing"


field3: Literal[1] | Literal[2] | str # OK
field4: str | Literal[1] | Literal[2] # OK
field5: Literal[1] | str | Literal[2] # OK
field6: Literal[1] | bool | Literal[2] | str # OK
field7 = Literal[1] | Literal[2] # OK
field8: Literal[1] | (Literal[2] | str) # OK
field9: Literal[1] | (Literal[2] | str) # OK
field10: (Literal[1] | str) | Literal[2] # OK
field11: dict[Literal[1] | Literal[2], str] # OK
86 changes: 86 additions & 0 deletions crates/ruff/resources/test/fixtures/flake8_pyi/PYI030.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import typing
import typing_extensions
from typing import Literal

# Shouldn't affect non-union field types.
field1: Literal[1] # OK

# Should emit for duplicate field types.
field2: Literal[1] | Literal[2] # Error

# Should emit for union types in arguments.
def func1(arg1: Literal[1] | Literal[2]): # Error
print(arg1)


# Should emit for unions in return types.
def func2() -> Literal[1] | Literal[2]: # Error
return "my Literal[1]ing"


# Should emit in longer unions, even if not directly adjacent.
field3: Literal[1] | Literal[2] | str # Error
field4: str | Literal[1] | Literal[2] # Error
field5: Literal[1] | str | Literal[2] # Error
field6: Literal[1] | bool | Literal[2] | str # Error

# Should emit for non-type unions.
field7 = Literal[1] | Literal[2] # Error

# Should emit for parenthesized unions.
field8: Literal[1] | (Literal[2] | str) # Error

# Should handle user parentheses when fixing.
field9: Literal[1] | (Literal[2] | str) # Error
field10: (Literal[1] | str) | Literal[2] # Error

# Should emit for union in generic parent type.
field11: dict[Literal[1] | Literal[2], str] # Error

# Should emit for unions with more than two cases
field12: Literal[1] | Literal[2] | Literal[3] # Error
field13: Literal[1] | Literal[2] | Literal[3] | Literal[4] # Error

# Should emit for unions with more than two cases, even if not directly adjacent
field14: Literal[1] | Literal[2] | str | Literal[3] # Error

# Should emit for unions with mixed literal internal types
field15: Literal[1] | Literal["foo"] | Literal[True] # Error

# Shouldn't emit for duplicate field types with same value; covered by Y016
field16: Literal[1] | Literal[1] # OK

# Shouldn't emit if in new parent type
field17: Literal[1] | dict[Literal[2], str] # OK

# Shouldn't emit if not in a union parent
field18: dict[Literal[1], Literal[2]] # OK

# Should respect name of literal type used
field19: typing.Literal[1] | typing.Literal[2] # Error

# Should emit in cases with newlines
field20: typing.Union[
Literal[
1 # test
],
Literal[2],
] # Error, newline and comment will not be emitted in message

# Should handle multiple unions with multiple members
field21: Literal[1, 2] | Literal[3, 4] # Error

# Should emit in cases with `typing.Union` instead of `|`
field22: typing.Union[Literal[1], Literal[2]] # Error

# Should emit in cases with `typing_extensions.Literal`
field23: typing_extensions.Literal[1] | typing_extensions.Literal[2] # Error

# Should emit in cases with nested `typing.Union`
field24: typing.Union[Literal[1], typing.Union[Literal[2], str]] # Error

# Should emit in cases with mixed `typing.Union` and `|`
field25: typing.Union[Literal[1], Literal[2] | str] # Error

# Should emit only once in cases with multiple nested `typing.Union`
field24: typing.Union[Literal[1], typing.Union[Literal[2], typing.Union[Literal[3], Literal[4]]]] # Error
40 changes: 31 additions & 9 deletions crates/ruff/src/checkers/ast/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2189,6 +2189,22 @@ where
}
}

// Ex) Union[...]
if self.enabled(Rule::UnnecessaryLiteralUnion) {
let mut check = true;

// Avoid duplicate checks if the parent is an `Union[...]`
if let Some(Expr::Subscript(ast::ExprSubscript { value, .. })) =
self.semantic.expr_grandparent()
{
check = !self.semantic.match_typing_expr(value, "Union");
}

if check {
flake8_pyi::rules::unnecessary_literal_union(self, expr);
}
}

if self.semantic.match_typing_expr(value, "Literal") {
self.semantic.flags |= SemanticModelFlags::LITERAL;
}
Expand Down Expand Up @@ -3136,18 +3152,24 @@ where
if self.is_stub {
if self.enabled(Rule::DuplicateUnionMember)
&& self.semantic.in_type_definition()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may want to add these (&& self.semantic.in_type_definition()) to the Rule::UnnecessaryLiteralUnion condition too. I can't quite remember why they're necessary here. I'm guessing it's because for |, if it's used outside of a type definition, it's not a union operator (it's a bitwise or, I think).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's true for the DuplicateUnionMember rule where they're checking for any duplicate items in a | but here we know we are constrained to literal types where this does apply.

We test for this at

https://github.com/charliermarsh/ruff/blob/1759d914456b62dfc479518ce8717949935f6c87/crates/ruff/resources/test/fixtures/flake8_pyi/PYI030.pyi#L28

You can see that a bitwise or of literals resolve to a Union outside of an annotation:

In [3]: Literal[1] | Literal[2]
Out[3]: typing.Union[typing.Literal[1], typing.Literal[2]]

In [4]: x = Literal[1] | Literal[2]

In [5]: x
Out[5]: typing.Union[typing.Literal[1], typing.Literal[2]]

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess it's possible that someone has Literal[1] | bar | Literal[2] where bar implements some sort of custom bitwise or behavior and this rule no longer holds but I'm not sure that's worth handling

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, that makes sense. Thanks for the thorough follow-up!

&& self.semantic.expr_parent().map_or(true, |parent| {
!matches!(
parent,
Expr::BinOp(ast::ExprBinOp {
op: Operator::BitOr,
..
})
)
})
// Avoid duplicate checks if the parent is an `|`
&& !matches!(
self.semantic.expr_parent(),
Some(Expr::BinOp(ast::ExprBinOp { op: Operator::BitOr, ..}))
)
{
flake8_pyi::rules::duplicate_union_member(self, expr);
}

if self.enabled(Rule::UnnecessaryLiteralUnion)
// Avoid duplicate checks if the parent is an `|`
&& !matches!(
self.semantic.expr_parent(),
Some(Expr::BinOp(ast::ExprBinOp { op: Operator::BitOr, ..}))
)
{
flake8_pyi::rules::unnecessary_literal_union(self, expr);
}
}
}
Expr::UnaryOp(ast::ExprUnaryOp {
Expand Down
1 change: 1 addition & 0 deletions crates/ruff/src/codes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
(Flake8Pyi, "024") => (RuleGroup::Unspecified, rules::flake8_pyi::rules::CollectionsNamedTuple),
(Flake8Pyi, "025") => (RuleGroup::Unspecified, rules::flake8_pyi::rules::UnaliasedCollectionsAbcSetImport),
(Flake8Pyi, "029") => (RuleGroup::Unspecified, rules::flake8_pyi::rules::StrOrReprDefinedInStub),
(Flake8Pyi, "030") => (RuleGroup::Unspecified, rules::flake8_pyi::rules::UnnecessaryLiteralUnion),
(Flake8Pyi, "032") => (RuleGroup::Unspecified, rules::flake8_pyi::rules::AnyEqNeAnnotation),
(Flake8Pyi, "033") => (RuleGroup::Unspecified, rules::flake8_pyi::rules::TypeCommentInStub),
(Flake8Pyi, "034") => (RuleGroup::Unspecified, rules::flake8_pyi::rules::NonSelfReturnType),
Expand Down
2 changes: 2 additions & 0 deletions crates/ruff/src/rules/flake8_pyi/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ mod tests {
#[test_case(Rule::UnassignedSpecialVariableInStub, Path::new("PYI035.pyi"))]
#[test_case(Rule::StrOrReprDefinedInStub, Path::new("PYI029.py"))]
#[test_case(Rule::StrOrReprDefinedInStub, Path::new("PYI029.pyi"))]
#[test_case(Rule::UnnecessaryLiteralUnion, Path::new("PYI030.py"))]
#[test_case(Rule::UnnecessaryLiteralUnion, Path::new("PYI030.pyi"))]
#[test_case(Rule::StubBodyMultipleStatements, Path::new("PYI048.py"))]
#[test_case(Rule::StubBodyMultipleStatements, Path::new("PYI048.pyi"))]
#[test_case(Rule::TSuffixedTypeAlias, Path::new("PYI043.py"))]
Expand Down
2 changes: 2 additions & 0 deletions crates/ruff/src/rules/flake8_pyi/rules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub(crate) use stub_body_multiple_statements::*;
pub(crate) use type_alias_naming::*;
pub(crate) use type_comment_in_stub::*;
pub(crate) use unaliased_collections_abc_set_import::*;
pub(crate) use unnecessary_literal_union::*;
pub(crate) use unrecognized_platform::*;
pub(crate) use unrecognized_version_info::*;

Expand Down Expand Up @@ -49,5 +50,6 @@ mod stub_body_multiple_statements;
mod type_alias_naming;
mod type_comment_in_stub;
mod unaliased_collections_abc_set_import;
mod unnecessary_literal_union;
mod unrecognized_platform;
mod unrecognized_version_info;
120 changes: 120 additions & 0 deletions crates/ruff/src/rules/flake8_pyi/rules/unnecessary_literal_union.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
use ruff_python_semantic::SemanticModel;
use rustpython_parser::ast::{self, Expr, Operator, Ranged};

use ruff_diagnostics::{Diagnostic, Violation};
use ruff_macros::{derive_message_formats, violation};
use smallvec::SmallVec;

use crate::checkers::ast::Checker;

/// ## What it does
/// Checks for the presence of multiple literal types in a union.
///
/// ## Why is this bad?
/// Literal types accept multiple arguments and it is clearer to specify them
/// as a single literal.
///
/// ## Example
/// ```python
/// from typing import Literal
///
/// field: Literal[1] | Literal[2]
zanieb marked this conversation as resolved.
Show resolved Hide resolved
/// ```
///
/// Use instead:
/// ```python
/// from typing import Literal
///
/// field: Literal[1, 2]
/// ```
#[violation]
pub struct UnnecessaryLiteralUnion {
members: Vec<String>,
}

impl Violation for UnnecessaryLiteralUnion {
#[derive_message_formats]
fn message(&self) -> String {
format!(
"Multiple literal members in a union. Use a single literal, e.g. `Literal[{}]`",
self.members.join(", ")
)
}
}

/// PYI030
pub(crate) fn unnecessary_literal_union<'a>(checker: &mut Checker, expr: &'a Expr) {
let mut literal_exprs = SmallVec::<[&Box<Expr>; 1]>::new();

// Adds a member to `literal_exprs` if it is a `Literal` annotation
let mut collect_literal_expr = |expr: &'a Expr| {
if let Expr::Subscript(ast::ExprSubscript { value, slice, .. }) = expr {
if checker.semantic().match_typing_expr(value, "Literal") {
literal_exprs.push(slice);
}
}
};

// Traverse the union, collect all literal members
traverse_union(&mut collect_literal_expr, expr, checker.semantic());

// Raise a violation if more than one
if literal_exprs.len() > 1 {
let diagnostic = Diagnostic::new(
UnnecessaryLiteralUnion {
members: literal_exprs
.into_iter()
.map(|literal_expr| checker.locator.slice(literal_expr.range()).to_string())
.collect(),
},
expr.range(),
);

checker.diagnostics.push(diagnostic);
}
}

/// Traverse a "union" type annotation, calling `func` on each expression in the union.
fn traverse_union<'a, F>(func: &mut F, expr: &'a Expr, semantic: &SemanticModel)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a follow-up, we can probably use this in PYI016 (#3922) and fix support for typing.Union there

where
F: FnMut(&'a Expr),
{
// Ex) x | y
if let Expr::BinOp(ast::ExprBinOp {
op: Operator::BitOr,
left,
right,
range: _,
}) = 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 members
// in the order they appear in the source code.

// Traverse the left then right arms
traverse_union(func, left, semantic);
traverse_union(func, right, semantic);
return;
}

// Ex) Union[x, y]
if let Expr::Subscript(ast::ExprSubscript { value, slice, .. }) = expr {
if semantic.match_typing_expr(value, "Union") {
if let Expr::Tuple(ast::ExprTuple { elts, .. }) = slice.as_ref() {
// Traverse each element of the tuple within the union recursively to handle cases
// such as `Union[..., Union[...]]
elts.iter()
.for_each(|elt| traverse_union(func, elt, semantic));
return;
}
}
}

// Otherwise, call the function on expression
func(expr);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
source: crates/ruff/src/rules/flake8_pyi/mod.rs
---

Loading
Loading