From 26098b8d91e357150da30666c950eae541039834 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 7 Aug 2023 15:17:26 -0400 Subject: [PATCH] Extend nested union detection to handle bitwise or `Union` expressions (#6399) ## Summary We have some logic in the expression analyzer method to avoid re-checking the inner `Union` in `Union[Union[...]]`, since the methods that analyze `Union` expressions already recurse. Elsewhere, we have logic to avoid re-checking the inner `|` in `int | (int | str)`, for the same reason. This PR unifies that logic into a single method _and_ ensures that, just as we recurse over both `Union` and `|`, we also detect that we're in _either_ kind of nested union. Closes https://github.com/astral-sh/ruff/issues/6285. ## Test Plan Added some new snapshots. --- .../test/fixtures/flake8_pyi/PYI016.py | 57 +- .../test/fixtures/flake8_pyi/PYI016.pyi | 10 + .../src/checkers/ast/analyze/expression.rs | 50 +- ...__flake8_pyi__tests__PYI016_PYI016.py.snap | 588 ++++++++++++++---- ..._flake8_pyi__tests__PYI016_PYI016.pyi.snap | 59 ++ crates/ruff_python_semantic/src/model.rs | 30 +- 6 files changed, 627 insertions(+), 167 deletions(-) diff --git a/crates/ruff/resources/test/fixtures/flake8_pyi/PYI016.py b/crates/ruff/resources/test/fixtures/flake8_pyi/PYI016.py index 9c3b530edf..6b11efb181 100644 --- a/crates/ruff/resources/test/fixtures/flake8_pyi/PYI016.py +++ b/crates/ruff/resources/test/fixtures/flake8_pyi/PYI016.py @@ -1,20 +1,19 @@ +import typing + # 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` @@ -33,3 +32,55 @@ field10: (str | int) | str # PYI016: Duplicate union member `str` # Should emit for nested unions. field11: dict[int | int, str] + +# Should emit for unions with more than two cases +field12: int | int | int # Error +field13: int | int | int | int # Error + +# Should emit for unions with more than two cases, even if not directly adjacent +field14: int | int | str | int # Error + +# Should emit for duplicate literal types; also covered by PYI030 +field15: typing.Literal[1] | typing.Literal[1] # Error + +# Shouldn't emit if in new parent type +field16: int | dict[int, str] # OK + +# Shouldn't emit if not in a union parent +field17: dict[int, int] # OK + +# Should emit in cases with newlines +field18: typing.Union[ + set[ + int # foo + ], + set[ + int # bar + ], +] # Error, newline and comment will not be emitted in message + +# Should emit in cases with `typing.Union` instead of `|` +field19: typing.Union[int, int] # Error + +# Should emit in cases with nested `typing.Union` +field20: typing.Union[int, typing.Union[int, str]] # Error + +# Should emit in cases with mixed `typing.Union` and `|` +field21: typing.Union[int, int | str] # Error + +# Should emit only once in cases with multiple nested `typing.Union` +field22: typing.Union[int, typing.Union[int, typing.Union[int, int]]] # Error + +# Should emit in cases with newlines +field23: set[ # foo + int] | set[int] + +# Should emit twice (once for each `int` in the nested union, both of which are +# duplicates of the outer `int`), but not three times (which would indicate that +# we incorrectly re-checked the nested union). +field24: typing.Union[int, typing.Union[int, int]] # PYI016: Duplicate union member `int` + +# Should emit twice (once for each `int` in the nested union, both of which are +# duplicates of the outer `int`), but not three times (which would indicate that +# we incorrectly re-checked the nested union). +field25: typing.Union[int, int | int] # PYI016: Duplicate union member `int` diff --git a/crates/ruff/resources/test/fixtures/flake8_pyi/PYI016.pyi b/crates/ruff/resources/test/fixtures/flake8_pyi/PYI016.pyi index 1fe4d0a6c7..6b11efb181 100644 --- a/crates/ruff/resources/test/fixtures/flake8_pyi/PYI016.pyi +++ b/crates/ruff/resources/test/fixtures/flake8_pyi/PYI016.pyi @@ -74,3 +74,13 @@ field22: typing.Union[int, typing.Union[int, typing.Union[int, int]]] # Error # Should emit in cases with newlines field23: set[ # foo int] | set[int] + +# Should emit twice (once for each `int` in the nested union, both of which are +# duplicates of the outer `int`), but not three times (which would indicate that +# we incorrectly re-checked the nested union). +field24: typing.Union[int, typing.Union[int, int]] # PYI016: Duplicate union member `int` + +# Should emit twice (once for each `int` in the nested union, both of which are +# duplicates of the outer `int`), but not three times (which would indicate that +# we incorrectly re-checked the nested union). +field25: typing.Union[int, int | int] # PYI016: Duplicate union member `int` diff --git a/crates/ruff/src/checkers/ast/analyze/expression.rs b/crates/ruff/src/checkers/ast/analyze/expression.rs index e47b871f77..66e9678955 100644 --- a/crates/ruff/src/checkers/ast/analyze/expression.rs +++ b/crates/ruff/src/checkers/ast/analyze/expression.rs @@ -80,17 +80,9 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) { Rule::RedundantLiteralUnion, Rule::UnnecessaryTypeUnion, ]) { - // Avoid duplicate checks if the parent is an `Union[...]` since these rules + // Avoid duplicate checks if the parent is a union, since these rules already // traverse nested unions. - let is_unchecked_union = checker - .semantic - .current_expression_grandparent() - .and_then(Expr::as_subscript_expr) - .map_or(true, |parent| { - !checker.semantic.match_typing_expr(&parent.value, "Union") - }); - - if is_unchecked_union { + if !checker.semantic.in_nested_union() { if checker.enabled(Rule::UnnecessaryLiteralUnion) { flake8_pyi::rules::unnecessary_literal_union(checker, expr); } @@ -1084,29 +1076,23 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) { } } - // Avoid duplicate checks if the parent is an `|` since these rules + // Avoid duplicate checks if the parent is a union, since these rules already // traverse nested unions. - let is_unchecked_union = !matches!( - checker.semantic.current_expression_parent(), - Some(Expr::BinOp(ast::ExprBinOp { - op: Operator::BitOr, - .. - })) - ); - if checker.enabled(Rule::DuplicateUnionMember) - && checker.semantic.in_type_definition() - && is_unchecked_union - { - flake8_pyi::rules::duplicate_union_member(checker, expr); - } - if checker.enabled(Rule::UnnecessaryLiteralUnion) && is_unchecked_union { - flake8_pyi::rules::unnecessary_literal_union(checker, expr); - } - if checker.enabled(Rule::RedundantLiteralUnion) && is_unchecked_union { - flake8_pyi::rules::redundant_literal_union(checker, expr); - } - if checker.enabled(Rule::UnnecessaryTypeUnion) && is_unchecked_union { - flake8_pyi::rules::unnecessary_type_union(checker, expr); + if !checker.semantic.in_nested_union() { + if checker.enabled(Rule::DuplicateUnionMember) + && checker.semantic.in_type_definition() + { + flake8_pyi::rules::duplicate_union_member(checker, expr); + } + if checker.enabled(Rule::UnnecessaryLiteralUnion) { + flake8_pyi::rules::unnecessary_literal_union(checker, expr); + } + if checker.enabled(Rule::RedundantLiteralUnion) { + flake8_pyi::rules::redundant_literal_union(checker, expr); + } + if checker.enabled(Rule::UnnecessaryTypeUnion) { + flake8_pyi::rules::unnecessary_type_union(checker, expr); + } } } Expr::UnaryOp(ast::ExprUnaryOp { diff --git a/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI016_PYI016.py.snap b/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI016_PYI016.py.snap index 23b1b08e70..abc1d3226a 100644 --- a/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI016_PYI016.py.snap +++ b/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI016_PYI016.py.snap @@ -1,42 +1,44 @@ --- source: crates/ruff/src/rules/flake8_pyi/mod.rs --- -PYI016.py:5:15: PYI016 [*] Duplicate union member `str` +PYI016.py:7:15: PYI016 [*] Duplicate union member `str` | -4 | # Should emit for duplicate field types. -5 | field2: str | str # PYI016: Duplicate union member `str` +6 | # Should emit for duplicate field types. +7 | field2: str | str # PYI016: Duplicate union member `str` | ^^^ PYI016 +8 | +9 | # Should emit for union types in arguments. | = help: Remove duplicate union member `str` ℹ Fix -2 2 | field1: str -3 3 | -4 4 | # Should emit for duplicate field types. -5 |-field2: str | str # PYI016: Duplicate union member `str` - 5 |+field2: str # PYI016: Duplicate union member `str` -6 6 | -7 7 | -8 8 | # Should emit for union types in arguments. +4 4 | field1: str +5 5 | +6 6 | # Should emit for duplicate field types. +7 |-field2: str | str # PYI016: Duplicate union member `str` + 7 |+field2: str # PYI016: Duplicate union member `str` +8 8 | +9 9 | # Should emit for union types in arguments. +10 10 | def func1(arg1: int | int): # PYI016: Duplicate union member `int` -PYI016.py:9:23: PYI016 [*] Duplicate union member `int` +PYI016.py:10:23: PYI016 [*] Duplicate union member `int` | - 8 | # Should emit for union types in arguments. - 9 | def func1(arg1: int | int): # PYI016: Duplicate union member `int` + 9 | # Should emit for union types in arguments. +10 | def func1(arg1: int | int): # PYI016: Duplicate union member `int` | ^^^ PYI016 -10 | print(arg1) +11 | print(arg1) | = help: Remove duplicate union member `int` ℹ Fix -6 6 | -7 7 | -8 8 | # Should emit for union types in arguments. -9 |-def func1(arg1: int | int): # PYI016: Duplicate union member `int` - 9 |+def func1(arg1: int): # PYI016: Duplicate union member `int` -10 10 | print(arg1) -11 11 | +7 7 | field2: str | str # PYI016: Duplicate union member `str` +8 8 | +9 9 | # Should emit for union types in arguments. +10 |-def func1(arg1: int | int): # PYI016: Duplicate union member `int` + 10 |+def func1(arg1: int): # PYI016: Duplicate union member `int` +11 11 | print(arg1) 12 12 | +13 13 | # Should emit for unions in return types. PYI016.py:14:22: PYI016 [*] Duplicate union member `str` | @@ -48,170 +50,494 @@ PYI016.py:14:22: PYI016 [*] Duplicate union member `str` = help: Remove duplicate union member `str` ℹ Fix -11 11 | +11 11 | print(arg1) 12 12 | 13 13 | # Should emit for unions in return types. 14 |-def func2() -> str | str: # PYI016: Duplicate union member `str` 14 |+def func2() -> str: # PYI016: Duplicate union member `str` 15 15 | return "my string" 16 16 | -17 17 | +17 17 | # Should emit in longer unions, even if not directly adjacent. -PYI016.py:19:15: PYI016 [*] Duplicate union member `str` +PYI016.py:18:15: PYI016 [*] Duplicate union member `str` | -18 | # Should emit in longer unions, even if not directly adjacent. -19 | field3: str | str | int # PYI016: Duplicate union member `str` +17 | # Should emit in longer unions, even if not directly adjacent. +18 | field3: str | str | int # PYI016: Duplicate union member `str` | ^^^ PYI016 -20 | field4: int | int | str # PYI016: Duplicate union member `int` -21 | field5: str | int | str # PYI016: Duplicate union member `str` +19 | field4: int | int | str # PYI016: Duplicate union member `int` +20 | field5: str | int | str # PYI016: Duplicate union member `str` | = help: Remove duplicate union member `str` +ℹ Fix +15 15 | return "my string" +16 16 | +17 17 | # Should emit in longer unions, even if not directly adjacent. +18 |-field3: str | str | int # PYI016: Duplicate union member `str` + 18 |+field3: str | int # PYI016: Duplicate union member `str` +19 19 | field4: int | int | str # PYI016: Duplicate union member `int` +20 20 | field5: str | int | str # PYI016: Duplicate union member `str` +21 21 | field6: int | bool | str | int # PYI016: Duplicate union member `int` + +PYI016.py:19:15: PYI016 [*] Duplicate union member `int` + | +17 | # Should emit in longer unions, even if not directly adjacent. +18 | field3: str | str | int # PYI016: Duplicate union member `str` +19 | field4: int | int | str # PYI016: Duplicate union member `int` + | ^^^ PYI016 +20 | field5: str | int | str # PYI016: Duplicate union member `str` +21 | field6: int | bool | str | int # PYI016: Duplicate union member `int` + | + = help: Remove duplicate union member `int` + ℹ Fix 16 16 | -17 17 | -18 18 | # Should emit in longer unions, even if not directly adjacent. -19 |-field3: str | str | int # PYI016: Duplicate union member `str` - 19 |+field3: str | int # PYI016: Duplicate union member `str` -20 20 | field4: int | int | str # PYI016: Duplicate union member `int` -21 21 | field5: str | int | str # PYI016: Duplicate union member `str` -22 22 | field6: int | bool | str | int # PYI016: Duplicate union member `int` +17 17 | # Should emit in longer unions, even if not directly adjacent. +18 18 | field3: str | str | int # PYI016: Duplicate union member `str` +19 |-field4: int | int | str # PYI016: Duplicate union member `int` + 19 |+field4: int | str # PYI016: Duplicate union member `int` +20 20 | field5: str | int | str # PYI016: Duplicate union member `str` +21 21 | field6: int | bool | str | int # PYI016: Duplicate union member `int` +22 22 | -PYI016.py:20:15: PYI016 [*] Duplicate union member `int` +PYI016.py:20:21: PYI016 [*] Duplicate union member `str` | -18 | # Should emit in longer unions, even if not directly adjacent. -19 | field3: str | str | int # PYI016: Duplicate union member `str` -20 | field4: int | int | str # PYI016: Duplicate union member `int` - | ^^^ PYI016 -21 | field5: str | int | str # PYI016: Duplicate union member `str` -22 | field6: int | bool | str | int # PYI016: Duplicate union member `int` - | - = help: Remove duplicate union member `int` - -ℹ Fix -17 17 | -18 18 | # Should emit in longer unions, even if not directly adjacent. -19 19 | field3: str | str | int # PYI016: Duplicate union member `str` -20 |-field4: int | int | str # PYI016: Duplicate union member `int` - 20 |+field4: int | str # PYI016: Duplicate union member `int` -21 21 | field5: str | int | str # PYI016: Duplicate union member `str` -22 22 | field6: int | bool | str | int # PYI016: Duplicate union member `int` -23 23 | - -PYI016.py:21:21: PYI016 [*] Duplicate union member `str` - | -19 | field3: str | str | int # PYI016: Duplicate union member `str` -20 | field4: int | int | str # PYI016: Duplicate union member `int` -21 | field5: str | int | str # PYI016: Duplicate union member `str` +18 | field3: str | str | int # PYI016: Duplicate union member `str` +19 | field4: int | int | str # PYI016: Duplicate union member `int` +20 | field5: str | int | str # PYI016: Duplicate union member `str` | ^^^ PYI016 -22 | field6: int | bool | str | int # PYI016: Duplicate union member `int` +21 | field6: int | bool | str | int # PYI016: Duplicate union member `int` | = help: Remove duplicate union member `str` ℹ Fix -18 18 | # Should emit in longer unions, even if not directly adjacent. -19 19 | field3: str | str | int # PYI016: Duplicate union member `str` -20 20 | field4: int | int | str # PYI016: Duplicate union member `int` -21 |-field5: str | int | str # PYI016: Duplicate union member `str` - 21 |+field5: str | int # PYI016: Duplicate union member `str` -22 22 | field6: int | bool | str | int # PYI016: Duplicate union member `int` -23 23 | -24 24 | # Shouldn't emit for non-type unions. +17 17 | # Should emit in longer unions, even if not directly adjacent. +18 18 | field3: str | str | int # PYI016: Duplicate union member `str` +19 19 | field4: int | int | str # PYI016: Duplicate union member `int` +20 |-field5: str | int | str # PYI016: Duplicate union member `str` + 20 |+field5: str | int # PYI016: Duplicate union member `str` +21 21 | field6: int | bool | str | int # PYI016: Duplicate union member `int` +22 22 | +23 23 | # Shouldn't emit for non-type unions. -PYI016.py:22:28: PYI016 [*] Duplicate union member `int` +PYI016.py:21:28: PYI016 [*] Duplicate union member `int` | -20 | field4: int | int | str # PYI016: Duplicate union member `int` -21 | field5: str | int | str # PYI016: Duplicate union member `str` -22 | field6: int | bool | str | int # PYI016: Duplicate union member `int` +19 | field4: int | int | str # PYI016: Duplicate union member `int` +20 | field5: str | int | str # PYI016: Duplicate union member `str` +21 | field6: int | bool | str | int # PYI016: Duplicate union member `int` | ^^^ PYI016 -23 | -24 | # Shouldn't emit for non-type unions. +22 | +23 | # Shouldn't emit for non-type unions. | = help: Remove duplicate union member `int` ℹ Fix -19 19 | field3: str | str | int # PYI016: Duplicate union member `str` -20 20 | field4: int | int | str # PYI016: Duplicate union member `int` -21 21 | field5: str | int | str # PYI016: Duplicate union member `str` -22 |-field6: int | bool | str | int # PYI016: Duplicate union member `int` - 22 |+field6: int | bool | str # PYI016: Duplicate union member `int` -23 23 | -24 24 | # Shouldn't emit for non-type unions. -25 25 | field7 = str | str +18 18 | field3: str | str | int # PYI016: Duplicate union member `str` +19 19 | field4: int | int | str # PYI016: Duplicate union member `int` +20 20 | field5: str | int | str # PYI016: Duplicate union member `str` +21 |-field6: int | bool | str | int # PYI016: Duplicate union member `int` + 21 |+field6: int | bool | str # PYI016: Duplicate union member `int` +22 22 | +23 23 | # Shouldn't emit for non-type unions. +24 24 | field7 = str | str -PYI016.py:28:22: PYI016 [*] Duplicate union member `int` +PYI016.py:27:22: PYI016 [*] Duplicate union member `int` | -27 | # Should emit for strangely-bracketed unions. -28 | field8: int | (str | int) # PYI016: Duplicate union member `int` +26 | # Should emit for strangely-bracketed unions. +27 | field8: int | (str | int) # PYI016: Duplicate union member `int` | ^^^ PYI016 -29 | -30 | # Should handle user brackets when fixing. +28 | +29 | # Should handle user brackets when fixing. | = help: Remove duplicate union member `int` ℹ Fix -25 25 | field7 = str | str -26 26 | -27 27 | # Should emit for strangely-bracketed unions. -28 |-field8: int | (str | int) # PYI016: Duplicate union member `int` - 28 |+field8: int | (str) # PYI016: Duplicate union member `int` -29 29 | -30 30 | # Should handle user brackets when fixing. -31 31 | field9: int | (int | str) # PYI016: Duplicate union member `int` +24 24 | field7 = str | str +25 25 | +26 26 | # Should emit for strangely-bracketed unions. +27 |-field8: int | (str | int) # PYI016: Duplicate union member `int` + 27 |+field8: int | (str) # PYI016: Duplicate union member `int` +28 28 | +29 29 | # Should handle user brackets when fixing. +30 30 | field9: int | (int | str) # PYI016: Duplicate union member `int` -PYI016.py:31:16: PYI016 [*] Duplicate union member `int` +PYI016.py:30:16: PYI016 [*] Duplicate union member `int` | -30 | # Should handle user brackets when fixing. -31 | field9: int | (int | str) # PYI016: Duplicate union member `int` +29 | # Should handle user brackets when fixing. +30 | field9: int | (int | str) # PYI016: Duplicate union member `int` | ^^^ PYI016 -32 | field10: (str | int) | str # PYI016: Duplicate union member `str` +31 | field10: (str | int) | str # PYI016: Duplicate union member `str` | = help: Remove duplicate union member `int` ℹ Fix -28 28 | field8: int | (str | int) # PYI016: Duplicate union member `int` -29 29 | -30 30 | # Should handle user brackets when fixing. -31 |-field9: int | (int | str) # PYI016: Duplicate union member `int` - 31 |+field9: int | (str) # PYI016: Duplicate union member `int` -32 32 | field10: (str | int) | str # PYI016: Duplicate union member `str` -33 33 | -34 34 | # Should emit for nested unions. +27 27 | field8: int | (str | int) # PYI016: Duplicate union member `int` +28 28 | +29 29 | # Should handle user brackets when fixing. +30 |-field9: int | (int | str) # PYI016: Duplicate union member `int` + 30 |+field9: int | (str) # PYI016: Duplicate union member `int` +31 31 | field10: (str | int) | str # PYI016: Duplicate union member `str` +32 32 | +33 33 | # Should emit for nested unions. -PYI016.py:32:24: PYI016 [*] Duplicate union member `str` +PYI016.py:31:24: PYI016 [*] Duplicate union member `str` | -30 | # Should handle user brackets when fixing. -31 | field9: int | (int | str) # PYI016: Duplicate union member `int` -32 | field10: (str | int) | str # PYI016: Duplicate union member `str` +29 | # Should handle user brackets when fixing. +30 | field9: int | (int | str) # PYI016: Duplicate union member `int` +31 | field10: (str | int) | str # PYI016: Duplicate union member `str` | ^^^ PYI016 -33 | -34 | # Should emit for nested unions. +32 | +33 | # Should emit for nested unions. | = help: Remove duplicate union member `str` ℹ Fix -29 29 | -30 30 | # Should handle user brackets when fixing. -31 31 | field9: int | (int | str) # PYI016: Duplicate union member `int` -32 |-field10: (str | int) | str # PYI016: Duplicate union member `str` - 32 |+field10: str | int # PYI016: Duplicate union member `str` -33 33 | -34 34 | # Should emit for nested unions. -35 35 | field11: dict[int | int, str] +28 28 | +29 29 | # Should handle user brackets when fixing. +30 30 | field9: int | (int | str) # PYI016: Duplicate union member `int` +31 |-field10: (str | int) | str # PYI016: Duplicate union member `str` + 31 |+field10: str | int # PYI016: Duplicate union member `str` +32 32 | +33 33 | # Should emit for nested unions. +34 34 | field11: dict[int | int, str] -PYI016.py:35:21: PYI016 [*] Duplicate union member `int` +PYI016.py:34:21: PYI016 [*] Duplicate union member `int` | -34 | # Should emit for nested unions. -35 | field11: dict[int | int, str] +33 | # Should emit for nested unions. +34 | field11: dict[int | int, str] | ^^^ PYI016 +35 | +36 | # Should emit for unions with more than two cases | = help: Remove duplicate union member `int` ℹ Fix -32 32 | field10: (str | int) | str # PYI016: Duplicate union member `str` -33 33 | -34 34 | # Should emit for nested unions. -35 |-field11: dict[int | int, str] - 35 |+field11: dict[int, str] +31 31 | field10: (str | int) | str # PYI016: Duplicate union member `str` +32 32 | +33 33 | # Should emit for nested unions. +34 |-field11: dict[int | int, str] + 34 |+field11: dict[int, str] +35 35 | +36 36 | # Should emit for unions with more than two cases +37 37 | field12: int | int | int # Error + +PYI016.py:37:16: PYI016 [*] Duplicate union member `int` + | +36 | # Should emit for unions with more than two cases +37 | field12: int | int | int # Error + | ^^^ PYI016 +38 | field13: int | int | int | int # Error + | + = help: Remove duplicate union member `int` + +ℹ Fix +34 34 | field11: dict[int | int, str] +35 35 | +36 36 | # Should emit for unions with more than two cases +37 |-field12: int | int | int # Error + 37 |+field12: int | int # Error +38 38 | field13: int | int | int | int # Error +39 39 | +40 40 | # Should emit for unions with more than two cases, even if not directly adjacent + +PYI016.py:37:22: PYI016 [*] Duplicate union member `int` + | +36 | # Should emit for unions with more than two cases +37 | field12: int | int | int # Error + | ^^^ PYI016 +38 | field13: int | int | int | int # Error + | + = help: Remove duplicate union member `int` + +ℹ Fix +34 34 | field11: dict[int | int, str] +35 35 | +36 36 | # Should emit for unions with more than two cases +37 |-field12: int | int | int # Error + 37 |+field12: int | int # Error +38 38 | field13: int | int | int | int # Error +39 39 | +40 40 | # Should emit for unions with more than two cases, even if not directly adjacent + +PYI016.py:38:16: PYI016 [*] Duplicate union member `int` + | +36 | # Should emit for unions with more than two cases +37 | field12: int | int | int # Error +38 | field13: int | int | int | int # Error + | ^^^ PYI016 +39 | +40 | # Should emit for unions with more than two cases, even if not directly adjacent + | + = help: Remove duplicate union member `int` + +ℹ Fix +35 35 | +36 36 | # Should emit for unions with more than two cases +37 37 | field12: int | int | int # Error +38 |-field13: int | int | int | int # Error + 38 |+field13: int | int | int # Error +39 39 | +40 40 | # Should emit for unions with more than two cases, even if not directly adjacent +41 41 | field14: int | int | str | int # Error + +PYI016.py:38:22: PYI016 [*] Duplicate union member `int` + | +36 | # Should emit for unions with more than two cases +37 | field12: int | int | int # Error +38 | field13: int | int | int | int # Error + | ^^^ PYI016 +39 | +40 | # Should emit for unions with more than two cases, even if not directly adjacent + | + = help: Remove duplicate union member `int` + +ℹ Fix +35 35 | +36 36 | # Should emit for unions with more than two cases +37 37 | field12: int | int | int # Error +38 |-field13: int | int | int | int # Error + 38 |+field13: int | int | int # Error +39 39 | +40 40 | # Should emit for unions with more than two cases, even if not directly adjacent +41 41 | field14: int | int | str | int # Error + +PYI016.py:38:28: PYI016 [*] Duplicate union member `int` + | +36 | # Should emit for unions with more than two cases +37 | field12: int | int | int # Error +38 | field13: int | int | int | int # Error + | ^^^ PYI016 +39 | +40 | # Should emit for unions with more than two cases, even if not directly adjacent + | + = help: Remove duplicate union member `int` + +ℹ Fix +35 35 | +36 36 | # Should emit for unions with more than two cases +37 37 | field12: int | int | int # Error +38 |-field13: int | int | int | int # Error + 38 |+field13: int | int | int # Error +39 39 | +40 40 | # Should emit for unions with more than two cases, even if not directly adjacent +41 41 | field14: int | int | str | int # Error + +PYI016.py:41:16: PYI016 [*] Duplicate union member `int` + | +40 | # Should emit for unions with more than two cases, even if not directly adjacent +41 | field14: int | int | str | int # Error + | ^^^ PYI016 +42 | +43 | # Should emit for duplicate literal types; also covered by PYI030 + | + = help: Remove duplicate union member `int` + +ℹ Fix +38 38 | field13: int | int | int | int # Error +39 39 | +40 40 | # Should emit for unions with more than two cases, even if not directly adjacent +41 |-field14: int | int | str | int # Error + 41 |+field14: int | str | int # Error +42 42 | +43 43 | # Should emit for duplicate literal types; also covered by PYI030 +44 44 | field15: typing.Literal[1] | typing.Literal[1] # Error + +PYI016.py:41:28: PYI016 [*] Duplicate union member `int` + | +40 | # Should emit for unions with more than two cases, even if not directly adjacent +41 | field14: int | int | str | int # Error + | ^^^ PYI016 +42 | +43 | # Should emit for duplicate literal types; also covered by PYI030 + | + = help: Remove duplicate union member `int` + +ℹ Fix +38 38 | field13: int | int | int | int # Error +39 39 | +40 40 | # Should emit for unions with more than two cases, even if not directly adjacent +41 |-field14: int | int | str | int # Error + 41 |+field14: int | int | str # Error +42 42 | +43 43 | # Should emit for duplicate literal types; also covered by PYI030 +44 44 | field15: typing.Literal[1] | typing.Literal[1] # Error + +PYI016.py:44:30: PYI016 [*] Duplicate union member `typing.Literal[1]` + | +43 | # Should emit for duplicate literal types; also covered by PYI030 +44 | field15: typing.Literal[1] | typing.Literal[1] # Error + | ^^^^^^^^^^^^^^^^^ PYI016 +45 | +46 | # Shouldn't emit if in new parent type + | + = help: Remove duplicate union member `typing.Literal[1]` + +ℹ Fix +41 41 | field14: int | int | str | int # Error +42 42 | +43 43 | # Should emit for duplicate literal types; also covered by PYI030 +44 |-field15: typing.Literal[1] | typing.Literal[1] # Error + 44 |+field15: typing.Literal[1] # Error +45 45 | +46 46 | # Shouldn't emit if in new parent type +47 47 | field16: int | dict[int, str] # OK + +PYI016.py:57:5: PYI016 Duplicate union member `set[int]` + | +55 | int # foo +56 | ], +57 | set[ + | _____^ +58 | | int # bar +59 | | ], + | |_____^ PYI016 +60 | ] # Error, newline and comment will not be emitted in message + | + = help: Remove duplicate union member `set[int]` + +PYI016.py:63:28: PYI016 Duplicate union member `int` + | +62 | # Should emit in cases with `typing.Union` instead of `|` +63 | field19: typing.Union[int, int] # Error + | ^^^ PYI016 +64 | +65 | # Should emit in cases with nested `typing.Union` + | + = help: Remove duplicate union member `int` + +PYI016.py:66:41: PYI016 Duplicate union member `int` + | +65 | # Should emit in cases with nested `typing.Union` +66 | field20: typing.Union[int, typing.Union[int, str]] # Error + | ^^^ PYI016 +67 | +68 | # Should emit in cases with mixed `typing.Union` and `|` + | + = help: Remove duplicate union member `int` + +PYI016.py:69:28: PYI016 [*] Duplicate union member `int` + | +68 | # Should emit in cases with mixed `typing.Union` and `|` +69 | field21: typing.Union[int, int | str] # Error + | ^^^ PYI016 +70 | +71 | # Should emit only once in cases with multiple nested `typing.Union` + | + = help: Remove duplicate union member `int` + +ℹ Fix +66 66 | field20: typing.Union[int, typing.Union[int, str]] # Error +67 67 | +68 68 | # Should emit in cases with mixed `typing.Union` and `|` +69 |-field21: typing.Union[int, int | str] # Error + 69 |+field21: typing.Union[int, str] # Error +70 70 | +71 71 | # Should emit only once in cases with multiple nested `typing.Union` +72 72 | field22: typing.Union[int, typing.Union[int, typing.Union[int, int]]] # Error + +PYI016.py:72:41: PYI016 Duplicate union member `int` + | +71 | # Should emit only once in cases with multiple nested `typing.Union` +72 | field22: typing.Union[int, typing.Union[int, typing.Union[int, int]]] # Error + | ^^^ PYI016 +73 | +74 | # Should emit in cases with newlines + | + = help: Remove duplicate union member `int` + +PYI016.py:72:59: PYI016 Duplicate union member `int` + | +71 | # Should emit only once in cases with multiple nested `typing.Union` +72 | field22: typing.Union[int, typing.Union[int, typing.Union[int, int]]] # Error + | ^^^ PYI016 +73 | +74 | # Should emit in cases with newlines + | + = help: Remove duplicate union member `int` + +PYI016.py:72:64: PYI016 Duplicate union member `int` + | +71 | # Should emit only once in cases with multiple nested `typing.Union` +72 | field22: typing.Union[int, typing.Union[int, typing.Union[int, int]]] # Error + | ^^^ PYI016 +73 | +74 | # Should emit in cases with newlines + | + = help: Remove duplicate union member `int` + +PYI016.py:76:12: PYI016 [*] Duplicate union member `set[int]` + | +74 | # Should emit in cases with newlines +75 | field23: set[ # foo +76 | int] | set[int] + | ^^^^^^^^ PYI016 +77 | +78 | # Should emit twice (once for each `int` in the nested union, both of which are + | + = help: Remove duplicate union member `set[int]` + +ℹ Fix +73 73 | +74 74 | # Should emit in cases with newlines +75 75 | field23: set[ # foo +76 |- int] | set[int] + 76 |+ int] +77 77 | +78 78 | # Should emit twice (once for each `int` in the nested union, both of which are +79 79 | # duplicates of the outer `int`), but not three times (which would indicate that + +PYI016.py:81:41: PYI016 Duplicate union member `int` + | +79 | # duplicates of the outer `int`), but not three times (which would indicate that +80 | # we incorrectly re-checked the nested union). +81 | field24: typing.Union[int, typing.Union[int, int]] # PYI016: Duplicate union member `int` + | ^^^ PYI016 +82 | +83 | # Should emit twice (once for each `int` in the nested union, both of which are + | + = help: Remove duplicate union member `int` + +PYI016.py:81:46: PYI016 Duplicate union member `int` + | +79 | # duplicates of the outer `int`), but not three times (which would indicate that +80 | # we incorrectly re-checked the nested union). +81 | field24: typing.Union[int, typing.Union[int, int]] # PYI016: Duplicate union member `int` + | ^^^ PYI016 +82 | +83 | # Should emit twice (once for each `int` in the nested union, both of which are + | + = help: Remove duplicate union member `int` + +PYI016.py:86:28: PYI016 [*] Duplicate union member `int` + | +84 | # duplicates of the outer `int`), but not three times (which would indicate that +85 | # we incorrectly re-checked the nested union). +86 | field25: typing.Union[int, int | int] # PYI016: Duplicate union member `int` + | ^^^ PYI016 + | + = help: Remove duplicate union member `int` + +ℹ Fix +83 83 | # Should emit twice (once for each `int` in the nested union, both of which are +84 84 | # duplicates of the outer `int`), but not three times (which would indicate that +85 85 | # we incorrectly re-checked the nested union). +86 |-field25: typing.Union[int, int | int] # PYI016: Duplicate union member `int` + 86 |+field25: typing.Union[int, int] # PYI016: Duplicate union member `int` + +PYI016.py:86:34: PYI016 [*] Duplicate union member `int` + | +84 | # duplicates of the outer `int`), but not three times (which would indicate that +85 | # we incorrectly re-checked the nested union). +86 | field25: typing.Union[int, int | int] # PYI016: Duplicate union member `int` + | ^^^ PYI016 + | + = help: Remove duplicate union member `int` + +ℹ Fix +83 83 | # Should emit twice (once for each `int` in the nested union, both of which are +84 84 | # duplicates of the outer `int`), but not three times (which would indicate that +85 85 | # we incorrectly re-checked the nested union). +86 |-field25: typing.Union[int, int | int] # PYI016: Duplicate union member `int` + 86 |+field25: typing.Union[int, int] # PYI016: Duplicate union member `int` diff --git a/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI016_PYI016.pyi.snap b/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI016_PYI016.pyi.snap index ca5312e2e9..d29fea9901 100644 --- a/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI016_PYI016.pyi.snap +++ b/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI016_PYI016.pyi.snap @@ -471,6 +471,8 @@ PYI016.pyi:76:12: PYI016 [*] Duplicate union member `set[int]` 75 | field23: set[ # foo 76 | int] | set[int] | ^^^^^^^^ PYI016 +77 | +78 | # Should emit twice (once for each `int` in the nested union, both of which are | = help: Remove duplicate union member `set[int]` @@ -480,5 +482,62 @@ PYI016.pyi:76:12: PYI016 [*] Duplicate union member `set[int]` 75 75 | field23: set[ # foo 76 |- int] | set[int] 76 |+ int] +77 77 | +78 78 | # Should emit twice (once for each `int` in the nested union, both of which are +79 79 | # duplicates of the outer `int`), but not three times (which would indicate that + +PYI016.pyi:81:41: PYI016 Duplicate union member `int` + | +79 | # duplicates of the outer `int`), but not three times (which would indicate that +80 | # we incorrectly re-checked the nested union). +81 | field24: typing.Union[int, typing.Union[int, int]] # PYI016: Duplicate union member `int` + | ^^^ PYI016 +82 | +83 | # Should emit twice (once for each `int` in the nested union, both of which are + | + = help: Remove duplicate union member `int` + +PYI016.pyi:81:46: PYI016 Duplicate union member `int` + | +79 | # duplicates of the outer `int`), but not three times (which would indicate that +80 | # we incorrectly re-checked the nested union). +81 | field24: typing.Union[int, typing.Union[int, int]] # PYI016: Duplicate union member `int` + | ^^^ PYI016 +82 | +83 | # Should emit twice (once for each `int` in the nested union, both of which are + | + = help: Remove duplicate union member `int` + +PYI016.pyi:86:28: PYI016 [*] Duplicate union member `int` + | +84 | # duplicates of the outer `int`), but not three times (which would indicate that +85 | # we incorrectly re-checked the nested union). +86 | field25: typing.Union[int, int | int] # PYI016: Duplicate union member `int` + | ^^^ PYI016 + | + = help: Remove duplicate union member `int` + +ℹ Fix +83 83 | # Should emit twice (once for each `int` in the nested union, both of which are +84 84 | # duplicates of the outer `int`), but not three times (which would indicate that +85 85 | # we incorrectly re-checked the nested union). +86 |-field25: typing.Union[int, int | int] # PYI016: Duplicate union member `int` + 86 |+field25: typing.Union[int, int] # PYI016: Duplicate union member `int` + +PYI016.pyi:86:34: PYI016 [*] Duplicate union member `int` + | +84 | # duplicates of the outer `int`), but not three times (which would indicate that +85 | # we incorrectly re-checked the nested union). +86 | field25: typing.Union[int, int | int] # PYI016: Duplicate union member `int` + | ^^^ PYI016 + | + = help: Remove duplicate union member `int` + +ℹ Fix +83 83 | # Should emit twice (once for each `int` in the nested union, both of which are +84 84 | # duplicates of the outer `int`), but not three times (which would indicate that +85 85 | # we incorrectly re-checked the nested union). +86 |-field25: typing.Union[int, int | int] # PYI016: Duplicate union member `int` + 86 |+field25: typing.Union[int, int] # PYI016: Duplicate union member `int` diff --git a/crates/ruff_python_semantic/src/model.rs b/crates/ruff_python_semantic/src/model.rs index 3ae1b06bb2..4b55de7648 100644 --- a/crates/ruff_python_semantic/src/model.rs +++ b/crates/ruff_python_semantic/src/model.rs @@ -6,7 +6,7 @@ use smallvec::smallvec; use ruff_python_ast::call_path::{collect_call_path, from_unqualified_name, CallPath}; use ruff_python_ast::helpers::from_relative_import; -use ruff_python_ast::{self as ast, Expr, Ranged, Stmt}; +use ruff_python_ast::{self as ast, Expr, Operator, Ranged, Stmt}; use ruff_python_stdlib::path::is_python_stub_file; use ruff_python_stdlib::typing::is_typing_extension; use ruff_text_size::{TextRange, TextSize}; @@ -1022,6 +1022,34 @@ impl<'a> SemanticModel<'a> { false } + /// Return `true` if the model is in a nested union expression (e.g., the inner `Union` in + /// `Union[Union[int, str], float]`). + pub fn in_nested_union(&self) -> bool { + // Ex) `Union[Union[int, str], float]` + if self + .current_expression_grandparent() + .and_then(Expr::as_subscript_expr) + .is_some_and(|parent| self.match_typing_expr(&parent.value, "Union")) + { + return true; + } + + // Ex) `int | Union[str, float]` + if self.current_expression_parent().is_some_and(|parent| { + matches!( + parent, + Expr::BinOp(ast::ExprBinOp { + op: Operator::BitOr, + .. + }) + ) + }) { + return true; + } + + false + } + /// Returns `true` if the given [`BindingId`] is used. pub fn is_used(&self, binding_id: BindingId) -> bool { self.bindings[binding_id].is_used()