Repository: wemake-services/wemake-python-styleguide

Issue:
Empty lines in method body
# Rule request

Empty lines in the method body are bad. I propose prohibit their use or limit it count in method body

## Thesis

I received a PR with the same code

```python
    def to_representation(self, data):
        result = None

        self.get_person(data)

        if self.person and self.person.use_other_veh:

            result = super().to_representation(data)

        return result
```

## Reasoning

I think, if method have few empty lines, it not holistic, and it makes sense to divide the method into several. Yegor Bugaenko's [post](https://www.yegor256.com/2014/11/03/empty-line-code-smell.html) about empty lines



Discussion:
blablatdinov: @sobolevn I don't agree or don't correctly understand you proportions. In this proportions linter don't reject next code:

```python
def func(a, b):
    first_expression(a)

    second_expression(b)
```

Or you implied 2 line with expressions without gap between them?
sobolevn: We can add a lower bound of 0 empty lines for 2 lines. That's not a problem.
blablatdinov: @sobolevn I'm pushed commit with code for configurating this rule. Please, check it
sobolevn: Can you please rebase your work? I've fixed new poetry@1.2.0 build
blablatdinov: Yes, of course. My branch rebased, now
blablatdinov: Sorry, my bad. I connect my visitor and found a problem: `ast` module ignore comments, so my algorithm count it like empty line. I've been looked solution of this problem on Google and I can't find it (in stackoverflow recomends using `tokenize` module). Do you have recomendation for resolve this problem ?
blablatdinov: I'm change visitor to `BaseTokenVisitor`. Can you review it?
codecov[bot]: # [Codecov](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/2487?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services) Report
> Merging [#2487](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/2487?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services) (3de1283) into [master](https://codecov.io/gh/wemake-services/wemake-python-styleguide/commit/5bfdbddc3b47c9089a9f3dc8fe096b346a309dd1?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services) (5bfdbdd) will **not change** coverage.
> The diff coverage is `100.00%`.

```diff
@@            Coverage Diff            @@
##            master     #2487   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          119       120    +1     
  Lines         6390      6406   +16     
  Branches      1449      1451    +2     
=========================================
+ Hits          6390      6406   +16     
```


| [Impacted Files](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/2487?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services) | Coverage Δ | |
|---|---|---|
| [wemake\_python\_styleguide/options/config.py](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/2487/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services#diff-d2VtYWtlX3B5dGhvbl9zdHlsZWd1aWRlL29wdGlvbnMvY29uZmlnLnB5) | `100.00% <ø> (ø)` | |
| [wemake\_python\_styleguide/presets/types/tree.py](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/2487/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services#diff-d2VtYWtlX3B5dGhvbl9zdHlsZWd1aWRlL3ByZXNldHMvdHlwZXMvdHJlZS5weQ==) | `100.00% <ø> (ø)` | |
| [wemake\_python\_styleguide/options/defaults.py](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/2487/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services#diff-d2VtYWtlX3B5dGhvbl9zdHlsZWd1aWRlL29wdGlvbnMvZGVmYXVsdHMucHk=) | `100.00% <100.00%> (ø)` | |
| [wemake\_python\_styleguide/options/validation.py](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/2487/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services#diff-d2VtYWtlX3B5dGhvbl9zdHlsZWd1aWRlL29wdGlvbnMvdmFsaWRhdGlvbi5weQ==) | `100.00% <100.00%> (ø)` | |
| [...ake\_python\_styleguide/violations/best\_practices.py](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/2487/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services#diff-d2VtYWtlX3B5dGhvbl9zdHlsZWd1aWRlL3Zpb2xhdGlvbnMvYmVzdF9wcmFjdGljZXMucHk=) | `100.00% <100.00%> (ø)` | |
| [...on\_styleguide/visitors/ast/function\_empty\_lines.py](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/2487/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services#diff-d2VtYWtlX3B5dGhvbl9zdHlsZWd1aWRlL3Zpc2l0b3JzL2FzdC9mdW5jdGlvbl9lbXB0eV9saW5lcy5weQ==) | `100.00% <100.00%> (ø)` | |
| [wemake\_python\_styleguide/compat/nodes.py](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/2487/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services#diff-d2VtYWtlX3B5dGhvbl9zdHlsZWd1aWRlL2NvbXBhdC9ub2Rlcy5weQ==) | `100.00% <0.00%> (ø)` | |
| [wemake\_python\_styleguide/logic/walrus.py](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/2487/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services#diff-d2VtYWtlX3B5dGhvbl9zdHlsZWd1aWRlL2xvZ2ljL3dhbHJ1cy5weQ==) | `100.00% <0.00%> (ø)` | |
| [wemake\_python\_styleguide/compat/routing.py](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/2487/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services#diff-d2VtYWtlX3B5dGhvbl9zdHlsZWd1aWRlL2NvbXBhdC9yb3V0aW5nLnB5) | `100.00% <0.00%> (ø)` | |
| [wemake\_python\_styleguide/logic/safe\_eval.py](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/2487/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services#diff-d2VtYWtlX3B5dGhvbl9zdHlsZWd1aWRlL2xvZ2ljL3NhZmVfZXZhbC5weQ==) | `100.00% <0.00%> (ø)` | |
| ... and [4 more](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/2487/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services) | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services)

blablatdinov: Thanks for comments. I'm add test cases with comments and docstrings
blablatdinov: Thanks for your suggested changes! I'm add test with `exps_for_one_empty_line=0` for method without empty lines

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6feb8359..2819dd3a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,8 @@ Semantic versioning in our case means:
 - Adds `__init_subclass__` in the beginning of accepted methods
   order as per WPS338 #2411
 
+- Adds `WrongEmptyLinesCountViolation` #2486
+
 ### Bugfixes
 
 - Fixes `WPS226` false positives on `|` use in `SomeType | AnotherType`
diff --git a/wemake_python_styleguide/options/config.py b/wemake_python_styleguide/options/config.py
index 60e0c844..ac77bca7 100644
--- a/wemake_python_styleguide/options/config.py
+++ b/wemake_python_styleguide/options/config.py
@@ -59,6 +59,9 @@ You can also show all options that ``flake8`` supports by running:
 - ``forbidden-inline-ignore`` - list of codes of violations or
     class of violations that are forbidden to ignore inline, defaults to
     :str:`wemake_python_styleguide.options.defaults.FORBIDDEN_INLINE_IGNORE`
+- ``exps-for-one-empty-line`` - number of expressions in
+    function or method body for available empty line (without code)
+    :str:`wemake_python_styleguide.options.defaults.EXPS_FOR_ONE_EMPTY_LINE`
 
 
 .. rubric:: Complexity options
@@ -262,6 +265,11 @@ class Configuration(object):
             type=String,
             comma_separated_list=True,
         ),
+        _Option(
+            '--exps-for-one-empty-line',
+            defaults.EXPS_FOR_ONE_EMPTY_LINE,
+            'Count of expressions for one empty line in a function body.',
+        ),
 
         # Complexity:
 
diff --git a/wemake_python_styleguide/options/defaults.py b/wemake_python_styleguide/options/defaults.py
index f36645e2..542d574e 100644
--- a/wemake_python_styleguide/options/defaults.py
+++ b/wemake_python_styleguide/options/defaults.py
@@ -46,6 +46,9 @@ FORBIDDEN_DOMAIN_NAMES: Final = ()
 #: Violation codes that are forbidden to use.
 FORBIDDEN_INLINE_IGNORE: Final = ()
 
+#: Count of available expressions for one empty line in function or method body.
+EXPS_FOR_ONE_EMPTY_LINE: Final = 2
+
 # ===========
 # Complexity:
 # ===========
diff --git a/wemake_python_styleguide/options/validation.py b/wemake_python_styleguide/options/validation.py
index 39578ec9..0c18508f 100644
--- a/wemake_python_styleguide/options/validation.py
+++ b/wemake_python_styleguide/options/validation.py
@@ -98,6 +98,7 @@ class _ValidatedOptions(object):
     max_import_from_members: int = attr.ib(validator=[_min_max(min=1)])
     max_tuple_unpack_length: int = attr.ib(validator=[_min_max(min=1)])
     show_violation_links: bool
+    exps_for_one_empty_line: int
 
 
 def validate_options(options: ConfigurationOptions) -> _ValidatedOptions:
diff --git a/wemake_python_styleguide/presets/types/tree.py b/wemake_python_styleguide/presets/types/tree.py
index 126a047f..ba8016ee 100644
--- a/wemake_python_styleguide/presets/types/tree.py
+++ b/wemake_python_styleguide/presets/types/tree.py
@@ -11,6 +11,7 @@ from wemake_python_styleguide.visitors.ast import (  # noqa: WPS235
     conditions,
     decorators,
     exceptions,
+    function_empty_lines,
     functions,
     imports,
     iterables,
@@ -107,6 +108,8 @@ PRESET: Final = (
 
     redundancy.RedundantEnumerateVisitor,
 
+    function_empty_lines.WrongEmptyLinesCountVisitor,
+
     # Modules:
     modules.EmptyModuleContentsVisitor,
     modules.MagicModuleFunctionsVisitor,
diff --git a/wemake_python_styleguide/types.py b/wemake_python_styleguide/types.py
index 87552261..c7d4aa06 100644
--- a/wemake_python_styleguide/types.py
+++ b/wemake_python_styleguide/types.py
@@ -273,3 +273,7 @@ class ConfigurationOptions(Protocol):
     @property
     def show_violation_links(self) -> bool:
         ...
+
+    @property
+    def exps_for_one_empty_line(self) -> int:
+        ...
diff --git a/wemake_python_styleguide/violations/best_practices.py b/wemake_python_styleguide/violations/best_practices.py
index ce41a316..23b7f0a0 100644
--- a/wemake_python_styleguide/violations/best_practices.py
+++ b/wemake_python_styleguide/violations/best_practices.py
@@ -89,6 +89,7 @@ Summary
    KwargsUnpackingInClassDefinitionViolation
    ConsecutiveSlicesViolation
    GettingElementByUnpackingViolation
+   WrongEmptyLinesCountViolation
 
 Best practices
 --------------
@@ -166,6 +167,7 @@ Best practices
 .. autoclass:: KwargsUnpackingInClassDefinitionViolation
 .. autoclass:: ConsecutiveSlicesViolation
 .. autoclass:: GettingElementByUnpackingViolation
+.. autoclass:: WrongEmptyLinesCountViolation
 
 """
 
@@ -2770,3 +2772,48 @@ class GettingElementByUnpackingViolation(ASTViolation):
         'Found unpacking used to get a single element from a collection'
     )
     code = 472
+
+
+@final
+class WrongEmptyLinesCountViolation(TokenizeViolation):
+    """
+    Limit empty lines in functions or methods body.
+
+    Reasoning:
+        It's not holistic to have functions or methods that contain many
+        empty lines, and it makes sense to divide the method into several
+        ones.
+
+    Solution:
+        Limit count of empty lines of the function or method body
+        By default, we allow 1 empty line for 2 non-empty lines.
+
+    Example::
+
+        # Correct:
+        def func(name):
+            foo()
+            if name == 'Moonflower':
+                print('Love')
+            baz()
+
+        # Wrong:
+        def func(name):
+            foo()
+
+            if name == 'Moonflower':
+                print('Love')
+
+            baz()
+
+    Configuration:
+        This rule is configurable with ``--exps-for-one-empty-line``.
+        Default:
+        :str:`wemake_python_styleguide.options.defaults.EXPS_FOR_ONE_EMPTY_LINE`
+
+    .. versionadded:: 0.17.0
+
+    """
+
+    error_template = 'Found too many empty lines in `def`: {0}'
+    code = 473
diff --git a/wemake_python_styleguide/visitors/ast/function_empty_lines.py b/wemake_python_styleguide/visitors/ast/function_empty_lines.py
new file mode 100644
index 00000000..7bc0a044
--- /dev/null
+++ b/wemake_python_styleguide/visitors/ast/function_empty_lines.py
@@ -0,0 +1,60 @@
+import math
+import tokenize
+
+from typing_extensions import final
+
+from wemake_python_styleguide.violations import best_practices
+from wemake_python_styleguide.visitors import base
+
+
+@final
+class WrongEmptyLinesCountVisitor(base.BaseTokenVisitor):
+    """Restricts empty lines in function or method body."""
+
+    def __init__(self, *args, **kwargs) -> None:
+        """Initializes a counter."""
+        super().__init__(*args, **kwargs)
+        self._empty_lines_count = 0
+        self._function_start_line = 0
+
+    def visit(self, token: tokenize.TokenInfo) -> None:
+        """Find empty lines count."""
+        self._try_mark_function_start(token)
+        if self._function_start_line:
+            if token.type == tokenize.NL and token.line == '\n':
+                self._empty_lines_count += 1
+            self._check_empty_lines(token)
+
+    def _check_empty_lines(self, token: tokenize.TokenInfo):
+        if token.type == tokenize.DEDENT and token.line == '':
+            func_lines = token.start[0] - self._function_start_line - 1
+            if self._empty_lines_count:
+                available_empty_lines = self._available_empty_lines(
+                    func_lines,
+                    self._empty_lines_count,
+                )
+                if self._empty_lines_count > available_empty_lines:
+                    self.add_violation(
+                        best_practices.WrongEmptyLinesCountViolation(
+                            token,
+                            text=str(self._empty_lines_count),
+                            baseline=available_empty_lines,
+                        ),
+                    )
+                self._function_start_line = 0
+
+    def _try_mark_function_start(self, token: tokenize.TokenInfo):
+        if token.string == 'def':
+            self._empty_lines_count = 0
+            self._function_start_line = token.start[0]
+
+    def _available_empty_lines(
+        self,
+        function_len: int,
+        empty_lines: int,
+    ) -> int:
+        option = self.options.exps_for_one_empty_line
+        if option == 0:
+            return 0
+        lines_with_expressions = function_len - empty_lines
+        return math.floor(lines_with_expressions / option)


Test patch:
diff --git a/tests/test_checker/test_noqa.py b/tests/test_checker/test_noqa.py
index 4f1e4325..bdb1aab0 100644
--- a/tests/test_checker/test_noqa.py
+++ b/tests/test_checker/test_noqa.py
@@ -303,6 +303,7 @@ SHOULD_BE_RAISED = types.MappingProxyType({
     'WPS613': 1,
     'WPS614': 1,
     'WPS615': 2,
+    'WPS473': 0,
 })
 
 # Violations which may be tweaked by `i_control_code` option:
diff --git a/tests/test_visitors/test_ast/test_classes/test_wrong_empty_lines_count.py b/tests/test_visitors/test_ast/test_classes/test_wrong_empty_lines_count.py
new file mode 100644
index 00000000..a2e44cfe
--- /dev/null
+++ b/tests/test_visitors/test_ast/test_classes/test_wrong_empty_lines_count.py
@@ -0,0 +1,197 @@
+"""Test method contain only allowed empty lines count."""
+import pytest
+
+from wemake_python_styleguide.violations.best_practices import (
+    WrongEmptyLinesCountViolation,
+)
+from wemake_python_styleguide.visitors.ast.function_empty_lines import (
+    WrongEmptyLinesCountVisitor,
+)
+
+class_with_wrong_method = """
+class WrongClass(object):
+
+    def wrong_method(self):
+        foo()
+
+        bar()
+
+        baz()
+
+        lighter()
+"""
+
+class_with_valid_method = """
+class WrongClass(object):
+
+    def wrong_method(self):
+        foo()
+        bar()
+        baz()
+"""
+
+wrong_function = """
+def func():
+    foo()
+
+    a = 1 + 4
+
+    bar()
+
+    baz()
+"""
+
+wrong_function_with_loop = """
+def func():
+    for x in range(10):
+
+        requests.get(
+
+
+            'https://github.com/wemake-services/wemake-python-styleguide'
+        )
+"""
+
+allow_function = """
+def func():
+    foo()
+    if name == 'Moonflower':
+        print('Love')
+
+    baz()
+"""
+
+allow_function_with_comments = """
+def test_func():
+   # This function
+   #
+   # has lots
+   #
+   # of empty
+   #
+   # lines
+   #
+   # in comments
+   return 0
+"""
+
+function_with_docstring = """
+def test_func():
+   \"""
+   Its docstring
+
+   has many new lines
+
+   but this is
+
+   totally fine
+
+   we don't raise a violation for this
+   \"""
+   return
+"""
+
+function_with_docstring_and_comments = """
+def test_func():
+   \"""
+   Its docstring
+
+   has many new lines
+
+   but this is
+
+   totally fine
+
+   we don't raise a violation for this
+   \"""
+   # This function
+   #
+   # has lots
+   #
+   # of empty
+   #
+   # lines
+   #
+   # in comments
+   return 0
+"""
+
+
+@pytest.mark.parametrize('input_', [
+    class_with_wrong_method,
+    wrong_function,
+    wrong_function_with_loop,
+])
+def test_wrong(
+    input_,
+    default_options,
+    assert_errors,
+    parse_tokens,
+    mode,
+):
+    """Testing wrong cases."""
+    file_tokens = parse_tokens(mode(input_))
+
+    visitor = WrongEmptyLinesCountVisitor(
+        default_options, file_tokens=file_tokens,
+    )
+    visitor.run()
+
+    assert_errors(visitor, [WrongEmptyLinesCountViolation])
+
+
+@pytest.mark.parametrize('input_', [
+    class_with_valid_method,
+    allow_function,
+    allow_function_with_comments,
+    function_with_docstring,
+    function_with_docstring_and_comments,
+])
+def test_success(
+    input_,
+    parse_tokens,
+    default_options,
+    assert_errors,
+    mode,
+):
+    """Testing available cases."""
+    file_tokens = parse_tokens(mode(input_))
+
+    visitor = WrongEmptyLinesCountVisitor(
+        default_options, file_tokens=file_tokens,
+    )
+    visitor.run()
+
+    assert_errors(visitor, [])
+
+
+def test_zero_option(
+    parse_tokens,
+    default_options,
+    assert_errors,
+    options,
+    mode,
+):
+    """Test zero configuration."""
+    file_tokens = parse_tokens(mode(allow_function))
+    visitor = WrongEmptyLinesCountVisitor(
+        options(exps_for_one_empty_line=0), file_tokens=file_tokens,
+    )
+    visitor.run()
+    assert_errors(visitor, [WrongEmptyLinesCountViolation])
+
+
+def test_zero_option_with_valid_method(
+    parse_tokens,
+    default_options,
+    assert_errors,
+    options,
+    mode,
+):
+    """Test zero configuration with valid method."""
+    file_tokens = parse_tokens(mode(class_with_valid_method))
+    visitor = WrongEmptyLinesCountVisitor(
+        options(exps_for_one_empty_line=0), file_tokens=file_tokens,
+    )
+    visitor.run()
+    assert_errors(visitor, [])