Repository: wemake-services/wemake-python-styleguide

Issue:
Add exception to WPS226 to allow commas and dots
# Rule request

I suggest adding comma `,` and dot `.` to the list of allowed exceptions for WPS226

## Thesis

Forcing defining a constant for dot and comma strings worsen the code readability without need.

## Reasoning

It is a [classical](https://realpython.com/python-string-split-concatenate-join/#going-from-a-list-to-a-string-in-python-with-join) pattern to build a sentence with the code like:
```
strings = ['a', 'b', 'c']
','.join(strings)
```
It feels unnatural to define a constant (e.g. DOT, COMMA) for such obvious case.

Besides that the check WPS226 looks good. I don't like disabling it or increasing the number of string occurrences.
This extends the idea in https://github.com/wemake-services/wemake-python-styleguide/issues/1534

Discussion:
0anton: Thanks for approving, @sobolevn ! I see that the [test](https://github.com/wemake-services/wemake-python-styleguide/pull/2210/checks?check_run_id=3806985797) is breaking. This is possibly related to the change in the format of the error message, I introduces. Do I have to look at it?
sobolevn: Yes, seems related. Can you please fix it? 
0anton: I fixed failing tests with this https://github.com/wemake-services/wemake-python-styleguide/pull/2210/commits/41d090c1ccfbb2f36013586a190ae013c84157f2. Could you @sobolevn please let the pipeline run again?
0anton: Ah, my fault, I check that only `test_overused_string.py` is successful, not the complete test suit. I'll think of how to improve it and come back.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f566ce5b..910b3b58 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,7 +23,8 @@ Semantic versioning in our case means:
 
 - Now `InconsistentYieldViolation` and `InconsistentReturnViolation` are raised
   when `yield` or `return` is used with `None`
-  where plain version should be used
+  where plain version should be used #2151
+- Dot `'.'` and comma `','` do not count against string literal overuse limit anymore #2209
 - Added `RedundantEnumerateViolation` #1825
 - Adds `RaiseFromItselfViolation` #2133
 - Adds `ConsecutiveSlicesViolation` #2064
@@ -83,6 +84,10 @@ Semantic versioning in our case means:
 
 - Updates GitHub Action's base Python image version to `3.8.8`
 
+### Features
+
+- Adds a math operations evaluator to improve and allow several violation checks.
+
 
 ## 0.15.1
 
diff --git a/wemake_python_styleguide/logic/nodes.py b/wemake_python_styleguide/logic/nodes.py
index feb10dfb..c994aec5 100644
--- a/wemake_python_styleguide/logic/nodes.py
+++ b/wemake_python_styleguide/logic/nodes.py
@@ -1,6 +1,7 @@
 import ast
-from typing import Optional
+from typing import Optional, Union
 
+from wemake_python_styleguide.logic.safe_eval import literal_eval_with_names
 from wemake_python_styleguide.types import ContextNodes
 
 
@@ -26,3 +27,16 @@ def get_parent(node: ast.AST) -> Optional[ast.AST]:
 def get_context(node: ast.AST) -> Optional[ContextNodes]:
     """Returns the context or ``None`` if node has no context."""
     return getattr(node, 'wps_context', None)
+
+
+def evaluate_node(node: ast.AST) -> Optional[Union[int, float, str, bytes]]:
+    """Returns the value of a node or its evaluation."""
+    if isinstance(node, ast.Name):
+        return None
+    if isinstance(node, (ast.Str, ast.Bytes)):
+        return node.s
+    try:
+        signed_node = literal_eval_with_names(node)
+    except Exception:
+        return None
+    return signed_node
diff --git a/wemake_python_styleguide/transformations/ast/enhancements.py b/wemake_python_styleguide/transformations/ast/enhancements.py
index d5c24769..4013d198 100644
--- a/wemake_python_styleguide/transformations/ast/enhancements.py
+++ b/wemake_python_styleguide/transformations/ast/enhancements.py
@@ -1,8 +1,13 @@
 import ast
-from typing import Optional, Tuple, Type
+import operator
+from contextlib import suppress
+from types import MappingProxyType
+from typing import Optional, Tuple, Type, Union
+
+from typing_extensions import Final
 
 from wemake_python_styleguide.compat.aliases import FunctionNodes
-from wemake_python_styleguide.logic.nodes import get_parent
+from wemake_python_styleguide.logic.nodes import evaluate_node, get_parent
 from wemake_python_styleguide.types import ContextNodes
 
 _CONTEXTS: Tuple[Type[ContextNodes], ...] = (
@@ -11,6 +16,21 @@ _CONTEXTS: Tuple[Type[ContextNodes], ...] = (
     *FunctionNodes,
 )
 
+_AST_OPS_TO_OPERATORS: Final = MappingProxyType({
+    ast.Add: operator.add,
+    ast.Sub: operator.sub,
+    ast.Mult: operator.mul,
+    ast.Div: operator.truediv,
+    ast.FloorDiv: operator.floordiv,
+    ast.Mod: operator.mod,
+    ast.Pow: operator.pow,
+    ast.LShift: operator.lshift,
+    ast.RShift: operator.rshift,
+    ast.BitAnd: operator.and_,
+    ast.BitOr: operator.or_,
+    ast.BitXor: operator.xor,
+})
+
 
 def set_if_chain(tree: ast.AST) -> ast.AST:
     """
@@ -71,6 +91,31 @@ def set_node_context(tree: ast.AST) -> ast.AST:
     return tree
 
 
+def set_constant_evaluations(tree: ast.AST) -> ast.AST:
+    """
+    Used to evaluate operations between constants.
+
+    We want this to be able to analyze parts of the code in which a math
+    operation is making the linter unable to understand if the code is
+    compliant or not.
+
+    Example:
+    .. code:: python
+
+        value = array[1 + 0.5]
+
+    This should not be allowed, because we would be using a float to index an
+    array, but since there is an addition, the linter does not know that and
+    does not raise an error.
+    """
+    for stmt in ast.walk(tree):
+        parent = get_parent(stmt)
+        if isinstance(stmt, ast.BinOp) and not isinstance(parent, ast.BinOp):
+            evaluation = evaluate_operation(stmt)
+            setattr(stmt, 'wps_op_eval', evaluation)  # noqa: B010
+    return tree
+
+
 def _find_context(
     node: ast.AST,
     contexts: Tuple[Type[ast.AST], ...],
@@ -96,3 +141,27 @@ def _apply_if_statement(statement: ast.If) -> None:
             if child in statement.orelse:
                 setattr(statement, 'wps_if_chained', True)  # noqa: B010
                 setattr(child, 'wps_if_chain', statement)  # noqa: B010
+
+
+def evaluate_operation(
+    statement: ast.BinOp,
+) -> Optional[Union[int, float, str, bytes]]:
+    """Tries to evaluate all math operations inside the statement."""
+    if isinstance(statement.left, ast.BinOp):
+        left = evaluate_operation(statement.left)
+    else:
+        left = evaluate_node(statement.left)
+
+    if isinstance(statement.right, ast.BinOp):
+        right = evaluate_operation(statement.right)
+    else:
+        right = evaluate_node(statement.right)
+
+    op = _AST_OPS_TO_OPERATORS.get(type(statement.op))
+
+    evaluation = None
+    if op is not None:
+        with suppress(Exception):
+            evaluation = op(left, right)
+
+    return evaluation
diff --git a/wemake_python_styleguide/transformations/ast_tree.py b/wemake_python_styleguide/transformations/ast_tree.py
index 547028a6..b55be144 100644
--- a/wemake_python_styleguide/transformations/ast_tree.py
+++ b/wemake_python_styleguide/transformations/ast_tree.py
@@ -8,6 +8,7 @@ from wemake_python_styleguide.transformations.ast.bugfixes import (
     fix_line_number,
 )
 from wemake_python_styleguide.transformations.ast.enhancements import (
+    set_constant_evaluations,
     set_if_chain,
     set_node_context,
 )
@@ -85,6 +86,7 @@ def transform(tree: ast.AST) -> ast.AST:
         # Enhancements, order is not important:
         set_node_context,
         set_if_chain,
+        set_constant_evaluations,
     )
 
     for transformation in pipeline:
diff --git a/wemake_python_styleguide/violations/complexity.py b/wemake_python_styleguide/violations/complexity.py
index c424bbdf..acfaf1b8 100644
--- a/wemake_python_styleguide/violations/complexity.py
+++ b/wemake_python_styleguide/violations/complexity.py
@@ -851,12 +851,16 @@ class TooManyExceptCasesViolation(ASTViolation):
 
 @final
 class OverusedStringViolation(MaybeASTViolation):
-    """
-    Forbid the overuse of string constants.
+    r"""
+    Forbid the overuse of string literals.
 
     We allow to use strings without any restrictions as annotations for
     variables, arguments, return values, and class attributes.
 
+    Some common string literals like dot `'.'`, comma `','`, empty string `''`,
+    single space `' '`, new line `'\n'`, `'\r\n'` and tabulator `'\t'`
+    do not count against string literal overuse limit.
+
     Reasoning:
         When some string is used more than several time in your code,
         it probably means that this string is a meaningful constant
@@ -875,7 +879,7 @@ class OverusedStringViolation(MaybeASTViolation):
 
     """
 
-    error_template = 'Found string constant over-use: {0}'
+    error_template = 'Found string literal over-use: {0}'
     code = 226
 
 
diff --git a/wemake_python_styleguide/visitors/ast/builtins.py b/wemake_python_styleguide/visitors/ast/builtins.py
index 3aa8dd10..24c1d72c 100644
--- a/wemake_python_styleguide/visitors/ast/builtins.py
+++ b/wemake_python_styleguide/visitors/ast/builtins.py
@@ -449,12 +449,17 @@ class WrongCollectionVisitor(base.BaseNodeVisitor):
             if dict_key is None:
                 continue
 
+            evaluates_to_float = False
+            if isinstance(dict_key, ast.BinOp):
+                evaluated_key = getattr(dict_key, 'wps_op_eval', None)
+                evaluates_to_float = isinstance(evaluated_key, float)
+
             real_key = operators.unwrap_unary_node(dict_key)
             is_float_key = (
                 isinstance(real_key, ast.Num) and
                 isinstance(real_key.n, float)
             )
-            if is_float_key:
+            if is_float_key or evaluates_to_float:
                 self.add_violation(best_practices.FloatKeyViolation(dict_key))
 
     def _check_unhashable_elements(
diff --git a/wemake_python_styleguide/visitors/ast/complexity/overuses.py b/wemake_python_styleguide/visitors/ast/complexity/overuses.py
index 93562fff..ba31b642 100644
--- a/wemake_python_styleguide/visitors/ast/complexity/overuses.py
+++ b/wemake_python_styleguide/visitors/ast/complexity/overuses.py
@@ -36,16 +36,21 @@ class StringOveruseVisitor(base.BaseNodeVisitor):
     Restricts repeated usage of the same string constant.
 
     NB: Some short strings are ignored, as their use is very common and
-    forcing assignment would not make much sense (i.e. newlines or "").
+    forcing assignment would not make much sense (i.e. newlines, "",
+    comma, dot).
     """
 
     _ignored_string_constants: ClassVar[_StringConstants] = frozenset((
         ' ',
+        '.',
+        ',',
         '',
         '\n',
         '\r\n',
         '\t',
         b' ',
+        b'.',
+        b',',
         b'',
         b'\n',
         b'\r\n',


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 5c6257b5..273e148e 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -12,7 +12,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: ['3.6', '3.7', '3.8', '3.9.7']
+        python-version: ['3.6', '3.7', '3.8', '3.9']
 
     steps:
     - uses: actions/checkout@v2
diff --git a/tests/test_transformations/test_enhancements.py b/tests/test_transformations/test_enhancements.py
new file mode 100644
index 00000000..75643737
--- /dev/null
+++ b/tests/test_transformations/test_enhancements.py
@@ -0,0 +1,41 @@
+import pytest
+
+
+@pytest.mark.parametrize(('expression', 'output'), [
+    ('-1 + 1', 0),
+    ('1 * 2', 2),
+    ('"a" * 5', 'aaaaa'),
+    ('b"hello" * 2', b'hellohello'),
+    ('"hello " + "world"', 'hello world'),
+    ('(2 + 6) / 4 - 2', 0),
+    ('1 << 4', 16),
+    ('255 >> 4', 15),
+    ('2**4', 16),
+    ('5^9', 12),
+    ('12 & 24', 8),
+    ('6 | 9', 15),
+    ('5 % 3', 2),
+    ('4 // 3', 1),
+    ('(6 - 2) * ((3 << 3) // 10) % 5 | 7**2', 51),
+])
+def test_evaluate_valid_operations(parse_ast_tree, expression: str, output):
+    """Tests that the operations are correctly evaluated."""
+    tree = parse_ast_tree(expression)
+    assert tree.body[0].value.wps_op_eval == output
+
+
+@pytest.mark.parametrize('expression', [
+    'x * 2',
+    'x << y',
+    '-x + y',
+    '0 / 0',
+    '"a" * 2.1',
+    '"a" + 1',
+    '3 << 1.5',
+    '((4 - 1) * 3 - 9) // (7 >> 4)',
+    '[[1, 0], [0, 1]] @ [[1, 1], [0, 0]]',
+])
+def test_evaluate_invalid_operations(parse_ast_tree, expression: str):
+    """Tests that the operations can not be evaluated and thus return None."""
+    tree = parse_ast_tree(expression)
+    assert tree.body[0].value.wps_op_eval is None
diff --git a/tests/test_visitors/conftest.py b/tests/test_visitors/conftest.py
index d6af0e23..9003d7fc 100644
--- a/tests/test_visitors/conftest.py
+++ b/tests/test_visitors/conftest.py
@@ -1,6 +1,7 @@
 from typing import Optional, Sequence, Tuple, Type, Union
 
 import pytest
+from typing_extensions import Final
 
 from wemake_python_styleguide.violations.base import (
     ASTViolation,
@@ -14,6 +15,7 @@ _IgnoredTypes = Union[
     Tuple[Type[BaseViolation], ...],
     None,
 ]
+_ERROR_FORMAT: Final = ': {0}'
 
 
 @pytest.fixture(scope='session')
@@ -69,10 +71,9 @@ def assert_error_text():
             assert len(real_errors) == 1
 
         violation = real_errors[0]
-        error_format = ': {0}'
 
-        assert error_format in violation.error_template
-        assert violation.error_template.endswith(error_format)
+        assert _ERROR_FORMAT in violation.error_template
+        assert violation.error_template.endswith(_ERROR_FORMAT)
 
         reproduction = violation.__class__(
             node=violation._node,  # noqa: WPS437
diff --git a/tests/test_visitors/test_ast/test_builtins/test_collection_hashes/test_float_keys.py b/tests/test_visitors/test_ast/test_builtins/test_collection_hashes/test_float_keys.py
index 280aca76..a6857ad3 100644
--- a/tests/test_visitors/test_ast/test_builtins/test_collection_hashes/test_float_keys.py
+++ b/tests/test_visitors/test_ast/test_builtins/test_collection_hashes/test_float_keys.py
@@ -21,6 +21,9 @@ dict_template4 = '{{ {0}: 1, other: value }}'
     '1.0',
     '-0.3',
     '+0.0',
+    '1 / 3',
+    '-1 - 0.5',
+    '0 + 0.1',
 ])
 def test_dict_with_float_key(
     assert_errors,
@@ -47,9 +50,6 @@ def test_dict_with_float_key(
 @pytest.mark.parametrize('element', [
     '1',
     '"-0.3"',
-    '0 + 0.1',
-    '0 - 1.0',
-    '1 / 3',
     '1 // 3',
     'call()',
     'name',
diff --git a/tests/test_visitors/test_ast/test_complexity/test_overuses/test_overused_string.py b/tests/test_visitors/test_ast/test_complexity/test_overuses/test_overused_string.py
index 3fb9bb51..7d8095bc 100644
--- a/tests/test_visitors/test_ast/test_complexity/test_overuses/test_overused_string.py
+++ b/tests/test_visitors/test_ast/test_complexity/test_overuses/test_overused_string.py
@@ -200,20 +200,22 @@ def test_string_type_annotations(
     r'"\t"',
     r'"\n"',
     '""',
+    '","',
+    '"."',
 ])
 @pytest.mark.parametrize('prefix', [
     'b',
     'u',
     '',
 ])
-def test_string_overuse_exceptions(
+def test_common_strings_allowed(
     assert_errors,
     parse_ast_tree,
     default_options,
     prefix,
     string_value,
 ):
-    """Ensures that over-used strings raise violations."""
+    """Ensures that common strings do not count against the overuse limit."""
     snippet = string_actions.format(prefix + string_value)
     tree = parse_ast_tree(snippet)