diff --git a/.sourcery.yaml b/.sourcery.yaml index 1a767dc..407b105 100644 --- a/.sourcery.yaml +++ b/.sourcery.yaml @@ -19,15 +19,15 @@ ignore: # A list of paths or files which Sourcery will ignore. - .env - .tox -# rule_settings: -# enable: -# - default -# disable: [] # A list of rule IDs Sourcery will never suggest. -# rule_types: -# - refactoring -# - suggestion -# - comment -# python_version: '3.9' # A string specifying the lowest Python version your project supports. Sourcery will not suggest refactorings requiring a higher Python version. +rule_settings: + enable: + - default + disable: [] # A list of rule IDs Sourcery will never suggest. + rule_types: + - refactoring + - suggestion + - comment + python_version: '3.7' # rules: # A list of custom rules Sourcery will include in its analysis. # - id: no-print-statements diff --git a/README.rst b/README.rst index bb6a989..b38419a 100644 --- a/README.rst +++ b/README.rst @@ -85,7 +85,7 @@ Or, if you want to use a release candidate (or any other tag):: $ pip install git+https://github.com/PyCQA/docformatter.git@ Where is the release candidate tag you'd like to install. Release -candidate tags will have the format v1.6.0-rc.1 Release candidates will also be +candidate tags will have the format v1.6.0-rc1 Release candidates will also be made available as a Github Release. Example diff --git a/docs/source/conf.py b/docs/source/conf.py index d546136..f82b6e6 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -7,9 +7,9 @@ # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information project = 'docformatter' -copyright = '2022, Steven Myint' +copyright = '2022-2023, Steven Myint' author = 'Steven Myint' -release = '1.5.1' +release = '1.6.0' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/docs/source/requirements.rst b/docs/source/requirements.rst index b941179..6bc3610 100644 --- a/docs/source/requirements.rst +++ b/docs/source/requirements.rst @@ -320,4 +320,8 @@ prioritization scheme: Integration of a bug fix will result in a patch version bump (i.e., 1.5.0 -> 1.5.1). Integration of one or more enhancements will result in a minor -version bump (i.e., 1.5.0 -> 1.6.0). \ No newline at end of file +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 diff --git a/pyproject.toml b/pyproject.toml index debe0c2..afe820f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "docformatter" -version = "1.5.1" +version = "1.6.0" description = "Formats docstrings to follow PEP 257" authors = ["Steven Myint"] maintainers = [ @@ -232,7 +232,6 @@ depends = py37, py38, py39, py310, py311, pypy3 description = run autoformatters and style checkers deps = charset_normalizer - docformatter pycodestyle pydocstyle pylint @@ -240,6 +239,8 @@ deps = toml untokenize commands = + pip install -U pip + pip install . docformatter --recursive {toxinidir}/src/docformatter pycodestyle --ignore=E203,W503,W504 {toxinidir}/src/docformatter pydocstyle {toxinidir}/src/docformatter diff --git a/src/docformatter/__pkginfo__.py b/src/docformatter/__pkginfo__.py index 45d4c25..6245457 100644 --- a/src/docformatter/__pkginfo__.py +++ b/src/docformatter/__pkginfo__.py @@ -23,4 +23,4 @@ # SOFTWARE. """Package information for docformatter.""" -__version__ = "1.5.1" +__version__ = "1.6.0" diff --git a/src/docformatter/format.py b/src/docformatter/format.py index 5195b91..ce7a3c3 100644 --- a/src/docformatter/format.py +++ b/src/docformatter/format.py @@ -370,6 +370,12 @@ def _do_format_docstring( if contents.lstrip().startswith(">>>"): return docstring + # Do not modify docstring if the only thing it contains is a link. + _links = _syntax.do_find_links(contents) + with contextlib.suppress(IndexError): + if _links[0][0] == 0 and _links[0][1] == len(contents): + return docstring + summary, description = _strings.split_summary_and_description(contents) # Leave docstrings with underlined summaries alone. @@ -379,9 +385,12 @@ def _do_format_docstring( ): return docstring - if not self.args.force_wrap and _syntax.is_some_sort_of_list( - summary, - self.args.non_strict, + if not self.args.force_wrap and ( + _syntax.is_some_sort_of_list( + summary, + self.args.non_strict, + ) + or _syntax.do_find_directives(summary) ): # Something is probably not right with the splitting. return docstring @@ -393,64 +402,123 @@ def _do_format_docstring( self.args.wrap_descriptions -= tab_compensation if description: - # Compensate for triple quotes by temporarily prepending 3 spaces. - # This temporary prepending is undone below. - initial_indent = ( - indentation - if self.args.pre_summary_newline - else 3 * " " + indentation - ) - pre_summary = ( - "\n" + indentation if self.args.pre_summary_newline else "" + return self._do_format_multiline_docstring( + indentation, + summary, + description, + open_quote, ) - summary = _syntax.wrap_summary( - _strings.normalize_summary(summary), + + return self._do_format_oneline_docstring( + indentation, + contents, + open_quote, + ) + + def _do_format_oneline_docstring( + self, + indentation: str, + contents: str, + open_quote: str, + ) -> str: + """Format one line docstrings. + + Parameters + ---------- + indentation : str + The indentation to use for each line. + contents : str + The contents of the original docstring. + open_quote : str + The type of quote used by the original docstring. Selected from + QUOTE_TYPES. + + Returns + ------- + formatted_docstring : str + The formatted docstring. + """ + if self.args.make_summary_multi_line: + beginning = f"{open_quote}\n{indentation}" + ending = f'\n{indentation}"""' + summary_wrapped = _syntax.wrap_summary( + _strings.normalize_summary(contents), wrap_length=self.args.wrap_summaries, - initial_indent=initial_indent, + initial_indent=indentation, subsequent_indent=indentation, - ).lstrip() - description = _syntax.wrap_description( - description, - indentation=indentation, - wrap_length=self.args.wrap_descriptions, - force_wrap=self.args.force_wrap, - strict=self.args.non_strict, - ) - post_description = "\n" if self.args.post_description_blank else "" - return f'''\ + ).strip() + return f"{beginning}{summary_wrapped}{ending}" + else: + summary_wrapped = _syntax.wrap_summary( + open_quote + _strings.normalize_summary(contents) + '"""', + wrap_length=self.args.wrap_summaries, + initial_indent=indentation, + subsequent_indent=indentation, + ).strip() + if self.args.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 + + def _do_format_multiline_docstring( + self, + indentation: str, + summary: str, + description: str, + open_quote: str, + ) -> str: + """Format multiline docstrings. + + Parameters + ---------- + indentation : str + The indentation to use for each line. + summary : str + The summary from the original docstring. + description : str + The long description from the original docstring. + open_quote : str + The type of quote used by the original docstring. Selected from + QUOTE_TYPES. + + Returns + ------- + formatted_docstring : str + The formatted docstring. + """ + # Compensate for triple quotes by temporarily prepending 3 spaces. + # This temporary prepending is undone below. + initial_indent = ( + indentation + if self.args.pre_summary_newline + else 3 * " " + indentation + ) + pre_summary = ( + "\n" + indentation if self.args.pre_summary_newline else "" + ) + summary = _syntax.wrap_summary( + _strings.normalize_summary(summary), + wrap_length=self.args.wrap_summaries, + initial_indent=initial_indent, + subsequent_indent=indentation, + ).lstrip() + description = _syntax.wrap_description( + description, + indentation=indentation, + wrap_length=self.args.wrap_descriptions, + force_wrap=self.args.force_wrap, + strict=self.args.non_strict, + ) + post_description = "\n" if self.args.post_description_blank else "" + return f'''\ {open_quote}{pre_summary}{summary} {description}{post_description} {indentation}"""\ ''' - else: - if not self.args.make_summary_multi_line: - summary_wrapped = _syntax.wrap_summary( - open_quote + _strings.normalize_summary(contents) + '"""', - wrap_length=self.args.wrap_summaries, - initial_indent=indentation, - subsequent_indent=indentation, - ).strip() - if ( - self.args.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 = _syntax.wrap_summary( - _strings.normalize_summary(contents), - wrap_length=self.args.wrap_summaries, - initial_indent=indentation, - subsequent_indent=indentation, - ).strip() - return f"{beginning}{summary_wrapped}{ending}" @staticmethod def _do_remove_blank_lines_after_method(modified_tokens): diff --git a/src/docformatter/strings.py b/src/docformatter/strings.py index a5f9e09..4357254 100644 --- a/src/docformatter/strings.py +++ b/src/docformatter/strings.py @@ -70,11 +70,11 @@ def is_probably_beginning_of_sentence(line): """ # Check heuristically for a parameter list. for token in ["@", "-", r"\*"]: - if re.search(r"\s" + token + r"\s", line): + if re.search(rf"\s{token}\s", line): return True stripped_line = line.strip() - is_beginning_of_sentence = re.match(r'[^\w"\'`\(\)]', stripped_line) + is_beginning_of_sentence = re.match(r"^[-@\)]", stripped_line) is_pydoc_ref = re.match(r"^:\w+:", stripped_line) return is_beginning_of_sentence and not is_pydoc_ref @@ -97,9 +97,9 @@ def normalize_line_endings(lines, newline): return "".join([normalize_line(line, newline) for line in lines]) -def normalize_summary(summary): +def normalize_summary(summary: str) -> str: """Return normalized docstring summary.""" - # remove trailing whitespace + # Remove trailing whitespace summary = summary.rstrip() # Add period at end of sentence and capitalize the first word of the @@ -162,16 +162,11 @@ def split_summary_and_description(contents): split_lines = contents.rstrip().splitlines() for index in range(1, len(split_lines)): - found = False - - # Empty line separation would indicate the rest is the description or, + # Empty line separation would indicate the rest is the description or # symbol on second line probably is a description with a list. if not split_lines[index].strip() or is_probably_beginning_of_sentence( split_lines[index] ): - found = True - - if found: return ( "\n".join(split_lines[:index]).strip(), "\n".join(split_lines[index:]).rstrip(), diff --git a/src/docformatter/syntax.py b/src/docformatter/syntax.py index 2a5b536..711400c 100644 --- a/src/docformatter/syntax.py +++ b/src/docformatter/syntax.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# Copyright (C) 2012-2022 Steven Myint +# Copyright (C) 2012-2023 Steven Myint # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -23,13 +23,16 @@ # SOFTWARE. """This module provides docformatter's Syntaxor class.""" + # Standard Library Imports +import contextlib import re import textwrap from typing import Iterable, List, Tuple, Union -# These are the URL pattern to look for when finding links and is based on the -# table at +REST_REGEX = r"(\.{2}|``) ?[\w-]+(:{1,2}|``)?" +"""The regular expression to use for finding reST directives.""" + URL_PATTERNS = ( "afp|" "apt|" @@ -76,24 +79,55 @@ "xmpp|" "xri" ) +"""The URL patterns to look for when finding links. + +Based on the table at + +""" # This is the regex used to find URL links: # -# (`[\w.:]+|\.\._?[\w:]+)? is used to find in-line links that should remain -# on a single line even if it exceeds the wrap length. -# `[\w.:]+ matches the character ` followed by any number of letters, -# periods, spaces, or colons. -# \.\._?[\w:]+ matches the pattern .. followed by zero or one underscore, -# then any number of letters, periods, spaces, or colons. +# (`{{2}}|`\w[\w. :\n]*|\.\. _?[\w. :]+|')? is used to find in-line links that +# should remain on a single line even if it exceeds the wrap length. +# `{{2}} is used to find two back-tick characters. +# This finds patterns like: ``http://www.example.com`` +# +# `\w[\w. :#\n]* matches the back-tick character immediately followed by one +# letter, then followed by any number of letters, periods, spaces, colons, +# hash marks or newlines. +# This finds patterns like: `Link text `_ +# +# \.\. _?[\w. :]+ matches the pattern .. followed one space, then by zero or +# one underscore, then any number of letters, periods, spaces, or colons. +# This finds patterns like: .. _a link: https://domain.invalid/ +# +# ' matches a single quote. +# This finds patterns like: 'http://www.example.com' +# # ? matches the previous pattern between zero or one times. +# # ? is used to find the actual link. -# < ? matches the character < between zero and one times. -# ({URL_PATTERNS}):(//)? matches one of the strings in the variable -# URL_PATTERNS, followed by a colon and two forward slashes zero or one time. -# (\S*) matches any non-whitespace character between zero and unlimited -# times. +# ? matches the character > between zero and one times. -URL_REGEX = rf"(`[\w. :]+|\.\. _?[\w :]+)??" +URL_REGEX = rf"(`{{2}}|`\w[\w. :#\n]*|\.\. _?[\w. :]+|')??" + +URL_SKIP_REGEX = rf"({URL_PATTERNS}):(/){{0,2}}(``|')" +"""The regex used to ignore found hyperlinks. + +URLs that don't actually contain a domain, but only the URL pattern should +be treated like simple text. This will ignore URLs like ``http://`` or 'ftp:`. + +({URL_PATTERNS}) matches one of the URL patterns. +:(/){{0,2}} matches a colon followed by up to two forward slashes. +(``|') matches a double back-tick or single quote. +""" + HEURISTIC_MIN_LIST_ASPECT_RATIO = 0.4 @@ -116,7 +150,7 @@ def description_to_list( Returns ------- - lines : list + _lines : list A list containing each line of the description with any links put back together. """ @@ -130,8 +164,8 @@ def description_to_list( ) # This is a description containing multiple paragraphs. - lines = [] - for _line in text.splitlines(): + _lines = [] + for _line in text.split("\n\n"): _text = textwrap.wrap( textwrap.dedent(_line), width=wrap_length, @@ -139,11 +173,67 @@ def description_to_list( subsequent_indent=indentation, ) if _text: - lines.extend(_text) - else: - lines.append("") + _lines.extend(_text) + _lines.append("") + + return _lines + + +def do_clean_url(url: str, indentation: str) -> str: + r"""Strip newlines and multiple whitespace from URL string. + + This function deals with situations such as: + + `Get\n + Cookies.txt bool: + """Determine if docstring contains any reST directives. + + .. todo:: + + Currently this function only returns True/False to indicate whether a + reST directive was found. Should return a list of tuples containing + the start and end position of each reST directive found similar to the + function do_find_links(). + + Parameters + ---------- + text : str + The docstring text to test. + + Returns + ------- + is_directive : bool + Whether the docstring is a reST directive. + """ + _rest_iter = re.finditer(REST_REGEX, text) + return bool([(rest.start(0), rest.end(0)) for rest in _rest_iter]) def do_find_links(text: str) -> List[Tuple[int, int]]: @@ -151,12 +241,12 @@ def do_find_links(text: str) -> List[Tuple[int, int]]: Parameters ---------- - text: str + text : str the docstring description to check for a link patterns. Returns ------- - url_index: list + url_index : list a list of tuples with each tuple containing the starting and ending position of each URL found in the passed description. """ @@ -164,6 +254,36 @@ def do_find_links(text: str) -> List[Tuple[int, int]]: return [(_url.start(0), _url.end(0)) for _url in _url_iter] +def do_skip_link(text: str, index: Tuple[int, int]) -> bool: + """Check if the identified URL is something other than a complete link. + + Is the identified link simply: + 1. The URL scheme pattern such as 's3://' or 'file://' or 'dns:'. + 2. The beginning of a URL link that has been wrapped by the user. + + Arguments + --------- + text : str + The description text containing the link. + index : tuple + The index in the text of the starting and ending position of the + identified link. + + Returns + ------- + _do_skip : bool + Whether to skip this link and simply treat it as a standard text word. + """ + _do_skip = re.search(URL_SKIP_REGEX, text[index[0] : index[1]]) is not None + + with contextlib.suppress(IndexError): + _do_skip = _do_skip or ( + text[index[0]] == "<" and text[index[1]] != ">" + ) + + return _do_skip + + def do_split_description( text: str, indentation: str, @@ -189,36 +309,54 @@ def do_split_description( """ # Check if the description contains any URLs. _url_idx = do_find_links(text) - if _url_idx: - _lines = [] - _text_idx = 0 - for _idx in _url_idx: - # If the text including the URL is longer than the wrap length, - # we need to split the description before the URL, wrap the pre-URL - # text, and add the URL as a separate line. - if len(text[_text_idx : _idx[1]]) > ( - wrap_length - len(indentation) - ): - # Wrap everything in the description before the first URL. - _lines.extend( - description_to_list( - text[_text_idx : _idx[0]], indentation, wrap_length - ) + if not _url_idx: + return description_to_list( + text, + indentation, + wrap_length, + ) + _lines = [] + _text_idx = 0 + for _idx in _url_idx: + # Skip URL if it is simply a quoted pattern. + if do_skip_link(text, _idx): + continue + + # If the text including the URL is longer than the wrap length, + # we need to split the description before the URL, wrap the pre-URL + # text, and add the URL as a separate line. + if len(text[_text_idx : _idx[1]]) > ( + wrap_length - len(indentation) + ): + # Wrap everything in the description before the first URL. + _lines.extend( + description_to_list( + text[_text_idx : _idx[0]], + indentation, + wrap_length, ) - # Add the URL. - _lines.append(f"{indentation}{text[_idx[0]:_idx[1]].strip()}") - _text_idx = _idx[1] + ) - # Finally, add everything after the last URL. - _lines.append(f"{indentation}{text[_text_idx:].strip()}") + # Add the URL. + _lines.append( + f"{do_clean_url(text[_idx[0] : _idx[1]], indentation)}" + ) - return _lines - else: - return description_to_list(text, indentation, wrap_length) + _text_idx = _idx[1] + + # Finally, add everything after the last URL. + with contextlib.suppress(IndexError): + _stripped_text = ( + text[_text_idx + 1 :].strip(indentation) + if text[_text_idx] == "\n" + else text[_text_idx:].strip() + ) + _lines.append(f"{indentation}{_stripped_text}") + return _lines # pylint: disable=line-too-long -def is_some_sort_of_list(text, strict) -> bool: +def is_some_sort_of_list(text: str, strict: bool) -> bool: """Determine if docstring is a reST list. Notes @@ -366,7 +504,11 @@ def wrap_description(text, indentation, wrap_length, force_wrap, strict): # Ignore possibly complicated cases. if wrap_length <= 0 or ( not force_wrap - and (is_some_sort_of_code(text) or is_some_sort_of_list(text, strict)) + and ( + is_some_sort_of_code(text) + or do_find_directives(text) + or is_some_sort_of_list(text, strict) + ) ): return text diff --git a/src/docformatter/util.py b/src/docformatter/util.py index b78376d..4dd2772 100644 --- a/src/docformatter/util.py +++ b/src/docformatter/util.py @@ -43,7 +43,6 @@ def find_py_files(sources, recursive, exclude=None): Return: yields paths to found files. """ - def not_hidden(name): """Return True if file 'name' isn't .hidden.""" return not name.startswith(".") diff --git a/tests/test_docformatter.py b/tests/test_docformatter.py index 772af31..1a6d01b 100644 --- a/tests/test_docformatter.py +++ b/tests/test_docformatter.py @@ -462,7 +462,9 @@ def test_end_to_end_keep_rest_link_one_line( def foo(): """Description from issue #150 that was being improperly wrapped. - The text file can be retrieved via the Chrome plugin `Get Cookies.txt ` while browsing""" + The text file can be retrieved via the Chrome plugin `Get + Cookies.txt ` while browsing.""" ''' ], ) @@ -475,26 +477,28 @@ def foo(): ] ], ) - def test_end_to_end_keep_in_line_link_one_line( + def test_ignore_already_wrapped_link( self, run_docformatter, temporary_file, arguments, contents, ): - """Keep in-line URL link on one line. + """Ignore a URL link that was wrapped by the user. - See issue #150. See requirement docformatter_10.1.3.1. + See issue #150. """ assert '''\ -@@ -1,4 +1,7 @@ +@@ -1,6 +1,7 @@ def foo(): """Description from issue #150 that was being improperly wrapped. -- The text file can be retrieved via the Chrome plugin `Get Cookies.txt ` while browsing""" +- The text file can be retrieved via the Chrome plugin `Get +- Cookies.txt ` while browsing.""" + The text file can be retrieved via the Chrome plugin -+ `Get Cookies.txt ` -+ while browsing ++ `Get Cookies.txt ` while browsing. + """ ''' == "\n".join( run_docformatter.communicate()[0] diff --git a/tests/test_format_docstring.py b/tests/test_format_docstring.py index 88d6534..3f01b43 100644 --- a/tests/test_format_docstring.py +++ b/tests/test_format_docstring.py @@ -287,6 +287,49 @@ def test_format_docstring_leave_underlined_summaries_alone( """''' assert docstring == uut._do_format_docstring(INDENTATION, docstring) + @pytest.mark.unit + @pytest.mark.parametrize("args", [[""]]) + def test_format_docstring_leave_directive_alone(self, test_args, args): + """Leave docstrings that have a reST directive in the summary alone.""" + uut = Formatter( + test_args, + sys.stderr, + sys.stdin, + sys.stdout, + ) + + docstring = ''' + """.. code-block:: shell-session + + ► apm --version + apm 2.6.2 + npm 6.14.13 + node 12.14.1 x64 + atom 1.58.0 + python 2.7.16 + git 2.33.0 + """''' + assert docstring == uut._do_format_docstring(INDENTATION, docstring) + + @pytest.mark.unit + @pytest.mark.parametrize("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, + sys.stderr, + sys.stdin, + sys.stdout, + ) + + docstring = '''""" + `Source of this snippet + `_. + """''' + assert docstring == uut._do_format_docstring(INDENTATION, docstring) + class TestFormatLists: """Class for testing format_docstring() with lists in the docstring.""" @@ -540,14 +583,17 @@ def test_format_docstring_with_description_wrapping( '''.strip(), ) - @pytest.mark.unit + @pytest.mark.xfail @pytest.mark.parametrize("args", [["--wrap-descriptions", "72", ""]]) def test_format_docstring_should_ignore_multi_paragraph( self, test_args, args, ): - """Ignore multiple paragraphs in elaborate description.""" + """Ignore multiple paragraphs in elaborate description. + + Multiple description paragraphs is supported since v1.5.0. + """ uut = Formatter( test_args, sys.stderr, @@ -858,19 +904,56 @@ def test_format_docstring_with_short_inline_link( docstring = '''\ """This is yanf with a short link. - See `the link `_ for more details. """\ ''' assert '''\ """This is yanf with a short link. - See `the link `_ for more details. """\ ''' == uut._do_format_docstring( INDENTATION, docstring.strip() ) + @pytest.mark.unit + @pytest.mark.parametrize( + "args", + [["--wrap-descriptions", "72", ""]], + ) + def test_format_docstring_with_short_inline_link( + self, + test_args, + args, + ): + """Should move long in-line links to line by themselves.""" + uut = Formatter( + test_args, + sys.stderr, + sys.stdin, + sys.stdout, + ) + + docstring = '''\ + """Helpful docstring. + + A larger description that starts here. https://github.com/apache/kafka/blob/2.5/clients/src/main/java/org/apache/kafka/common/requests/DescribeConfigsResponse.java + A larger description that ends here. + """\ +''' + + assert '''\ +"""Helpful docstring. + + A larger description that starts here. + https://github.com/apache/kafka/blob/2.5/clients/src/main/java/org/apache/kafka/common/requests/DescribeConfigsResponse.java + A larger description that ends here. + """\ +''' == uut._do_format_docstring( + INDENTATION, docstring.strip() + ) + @pytest.mark.unit @pytest.mark.parametrize( "args", @@ -947,6 +1030,24 @@ def test_format_docstring_with_target_links( INDENTATION, docstring.strip() ) + docstring = '''\ +""" + + .. _linspace API: https://numpy.org/doc/stable/reference/generated/numpy.linspace.html + .. _arange API: https://numpy.org/doc/stable/reference/generated/numpy.arange.html + .. _logspace API: https://numpy.org/doc/stable/reference/generated/numpy.logspace.html + """\ +''' + assert '''\ +""" + + .. _linspace API: https://numpy.org/doc/stable/reference/generated/numpy.linspace.html + .. _arange API: https://numpy.org/doc/stable/reference/generated/numpy.arange.html + .. _logspace API: https://numpy.org/doc/stable/reference/generated/numpy.logspace.html + """\ +''' == uut._do_format_docstring( + INDENTATION, docstring.strip() + ) @pytest.mark.unit @pytest.mark.parametrize( "args", @@ -986,6 +1087,97 @@ def test_format_docstring_with_simple_link( INDENTATION, docstring.strip() ) + @pytest.mark.unit + @pytest.mark.parametrize( + "args", + [["--wrap-descriptions", "88", ""]], + ) + def test_format_docstring_keep_inline_link_together( + self, + test_args, + args, + ): + """Keep in-line links together with the display text. + + See issue #157. + """ + uut = Formatter( + test_args, + sys.stderr, + sys.stdin, + sys.stdout, + ) + + docstring = '''\ + """Get the Python type of a Click parameter. + + See the list of `custom types provided by Click + `_. + """\ + ''' + + assert '''\ +"""Get the Python type of a Click parameter. + + See the list of + `custom types provided by Click `_. + """\ +''' == uut._do_format_docstring( + INDENTATION, docstring.strip() + ) + + @pytest.mark.unit + @pytest.mark.parametrize( + "args", + [["--wrap-descriptions", "88", ""]], + ) + def test_format_docstring_keep_inline_link_together_two_paragraphs( + self, + test_args, + args, + ): + """Keep in-line links together with the display text. + + If there is another paragraph following the in-line link, don't strip + the newline in between. + + See issue #157. + """ + uut = Formatter( + test_args, + sys.stderr, + sys.stdin, + sys.stdout, + ) + + docstring = '''\ +"""Fetch parameters values from configuration file and merge them with the + defaults. + + User configuration is `merged to the context default_map as Click does + `_. + + This allow user's config to only overrides defaults. Values sets from direct + command line parameters, environment variables or interactive prompts, takes + precedence over any values from the config file. +"""\ +''' + + assert '''\ +"""Fetch parameters values from configuration file and merge them with the + defaults. + + User configuration is + `merged to the context default_map as Click does `_. + + This allow user\'s config to only overrides defaults. Values sets from direct + command line parameters, environment variables or interactive prompts, takes + precedence over any values from the config file. + """\ +''' == uut._do_format_docstring( + INDENTATION, docstring.strip() + ) + @pytest.mark.unit @pytest.mark.parametrize( "args", diff --git a/tests/test_string_functions.py b/tests/test_string_functions.py index 84f4f9f..0a155b2 100644 --- a/tests/test_string_functions.py +++ b/tests/test_string_functions.py @@ -3,7 +3,7 @@ # # tests.test_string_functions.py is part of the docformatter project # -# Copyright (C) 2012-2019 Steven Myint +# Copyright (C) 2012-2023 Steven Myint # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -377,7 +377,7 @@ def test_split_summary_and_description_with_quote(self): ) @pytest.mark.unit - def test_split_summary_and_description_with_late__punctuation(self): + def test_split_summary_and_description_with_punctuation(self): """""" assert ( ( @@ -402,7 +402,7 @@ def test_split_summary_and_description_with_late__punctuation(self): ) @pytest.mark.unit - def test_split_summary_and_description_without__punctuation(self): + def test_split_summary_and_description_without_punctuation(self): """""" assert ( ( @@ -440,6 +440,23 @@ def test_split_summary_and_description_with_abbreviation(self): text ) + @pytest.mark.unit + def test_split_summary_and_description_with_url(self): + """Retain URL on second line with summary.""" + text = '''\ +"""Sequence of package managers as defined by `XKCD #1654: Universal Install Script +`_. + +See the corresponding :issue:`implementation rationale in issue #10 <10>`. +"""\ +''' + assert ( + '"""Sequence of package managers as defined by `XKCD #1654: Universal Install Script\n' + "`_.", + "\nSee the corresponding :issue:`implementation rationale in issue #10 <10>`." + '\n"""', + ) == docformatter.split_summary_and_description(text) + class TestStrippers: """Class for testing the string stripping functions. diff --git a/tests/test_syntax_functions.py b/tests/test_syntax_functions.py new file mode 100644 index 0000000..235bb45 --- /dev/null +++ b/tests/test_syntax_functions.py @@ -0,0 +1,161 @@ +# pylint: skip-file +# type: ignore +# +# tests.test_syntax_functions.py is part of the docformatter project +# +# Copyright (C) 2012-2023 Steven Myint +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +"""Module for testing functions that deal with syntax. + +This module contains tests for syntax functions. Syntax functions are +those: + + do_clean_link() + do_find_directives() + do_find_links() + do_skip_link() +""" + +# Third Party Imports +import pytest + +# docformatter Package Imports +import docformatter + + +class TestURLHandlers: + """Class for testing the URL handling functions. + + Includes tests for: + + - do_clean_link() + - do_find_links() + - do_skip_link() + """ + + @pytest.mark.unit + def test_find_in_line_link(self): + """Should find link pattern in a text block.""" + assert [(53, 162)] == docformatter.do_find_links( + "The text file can be retrieved via the Chrome plugin `Get \ +Cookies.txt ` while browsing." + ) + assert [(95, 106), (110, 123)] == docformatter.do_find_links( + "``pattern`` is considered as an URL only if it is parseable as such\ + and starts with ``http://`` or ``https://``." + ) + + @pytest.mark.unit + def test_skip_link_with_manual_wrap(self): + """Should skip a link that has been manually wrapped by the user.""" + assert docformatter.do_skip_link( + "``pattern`` is considered as an URL only if it is parseable as such\ + and starts with ``http://`` or ``https://``.", + (95, 106), + ) + assert docformatter.do_skip_link( + "``pattern`` is considered as an URL only if it is parseable as such\ + and starts with ``http://`` or ``https://``.", + (110, 123), + ) + + @pytest.mark.unit + def test_do_clean_link(self): + """Should remove line breaks from links.""" + assert ( + " `Get Cookies.txt `" + ) == docformatter.do_clean_url( + "`Get \ +Cookies.txt `", + " ", + ) + + assert ( + " `custom types provided by Click `_." + ) == docformatter.do_clean_url( + "`custom types provided by Click\ + `_.", + " ", + ) + + +class TestreSTHandlers: + """Class for testing the reST directive handling functions. + + Includes tests for: + + - do_find_directives() + """ + + @pytest.mark.unit + def test_find_in_line_directives(self): + """Should find reST directieves in a text block.""" + assert docformatter.do_find_directives( + "These are some reST directives that need to be retained even if it means not wrapping the line they are found on.\ + Constructs and returns a :class:`QuadraticCurveTo `.\ + Register ``..click:example::`` and ``.. click:run::`` directives, augmented with ANSI coloring." + ) + + @pytest.mark.unit + def test_find_double_dot_directives(self): + """Should find reST directives preceeded by ..""" + assert docformatter.do_find_directives( + ".. _linspace API: https://numpy.org/doc/stable/reference/generated/numpy.linspace.html\ + .. _arange API: https://numpy.org/doc/stable/reference/generated/numpy.arange.html\ + .. _logspace API: https://numpy.org/doc/stable/reference/generated/numpy.logspace.html" + ) + + assert docformatter.do_find_directives( + "``pattern`` is considered as an URL only if it is parseable as such" + "and starts with ``http://`` or ``https://``." + "" + ".. important::" + "" + "This is a straight `copy of the functools.cache implementation" + "`_," + "hich is only `available in the standard library starting with Python v3.9" + "`." + ) + + @pytest.mark.unit + def test_find_double_backtick_directives(self): + """Should find reST directives preceeded by ``.""" + assert docformatter.do_find_directives( + "By default we choose to exclude:" + "" + "``Cc``" + " Since ``mailman`` apparently `sometimes trims list members" + " `_" + " from the ``Cc`` header to avoid sending duplicates. Which means that copies of mail" + " reflected back from the list server will have a different ``Cc`` to the copy saved by" + " the MUA at send-time." + "" + "``Bcc``" + " Because copies of the mail saved by the MUA at send-time will have ``Bcc``, but copies" + " reflected back from the list server won't." + "" + "``Reply-To``" + " Since a mail could be ``Cc``'d to two lists with different ``Reply-To`` munging" + "options set." + ) diff --git a/tests/test_utility_functions.py b/tests/test_utility_functions.py index 065248c..4a4b243 100644 --- a/tests/test_utility_functions.py +++ b/tests/test_utility_functions.py @@ -377,6 +377,37 @@ def test_do_find_initiation_link(self): assert docformatter.do_find_links(text) == [(58, 125)] +class TestDoSkipLink: + """Class for testing the do_skip_links() function.""" + + @pytest.mark.unit + def test_do_skip_only_link_pattern(self): + """Don't treat things like 's3://' or 'file://' as links. + + See issue #150. + """ + text = ( + "Directories are implicitly created. The accepted URL can start " + "with 's3://' to refer to files on s3. Local files can be " + "prefixed with 'file://' (but it is not needed!)" + ) + assert docformatter.do_skip_link(text, (70, 76)) + assert docformatter.do_skip_link(text, (137, 145)) + + @pytest.mark.unit + def test_do_skip_already_wrapped_link(self): + """Skip links that were already wrapped by the user. + + See issue #150. + """ + text = ( + "The text file can be retrieved via the Chrome plugin `Get " + "Cookies.txt ` while browsing." + ) + assert docformatter.do_skip_link(text, (70, 117)) + + class TestIsSomeSortOfList: """Class for testing the is_some_sort_of_list() function."""