Repository: wemake-services/wemake-python-styleguide

Issue:
Forbid to unpack values like `**{'@': 1}` 
# Rule request

<!-- Thanks for coming up with a proposal! -->

## Thesis

<!-- What exactly do you propose? Please, provide code examples. -->
Related to #767

This should be forbidden: 

```
>>> print(**{'@': 2})
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: '@' is an invalid keyword argument for print()
```

To validate this we need to use `str.is_identifier`

## Reasoning

<!--
Why do you think this is a good idea?

Please, include some strong points and considerations.
-->

This is simply wrong.

<!--

  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:
Hello, Nikita! I would like to work on this issue.

Resolving patch:
diff --git a/wemake_python_styleguide/presets/types/tree.py b/wemake_python_styleguide/presets/types/tree.py
--- a/wemake_python_styleguide/presets/types/tree.py
+++ b/wemake_python_styleguide/presets/types/tree.py
@@ -28,6 +28,7 @@
     statements.StatementsWithBodiesVisitor,
     statements.WrongParametersIndentationVisitor,
     statements.PointlessStarredVisitor,
+    statements.WrongNamedKeywordVisitor,
 
     keywords.WrongRaiseVisitor,
     keywords.WrongKeywordVisitor,
diff --git a/wemake_python_styleguide/violations/best_practices.py b/wemake_python_styleguide/violations/best_practices.py
--- a/wemake_python_styleguide/violations/best_practices.py
+++ b/wemake_python_styleguide/violations/best_practices.py
@@ -64,6 +64,7 @@
    OuterScopeShadowingViolation
    UnhashableTypeInHashViolation
    WrongKeywordConditionViolation
+   WrongNamedKeywordViolation
 
 Best practices
 --------------
@@ -113,6 +114,7 @@
 .. autoclass:: OuterScopeShadowingViolation
 .. autoclass:: UnhashableTypeInHashViolation
 .. autoclass:: WrongKeywordConditionViolation
+.. autoclass:: WrongNamedKeywordViolation
 
 """
 
@@ -1741,3 +1743,30 @@ class WrongKeywordConditionViolation(ASTViolation):
 
     error_template = 'Found wrong keyword condition: {0}'
     code = 444
+
+
+class WrongNamedKeywordViolation(ASTViolation):
+    """
+    Forbids to have wrong named keywords in starred dicts.
+
+    Reasoning:
+        Using wrong keywords in starred dict.
+        Eg.: ``print(**{'@': 1})``.
+
+    Solution:
+        Don't use incorrect identifiers for keywords.
+
+    Example::
+
+        # Correct:
+        print(**{'end': '|'})
+
+        # Wrong:
+        print(**{'3end': '|'})
+
+    .. versionadded:: 0.13.0
+
+    """
+
+    code = 445
+    error_template = 'Found wrong named keyword in starred dict'
diff --git a/wemake_python_styleguide/visitors/ast/statements.py b/wemake_python_styleguide/visitors/ast/statements.py
--- a/wemake_python_styleguide/visitors/ast/statements.py
+++ b/wemake_python_styleguide/visitors/ast/statements.py
@@ -19,6 +19,7 @@
 from wemake_python_styleguide.violations.best_practices import (
     StatementHasNoEffectViolation,
     UnreachableCodeViolation,
+    WrongNamedKeywordViolation,
 )
 from wemake_python_styleguide.violations.consistency import (
     ParametersIndentationViolation,
@@ -341,3 +342,34 @@ def _has_non_string_keys(self, node: ast.keyword) -> bool:
             if not isinstance(key_node, ast.Str):
                 return True
         return False
+
+
+@final
+class WrongNamedKeywordVisitor(BaseNodeVisitor):
+    """Responsible for absence of wrong keywords."""
+
+    def visit_Call(self, node: ast.Call) -> None:
+        """Checks useless call arguments."""
+        self._check_double_starred_dict(node.keywords)
+        self.generic_visit(node)
+
+    def _check_double_starred_dict(
+        self,
+        keywords: Sequence[ast.keyword],
+    ) -> None:
+        for keyword in keywords:
+            if keyword.arg is not None:
+                continue
+
+            if self._has_wrong_keys(keyword):
+                self.add_violation(WrongNamedKeywordViolation(keyword.value))
+
+    def _has_wrong_keys(self, node: ast.keyword) -> bool:
+        if not isinstance(node.value, ast.Dict):
+            return False
+
+        for key_node in node.value.keys:
+            if isinstance(key_node, ast.Str):
+                if not str.isidentifier(key_node.s):
+                    return True
+        return False


Test patch:
diff --git a/tests/fixtures/noqa.py b/tests/fixtures/noqa.py
--- a/tests/fixtures/noqa.py
+++ b/tests/fixtures/noqa.py
@@ -327,7 +327,7 @@ def some_other_function():
 file_obj = open('filaname.py')  # noqa: WPS515
 print(type(file_obj) == int)  # noqa: WPS516
 
-print(*[])  # noqa: WPS517
+print(*[], **{'@': 1})  # noqa: WPS517, WPS445
 
 for range_len in range(len(file_obj)):  # noqa: WPS518
     print(range_len)
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
@@ -166,6 +166,7 @@
     'WPS442': 2,
     'WPS443': 1,
     'WPS444': 1,
+    'WPS445': 1,
 
     'WPS500': 1,
     'WPS501': 1,
@@ -184,7 +185,7 @@
     'WPS514': 1,
     'WPS515': 1,
     'WPS516': 1,
-    'WPS517': 1,
+    'WPS517': 2,
     'WPS518': 1,
     'WPS519': 1,
     'WPS520': 1,
diff --git a/tests/test_visitors/test_ast/test_statements/test_wrong_named_keyword.py b/tests/test_visitors/test_ast/test_statements/test_wrong_named_keyword.py
new file mode 100644
--- /dev/null
+++ b/tests/test_visitors/test_ast/test_statements/test_wrong_named_keyword.py
@@ -0,0 +1,68 @@
+# -*- coding: utf-8 -*-
+
+import pytest
+
+from wemake_python_styleguide.violations.best_practices import (
+    WrongNamedKeywordViolation,
+)
+from wemake_python_styleguide.visitors.ast.statements import (
+    WrongNamedKeywordVisitor,
+)
+
+
+@pytest.mark.parametrize('code', [
+    'print(**{"@": "2"})',
+    'print(**{"2ab": "2"})',
+    'print(**{"!": "2"})',
+    'print(**{"&a": "2"})',
+    'print(**{"()": "2"})',
+    'print(**{"-b": "2"})',
+    'print(**{"+1": "2"})',
+    'print(**{"#FFF": "2"})',
+    'print(**{"some.value": "2"})',
+    'print(**{"Username[FIELD]": "2"})',
+    'print(**{"call()": "2"})',
+    'print(end="|", **{"2ab": "2"})',
+])
+def test_wrong_starred_keyword(
+    assert_errors,
+    parse_ast_tree,
+    default_options,
+    code,
+):
+    """Testing that pointless starred expression is detected."""
+    tree = parse_ast_tree(code)
+
+    visitor = WrongNamedKeywordVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [WrongNamedKeywordViolation])
+
+
+@pytest.mark.parametrize('code', [
+    '_list = [1, 2]',
+    '_dict = {"end": " "}',
+    'print(*_list, **_dict)',
+    'print(end="1", **{"a": 1})',
+    'filter(**{User.USERNAME_FIELD: username})',  # noqa: P103
+    'filter(**{"a2": 1, _: 2})',
+    'filter(**{"a": 1, b: 2})',
+    'filter(**{"_a": 1, b: 2})',
+    'filter(**{"a_": 1, b: 2})',
+    'filter(**{"a": 1, call(): 2})',
+    'filter(**{"a": 1, b.method(): 2})',
+    'filter(**{b["a"]: 2})',
+])
+def test_correct_starred_keyword(
+    assert_errors,
+    parse_ast_tree,
+    default_options,
+    code,
+):
+    """Testing that pointless starred expression is detected."""
+    tree = parse_ast_tree(code)
+
+    visitor = WrongNamedKeywordVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [])