Repository: wemake-services/wemake-python-styleguide

Issue:
False positive with WPS473
### What's wrong

I am seeing the new WPS473 appear at the bottom of a module, but it doesn't make sense. 

This is the module (a bunch of sensitive names have been changed but I still see the error): 

```python
from enum import Enum
from typing import Optional

from pydantic import BaseModel, Field


AliasType = list[list[int]]


class ObjectType(Enum):
    """Object output types."""

    object_class = "OBJECT_CLASS"
    object_mask = "OBJECT_MASK"

    @classmethod
    def get_type_from_args(cls, mask: Optional[AliasType], name: Optional[str]) -> "ObjectType":
        """Automatically return the correct type given the parameters."""
        if mask is not None:
            return ObjectType.object_mask
        if name is not None:
            return ObjectType.object_class
        raise AssertionError(
            "Unable to automatically get the output type from the arguments the combination is not supported."
        )


class ModelOne(BaseModel):
    """One model."""

    image_index: int = Field(0, alias="imageIndex")
    mask: Optional[AliasType] = None
    name: str

    @property
    def output_type(self) -> ObjectType:
        """Get the correct output type from the given args."""
        return ObjectType.get_type_from_args(self.mask, self.name)


class ModelTwo(BaseModel):
    """Another model below the above one."""

    line: ModelOne
```

And below is the error that is reported by flake8: 

```bash
./src/PROJECT_NAME/datamodels/BLAH/BLAHBLAH/weird_error.py
  45:1     WPS473 Found too many empty lines in `def`: 3 > 2

Full list of violations and explanations:
https://wemake-python-styleguide.rtfd.io/en/0.17.0/pages/usage/violations/
```



### How it should be

I expect the error to not be showing at all. 

### Flake8 version and plugins

```bash
{
  "dependencies": [],
  "platform": {
    "python_implementation": "CPython",
    "python_version": "3.9.12",
    "system": "Darwin"
  },
  "plugins": [
    {
      "is_local": false,
      "plugin": "flake8-bandit",
      "version": "3.0.0"
    },
    {
      "is_local": false,
      "plugin": "flake8-broken-line",
      "version": "0.5.0"
    },
    {
      "is_local": false,
      "plugin": "flake8-bugbear",
      "version": "22.9.23"
    },
    {
      "is_local": false,
      "plugin": "flake8-comprehensions",
      "version": "3.10.0"
    },
    {
      "is_local": false,
      "plugin": "flake8-darglint",
      "version": "1.8.1"
    },
    {
      "is_local": false,
      "plugin": "flake8-debugger",
      "version": "4.1.2"
    },
    {
      "is_local": false,
      "plugin": "flake8-docstrings",
      "version": "1.6.0, pydocstyle: 6.1.1"
    },
    {
      "is_local": false,
      "plugin": "flake8-eradicate",
      "version": "1.4.0"
    },
    {
      "is_local": false,
      "plugin": "flake8-string-format",
      "version": "0.3.0"
    },
    {
      "is_local": false,
      "plugin": "flake8_commas",
      "version": "2.1.0"
    },
    {
      "is_local": false,
      "plugin": "flake8_isort",
      "version": "4.2.0"
    },
    {
      "is_local": false,
      "plugin": "flake8_quotes",
      "version": "3.3.1"
    },
    {
      "is_local": false,
      "plugin": "mccabe",
      "version": "0.6.1"
    },
    {
      "is_local": false,
      "plugin": "naming",
      "version": "0.13.2"
    },
    {
      "is_local": false,
      "plugin": "pycodestyle",
      "version": "2.8.0"
    },
    {
      "is_local": false,
      "plugin": "pyflakes",
      "version": "2.4.0"
    },
    {
      "is_local": false,
      "plugin": "rst-docstrings",
      "version": "0.2.7"
    },
    {
      "is_local": false,
      "plugin": "wemake_python_styleguide",
      "version": "0.17.0"
    }
  ],
  "version": "4.0.1"
}
```

### pip information

```bash
pip 22.3 from /Users/REDACTED/.venv/lib/python3.9/site-packages/pip (python 3.9)
aiohttp==3.8.3
aiosignal==1.2.0
anyio==3.6.1
asgiref==3.5.2
astor==0.8.1
async-timeout==4.0.2
attrs==22.1.0
aws-sam-translator==1.51.0
aws-xray-sdk==2.10.0
backoff==2.2.1
bandit==1.7.4
black==22.8.0
boto3==1.23.3
boto3-stubs==1.24.82
botocore==1.26.10
botocore-stubs==1.27.82
Brotli==1.0.9
certifi==2022.9.24
cffi==1.15.1
cfgv==3.3.1
cfn-lint==0.65.1
charset-normalizer==2.1.1
cli-ui==0.17.2
click==8.1.3
colorama==0.4.5
commonmark==0.9.1
contextlib2==21.6.0
convert-case==1.1.1
coverage==6.4.4
cryptography==38.0.1
darglint==1.8.1
datasets==2.5.1
decopatch==1.4.10
Deprecated==1.2.13
dill==0.3.5.1
distlib==0.3.6
dnspython==2.2.1
docker==6.0.0
docopt==0.6.2
docutils==0.19
ecdsa==0.18.0
email-validator==1.3.0
REDACTED @ git+https://github.com/REDACTED/REDACTED@REDACTED
-e git+ssh://git@github.com/REDACTED/REDACTED@REDACTED
eradicate==2.1.0
execnet==1.9.0
fastapi==0.85.0
filelock==3.8.0
flake8==4.0.1
flake8-bandit==3.0.0
flake8-broken-line==0.5.0
flake8-bugbear==22.9.23
flake8-commas==2.1.0
flake8-comprehensions==3.10.0
flake8-debugger==4.1.2
flake8-docstrings==1.6.0
flake8-eradicate==1.4.0
flake8-isort==4.2.0
flake8-polyfill==1.0.2
flake8-quotes==3.3.1
flake8-rst-docstrings==0.2.7
flake8-string-format==0.3.0
Flask==2.1.3
Flask-Cors==3.0.10
frozenlist==1.3.1
fsspec==2022.8.2
gitdb==4.0.9
GitPython==3.1.27
googleapis-common-protos==1.56.4
graphql-core==3.2.3
grpcio==1.49.1
h11==0.12.0
httpcore==0.15.0
httptools==0.5.0
httpx==0.23.0
huggingface-hub==0.10.0
identify==2.5.5
idna==3.4
importlib-metadata==4.12.0
iniconfig==1.1.1
isort==5.10.1
itsdangerous==2.1.2
Jinja2==3.1.2
jmespath==1.0.1
jschema-to-python==1.2.3
jsondiff==2.0.0
jsonpatch==1.32
jsonpickle==2.2.0
jsonpointer==2.3
jsonschema==3.2.0
junit-xml==1.9
libcst==0.4.7
loguru==0.6.0
makefun==1.15.0
MarkupSafe==2.1.1
mccabe==0.6.1
moto==4.0.5
multidict==6.0.2
multiprocess==0.70.13
multivolumefile==0.2.3
mypy==0.981
mypy-boto3-cloudformation==1.24.36.post1
mypy-boto3-dynamodb==1.24.74
mypy-boto3-ec2==1.24.80
mypy-boto3-lambda==1.24.54
mypy-boto3-rds==1.24.76
mypy-boto3-s3==1.24.76
mypy-boto3-sqs==1.24.60
mypy-extensions==0.4.3
networkx==2.8.6
nodeenv==1.7.0
numpy==1.23.3
openapi-schema-validator==0.2.3
openapi-spec-validator==0.4.0
opentelemetry-api==1.13.0
opentelemetry-distro==0.34b0
opentelemetry-exporter-otlp==1.13.0
opentelemetry-exporter-otlp-proto-grpc==1.13.0
opentelemetry-exporter-otlp-proto-http==1.13.0
opentelemetry-instrumentation==0.34b0
opentelemetry-instrumentation-aiohttp-client==0.34b0
opentelemetry-instrumentation-asgi==0.34b0
opentelemetry-instrumentation-aws-lambda==0.34b0
opentelemetry-instrumentation-boto3sqs==0.34b0
opentelemetry-instrumentation-botocore==0.34b0
opentelemetry-instrumentation-dbapi==0.34b0
opentelemetry-instrumentation-fastapi==0.34b0
opentelemetry-instrumentation-flask==0.34b0
opentelemetry-instrumentation-grpc==0.34b0
opentelemetry-instrumentation-httpx==0.34b0
opentelemetry-instrumentation-jinja2==0.34b0
opentelemetry-instrumentation-logging==0.34b0
opentelemetry-instrumentation-requests==0.34b0
opentelemetry-instrumentation-sqlite3==0.34b0
opentelemetry-instrumentation-system-metrics==0.34b0
opentelemetry-instrumentation-urllib==0.34b0
opentelemetry-instrumentation-urllib3==0.34b0
opentelemetry-instrumentation-wsgi==0.34b0
opentelemetry-propagator-aws-xray==1.0.1
opentelemetry-proto==1.13.0
opentelemetry-sdk==1.13.0
opentelemetry-sdk-extension-aws==2.0.1
opentelemetry-semantic-conventions==0.34b0
opentelemetry-util-http==0.34b0
orjson==3.8.0
overrides==6.2.0
packaging==21.3
pandas==1.5.0
pastel==0.2.1
pathspec==0.10.1
pbr==5.10.0
pep8-naming==0.13.2
Pillow==9.2.0
platformdirs==2.5.2
pluggy==1.0.0
poethepoet==0.16.2
pre-commit==2.20.0
protobuf==3.20.3
psutil==5.9.2
py==1.11.0
py7zr==0.18.10
pyarrow==9.0.0
pyasn1==0.4.8
pybcj==1.0.1
pycodestyle==2.8.0
pycparser==2.21
pycryptodomex==3.15.0
pydantic==1.10.2
pydocstyle==6.1.1
pyflakes==2.4.0
Pygments==2.13.0
pyparsing==3.0.9
pyppmd==0.18.3
pyrsistent==0.18.1
pytest==7.1.3
pytest-cases==3.6.13
pytest-cov==3.0.0
pytest-forked==1.4.0
pytest-httpx==0.21.0
pytest-xdist==2.5.0
python-dateutil==2.8.2
python-dotenv==0.21.0
python-jose==3.3.0
python-multipart==0.0.5
pytz==2022.2.1
PyYAML==6.0
pyzstd==0.15.3
requests==2.28.1
responses==0.18.0
restructuredtext-lint==1.4.0
rfc3986==1.5.0
rich==12.5.1
rich-click==1.5.1
rsa==4.9
s3transfer==0.5.2
sarif-om==1.0.4
schema==0.7.5
six==1.16.0
smmap==5.0.0
sniffio==1.3.0
snowballstemmer==2.2.0
sshpubkeys==3.3.1
starlette==0.20.4
stevedore==4.0.0
tabulate==0.8.10
tbump==6.9.0
texttable==1.6.4
toml==0.10.2
tomli==2.0.1
tomlkit==0.11.5
torch==1.12.1
tqdm==4.64.1
typer==0.6.1
types-awscrt==0.14.6
types-s3transfer==0.6.0.post4
typing-inspect==0.8.0
typing_extensions==4.3.0
ujson==5.5.0
Unidecode==1.3.4
unimport==0.12.1
urllib3==1.26.12
uvicorn==0.18.3
uvloop==0.17.0
virtualenv==20.16.5
watchfiles==0.17.0
watchtower==3.0.0
websocket-client==1.4.1
websockets==10.3
wemake-python-styleguide==0.17.0
Werkzeug==2.1.2
wrapt==1.14.1
xmltodict==0.13.0
xxhash==3.0.0
yarl==1.8.1
zipfile-deflate64==0.2.0
zipp==3.8.1

### OS information

Mac OS 12.4


Discussion:
cc @blablatdinov
Thank you for report! I have found the source of the problem and will try to fix it

Resolving patch:
diff --git a/wemake_python_styleguide/visitors/ast/function_empty_lines.py b/wemake_python_styleguide/visitors/ast/function_empty_lines.py
--- a/wemake_python_styleguide/visitors/ast/function_empty_lines.py
+++ b/wemake_python_styleguide/visitors/ast/function_empty_lines.py
@@ -1,5 +1,6 @@
 import math
 import tokenize
+from typing import List
 
 from typing_extensions import final
 
@@ -8,53 +9,125 @@
 
 
 @final
-class WrongEmptyLinesCountVisitor(base.BaseTokenVisitor):
-    """Restricts empty lines in function or method body."""
+class _Function(object):
 
-    def __init__(self, *args, **kwargs) -> None:
-        """Initializes a counter."""
-        super().__init__(*args, **kwargs)
-        self._empty_lines_count = 0
-        self._function_start_line = 0
+    def __init__(self, file_tokens: List[tokenize.TokenInfo]) -> None:
+        self._tokens = file_tokens
 
-    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,
+    def name_token(self) -> tokenize.TokenInfo:
+        return self._tokens[1]
+
+    def body(self) -> str:
+        target_tokens = []
+        for token in self._tokens:
+            if self._is_target_line(token):
+                continue
+            target_tokens.append(token)
+        return ''.join([target_token.string for target_token in target_tokens])
+
+    def _is_target_line(self, token: tokenize.TokenInfo) -> bool:
+        is_comment = '#' in token.line
+        is_string = token.type == tokenize.STRING
+        is_multistring_end = '"""' in token.line
+        return is_comment or is_string or is_multistring_end
+
+
+@final
+class _FileFunctions(object):
+
+    def __init__(self, file_tokens: List[tokenize.TokenInfo]) -> None:
+        self._file_tokens = file_tokens
+
+    def as_list(self) -> List[_Function]:
+        functions = []
+        function_tokens: List[tokenize.TokenInfo] = []
+        in_function = False
+        for token in self._file_tokens:
+            if self._is_function_start(token):
+                in_function = True
+            elif self._is_function_end(token, bool(function_tokens)):
+                in_function = False
+                functions.append(_Function(function_tokens))
+                function_tokens = []
+            if in_function:
+                function_tokens.append(token)
+        return functions
+
+    def _is_function_start(self, token: tokenize.TokenInfo) -> bool:
+        return token.type == tokenize.NAME and token.string == 'def'
+
+    def _is_function_end(
+        self,
+        token: tokenize.TokenInfo,
+        function_tokens_exists: bool,
+    ) -> bool:
+        is_dedent_token = token.type == tokenize.DEDENT and token.start[1] == 0
+        return is_dedent_token and function_tokens_exists
+
+
+@final
+class _FileTokens(object):
+
+    def __init__(
+        self,
+        file_functions: _FileFunctions,
+        exps_for_one_empty_line: int,
+    ) -> None:
+        self._file_functions = file_functions
+        self._exps_for_one_empty_line = exps_for_one_empty_line
+
+    def analyze(self) -> List[best_practices.WrongEmptyLinesCountViolation]:
+        violations = []
+        for function in self._file_functions.as_list():
+            splitted_function_body = function.body().strip().split('\n')
+            empty_lines_count = len([
+                line for line in splitted_function_body if line == ''
+            ])
+            if not empty_lines_count:
+                return []
+
+            available_empty_lines = self._available_empty_lines(
+                len(splitted_function_body), empty_lines_count,
+            )
+            if empty_lines_count > available_empty_lines:
+                violations.append(
+                    best_practices.WrongEmptyLinesCountViolation(
+                        function.name_token(),
+                        text=str(empty_lines_count),
+                        baseline=available_empty_lines,
+                    ),
                 )
-                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]
+        return violations
 
     def _available_empty_lines(
         self,
         function_len: int,
         empty_lines: int,
     ) -> int:
-        option = self.options.exps_for_one_empty_line
+        option = self._exps_for_one_empty_line
         if option == 0:
             return 0
         lines_with_expressions = function_len - empty_lines
         return math.floor(lines_with_expressions / option)
+
+
+@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._file_tokens: List[tokenize.TokenInfo] = []
+
+    def visit(self, token: tokenize.TokenInfo) -> None:
+        """Find empty lines count."""
+        self._file_tokens.append(token)
+        if token.type != tokenize.ENDMARKER:
+            return
+        violations = _FileTokens(
+            _FileFunctions(self._file_tokens),
+            self.options.exps_for_one_empty_line,
+        ).analyze()
+        for violation in violations:
+            self.add_violation(violation)


Test patch:
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
--- 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
@@ -30,6 +30,24 @@ def wrong_method(self):
         baz()
 """
 
+file_with_few_class = """
+class ValidClass(object):
+
+    def valid_method(self):
+        foo()
+        bar()
+        baz()
+
+    def very_valid_method(self):
+        foo()
+        bar()
+
+
+class EmptyError(Exception):
+
+    message = 'fail'
+"""
+
 wrong_function = """
 def func():
     foo()
@@ -117,6 +135,38 @@ def test_func():
 """
 
 
+class_with_attributes = """
+class Foo(object):
+    first_attribute = 'foo'
+
+
+
+
+    second_attribute = 'bar'
+"""
+
+expression_without_function = """
+print(
+    'Contributing in WPS',
+
+
+
+
+    'is a lot of fun',
+)
+"""
+
+module_level_empty_lines = """
+fstrings_feature = Fstring()
+
+
+
+
+
+fstrings_feature.deprecate()
+"""
+
+
 @pytest.mark.parametrize('input_', [
     class_with_wrong_method,
     wrong_function,
@@ -146,6 +196,10 @@ def test_wrong(
     allow_function_with_comments,
     function_with_docstring,
     function_with_docstring_and_comments,
+    file_with_few_class,
+    class_with_attributes,
+    expression_without_function,
+    module_level_empty_lines,
 ])
 def test_success(
     input_,