Repository: wemake-services/wemake-python-styleguide

Issue:
List should be treated as invalid unpacking targets
# Rule request

<!-- Thanks for coming up with a proposal! -->

## Thesis

<!-- What exactly do you propose? Please, provide code examples. -->

Currently, this code passes our linter:

```python
[first, second] = some()
```

But, it is wrong. We need to force:

```python
first, second = some()
```

So, we need to disallow `ast.List` as an unpacking target.

## Reasoning

<!--
Why do you think this is a good idea?

Please, include some strong points and considerations.
-->

There's an already a working way of doing unpacking assignment: tuples.
We don't need one more way to do it.

I would say this is a consistency rule.



Discussion:
Can I prepare PR for this issue?
@skarzi sure! Thanks a lot!

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
@@ -82,6 +82,7 @@
    IterableUnpackingViolation
    LineCompriseCarriageReturnViolation
    FloatZeroViolation
+   UnpackingIterableToListViolation
 
 Consistency checks
 ------------------
@@ -145,6 +146,7 @@
 .. autoclass:: IterableUnpackingViolation
 .. autoclass:: LineCompriseCarriageReturnViolation
 .. autoclass:: FloatZeroViolation
+.. autoclass:: UnpackingIterableToListViolation
 
 """
 
@@ -2197,3 +2199,32 @@ class FloatZeroViolation(TokenizeViolation):
 
     code = 358
     error_template = 'Found a float zero (0.0)'
+
+
+@final
+class UnpackingIterableToListViolation(ASTViolation):
+    """
+    Forbids to unpack iterable objects to lists.
+
+    Reasoning:
+        We do this for consistency.
+
+    Solution:
+        Do not unpack iterables to lists, use tuples for that.
+
+    Example::
+
+        # Correct:
+        first, second = (7, 4)
+        first, *iterable = other_iterable
+
+        # Wrong:
+        [first, second] = (7, 4)
+        [first, *iterable] = other_iterable
+
+    .. versionadded:: 0.15.0
+
+    """
+
+    error_template = 'Found an iterable unpacking to list'
+    code = 359
diff --git a/wemake_python_styleguide/visitors/ast/builtins.py b/wemake_python_styleguide/visitors/ast/builtins.py
--- a/wemake_python_styleguide/visitors/ast/builtins.py
+++ b/wemake_python_styleguide/visitors/ast/builtins.py
@@ -23,7 +23,7 @@
     FunctionNodes,
     TextNodes,
 )
-from wemake_python_styleguide.logic import nodes, safe_eval, source
+from wemake_python_styleguide.logic import nodes, safe_eval, source, walk
 from wemake_python_styleguide.logic.naming.name_nodes import extract_name
 from wemake_python_styleguide.logic.tree import operators, strings
 from wemake_python_styleguide.types import AnyFor, AnyNodes, AnyText, AnyWith
@@ -208,10 +208,12 @@ def visit_any_with(self, node: AnyWith) -> None:
         Checks assignments inside context managers to be correct.
 
         Raises:
+            UnpackingIterableToListViolation
             WrongUnpackingViolation
 
         """
         for withitem in node.items:
+            self._check_unpacking_target_types(withitem.optional_vars)
             if isinstance(withitem.optional_vars, ast.Tuple):
                 self._check_unpacking_targets(
                     node, withitem.optional_vars.elts,
@@ -223,9 +225,11 @@ def visit_comprehension(self, node: ast.comprehension) -> None:
         Checks comprehensions for the correct assignments.
 
         Raises:
+            UnpackingIterableToListViolation
             WrongUnpackingViolation
 
         """
+        self._check_unpacking_target_types(node.target)
         if isinstance(node.target, ast.Tuple):
             self._check_unpacking_targets(node.target, node.target.elts)
         self.generic_visit(node)
@@ -235,9 +239,11 @@ def visit_any_for(self, node: AnyFor) -> None:
         Checks assignments inside ``for`` loops to be correct.
 
         Raises:
+            UnpackingIterableToListViolation
             WrongUnpackingViolation
 
         """
+        self._check_unpacking_target_types(node.target)
         if isinstance(node.target, ast.Tuple):
             self._check_unpacking_targets(node, node.target.elts)
         self.generic_visit(node)
@@ -250,11 +256,16 @@ def visit_Assign(self, node: ast.Assign) -> None:
         because it does not have problems that we check.
 
         Raises:
+            UnpackingIterableToListViolation
             MultipleAssignmentsViolation
             WrongUnpackingViolation
 
         """
         self._check_assign_targets(node)
+
+        for target in node.targets:
+            self._check_unpacking_target_types(target)
+
         if isinstance(node.targets[0], ast.Tuple):
             self._check_unpacking_targets(node, node.targets[0].elts)
         self.generic_visit(node)
@@ -277,6 +288,14 @@ def _check_unpacking_targets(
                     best_practices.WrongUnpackingViolation(node),
                 )
 
+    def _check_unpacking_target_types(self, node: Optional[ast.AST]) -> None:
+        if not node:
+            return
+        for subnode in walk.get_subnodes_by_type(node, ast.List):
+            self.add_violation(
+                consistency.UnpackingIterableToListViolation(subnode),
+            )
+
 
 @final
 class WrongCollectionVisitor(base.BaseNodeVisitor):


Test patch:
diff --git a/tests/fixtures/noqa/noqa.py b/tests/fixtures/noqa/noqa.py
--- a/tests/fixtures/noqa/noqa.py
+++ b/tests/fixtures/noqa/noqa.py
@@ -707,6 +707,7 @@ def consecutive_yields():
     'wrong',
 ]
 *numbers, = [4, 7]  # noqa: WPS356
+[first_number, second_number] = [4, 7]  # noqa: WPS359
 
 for element in range(10):
     try:  # noqa: WPS452
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
@@ -167,6 +167,7 @@
     'WPS356': 1,
     'WPS357': 0,  # logically unacceptable.
     'WPS358': 1,
+    'WPS359': 1,
 
     'WPS400': 0,  # defined in ignored violations.
     'WPS401': 0,  # logically unacceptable.
diff --git a/tests/test_visitors/test_ast/test_builtins/test_assign/test_unpacking_to_list.py b/tests/test_visitors/test_ast/test_builtins/test_assign/test_unpacking_to_list.py
new file mode 100644
--- /dev/null
+++ b/tests/test_visitors/test_ast/test_builtins/test_assign/test_unpacking_to_list.py
@@ -0,0 +1,163 @@
+import pytest
+
+from wemake_python_styleguide.violations.best_practices import (
+    MultipleAssignmentsViolation,
+    WrongUnpackingViolation,
+)
+from wemake_python_styleguide.violations.consistency import (
+    UnpackingIterableToListViolation,
+)
+from wemake_python_styleguide.visitors.ast.builtins import (
+    WrongAssignmentVisitor,
+)
+
+list_target = '[first, second]'
+nested_list_target0 = '([first, second], third)'
+nested_list_tatget1 = '(first, [second, third])'
+nested_list_target2 = '(first, (second, [third, fourth]))'
+multiple_level_nested_list_target = '(first, (second, [third, fourth]))'
+
+spread_assignment_in_list_target = '[first, *rest]'
+nested_spread_assignment_in_list = '(first, [second, *rest])'
+multiple_level_nested_spread_assign_in_list = (
+    '(first, (second, [*rest, last]))'
+)
+
+regular_assignment = '{0} = some()'
+
+regular_multiple_assignment = 'result = {0} = some_other_result = some()'
+
+for_assignment = """
+def wrapper():
+    for {0} in iterable:
+        ...
+"""
+
+list_comprehension = """
+def wrapper():
+    comp = [1 for {0} in some()]
+"""
+
+generator_expression = """
+def wrapper():
+    comp = (1 for {0} in some())
+"""
+
+set_comprehension = """
+def wrapper():
+    comp = {{1 for {0} in some()}}
+"""
+
+dict_comprehension = """
+def wrapper():
+    comp = {{'1': 1 for {0} in some()}}
+"""
+
+with_assignment = """
+def wrapper():
+    with some() as {0}:
+        ...
+"""
+
+with_multiple_assignments = """
+def wrapper():
+    with some() as s, some_other() as {0} :
+        ...
+"""
+
+
+@pytest.mark.parametrize('assignment', [
+    list_target,
+    spread_assignment_in_list_target,
+])
+@pytest.mark.parametrize('code', [
+    regular_assignment,
+    for_assignment,
+    list_comprehension,
+    generator_expression,
+    set_comprehension,
+    dict_comprehension,
+    with_assignment,
+    with_multiple_assignments,
+])
+def test_unpacking_to_list(
+    assert_errors,
+    parse_ast_tree,
+    default_options,
+    code,
+    assignment,
+):
+    """Ensure that unpacking iterable to list is restricted."""
+    tree = parse_ast_tree(code.format(assignment))
+
+    visitor = WrongAssignmentVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [UnpackingIterableToListViolation])
+
+
+@pytest.mark.parametrize('assignment', [
+    nested_list_target0,
+    nested_list_tatget1,
+    nested_list_target2,
+    multiple_level_nested_list_target,
+    nested_spread_assignment_in_list,
+    multiple_level_nested_spread_assign_in_list,
+])
+@pytest.mark.parametrize('code', [
+    regular_assignment,
+    for_assignment,
+    list_comprehension,
+    generator_expression,
+    set_comprehension,
+    dict_comprehension,
+    with_assignment,
+    with_multiple_assignments,
+])
+def test_unpacking_to_nested_list(
+    assert_errors,
+    parse_ast_tree,
+    default_options,
+    code,
+    assignment,
+):
+    """Ensure that unpacking iterable to nested list is restricted."""
+    tree = parse_ast_tree(code.format(assignment))
+
+    visitor = WrongAssignmentVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(
+        visitor,
+        [UnpackingIterableToListViolation, WrongUnpackingViolation],
+    )
+
+
+@pytest.mark.parametrize('assignment', [
+    list_target,
+    nested_list_target0,
+    nested_list_tatget1,
+    nested_list_target2,
+    multiple_level_nested_list_target,
+    spread_assignment_in_list_target,
+    nested_spread_assignment_in_list,
+    multiple_level_nested_spread_assign_in_list,
+])
+@pytest.mark.parametrize('code', [regular_multiple_assignment])
+def test_unpacking_to_list_in_middle_target(
+    assert_errors,
+    parse_ast_tree,
+    default_options,
+    code,
+    assignment,
+):
+    """Ensure that unpacking iterable to list in middle target is restricted."""
+    tree = parse_ast_tree(code.format(assignment))
+
+    visitor = WrongAssignmentVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(
+        visitor,
+        [MultipleAssignmentsViolation, UnpackingIterableToListViolation],
+    )