Repository: PyCQA/docformatter (python)

Issue:
""" 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?


Pull request description:
feat: add option to place closing quotes on newline
A new argument `--close-quotes-on-newline` was added to allow the closing triple quotes to be placed on a newline when a one-line docstring is wrapped and takes two or more lines.  The default value is false to avoid breaking current use-cases.

Closes #9

Patch:
diff --git a/README.rst b/README.rst
index b79178a..a18c91d 100644
--- a/README.rst
+++ b/README.rst
@@ -201,6 +201,10 @@ Below is the help output::
       --make-summary-multi-line
                             add a newline before and after a one-line docstring
                             (default: False)
+      --close-quotes-on-newline
+                            place closing triple quotes on a new-line when a 
+                            one-line docstring wraps to two or more lines
+                            (default: False)
       --force-wrap
                             force descriptions to be wrapped even if it may result
                             in a mess
diff --git a/docformatter.py b/docformatter.py
index 37140c0..ad8f08a 100755
--- a/docformatter.py
+++ b/docformatter.py
@@ -196,19 +196,21 @@ class Configurator:
             dest="post_description_blank",
             action="store_true",
             default=bool(self.flargs_dct.get("blank", False)),
-            help="add blank line after description",
+            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",
+            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",
+            help="add a space after the opening triple quotes "
+            "(default: %(default)s)",
         )
         self.parser.add_argument(
             "--make-summary-multi-line",
@@ -216,15 +218,25 @@ class Configurator:
             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",
+            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",
+            "result in a mess (default: %(default)s)",
         )
         self.parser.add_argument(
             "--range",
@@ -234,7 +246,7 @@ class Configurator:
             type=int,
             nargs=2,
             help="apply docformatter to docstrings between these "
-            "lines; line numbers are indexed at 1",
+            "lines; line numbers are indexed at 1 (default: %(default)s)",
         )
         self.parser.add_argument(
             "--docstring-length",
@@ -243,14 +255,15 @@ class Configurator:
             default=self.flargs_dct.get("docstring-length", None),
             type=int,
             nargs=2,
-            help="apply docformatter to docstrings of given length range",
+            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)",
+            "issue #67) (default: False)",
         )
         self.parser.add_argument(
             "--config", help="path to file containing docformatter options"
@@ -402,6 +415,7 @@ def _format_code(
     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,
@@ -451,6 +465,7 @@ def _format_code(
                 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,
@@ -488,6 +503,7 @@ def format_docstring(
     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,
@@ -555,21 +571,29 @@ def format_docstring(
 '''
     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 = 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}"
+            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):
@@ -802,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
@@ -951,6 +973,7 @@ def _format_code_with_args(source, args):
         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,
diff --git a/pyproject.toml b/pyproject.toml
index e85c101..1517319 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -182,10 +182,10 @@ deps =
     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
-    docformatter docformatter.py setup.py
     python -m doctest docformatter.py
 """


Test patch:
diff --git a/tests/test_format_docstring.py b/tests/test_format_docstring.py
index fc51f76..b63e51a 100644
--- a/tests/test_format_docstring.py
+++ b/tests/test_format_docstring.py
@@ -285,7 +285,6 @@ class TestFormatWrap:
                 ),
                 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.
@@ -442,6 +441,47 @@ num_iterations is the number of updates - instead of a better definition of conv
             tab_width=4,
         )
 
+    @pytest.mark.unit
+    def test_format_docstring_for_multi_line_summary_alone(self):
+        """Place closing quotes on newline when wrapping one-liner."""
+        assert (
+            (
+                '''\
+"""This one-line docstring will be multi-line because it's quite
+    long.
+    """\
+'''
+            )
+            == docformatter.format_docstring(
+                "    ",
+                '''\
+"""This one-line docstring will be multi-line because it's quite long."""\
+''',
+                summary_wrap_length=69,
+                close_quotes_on_newline=True,
+            )
+        )
+
+    @pytest.mark.unit
+    def test_format_docstring_for_one_line_summary_alone_but_too_long(self):
+        """"""
+        assert (
+            (
+                '''\
+"""This one-line docstring will not be wrapped and quotes will be in-line."""\
+'''
+            )
+            == docformatter.format_docstring(
+                "    ",
+                '''\
+"""This one-line docstring will not be wrapped and quotes will be 
+in-line."""\
+''',
+                summary_wrap_length=88,
+                close_quotes_on_newline=True,
+            )
+        )
+
 
 class TestFormatStyleOptions:
     """Class for testing format_docstring() when requesting style options."""