diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_pyi/PYI016.py b/crates/ruff_linter/resources/test/fixtures/flake8_pyi/PYI016.py index 89efd61e8f..74eaf356d5 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_pyi/PYI016.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_pyi/PYI016.py @@ -142,3 +142,7 @@ field47: typing.Optional[int] | typing.Optional[dict] # avoid reporting twice field48: typing.Union[typing.Optional[typing.Union[complex, complex]], complex] field49: typing.Optional[complex | complex] | complex + +# Regression test for https://github.com/astral-sh/ruff/issues/19403 +# Should throw duplicate union member but not fix +isinstance(None, typing.Union[None, None]) \ No newline at end of file diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/duplicate_union_member.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/duplicate_union_member.rs index 33812cebee..2fefb8e01d 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/duplicate_union_member.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/duplicate_union_member.rs @@ -64,6 +64,7 @@ pub(crate) fn duplicate_union_member<'a>(checker: &Checker, expr: &'a Expr) { let mut diagnostics = Vec::new(); let mut union_type = UnionKind::TypingUnion; + let mut optional_present = false; // Adds a member to `literal_exprs` if it is a `Literal` annotation let mut check_for_duplicate_members = |expr: &'a Expr, parent: &'a Expr| { if matches!(parent, Expr::BinOp(_)) { @@ -74,6 +75,7 @@ pub(crate) fn duplicate_union_member<'a>(checker: &Checker, expr: &'a Expr) { && is_optional_type(checker, expr) { // If the union member is an `Optional`, add a virtual `None` literal. + optional_present = true; &VIRTUAL_NONE_LITERAL } else { expr @@ -87,7 +89,7 @@ pub(crate) fn duplicate_union_member<'a>(checker: &Checker, expr: &'a Expr) { DuplicateUnionMember { duplicate_name: checker.generator().expr(virtual_expr), }, - // Use the real expression's range for diagnostics, + // Use the real expression's range for diagnostics. expr.range(), )); } @@ -104,6 +106,13 @@ pub(crate) fn duplicate_union_member<'a>(checker: &Checker, expr: &'a Expr) { return; } + // Do not reduce `Union[None, ... None]` to avoid introducing a `TypeError` unintentionally + // e.g. `isinstance(None, Union[None, None])`, if reduced to `isinstance(None, None)`, causes + // `TypeError: isinstance() arg 2 must be a type, a tuple of types, or a union` to throw. + if unique_nodes.iter().all(|expr| expr.is_none_literal_expr()) && !optional_present { + return; + } + // Mark [`Fix`] as unsafe when comments are in range. let applicability = if checker.comment_ranges().intersects(expr.range()) { Applicability::Unsafe diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI016_PYI016.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI016_PYI016.py.snap index 8a51694b4a..62c0d69f78 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI016_PYI016.py.snap +++ b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI016_PYI016.py.snap @@ -974,6 +974,8 @@ PYI016.py:143:61: PYI016 [*] Duplicate union member `complex` 143 |-field48: typing.Union[typing.Optional[typing.Union[complex, complex]], complex] 143 |+field48: typing.Union[typing.Optional[complex], complex] 144 144 | field49: typing.Optional[complex | complex] | complex +145 145 | +146 146 | # Regression test for https://github.com/astral-sh/ruff/issues/19403 PYI016.py:144:36: PYI016 [*] Duplicate union member `complex` | @@ -981,6 +983,8 @@ PYI016.py:144:36: PYI016 [*] Duplicate union member `complex` 143 | field48: typing.Union[typing.Optional[typing.Union[complex, complex]], complex] 144 | field49: typing.Optional[complex | complex] | complex | ^^^^^^^ PYI016 +145 | +146 | # Regression test for https://github.com/astral-sh/ruff/issues/19403 | = help: Remove duplicate union member `complex` @@ -990,3 +994,15 @@ PYI016.py:144:36: PYI016 [*] Duplicate union member `complex` 143 143 | field48: typing.Union[typing.Optional[typing.Union[complex, complex]], complex] 144 |-field49: typing.Optional[complex | complex] | complex 144 |+field49: typing.Optional[complex] | complex +145 145 | +146 146 | # Regression test for https://github.com/astral-sh/ruff/issues/19403 +147 147 | # Should throw duplicate union member but not fix + +PYI016.py:148:37: PYI016 Duplicate union member `None` + | +146 | # Regression test for https://github.com/astral-sh/ruff/issues/19403 +147 | # Should throw duplicate union member but not fix +148 | isinstance(None, typing.Union[None, None]) + | ^^^^ PYI016 + | + = help: Remove duplicate union member `None` diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__preview__PYI016_PYI016.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__preview__PYI016_PYI016.py.snap index 8b49997fcc..a1212a7b5b 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__preview__PYI016_PYI016.py.snap +++ b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__preview__PYI016_PYI016.py.snap @@ -1162,6 +1162,8 @@ PYI016.py:143:61: PYI016 [*] Duplicate union member `complex` 143 |-field48: typing.Union[typing.Optional[typing.Union[complex, complex]], complex] 143 |+field48: typing.Union[None, complex] 144 144 | field49: typing.Optional[complex | complex] | complex +145 145 | +146 146 | # Regression test for https://github.com/astral-sh/ruff/issues/19403 PYI016.py:143:72: PYI016 [*] Duplicate union member `complex` | @@ -1179,6 +1181,8 @@ PYI016.py:143:72: PYI016 [*] Duplicate union member `complex` 143 |-field48: typing.Union[typing.Optional[typing.Union[complex, complex]], complex] 143 |+field48: typing.Union[None, complex] 144 144 | field49: typing.Optional[complex | complex] | complex +145 145 | +146 146 | # Regression test for https://github.com/astral-sh/ruff/issues/19403 PYI016.py:144:36: PYI016 [*] Duplicate union member `complex` | @@ -1186,6 +1190,8 @@ PYI016.py:144:36: PYI016 [*] Duplicate union member `complex` 143 | field48: typing.Union[typing.Optional[typing.Union[complex, complex]], complex] 144 | field49: typing.Optional[complex | complex] | complex | ^^^^^^^ PYI016 +145 | +146 | # Regression test for https://github.com/astral-sh/ruff/issues/19403 | = help: Remove duplicate union member `complex` @@ -1195,6 +1201,9 @@ PYI016.py:144:36: PYI016 [*] Duplicate union member `complex` 143 143 | field48: typing.Union[typing.Optional[typing.Union[complex, complex]], complex] 144 |-field49: typing.Optional[complex | complex] | complex 144 |+field49: None | complex +145 145 | +146 146 | # Regression test for https://github.com/astral-sh/ruff/issues/19403 +147 147 | # Should throw duplicate union member but not fix PYI016.py:144:47: PYI016 [*] Duplicate union member `complex` | @@ -1202,6 +1211,8 @@ PYI016.py:144:47: PYI016 [*] Duplicate union member `complex` 143 | field48: typing.Union[typing.Optional[typing.Union[complex, complex]], complex] 144 | field49: typing.Optional[complex | complex] | complex | ^^^^^^^ PYI016 +145 | +146 | # Regression test for https://github.com/astral-sh/ruff/issues/19403 | = help: Remove duplicate union member `complex` @@ -1211,3 +1222,15 @@ PYI016.py:144:47: PYI016 [*] Duplicate union member `complex` 143 143 | field48: typing.Union[typing.Optional[typing.Union[complex, complex]], complex] 144 |-field49: typing.Optional[complex | complex] | complex 144 |+field49: None | complex +145 145 | +146 146 | # Regression test for https://github.com/astral-sh/ruff/issues/19403 +147 147 | # Should throw duplicate union member but not fix + +PYI016.py:148:37: PYI016 Duplicate union member `None` + | +146 | # Regression test for https://github.com/astral-sh/ruff/issues/19403 +147 | # Should throw duplicate union member but not fix +148 | isinstance(None, typing.Union[None, None]) + | ^^^^ PYI016 + | + = help: Remove duplicate union member `None`