Repository: wemake-services/wemake-python-styleguide

Issue:
Forbid assigning `lambda` to object properties
# Rule request

<!-- Thanks for coming up with a proposal! -->

## Thesis

<!-- What exactly do you propose? Please, provide code examples. -->

We should forbid code like: 

```python
class Example(object):
    def __init__(self):
        self.callback = lambda arg: arg + 1
```

## Reasoning

<!--
Why do you think this is a good idea?

Please, include some strong points and considerations.
-->

This will be very tricky to understand for users: `example.callback(1)` will look like a method, but it is not a method.

<!--

  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:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/wemake-services/wemake-python-styleguide/pull/3172?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 [(`17b724c`)](https://app.codecov.io/gh/wemake-services/wemake-python-styleguide/commit/17b724c6b8fc30850aa9dc6b63cc17ffd7d40bbd?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services) to head [(`4395318`)](https://app.codecov.io/gh/wemake-services/wemake-python-styleguide/commit/4395318f75479525e303449b7dd4a54db9cb641a?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     #3172   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          369       370    +1     
  Lines        12449     12458    +9     
  Branches       848       848           
=========================================
+ Hits         12449     12458    +9     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/wemake-services/wemake-python-styleguide/pull/3172?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 e36a4168..c21a73f7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -82,7 +82,8 @@ Semantic versioning in our case means:
 - Does not count `self`, `cls`, and `mcs` as arguments
   for `WPS211` complexity check anymore, #2394
 - `WPS303`: Underscore (`_`) now only allowed with 3 digits after it, #3120
-- Adds support to run `wemake-python-styleguide` as a `pre-commit` hook #2588
+- Adds new rule to forbid `lambda` assigns to special attributes, #1733
+- Adds support to run `wemake-python-styleguide` as a `pre-commit` hook, #2588
 
 ### Bugfixes
 
diff --git a/wemake_python_styleguide/logic/complexity/overuses.py b/wemake_python_styleguide/logic/complexity/overuses.py
index b7c5b235..43182442 100644
--- a/wemake_python_styleguide/logic/complexity/overuses.py
+++ b/wemake_python_styleguide/logic/complexity/overuses.py
@@ -1,7 +1,9 @@
 import ast
 
 from wemake_python_styleguide.compat.aliases import FunctionNodes
-from wemake_python_styleguide.constants import SPECIAL_ARGUMENT_NAMES_WHITELIST
+from wemake_python_styleguide.constants import (
+    SPECIAL_ARGUMENT_NAMES_WHITELIST,
+)
 from wemake_python_styleguide.logic import nodes, walk
 from wemake_python_styleguide.logic.arguments import call_args
 
diff --git a/wemake_python_styleguide/logic/tree/attributes.py b/wemake_python_styleguide/logic/tree/attributes.py
index 899ecc13..039ca84e 100644
--- a/wemake_python_styleguide/logic/tree/attributes.py
+++ b/wemake_python_styleguide/logic/tree/attributes.py
@@ -49,3 +49,10 @@ def is_foreign_attribute(node: AnyVariableDef) -> bool:
     # So, we change the strictness of this rule,
     # based on the attribute source.
     return node.value.id not in SPECIAL_ARGUMENT_NAMES_WHITELIST
+
+
+def is_special_attr(node: ast.Attribute) -> bool:
+    """Finds attributes that are assigned to `self`, `cls`, etc."""
+    if not isinstance(node.value, ast.Name):
+        return False
+    return node.value.id in SPECIAL_ARGUMENT_NAMES_WHITELIST
diff --git a/wemake_python_styleguide/logic/tree/classes.py b/wemake_python_styleguide/logic/tree/classes.py
index da1afd18..75f25d3a 100644
--- a/wemake_python_styleguide/logic/tree/classes.py
+++ b/wemake_python_styleguide/logic/tree/classes.py
@@ -65,7 +65,7 @@ def get_attributes(
     instance_attributes = []
 
     for subnode in ast.walk(node):
-        instance_attr = _get_instance_attribute(subnode)
+        instance_attr = get_instance_attribute(subnode)
         if instance_attr is not None:
             instance_attributes.append(instance_attr)
             continue
@@ -80,7 +80,8 @@ def get_attributes(
     return class_attributes, instance_attributes
 
 
-def _get_instance_attribute(node: ast.AST) -> ast.Attribute | None:
+def get_instance_attribute(node: ast.AST) -> ast.Attribute | None:
+    """Returns node if this is an instance attribute or `None` if not."""
     return (
         node
         if (
diff --git a/wemake_python_styleguide/violations/oop.py b/wemake_python_styleguide/violations/oop.py
index 5b7d3f13..c843fff5 100644
--- a/wemake_python_styleguide/violations/oop.py
+++ b/wemake_python_styleguide/violations/oop.py
@@ -29,6 +29,7 @@ Summary
    WrongDescriptorDecoratorViolation
    UnpythonicGetterSetterViolation
    BuggySuperContextViolation
+   LambdaAttributeAssignedViolation
 
 Respect your objects
 --------------------
@@ -50,6 +51,7 @@ Respect your objects
 .. autoclass:: WrongDescriptorDecoratorViolation
 .. autoclass:: UnpythonicGetterSetterViolation
 .. autoclass:: BuggySuperContextViolation
+.. autoclass:: LambdaAttributeAssignedViolation
 
 """
 
@@ -678,14 +680,49 @@ class BuggySuperContextViolation(ASTViolation):
 
     Example::
 
-        # Correct
+        # Correct:
         (super(cls, self).augment(it) for it in items)
 
-        # Wrong
+        # Wrong:
         (super().augment(it) for it in items)
 
     .. versionadded:: 0.18.0
+
     """
 
     error_template = 'Found incorrect form of `super()` call for the context'
     code = 616
+
+
+@final
+class LambdaAttributeAssignedViolation(ASTViolation):
+    """
+    Forbid using ``lambda`` as an assigned attribute.
+
+    Reasoning:
+        Assigning ``lambda`` as an attribute does not make much sense.
+        And can lead to potentially incorrect code.
+
+    Solution:
+        Use ``def`` statements to create regular or class methods.
+
+    Example::
+
+        # Correct:
+        class Used:
+            def login(self): ...
+
+        # Wrong:
+        class User:
+            def __init__(self):
+                self.login = lambda: ...
+
+    See als:
+        https://docs.astral.sh/ruff/rules/lambda-assignment
+
+    .. versionadded:: 1.0.0
+
+    """
+
+    error_template = 'Found lambda assigned as an attribute'
+    code = 617
diff --git a/wemake_python_styleguide/visitors/ast/attributes.py b/wemake_python_styleguide/visitors/ast/attributes.py
index bbe756f1..3c507215 100644
--- a/wemake_python_styleguide/visitors/ast/attributes.py
+++ b/wemake_python_styleguide/visitors/ast/attributes.py
@@ -5,10 +5,10 @@ from typing_extensions import final
 from wemake_python_styleguide.compat.aliases import FunctionNodes
 from wemake_python_styleguide.constants import (
     ALL_MAGIC_METHODS,
-    SPECIAL_ARGUMENT_NAMES_WHITELIST,
 )
 from wemake_python_styleguide.logic import nodes
 from wemake_python_styleguide.logic.naming import access
+from wemake_python_styleguide.logic.tree import attributes
 from wemake_python_styleguide.violations.base import ASTViolation
 from wemake_python_styleguide.violations.best_practices import (
     ProtectedAttributeViolation,
@@ -37,10 +37,7 @@ class WrongAttributeVisitor(BaseNodeVisitor):
         node: ast.Attribute,
         exception: type[ASTViolation],
     ) -> None:
-        if (
-            isinstance(node.value, ast.Name)
-            and node.value.id in SPECIAL_ARGUMENT_NAMES_WHITELIST
-        ):
+        if attributes.is_special_attr(node):
             return
 
         if isinstance(node.value, ast.Call) and self._is_super_called(
diff --git a/wemake_python_styleguide/visitors/ast/classes.py b/wemake_python_styleguide/visitors/ast/classes.py
index 903dbc80..be996952 100644
--- a/wemake_python_styleguide/visitors/ast/classes.py
+++ b/wemake_python_styleguide/visitors/ast/classes.py
@@ -350,13 +350,18 @@ class WrongSlotsVisitor(base.BaseNodeVisitor):
 
 @final
 class ClassAttributeVisitor(base.BaseNodeVisitor):
-    """Finds incorrect class attributes."""
+    """Finds incorrectattributes."""
 
     def visit_ClassDef(self, node: ast.ClassDef) -> None:
-        """Checks that class attributes are correct."""
+        """Checks that assigned attributes are correct."""
         self._check_attributes_shadowing(node)
         self.generic_visit(node)
 
+    def visit_Lambda(self, node: ast.Lambda) -> None:
+        """Finds `lambda` assigns in attributes."""
+        self._check_lambda_attribute(node)
+        self.generic_visit(node)
+
     def _check_attributes_shadowing(self, node: ast.ClassDef) -> None:
         class_attributes, instance_attributes = classes.get_attributes(
             node,
@@ -375,6 +380,24 @@ class ClassAttributeVisitor(base.BaseNodeVisitor):
                     ),
                 )
 
+    def _check_lambda_attribute(self, node: ast.Lambda) -> None:
+        assigned = walk.get_closest_parent(node, AssignNodes)
+        if not assigned or not isinstance(assigned, ast.Assign):
+            return  # just used, not assigned
+
+        context = nodes.get_context(assigned)
+        if not isinstance(context, types.AnyFunctionDef) or not isinstance(
+            nodes.get_context(context),
+            ast.ClassDef,
+        ):
+            return  # it is not assigned in a method of a class
+
+        for attribute in assigned.targets:
+            if isinstance(
+                attribute, ast.Attribute
+            ) and attributes.is_special_attr(attribute):
+                self.add_violation(oop.LambdaAttributeAssignedViolation(node))
+
 
 @final
 class ClassMethodOrderVisitor(base.BaseNodeVisitor):


Test patch:
diff --git a/tests/fixtures/noqa/noqa.py b/tests/fixtures/noqa/noqa.py
index 936f127a..edecf72e 100644
--- a/tests/fixtures/noqa/noqa.py
+++ b/tests/fixtures/noqa/noqa.py
@@ -675,6 +675,11 @@ class Antediluvian(Baseline):
         return (super().method(some_item) for some_item in items)  # noqa: WPS616
 
 
+class LambdaAssign:
+    def __init__(self):
+        self.attribute = lambda self_arg: int(self_arg) + 1  # noqa: WPS617
+
+
 # porting noqa38.py
 class WithStatic:
     @staticmethod
diff --git a/tests/test_checker/test_noqa.py b/tests/test_checker/test_noqa.py
index b7d0ee58..03339884 100644
--- a/tests/test_checker/test_noqa.py
+++ b/tests/test_checker/test_noqa.py
@@ -280,6 +280,7 @@ SHOULD_BE_RAISED = types.MappingProxyType(
         'WPS614': 1,
         'WPS615': 2,
         'WPS616': 1,
+        'WPS617': 1,
     },
 )
 
diff --git a/tests/test_visitors/test_ast/test_classes/test_lambda_attr_assign.py b/tests/test_visitors/test_ast/test_classes/test_lambda_attr_assign.py
new file mode 100644
index 00000000..97b71170
--- /dev/null
+++ b/tests/test_visitors/test_ast/test_classes/test_lambda_attr_assign.py
@@ -0,0 +1,127 @@
+import pytest
+
+from wemake_python_styleguide.violations.oop import (
+    LambdaAttributeAssignedViolation,
+)
+from wemake_python_styleguide.visitors.ast.classes import ClassAttributeVisitor
+
+# Correct:
+
+correct_lambda_usage = """
+class A:
+    def method(self):
+        return filter(lambda x: x - 1, items)
+"""
+
+# This might be an API or something:
+correct_lambda_attr_assign1 = """
+class A:
+    def method(self, other):
+        other.api = lambda x: x * 2
+"""
+
+correct_lambda_attr_assign2 = """
+other.api = lambda x: x * 2
+"""
+
+correct_lambda_attr_assign3 = """
+class A:
+    other.api = lambda x: x * 2
+"""
+
+correct_lambda_attr_assign4 = """
+def some(other):
+    other.api = lambda x: x * 2
+"""
+
+# This is not strictly correct, but we won't detect this:
+# TODO: maybe we should after we can create pairs of assigned values?
+correct_lambda_attr_assign5 = """
+class A:
+    def __init__(self):
+        self._api, self.other = (3, lambda x: x * 2)
+"""
+
+correct_lambda_attr_assign6 = """
+class A:
+    def method(self):
+        self.other.api = lambda x: x * 2
+"""
+
+# Wrong:
+
+wrong_lambda_assign1 = """
+class A:
+    def method(self, other):
+        self.api = lambda x: x * 2
+"""
+
+wrong_lambda_assign2 = """
+class A:
+    def __init__(self):
+        self._api = lambda x: x * 2
+"""
+
+wrong_lambda_assign3 = """
+class A:
+    @classmethod
+    def build(cls):
+        cls.__builder = lambda x: x * 2
+"""
+
+wrong_lambda_assign4 = """
+class A:
+    def __init__(self):
+        self._api = temp = lambda x: x * 2
+"""
+
+
+@pytest.mark.parametrize(
+    'code',
+    [
+        wrong_lambda_assign1,
+        wrong_lambda_assign2,
+        wrong_lambda_assign3,
+        wrong_lambda_assign4,
+    ],
+)
+def test_wrong_lambda_assign(
+    assert_errors,
+    parse_ast_tree,
+    default_options,
+    code,
+):
+    """Testing that wrong `lambda` assigns raise."""
+    tree = parse_ast_tree(code)
+
+    visitor = ClassAttributeVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [LambdaAttributeAssignedViolation])
+
+
+@pytest.mark.parametrize(
+    'code',
+    [
+        correct_lambda_usage,
+        correct_lambda_attr_assign1,
+        correct_lambda_attr_assign2,
+        correct_lambda_attr_assign3,
+        correct_lambda_attr_assign4,
+        correct_lambda_attr_assign5,
+        correct_lambda_attr_assign6,
+    ],
+)
+def test_correct_lambda_assign(
+    assert_errors,
+    parse_ast_tree,
+    default_options,
+    code,
+):
+    """Testing that correct `lambda` assigns do not raise."""
+    tree = parse_ast_tree(code)
+
+    visitor = ClassAttributeVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [])