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@ 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 @@ def _help(): -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 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: 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 @@ def test_format_docstring(self, test_args, args): @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 @@ def test_format_docstring_with_single_quotes(self, test_args, args): @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 @@ def test_format_docstring_with_single_quotes_multi_line( @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 @@ def test_format_docstring_leave_directive_alone(self, test_args, args): @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 @@ def test_format_docstring_should_ignore_numbered_lists( @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 -.""" 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 test_format_docstring_black( 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 @@ def test_format_docstring_non_sphinx_style( '''\ """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 @@ def test_strip_docstring( @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 @@ def test_strip_docstring_with_empty_string( ) docstring, open_quote = uut._do_strip_docstring('""""""') - assert docstring == "" + assert not docstring assert open_quote == '"""' @pytest.mark.unit