Repository: PyCQA/docformatter (python)

Issue:
Multiple sentences in multi-line docstring summary
When reading [PEP 257](https://peps.python.org/pep-0257/), I am under the impression that the one line summary at the beginning of the docstring should only be composed of a single sentence, from [`One-line Docstrings`](https://peps.python.org/pep-0257/#one-line-docstrings):

> The docstring is a phrase ending in a period. It prescribes the function or method’s effect as a command (“Do this”, “Return that”), not as a description; e.g. don’t write “Returns the pathname …”.

From what I have tested, this seems correctly enforced in `docformatter` with single line docstrings. But shouldn't it also be enforce with multi-line docstrings? From [`Multi-line Docstrings`](https://peps.python.org/pep-0257/#multi-line-docstrings):

> Multi-line docstrings consist of a summary line just like a one-line docstring, followed by a blank line, followed by a more elaborate description

From what I have tested:

```py
def foo():
    """Do bar. This is the first sentence. And this is the second.

    And now this is the longer description.
    """
```

Is not reformatted by `docformatter` (`1.75`).

But:

```py
def foo():
    """Do bar. This is the first sentence. And this is the second."""
```

gets (correctly) reformatted into:

```py
def foo():
    """Do bar.

    This is the first sentence. And this is the second.
    """
```

Is this an inconsistency or am I wrong in my interpretation? Thanks!

Pull request description:
fix: multi sentence summary
This PR adds a new function to split a multi-sentence summary into the first sentence, which becomes the summary, and the remaining sentences which get prepended to the long description.

Fixes #283.

Patch:
diff --git a/src/docformatter/configuration.py b/src/docformatter/configuration.py
index 248ddb7..1d0ad72 100644
--- a/src/docformatter/configuration.py
+++ b/src/docformatter/configuration.py
@@ -152,7 +152,7 @@ class Configurater:
 
         self.args = self.parser.parse_known_args(self.args_lst[1:])[0]
 
-        # Default black line length is 88 so use this when not specified
+        # Default black line length is 88, so use this when not specified
         # otherwise use PEP-8 defaults
         if self.args.black:
             _default_wrap_summaries = 88
diff --git a/src/docformatter/strings.py b/src/docformatter/strings.py
index 0eccef0..f396545 100644
--- a/src/docformatter/strings.py
+++ b/src/docformatter/strings.py
@@ -29,6 +29,18 @@ import contextlib
 import re
 from typing import List, Match, Optional, Union
 
+# TODO: Read this from the configuration file or command line.
+ABBREVIATIONS = (
+    "e.g.",
+    "i.e.",
+    "et. al.",
+    "etc.",
+    "Dr.",
+    "Mr.",
+    "Mrs.",
+    "Ms.",
+)
+
 
 def find_shortest_indentation(lines: List[str]) -> str:
     """Determine the shortest indentation in a list of lines.
@@ -184,14 +196,14 @@ def split_first_sentence(text):
 
         sentence += previous_delimiter + word
 
-        if sentence.endswith(("e.g.", "i.e.", "etc.", "Dr.", "Mr.", "Mrs.", "Ms.")):
+        if sentence.endswith(ABBREVIATIONS):
             # Ignore false end of sentence.
             pass
         elif sentence.endswith((".", "?", "!")):
             break
         elif sentence.endswith(":") and delimiter == "\n":
             # Break on colon if it ends the line. This is a heuristic to detect
-            # the beginning of some parameter list afterwards.
+            # the beginning of some parameter list after wards.
             break
 
         previous_delimiter = delimiter
@@ -200,12 +212,48 @@ def split_first_sentence(text):
     return sentence, delimiter + rest
 
 
+def split_summary(lines) -> List[str]:
+    """Split multi-sentence summary into the first sentence and the rest."""
+    if not lines or not lines[0].strip():
+        return lines
+
+    text = lines[0].strip()
+
+    tokens = re.split(r"(\s+)", text)  # Keep whitespace for accurate rejoining
+    sentence = []
+    rest = []
+    i = 0
+
+    while i < len(tokens):
+        token = tokens[i]
+        sentence.append(token)
+
+        if token.endswith(".") and not any(
+            "".join(sentence).strip().endswith(abbr) for abbr in ABBREVIATIONS
+        ):
+            i += 1
+            break
+
+        i += 1
+
+    rest = tokens[i:]
+    first_sentence = "".join(sentence).strip()
+    rest_text = "".join(rest).strip()
+
+    lines[0] = first_sentence
+    if rest_text:
+        lines.insert(2, rest_text)
+
+    return lines
+
+
 def split_summary_and_description(contents):
     """Split docstring into summary and description.
 
     Return tuple (summary, description).
     """
     split_lines = contents.rstrip().splitlines()
+    split_lines = split_summary(split_lines)
 
     for index in range(1, len(split_lines)):
         # Empty line separation would indicate the rest is the description or


Test patch:
diff --git a/tests/test_string_functions.py b/tests/test_string_functions.py
index 23d23e6..0e83d37 100644
--- a/tests/test_string_functions.py
+++ b/tests/test_string_functions.py
@@ -36,6 +36,7 @@ those:
     normalize_summary()
     remove_section_headers()
     split_first_sentence()
+    split_summary()
     split_summary_and_description()
     strip_leading_blank_lines()
     strip_quotes()
@@ -261,6 +262,40 @@ class TestSplitters:
             "\none\ntwo",
         ) == docformatter.split_first_sentence("This is the first:\none\ntwo")
 
+    @pytest.mark.unit
+    def test_split_one_sentence_summary(self):
+        """A single sentence summary should be returned as is.
+
+        See issue #283.
+        """
+        assert [
+            "This is a sentence.",
+            "",
+        ] == docformatter.split_summary(["This is a sentence.", ""])
+
+        assert [
+            "This e.g. a sentence.",
+            "",
+        ] == docformatter.split_summary(["This e.g. a sentence.", ""])
+
+    @pytest.mark.unit
+    def test_split_multi_sentence_summary(self):
+        """A multi-sentence summary should return only the first as the summary.
+
+        See issue #283.
+        """
+        assert [
+            "This is a sentence.",
+            "",
+            "This is another.",
+        ] == docformatter.split_summary(["This is a sentence. This is another.", ""])
+
+        assert [
+            "This e.g. a sentence.",
+            "",
+            "This is another.",
+        ] == docformatter.split_summary(["This e.g. a sentence. This is another.", ""])
+
     @pytest.mark.unit
     def test_split_summary_and_description(self):
         """"""
@@ -317,9 +352,7 @@ class TestSplitters:
         assert (
             "This is the first\nWashington",
             "",
-        ) == docformatter.split_summary_and_description(
-            "This is the first\nWashington"
-        )
+        ) == docformatter.split_summary_and_description("This is the first\nWashington")
 
     @pytest.mark.unit
     def test_split_summary_and_description_with_list_on_other_line(self):
@@ -351,9 +384,7 @@ class TestSplitters:
         assert (
             "This is the first:",
             "one\ntwo",
-        ) == docformatter.split_summary_and_description(
-            "This is the first:\none\ntwo"
-        )
+        ) == docformatter.split_summary_and_description("This is the first:\none\ntwo")
 
     @pytest.mark.unit
     def test_split_summary_and_description_with_exclamation(self):
@@ -361,9 +392,7 @@ class TestSplitters:
         assert (
             "This is the first!",
             "one\ntwo",
-        ) == docformatter.split_summary_and_description(
-            "This is the first!\none\ntwo"
-        )
+        ) == docformatter.split_summary_and_description("This is the first!\none\ntwo")
 
     @pytest.mark.unit
     def test_split_summary_and_description_with_question_mark(self):
@@ -371,9 +400,7 @@ class TestSplitters:
         assert (
             "This is the first?",
             "one\ntwo",
-        ) == docformatter.split_summary_and_description(
-            "This is the first?\none\ntwo"
-        )
+        ) == docformatter.split_summary_and_description("This is the first?\none\ntwo")
 
     @pytest.mark.unit
     def test_split_summary_and_description_with_quote(self):
@@ -381,23 +408,17 @@ class TestSplitters:
         assert (
             'This is the first\n"one".',
             "",
-        ) == docformatter.split_summary_and_description(
-            'This is the first\n"one".'
-        )
+        ) == docformatter.split_summary_and_description('This is the first\n"one".')
 
         assert (
             "This is the first\n'one'.",
             "",
-        ) == docformatter.split_summary_and_description(
-            "This is the first\n'one'."
-        )
+        ) == docformatter.split_summary_and_description("This is the first\n'one'.")
 
         assert (
             "This is the first\n``one``.",
             "",
-        ) == docformatter.split_summary_and_description(
-            "This is the first\n``one``."
-        )
+        ) == docformatter.split_summary_and_description("This is the first\n``one``.")
 
     @pytest.mark.unit
     def test_split_summary_and_description_with_punctuation(self):
@@ -461,9 +482,7 @@ Try this and this and this and this and this and this and this at
             "Test Mrs. now",
             "Test Ms. now",
         ]:
-            assert (text, "") == docformatter.split_summary_and_description(
-                text
-            )
+            assert (text, "") == docformatter.split_summary_and_description(text)
 
     @pytest.mark.unit
     def test_split_summary_and_description_with_url(self):
@@ -497,9 +516,7 @@ class TestStrippers:
     @pytest.mark.unit
     def test_remove_section_header(self):
         """Remove section header directives."""
-        assert "foo\nbar\n" == docformatter.remove_section_header(
-            "----\nfoo\nbar\n"
-        )
+        assert "foo\nbar\n" == docformatter.remove_section_header("----\nfoo\nbar\n")
 
         line = "foo\nbar\n"
         assert line == docformatter.remove_section_header(line)