Repository: PyCQA/docformatter (2022-08-04 ~ 2022-08-24)

problem_statement: 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"""
```
date: 2022-08-04
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/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/__init__.py b/tests/__init__.py
index e69de29..9f49e72 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -0,0 +1,53 @@
+# pylint: skip-file
+# type: ignore
+#
+#       tests._init__.py is part of the docformatter project
+#
+# Copyright (C) 2012-2019 Steven Myint
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+# Standard Library Imports
+import random
+import string
+
+
+def generate_random_docstring(
+    max_indentation_length=32,
+    max_word_length=20,
+    max_words=50,
+):
+    """Generate single-line docstring."""
+    if random.randint(0, 1):
+        words = []
+    else:
+        words = [
+            generate_random_word(random.randint(0, max_word_length))
+            for _ in range(random.randint(0, max_words))
+        ]
+
+    indentation = random.randint(0, max_indentation_length) * " "
+    quote = '"""' if random.randint(0, 1) else "'''"
+    return quote + indentation + " ".join(words) + quote
+
+
+def generate_random_word(word_length):
+    return "".join(random.sample(string.ascii_letters, word_length))
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_docformatter.py b/tests/test_docformatter.py
new file mode 100644
index 0000000..20007ee
--- /dev/null
+++ b/tests/test_docformatter.py
@@ -0,0 +1,491 @@
+# pylint: skip-file
+# type: ignore
+#
+#       tests.test_docformatter.py is part of the docformatter project
+#
+# Copyright (C) 2012-2019 Steven Myint
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+"""Module for testing docformatter end-to-end."""
+
+# Standard Library Imports
+import io
+import os
+
+# Third Party Imports
+import pytest
+
+# docformatter Package Imports
+from docformatter import __main__ as main
+
+
+class TestMain:
+    """Class for testing the _main() function."""
+
+    @pytest.mark.system
+    @pytest.mark.parametrize(
+        "contents",
+        [
+            '''\
+    def foo():
+        """
+        Hello world
+        """
+'''
+        ],
+    )
+    def test_diff(self, temporary_file, contents):
+        """Should produce diff showing changes."""
+        output_file = io.StringIO()
+        main._main(
+            argv=["my_fake_program", temporary_file],
+            standard_out=output_file,
+            standard_error=None,
+            standard_in=None,
+        )
+
+        assert '''\
+@@ -1,4 +1,2 @@
+     def foo():
+-        """
+-        Hello world
+-        """
++        """Hello world."""
+''' == "\n".join(
+            output_file.getvalue().split("\n")[2:]
+        )
+
+    @pytest.mark.system
+    def test_diff_with_nonexistent_file(self):
+        """Should return error message when file doesn't exist."""
+        output_file = io.StringIO()
+        main._main(
+            argv=["my_fake_program", "nonexistent_file"],
+            standard_out=output_file,
+            standard_error=output_file,
+            standard_in=None,
+        )
+        assert "no such file" in output_file.getvalue().lower()
+
+    @pytest.mark.system
+    @pytest.mark.parametrize(
+        "contents",
+        [
+            '''\
+def foo():
+    """
+    Hello world
+    """
+'''
+        ],
+    )
+    def test_in_place(self, temporary_file, contents):
+        """Should make changes and save back to file."""
+        output_file = io.StringIO()
+        main._main(
+            argv=["my_fake_program", "--in-place", temporary_file],
+            standard_out=output_file,
+            standard_error=None,
+            standard_in=None,
+        )
+        with open(temporary_file) as f:
+            assert (
+                '''\
+def foo():
+    """Hello world."""
+'''
+                == f.read()
+            )
+
+    @pytest.mark.system
+    @pytest.mark.parametrize(
+        "contents",
+        [
+            '''\
+def foo():
+    """
+    Hello world
+    """
+'''
+        ],
+    )
+    @pytest.mark.parametrize("file_directory", ["temporary_directory"])
+    @pytest.mark.parametrize("directory", ["."])
+    @pytest.mark.parametrize("prefix", ["."])
+    def test_ignore_hidden_directories(
+        self,
+        temporary_file,
+        temporary_directory,
+        contents,
+        file_directory,
+        directory,
+        prefix,
+    ):
+        """Ignore 'hidden' directories when recursing."""
+        output_file = io.StringIO()
+        main._main(
+            argv=["my_fake_program", "--recursive", temporary_directory],
+            standard_out=output_file,
+            standard_error=None,
+            standard_in=None,
+        )
+        assert "" == output_file.getvalue().strip()
+
+    @pytest.mark.system
+    def test_io_error_exit_code(self):
+        """Return error code 1 when file does not exist."""
+        stderr = io.StringIO()
+        ret_code = main._main(
+            argv=["my_fake_program", "this_file_should_not_exist_please"],
+            standard_out=None,
+            standard_error=stderr,
+            standard_in=None,
+        )
+
+        assert ret_code == 1
+
+    @pytest.mark.system
+    @pytest.mark.parametrize(
+        "contents",
+        ["""Totally fine docstring, do not report anything."""],
+    )
+    def test_check_mode_correct_docstring(self, temporary_file, contents):
+        """"""
+        stdout = io.StringIO()
+        stderr = io.StringIO()
+        ret_code = main._main(
+            argv=["my_fake_program", "--check", temporary_file],
+            standard_out=stdout,
+            standard_error=stderr,
+            standard_in=None,
+        )
+
+        assert ret_code == 0  # FormatResult.ok
+        assert stdout.getvalue() == ""
+        assert stderr.getvalue() == ""
+
+    @pytest.mark.system
+    @pytest.mark.parametrize(
+        "contents",
+        [
+            '''
+    """
+Print my path and return error code
+"""
+'''
+        ],
+    )
+    def test_check_mode_incorrect_docstring(self, temporary_file, contents):
+        """"""
+        stdout = io.StringIO()
+        stderr = io.StringIO()
+        ret_code = main._main(
+            argv=["my_fake_program", "--check", temporary_file],
+            standard_out=stdout,
+            standard_error=stderr,
+            standard_in=None,
+        )
+        assert ret_code == 3  # FormatResult.check_failed
+        assert stdout.getvalue() == ""
+        assert stderr.getvalue().strip() == temporary_file
+
+
+class TestEndToEnd:
+    """Class to test docformatter by executing it from the command line."""
+
+    @pytest.mark.system
+    @pytest.mark.parametrize(
+        "contents",
+        [
+            '''\
+    def foo():
+        """
+        Hello world
+        """
+'''
+        ],
+    )
+    @pytest.mark.parametrize("arguments", [[]])
+    def test_end_to_end(
+        self,
+        temporary_file,
+        contents,
+        run_docformatter,
+        arguments,
+    ):
+        """"""
+        assert '''\
+@@ -1,4 +1,2 @@
+     def foo():
+-        """
+-        Hello world
+-        """
++        """Hello world."""
+''' == "\n".join(
+            run_docformatter.communicate()[0]
+            .decode()
+            .replace("\r", "")
+            .split("\n")[2:]
+        )
+
+    @pytest.mark.system
+    @pytest.mark.parametrize(
+        "contents",
+        [
+            '''\
+def foo():
+    """
+    Hello world this is a summary that will get wrapped
+    """
+'''
+        ],
+    )
+    @pytest.mark.parametrize("arguments", [["--wrap-summaries=40"]])
+    def test_end_to_end_with_wrapping(
+        self,
+        run_docformatter,
+        temporary_file,
+        contents,
+        arguments,
+    ):
+        """"""
+        assert '''\
+@@ -1,4 +1,3 @@
+ def foo():
+-    """
+-    Hello world this is a summary that will get wrapped
+-    """
++    """Hello world this is a summary
++    that will get wrapped."""
+''' == "\n".join(
+            run_docformatter.communicate()[0]
+            .decode()
+            .replace("\r", "")
+            .split("\n")[2:]
+        )
+
+    @pytest.mark.system
+    @pytest.mark.parametrize(
+        "contents",
+        [
+            '''\
+def foo():
+    """Hello world is a long sentence that will not be wrapped because I turned wrapping off.
+
+    Hello world is a long sentence that will not be wrapped because I turned wrapping off.
+    """
+'''
+        ],
+    )
+    @pytest.mark.parametrize(
+        "arguments", [["--wrap-summaries=0", "--wrap-description=0"]]
+    )
+    def test_end_to_end_with_no_wrapping(
+        self,
+        run_docformatter,
+        temporary_file,
+        contents,
+        arguments,
+    ):
+        assert "" == "\n".join(
+            run_docformatter.communicate()[0]
+            .decode()
+            .replace("\r", "")
+            .split("\n")[2:]
+        )
+
+    @pytest.mark.system
+    @pytest.mark.parametrize(
+        "contents",
+        [
+            '''\
+def foo():
+    """Hello world is a long sentence that will not
+    be wrapped because I turned wrapping off.
+
+    Hello world is a long sentence that will not
+    be wrapped because I turned wrapping off.
+    """
+'''
+        ],
+    )
+    @pytest.mark.parametrize(
+        "arguments", [["--wrap-summaries=0", "--wrap-description=0"]]
+    )
+    def test_end_to_end_with_no_wrapping_2(
+        self,
+        run_docformatter,
+        temporary_file,
+        arguments,
+        contents,
+    ):
+        """"""
+        assert "" == "\n".join(
+            run_docformatter.communicate()[0]
+            .decode()
+            .replace("\r", "")
+            .split("\n")[2:]
+        )
+
+    @pytest.mark.system
+    @pytest.mark.parametrize(
+        "contents",
+        [
+            '''\
+def foo():
+    """Wrapping is off, but it will still add
+    the trailing period  """
+'''
+        ],
+    )
+    @pytest.mark.parametrize("arguments", [["--wrap-summaries=0"]])
+    def test_end_to_end_no_wrapping_period(
+        self,
+        run_docformatter,
+        temporary_file,
+        arguments,
+        contents,
+    ):
+        """"""
+        assert '''\
+@@ -1,3 +1,3 @@
+ def foo():
+     """Wrapping is off, but it will still add
+-    the trailing period  """
++    the trailing period."""
+''' == "\n".join(
+            run_docformatter.communicate()[0]
+            .decode()
+            .replace("\r", "")
+            .split("\n")[2:]
+        )
+
+    @pytest.mark.system
+    @pytest.mark.parametrize(
+        "contents",
+        [
+            '''\
+def foo():
+    """Hello world is a long sentence that will be wrapped at 40 characters because I'm using that option
+    - My list item
+    - My list item
+
+
+    """
+'''
+        ],
+    )
+    @pytest.mark.parametrize(
+        "arguments",
+        [
+            [
+                "--wrap-summaries=40",
+                "--wrap-summaries=40",
+                "--pre-summary-newline",
+                "--blank",
+            ]
+        ],
+    )
+    def test_end_to_end_all_options(
+        self,
+        run_docformatter,
+        temporary_file,
+        arguments,
+        contents,
+    ):
+        """"""
+        assert '''\
+@@ -1,7 +1,10 @@
+ def foo():
+-    """Hello world is a long sentence that will be wrapped at 40 characters because I'm using that option
++    """
++    Hello world is a long sentence that
++    will be wrapped at 40 characters
++    because I'm using that option.
++
+     - My list item
+     - My list item
+ 
+-
+     """
+''' == "\n".join(
+            run_docformatter.communicate()[0]
+            .decode()
+            .replace("\r", "")
+            .split("\n")[2:]
+        )
+
+    @pytest.mark.system
+    @pytest.mark.parametrize("contents", [""])
+    @pytest.mark.parametrize(
+        "arguments",
+        [
+            ["--range", "0", "1", os.devnull],
+            ["--range", "3", "1", os.devnull],
+        ],
+    )
+    def test_invalid_range(self, run_docformatter, arguments):
+        """"""
+        if arguments[1] == "0":
+            assert (
+                "must be positive"
+                in run_docformatter.communicate()[1].decode()
+            )
+        if arguments[1] == "3":
+            assert (
+                "should be less than"
+                in run_docformatter.communicate()[1].decode()
+            )
+
+    @pytest.mark.system
+    @pytest.mark.parametrize("arguments", [[]])
+    @pytest.mark.parametrize("contents", [""])
+    def test_no_arguments(self, run_docformatter, arguments):
+        """"""
+        assert '' == run_docformatter.communicate()[1].decode()
+
+    @pytest.mark.system
+    @pytest.mark.parametrize("arguments", [['-']])
+    @pytest.mark.parametrize("contents", [""])
+    def test_standard_in(self, run_docformatter, arguments):
+        result = run_docformatter.communicate('''\
+"""
+Hello world"""
+'''.encode())[0].decode().replace("\r", "")
+
+        assert 0 == run_docformatter.returncode
+        assert '''"""Hello world."""\n''' == result
+
+    @pytest.mark.system
+    @pytest.mark.parametrize("arguments", [['foo.py','-'], ['--in-place',
+                                                            '-'],
+                                           ['--recursive', '-'],])
+    @pytest.mark.parametrize("contents", [""])
+    def test_standard_in_with_invalid_options(self, run_docformatter, arguments):
+        """"""
+        if arguments[0] == "foo.py":
+            assert 'cannot mix' in run_docformatter.communicate()[1].decode()
+
+        if arguments[0] == "--in-place":
+            assert 'cannot be used' in run_docformatter.communicate()[1].decode()
+
+        if arguments[0] == "--recursive":
+            assert 'cannot be used' in run_docformatter.communicate()[1].decode()
diff --git a/tests/test_format_docstring.py b/tests/test_format_docstring.py
new file mode 100644
index 0000000..fc51f76
--- /dev/null
+++ b/tests/test_format_docstring.py
@@ -0,0 +1,522 @@
+# pylint: skip-file
+# type: ignore
+#
+#       tests.test_format_docstring.py is part of the docformatter project
+#
+# Copyright (C) 2012-2019 Steven Myint
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+"""Module for testing the format_docstring() function."""
+
+
+# Standard Library Imports
+import itertools
+import random
+
+# Third Party Imports
+import pytest
+
+# docformatter Package Imports
+import docformatter
+
+# docformatter Local Imports
+from . import generate_random_docstring
+
+
+class TestFormatDocstring:
+    """Class for testing format_docstring() with no arguments."""
+
+    @pytest.mark.unit
+    def test_format_docstring(self):
+        """Return one-line docstring."""
+        assert '"""Hello."""' == docformatter.format_docstring(
+            "    ",
+            '''
+"""
+
+Hello.
+"""
+'''.strip(),
+        )
+
+    @pytest.mark.unit
+    def test_format_docstring_with_summary_that_ends_in_quote(self):
+        """Return one-line docstring with period after quote."""
+        assert '''""""Hello"."""''' == docformatter.format_docstring(
+            "    ",
+            '''
+"""
+
+"Hello"
+"""
+'''.strip(),
+        )
+
+    @pytest.mark.unit
+    def test_format_docstring_with_bad_indentation(self):
+        """Add spaces to indentation when too few."""
+        assert '''"""Hello.
+
+    This should be indented but it is not. The
+    next line should be indented too. And
+    this too.
+    """''' == docformatter.format_docstring(
+            "    ",
+            '''
+"""Hello.
+
+ This should be indented but it is not. The
+ next line should be indented too. And
+ this too.
+    """
+'''.strip(),
+        )
+
+    @pytest.mark.unit
+    def test_format_docstring_with_too_much_indentation(self):
+        """Remove spaces from indentation when too many."""
+        assert '''"""Hello.
+
+    This should be dedented.
+
+    1. This too.
+    2. And this.
+    3. And this.
+    """''' == docformatter.format_docstring(
+            "    ",
+            '''
+"""Hello.
+
+        This should be dedented.
+
+        1. This too.
+        2. And this.
+        3. And this.
+
+    """
+'''.strip(),
+        )
+
+    @pytest.mark.unit
+    def test_format_docstring_with_trailing_whitespace(self):
+        """Remove trailing white space."""
+        assert '''"""Hello.
+
+    This should be not have trailing whitespace. The
+    next line should not have trailing whitespace either.
+    """''' == docformatter.format_docstring(
+            "    ",
+            '''
+"""Hello.\t
+\t
+    This should be not have trailing whitespace. The\t\t\t
+    next line should not have trailing whitespace either.\t
+\t
+    """
+'''.strip(),
+        )
+
+    @pytest.mark.unit
+    def test_format_docstring_with_empty_docstring(self):
+        """Do nothing with empty docstring."""
+        assert '""""""' == docformatter.format_docstring("    ", '""""""')
+
+    @pytest.mark.unit
+    def test_format_docstring_with_no_period(self):
+        """Add period to end of one-line and summary line."""
+        assert '"""Hello."""' == docformatter.format_docstring(
+            "    ",
+            '''
+"""
+
+Hello
+"""
+'''.strip(),
+        )
+
+    @pytest.mark.unit
+    def test_format_docstring_with_single_quotes(self):
+        """Replace single triple quotes with triple double quotes."""
+        assert '"""Hello."""' == docformatter.format_docstring(
+            "    ",
+            """
+'''
+
+Hello.
+'''
+""".strip(),
+        )
+
+    @pytest.mark.unit
+    def test_format_docstring_with_single_quotes_multi_line(self):
+        """Replace single triple quotes with triple double quotes."""
+        assert '''
+    """Return x factorial.
+
+    This uses math.factorial.
+    """
+'''.strip() == docformatter.format_docstring(
+            "    ",
+            """
+    '''
+    Return x factorial.
+
+    This uses math.factorial.
+    '''
+""".strip(),
+        )
+
+    @pytest.mark.unit
+    def test_format_docstring_leave_underlined_summaries_alone(self):
+        docstring = '''"""
+    Foo bar
+    -------
+
+    This is more.
+
+    """'''
+        assert docstring == docformatter.format_docstring("    ", docstring)
+
+
+class TestFormatLists:
+    """Class for testing format_docstring() with lists in the docstring."""
+
+    @pytest.mark.unit
+    def test_format_docstring_should_ignore_numbered_lists(self):
+        """Ignore lists beginning with numbers."""
+        docstring = '''"""Hello.
+
+    1. This should be indented but it is not. The
+    next line should be indented too. But
+    this is okay.
+    """'''
+        assert docstring == docformatter.format_docstring(
+            "    ", docstring, description_wrap_length=72
+        )
+
+    @pytest.mark.unit
+    def test_format_docstring_should_ignore_parameter_lists(self):
+        """Ignore lists beginning with <word> -."""
+        docstring = '''"""Hello.
+
+    foo - This is a foo. This is a foo. This is a foo. This is a foo. This is.
+    bar - This is a bar. This is a bar. This is a bar. This is a bar. This is.
+    """'''
+        assert docstring == docformatter.format_docstring(
+            "    ", docstring, description_wrap_length=72
+        )
+
+    @pytest.mark.unit
+    def test_format_docstring_should_ignore_colon_parameter_lists(self):
+        """Ignore lists beginning with <word>:"""
+        docstring = '''"""Hello.
+
+    foo: This is a foo. This is a foo. This is a foo. This is a foo. This is.
+    bar: This is a bar. This is a bar. This is a bar. This is a bar. This is.
+    """'''
+        assert docstring == docformatter.format_docstring(
+            "    ", docstring, description_wrap_length=72
+        )
+
+    @pytest.mark.unit
+    def test_format_docstring_should_leave_list_alone(self):
+        docstring = '''"""
+    one
+    two
+    three
+    four
+    five
+    six
+    seven
+    eight
+    nine
+    ten
+    eleven
+    """'''
+        assert docstring == docformatter.format_docstring(
+            "    ", docstring, strict=False
+        )
+
+
+class TestFormatWrap:
+    """Class for testing format_docstring() when requesting line wrapping."""
+
+    @pytest.mark.unit
+    def test_unwrap_summary(self):
+        """Remove newline and tab characters."""
+        assert "This is a sentence." == docformatter.unwrap_summary(
+            "This \n\tis\na sentence."
+        )
+
+    @pytest.mark.unit
+    def test_format_docstring_with_wrap(self):
+        """Wrap docstring."""
+        # This function uses `random` so make sure each run of this test is
+        # repeatable.
+        random.seed(0)
+
+        min_line_length = 50
+        for max_length, num_indents in itertools.product(
+            range(min_line_length, 100), range(20)
+        ):
+            indentation = " " * num_indents
+            formatted_text = indentation + docformatter.format_docstring(
+                indentation=indentation,
+                docstring=generate_random_docstring(
+                    max_word_length=min_line_length // 2
+                ),
+                summary_wrap_length=max_length,
+            )
+
+            for line in formatted_text.split("\n"):
+                # It is not the formatter's fault if a word is too long to
+                # wrap.
+                if len(line.split()) > 1:
+                    assert len(line) <= max_length
+
+    @pytest.mark.unit
+    def test_format_docstring_with_weird_indentation_and_punctuation(self):
+        """"""
+        assert '''
+    """Creates and returns four was awakens to was created tracked ammonites
+    was the fifty, arithmetical four was pyrotechnic to pyrotechnic physicists.
+
+    `four' falsified x falsified ammonites
+    to awakens to. `created' to ancestor was four to x dynamo to was
+    four ancestor to physicists().
+    """
+        '''.strip() == docformatter.format_docstring(
+            "    ",
+            '''
+            """Creates and returns four was awakens to was created tracked
+               ammonites was the fifty, arithmetical four was pyrotechnic to
+               pyrotechnic physicists. `four' falsified x falsified ammonites
+               to awakens to. `created' to ancestor was four to x dynamo to was
+               four ancestor to physicists().
+            """
+        '''.strip(),
+            summary_wrap_length=79,
+        )
+
+    @pytest.mark.unit
+    def test_format_docstring_with_description_wrapping(self):
+        """Wrap description at 72 characters."""
+        assert '''"""Hello.
+
+    This should be indented but it is not. The next line should be
+    indented too. But this is okay.
+    """''' == docformatter.format_docstring(
+            "    ",
+            '''
+"""Hello.
+
+    This should be indented but it is not. The
+    next line should be indented too. But
+    this is okay.
+
+    """
+'''.strip(),
+            description_wrap_length=72,
+        )
+
+    @pytest.mark.unit
+    def test_format_docstring_should_ignore_multi_paragraph(self):
+        """Ignore multiple paragraphs in elaborate description."""
+        docstring = '''"""Hello.
+
+    This should be indented but it is not. The
+    next line should be indented too. But
+    this is okay.
+
+    This should be indented but it is not. The
+    next line should be indented too. But
+    this is okay.
+    """'''
+        assert docstring == docformatter.format_docstring(
+            "    ", docstring, description_wrap_length=72
+        )
+
+    @pytest.mark.unit
+    def test_format_docstring_should_ignore_doctests(self):
+        """Leave doctests alone."""
+        docstring = '''"""Hello.
+
+    >>> 4
+    4
+    """'''
+        assert docstring == docformatter.format_docstring(
+            "    ", docstring, description_wrap_length=72
+        )
+
+    @pytest.mark.unit
+    def test_format_docstring_should_ignore_doctests_in_summary(self):
+        """Leave doctests alone if they're in the summary."""
+        docstring = '''"""
+    >>> 4
+    4
+
+    """'''
+        assert docstring == docformatter.format_docstring(
+            "    ", docstring, description_wrap_length=72
+        )
+
+    @pytest.mark.unit
+    def test_format_docstring_should_maintain_indentation_of_doctest(self):
+        """Don't change indentation of doctest lines."""
+        assert '''"""Foo bar bing bang.
+
+        >>> tests = DocTestFinder().find(_TestClass)
+        >>> runner = DocTestRunner(verbose=False)
+        >>> tests.sort(key = lambda test: test.name)
+    """''' == docformatter.format_docstring(
+            "    ",
+            docstring='''"""Foo bar bing bang.
+
+        >>> tests = DocTestFinder().find(_TestClass)
+        >>> runner = DocTestRunner(verbose=False)
+        >>> tests.sort(key = lambda test: test.name)
+
+    """''',
+            description_wrap_length=72,
+        )
+
+    @pytest.mark.unit
+    def test_force_wrap(self):
+        """Force even lists to be wrapped."""
+        assert (
+            (
+                '''\
+"""num_iterations is the number of updates -
+    instead of a better definition of
+    convergence."""\
+'''
+            )
+            == docformatter.format_docstring(
+                "    ",
+                '''\
+"""
+num_iterations is the number of updates - instead of a better definition of convergence.
+"""\
+''',
+                description_wrap_length=50,
+                summary_wrap_length=50,
+                force_wrap=True,
+            )
+        )
+
+    @pytest.mark.xfail
+    def test_format_docstring_with_summary_only_and_wrap_and_tab_indentation(
+        self,
+    ):
+        """"Should account for length of tab when wrapping.
+
+        See PR #69.
+        """
+        assert '''
+\t\t"""Some summary x x x
+\t\tx."""
+'''.strip() == docformatter.format_docstring(
+            "\t\t",
+            '''
+\t\t"""Some summary x x x x."""
+'''.strip(),
+            summary_wrap_length=30,
+            tab_width=4,
+        )
+
+
+class TestFormatStyleOptions:
+    """Class for testing format_docstring() when requesting style options."""
+
+    @pytest.mark.unit
+    def test_format_docstring_with_no_post_description_blank(self):
+        """Remove blank lines before closing triple quotes."""
+        assert '''"""Hello.
+
+    Description.
+    """''' == docformatter.format_docstring(
+            "    ",
+            '''
+"""
+
+Hello.
+
+    Description.
+
+
+    """
+'''.strip(),
+            post_description_blank=False,
+        )
+
+    @pytest.mark.unit
+    def test_format_docstring_with_pre_summary_newline(self):
+        """Remove blank line before summary."""
+        assert '''"""
+    Hello.
+
+    Description.
+    """''' == docformatter.format_docstring(
+            "    ",
+            '''
+"""
+
+Hello.
+
+    Description.
+
+
+    """
+'''.strip(),
+            pre_summary_newline=True,
+        )
+
+    @pytest.mark.unit
+    def test_format_docstring_make_summary_multi_line(self):
+        assert (
+            (
+                '''\
+"""
+    This one-line docstring will be multi-line.
+    """\
+'''
+            )
+            == docformatter.format_docstring(
+                "    ",
+                '''\
+"""This one-line docstring will be multi-line"""\
+''',
+                make_summary_multi_line=True,
+            )
+        )
+
+    @pytest.mark.unit
+    def test_format_docstring_pre_summary_space(self):
+        """"""
+        assert (
+            '''""" This one-line docstring will have a leading space."""'''
+        ) == docformatter.format_docstring(
+            "    ",
+            '''\
+"""This one-line docstring will have a leading space."""\
+''',
+            pre_summary_space=True,
+        )
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,
         )
 
 


----

problem_statement: """ getting placed at end of comment line instead of its own line
docformatter is resulting in this diff:

```
-    """
-    return a map mapping appliance_ids for a house back to themselves
-    except for multiphase appliances
-    """
+    """return a map mapping appliance_ids for a house back to themselves except
+    for multiphase appliances."""
```

I'm ok with putting the word return immediately after the triple quotes, but shouldn't the final set of triple quotes be on their own line?

date: 2022-08-09
patch: diff --git a/docformatter.py b/docformatter.py
index ed777ee..ad8f08a 100755
--- a/docformatter.py
+++ b/docformatter.py
@@ -33,6 +33,7 @@ from __future__ import (
 )
 
 # Standard Library Imports
+import argparse
 import collections
 import contextlib
 import io
@@ -44,7 +45,8 @@ import sys
 import sysconfig
 import textwrap
 import tokenize
-from typing import Tuple
+from configparser import ConfigParser
+from typing import List, Tuple, Union
 
 # Third Party Imports
 import untokenize
@@ -99,6 +101,246 @@ class FormatResult(object):
     check_failed = 3
 
 
+class Configurator:
+    """Read and store all the docformatter configuration information."""
+
+    parser = None
+    """Parser object."""
+
+    flargs_dct = {}
+    """Dictionary of configuration file arguments."""
+
+    configuration_file_lst = [
+        "pyproject.toml",
+        "setup.cfg",
+        "tox.ini",
+    ]
+    """List of supported configuration files."""
+
+    args: argparse.Namespace = None
+
+    def __init__(self, args: List[Union[bool, int, str]]) -> None:
+        """Initialize a Configurator class instance.
+
+        Parameters
+        ----------
+        args : list
+            Any command line arguments passed during invocation.
+        """
+        self.args_lst = args
+        self.parser = argparse.ArgumentParser(
+            description=__doc__,
+            prog="docformatter",
+        )
+
+        try:
+            self.config_file = self.args_lst[
+                self.args_lst.index("--config") + 1
+            ]
+        except ValueError:
+            for _configuration_file in self.configuration_file_lst:
+                if os.path.isfile(_configuration_file):
+                    self.config_file = f"./{_configuration_file}"
+                    break
+
+        self._do_read_configuration_file()
+
+    def do_parse_arguments(self) -> None:
+        """Parse configuration file and command line arguments."""
+        changes = self.parser.add_mutually_exclusive_group()
+        changes.add_argument(
+            "-i",
+            "--in-place",
+            action="store_true",
+            help="make changes to files instead of printing diffs",
+        )
+        changes.add_argument(
+            "-c",
+            "--check",
+            action="store_true",
+            help="only check and report incorrectly formatted files",
+        )
+        self.parser.add_argument(
+            "-r",
+            "--recursive",
+            action="store_true",
+            default=bool(self.flargs_dct.get("recursive", False)),
+            help="drill down directories recursively",
+        )
+        self.parser.add_argument(
+            "-e",
+            "--exclude",
+            nargs="*",
+            help="exclude directories and files by names",
+        )
+        self.parser.add_argument(
+            "--wrap-summaries",
+            default=int(self.flargs_dct.get("wrap-summaries", 79)),
+            type=int,
+            metavar="length",
+            help="wrap long summary lines at this length; "
+            "set to 0 to disable wrapping "
+            "(default: %(default)s)",
+        )
+        self.parser.add_argument(
+            "--wrap-descriptions",
+            default=int(self.flargs_dct.get("wrap-descriptions", 72)),
+            type=int,
+            metavar="length",
+            help="wrap descriptions at this length; "
+            "set to 0 to disable wrapping "
+            "(default: %(default)s)",
+        )
+        self.parser.add_argument(
+            "--blank",
+            dest="post_description_blank",
+            action="store_true",
+            default=bool(self.flargs_dct.get("blank", False)),
+            help="add blank line after description (default: %(default)s)",
+        )
+        self.parser.add_argument(
+            "--pre-summary-newline",
+            action="store_true",
+            default=bool(self.flargs_dct.get("pre-summary-newline", False)),
+            help="add a newline before the summary of a multi-line docstring "
+            "(default: %(default)s)",
+        )
+        self.parser.add_argument(
+            "--pre-summary-space",
+            action="store_true",
+            default=bool(self.flargs_dct.get("pre-summary-space", False)),
+            help="add a space after the opening triple quotes "
+            "(default: %(default)s)",
+        )
+        self.parser.add_argument(
+            "--make-summary-multi-line",
+            action="store_true",
+            default=bool(
+                self.flargs_dct.get("make-summary-multi-line", False)
+            ),
+            help="add a newline before and after the summary of a one-line "
+            "docstring (default: %(default)s)",
+        )
+        self.parser.add_argument(
+            "--close-quotes-on-newline",
+            action="store_true",
+            default=bool(
+                self.flargs_dct.get("close-quotes-on-newline", False)
+            ),
+            help="place closing triple quotes on a new-line when a "
+            "one-line docstring wraps to two or more lines "
+            "(default: %(default)s)",
+        )
+        self.parser.add_argument(
+            "--force-wrap",
+            action="store_true",
+            default=bool(self.flargs_dct.get("force-wrap", False)),
+            help="force descriptions to be wrapped even if it may "
+            "result in a mess (default: %(default)s)",
+        )
+        self.parser.add_argument(
+            "--range",
+            metavar="line",
+            dest="line_range",
+            default=self.flargs_dct.get("range", None),
+            type=int,
+            nargs=2,
+            help="apply docformatter to docstrings between these "
+            "lines; line numbers are indexed at 1 (default: %(default)s)",
+        )
+        self.parser.add_argument(
+            "--docstring-length",
+            metavar="length",
+            dest="length_range",
+            default=self.flargs_dct.get("docstring-length", None),
+            type=int,
+            nargs=2,
+            help="apply docformatter to docstrings of given length range "
+            "(default: %(default)s)",
+        )
+        self.parser.add_argument(
+            "--non-strict",
+            action="store_true",
+            default=bool(self.flargs_dct.get("non-strict", False)),
+            help="don't strictly follow reST syntax to identify lists (see "
+            "issue #67) (default: False)",
+        )
+        self.parser.add_argument(
+            "--config", help="path to file containing docformatter options"
+        )
+        self.parser.add_argument(
+            "--version", action="version", version=f"%(prog)s {__version__}"
+        )
+        self.parser.add_argument(
+            "files", nargs="+", help="files to format or '-' for standard in"
+        )
+
+        self.args = self.parser.parse_args(self.args_lst[1:])
+
+        if self.args.line_range:
+            if self.args.line_range[0] <= 0:
+                self.parser.error("--range must be positive numbers")
+            if self.args.line_range[0] > self.args.line_range[1]:
+                self.parser.error(
+                    "First value of --range should be less than or equal "
+                    "to the second"
+                )
+
+        if self.args.length_range:
+            if self.args.length_range[0] <= 0:
+                self.parser.error(
+                    "--docstring-length must be positive numbers"
+                )
+            if self.args.length_range[0] > self.args.length_range[1]:
+                self.parser.error(
+                    "First value of --docstring-length should be less "
+                    "than or equal to the second"
+                )
+
+    def _do_read_configuration_file(self) -> None:
+        """Read docformatter options from a configuration file."""
+        if os.path.isfile(self.config_file):
+            argfile = os.path.basename(self.config_file)
+            for f in self.configuration_file_lst:
+                if argfile == f:
+                    break
+
+        fullpath, ext = os.path.splitext(self.config_file)
+        filename = os.path.basename(fullpath)
+
+        if ext == ".toml" and TOMLI_INSTALLED and filename == "pyproject":
+            self._do_read_toml_configuration()
+
+        if (ext == ".cfg" and filename == "setup") or (
+            ext == ".ini" and filename == "tox"
+        ):
+            self._do_read_parser_configuration()
+
+    def _do_read_toml_configuration(self) -> None:
+        """Load configuration information from a *.toml file."""
+        with open(self.config_file, "rb") as f:
+            config = tomli.load(f)
+
+        result = config.get("tool", {}).get("docformatter", None)
+        if result is not None:
+            self.flargs_dct = {
+                k: v if isinstance(v, list) else str(v)
+                for k, v in result.items()
+            }
+
+    def _do_read_parser_configuration(self) -> None:
+        """Load configuration information from a *.cfg or *.ini file."""
+        config = ConfigParser()
+        config.read(self.config_file)
+
+        for _section in ["tool:docformatter", "docformatter"]:
+            if _section in config.sections():
+                self.flargs_dct = {
+                    k: v if isinstance(v, list) else str(v)
+                    for k, v in config[_section].items()
+                }
+
+
 def has_correct_length(length_range, start, end):
     """Return True if docstring's length is in range."""
     if length_range is None:
@@ -171,7 +413,9 @@ def _format_code(
     summary_wrap_length=79,
     description_wrap_length=72,
     pre_summary_newline=False,
+    pre_summary_space=False,
     make_summary_multi_line=False,
+    close_quotes_on_newline=False,
     post_description_blank=False,
     force_wrap=False,
     line_range=None,
@@ -202,7 +446,6 @@ def _format_code(
         end,
         line,
     ) in tokenize.generate_tokens(sio.readline):
-
         if (
             token_type == tokenize.STRING
             and token_string.startswith(QUOTE_TYPES)
@@ -220,7 +463,9 @@ def _format_code(
                 summary_wrap_length=summary_wrap_length,
                 description_wrap_length=description_wrap_length,
                 pre_summary_newline=pre_summary_newline,
+                pre_summary_space=pre_summary_space,
                 make_summary_multi_line=make_summary_multi_line,
+                close_quotes_on_newline=close_quotes_on_newline,
                 post_description_blank=post_description_blank,
                 force_wrap=force_wrap,
                 strict=strict,
@@ -232,15 +477,20 @@ def _format_code(
         previous_token_string = token_string
         previous_token_type = token_type
 
-        # 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:
+        # 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.
+        if (
+            len(modified_tokens) <= 2
+            or token_type not in {tokenize.NL, tokenize.NEWLINE}
+            or modified_tokens[-1][0] not in {tokenize.NL, tokenize.NEWLINE}
+            or modified_tokens[-2][1] != ":"
+            and modified_tokens[-2][0] != tokenize.COMMENT
+            or modified_tokens[-2][4][:3] != "def"
+        ):
             modified_tokens.append(
-                (token_type, token_string, start, end, line))
+                (token_type, token_string, start, end, line)
+            )
 
     return untokenize.untokenize(modified_tokens)
 
@@ -251,7 +501,9 @@ def format_docstring(
     summary_wrap_length=0,
     description_wrap_length=0,
     pre_summary_newline=False,
+    pre_summary_space=False,
     make_summary_multi_line=False,
+    close_quotes_on_newline=False,
     post_description_blank=False,
     force_wrap=False,
     strict=True,
@@ -270,6 +522,7 @@ def format_docstring(
           on a line by themselves.
     """
     contents, open_quote = strip_docstring(docstring)
+    open_quote = f"{open_quote} " if pre_summary_space else open_quote
 
     # Skip if there are nested triple double quotes
     if contents.count(QUOTE_TYPES[0]):
@@ -295,50 +548,52 @@ def format_docstring(
         initial_indent = (
             indentation if pre_summary_newline else 3 * " " + indentation
         )
-        return '''\
+        pre_summary = "\n" + indentation if pre_summary_newline else ""
+        summary = wrap_summary(
+            normalize_summary(summary),
+            wrap_length=summary_wrap_length,
+            initial_indent=initial_indent,
+            subsequent_indent=indentation,
+        ).lstrip()
+        description = wrap_description(
+            description,
+            indentation=indentation,
+            wrap_length=description_wrap_length,
+            force_wrap=force_wrap,
+            strict=strict,
+        )
+        post_description = "\n" if post_description_blank else ""
+        return f'''\
 {open_quote}{pre_summary}{summary}
 
 {description}{post_description}
 {indentation}"""\
-'''.format(
-            open_quote=open_quote,
-            pre_summary=("\n" + indentation if pre_summary_newline else ""),
-            summary=wrap_summary(
-                normalize_summary(summary),
-                wrap_length=summary_wrap_length,
-                initial_indent=initial_indent,
-                subsequent_indent=indentation,
-            ).lstrip(),
-            description=wrap_description(
-                description,
-                indentation=indentation,
-                wrap_length=description_wrap_length,
-                force_wrap=force_wrap,
-                strict=strict,
-            ),
-            post_description=("\n" if post_description_blank else ""),
-            indentation=indentation,
-        )
+'''
     else:
         if not make_summary_multi_line:
-            return wrap_summary(
+            summary_wrapped = wrap_summary(
                 open_quote + normalize_summary(contents) + '"""',
                 wrap_length=summary_wrap_length,
                 initial_indent=indentation,
                 subsequent_indent=indentation,
             ).strip()
-
-        beginning = '"""\n' + indentation
-        ending = "\n" + indentation + '"""'
-        summary_wrapped = wrap_summary(
-            normalize_summary(contents),
-            wrap_length=summary_wrap_length,
-            initial_indent=indentation,
-            subsequent_indent=indentation,
-        ).strip()
-        return "{beginning}{summary}{ending}".format(
-            beginning=beginning, summary=summary_wrapped, ending=ending
-        )
+            if close_quotes_on_newline and "\n" in summary_wrapped:
+                summary_wrapped = (
+                    f"{summary_wrapped[:-3]}"
+                    f"\n{indentation}"
+                    f"{summary_wrapped[-3:]}"
+                )
+            return summary_wrapped
+        else:
+            beginning = f"{open_quote}\n{indentation}"
+            ending = f'\n{indentation}"""'
+            summary_wrapped = wrap_summary(
+                normalize_summary(contents),
+                wrap_length=summary_wrap_length,
+                initial_indent=indentation,
+                subsequent_indent=indentation,
+            ).strip()
+            return f"{beginning}{summary_wrapped}{ending}"
 
 
 def is_probably_beginning_of_sentence(line):
@@ -527,7 +782,6 @@ def strip_docstring(docstring: str) -> Tuple[str, str]:
     (docstring, open_quote) : tuple
         The docstring with the triple quotes removed.
         The opening quote type with single quotes replaced by double quotes.
-
     """
     docstring = docstring.strip()
 
@@ -572,13 +826,11 @@ def normalize_summary(summary):
 def wrap_summary(summary, initial_indent, subsequent_indent, wrap_length):
     """Return line-wrapped summary text."""
     if wrap_length > 0:
-        return "\n".join(
-            textwrap.wrap(
-                unwrap_summary(summary),
-                width=wrap_length,
-                initial_indent=initial_indent,
-                subsequent_indent=subsequent_indent,
-            )
+        return textwrap.fill(
+            unwrap_summary(summary),
+            width=wrap_length,
+            initial_indent=initial_indent,
+            subsequent_indent=subsequent_indent,
         ).strip()
     else:
         return summary
@@ -626,7 +878,6 @@ def remove_section_header(text):
 
     >>> remove_section_header('===\nfoo\nbar\n')
     'foo\nbar\n'
-
     """
     stripped = text.lstrip()
     if not stripped:
@@ -651,6 +902,7 @@ def strip_leading_blank_lines(text):
     found = next(
         (index for index, line in enumerate(split) if line.strip()), 0
     )
+
     return "\n".join(split[found:])
 
 
@@ -673,7 +925,6 @@ def detect_encoding(filename):
             # Check for correctness of encoding.
             with open_with_encoding(filename, encoding) as check_file:
                 check_file.read()
-
         return encoding
     except (SyntaxError, LookupError, UnicodeDecodeError):
         return "latin-1"
@@ -708,7 +959,6 @@ def format_file(filename, args, standard_out):
                 f"after/{filename}",
                 lineterm="",
             )
-
             standard_out.write("\n".join(list(diff) + [""]))
 
     return FormatResult.ok
@@ -721,194 +971,33 @@ def _format_code_with_args(source, args):
         summary_wrap_length=args.wrap_summaries,
         description_wrap_length=args.wrap_descriptions,
         pre_summary_newline=args.pre_summary_newline,
+        pre_summary_space=args.pre_summary_space,
         make_summary_multi_line=args.make_summary_multi_line,
+        close_quotes_on_newline=args.close_quotes_on_newline,
         post_description_blank=args.post_description_blank,
         force_wrap=args.force_wrap,
         line_range=args.line_range,
-        strict=args.non_strict,
+        strict=not args.non_strict,
     )
 
 
-def find_config_file(args):
-    """Find the configuration file the user specified."""
-    flargs = {}
-
-    config_file = args[args.index("--config") + 1]
-
-    if os.path.isfile(config_file):
-        argfile = os.path.basename(config_file)
-        config_files = ["pyproject.toml"]
-        for f in config_files:
-            if argfile == f:
-                flargs = read_configuration_from_file(config_file)
-
-    return flargs
-
-
-def read_configuration_from_file(configfile):
-    """Read docformatter options from a configuration file."""
-    flargs = {}
-    fullpath, ext = os.path.splitext(configfile)
-    filename = os.path.basename(fullpath)
-
-    if ext == ".toml" and TOMLI_INSTALLED and filename == "pyproject":
-        with open(configfile, "rb") as f:
-            config = tomli.load(f)
-
-        result = config.get("tool", {}).get("docformatter", None)
-        if result is not None:
-            flargs = {
-                k: v if isinstance(v, list) else str(v)
-                for k, v in result.items()
-            }
-
-    return flargs
-
-
 def _main(argv, standard_out, standard_error, standard_in):
     """Run internal main entry point."""
-    # Standard Library Imports
-    import argparse
-
-    flargs = find_config_file(argv) if "--config" in argv else {}
-    parser = argparse.ArgumentParser(description=__doc__, prog="docformatter")
-    changes = parser.add_mutually_exclusive_group()
-    changes.add_argument(
-        "-i",
-        "--in-place",
-        action="store_true",
-        help="make changes to files instead of printing diffs",
-    )
-    changes.add_argument(
-        "-c",
-        "--check",
-        action="store_true",
-        help="only check and report incorrectly formatted files",
-    )
-    parser.add_argument(
-        "-r",
-        "--recursive",
-        action="store_true",
-        default=bool(flargs.get("recursive", False)),
-        help="drill down directories recursively",
-    )
-    parser.add_argument(
-        "-e",
-        "--exclude",
-        nargs="*",
-        help="exclude directories and files by names",
-    )
-    parser.add_argument(
-        "--wrap-summaries",
-        default=int(flargs.get("wrap-summaries", 79)),
-        type=int,
-        metavar="length",
-        help="wrap long summary lines at this length; "
-        "set to 0 to disable wrapping "
-        "(default: %(default)s)",
-    )
-    parser.add_argument(
-        "--wrap-descriptions",
-        default=int(flargs.get("wrap-descriptions", 72)),
-        type=int,
-        metavar="length",
-        help="wrap descriptions at this length; "
-        "set to 0 to disable wrapping "
-        "(default: %(default)s)",
-    )
-    parser.add_argument(
-        "--blank",
-        dest="post_description_blank",
-        action="store_true",
-        default=bool(flargs.get("blank", False)),
-        help="add blank line after description",
-    )
-    parser.add_argument(
-        "--pre-summary-newline",
-        action="store_true",
-        default=bool(flargs.get("pre-summary-newline", False)),
-        help="add a newline before the summary of a multi-line docstring",
-    )
-    parser.add_argument(
-        "--make-summary-multi-line",
-        action="store_true",
-        default=bool(flargs.get("make-summary-multi-line", False)),
-        help="add a newline before and after the summary of a "
-        "one-line docstring",
-    )
-    parser.add_argument(
-        "--force-wrap",
-        action="store_true",
-        default=bool(flargs.get("force-wrap", False)),
-        help="force descriptions to be wrapped even if it may "
-        "result in a mess",
-    )
-    parser.add_argument(
-        "--range",
-        metavar="line",
-        dest="line_range",
-        default=flargs.get("range", None),
-        type=int,
-        nargs=2,
-        help="apply docformatter to docstrings between these "
-        "lines; line numbers are indexed at 1",
-    )
-    parser.add_argument(
-        "--docstring-length",
-        metavar="length",
-        dest="length_range",
-        default=flargs.get("docstring-length", None),
-        type=int,
-        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"
-    )
-    parser.add_argument(
-        "files", nargs="+", help="files to format or '-' for standard in"
-    )
+    configurator = Configurator(argv)
+    configurator.do_parse_arguments()
 
-    args = parser.parse_args(argv[1:])
-
-    if args.line_range:
-        if args.line_range[0] <= 0:
-            parser.error("--range must be positive numbers")
-        if args.line_range[0] > args.line_range[1]:
-            parser.error(
-                "First value of --range should be less than or equal "
-                "to the second"
-            )
-
-    if args.length_range:
-        if args.length_range[0] <= 0:
-            parser.error("--docstring-length must be positive numbers")
-        if args.length_range[0] > args.length_range[1]:
-            parser.error(
-                "First value of --docstring-length should be less "
-                "than or equal to the second"
-            )
-
-    if "-" in args.files:
+    if "-" in configurator.args.files:
         _format_standard_in(
-            args,
-            parser=parser,
+            configurator.args,
+            parser=configurator.parser,
             standard_out=standard_out,
             standard_in=standard_in,
         )
     else:
         return _format_files(
-            args, standard_out=standard_out, standard_error=standard_error
+            configurator.args,
+            standard_out=standard_out,
+            standard_error=standard_error,
         )
 
 
@@ -925,12 +1014,12 @@ def _format_standard_in(args, parser, standard_out, standard_in):
 
     encoding = None
     source = standard_in.read()
-
     if not isinstance(source, unicode):
         encoding = standard_in.encoding or _get_encoding()
         source = source.decode(encoding)
 
     formatted_source = _format_code_with_args(source, args=args)
+
     if encoding:
         formatted_source = formatted_source.encode(encoding)
 
@@ -1020,6 +1109,7 @@ def _format_files(args, standard_out, standard_error):
         FormatResult.check_failed,
         FormatResult.ok,
     ]
+
     for code in return_codes:
         if outcomes[code]:
             return code
diff --git a/pyproject.toml b/pyproject.toml
index facfdc9..1517319 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -122,7 +122,7 @@ output = 'coverage.xml'
 
 [tool.black]
 line-length = 79
-target-version = ['py37', 'py38']
+target-version = ['py36', 'py37', 'py38', 'py39', 'py310']
 include = '\.pyi?$'
 exclude = '''
 /(
@@ -171,18 +171,21 @@ commands =
     pytest -s -x -c ./pyproject.toml --cache-clear \
         --cov-config=pyproject.toml --cov=docformatter \
         --cov-branch --cov-report=term tests/
-    coverage run --branch --omit="*/site-packages/*,*/*pypy/*" \
-        test_docformatter.py
 
 [testenv:style]
 deps =
+    docformatter
     pycodestyle
     pydocstyle
     pylint
+    rstcheck
     toml
     untokenize
 commands =
+    docformatter docformatter.py setup.py
     pycodestyle docformatter.py setup.py
     pydocstyle docformatter.py setup.py
     pylint docformatter.py setup.py
+    rstcheck --report-level=1 README.rst
+    python -m doctest docformatter.py
 """


----

problem_statement: Allow showing the diff and exit 1 if files were changed
## What is the current behavior?

Currently, you can either run:

- `docformatter file`, which will display the diff but always `exit 0` if nothing was changed
- `docformatter --check file`, which will only display the file name if there is a change and `exit 1`.

## What would you like?

I'd like to be able to both:

- show the diff
- exit 1if there is a change

in a single invocation, which would allow me to have an easier CI setup, while showing a nice diff to users.


I am happy to contribute the feature once/if there is an agreement that it is worth it and how we want to do it
date: 2022-08-24
patch: diff --git a/src/docformatter/configuration.py b/src/docformatter/configuration.py
new file mode 100644
index 0000000..c484bf7
--- /dev/null
+++ b/src/docformatter/configuration.py
@@ -0,0 +1,306 @@
+#!/usr/bin/env python
+#
+# Copyright (C) 2012-2022 Steven Myint
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+"""This module provides docformatter's Configurater class."""
+
+# Standard Library Imports
+import argparse
+import os
+from configparser import ConfigParser
+from typing import Dict, List, Union
+
+try:
+    # Third Party Imports
+    import tomli
+
+    TOMLI_INSTALLED = True
+except ImportError:
+    TOMLI_INSTALLED = False
+
+# docformatter Package Imports
+from docformatter import __pkginfo__
+
+
+class Configurater:
+    """Read and store all the docformatter configuration information."""
+
+    parser = None
+    """Parser object."""
+
+    flargs_dct: Dict[str, Union[bool, float, int, str]] = {}
+    """Dictionary of configuration file arguments."""
+
+    configuration_file_lst = [
+        "pyproject.toml",
+        "setup.cfg",
+        "tox.ini",
+    ]
+    """List of supported configuration files."""
+
+    args: argparse.Namespace = None
+
+    def __init__(self, args: List[Union[bool, int, str]]) -> None:
+        """Initialize a Configurater class instance.
+
+        Parameters
+        ----------
+        args : list
+            Any command line arguments passed during invocation.
+        """
+        self.args_lst = args
+        self.config_file = ""
+        self.parser = argparse.ArgumentParser(
+            description=__doc__,
+            prog="docformatter",
+        )
+
+        try:
+            self.config_file = self.args_lst[
+                self.args_lst.index("--config") + 1
+            ]
+        except ValueError:
+            for _configuration_file in self.configuration_file_lst:
+                if os.path.isfile(_configuration_file):
+                    self.config_file = f"./{_configuration_file}"
+                    break
+
+        if os.path.isfile(self.config_file):
+            self._do_read_configuration_file()
+
+    def do_parse_arguments(self) -> None:
+        """Parse configuration file and command line arguments."""
+        changes = self.parser.add_mutually_exclusive_group()
+        changes.add_argument(
+            "-i",
+            "--in-place",
+            action="store_true",
+            help="make changes to files instead of printing diffs",
+        )
+        changes.add_argument(
+            "-c",
+            "--check",
+            action="store_true",
+            help="only check and report incorrectly formatted files",
+        )
+        self.parser.add_argument(
+            "-d",
+            "--diff",
+            action="store_true",
+            help="when used with `--check` or `--in-place`, also what changes "
+            "would be made",
+        )
+        self.parser.add_argument(
+            "-r",
+            "--recursive",
+            action="store_true",
+            default=bool(self.flargs_dct.get("recursive", False)),
+            help="drill down directories recursively",
+        )
+        self.parser.add_argument(
+            "-e",
+            "--exclude",
+            nargs="*",
+            help="exclude directories and files by names",
+        )
+        self.parser.add_argument(
+            "--wrap-summaries",
+            default=int(self.flargs_dct.get("wrap-summaries", 79)),
+            type=int,
+            metavar="length",
+            help="wrap long summary lines at this length; "
+            "set to 0 to disable wrapping (default: 79)",
+        )
+        self.parser.add_argument(
+            "--wrap-descriptions",
+            default=int(self.flargs_dct.get("wrap-descriptions", 72)),
+            type=int,
+            metavar="length",
+            help="wrap descriptions at this length; "
+            "set to 0 to disable wrapping (default: 72)",
+        )
+        self.parser.add_argument(
+            "--force-wrap",
+            action="store_true",
+            default=bool(self.flargs_dct.get("force-wrap", False)),
+            help="force descriptions to be wrapped even if it may "
+            "result in a mess (default: False)",
+        )
+        self.parser.add_argument(
+            "--tab-width",
+            type=int,
+            dest="tab_width",
+            metavar="width",
+            default=int(self.flargs_dct.get("tab-width", 1)),
+            help="tabs in indentation are this many characters when "
+            "wrapping lines (default: 1)",
+        )
+        self.parser.add_argument(
+            "--blank",
+            dest="post_description_blank",
+            action="store_true",
+            default=bool(self.flargs_dct.get("blank", False)),
+            help="add blank line after description (default: False)",
+        )
+        self.parser.add_argument(
+            "--pre-summary-newline",
+            action="store_true",
+            default=bool(self.flargs_dct.get("pre-summary-newline", False)),
+            help="add a newline before the summary of a multi-line docstring "
+            "(default: False)",
+        )
+        self.parser.add_argument(
+            "--pre-summary-space",
+            action="store_true",
+            default=bool(self.flargs_dct.get("pre-summary-space", False)),
+            help="add a space after the opening triple quotes "
+            "(default: False)",
+        )
+        self.parser.add_argument(
+            "--make-summary-multi-line",
+            action="store_true",
+            default=bool(
+                self.flargs_dct.get("make-summary-multi-line", False)
+            ),
+            help="add a newline before and after the summary of a one-line "
+            "docstring (default: False)",
+        )
+        self.parser.add_argument(
+            "--close-quotes-on-newline",
+            action="store_true",
+            default=bool(
+                self.flargs_dct.get("close-quotes-on-newline", False)
+            ),
+            help="place closing triple quotes on a new-line when a "
+            "one-line docstring wraps to two or more lines "
+            "(default: False)",
+        )
+        self.parser.add_argument(
+            "--range",
+            metavar="line",
+            dest="line_range",
+            default=self.flargs_dct.get("range", None),
+            type=int,
+            nargs=2,
+            help="apply docformatter to docstrings between these "
+            "lines; line numbers are indexed at 1 (default: None)",
+        )
+        self.parser.add_argument(
+            "--docstring-length",
+            metavar="length",
+            dest="length_range",
+            default=self.flargs_dct.get("docstring-length", None),
+            type=int,
+            nargs=2,
+            help="apply docformatter to docstrings of given length range "
+            "(default: None)",
+        )
+        self.parser.add_argument(
+            "--non-strict",
+            action="store_true",
+            default=bool(self.flargs_dct.get("non-strict", False)),
+            help="don't strictly follow reST syntax to identify lists (see "
+            "issue #67) (default: False)",
+        )
+        self.parser.add_argument(
+            "--config",
+            default=self.config_file,
+            help="path to file containing docformatter options",
+        )
+        self.parser.add_argument(
+            "--version",
+            action="version",
+            version=f"%(prog)s {__pkginfo__.__version__}",
+        )
+        self.parser.add_argument(
+            "files",
+            nargs="+",
+            help="files to format or '-' for standard in",
+        )
+
+        self.args = self.parser.parse_args(self.args_lst[1:])
+
+        if self.args.line_range:
+            if self.args.line_range[0] <= 0:
+                self.parser.error("--range must be positive numbers")
+            if self.args.line_range[0] > self.args.line_range[1]:
+                self.parser.error(
+                    "First value of --range should be less than or equal "
+                    "to the second"
+                )
+
+        if self.args.length_range:
+            if self.args.length_range[0] <= 0:
+                self.parser.error(
+                    "--docstring-length must be positive numbers"
+                )
+            if self.args.length_range[0] > self.args.length_range[1]:
+                self.parser.error(
+                    "First value of --docstring-length should be less "
+                    "than or equal to the second"
+                )
+
+    def _do_read_configuration_file(self) -> None:
+        """Read docformatter options from a configuration file."""
+        argfile = os.path.basename(self.config_file)
+        for f in self.configuration_file_lst:
+            if argfile == f:
+                break
+
+        fullpath, ext = os.path.splitext(self.config_file)
+        filename = os.path.basename(fullpath)
+
+        if ext == ".toml" and TOMLI_INSTALLED and filename == "pyproject":
+            self._do_read_toml_configuration()
+
+        if (ext == ".cfg" and filename == "setup") or (
+            ext == ".ini" and filename == "tox"
+        ):
+            self._do_read_parser_configuration()
+
+    def _do_read_toml_configuration(self) -> None:
+        """Load configuration information from a *.toml file."""
+        with open(self.config_file, "rb") as f:
+            config = tomli.load(f)
+
+        result = config.get("tool", {}).get("docformatter", None)
+        if result is not None:
+            self.flargs_dct = {
+                k: v if isinstance(v, list) else str(v)
+                for k, v in result.items()
+            }
+
+    def _do_read_parser_configuration(self) -> None:
+        """Load configuration information from a *.cfg or *.ini file."""
+        config = ConfigParser()
+        config.read(self.config_file)
+
+        for _section in [
+            "tool.docformatter",
+            "tool:docformatter",
+            "docformatter",
+        ]:
+            if _section in config.sections():
+                self.flargs_dct = {
+                    k: v if isinstance(v, list) else str(v)
+                    for k, v in config[_section].items()
+                }
diff --git a/src/docformatter/format.py b/src/docformatter/format.py
new file mode 100644
index 0000000..0f32942
--- /dev/null
+++ b/src/docformatter/format.py
@@ -0,0 +1,484 @@
+#!/usr/bin/env python
+#
+# Copyright (C) 2012-2022 Steven Myint
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+"""This module provides docformatter's Formattor class."""
+
+# Standard Library Imports
+import argparse
+import collections
+import io
+import tokenize
+from typing import TextIO, Tuple
+
+# Third Party Imports
+import untokenize
+
+# docformatter Package Imports
+import docformatter.strings as _strings
+import docformatter.syntax as _syntax
+import docformatter.util as _util
+import docformatter.encode as _encode
+
+
+unicode = str
+
+
+class FormatResult:
+    """Possible exit codes."""
+
+    ok = 0
+    error = 1
+    interrupted = 2
+    check_failed = 3
+
+
+class Formatter:
+    """Format docstrings."""
+
+    STR_QUOTE_TYPES = (
+        '"""',
+        "'''",
+    )
+    RAW_QUOTE_TYPES = (
+        'r"""',
+        'R"""',
+        "r'''",
+        "R'''",
+    )
+    UCODE_QUOTE_TYPES = (
+        'u"""',
+        'U"""',
+        "u'''",
+        "U'''",
+    )
+    QUOTE_TYPES = STR_QUOTE_TYPES + RAW_QUOTE_TYPES + UCODE_QUOTE_TYPES
+
+    parser = None
+    """Parser object."""
+
+    args: argparse.Namespace = None
+
+    def __init__(
+        self,
+        args: argparse.Namespace,
+        stderror: TextIO,
+        stdin: TextIO,
+        stdout: TextIO,
+    ) -> None:
+        """Initialize a Formattor instance.
+
+        Parameters
+        ----------
+        args : argparse.Namespace
+            Any command line arguments passed during invocation or
+            configuration file options.
+        stderror : TextIO
+            The standard error device.  Typically, the screen.
+        stdin :  TextIO
+            The standard input device.  Typically, the keyboard.
+        stdout : TextIO
+            The standard output device.  Typically, the screen.
+
+        Returns
+        -------
+        object
+        """
+        self.args = args
+        self.stderror: TextIO = stderror
+        self.stdin: TextIO = stdin
+        self.stdout: TextIO = stdout
+
+        self.encodor = _encode.Encoder()
+
+    def do_format_standard_in(self, parser: argparse.ArgumentParser):
+        """Print formatted text to standard out.
+
+        Parameters
+        ----------
+        parser: argparse.ArgumentParser
+            The argument parser containing the formatting options.
+        """
+        if len(self.args.files) > 1:
+            parser.error("cannot mix standard in and regular files")
+
+        if self.args.in_place:
+            parser.error("--in-place cannot be used with standard input")
+
+        if self.args.recursive:
+            parser.error("--recursive cannot be used with standard input")
+
+        encoding = None
+        source = self.stdin.read()
+        if not isinstance(source, unicode):
+            encoding = self.stdin.encoding or self.encodor.system_encoding
+            source = source.decode(encoding)
+
+        formatted_source = self._do_format_code(source)
+
+        if encoding:
+            formatted_source = formatted_source.encode(encoding)
+
+        self.stdout.write(formatted_source)
+
+    def do_format_files(self):
+        """Format multiple files.
+
+        Return
+        ------
+        code: int
+            One of the FormatResult codes.
+        """
+        outcomes = collections.Counter()
+        for filename in _util.find_py_files(
+            set(self.args.files), self.args.recursive, self.args.exclude
+        ):
+            try:
+                result = self._do_format_file(filename)
+                outcomes[result] += 1
+            except IOError as exception:
+                outcomes[FormatResult.error] += 1
+                print(unicode(exception), file=self.stderror)
+
+        return_codes = [  # in order of preference
+            FormatResult.error,
+            FormatResult.check_failed,
+            FormatResult.ok,
+        ]
+
+        for code in return_codes:
+            if outcomes[code]:
+                return code
+
+    def _do_format_file(self, filename):
+        """Run format_code() on a file.
+
+        Parameters
+        ----------
+        filename: str
+            The path to the file to be formatted.
+
+        Return
+        ------
+        result_code: int
+            One of the FormatResult codes.
+        """
+        self.encodor.do_detect_encoding(filename)
+
+        with self.encodor.do_open_with_encoding(filename) as input_file:
+            source = input_file.read()
+            formatted_source = self._do_format_code(source)
+
+        ret = FormatResult.ok
+        show_diff = self.args.diff
+
+        if source != formatted_source:
+            if self.args.check:
+                print(unicode(filename), file=self.stderror)
+                ret = FormatResult.check_failed
+            elif self.args.in_place:
+                with self.encodor.do_open_with_encoding(
+                    filename,
+                    mode="w",
+                ) as output_file:
+                    output_file.write(formatted_source)
+            else:
+                show_diff = True
+
+            if show_diff:
+                # Standard Library Imports
+                import difflib
+
+                diff = difflib.unified_diff(
+                    source.splitlines(),
+                    formatted_source.splitlines(),
+                    f"before/{filename}",
+                    f"after/{filename}",
+                    lineterm="",
+                )
+                self.stdout.write("\n".join(list(diff) + [""]))
+
+        return ret
+
+    def _do_format_code(self, source):
+        """Return source code with docstrings formatted.
+
+        Parameters
+        ----------
+        source: str
+            The text from the source file.
+        """
+        try:
+            original_newline = self.encodor.do_find_newline(
+                source.splitlines(True)
+            )
+            code = self._format_code(source)
+
+            return _strings.normalize_line_endings(
+                code.splitlines(True), original_newline
+            )
+        except (tokenize.TokenError, IndentationError):
+            return source
+
+    def _format_code(
+        self,
+        source,
+    ):
+        """Return source code with docstrings formatted.
+
+        Parameters
+        ----------
+        source: str
+            The source code string.
+
+        Returns
+        -------
+        formatted_source: str
+            The source code with formatted docstrings.
+        """
+        if not source:
+            return source
+
+        if self.args.line_range is not None:
+            assert self.args.line_range[0] > 0 and self.args.line_range[1] > 0
+
+        if self.args.length_range is not None:
+            assert (
+                self.args.length_range[0] > 0 and self.args.length_range[1] > 0
+            )
+
+        modified_tokens = []
+
+        sio = io.StringIO(source)
+        previous_token_type = None
+        only_comments_so_far = True
+
+        try:
+            for (
+                token_type,
+                token_string,
+                start,
+                end,
+                line,
+            ) in tokenize.generate_tokens(sio.readline):
+                if (
+                    token_type == tokenize.STRING
+                    and token_string.startswith(self.QUOTE_TYPES)
+                    and (
+                        previous_token_type == tokenize.INDENT
+                        or previous_token_type == tokenize.NEWLINE
+                        or only_comments_so_far
+                    )
+                    and _util.is_in_range(
+                        self.args.line_range, start[0], end[0]
+                    )
+                    and _util.has_correct_length(
+                        self.args.length_range, start[0], end[0]
+                    )
+                ):
+                    indentation = " " * (len(line) - len(line.lstrip()))
+                    token_string = self._do_format_docstring(
+                        indentation,
+                        token_string,
+                    )
+
+                if token_type not in [
+                    tokenize.COMMENT,
+                    tokenize.NEWLINE,
+                    tokenize.NL,
+                ]:
+                    only_comments_so_far = False
+
+                previous_token_type = token_type
+
+                # 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.
+                if (
+                    len(modified_tokens) <= 2
+                    or token_type not in {tokenize.NL, tokenize.NEWLINE}
+                    or modified_tokens[-1][0]
+                    not in {tokenize.NL, tokenize.NEWLINE}
+                    or modified_tokens[-2][1] != ":"
+                    and modified_tokens[-2][0] != tokenize.COMMENT
+                    or modified_tokens[-2][4][:3] != "def"
+                ):
+                    modified_tokens.append(
+                        (token_type, token_string, start, end, line)
+                    )
+
+            return untokenize.untokenize(modified_tokens)
+        except tokenize.TokenError:
+            return source
+
+    def _do_format_docstring(
+        self,
+        indentation: str,
+        docstring: str,
+    ) -> str:
+        """Return formatted version of docstring.
+
+        Parameters
+        ----------
+        indentation: str
+            The indentation characters for the docstring.
+        docstring: str
+            The docstring itself.
+
+        Returns
+        -------
+        docstring_formatted: str
+            The docstring formatted according the various options.
+        """
+        contents, open_quote = self._do_strip_docstring(docstring)
+        open_quote = (
+            f"{open_quote} " if self.args.pre_summary_space else open_quote
+        )
+
+        # Skip if there are nested triple double quotes
+        if contents.count(self.QUOTE_TYPES[0]):
+            return docstring
+
+        # Do not modify things that start with doctests.
+        if contents.lstrip().startswith(">>>"):
+            return docstring
+
+        summary, description = _strings.split_summary_and_description(contents)
+
+        # Leave docstrings with underlined summaries alone.
+        if (
+            _syntax.remove_section_header(description).strip()
+            != description.strip()
+        ):
+            return docstring
+
+        if not self.args.force_wrap and _syntax.is_some_sort_of_list(
+            summary,
+            self.args.non_strict,
+        ):
+            # Something is probably not right with the splitting.
+            return docstring
+
+        # Compensate for textwrap counting each tab in indentation as 1
+        # character.
+        tab_compensation = indentation.count("\t") * (self.args.tab_width - 1)
+        self.args.wrap_summaries -= tab_compensation
+        self.args.wrap_descriptions -= tab_compensation
+
+        if description:
+            # Compensate for triple quotes by temporarily prepending 3 spaces.
+            # This temporary prepending is undone below.
+            initial_indent = (
+                indentation
+                if self.args.pre_summary_newline
+                else 3 * " " + indentation
+            )
+            pre_summary = (
+                "\n" + indentation if self.args.pre_summary_newline else ""
+            )
+            summary = _syntax.wrap_summary(
+                _strings.normalize_summary(summary),
+                wrap_length=self.args.wrap_summaries,
+                initial_indent=initial_indent,
+                subsequent_indent=indentation,
+            ).lstrip()
+            description = _syntax.wrap_description(
+                description,
+                indentation=indentation,
+                wrap_length=self.args.wrap_descriptions,
+                force_wrap=self.args.force_wrap,
+                strict=self.args.non_strict,
+            )
+            post_description = "\n" if self.args.post_description_blank else ""
+            return f'''\
+{open_quote}{pre_summary}{summary}
+
+{description}{post_description}
+{indentation}"""\
+'''
+        else:
+            if not self.args.make_summary_multi_line:
+                summary_wrapped = _syntax.wrap_summary(
+                    open_quote + _strings.normalize_summary(contents) + '"""',
+                    wrap_length=self.args.wrap_summaries,
+                    initial_indent=indentation,
+                    subsequent_indent=indentation,
+                ).strip()
+                if (
+                    self.args.close_quotes_on_newline
+                    and "\n" in summary_wrapped
+                ):
+                    summary_wrapped = (
+                        f"{summary_wrapped[:-3]}"
+                        f"\n{indentation}"
+                        f"{summary_wrapped[-3:]}"
+                    )
+                return summary_wrapped
+            else:
+                beginning = f"{open_quote}\n{indentation}"
+                ending = f'\n{indentation}"""'
+                summary_wrapped = _syntax.wrap_summary(
+                    _strings.normalize_summary(contents),
+                    wrap_length=self.args.wrap_summaries,
+                    initial_indent=indentation,
+                    subsequent_indent=indentation,
+                ).strip()
+                return f"{beginning}{summary_wrapped}{ending}"
+
+    def _do_strip_docstring(self, docstring: str) -> Tuple[str, str]:
+        """Return contents of docstring and opening quote type.
+
+        Strips the docstring of its triple quotes, trailing white space,
+        and line returns.  Determines type of docstring quote (either string,
+        raw, or unicode) and returns the opening quotes, including the type
+        identifier, with single quotes replaced by double quotes.
+
+        Parameters
+        ----------
+        docstring: str
+            The docstring, including the opening and closing triple quotes.
+
+        Returns
+        -------
+        (docstring, open_quote) : tuple
+            The docstring with the triple quotes removed.
+            The opening quote type with single quotes replaced by double
+            quotes.
+        """
+        docstring = docstring.strip()
+
+        for quote in self.QUOTE_TYPES:
+            if quote in self.RAW_QUOTE_TYPES + self.UCODE_QUOTE_TYPES and (
+                docstring.startswith(quote) and docstring.endswith(quote[1:])
+            ):
+                return docstring.split(quote, 1)[1].rsplit(quote[1:], 1)[
+                    0
+                ].strip(), quote.replace("'", '"')
+            elif docstring.startswith(quote) and docstring.endswith(quote):
+                return docstring.split(quote, 1)[1].rsplit(quote, 1)[
+                    0
+                ].strip(), quote.replace("'", '"')
+
+        raise ValueError(
+            "docformatter only handles triple-quoted (single or double) "
+            "strings"
+        )