Repository: PyCQA/docformatter (python)

Issue:
Major change in 1.5.1 removes empty lines after class definitions
I updated to docformatter 1.5.1 from 1.5.0 expecting no major changes to take place. However, I found that running `docformatter` removes *all* empty lines following class definitions, *even when there is no docstring present*.

I'm not sure if this is intentional, but I expected docformatter to only modify places where there is a docstring present.

(I ran into this face-first because Django generates its migration files with an empty line after class definition, so a patch version upgrade resulted in `171 files changed, 113 insertions(+), 286 deletions(-)` for me. Half of that is this issue, the other half that apparently the max line length was slightly shortened?)

### Reproduction:

Format this file with `docformatter==1.5.0` and `docformatter==1.5.1`:

```python
class Test:

    default = True
```

1.5.0 will leave the file alone, while 1.5.1 will output

```
--- before/test.py
+++ after/test.py
@@ -1,3 +1,2 @@
 class Test:
-
     default = True
```

This change appears to be related to #130.

Pull request description:
fix: handle blank lines after class definition properly
This pull request no longer removes blank lines after class definitions when there is no class docstring, but will remove blank lines between the class definition and the docstring.

Closes #139 

Patch:
diff --git a/.sourcery.yaml b/.sourcery.yaml
new file mode 100644
index 0000000..1a767dc
--- /dev/null
+++ b/.sourcery.yaml
@@ -0,0 +1,67 @@
+# 🪄 This is your project's Sourcery configuration file.
+
+# You can use it to get Sourcery working in the way you want, such as
+# ignoring specific refactorings, skipping directories in your project,
+# or writing custom rules.
+
+# 📚 For a complete reference to this file, see the documentation at
+# https://docs.sourcery.ai/Configuration/Project-Settings/
+
+# This file was auto-generated by Sourcery on 2022-12-28 at 22:39.
+
+version: '1'  # The schema version of this config file
+
+ignore: # A list of paths or files which Sourcery will ignore.
+- .git
+- venv
+- .venv
+- env
+- .env
+- .tox
+
+# rule_settings:
+#  enable:
+#  - default
+#  disable: []  # A list of rule IDs Sourcery will never suggest.
+#  rule_types:
+#  - refactoring
+#  - suggestion
+#  - comment
+#  python_version: '3.9' # A string specifying the lowest Python version your project supports. Sourcery will not suggest refactorings requiring a higher Python version.
+
+# rules:  # A list of custom rules Sourcery will include in its analysis.
+# - id: no-print-statements
+#   description: Do not use print statements in the test directory.
+#   pattern: print(...)
+#   replacement:
+#   condition:
+#   explanation:
+#   paths:
+#     include:
+#     - test
+#     exclude:
+#     - conftest.py
+#   tests: []
+#   tags: []
+
+# rule_tags: {} # Additional rule tags.
+
+# metrics:
+#   quality_threshold: 25.0
+
+# github:
+#   labels: []
+#   ignore_labels:
+#   - sourcery-ignore
+#   request_review: author
+#   sourcery_branch: sourcery/{base_branch}
+
+# clone_detection:
+#   min_lines: 3
+#   min_duplicates: 2
+#   identical_clones_only: false
+
+# proxy:
+#   url:
+#   ssl_certs_file:
+#   no_ssl_verify: false
diff --git a/src/docformatter/format.py b/src/docformatter/format.py
index c28d24c..5195b91 100644
--- a/src/docformatter/format.py
+++ b/src/docformatter/format.py
@@ -313,7 +313,8 @@ class Formatter:
 
                 # If the current token is a newline, the previous token was a
                 # newline or a comment, and these two sequential newlines
-                # follow a function definition, ignore the blank line.
+                # follow a function or method definition, ignore the blank
+                # line before the docstring.
                 if (
                     len(modified_tokens) <= 2
                     or token_type not in {tokenize.NL, tokenize.NEWLINE}
@@ -321,9 +322,7 @@ class Formatter:
                     not in {tokenize.NL, tokenize.NEWLINE}
                     or modified_tokens[-2][1] != ":"
                     and modified_tokens[-2][0] != tokenize.COMMENT
-                    or not modified_tokens[-2][4]
-                    .lstrip()
-                    .startswith(("def", "class"))
+                    or not modified_tokens[-2][4].lstrip().startswith(("def"))
                 ):
                     modified_tokens.append(
                         (token_type, token_string, start, end, line)
@@ -331,6 +330,9 @@ class Formatter:
                     modified_tokens = self._do_remove_blank_lines_after_method(
                         modified_tokens
                     )
+                    modified_tokens = self._do_remove_blank_lines_before_class(
+                        modified_tokens
+                    )
 
             return untokenize.untokenize(modified_tokens)
         except tokenize.TokenError:
@@ -450,7 +452,8 @@ class Formatter:
                 ).strip()
                 return f"{beginning}{summary_wrapped}{ending}"
 
-    def _do_remove_blank_lines_after_method(self, modified_tokens):
+    @staticmethod
+    def _do_remove_blank_lines_after_method(modified_tokens):
         """Remove blank lines after method docstring.
 
         Parameters
@@ -467,12 +470,38 @@ class Formatter:
         with contextlib.suppress(IndexError):
             if (
                 modified_tokens[-1][4] == "\n"
-                and modified_tokens[-2][4].strip() == '"""'
+                and modified_tokens[-2][4].lstrip().startswith('"""')
                 and modified_tokens[-5][4].lstrip().startswith("def")
             ):
                 modified_tokens.pop(-1)
         return modified_tokens
 
+    @staticmethod
+    def _do_remove_blank_lines_before_class(modified_tokens):
+        """Remove blank lines before class docstring.
+
+        If there is no class docstring, leave any blank lines as is.
+
+        Parameters
+        ----------
+        modified_tokens: list
+            The list of tokens created from the docstring.
+
+        Returns
+        -------
+        modified_tokens: list
+            The list of tokens with any blank lines following a method
+            docstring removed.
+        """
+        with contextlib.suppress(IndexError):
+            if (
+                modified_tokens[-3][4] == "\n"
+                and modified_tokens[-2][4].lstrip().startswith('"""')
+                and modified_tokens[-6][4].lstrip().startswith("class")
+            ):
+                modified_tokens.pop(-3)
+        return modified_tokens
+
     def _do_strip_docstring(self, docstring: str) -> Tuple[str, str]:
         """Return contents of docstring and opening quote type.
 


Test patch:
diff --git a/tests/test_format_code.py b/tests/test_format_code.py
index cb4b5ff..f12931e 100644
--- a/tests/test_format_code.py
+++ b/tests/test_format_code.py
@@ -944,6 +944,70 @@ class TestClass:
 '''
         )
 
+    @pytest.mark.unit
+    @pytest.mark.parametrize("args", [[""]])
+    def test_format_code_class_docstring_remove_blank_line(
+        self, test_args, args
+    ):
+        """Remove blank line before class docstring.
+
+        See issue #139.
+        """
+        uut = Formatter(
+            test_args,
+            sys.stderr,
+            sys.stdin,
+            sys.stdout,
+        )
+
+        docstring = '''\
+class TestClass:
+    """This is a class docstring.
+
+    :cvar test_int: a class attribute.
+    ..py.method: big_method()
+    """
+'''
+        assert docstring == uut._do_format_code(
+            '''\
+class TestClass:
+
+    """This is a class docstring.
+    :cvar test_int: a class attribute.
+    ..py.method: big_method()
+    """
+'''
+        )
+
+    @pytest.mark.unit
+    @pytest.mark.parametrize("args", [[""]])
+    def test_format_code_class_docstring_keep_blank_line(
+        self, test_args, args
+    ):
+        """Keep blank line after class definition if there is no docstring.
+
+        See issue #139.
+        """
+        uut = Formatter(
+            test_args,
+            sys.stderr,
+            sys.stdin,
+            sys.stdout,
+        )
+
+        docstring = """\
+class TestClass:
+
+    variable = 1
+"""
+        assert docstring == uut._do_format_code(
+            """\
+class TestClass:
+
+    variable = 1
+"""
+        )
+
     @pytest.mark.unit
     @pytest.mark.parametrize("args", [[""]])
     def test_format_code_strip_blank_line_after_method_docstring(
@@ -989,7 +1053,7 @@ class TestClass:
 
         pass
 '''
-)
+        )
 
 
 class TestFormatCodeRanges: