Repository: wemake-services/wemake-python-styleguide

Issue:
Report useless ternary operator
# Rule request

## Thesis

Detect if both possible results of a ternary operator return the same value.

```python
# bad
# (all results below can be replaced by just `a`)
a if ... else a
a if a is not None else None
a if a != b else b
b if a == b else a

# ok
a if ... else c
a.split() if a is not None else None
a if a != b else c
```

## Reasoning

It is either a bug or unnecessary complexity.

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/wemake-services/wemake-python-styleguide/pull/3193?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`ac5b8cc`)](https://app.codecov.io/gh/wemake-services/wemake-python-styleguide/commit/ac5b8ccc42cc9e6ab9386a989af855e816192ca6?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services) to head [(`1b3330c`)](https://app.codecov.io/gh/wemake-services/wemake-python-styleguide/commit/1b3330c72c8e4eecba0977240917794ca7dd25ff?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##            master     #3193   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          372       373    +1     
  Lines        12608     12603    -5     
  Branches       863       862    -1     
=========================================
- Hits         12608     12603    -5     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/wemake-services/wemake-python-styleguide/pull/3193?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services).


Patch:
diff --git a/.github/workflows/wps.yml b/.github/workflows/wps.yml
index 202c36e1..641dbec0 100644
--- a/.github/workflows/wps.yml
+++ b/.github/workflows/wps.yml
@@ -1,6 +1,6 @@
 name: wps
 
-on:
+'on':
   pull_request:
     branches:
       - master
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 104760bc..b2042300 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -76,7 +76,7 @@ Semantic versioning in our case means:
 
 - Adds official `python3.13` support
 - Allows any compares in `assert` statements for `WPS520`, #3112
-- Allows walrus operator (`:=`) in comprehesions #3121
+- Allows walrus operator (`:=`) in comprehesions, #3121
 - Allows `pass` in `case` bodies, #2642
 - Allows subclassing builtins in `WPS600`, when creating an `Enum`, #2506
 - Allows using variables after blocks for `WPS441` in `assert` statements, #2543
@@ -89,11 +89,12 @@ Semantic versioning in our case means:
 - Allows any number of instance attributes on `@dataclass`es in `WPS230`, #2448
 - Allows any number of function parameters
   in `@overload` definitions for `WPS211`, #1957
-- Adds new rule to forbid `lambda` assigns to special attributes, #1733
-- Adds new rule to check problematic function params, #1343
-- Adds new rule to detect duplicate conditions in `if`s and `elif`s, #2241
+- Adds a new rule to forbid `lambda` assigns to special attributes, #1733
+- Adds a new rule to check problematic function params, #1343
+- Adds a new rule to detect duplicate conditions in `if`s and `elif`s, #2241
 - Adds a new rule to find too complex `except` with too many exceptions
-- Adds a new rule to find too many PEP695 type params
+- Adds a new rule to find too many `PEP695` type params
+- Adds a new rule to find useless ternary expressions, #1706
 - Adds support to run `wemake-python-styleguide` as a `pre-commit` hook, #2588
 - GitHub Action can now use `cwd:` parameter to specify
   where your configuration file is, #2474
diff --git a/pyproject.toml b/pyproject.toml
index b66b56b5..424dbc55 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -189,6 +189,7 @@ inline-quotes = "single"
 # The mypy configurations: http://bit.ly/2zEl9WI
 ignore_missing_imports = true
 strict = true
+local_partial_types = true
 warn_unreachable = true
 
 enable_error_code = [
@@ -199,13 +200,14 @@ enable_error_code = [
   # "ignore-without-code",
   "possibly-undefined",
   "redundant-self",
+  # "explicit-override",
   # "mutable-override",
   "unimported-reveal",
   "deprecated",
 ]
 
 disable_error_code = [
-  "no-untyped-def",
+  "no-untyped-def",  # TODO: fix
 ]
 
 [[tool.mypy.overrides]]
diff --git a/wemake_python_styleguide/logic/tree/attributes.py b/wemake_python_styleguide/logic/tree/attributes.py
index 039ca84e..9814a596 100644
--- a/wemake_python_styleguide/logic/tree/attributes.py
+++ b/wemake_python_styleguide/logic/tree/attributes.py
@@ -2,7 +2,7 @@ import ast
 from collections.abc import Iterable
 
 from wemake_python_styleguide.constants import SPECIAL_ARGUMENT_NAMES_WHITELIST
-from wemake_python_styleguide.types import AnyChainable, AnyVariableDef
+from wemake_python_styleguide.types import AnyNodes, AnyVariableDef
 
 
 def _chained_item(iterator: ast.AST) -> ast.AST | None:
@@ -13,7 +13,7 @@ def _chained_item(iterator: ast.AST) -> ast.AST | None:
     return None
 
 
-def parts(node: AnyChainable) -> Iterable[ast.AST]:
+def parts(node: ast.AST) -> Iterable[ast.AST]:
     """
     Returns all ``.`` separated elements for attributes, subscripts and calls.
 
@@ -25,7 +25,7 @@ def parts(node: AnyChainable) -> Iterable[ast.AST]:
 
     We need all parts from it.
     """
-    iterator: ast.AST = node
+    iterator = node
 
     while True:
         yield iterator
@@ -36,6 +36,11 @@ def parts(node: AnyChainable) -> Iterable[ast.AST]:
         iterator = chained_item
 
 
+def only_consists_of_parts(node: ast.AST, allowed: AnyNodes) -> bool:
+    """Returns `True` if some node consists of only given parts."""
+    return all(isinstance(part, allowed) for part in parts(node))
+
+
 def is_foreign_attribute(node: AnyVariableDef) -> bool:
     """Tells whether this node is a foreign attribute."""
     if not isinstance(node, ast.Attribute):
diff --git a/wemake_python_styleguide/logic/tree/classes.py b/wemake_python_styleguide/logic/tree/classes.py
index 851a4627..db474ab7 100644
--- a/wemake_python_styleguide/logic/tree/classes.py
+++ b/wemake_python_styleguide/logic/tree/classes.py
@@ -23,6 +23,7 @@ _DATACLASS_NAMES: Final = frozenset((
     'attr.attributes',
     'attr.frozen',
     'attr.mutable',
+    'attr.dataclass',
     # pydantic also has `dataclass` and `dataclasses.dataclass`
 ))
 
diff --git a/wemake_python_styleguide/logic/tree/compares.py b/wemake_python_styleguide/logic/tree/compares.py
index 846eb769..b11c5cb4 100644
--- a/wemake_python_styleguide/logic/tree/compares.py
+++ b/wemake_python_styleguide/logic/tree/compares.py
@@ -2,7 +2,7 @@ import ast
 import types
 from collections import defaultdict
 from collections.abc import Mapping
-from typing import Final
+from typing import Final, TypeAlias
 
 import attr
 from typing_extensions import final
@@ -19,19 +19,19 @@ class _Bounds:
     upper_bound: set[ast.Compare] = attr.ib(factory=set)
 
 
-_MultipleCompareOperators = tuple[type[ast.cmpop], ...]
+_MultipleCompareOperators: TypeAlias = tuple[type[ast.cmpop], ...]
 
-#: Type to represent `SIMILAR_OPERATORS` constant.
-_ComparesMapping = Mapping[
+#: Type to represent `_SIMILAR_OPERATORS` constant.
+_ComparesMapping: TypeAlias = Mapping[
     type[ast.cmpop],
     _MultipleCompareOperators,
 ]
 
 #: Used to track the operator usages in `a > b and b >c` compares.
-_OperatorUsages = defaultdict[str, _Bounds]
+_OperatorUsages: TypeAlias = defaultdict[str, _Bounds]
 
 #: Constant to define similar operators.
-SIMILAR_OPERATORS: Final[_ComparesMapping] = types.MappingProxyType(
+_SIMILAR_OPERATORS: Final[_ComparesMapping] = types.MappingProxyType(
     {
         ast.Gt: (ast.Gt, ast.GtE),
         ast.GtE: (ast.Gt, ast.GtE),
@@ -45,8 +45,8 @@ def get_similar_operators(
     operator: ast.cmpop,
 ) -> type[ast.cmpop] | _MultipleCompareOperators:
     """Returns similar operators types for the given operator."""
-    operator_type = operator.__class__
-    return SIMILAR_OPERATORS.get(operator_type, operator_type)
+    operator_type = type(operator)
+    return _SIMILAR_OPERATORS.get(operator_type, operator_type)
 
 
 @final
@@ -121,3 +121,29 @@ class CompareBounds:
 
         if key_name:
             getattr(self._uses[name], key_name).add(comparison_node)
+
+
+def is_useless_ternary(
+    node: ast.IfExp,
+    cmpop: ast.cmpop,
+    left: ast.expr,
+    right: ast.expr,
+) -> bool:
+    """Checks if the given ternary expression parts are useless."""
+    if isinstance(cmpop, ast.Is | ast.Eq):
+        comparators = {
+            source.node_to_string(left),
+            source.node_to_string(right),
+        }
+        common_elements = {
+            source.node_to_string(node.body),
+            source.node_to_string(node.orelse),
+        }.intersection(comparators)
+        return len(common_elements) == len(comparators)
+    if isinstance(cmpop, ast.IsNot | ast.NotEq):
+        return source.node_to_string(node.body) == source.node_to_string(
+            left,
+        ) and source.node_to_string(node.orelse) == source.node_to_string(
+            right,
+        )
+    return False
diff --git a/wemake_python_styleguide/violations/refactoring.py b/wemake_python_styleguide/violations/refactoring.py
index d4fa95ed..3b104761 100644
--- a/wemake_python_styleguide/violations/refactoring.py
+++ b/wemake_python_styleguide/violations/refactoring.py
@@ -47,6 +47,7 @@ Summary
    SimplifiableReturningIfViolation
    ChainedIsViolation
    DuplicateIfConditionViolation
+   UselessTernaryViolation
 
 Refactoring opportunities
 -------------------------
@@ -85,6 +86,7 @@ Refactoring opportunities
 .. autoclass:: SimplifiableReturningIfViolation
 .. autoclass:: ChainedIsViolation
 .. autoclass:: DuplicateIfConditionViolation
+.. autoclass:: UselessTernaryViolation
 
 """
 
@@ -1323,3 +1325,32 @@ class DuplicateIfConditionViolation(ASTViolation):
 
     error_template = 'Found duplicate condition in `if`: {0}'
     code = 533
+
+
+@final
+class UselessTernaryViolation(ASTViolation):
+    """
+    Forbid having useless ternary expressions.
+
+    Reasoning:
+        When ternary expression can be replaced with a single name,
+        it is way more readable and more performant.
+
+    Solution:
+        Remove the ternary expression.
+
+    Example::
+
+        # Correct:
+        first if some_condition else second
+
+        # Wrong:
+        a if a is not None else None
+        b if a == b else a
+
+    .. versionadded:: 1.0.0
+
+    """
+
+    error_template = 'Found useless ternary expression'
+    code = 534
diff --git a/wemake_python_styleguide/visitors/ast/conditions.py b/wemake_python_styleguide/visitors/ast/conditions.py
index 3863bee3..30ba5354 100644
--- a/wemake_python_styleguide/visitors/ast/conditions.py
+++ b/wemake_python_styleguide/visitors/ast/conditions.py
@@ -9,10 +9,14 @@ from wemake_python_styleguide.logic import source
 from wemake_python_styleguide.logic.naming.duplicates import (
     get_duplicate_names,
 )
-from wemake_python_styleguide.logic.tree import ifs, operators
-from wemake_python_styleguide.logic.tree.compares import CompareBounds
+from wemake_python_styleguide.logic.tree import (
+    attributes,
+    compares,
+    ifs,
+    operators,
+)
 from wemake_python_styleguide.logic.tree.functions import given_function_called
-from wemake_python_styleguide.types import AnyIf
+from wemake_python_styleguide.types import AnyIf, AnyNodes
 from wemake_python_styleguide.violations import (
     best_practices,
     consistency,
@@ -35,11 +39,23 @@ _OperatorPairs: TypeAlias = Mapping[type[ast.boolop], type[ast.cmpop]]
 class IfStatementVisitor(BaseNodeVisitor):
     """Checks single and consecutive ``if`` statement nodes."""
 
+    _nodes_to_check: ClassVar[AnyNodes] = (
+        ast.Name,
+        ast.Attribute,
+        ast.Subscript,
+        ast.Constant,
+        ast.List,
+        ast.Dict,
+        ast.Tuple,
+        ast.Set,
+    )
+
     def visit_any_if(self, node: AnyIf) -> None:
         """Checks ``if`` nodes and expressions."""
         self._check_negated_conditions(node)
         self._check_useless_len(node)
         self._check_repeated_conditions(node)
+        self._check_useless_ternary(node)
         self.generic_visit(node)
 
     def _check_negated_conditions(self, node: AnyIf) -> None:
@@ -79,6 +95,31 @@ class IfStatementVisitor(BaseNodeVisitor):
                     )
                 )
 
+    def _check_useless_ternary(self, node: AnyIf) -> None:
+        if not isinstance(node, ast.IfExp):
+            return
+
+        comp = node.test
+        if not isinstance(comp, ast.Compare) or len(comp.ops) > 1:
+            return  # We only check for compares with exactly one op
+
+        if not attributes.only_consists_of_parts(
+            node.body,
+            self._nodes_to_check,
+        ) or not attributes.only_consists_of_parts(
+            node.orelse,
+            self._nodes_to_check,
+        ):
+            return  # Only simple nodes are allowed on left and right parts
+
+        if compares.is_useless_ternary(
+            node,
+            comp.ops[0],
+            comp.left,
+            comp.comparators[0],
+        ):
+            self.add_violation(refactoring.UselessTernaryViolation(node))
+
 
 @final
 class BooleanConditionVisitor(BaseNodeVisitor):
@@ -160,7 +201,7 @@ class ImplicitBoolPatternsVisitor(BaseNodeVisitor):
         if not isinstance(node.op, ast.And):
             return
 
-        if not CompareBounds(node).is_valid():
+        if not compares.CompareBounds(node).is_valid():
             self.add_violation(
                 consistency.ImplicitComplexCompareViolation(node),
             )


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 81882816..f4fbb238 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1,6 +1,6 @@
 name: test
 
-on:
+'on':
   push:
     branches:
       - master
diff --git a/tests/fixtures/noqa/noqa.py b/tests/fixtures/noqa/noqa.py
index ba00df08..f4c217c1 100644
--- a/tests/fixtures/noqa/noqa.py
+++ b/tests/fixtures/noqa/noqa.py
@@ -671,6 +671,8 @@ if noqa_wps533:  # noqa: WPS533
 elif noqa_wps533:
     my_print('2')
 
+noqa_wps534 = second if first == second else first  # noqa: WPS534
+
 class Baseline:
     def method(self, number):
         return number + 1
diff --git a/tests/test_checker/test_noqa.py b/tests/test_checker/test_noqa.py
index 214ca72a..f756cf42 100644
--- a/tests/test_checker/test_noqa.py
+++ b/tests/test_checker/test_noqa.py
@@ -265,6 +265,7 @@ SHOULD_BE_RAISED = types.MappingProxyType(
         'WPS531': 0,  # disabled since 1.0.0
         'WPS532': 1,
         'WPS533': 1,
+        'WPS534': 1,
         'WPS600': 1,
         'WPS601': 1,
         'WPS602': 2,
diff --git a/tests/test_visitors/test_ast/test_conditions/test_useless_ternary.py b/tests/test_visitors/test_ast/test_conditions/test_useless_ternary.py
new file mode 100644
index 00000000..e16dfd78
--- /dev/null
+++ b/tests/test_visitors/test_ast/test_conditions/test_useless_ternary.py
@@ -0,0 +1,76 @@
+import pytest
+
+from wemake_python_styleguide.violations.refactoring import (
+    NegatedConditionsViolation,
+    UselessTernaryViolation,
+)
+from wemake_python_styleguide.visitors.ast.conditions import IfStatementVisitor
+
+template = '{0} if {1} else {2}'
+
+
+@pytest.mark.parametrize(
+    ('left', 'compare', 'right'),
+    [
+        ('x', 'condition', 'y'),
+        ('x', 'x > y', 'y'),
+        ('x', 'x == y and y > 0', 'y'),
+        ('2', 'x == y', 'call().attr'),
+        ('a', 'x == y', 'b'),
+        ('x[0]', 'x == y', 'x[1]'),
+        ('x[0]', 'x[0] == y[1]', 'y[0]'),
+        ('a.x', 'x == y', 'b.y'),
+        ('call.method()', 'x != y', 'obj.attr'),
+    ],
+)
+def test_useful_ternary(
+    assert_errors,
+    parse_ast_tree,
+    left,
+    compare,
+    right,
+    default_options,
+):
+    """Testing that correct code works."""
+    tree = parse_ast_tree(template.format(left, compare, right))
+
+    visitor = IfStatementVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [], ignored_types=NegatedConditionsViolation)
+
+
+@pytest.mark.parametrize(
+    ('left', 'compare', 'right'),
+    [
+        ('x', 'x == y', 'y'),
+        ('[x]', '[x] == [y]', '[y]'),
+        ('{x}', '{x} == {y}', '{y}'),
+        ('(x,)', '(x,) == (y,)', '(y,)'),
+        ('x.a', 'x.a == y.b', 'y.b'),
+        ('x.a', 'x.a is None', 'None'),
+        ('None', 'x[0] is None', 'x[0]'),
+        ('False', 'x[0] is False', 'x[0]'),
+        ('x[0]', 'x[0] is not None', 'None'),
+        ('x.attr[0]', 'x.attr[0] != y.b', 'y.b'),
+    ],
+)
+def test_useless_ternary(
+    assert_errors,
+    parse_ast_tree,
+    left,
+    compare,
+    right,
+    default_options,
+):
+    """Testing that incorrect code raises a violation."""
+    tree = parse_ast_tree(template.format(left, compare, right))
+
+    visitor = IfStatementVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(
+        visitor,
+        [UselessTernaryViolation],
+        ignored_types=NegatedConditionsViolation,
+    )