Repository: wemake-services/wemake-python-styleguide

Issue:
Forbid magic methods to be async
# Rule request

<!-- Thanks for coming up with a proposal! -->

## Thesis

Only several magic method can be `async`. Other method should be just functions.

## Reasoning

<!--
Why do you think this is a good idea?

Please, include some strong points and considerations.
-->

Having `async __lt__(self, ...) -> ...` is incorrect. It cannot be used like so.
The same applies to almost all other method including `__init__`, `__new__`, `__bool__`, etc.
Only `__aenter__`, `__aexit__` and possibly some other can be `async`.

We must have an explicit list of whitelisted async magic methods.

<!--

  Love wemake-python-styleguide? ❤️
  Do you want your features to be implemented faster?

  Please consider supporting our collective:
👉  https://opencollective.com/wemake-python-styleguide/donate

-->



Discussion:
@bellum I guess this might be interesting to you 🙂 

@sobolevn might be :) according to this link https://docs.python.org/3/reference/datamodel.html there are next async dunder methods:
- `__anext__`
- `__aenter__`
- `__aexit__`

Should this list be hardcoded in `wemake_python_styleguide/constants.py` under the name like `ASYNC_MAGIC_METHODS_WHITELIST`?
I think so 👍 

Resolving patch:
diff --git a/wemake_python_styleguide/constants.py b/wemake_python_styleguide/constants.py
--- a/wemake_python_styleguide/constants.py
+++ b/wemake_python_styleguide/constants.py
@@ -115,6 +115,13 @@
     '__delattr__',  # since we don't use `delattr()`
 ))
 
+#: List of magic methods that are allowed to be async.
+ASYNC_MAGIC_METHODS_WHITELIST: Final = frozenset((
+    '__anext__',
+    '__aenter__',
+    '__aexit__',
+))
+
 #: List of nested classes' names we allow to use.
 NESTED_CLASSES_WHITELIST: Final = frozenset((
     'Meta',  # django forms, models, drf, etc
diff --git a/wemake_python_styleguide/violations/oop.py b/wemake_python_styleguide/violations/oop.py
--- a/wemake_python_styleguide/violations/oop.py
+++ b/wemake_python_styleguide/violations/oop.py
@@ -24,6 +24,7 @@
    WrongSlotsViolation
    WrongSuperCallViolation
    DirectMagicAttributeAccessViolation
+   AsyncMagicMethodViolation
 
 Respect your objects
 --------------------
@@ -38,6 +39,7 @@
 .. autoclass:: WrongSlotsViolation
 .. autoclass:: WrongSuperCallViolation
 .. autoclass:: DirectMagicAttributeAccessViolation
+.. autoclass:: AsyncMagicMethodViolation
 
 """
 
@@ -399,3 +401,45 @@ class DirectMagicAttributeAccessViolation(ASTViolation):
     error_template = 'Found direct magic attribute usage: {0}'
     code = 609
     previous_codes = {462}
+
+
+@final
+class AsyncMagicMethodViolation(ASTViolation):
+    """
+    Forbids to make some magic methods async.
+
+    We forbid to make ``__init__``, ``__eq__``, ``__lt__``, etc async.
+    We allow to make ``__anext__``, ``__aenter__``, ``__aexit__`` async.
+
+    See
+    :py:data:`~wemake_python_styleguide.constants.ASYNC_MAGIC_METHODS_WHITELIST`
+    for the whole list of whitelisted async magic methods.
+
+    Reasoning:
+        Defining the magic methods as async which are not supposed
+        to be async would not work as expected.
+
+    Solution:
+        Do not make this magic method async.
+
+    Example::
+
+        # Correct:
+        class Test(object):
+            def __lt__(self, other): ...
+
+        # Wrong:
+        class Test(object):
+            async def __lt__(self, other): ...
+
+
+    .. versionadded:: 0.12.0
+
+
+    See also:
+        https://docs.python.org/3/reference/datamodel.html
+
+    """
+
+    error_template = 'Found forbidden async magic method usage: {0}'
+    code = 610
diff --git a/wemake_python_styleguide/visitors/ast/classes.py b/wemake_python_styleguide/visitors/ast/classes.py
--- a/wemake_python_styleguide/visitors/ast/classes.py
+++ b/wemake_python_styleguide/visitors/ast/classes.py
@@ -120,6 +120,7 @@ def visit_any_function(self, node: types.AnyFunctionDef) -> None:
             BadMagicMethodViolation
             YieldInsideInitViolation
             MethodWithoutArgumentsViolation
+            AsyncMagicMethodViolation
 
         """
         self._check_decorators(node)
@@ -148,6 +149,13 @@ def _check_bound_methods(self, node: types.AnyFunctionDef) -> None:
                 oop.BadMagicMethodViolation(node, text=node.name),
             )
 
+        is_async = isinstance(node, ast.AsyncFunctionDef)
+        if is_async and access.is_magic(node.name):
+            if node.name not in constants.ASYNC_MAGIC_METHODS_WHITELIST:
+                self.add_violation(
+                    oop.AsyncMagicMethodViolation(node, text=node.name),
+                )
+
     def _check_method_contents(self, node: types.AnyFunctionDef) -> None:
         if node.name == constants.INIT:
             if is_contained(node, self._not_appropriate_for_init):


Test patch:
diff --git a/tests/fixtures/noqa.py b/tests/fixtures/noqa.py
--- a/tests/fixtures/noqa.py
+++ b/tests/fixtures/noqa.py
@@ -191,6 +191,9 @@ def __del__(self, *_args, **_kwargs):  # noqa: WPS603
     class Nested:  # noqa: WPS306,WPS431
         anti_z444 = 1
 
+    async def __eq__(self, other):  # noqa: WPS610
+        anti_z444 = 3
+
 
 magic_numbers = 13.2 + 50  # noqa: WPS432
 
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
@@ -156,6 +156,7 @@
     'WPS607': 1,
     'WPS608': 1,
     'WPS609': 1,
+    'WPS610': 1,
 })
 
 
diff --git a/tests/test_visitors/test_ast/test_classes/test_methods/test_async_magic_methods.py b/tests/test_visitors/test_ast/test_classes/test_methods/test_async_magic_methods.py
new file mode 100644
--- /dev/null
+++ b/tests/test_visitors/test_ast/test_classes/test_methods/test_async_magic_methods.py
@@ -0,0 +1,104 @@
+# -*- coding: utf-8 -*-
+
+import pytest
+
+from wemake_python_styleguide.constants import ASYNC_MAGIC_METHODS_WHITELIST
+from wemake_python_styleguide.violations.oop import AsyncMagicMethodViolation
+from wemake_python_styleguide.visitors.ast.classes import WrongMethodVisitor
+
+sync_method = """
+class Example(object):
+    def {0}(self): ...
+"""
+
+async_method = """
+class Example(object):
+    async def {0}(self): ...
+"""
+
+
+@pytest.mark.parametrize('method', [
+    '__init__',
+    '__eq__',
+    '__lt__',
+    '__next__',
+    '__enter__',
+    '__exit__',
+])
+def test_wrong_async_magic_used(
+    assert_errors,
+    assert_error_text,
+    parse_ast_tree,
+    method,
+    default_options,
+):
+    """Testing that some async magic methods are restricted."""
+    tree = parse_ast_tree(async_method.format(method))
+
+    visitor = WrongMethodVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [AsyncMagicMethodViolation])
+    assert_error_text(visitor, method)
+
+
+@pytest.mark.parametrize('method', ASYNC_MAGIC_METHODS_WHITELIST)
+def test_correct_async_magic_used(
+    assert_errors,
+    parse_ast_tree,
+    method,
+    default_options,
+):
+    """Testing that some async magic methods are working fine."""
+    tree = parse_ast_tree(async_method.format(method))
+
+    visitor = WrongMethodVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [])
+
+
+@pytest.mark.parametrize(
+    'method',
+    ASYNC_MAGIC_METHODS_WHITELIST | {
+        '__init__',
+        '__eq__',
+        '__lt__',
+        '__next__',
+        '__enter__',
+        '__exit__',
+    },
+)
+def test_sync_magic_used(
+    assert_errors,
+    parse_ast_tree,
+    method,
+    default_options,
+):
+    """Testing that any sync magic methods are working fine."""
+    tree = parse_ast_tree(sync_method.format(method))
+
+    visitor = WrongMethodVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [])
+
+
+@pytest.mark.parametrize('method', [
+    'next',
+    'regular',
+])
+def test_regular_method_used(
+    assert_errors,
+    parse_ast_tree,
+    method,
+    mode,
+    default_options,
+):
+    """Testing that other methods are working fine."""
+    tree = parse_ast_tree(mode(sync_method.format(method)))
+
+    visitor = WrongMethodVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [])
diff --git a/tests/test_visitors/test_ast/test_classes/test_methods/test_magic_methods.py b/tests/test_visitors/test_ast/test_classes/test_methods/test_magic_methods.py
--- a/tests/test_visitors/test_ast/test_classes/test_methods/test_magic_methods.py
+++ b/tests/test_visitors/test_ast/test_classes/test_methods/test_magic_methods.py
@@ -20,11 +20,10 @@ def test_wrong_magic_used(
     assert_error_text,
     parse_ast_tree,
     method,
-    mode,
     default_options,
 ):
     """Testing that some magic methods are restricted."""
-    tree = parse_ast_tree(mode(magic_method.format(method)))
+    tree = parse_ast_tree(magic_method.format(method))
 
     visitor = WrongMethodVisitor(default_options, tree=tree)
     visitor.run()
@@ -40,6 +39,28 @@ def test_wrong_magic_used(
 @pytest.mark.parametrize('method', [
     '__add__',
     '__init__',
+])
+def test_correct_magic_method_used(
+    assert_errors,
+    parse_ast_tree,
+    code,
+    method,
+    default_options,
+):
+    """Testing that some magic methods are working fine."""
+    tree = parse_ast_tree(code.format(method))
+
+    visitor = WrongMethodVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [])
+
+
+@pytest.mark.parametrize('code', [
+    magic_method,
+    regular_function,
+])
+@pytest.mark.parametrize('method', [
     'next',
     'regular',
 ])
diff --git a/tests/test_visitors/test_ast/test_classes/test_methods/test_yield_inside_init.py b/tests/test_visitors/test_ast/test_classes/test_methods/test_yield_inside_init.py
--- a/tests/test_visitors/test_ast/test_classes/test_methods/test_yield_inside_init.py
+++ b/tests/test_visitors/test_ast/test_classes/test_methods/test_yield_inside_init.py
@@ -36,10 +36,9 @@ def test_init_generator(
     assert_errors,
     parse_ast_tree,
     default_options,
-    mode,
 ):
-    """Testing that `__init__` without `yield` is prohibited."""
-    tree = parse_ast_tree(mode(init_with_yield))
+    """Testing that `__init__` with `yield` is prohibited."""
+    tree = parse_ast_tree(init_with_yield)
 
     visitor = WrongMethodVisitor(default_options, tree=tree)
     visitor.run()
@@ -49,17 +48,34 @@ def test_init_generator(
 
 @pytest.mark.parametrize('code', [
     init_without_yield,
-    regular_method_with_yield,
     iter_with_yield,
 ])
-def test_init_regular(
+def test_magic_methods_regular(
     assert_errors,
     parse_ast_tree,
     code,
     default_options,
-    mode,
 ):
     """Testing that `__init__` without `yield` is allowed."""
+    tree = parse_ast_tree(code)
+
+    visitor = WrongMethodVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [])
+
+
+@pytest.mark.parametrize('code', [
+    regular_method_with_yield,
+])
+def test_regular_method(
+    assert_errors,
+    parse_ast_tree,
+    code,
+    default_options,
+    mode,
+):
+    """Testing that regular method with `yield` is allowed."""
     tree = parse_ast_tree(mode(code))
 
     visitor = WrongMethodVisitor(default_options, tree=tree)