Repository: wemake-services/wemake-python-styleguide

Issue:
Forbid single item in "in" operators
# Rule request

## Thesis


These two code samples are identical:

```python
a in {'s'}
```

and 

```python
a == 's'
```

But, the second version is more clear. We should enforce it.

## Reasoning

"in" operator with single item look like overhead. Let's do things more clear!


Discussion:

Resolving patch:
diff --git a/wemake_python_styleguide/presets/types/tree.py b/wemake_python_styleguide/presets/types/tree.py
--- a/wemake_python_styleguide/presets/types/tree.py
+++ b/wemake_python_styleguide/presets/types/tree.py
@@ -76,6 +76,7 @@
     compares.WrongComparisionOrderVisitor,
     compares.UnaryCompareVisitor,
     compares.WrongConstantCompareVisitor,
+    compares.InCompareSanityVisitor,
 
     conditions.IfStatementVisitor,
     conditions.BooleanConditionVisitor,
diff --git a/wemake_python_styleguide/violations/refactoring.py b/wemake_python_styleguide/violations/refactoring.py
--- a/wemake_python_styleguide/violations/refactoring.py
+++ b/wemake_python_styleguide/violations/refactoring.py
@@ -40,6 +40,7 @@
    ImplicitPrimitiveViolation
    AlmostSwappedViolation
    MisrefactoredAssignmentViolation
+   InCompareWithSingleItemContainerViolation
 
 Refactoring opportunities
 -------------------------
@@ -69,6 +70,7 @@
 .. autoclass:: ImplicitPrimitiveViolation
 .. autoclass:: AlmostSwappedViolation
 .. autoclass:: MisrefactoredAssignmentViolation
+.. autoclass:: InCompareWithSingleItemContainerViolation
 
 """
 
@@ -982,3 +984,31 @@ class MisrefactoredAssignmentViolation(ASTViolation):
 
     error_template = 'Found self assignment  with refactored assignment'
     code = 524
+
+
+@final
+class InCompareWithSingleItemContainerViolation(ASTViolation):
+    """
+    Forbids comparisons where ``in`` is compared with single item container.
+
+    Reasoning:
+        ``in`` comparison with a container which contains only one item looks
+        like overhead and unneeded complexity.
+
+    Solution:
+        Refactor your code to use ``==`` instead ``in``.
+
+    Example::
+
+        # Correct:
+        a == 's'
+
+        # Wrong:
+        a in {'s'}
+
+    .. versionadded:: 0.13.0
+
+    """
+
+    error_template = 'Found wrong "in" compare with single item container'
+    code = 525
diff --git a/wemake_python_styleguide/visitors/ast/compares.py b/wemake_python_styleguide/visitors/ast/compares.py
--- a/wemake_python_styleguide/visitors/ast/compares.py
+++ b/wemake_python_styleguide/visitors/ast/compares.py
@@ -30,6 +30,7 @@
 )
 from wemake_python_styleguide.violations.refactoring import (
     FalsyConstantCompareViolation,
+    InCompareWithSingleItemContainerViolation,
     NestedTernaryViolation,
     NotOperatorWithCompareViolation,
     SimplifiableIfViolation,
@@ -56,32 +57,20 @@ def _is_correct_len(sign: ast.cmpop, comparator: ast.AST) -> bool:
 class CompareSanityVisitor(BaseNodeVisitor):
     """Restricts the incorrect compares."""
 
-    _wrong_in_comparators: ClassVar[AnyNodes] = (
-        ast.List,
-        ast.ListComp,
-        ast.Dict,
-        ast.DictComp,
-        ast.Tuple,
-        ast.GeneratorExp,
-    )
-
     def visit_Compare(self, node: ast.Compare) -> None:
         """
         Ensures that compares are written correctly.
 
         Raises:
             ConstantCompareViolation
-            MultipleInCompareViolation
             UselessCompareViolation
             UselessLenCompareViolation
             HeterogenousCompareViolation
             ReversedComplexCompareViolation
-            WrongInCompareTypeViolation
 
         """
         self._check_literal_compare(node)
         self._check_useless_compare(node)
-        self._check_in_compare(node)
         self._check_unpythonic_compare(node)
         self._check_heterogenous_operators(node)
         self._check_reversed_complex_compare(node)
@@ -104,20 +93,6 @@ def _check_useless_compare(self, node: ast.Compare) -> None:
                 break
             last_variable = next_variable
 
-    def _check_in_compare(self, node: ast.Compare) -> None:
-        in_nodes = (ast.In, ast.NotIn)
-        count = sum(1 for op in node.ops if isinstance(op, in_nodes))
-        if count > 1:
-            self.add_violation(MultipleInCompareViolation(node))
-
-        for op, comp in zip(node.ops, node.comparators):
-            if not isinstance(op, in_nodes):
-                continue
-            if not isinstance(comp, self._wrong_in_comparators):
-                continue
-
-            self.add_violation(WrongInCompareTypeViolation(comp))
-
     def _check_unpythonic_compare(self, node: ast.Compare) -> None:
         all_nodes = [node.left, *node.comparators]
 
@@ -416,3 +391,65 @@ def _check_incorrect_not(self, node: ast.UnaryOp) -> None:
 
         if isinstance(node.operand, ast.Compare):
             self.add_violation(NotOperatorWithCompareViolation(node))
+
+
+@final
+class InCompareSanityVisitor(BaseNodeVisitor):
+    """Restricts the incorrect ``in`` compares."""
+
+    _in_nodes: ClassVar[AnyNodes] = (
+        ast.In,
+        ast.NotIn,
+    )
+
+    _wrong_in_comparators: ClassVar[AnyNodes] = (
+        ast.List,
+        ast.ListComp,
+        ast.Dict,
+        ast.DictComp,
+        ast.Tuple,
+        ast.GeneratorExp,
+    )
+
+    def visit_Compare(self, node: ast.Compare) -> None:
+        """
+        Ensures that compares are written correctly.
+
+        Raises:
+            MultipleInCompareViolation
+            WrongInCompareTypeViolation
+            InCompareWithSingleItemContainerViolation
+
+        """
+        self._check_multiply_compares(node)
+        self._check_comparators(node)
+        self.generic_visit(node)
+
+    def _check_multiply_compares(self, node: ast.Compare) -> None:
+        count = sum(1 for op in node.ops if isinstance(op, self._in_nodes))
+        if count > 1:
+            self.add_violation(MultipleInCompareViolation(node))
+
+    def _check_comparators(self, node: ast.Compare) -> None:
+        for op, comp in zip(node.ops, node.comparators):
+            if not isinstance(op, self._in_nodes):
+                continue
+
+            self._check_single_item_container(comp)
+            self._check_wrong_comparators(comp)
+
+    def _check_single_item_container(self, node: ast.AST) -> None:
+        is_lists_violated = (
+            isinstance(node, (ast.List, ast.Tuple, ast.Set)) and
+            len(node.elts) == 1
+        )
+
+        is_str_violated = isinstance(node, ast.Str) and len(node.s) == 1
+        is_dict_violated = isinstance(node, ast.Dict) and len(node.keys) == 1
+
+        if is_lists_violated or is_str_violated or is_dict_violated:
+            self.add_violation(InCompareWithSingleItemContainerViolation(node))
+
+    def _check_wrong_comparators(self, node: ast.AST) -> None:
+        if isinstance(node, self._wrong_in_comparators):
+            self.add_violation(WrongInCompareTypeViolation(node))


Test patch:
diff --git a/tests/fixtures/noqa.py b/tests/fixtures/noqa.py
--- a/tests/fixtures/noqa.py
+++ b/tests/fixtures/noqa.py
@@ -236,7 +236,7 @@ async def __eq__(self, other):  # noqa: WPS610
 number_with_scientific_notation = 1.5E-10  # noqa: WPS310
 number_with_useless_plus = +5  # noqa: WPS330
 
-if '6' in nodes in '6':  # noqa: WPS311
+if '6' in nodes in '6':  # noqa: WPS311, WPS525
     anti_z444 = 1
 
 assert hex_number == hex_number  # noqa: WPS312
@@ -579,3 +579,6 @@ def some_method(self):
 LOWERCASE_ALPH = "abcdefghijklmnopqrstuvwxyz" # noqa: WPS447
 
 int()  # noqa: WPS351
+
+if a in {1}:  # noqa: WPS525
+    print('bad!')
diff --git a/tests/test_checker/test_noqa.py b/tests/test_checker/test_noqa.py
--- a/tests/test_checker/test_noqa.py
+++ b/tests/test_checker/test_noqa.py
@@ -200,6 +200,7 @@
     'WPS522': 1,
     'WPS523': 1,
     'WPS524': 1,
+    'WPS525': 2,
 
     'WPS600': 1,
     'WPS601': 1,
diff --git a/tests/test_visitors/test_ast/test_compares/test_in_type_check.py b/tests/test_visitors/test_ast/test_compares/test_in_type_check.py
--- a/tests/test_visitors/test_ast/test_compares/test_in_type_check.py
+++ b/tests/test_visitors/test_ast/test_compares/test_in_type_check.py
@@ -3,9 +3,12 @@
 import pytest
 
 from wemake_python_styleguide.violations.refactoring import (
+    InCompareWithSingleItemContainerViolation,
     WrongInCompareTypeViolation,
 )
-from wemake_python_styleguide.visitors.ast.compares import CompareSanityVisitor
+from wemake_python_styleguide.visitors.ast.compares import (
+    InCompareSanityVisitor,
+)
 
 in_template = 'some in {0}'
 not_in_template = 'some not in {0}'
@@ -36,10 +39,14 @@ def test_compare_with_wrong_type(
     """Compares raise a violation for ``in`` with wrong types."""
     tree = parse_ast_tree(code.format(comparator))
 
-    visitor = CompareSanityVisitor(default_options, tree=tree)
+    visitor = InCompareSanityVisitor(default_options, tree=tree)
     visitor.run()
 
-    assert_errors(visitor, [WrongInCompareTypeViolation])
+    assert_errors(
+        visitor,
+        [WrongInCompareTypeViolation],
+        ignored_types=(InCompareWithSingleItemContainerViolation,),
+    )
 
 
 @pytest.mark.parametrize('code', [
@@ -64,7 +71,7 @@ def test_compare_with_correct_type(
     """Compares work correctly for ``in`` with correct types."""
     tree = parse_ast_tree(code.format(comparator))
 
-    visitor = CompareSanityVisitor(default_options, tree=tree)
+    visitor = InCompareSanityVisitor(default_options, tree=tree)
     visitor.run()
 
     assert_errors(visitor, [])
diff --git a/tests/test_visitors/test_ast/test_compares/test_in_with_single_item_container.py b/tests/test_visitors/test_ast/test_compares/test_in_with_single_item_container.py
new file mode 100644
--- /dev/null
+++ b/tests/test_visitors/test_ast/test_compares/test_in_with_single_item_container.py
@@ -0,0 +1,65 @@
+# -*- coding: utf-8 -*-
+
+import pytest
+
+from wemake_python_styleguide.violations.refactoring import (
+    InCompareWithSingleItemContainerViolation,
+    WrongInCompareTypeViolation,
+)
+from wemake_python_styleguide.visitors.ast.compares import (
+    InCompareSanityVisitor,
+)
+
+
+@pytest.mark.parametrize('code', [
+    'if a in {1}: ...',
+    'if a in {1: "a"}: ...',
+    'if a in [1]: ... ',
+    'if a in (1,): ... ',
+    'if a in "a": ... ',
+])
+def test_single_item_container(
+    assert_errors,
+    parse_ast_tree,
+    code,
+    default_options,
+    in_not_in,
+):
+    """Compares forbid ``in`` with single item containers."""
+    tree = parse_ast_tree(code)
+
+    visitor = InCompareSanityVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(
+        visitor,
+        [InCompareWithSingleItemContainerViolation],
+        ignored_types=(WrongInCompareTypeViolation,),
+    )
+
+
+@pytest.mark.parametrize('code', [
+    'if a in {1, 2}: ...',
+    'if a in {1: "a", 2: "b"}: ...',
+    'if a in [1, 2]: ... ',
+    'if a in (1, 2): ... ',
+    'if a in "ab": ... ',
+])
+def test_multi_item_contrainer(
+    assert_errors,
+    parse_ast_tree,
+    code,
+    default_options,
+    in_not_in,
+):
+    """Compares allow ``in`` with multi items containers."""
+    tree = parse_ast_tree(code)
+
+    visitor = InCompareSanityVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(
+        visitor,
+        [],
+        ignored_types=(WrongInCompareTypeViolation,),
+    )
diff --git a/tests/test_visitors/test_ast/test_compares/test_multiple_in.py b/tests/test_visitors/test_ast/test_compares/test_multiple_in.py
--- a/tests/test_visitors/test_ast/test_compares/test_multiple_in.py
+++ b/tests/test_visitors/test_ast/test_compares/test_multiple_in.py
@@ -2,13 +2,15 @@
 
 import pytest
 
-from wemake_python_styleguide.violations.best_practices import (
-    HeterogenousCompareViolation,
-)
 from wemake_python_styleguide.violations.consistency import (
     MultipleInCompareViolation,
 )
-from wemake_python_styleguide.visitors.ast.compares import CompareSanityVisitor
+from wemake_python_styleguide.violations.refactoring import (
+    InCompareWithSingleItemContainerViolation,
+)
+from wemake_python_styleguide.visitors.ast.compares import (
+    InCompareSanityVisitor,
+)
 
 if_with_multiple_in_compares = 'if {0} in {1} in {2}: ...'
 if_without_multiple_in_compares = 'if {0} in {1}: ...'
@@ -41,10 +43,14 @@ def test_compare_with_in(
     """Compares work well for single ``in``."""
     tree = parse_ast_tree(in_not_in(code.format(*comparators)))
 
-    visitor = CompareSanityVisitor(default_options, tree=tree)
+    visitor = InCompareSanityVisitor(default_options, tree=tree)
     visitor.run()
 
-    assert_errors(visitor, [])
+    assert_errors(
+        visitor,
+        [],
+        ignored_types=(InCompareWithSingleItemContainerViolation,),
+    )
 
 
 @pytest.mark.parametrize('code', [
@@ -68,10 +74,14 @@ def test_compare_with_multiple_in(
     """Compares raise for multiple ``in`` cases."""
     tree = parse_ast_tree(in_not_in(code.format(*comparators)))
 
-    visitor = CompareSanityVisitor(default_options, tree=tree)
+    visitor = InCompareSanityVisitor(default_options, tree=tree)
     visitor.run()
 
-    assert_errors(visitor, [MultipleInCompareViolation])
+    assert_errors(
+        visitor,
+        [MultipleInCompareViolation],
+        ignored_types=(InCompareWithSingleItemContainerViolation,),
+    )
 
 
 def test_compare_with_mixed_in(
@@ -82,10 +92,7 @@ def test_compare_with_mixed_in(
     """Compares raise for multiple ``in`` and ``not in`` cases."""
     tree = parse_ast_tree('x in a not in b')
 
-    visitor = CompareSanityVisitor(default_options, tree=tree)
+    visitor = InCompareSanityVisitor(default_options, tree=tree)
     visitor.run()
 
-    assert_errors(visitor, [
-        MultipleInCompareViolation,
-        HeterogenousCompareViolation,
-    ])
+    assert_errors(visitor, [MultipleInCompareViolation])