Repository: wemake-services/wemake-python-styleguide

Issue:
Restrict incorrect comparison order
# Rule request

## Thesis

There are two way of how one can use compare operators in python:

1. `if some_x > 3:`
2. `if 3 < some_x:`

The same applies to `if some_x == 3:` vs `if 3 == some_x:`

We need to enforce the following rule: argument should come the first.
This does not apply to `if 3 < some_x < 5:`. This is perfectly fine.

When there are two variables or more, do nothing. It is impossible to find the correct order.

## Reasoning

This will bring a consistency to the comparison. Making it more readable. 



Discussion:

Resolving patch:
diff --git a/wemake_python_styleguide/violations/consistency.py b/wemake_python_styleguide/violations/consistency.py
--- a/wemake_python_styleguide/violations/consistency.py
+++ b/wemake_python_styleguide/violations/consistency.py
@@ -41,6 +41,7 @@
    MultipleIfsInComprehensionViolation
    ConstantComparisonViolation
    BadNumberSuffixViolation
+   ComparisonOrderViolation
 
 Consistency checks
 ------------------
@@ -55,6 +56,7 @@
 .. autoclass:: MultipleIfsInComprehensionViolation
 .. autoclass:: ConstantComparisonViolation
 .. autoclass:: BadNumberSuffixViolation
+.. autoclass:: ComparisonOrderViolation
 
 """
 
@@ -401,3 +403,32 @@ class BadNumberSuffixViolation(TokenizeViolation):
     code = 309
     #: Error message shown to the user.
     error_template = 'Found underscored number: {0}'
+
+
+class ComparisonOrderViolation(ASTViolation):
+    """
+    Forbids comparisions where argument doesn't come first.
+
+    Reasoning:
+        brings a consistency to the comparison.
+
+    Solution:
+        Refactor your comparison expression.
+
+    Example::
+
+        # Wrong:
+        if 3 < some_x:
+
+        # Correct:
+        if some_x > 3:
+
+    Note:
+        Returns Z310 as error code
+
+    """
+
+    should_use_text = False
+    #: Error message shown to the user.
+    error_template = 'Found inconsistent comparison order'
+    code = 310
diff --git a/wemake_python_styleguide/visitors/ast/order.py b/wemake_python_styleguide/visitors/ast/order.py
new file mode 100644
--- /dev/null
+++ b/wemake_python_styleguide/visitors/ast/order.py
@@ -0,0 +1,69 @@
+# -*- coding: utf-8 -*-
+
+import ast
+from typing import List
+
+from wemake_python_styleguide.violations.consistency import (
+    ComparisonOrderViolation,
+)
+from wemake_python_styleguide.visitors.base import BaseNodeVisitor
+
+
+class WrongOrderVisitor(BaseNodeVisitor):
+    """Restricts comparisions where argument doesn't come first."""
+
+    def _check_for_in_op(self, operators: List[ast.cmpop]) -> bool:
+        for operator in operators:
+            if (isinstance(operator, ast.In) or
+                    isinstance(operator, ast.NotIn)):
+                return True
+
+        return False
+
+    def _get_num_variables_and_calls(self, comparators: List[ast.expr]) -> int:
+        count = 0
+        for comparator in comparators:
+            if (isinstance(comparator, ast.Name) or
+                    isinstance(comparator, ast.Call)):
+                count += 1
+
+        return count
+
+    def _get_num_variables_and_calls_in_BinOp(self, node: ast.AST):
+        count = 0
+        if not isinstance(node, ast.BinOp):
+            return 0
+        if isinstance(node.left, ast.Name) or isinstance(node.left, ast.Call):
+            count += 1
+        if isinstance(node.right, ast.Name) or isinstance(node.right, ast.Call):
+            count += 1
+        if count != 0:
+            return count
+
+        return (self._get_num_variables_and_calls_in_BinOp(node.left) +
+                self._get_num_variables_and_calls_in_BinOp(node.right))
+
+    def _check_order(self, node: ast.Compare) -> None:
+        if isinstance(node.left, ast.Name) or isinstance(node.left, ast.Call):
+            return
+        if (self._get_num_variables_and_calls(node.comparators) > 1 or
+                self._get_num_variables_and_calls_in_BinOp(node.left) > 0):
+            return
+        if self._check_for_in_op(node.ops):
+            return
+        if (not isinstance(node.comparators[-1], ast.Name) and
+                not isinstance(node.comparators[-1], ast.BinOp)):
+            return
+
+        self.add_violation(ComparisonOrderViolation(node))
+
+    def visit_Compare(self, node: ast.Compare) -> None:
+        """
+        Forbids comparisions where argument doesn't come first.
+
+        Raises:
+            ComparisonOrderViolation
+
+        """
+        self._check_order(node)
+        self.generic_visit(node)
diff --git a/wemake_python_styleguide/visitors/presets/general.py b/wemake_python_styleguide/visitors/presets/general.py
--- a/wemake_python_styleguide/visitors/presets/general.py
+++ b/wemake_python_styleguide/visitors/presets/general.py
@@ -10,6 +10,7 @@
 )
 from wemake_python_styleguide.visitors.ast.imports import WrongImportVisitor
 from wemake_python_styleguide.visitors.ast.modules import WrongContentsVisitor
+from wemake_python_styleguide.visitors.ast.order import WrongOrderVisitor
 from wemake_python_styleguide.visitors.ast.strings import WrongStringVisitor
 from wemake_python_styleguide.visitors.filenames.wrong_module_name import (
     WrongModuleNameVisitor,
@@ -28,6 +29,7 @@
     WrongStringVisitor,
     WrongContentsVisitor,
     ConstantComparisonVisitor,
+    WrongOrderVisitor,
 
     # Classes:
     WrongClassVisitor,


Test patch:
diff --git a/tests/test_visitors/test_ast/test_general/test_comparison_order.py b/tests/test_visitors/test_ast/test_general/test_comparison_order.py
new file mode 100644
--- /dev/null
+++ b/tests/test_visitors/test_ast/test_general/test_comparison_order.py
@@ -0,0 +1,253 @@
+# -*- coding: utf-8 -*-
+
+import pytest
+
+from wemake_python_styleguide.violations.consistency import (
+    ComparisonOrderViolation,
+)
+from wemake_python_styleguide.visitors.ast.order import WrongOrderVisitor
+
+# Templates to be checked
+
+if_with_is = """
+if {0} is {1}:
+    return 1
+"""
+
+if_with_is_not = """
+if {0} is not {1}:
+    return 1
+"""
+
+if_with_eq = """
+if {0} == {1}:
+    return 1
+"""
+
+if_with_not_eq = """
+if {0} != {1}:
+    return 1
+"""
+
+if_with_gt = """
+if {0} > {1}:
+    return 1
+"""
+
+if_with_lt = """
+if {0} < {1}:
+    return 1
+"""
+
+ternary = """
+ternary = 5 if {0} > {1} else 6
+"""
+
+while_construct = """
+while {0} > {1}:
+    return 1
+"""
+
+if_with_compound_expr = """
+if {0} > {1} and {0} < {1}:
+    return 1
+"""
+
+if_with_chained_comparisons = """
+if 0 < {0} < {1}:
+    return 1
+"""
+
+if_with_in = """
+if {0} in a:
+    return 1
+"""
+
+if_with_not_in = """
+if {0} not in a:
+    return 1
+"""
+
+
+@pytest.mark.parametrize('code', [
+    if_with_is,
+    if_with_is_not,
+    if_with_eq,
+    if_with_not_eq,
+    if_with_lt,
+    if_with_gt,
+    ternary,
+    while_construct,
+    if_with_compound_expr,
+])
+@pytest.mark.parametrize('variable', [
+    ('a', 'b'),
+])
+def test_comparison_variables(
+    assert_errors,
+    parse_ast_tree,
+    code,
+    variable,
+    default_options,
+):
+    """Testing : comparisons work well for no literal."""
+    tree = parse_ast_tree(code.format(variable[0], variable[1]))
+
+    visitor = WrongOrderVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [])
+
+
+@pytest.mark.parametrize('code', [
+    if_with_is,
+    if_with_is_not,
+    if_with_eq,
+    if_with_not_eq,
+    if_with_lt,
+    if_with_gt,
+    ternary,
+    while_construct,
+    if_with_compound_expr,
+])
+@pytest.mark.parametrize('var_lit', [
+    ('a', 1),
+    ('a', 2.3),
+    ('a', [1, 2]),
+])
+def test_comparison_literal_right(
+    assert_errors,
+    parse_ast_tree,
+    code,
+    var_lit,
+    default_options,
+):
+    """Testing : comparisons work well with argument on left."""
+    tree = parse_ast_tree(code.format(var_lit[0], var_lit[1]))
+
+    visitor = WrongOrderVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [])
+
+
+@pytest.mark.parametrize('code', [
+    if_with_is,
+    if_with_is_not,
+    if_with_eq,
+    if_with_not_eq,
+    if_with_lt,
+    if_with_gt,
+    ternary,
+    while_construct,
+])
+@pytest.mark.parametrize('lit_var', [
+    (1, 'a'),
+    (2.3, 'a'),
+    ([1, 2], 'a'),
+])
+def test_wrong_comparison(
+    assert_errors,
+    parse_ast_tree,
+    code,
+    lit_var,
+    default_options,
+):
+    """Testing : violations are raised with bad comparisons."""
+    tree = parse_ast_tree(code.format(lit_var[0], lit_var[1]))
+
+    visitor = WrongOrderVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [ComparisonOrderViolation])
+
+
+@pytest.mark.parametrize('code', [
+    if_with_compound_expr,
+])
+@pytest.mark.parametrize('lit_var', [
+    ([1, 2], 'a'),
+])
+def test_wrong_compound_comparison(
+    assert_errors,
+    parse_ast_tree,
+    code,
+    lit_var,
+    default_options,
+):
+    """Testing : violations are raised with bad comparisons."""
+    tree = parse_ast_tree(code.format(lit_var[0], lit_var[1]))
+
+    visitor = WrongOrderVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [ComparisonOrderViolation, ComparisonOrderViolation])
+
+
+@pytest.mark.parametrize('code', [
+    if_with_chained_comparisons,
+])
+@pytest.mark.parametrize('var_lit', [
+    ('a', 1),
+])
+def test_consistent_chained_comparison(
+    assert_errors,
+    parse_ast_tree,
+    code,
+    var_lit,
+    default_options,
+):
+    """Testing : no violations with correct comparisons."""
+    tree = parse_ast_tree(code.format(var_lit[0], var_lit[1]))
+
+    visitor = WrongOrderVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [])
+
+
+@pytest.mark.parametrize('code', [
+    if_with_chained_comparisons,
+])
+@pytest.mark.parametrize('lit_var', [
+    (5, 'x'),
+])
+def test_inconsistent_chained_comparison(
+    assert_errors,
+    parse_ast_tree,
+    code,
+    lit_var,
+    default_options,
+):
+    """Testing : violations are raised with bad comparisons."""
+    tree = parse_ast_tree(code.format(lit_var[0], lit_var[1]))
+
+    visitor = WrongOrderVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [ComparisonOrderViolation])
+
+
+@pytest.mark.parametrize('code', [
+    if_with_in,
+    if_with_not_in,
+])
+@pytest.mark.parametrize('literal', [
+    5,
+    1.2,
+    [1, 2],
+])
+def test_in_comparison(
+    assert_errors,
+    parse_ast_tree,
+    code,
+    literal,
+    default_options,
+):
+    """Testing : no violations with 'in' comparisons."""
+    tree = parse_ast_tree(code.format(literal))
+
+    visitor = WrongOrderVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [])
diff --git a/tests/test_visitors/test_ast/test_general/test_comparison_order_func.py b/tests/test_visitors/test_ast/test_general/test_comparison_order_func.py
new file mode 100644
--- /dev/null
+++ b/tests/test_visitors/test_ast/test_general/test_comparison_order_func.py
@@ -0,0 +1,71 @@
+# -*- coding: utf-8 -*-
+
+import pytest
+
+from wemake_python_styleguide.visitors.ast.order import WrongOrderVisitor
+
+# Templates to be checked
+
+if_with_func_call = """
+if len(a) > b:
+    return 1
+"""
+
+if_with_complex_call_1 = """
+if random() + index - some > index:
+    return 1
+"""
+
+if_with_complex_call_2 = """
+if (index - some) + (x + y) > index:
+    return 1
+"""
+
+if_with_method_call = """
+if index > some_object.get_index():
+    return 1
+"""
+
+more_than_one_variable = """
+if 0 < b < c:
+    return 1
+"""
+
+
+@pytest.mark.parametrize('code', [
+    if_with_func_call,
+    if_with_complex_call_1,
+    if_with_complex_call_2,
+    if_with_method_call,
+])
+def test_functions_methods_comparison(
+    assert_errors,
+    parse_ast_tree,
+    code,
+    default_options,
+):
+    """Testing : no violations with correct comparisons."""
+    tree = parse_ast_tree(code)
+
+    visitor = WrongOrderVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [])
+
+
+@pytest.mark.parametrize('code', [
+    more_than_one_variable,
+])
+def test_more_variables_comparison(
+    assert_errors,
+    parse_ast_tree,
+    code,
+    default_options,
+):
+    """Testing : no violations with correct comparisons."""
+    tree = parse_ast_tree(code)
+
+    visitor = WrongOrderVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [])