Repository: PyCQA/docformatter
Pull request #211 (2023-05-18 02:56:00)

feat: support epytext style
Adds wrapping support for Epytext style field lists.


Patch:
diff --git a/README.rst b/README.rst
index 4c1f12a..7ba38c8 100644
--- a/README.rst
+++ b/README.rst
@@ -63,6 +63,11 @@ Features
   whitespace. Such trailing whitespace is visually indistinguishable
   and some editors (or more recently, reindent.py) will trim them.
 
+``docformatter`` formats docstrings compatible with ``black`` when passed the
+``--black`` option.
+
+``docformatter`` formats field lists that use Epytext or Sphinx styles.
+
 See the the full documentation at `read-the-docs`_, especially the
 `requirements`_ section for a more detailed discussion of PEP 257 and other
 requirements.
@@ -82,6 +87,8 @@ Python < 3.11::
 
     $ pip install --upgrade docformatter[tomli]
 
+With Python >=3.11, ``tomllib`` from the standard library is used.
+
 Or, if you want to use a release candidate (or any other tag)::
 
     $ pip install git+https://github.com/PyCQA/docformatter.git@<RC_TAG>
diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst
index aa34783..5e9afbb 100644
--- a/docs/source/configuration.rst
+++ b/docs/source/configuration.rst
@@ -79,10 +79,9 @@ and
     ``""""This" summary does get a space."""`` becomes ``""" "This" summary does get a space."""``
 
 The ``--style`` argument takes a string which is the name of the parameter
-list style you are using.  Currently, only ``sphinx`` is recognized, but
-``epydoc``, ``numpy``, and ``google`` are future styles.  For the selected
-style, each line in the parameter lists will be wrapped at the
-``--wrap-descriptions`` length as well as any portion of the elaborate
-description preceding the parameter list.  Parameter lists that don't follow the
-passed style will cause the entire elaborate description to be ignored and
-remain unwrapped.
+list style you are using.  Currently, only ``sphinx`` and ``epytext`` are recognized,
+but ``numpy`` and ``google`` are future styles.  For the selected style, each line in
+the parameter lists will be wrapped at the ``--wrap-descriptions`` length as well as
+any portion of the elaborate description preceding the parameter list.  Parameter lists
+that don't follow the passed style will cause the entire elaborate description to be
+ignored and remain unwrapped.
diff --git a/docs/source/requirements.rst b/docs/source/requirements.rst
index 0f763bb..7262de7 100644
--- a/docs/source/requirements.rst
+++ b/docs/source/requirements.rst
@@ -55,14 +55,14 @@ Docstring Style
 ---------------
 
 There are at least four "flavors" of docstrings in common use today;
-Epydoc, Sphinx, NumPy, and Google.  Each of these docstring flavors follow the
+Epytext, Sphinx, NumPy, and Google.  Each of these docstring flavors follow the
 PEP 257 *convention* requirements.  What differs between the three docstring
 flavors is the reST syntax used in the parameter description of the multi-line
 docstring.
 
 For example, here is how each syntax documents function arguments.
 
-Epydoc syntax:
+Epytext syntax:
 
 .. code-block::
 
@@ -221,6 +221,9 @@ the requirement falls in, the type of requirement, and whether
     ' docformatter_10.5.2', ' Should wrap descriptions at 88 characters by default in black mode.', ' Style', ' Should', ' Yes'
     ' docformatter_10.5.3', ' Should insert a space before the first word in the summary if that word is quoted when in black mode.', ' Style', ' Should', ' Yes'
     ' docformatter_10.5.4', ' Default black mode options should be over-rideable by passing arguments or using configuration files.', ' Style', ' Should', ' Yes'
+    ' docformatter_10.6', ' Should format docstrings using Epytext style.', ' Style', ' Should', ' Yes'
+    ' docformatter_10.6.1', ' Shall ignore docstrings in other styles when using Epytext style.', ' Shall', ' Yes'
+    ' docformatter_10.6.2', ' Shall wrap Epytext-style parameter descriptions that exceed wrap length when using Epytext style.', ' Shall', ' Yes'
     ' docformatter_11', '**Program Control**'
     ' docformatter_11.1', ' Should check formatting and report incorrectly documented docstrings.', ' Stakeholder', ' Should', ' Yes [*PR #32*]'
     ' docformatter_11.2', ' Should fix formatting and save changes to file.', ' Stakeholder', ' Should', ' Yes'
@@ -344,4 +347,4 @@ version bump (i.e., 1.5.0 -> 1.6.0).  One or more release candidates will be
 provided for each minor or major version bump.  These will be indicated by
 appending `-rcX` to the version number, where the X is the release candidate
 number beginning with 1.  Release candidates will not be uploaded to PyPi,
-but will be made available via GitHub Releases.
\ No newline at end of file
+but will be made available via GitHub Releases.
diff --git a/docs/source/usage.rst b/docs/source/usage.rst
index 620a2fd..be83241 100644
--- a/docs/source/usage.rst
+++ b/docs/source/usage.rst
@@ -45,7 +45,7 @@ help output provides a summary of these options:
 
       -s style, --style style
                             the docstring style to use when formatting parameter
-                            lists (default: sphinx)
+                            lists.  One of epytext, sphinx. (default: sphinx)
       --black
                             make formatting compatible with standard black options
                             (default: False)
diff --git a/pyproject.toml b/pyproject.toml
index 28899c2..00de75e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -167,6 +167,17 @@ use_parentheses = true
 ensure_newline_before_comments = true
 line_length = 88
 
+[tool.rstcheck]
+report = "warning"
+ignore_directives = [
+	"automodule",
+	"tabularcolumns",
+	"toctree",
+]
+ignore_roles = [
+	"numref",
+]
+
 [tool.tox]
 legacy_tox_ini = """
 [tox]
diff --git a/src/docformatter/__main__.py b/src/docformatter/__main__.py
index 6c847c3..54384aa 100755
--- a/src/docformatter/__main__.py
+++ b/src/docformatter/__main__.py
@@ -24,12 +24,7 @@
 """Formats docstrings to follow PEP 257."""
 
 
-from __future__ import (
-    absolute_import,
-    division,
-    print_function,
-    unicode_literals,
-)
+from __future__ import absolute_import, division, print_function, unicode_literals
 
 # Standard Library Imports
 import contextlib
@@ -73,7 +68,7 @@ options:
 
   -s style, --style style
                         the docstring style to use when formatting parameter
-                        lists (default: sphinx)
+                        lists.  One of epytext, sphinx. (default: sphinx)
   --black               make formatting compatible with standard black options
                         (default: False)
   --wrap-summaries length
diff --git a/src/docformatter/syntax.py b/src/docformatter/syntax.py
index e2896fa..2a650ad 100644
--- a/src/docformatter/syntax.py
+++ b/src/docformatter/syntax.py
@@ -30,12 +30,33 @@ import re
 import textwrap
 from typing import Iterable, List, Tuple, Union
 
-SPHINX_REGEX = r":[a-zA-Z0-9_\- ]*:"
-"""Regular expression to use for finding Sphinx-style field lists."""
+BULLET_REGEX = r"\s*[*\-+] [\S ]+"
+"""Regular expression to use for finding bullet lists."""
+
+ENUM_REGEX = r"\s*\d\."
+"""Regular expression to use for finding enumerated lists."""
+
+EPYTEXT_REGEX = r"@[a-zA-Z0-9_\-\s]+:"
+"""Regular expression to use for finding Epytext-style field lists."""
+
+GOOGLE_REGEX = r"^ *[a-zA-Z0-9_\- ]*:$"
+"""Regular expression to use for finding Google-style field lists."""
+
+LITERAL_REGEX = r"[\S ]*::"
+"""Regular expression to use for finding literal blocks."""
+
+NUMPY_REGEX = r"^\s[a-zA-Z0-9_\- ]+ ?: [\S ]+"
+"""Regular expression to use for finding Numpy-style field lists."""
+
+OPTION_REGEX = r"^-{1,2}[\S ]+ {2}\S+"
+"""Regular expression to use for finding option lists."""
 
 REST_REGEX = r"(\.{2}|``) ?[\w-]+(:{1,2}|``)?"
 """Regular expression to use for finding reST directives."""
 
+SPHINX_REGEX = r":[a-zA-Z0-9_\- ]*:"
+"""Regular expression to use for finding Sphinx-style field lists."""
+
 URL_PATTERNS = (
     "afp|"
     "apt|"
@@ -238,22 +259,36 @@ def do_find_directives(text: str) -> bool:
     return bool([(_rest.start(0), _rest.end(0)) for _rest in _rest_iter])
 
 
-def do_find_sphinx_field_lists(text: str) -> List[Tuple[int, int]]:
+def do_find_field_lists(text: str, style: str):
     r"""Determine if docstring contains any field lists.
 
     Parameters
     ----------
     text : str
         The docstring description to check for field list patterns.
+    style : str
+        The field list style used.
 
     Returns
     -------
-    field_index : list
+    _field_idx, _wrap_parameters : tuple
         A list of tuples with each tuple containing the starting and ending
         position of each field list found in the passed description.
+        A boolean indicating whether long field list lines should be wrapped.
     """
-    _field_iter = re.finditer(SPHINX_REGEX, text)
-    return [(_field.start(0), _field.end(0)) for _field in _field_iter]
+    _field_idx = []
+    _wrap_parameters = False
+
+    if style == "epytext":
+        _field_iter = re.finditer(EPYTEXT_REGEX, text)
+        _field_idx = [(_field.start(0), _field.end(0)) for _field in _field_iter]
+        _wrap_parameters = True
+    elif style == "sphinx":
+        _field_iter = re.finditer(SPHINX_REGEX, text)
+        _field_idx = [(_field.start(0), _field.end(0)) for _field in _field_iter]
+        _wrap_parameters = True
+
+    return _field_idx, _wrap_parameters
 
 
 def do_find_links(text: str) -> List[Tuple[int, int]]:
@@ -333,12 +368,9 @@ def do_split_description(
 
     # Check if the description contains any URLs.
     _url_idx = do_find_links(text)
-    if style == "sphinx":
-        _parameter_idx = do_find_sphinx_field_lists(text)
-        _wrap_parameters = True
-    else:
-        _parameter_idx = []
-        _wrap_parameters = False
+
+    # Check if the description contains any field lists.
+    _parameter_idx, _wrap_parameters = do_find_field_lists(text, style)
 
     if not _url_idx and not (_parameter_idx and _wrap_parameters):
         return description_to_list(
@@ -511,6 +543,47 @@ def do_wrap_urls(
     return _lines, text_idx
 
 
+def is_some_sort_of_field_list(
+    text: str,
+    style: str,
+) -> bool:
+    """Determine if docstring contains field lists.
+
+    Parameters
+    ----------
+    text : str
+        The docstring text.
+    style : str
+        The field list style to use.
+
+    Returns
+    -------
+    is_field_list : bool
+        Whether the field list pattern for style was found in the docstring.
+    """
+    split_lines = text.rstrip().splitlines()
+
+    if style == "epytext":
+        return any(
+            (
+                # "@param x:" <-- Epytext style
+                # "@type x:" <-- Epytext style
+                re.match(EPYTEXT_REGEX, line)
+            )
+            for line in split_lines
+        )
+    elif style == "sphinx":
+        return any(
+            (
+                # ":parameter: description" <-- Sphinx style
+                re.match(SPHINX_REGEX, line)
+            )
+            for line in split_lines
+        )
+
+    return False
+
+
 # pylint: disable=line-too-long
 def is_some_sort_of_list(
     text: str,
@@ -545,81 +618,55 @@ def is_some_sort_of_list(
     ) and not strict:
         return True
 
-    if style == "sphinx":
-        return any(
-            (
-                # "* parameter" <-- Bullet list
-                # "- parameter" <-- Bullet list
-                # "+ parameter" <-- Bullet list
-                re.match(r"\s*[*\-+] [\S ]+", line)
-                or
-                # "1. item" <-- Enumerated list
-                re.match(r"\s*\d\.", line)
-                or
-                # "-a  description" <-- Option list
-                # "--long  description" <-- Option list
-                re.match(r"^-{1,2}[\S ]+ {2}\S+", line)
-                or
-                # "@parameter" <-- Epydoc style
-                re.match(r"\s*@\S*", line)
-                or
-                # "parameter : description" <-- Numpy style
-                # "parameter: description" <-- Numpy style
-                re.match(r"^\s*(?!:)\S+ ?: \S+", line)
-                or
-                # "word\n----" <-- Numpy headings
-                re.match(r"^\s*-+", line)
-                or
-                # "parameter - description"
-                re.match(r"[\S ]+ - \S+", line)
-                or
-                # "parameter -- description"
-                re.match(r"\s*\S+\s+--\s+", line)
-                or
-                # Literal block
-                re.match(r"[\S ]*::", line)
-            )
-            for line in split_lines
-        )
-    else:
-        return any(
-            (
-                # "* parameter" <-- Bullet list
-                # "- parameter" <-- Bullet list
-                # "+ parameter" <-- Bullet list
-                re.match(r"\s*[*\-+] [\S ]+", line)
-                or
-                # "1. item" <-- Enumerated list
-                re.match(r"\s*\d\.", line)
-                or
-                # "-a  description" <-- Option list
-                # "--long  description" <-- Option list
-                re.match(r"^-{1,2}[\S ]+ {2}\S+", line)
-                or
-                # "@parameter" <-- Epydoc style
-                re.match(r"\s*@\S*", line)
-                or
-                # ":parameter: description" <-- Sphinx style
-                re.match(SPHINX_REGEX, line)
-                or
-                # "parameter : description" <-- Numpy style
-                # "parameter: description" <-- Numpy style
-                re.match(r"^\s[\S ]+ ?: [\S ]+", line)
-                or
-                # "word\n----" <-- Numpy headings
-                re.match(r"^\s*-+", line)
-                or
-                # "parameter - description"
-                re.match(r"[\S ]+ - \S+", line)
-                or
-                # "parameter -- description"
-                re.match(r"\s*\S+\s+--\s+", line)
-                or
-                # Literal block
-                re.match(r"[\S ]*::", line)
-            )
-            for line in split_lines
+    if is_some_sort_of_field_list(text, style):
+        return False
+
+    return any(
+        (
+            # "* parameter" <-- Bullet list
+            # "- parameter" <-- Bullet list
+            # "+ parameter" <-- Bullet list
+            re.match(BULLET_REGEX, line)
+            or
+            # "1. item" <-- Enumerated list
+            re.match(ENUM_REGEX, line)
+            or
+            # "-a  description" <-- Option list
+            # "--long  description" <-- Option list
+            re.match(OPTION_REGEX, line)
+            or
+            # "@param x:" <-- Epytext style
+            # "@type x:" <-- Epytext style
+            re.match(EPYTEXT_REGEX, line)
+            or
+            # ":parameter: description" <-- Sphinx style
+            re.match(SPHINX_REGEX, line)
+            or
+            # "parameter : description" <-- Numpy style
+            # "parameter: description" <-- Numpy style
+            re.match(NUMPY_REGEX, line)
+            or
+            # "word\n----" <-- Numpy headings
+            re.match(r"^\s*-+", line)
+            or
+            # "Args:" <-- Google style
+            # "parameter:" <-- Google style
+            re.match(GOOGLE_REGEX, line)
+            or
+            # "parameter - description"
+            re.match(r"[\S ]+ - \S+", line)
+            or
+            # "parameter -- description"
+            re.match(r"\s*\S+\s+--\s+", line)
+            or
+            # Literal block
+            re.match(LITERAL_REGEX, line)
+            or
+            # "@parameter"
+            re.match(r"^ *@[a-zA-Z0-9_\- ]*(?:(?!:).)*$", line)
         )
+        for line in split_lines
+    )
 
 
 def is_some_sort_of_code(text: str) -> bool:


Test patch:
diff --git a/tests/test_format_docstring.py b/tests/test_format_docstring.py
index bf74827..20b059e 100644
--- a/tests/test_format_docstring.py
+++ b/tests/test_format_docstring.py
@@ -71,9 +71,7 @@ Hello.
 
     @pytest.mark.unit
     @pytest.mark.parametrize("args", [[""]])
-    def test_format_docstring_with_summary_that_ends_in_quote(
-        self, test_args, args
-    ):
+    def test_format_docstring_with_summary_that_ends_in_quote(self, test_args, args):
         """Return one-line docstring with period after quote."""
         uut = Formatter(
             test_args,
@@ -261,9 +259,7 @@ Hello.
 
     @pytest.mark.unit
     @pytest.mark.parametrize("args", [[""]])
-    def test_format_docstring_with_single_quotes_multi_line(
-        self, test_args, args
-    ):
+    def test_format_docstring_with_single_quotes_multi_line(self, test_args, args):
         """Replace single triple quotes with triple double quotes."""
         uut = Formatter(
             test_args,
@@ -290,9 +286,7 @@ Hello.
 
     @pytest.mark.unit
     @pytest.mark.parametrize("args", [[""]])
-    def test_format_docstring_leave_underlined_summaries_alone(
-        self, test_args, args
-    ):
+    def test_format_docstring_leave_underlined_summaries_alone(self, test_args, args):
         """Leave underlined summary lines as is."""
         uut = Formatter(
             test_args,
@@ -336,9 +330,7 @@ Hello.
 
     @pytest.mark.unit
     @pytest.mark.parametrize("args", [[""]])
-    def test_format_docstring_leave_link_only_docstring_alone(
-        self, test_args, args
-    ):
+    def test_format_docstring_leave_link_only_docstring_alone(self, test_args, args):
         """Leave docstrings that consist of only a link alone."""
         uut = Formatter(
             test_args,
@@ -441,9 +433,7 @@ class TestFormatLists:
 
     @pytest.mark.unit
     @pytest.mark.parametrize("args", [["--wrap-descriptions", "72", ""]])
-    def test_format_docstring_should_ignore_numbered_lists(
-        self, test_args, args
-    ):
+    def test_format_docstring_should_ignore_numbered_lists(self, test_args, args):
         """Ignore lists beginning with numbers."""
         uut = Formatter(
             test_args,
@@ -465,9 +455,7 @@ class TestFormatLists:
 
     @pytest.mark.unit
     @pytest.mark.parametrize("args", [["--wrap-descriptions", "72", ""]])
-    def test_format_docstring_should_ignore_parameter_lists(
-        self, test_args, args
-    ):
+    def test_format_docstring_should_ignore_parameter_lists(self, test_args, args):
         """Ignore lists beginning with <word> -."""
         uut = Formatter(
             test_args,
@@ -1480,9 +1468,7 @@ class TestClass:
 
     @pytest.mark.unit
     @pytest.mark.parametrize("args", [[""]])
-    def test_format_docstring_no_newline_in_summary_with_symbol(
-        self, test_args, args
-    ):
+    def test_format_docstring_no_newline_in_summary_with_symbol(self, test_args, args):
         """Wrap summary with symbol should not add newline.
 
         See issue #79.
@@ -1562,6 +1548,141 @@ def function2():
 
 This long description will be wrapped at 88 characters because we passed the --black option and 88 characters is the default wrap length.
 """\
+''',
+            )
+        )
+
+    @pytest.mark.unit
+    @pytest.mark.parametrize(
+        "args",
+        [
+            [
+                "--wrap-descriptions",
+                "88",
+                "--wrap-summaries",
+                "88",
+                "--style",
+                "epytext",
+                "",
+            ]
+        ],
+    )
+    def test_format_docstring_epytext_style(
+        self,
+        test_args,
+        args,
+    ):
+        """Wrap epytext style parameter lists.
+
+        See requirement docformatter_10.6.2
+        """
+        uut = Formatter(
+            test_args,
+            sys.stderr,
+            sys.stdin,
+            sys.stdout,
+        )
+
+        assert (
+            (
+                '''\
+"""Return line-wrapped description text.
+
+    We only wrap simple descriptions. We leave doctests, multi-paragraph text, and
+    bulleted lists alone.  See
+    http://www.docformatter.com/.
+
+    @param text: the text argument.
+    @param indentation: the super long description for the indentation argument that
+        will require docformatter to wrap this line.
+    @param wrap_length: the wrap_length argument
+    @param force_wrap: the force_warp argument.
+    @return: really long description text wrapped at n characters and a very long
+        description of the return value so we can wrap this line abcd efgh ijkl mnop
+        qrst uvwx yz.
+    """\
+'''
+            )
+            == uut._do_format_docstring(
+                INDENTATION,
+                '''\
+"""Return line-wrapped description text.
+
+    We only wrap simple descriptions. We leave doctests, multi-paragraph text,
+    and bulleted lists alone.  See http://www.docformatter.com/.
+
+    @param text: the text argument.
+    @param indentation: the super long description for the indentation argument that will require docformatter to wrap this line.
+    @param wrap_length: the wrap_length argument
+    @param force_wrap: the force_warp argument.
+    @return: really long description text wrapped at n characters and a very long description of the return value so we can wrap this line abcd efgh ijkl mnop qrst uvwx yz.
+"""\
+''',
+            )
+        )
+
+    @pytest.mark.unit
+    @pytest.mark.parametrize(
+        "args",
+        [
+            [
+                "--wrap-descriptions",
+                "88",
+                "--wrap-summaries",
+                "88",
+                "--style",
+                "numpy",
+                "",
+            ]
+        ],
+    )
+    def test_format_docstring_non_epytext_style(
+        self,
+        test_args,
+        args,
+    ):
+        """Ignore wrapping epytext style parameter lists when not using epytext style.
+
+        See requirement docformatter_10.6.1
+        """
+        uut = Formatter(
+            test_args,
+            sys.stderr,
+            sys.stdin,
+            sys.stdout,
+        )
+
+        assert (
+            (
+                '''\
+"""Return line-wrapped description text.
+
+    We only wrap simple descriptions. We leave doctests, multi-paragraph text, and
+    bulleted lists alone.  See
+    http://www.docformatter.com/.
+
+    @param text: the text argument.
+    @param indentation: the super long description for the indentation argument that will require docformatter to wrap this line.
+    @param wrap_length: the wrap_length argument
+    @param force_wrap: the force_warp argument.
+    @return: really long description text wrapped at n characters and a very long description of the return value so we can wrap this line abcd efgh ijkl mnop qrst uvwx yz.
+    """\
+'''
+            )
+            == uut._do_format_docstring(
+                INDENTATION,
+                '''\
+"""Return line-wrapped description text.
+
+    We only wrap simple descriptions. We leave doctests, multi-paragraph text,
+    and bulleted lists alone.  See http://www.docformatter.com/.
+
+    @param text: the text argument.
+    @param indentation: the super long description for the indentation argument that will require docformatter to wrap this line.
+    @param wrap_length: the wrap_length argument
+    @param force_wrap: the force_warp argument.
+    @return: really long description text wrapped at n characters and a very long description of the return value so we can wrap this line abcd efgh ijkl mnop qrst uvwx yz.
+"""\
 ''',
             )
         )
@@ -1672,7 +1793,9 @@ This long description will be wrapped at 88 characters because we passed the --b
                 '''\
 """Return line-wrapped description text.
 
-    We only wrap simple descriptions. We leave doctests, multi-paragraph text, and bulleted lists alone.  See http://www.docformatter.com/.
+    We only wrap simple descriptions. We leave doctests, multi-paragraph text, and
+    bulleted lists alone.  See
+    http://www.docformatter.com/.
 
     :param str text: the text argument.
     :param str indentation: the super long description for the indentation argument that will require docformatter to wrap this line.
@@ -1857,7 +1980,7 @@ class TestStripDocstring:
 
     @pytest.mark.unit
     @pytest.mark.parametrize("args", [[""]])
-    def test_strip_docstring_with_single_quotes(
+    def test_strip_docstring_with_triple_single_quotes(
         self,
         test_args,
         args,
@@ -1897,7 +2020,7 @@ class TestStripDocstring:
         )
 
         docstring, open_quote = uut._do_strip_docstring('""""""')
-        assert docstring == ""
+        assert not docstring
         assert open_quote == '"""'
 
     @pytest.mark.unit