Repository: wemake-services/wemake-python-styleguide

Issue:
WPS504: triggered with multiple conditions
### What's wrong

Code like this seems to trigger a violation:

```
if not this():
    ...
elif not that():
    ...
else:
    ...
```

### How it should be

No violation. It would be awkward to rewrite the code without negations, as it would require either calculating the conditions multiple times or pre-calculating the conditions into variables beforehand.

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/wemake-services/wemake-python-styleguide/pull/3199?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 [(`b0033cf`)](https://app.codecov.io/gh/wemake-services/wemake-python-styleguide/commit/b0033cfb5c9183fde030579c1afb1d5beb6a852f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services) to head [(`183b63d`)](https://app.codecov.io/gh/wemake-services/wemake-python-styleguide/commit/183b63db6a10971079f76a6898897d3a3959841a?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     #3199   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          376       376           
  Lines        12712     12700   -12     
  Branches       871       868    -3     
=========================================
- Hits         12712     12700   -12     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/wemake-services/wemake-python-styleguide/pull/3199?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/CHANGELOG.md b/CHANGELOG.md
index 37376b94..6b1c9ef3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -91,6 +91,8 @@ Semantic versioning in our case means:
   in `@overload` definitions for `WPS211`, #1957
 - Allows docstrings after attributes in classes for `WPS428`, #3056
 - Allows using multiline strings when placed on separate lines, #3056
+- Disallows using `is not` and `not in` as negated conditions in `WPS504`, #2617
+- Allows all branches in `if/elif/else` to be negated in `WPS504`, #2617
 - 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
diff --git a/wemake_python_styleguide/logic/tree/getters_setters.py b/wemake_python_styleguide/logic/tree/getters_setters.py
index 3179ae5d..2f0294bd 100644
--- a/wemake_python_styleguide/logic/tree/getters_setters.py
+++ b/wemake_python_styleguide/logic/tree/getters_setters.py
@@ -20,15 +20,14 @@ def find_paired_getters_and_setters(
     node: ast.ClassDef,
 ) -> Iterable[AnyFunctionDef]:
     """Returns nodes of paired getter or setter methods."""
-    stack = {}
+    stack: dict[str, AnyFunctionDef] = {}
     for method in _find_getters_and_setters(node):
         method_stripped = method.name[GETTER_LENGTH:]
-        if method_stripped not in stack:
+        paired_method = stack.pop(method_stripped, None)
+        if paired_method is None:
             stack[method_stripped] = method
         else:
-            yield method
-            paired_method = stack.pop(method_stripped)
-            yield paired_method
+            yield from (method, paired_method)
 
 
 def find_attributed_getters_and_setters(
diff --git a/wemake_python_styleguide/logic/tree/ifs.py b/wemake_python_styleguide/logic/tree/ifs.py
index e1faf189..5c6e7b11 100644
--- a/wemake_python_styleguide/logic/tree/ifs.py
+++ b/wemake_python_styleguide/logic/tree/ifs.py
@@ -1,6 +1,8 @@
 import ast
-from collections.abc import Iterable
-from typing import TypeAlias
+from collections.abc import Iterable, Sequence
+from typing import ClassVar, TypeAlias, final
+
+from wemake_python_styleguide.types import AnyIf, AnyNodes
 
 _IfAndElifASTNode: TypeAlias = ast.If | list[ast.stmt]
 
@@ -36,3 +38,59 @@ def chain(node: ast.If) -> Iterable[_IfAndElifASTNode]:
         else:
             yield next_if
             iterator = next_if
+
+
+@final
+class NegatedIfConditions:
+    """Finds negated ``if`` nodes."""
+
+    _negated_ops: ClassVar[AnyNodes] = (
+        ast.NotEq,
+        ast.IsNot,
+        ast.NotIn,
+    )
+
+    def __init__(self) -> None:
+        """Collects visited nodes not to double report them."""
+        self._visited_ifs: set[ast.If] = set()
+
+    def negated_nodes(self, node: AnyIf) -> Sequence[AnyIf]:
+        """Returns the list of negated nodes to raise violations on."""
+        if isinstance(node, ast.If):
+            return self._process_if(node)
+        return self._process_ifexpr(node)
+
+    def _process_if(self, node: ast.If) -> Sequence[ast.If]:
+        if not has_else(node):
+            return []
+        negated_nodes = []
+        regular_nodes = []
+        for subnode in chain(node):
+            if not isinstance(subnode, ast.If) or subnode in self._visited_ifs:
+                continue
+            self._visited_ifs.add(subnode)
+
+            if self._is_negated_if_condition(subnode):
+                negated_nodes.append(subnode)
+            else:
+                regular_nodes.append(subnode)
+
+        if not regular_nodes and len(negated_nodes) > 1:
+            return []  # we allow all negated nodes in `if/elif/else`
+        return negated_nodes
+
+    def _process_ifexpr(self, node: ast.IfExp) -> Sequence[ast.IfExp]:
+        if not self._is_negated_if_condition(node):
+            return []
+        return [node]
+
+    def _is_negated_if_condition(self, node: AnyIf) -> bool:
+        return (
+            isinstance(node.test, ast.UnaryOp)
+            and isinstance(node.test.op, ast.Not)
+        ) or (
+            isinstance(node.test, ast.Compare)
+            and all(
+                isinstance(elem, self._negated_ops) for elem in node.test.ops
+            )
+        )
diff --git a/wemake_python_styleguide/violations/refactoring.py b/wemake_python_styleguide/violations/refactoring.py
index 3b104761..f327936c 100644
--- a/wemake_python_styleguide/violations/refactoring.py
+++ b/wemake_python_styleguide/violations/refactoring.py
@@ -286,12 +286,12 @@ class NegatedConditionsViolation(ASTViolation):
 
         # Correct:
         if some == 1:
-             ...
+            ...
         else:
-             ...
+            ...
 
         if not some:
-             ...
+            ...
 
         if not some:
             ...
@@ -300,12 +300,16 @@ class NegatedConditionsViolation(ASTViolation):
 
         # Wrong:
         if not some:
-             ...
+            ...
         else:
-             ...
+            ...
 
     .. versionadded:: 0.8.0
     .. versionchanged:: 0.11.0
+    .. versionchanged:: 1.0.0
+       We now also detect ``is not`` and ``not in`` as negated conditions.
+
+       We now allow using all negated conditions in ``if/elif/else`` cases.
 
     """
 
diff --git a/wemake_python_styleguide/visitors/ast/conditions.py b/wemake_python_styleguide/visitors/ast/conditions.py
index 30ba5354..f71781d1 100644
--- a/wemake_python_styleguide/visitors/ast/conditions.py
+++ b/wemake_python_styleguide/visitors/ast/conditions.py
@@ -50,6 +50,11 @@ class IfStatementVisitor(BaseNodeVisitor):
         ast.Set,
     )
 
+    def __init__(self, *args, **kwargs) -> None:
+        """Save visited ``if`` nodes."""
+        super().__init__(*args, **kwargs)
+        self._finder = ifs.NegatedIfConditions()
+
     def visit_any_if(self, node: AnyIf) -> None:
         """Checks ``if`` nodes and expressions."""
         self._check_negated_conditions(node)
@@ -59,16 +64,10 @@ class IfStatementVisitor(BaseNodeVisitor):
         self.generic_visit(node)
 
     def _check_negated_conditions(self, node: AnyIf) -> None:
-        if isinstance(node, ast.If) and not ifs.has_else(node):
-            return
-
-        if isinstance(node.test, ast.UnaryOp):
-            if isinstance(node.test.op, ast.Not):
-                self.add_violation(refactoring.NegatedConditionsViolation(node))
-        elif isinstance(node.test, ast.Compare) and any(
-            isinstance(elem, ast.NotEq) for elem in node.test.ops
-        ):
-            self.add_violation(refactoring.NegatedConditionsViolation(node))
+        for subnode in self._finder.negated_nodes(node):
+            self.add_violation(
+                refactoring.NegatedConditionsViolation(subnode),
+            )
 
     def _check_useless_len(self, node: AnyIf) -> None:
         if isinstance(node.test, ast.Call) and given_function_called(


Test patch:
diff --git a/tests/test_visitors/test_ast/test_conditions/test_negated_conditions.py b/tests/test_visitors/test_ast/test_conditions/test_negated_conditions.py
index d428f359..469eb687 100644
--- a/tests/test_visitors/test_ast/test_conditions/test_negated_conditions.py
+++ b/tests/test_visitors/test_ast/test_conditions/test_negated_conditions.py
@@ -34,6 +34,15 @@ else:
     ...
 """
 
+double_elif_else_conditions = """
+if {0}:
+    ...
+elif {1}:
+    ...
+else:
+    ...
+"""
+
 
 @pytest.mark.parametrize(
     'code',
@@ -44,6 +53,10 @@ else:
         'some',
         'some == 0',
         'some != other',
+        'some == 1 != other',
+        'some != first != second',
+        'some not in other',
+        'some is not None',
         'some > 1',
     ],
 )
@@ -71,7 +84,13 @@ def test_negated_simple_conditions(
         'some',
         'some == 0',
         'some != other',
+        'some != first != second',
+        'some == first != second',
         'some > 1',
+        'some is None',
+        'some is not None',
+        'some in other',
+        'some not in other',
     ],
 )
 def test_negated_complex_elif_conditions(
@@ -101,8 +120,10 @@ def test_negated_complex_elif_conditions(
     'code',
     [
         'not some',
-        'some != 1',
         'some != other',
+        'some != other != first',
+        'some is not None',
+        'some not in other',
     ],
 )
 def test_wrong_negated_complex_conditions(
@@ -135,8 +156,11 @@ def test_wrong_negated_complex_conditions(
         '-some',
         '~some',
         'some == 0',
+        'some == first == second',
         'some > -1',
         'some < other',
+        'some is None',
+        'some in other',
     ],
 )
 def test_correctly_negated_complex_conditions(
@@ -153,3 +177,29 @@ def test_correctly_negated_complex_conditions(
     visitor.run()
 
     assert_errors(visitor, [])
+
+
+@pytest.mark.parametrize(
+    ('first', 'second', 'violations'),
+    [
+        ('x == 1', 'y == 2', []),
+        ('x != 1', 'y != 2', []),
+        ('x not in 1', 'y != 2', []),
+        ('x != 1', 'y == 2', [NegatedConditionsViolation]),
+    ],
+)
+def test_correctly_double_negated_condtions(
+    first,
+    second,
+    violations,
+    assert_errors,
+    parse_ast_tree,
+    default_options,
+):
+    """Testing correctly negated complex conditions."""
+    tree = parse_ast_tree(double_elif_else_conditions.format(first, second))
+
+    visitor = IfStatementVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, violations)