Repository: PyCQA/docformatter (python)

Issue:
v1.6.4 - Consider Not Capitalizing Proper Nouns if Other Capital Letters Exist in the Word
This one may be a bit opinionated...but when a docstring starts with a proper noun that does not start with a capital letter, docformatter is now capitalizing it.

Example:
```diff
@pytest.fixture(scope="session", autouse=True)
def client():
-    """qBittorrent Client for testing session."""
+    """QBittorrent Client for testing session."""
    client = Client()
```

I can imagine other words like `iOS`, `macOS`, `eBay` etc. being affected.

Since detecting whether a word is a proper noun isn't trivial, perhaps detecting if the first word has other capital letters may be sufficient. Alternatively, saying, "wait on #144" may make sense too.

Pull request description:
feat: add option for user to provide list of words not to capitalize
Closes #193.

Patch:
diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst
index e50922f..1b850d5 100644
--- a/docs/source/configuration.rst
+++ b/docs/source/configuration.rst
@@ -1,4 +1,3 @@
-
 How to Configure docformatter
 =============================
 
diff --git a/docs/source/requirements.rst b/docs/source/requirements.rst
index 9b0c00e..32c4a86 100644
--- a/docs/source/requirements.rst
+++ b/docs/source/requirements.rst
@@ -163,6 +163,7 @@ the requirement falls in, the type of requirement, and whether
     ' docformatter_4.5.1', ' One-line docstrings may end in any of the following punctuation marks [. ! ?]', ' Derived', ' May', ' Yes'
     ' docformatter_4.5.2', ' One-line docstrings will have the first word capitalized.', ' Derived', ' Shall', ' Yes'
     ' docformatter_4.5.2.1', ' First words in one-line docstrings that are variables or filenames shall remain unchanged.', ' Derived', ' Shall', ' Yes [PR #185, #188]'
+    ' docformatter_4.5.2.2', ' First words in one-line docstrings that are user-specified to not be capitalized shall remain unchanged.', ' Derived', ' Shall', ' Yes [PR #194]'
     ' docformatter_4.5.3', ' Shall not place a newline after the first line of a wrapped one-line docstring.' ' Derived', ' Shall', ' Yes [PR #179]'
     ' PEP_257_5','**Multi-line docstrings:**'
     ' PEP_257_5.1',' A summary is just like a one-line docstring.',' Convention',' Shall',' Yes'
diff --git a/docs/source/usage.rst b/docs/source/usage.rst
index 89b43e1..9f55474 100644
--- a/docs/source/usage.rst
+++ b/docs/source/usage.rst
@@ -40,6 +40,8 @@ help output provides a summary of these options:
       -c, --check           only check and report incorrectly formatted files
       -r, --recursive       drill down directories recursively
       -e, --exclude         in recursive mode, exclude directories and files by names
+      -n, --non-cap         list of words not to capitalize when they appear as the
+                            first word in the summary
 
       --wrap-summaries length
                             wrap long summary lines at this length; set
diff --git a/src/docformatter/configuration.py b/src/docformatter/configuration.py
index bd18432..5fde31b 100644
--- a/src/docformatter/configuration.py
+++ b/src/docformatter/configuration.py
@@ -127,6 +127,15 @@ class Configurater:
             default=self.flargs_dct.get("exclude", None),
             help="in recursive mode, exclude directories and files by names",
         )
+        self.parser.add_argument(
+            "-n",
+            "--non-cap",
+            action="store",
+            nargs="*",
+            default=self.flargs_dct.get("non-cap", None),
+            help="list of words not to capitalize when they appear as the "
+            "first word in the summary",
+        )
         self.parser.add_argument(
             "--wrap-summaries",
             default=int(self.flargs_dct.get("wrap-summaries", 79)),
diff --git a/src/docformatter/format.py b/src/docformatter/format.py
index 88df426..2bf1233 100644
--- a/src/docformatter/format.py
+++ b/src/docformatter/format.py
@@ -430,7 +430,7 @@ class Formatter:
             beginning = f"{open_quote}\n{indentation}"
             ending = f'\n{indentation}"""'
             summary_wrapped = _syntax.wrap_summary(
-                _strings.normalize_summary(contents),
+                _strings.normalize_summary(contents, self.args.non_cap),
                 wrap_length=self.args.wrap_summaries,
                 initial_indent=indentation,
                 subsequent_indent=indentation,
@@ -438,7 +438,9 @@ class Formatter:
             return f"{beginning}{summary_wrapped}{ending}"
         else:
             summary_wrapped = _syntax.wrap_summary(
-                open_quote + _strings.normalize_summary(contents) + '"""',
+                open_quote
+                + _strings.normalize_summary(contents, self.args.non_cap)
+                + '"""',
                 wrap_length=self.args.wrap_summaries,
                 initial_indent=indentation,
                 subsequent_indent=indentation,
@@ -488,7 +490,7 @@ class Formatter:
             "\n" + indentation if self.args.pre_summary_newline else ""
         )
         summary = _syntax.wrap_summary(
-            _strings.normalize_summary(summary),
+            _strings.normalize_summary(summary, self.args.non_cap),
             wrap_length=self.args.wrap_summaries,
             initial_indent=initial_indent,
             subsequent_indent=indentation,
diff --git a/src/docformatter/strings.py b/src/docformatter/strings.py
index 1a4e076..bb7344d 100644
--- a/src/docformatter/strings.py
+++ b/src/docformatter/strings.py
@@ -24,9 +24,10 @@
 """This module provides docformatter string functions."""
 
 
-import contextlib
 # Standard Library Imports
+import contextlib
 import re
+from typing import List
 
 
 def find_shortest_indentation(lines):
@@ -99,8 +100,28 @@ def normalize_line_endings(lines, newline):
     return "".join([normalize_line(line, newline) for line in lines])
 
 
-def normalize_summary(summary: str) -> str:
-    """Return normalized docstring summary."""
+def normalize_summary(summary: str, noncap: List[str] = None) -> str:
+    """Return normalized docstring summary.
+
+    A normalized docstring summary will have the first word capitalized and
+    a period at the end.
+
+    Parameters
+    ----------
+    summary : str
+        The summary string.
+    noncap : list
+        A user-provided list of words not to capitalize when they appear as
+        the first word in the summary.
+
+    Returns
+    -------
+    summary : str
+        The normalized summary string.
+    """
+    if noncap is None:
+        noncap = []
+
     # Remove trailing whitespace
     summary = summary.rstrip()
 
@@ -115,9 +136,13 @@ def normalize_summary(summary: str) -> str:
     with contextlib.suppress(IndexError):
         # Look for underscores, periods in the first word, this would typically
         # indicate the first word is a variable name, file name, or some other
-        # non-standard English word.  If none of these exist capitalize the
+        # non-standard English word.  The search the list of user-defined
+        # words not to capitalize.  If none of these exist capitalize the
         # first word of the summary.
-        if all(char not in summary.split(" ", 1)[0] for char in ["_", "."]):
+        if (
+            all(char not in summary.split(" ", 1)[0] for char in ["_", "."])
+            and summary.split(" ", 1)[0] not in noncap
+        ):
             summary = summary[0].upper() + summary[1:]
 
     return summary


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 4995dcb..69c4b08 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -158,6 +158,11 @@ def test_args(args):
         "--exclude",
         nargs="*",
     )
+    parser.add_argument(
+        "-n",
+        "--non-cap",
+        nargs="*",
+    )
     parser.add_argument(
         "--wrap-summaries",
         default=79,
diff --git a/tests/test_configuration_functions.py b/tests/test_configuration_functions.py
index e402fbc..543d253 100644
--- a/tests/test_configuration_functions.py
+++ b/tests/test_configuration_functions.py
@@ -380,3 +380,106 @@ exclude = ["src/", "tests/"]
             "diff": "true",
             "exclude": '["src/", "tests/"]'
         }
+
+    @pytest.mark.unit
+    def test_non_capitalize_words(self, capsys):
+        """Read list of words not to capitalize.
+
+        See issue #193.
+        """
+        argb = [
+            "/path/to/docformatter",
+            "-n",
+            "qBittorrent",
+            "eBay",
+            "iPad",
+            "-c",
+            "",
+        ]
+
+        uut = Configurater(argb)
+        uut.do_parse_arguments()
+
+        assert uut.args.non_cap == ["qBittorrent", "eBay", "iPad"]
+
+
+    @pytest.mark.unit
+    @pytest.mark.parametrize(
+        "config",
+        [
+            """\
+[tool.docformatter]
+check = true
+diff = true
+recursive = true
+non-cap = ["qBittorrent", "iPad", "iOS", "eBay"]
+"""
+        ],
+    )
+    def test_non_cap_from_pyproject_toml(self,temporary_pyproject_toml,
+                                         config,):
+        """Read list of words not to capitalize from pyproject.toml.
+
+        See issue #193.
+        """
+        argb = [
+            "/path/to/docformatter",
+            "-c",
+            "--config",
+            "/tmp/pyproject.toml",
+            "",
+        ]
+
+        uut = Configurater(argb)
+        uut.do_parse_arguments()
+
+        assert uut.args.check
+        assert not uut.args.in_place
+        assert uut.args_lst == argb
+        assert uut.config_file == "/tmp/pyproject.toml"
+        assert uut.flargs_dct == {
+            "recursive": "True",
+            "check": "True",
+            "diff": "True",
+            "non-cap": ["qBittorrent", "iPad", "iOS", "eBay"]
+        }
+
+    @pytest.mark.unit
+    @pytest.mark.parametrize(
+        "config",
+        [
+            """\
+[docformatter]
+check = true
+diff = true
+recursive = true
+non-cap = ["qBittorrent", "iPad", "iOS", "eBay"]
+"""
+        ],
+    )
+    def test_non_cap_from_setup_cfg(self,temporary_setup_cfg,config,):
+        """Read list of words not to capitalize from setup.cfg.
+
+        See issue #193.
+        """
+        argb = [
+            "/path/to/docformatter",
+            "-c",
+            "--config",
+            "/tmp/setup.cfg",
+            "",
+        ]
+
+        uut = Configurater(argb)
+        uut.do_parse_arguments()
+
+        assert uut.args.check
+        assert not uut.args.in_place
+        assert uut.args_lst == argb
+        assert uut.config_file == "/tmp/setup.cfg"
+        assert uut.flargs_dct == {
+            "recursive": "true",
+            "check": "true",
+            "diff": "true",
+            "non-cap": '["qBittorrent", "iPad", "iOS", "eBay"]'
+        }
diff --git a/tests/test_format_docstring.py b/tests/test_format_docstring.py
index 92bc507..de37a78 100644
--- a/tests/test_format_docstring.py
+++ b/tests/test_format_docstring.py
@@ -215,6 +215,28 @@ Hello
 '''.strip(),
         )
 
+    @pytest.mark.unit
+    @pytest.mark.parametrize("args", [["--non-cap", "eBay", 'iPad', "-c", ""]])
+    def test_format_docstring_with_non_cap_words(self, test_args, args):
+        """Capitalize words not found in the non_cap list.
+
+        See issue #193.
+        """
+        uut = Formatter(
+            test_args,
+            sys.stderr,
+            sys.stdin,
+            sys.stdout,
+        )
+
+        assert '"""eBay kinda suss."""' == uut._do_format_docstring(
+            INDENTATION,
+            '''\
+"""
+eBay kinda suss
+"""
+''')
+
     @pytest.mark.unit
     @pytest.mark.parametrize("args", [[""]])
     def test_format_docstring_with_single_quotes(self, test_args, args):