Repository: PyCQA/docformatter (python)

Issue:
Readme example does not produce the expected result
In the readme, an example using the syntax `docformatter --in-place example.py` is used, and suggests that 
```
def launch_rocket():
    """Launch
the
rocket. Go colonize space."""
```
is autoformatted to 
```
def launch_rocket():
    """Launch the rocket.

    Go colonize space.
    """
```
. This is not the case unless one runs `docformatter --in-place --force-wrap example.py` (this function is unchanged). As far as I can tell this happens due to the use heuristic ratio detection in `is_some_sort_of_list`

This isn't exactly a problem as one can just enable the flag, but may be worth making that explicit in the example. Not sure if there could be a better solution (maybe the first line of a docstring shouldn't be parsed as a list?) but I'm a new user so not sure what the desired behaviour should be.

Pull request:
fix: add argument to turn on/off many lines of short words being a list
The conditional in is_some_sort_of_list() function was identifying the README example
```
def launch_rocket():
    """Launch
the
rocket. Go colonize space."""
```
as a list because it was being processed as many lines of short words.  This required the --force-wrap argument to be set to produce the desired result.

This PR adds a new argument `--non-strict` to control the execution of this conditional.  Since the list format it is searching for is a non-standard reST list, this argument is assigned a default value of False.  Passing it on the command line or setting it True in the configuration file will restore current behavior (i.e., not strictly following reST).

Also adds some bling to the top of the README.

Closes #67 

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..2a2cc91 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]:
@@ -244,6 +246,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 +277,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 +306,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 +421,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 +432,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 +576,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 +593,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 +717,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 +854,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 +951,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..b841406 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 = '''"""
@@ -1058,7 +1058,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 +1078,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 +1127,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 +1160,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 +1186,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,
         )