Repository: PyCQA/docformatter (python)

Issue:
Remove the empty line between function definiton and docstring
If there is a blank line between function definition and the docstring, it should be removed: 
```python
def my_func():

"""Summary of my function"""
```

should be modified to:
 
```python
def my_func():
"""Summary of my function"""
```

Pull request description:
Removed the empty line between function definiton and docstring #51
Prevents additional line breaks after function definition as only one linebreak is allowed.

Closes #51 

Patch:
diff --git a/.gitignore b/.gitignore
index 7e1c99c..51246d6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
+*~
 *.egg
 *.egg-info/
 *.eggs/
diff --git a/README.rst b/README.rst
index d206021..a926b88 100644
--- a/README.rst
+++ b/README.rst
@@ -2,6 +2,34 @@
 docformatter
 ============
 
+.. |CI| image:: https://img.shields.io/github/workflow/status/PyCQA/docformatter/CI
+.. |CONTRIBUTORS| image:: https://img.shields.io/github/contributors/PyCQA/docformatter
+.. |COMMIT| image:: https://img.shields.io/github/last-commit/PyCQA/docformatter
+.. |BLACK| image:: https://img.shields.io/badge/%20style-black-000000.svg
+    :target: https://github.com/psf/black
+.. |ISORT| image:: https://img.shields.io/badge/%20imports-isort-%231674b1
+    :target: https://pycqa.github.io/isort/
+.. |SELF| image:: https://img.shields.io/badge/%20formatter-docformatter-fedcba.svg
+    :target: https://pycqa.github.io/docformatter/
+.. |DOCSTYLE| image:: https://img.shields.io/badge/%20style-numpy-459db9.svg
+    :target: https://numpydoc.readthedocs.io/en/latest/format.html
+
+.. |VERSION| image:: https://img.shields.io/pypi/v/docformatter
+.. |LICENSE| image:: https://img.shields.io/pypi/l/docformatter
+.. |PYVERS| image:: https://img.shields.io/pypi/pyversions/docformatter
+.. |PYMAT| image:: https://img.shields.io/pypi/format/docformatter
+.. |DD| image:: https://img.shields.io/pypi/dd/docformatter
+
++----------------+----------------------------------------------------------+
+| **Code**       + |BLACK| |ISORT|                                          +
++----------------+----------------------------------------------------------+
+| **Docstrings** + |SELF| |DOCSTYLE|                                        +
++----------------+----------------------------------------------------------+
+| **GitHub**     + |CI| |CONTRIBUTORS| |COMMIT|                             +
++----------------+----------------------------------------------------------+
+| **PyPi**       + |VERSION| |LICENSE| |PYVERS| |PYMAT| |DD|                +
++----------------+----------------------------------------------------------+
+
 Formats docstrings to follow `PEP 257`_.
 
 .. _`PEP 257`: http://www.python.org/dev/peps/pep-0257/
@@ -10,9 +38,7 @@ Formats docstrings to follow `PEP 257`_.
 Features
 ========
 
-*docformatter* currently automatically formats docstrings to follow a
-subset of the PEP 257 conventions. Below are the relevant items quoted
-from PEP 257.
+*docformatter* automatically formats docstrings to follow a subset of the PEP 257 conventions. Below are the relevant items quoted from PEP 257.
 
 - For consistency, always use triple double quotes around docstrings.
 - Triple quotes are used even though the string fits on one line.
@@ -169,6 +195,7 @@ Below is the help output::
                             line numbers are indexed at 1
       --docstring-length min_length max_length
                             apply docformatter to docstrings of given length range
+      --strict              strictly follow reST syntax to identify lists (see issue #67)
       --version             show program's version number and exit
       --config CONFIG       path to file containing docformatter options
 
@@ -236,9 +263,43 @@ PyCharm
 
 Head over to ``Preferences > Tools > File Watchers``, click the ``+`` icon and configure *docformatter* as shown below:
 
-.. image:: /images/pycharm-file-watcher-configurations.png
+.. image:: ./images/pycharm-file-watcher-configurations.png
    :alt: PyCharm file watcher configurations
 
+Marketing
+=========
+Do you use *docformatter*?  What style docstrings do you use?  Add some badges to your project's **README** and let everyone know.
+
+|SELF|
+
+.. code-block::
+
+	.. image:: https://img.shields.io/badge/%20formatter-docformatter-fedcba.svg
+  	  :target: https://pycqa.github.io/docformatter/
+
+.. image:: https://img.shields.io/badge/%20style-google-3666d6.svg
+	    :target: https://google.github.io/styleguide/pyguide.html#s3.8-comments-and-docstrings
+
+.. code-block::
+
+	.. image:: https://img.shields.io/badge/%20style-google-3666d6.svg
+	    :target: https://google.github.io/styleguide/pyguide.html#s3.8-comments-and-docstrings
+
+|DOCSTYLE|
+
+.. code-block::
+
+	.. image:: https://img.shields.io/badge/%20style-numpy-459db9.svg
+  	  :target: https://numpydoc.readthedocs.io/en/latest/format.html
+
+.. image:: https://img.shields.io/badge/%20style-sphinx-0a507a.svg
+	    :target: https://www.sphinx-doc.org/en/master/usage/index.html
+
+.. code-block::
+
+	.. image:: https://img.shields.io/badge/%20style-sphinx-0a507a.svg
+	    :target: https://www.sphinx-doc.org/en/master/usage/index.html
+
 
 Issues
 ======
diff --git a/docformatter.py b/docformatter.py
index 582aacf..ed777ee 100755
--- a/docformatter.py
+++ b/docformatter.py
@@ -176,6 +176,7 @@ def _format_code(
     force_wrap=False,
     line_range=None,
     length_range=None,
+    strict=True,
 ):
     """Return source code with docstrings formatted."""
     if not source:
@@ -222,6 +223,7 @@ def _format_code(
                 make_summary_multi_line=make_summary_multi_line,
                 post_description_blank=post_description_blank,
                 force_wrap=force_wrap,
+                strict=strict,
             )
 
         if token_type not in [tokenize.COMMENT, tokenize.NEWLINE, tokenize.NL]:
@@ -230,7 +232,15 @@ def _format_code(
         previous_token_string = token_string
         previous_token_type = token_type
 
-        modified_tokens.append((token_type, token_string, start, end, line))
+        # prevent empty line between func def and docstring
+        new_line_tokens = {tokenize.NL, tokenize.NEWLINE}
+        if len(modified_tokens) > 2 and modified_tokens[-2][0] == tokenize.OP \
+                and modified_tokens[-1][0] in new_line_tokens \
+                and token_type in new_line_tokens:
+            pass
+        else:
+            modified_tokens.append(
+                (token_type, token_string, start, end, line))
 
     return untokenize.untokenize(modified_tokens)
 
@@ -244,6 +254,7 @@ def format_docstring(
     make_summary_multi_line=False,
     post_description_blank=False,
     force_wrap=False,
+    strict=True,
 ):
     """Return formatted version of docstring.
 
@@ -274,7 +285,7 @@ def format_docstring(
     if remove_section_header(description).strip() != description.strip():
         return docstring
 
-    if not force_wrap and is_some_sort_of_list(summary):
+    if not force_wrap and is_some_sort_of_list(summary, strict):
         # Something is probably not right with the splitting.
         return docstring
 
@@ -303,6 +314,7 @@ def format_docstring(
                 indentation=indentation,
                 wrap_length=description_wrap_length,
                 force_wrap=force_wrap,
+                strict=strict,
             ),
             post_description=("\n" if post_description_blank else ""),
             indentation=indentation,
@@ -417,7 +429,7 @@ def split_first_sentence(text):
     return sentence, delimiter + rest
 
 
-def is_some_sort_of_list(text):
+def is_some_sort_of_list(text, strict):
     """Return True if text looks like a list."""
     split_lines = text.rstrip().splitlines()
 
@@ -428,7 +440,7 @@ def is_some_sort_of_list(text):
         len(split_lines)
         / max([len(line.strip()) for line in split_lines] + [1])
         > HEURISTIC_MIN_LIST_ASPECT_RATIO
-    ):
+    ) and not strict:
         return True
 
     return any(
@@ -572,7 +584,7 @@ def wrap_summary(summary, initial_indent, subsequent_indent, wrap_length):
         return summary
 
 
-def wrap_description(text, indentation, wrap_length, force_wrap):
+def wrap_description(text, indentation, wrap_length, force_wrap, strict):
     """Return line-wrapped description text.
 
     We only wrap simple descriptions. We leave doctests, multi-paragraph
@@ -589,7 +601,7 @@ def wrap_description(text, indentation, wrap_length, force_wrap):
     # Ignore possibly complicated cases.
     if wrap_length <= 0 or (
         not force_wrap
-        and (is_some_sort_of_list(text) or is_some_sort_of_code(text))
+        and (is_some_sort_of_list(text, strict) or is_some_sort_of_code(text))
     ):
         return text
 
@@ -713,6 +725,7 @@ def _format_code_with_args(source, args):
         post_description_blank=args.post_description_blank,
         force_wrap=args.force_wrap,
         line_range=args.line_range,
+        strict=args.non_strict,
     )
 
 
@@ -849,10 +862,16 @@ def _main(argv, standard_out, standard_error, standard_in):
         nargs=2,
         help="apply docformatter to docstrings of given length range",
     )
+    parser.add_argument(
+        "--non-strict",
+        action="store_true",
+        default=bool(flargs.get("non-strict", False)),
+        help="don't strictly follow reST syntax to identify lists (see issue "
+             "#67)",
+    )
     parser.add_argument(
         "--version", action="version", version=f"%(prog)s {__version__}"
     )
-
     parser.add_argument(
         "--config", help="path to file containing docformatter options"
     )
@@ -940,12 +959,14 @@ def find_py_files(sources, recursive, exclude=None):
 
     def is_excluded(name, exclude):
         """Return True if file 'name' is excluded."""
-        if not exclude:
-            return False
-        for e in exclude:
-            if re.search(re.escape(str(e)), name, re.IGNORECASE):
-                return True
-        return False
+        return (
+            any(
+                re.search(re.escape(str(e)), name, re.IGNORECASE)
+                for e in exclude
+            )
+            if exclude
+            else False
+        )
 
     for name in sorted(sources):
         if recursive and os.path.isdir(name):
diff --git a/pyproject.toml b/pyproject.toml
index eca3d45..facfdc9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -80,6 +80,7 @@ disable = [
 [tool.docformatter]
 wrap-summaries = 79
 wrap-descriptions = 72
+non-strict = false
 
 [tool.pydocstyle]
 convention = "pep257"
@@ -179,6 +180,7 @@ deps =
     pydocstyle
     pylint
     toml
+    untokenize
 commands =
     pycodestyle docformatter.py setup.py
     pydocstyle docformatter.py setup.py


Test patch:
diff --git a/test_docformatter.py b/test_docformatter.py
index 0e225e6..da4b380 100755
--- a/test_docformatter.py
+++ b/test_docformatter.py
@@ -356,7 +356,7 @@ Hello.
     """'''
         self.assertEqual(
             docstring,
-            docformatter.format_docstring('    ', docstring))
+            docformatter.format_docstring('    ', docstring, strict=False))
 
     def test_format_docstring_should_underlined_summaries_alone(self):
         docstring = '''"""
@@ -971,6 +971,55 @@ num_iterations is the number of updates - instead of a better definition of conv
 """This one-line docstring will be multi-line"""\
 ''', make_summary_multi_line=True))
 
+    def test__format_code_additional_empty_line_before_doc(self):
+        args = {'summary_wrap_length': 79,
+                'description_wrap_length': 72,
+                'pre_summary_newline': False,
+                'make_summary_multi_line': False,
+                'post_description_blank': False,
+                'force_wrap': False,
+                'line_range': None}
+        self.assertEqual('\n\n\ndef my_func():\n"""Summary of my function."""\npass',
+                         docformatter._format_code('\n\n\ndef my_func():\n\n"""Summary of my function."""\npass', args))
+
+    def test_exclude(self):
+        sources = {"/root"}
+        patch_data = [
+            ("/root", ['folder_one', 'folder_two'], []),
+            ("/root/folder_one", ['folder_three'], ["one.py"]),
+            ("/root/folder_one/folder_three", [], ["three.py"]),
+            ("/root/folder_two", [], ["two.py"]),
+        ]
+        with patch("os.walk", return_value=patch_data), patch("os.path.isdir", return_value=True):
+            test_exclude_one = list(docformatter.find_py_files(sources, True, ["folder_one"]))
+            self.assertEqual(test_exclude_one, ['/root/folder_two/two.py'])
+            test_exclude_two = list(docformatter.find_py_files(sources, True, ["folder_two"]))
+            self.assertEqual(test_exclude_two, ['/root/folder_one/one.py', '/root/folder_one/folder_three/three.py'])
+            test_exclude_three = list(docformatter.find_py_files(sources, True, ["folder_three"]))
+            self.assertEqual(test_exclude_three, ['/root/folder_one/one.py', '/root/folder_two/two.py'])
+            test_exclude_py = list(docformatter.find_py_files(sources, True, ".py"))
+            self.assertFalse(test_exclude_py)
+            test_exclude_two_and_three = list(docformatter.find_py_files(sources, True, ["folder_two", "folder_three"]))
+            self.assertEqual(test_exclude_two_and_three, ['/root/folder_one/one.py'])
+            test_exclude_files = list(docformatter.find_py_files(sources, True, ["one.py", "two.py"]))
+            self.assertEqual(test_exclude_files, ['/root/folder_one/folder_three/three.py'])
+
+    def test_exclude_nothing(self):
+        sources = {"/root"}
+        patch_data = [
+            ("/root", ['folder_one', 'folder_two'], []),
+            ("/root/folder_one", ['folder_three'], ["one.py"]),
+            ("/root/folder_one/folder_three", [], ["three.py"]),
+            ("/root/folder_two", [], ["two.py"]),
+        ]
+        with patch("os.walk", return_value=patch_data), patch("os.path.isdir", return_value=True):
+            test_exclude_nothing = list(docformatter.find_py_files(sources, True, []))
+            self.assertEqual(test_exclude_nothing, ['/root/folder_one/one.py', '/root/folder_one/folder_three/three.py',
+                                                    '/root/folder_two/two.py'])
+            test_exclude_nothing = list(docformatter.find_py_files(sources, True))
+            self.assertEqual(test_exclude_nothing, ['/root/folder_one/one.py', '/root/folder_one/folder_three/three.py',
+                                                    '/root/folder_two/two.py'])
+
 class TestSystem(unittest.TestCase):
 
     def test_diff(self):
@@ -1058,7 +1107,8 @@ def foo():
 -    Hello world 
 -    """
 +    """Hello world."""
-''', '\n'.join(process.communicate()[0].decode().replace("\r", "").split('\n')[2:]))
+''', '\n'.join(process.communicate()[0].decode().replace("\r", "").split(
+                '\n')[2:]))
 
     def test_end_to_end_with_wrapping(self):
         with temporary_file('''\
@@ -1077,7 +1127,8 @@ def foo():
 -    """
 +    """Hello world this is a summary
 +    that will get wrapped."""
-''', '\n'.join(process.communicate()[0].decode().replace("\r", "").split('\n')[2:]))
+''', '\n'.join(process.communicate()[0].decode().replace("\r", "").split(
+                '\n')[2:]))
 
     def test_end_to_end_with_no_wrapping(self):
         with temporary_file('''\
@@ -1125,7 +1176,8 @@ def foo():
      """Wrapping is off, but it will still add
 -    the trailing period  """
 +    the trailing period."""
-''', '\n'.join(process.communicate()[0].decode().replace("\r", "").split('\n')[2:]))
+''', '\n'.join(process.communicate()[0].decode().replace("\r", "").split(
+                '\n')[2:]))
 
 
     def test_end_to_end_all_options(self):
@@ -1157,7 +1209,8 @@ def foo():
  
 -
      """
-''', '\n'.join(process.communicate()[0].decode().replace("\r", "").split('\n')[2:]))
+''', '\n'.join(process.communicate()[0].decode().replace("\r", "").split(
+                '\n')[2:]))
 
     def test_invalid_range(self):
         process = run_docformatter(['--range', '0', '1', os.devnull])
@@ -1182,7 +1235,6 @@ Hello world"""
 '''.encode())[0].decode().replace("\r", "")
 
         self.assertEqual(0, process.returncode)
-
         self.assertEqual(
             '''"""Hello world."""\n''',
             result)
diff --git a/tests/_data/pyproject.toml b/tests/_data/pyproject.toml
index 5503646..008373a 100644
--- a/tests/_data/pyproject.toml
+++ b/tests/_data/pyproject.toml
@@ -1,4 +1,3 @@
 [tool.docformatter]
 recursive = true
 wrap-summaries = 82
-
diff --git a/tests/test_utility_functions.py b/tests/test_utility_functions.py
index cb9bb3a..7105ec1 100644
--- a/tests/test_utility_functions.py
+++ b/tests/test_utility_functions.py
@@ -238,7 +238,8 @@ class TestIsSomeSortdOfList:
         @param
         @param
         @param
-    """
+    """,
+            True,
         )
 
     @pytest.mark.unit
@@ -249,7 +250,8 @@ class TestIsSomeSortdOfList:
         Keyword arguments:
         real -- the real part (default 0.0)
         imag -- the imaginary part (default 0.0)
-    """
+    """,
+            True,
         )
 
     @pytest.mark.unit
@@ -264,7 +266,8 @@ class TestIsSomeSortdOfList:
           release-1.4/
           release-1.4.1/
           release-1.5/
-    """
+    """,
+            True,
         )
 
     @pytest.mark.unit
@@ -274,7 +277,38 @@ class TestIsSomeSortdOfList:
             """\
     Args:
         stream (BinaryIO): Binary stream (usually a file object).
-    """
+    """,
+            True,
+        )
+
+    @pytest.mark.unit
+    def test_is_some_sort_of_list_strict_wrap(self):
+        """Ignore many lines of short words as a list with strict set True.
+
+        See issue #67.
+        """
+        assert not docformatter.is_some_sort_of_list(
+            """\
+        Launch
+the
+rocket.
+    """,
+            True,
+        )
+
+    @pytest.mark.unit
+    def test_is_some_sort_of_list_non_strict_wrap(self):
+        """Identify many lines of short words as a list with strict False.
+
+        See issue #67.
+        """
+        assert docformatter.is_some_sort_of_list(
+            """\
+        Launch
+the
+rocket.
+    """,
+            False,
         )