Repository: PyCQA/docformatter
Pull request #91 (2022-08-03 01:44:53)

fix: add support for raw and unicode docstrings
This adds support for raw and unicode docstrings.  The type identifier can be either lower case or upper case (r, R, u, U).  The formatted docstring will retain the type identifier in the same case.

Cleaned up some linting and style warnings.

Closes #54.


Patch:
diff --git a/docformatter.py b/docformatter.py
index 07ef887..582aacf 100755
--- a/docformatter.py
+++ b/docformatter.py
@@ -24,6 +24,7 @@
 
 """Formats docstrings to follow PEP 257."""
 
+
 from __future__ import (
     absolute_import,
     division,
@@ -33,6 +34,7 @@ from __future__ import (
 
 # Standard Library Imports
 import collections
+import contextlib
 import io
 import locale
 import os
@@ -42,6 +44,7 @@ import sys
 import sysconfig
 import textwrap
 import tokenize
+from typing import Tuple
 
 # Third Party Imports
 import untokenize
@@ -57,9 +60,7 @@ except ImportError:
 __version__ = "1.4"
 
 
-try:
-    unicode
-except NameError:
+if sys.version_info.major == 3:
     unicode = str
 
 
@@ -68,10 +69,23 @@ HEURISTIC_MIN_LIST_ASPECT_RATIO = 0.4
 CR = "\r"
 LF = "\n"
 CRLF = "\r\n"
-QUOTE_TYPES = (
+STR_QUOTE_TYPES = (
     '"""',
     "'''",
 )
+RAW_QUOTE_TYPES = (
+    'r"""',
+    'R"""',
+    "r'''",
+    "R'''",
+)
+UCODE_QUOTE_TYPES = (
+    'u"""',
+    'U"""',
+    "u'''",
+    "U'''",
+)
+QUOTE_TYPES = STR_QUOTE_TYPES + RAW_QUOTE_TYPES + UCODE_QUOTE_TYPES
 
 _PYTHON_LIBS = set(sysconfig.get_paths().values())
 
@@ -121,7 +135,7 @@ def reindent(text, indentation):
 
 
 def _find_shortest_indentation(lines):
-    """Return most shortest indentation."""
+    """Return shortest indentation."""
     assert not isinstance(lines, str)
 
     indentation = None
@@ -244,7 +258,7 @@ def format_docstring(
         - Unless the entire docstring fits on a line, place the closing quotes
           on a line by themselves.
     """
-    contents = strip_docstring(docstring)
+    contents, open_quote = strip_docstring(docstring)
 
     # Skip if there are nested triple double quotes
     if contents.count(QUOTE_TYPES[0]):
@@ -271,11 +285,12 @@ def format_docstring(
             indentation if pre_summary_newline else 3 * " " + indentation
         )
         return '''\
-"""{pre_summary}{summary}
+{open_quote}{pre_summary}{summary}
 
 {description}{post_description}
 {indentation}"""\
 '''.format(
+            open_quote=open_quote,
             pre_summary=("\n" + indentation if pre_summary_newline else ""),
             summary=wrap_summary(
                 normalize_summary(summary),
@@ -295,7 +310,7 @@ def format_docstring(
     else:
         if not make_summary_multi_line:
             return wrap_summary(
-                '"""' + normalize_summary(contents) + '"""',
+                open_quote + normalize_summary(contents) + '"""',
                 wrap_length=summary_wrap_length,
                 initial_indent=indentation,
                 subsequent_indent=indentation,
@@ -338,11 +353,11 @@ def split_summary_and_description(contents):
     for index in range(1, len(split_lines)):
         found = False
 
-        if not split_lines[index].strip():
-            # Empty line separation would indicate the rest is the description.
-            found = True
-        elif is_probably_beginning_of_sentence(split_lines[index]):
-            # Symbol on second line probably is a description with a list.
+        # 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:
@@ -360,7 +375,7 @@ def split_summary_and_description(contents):
             + split[1].strip(),
         )
 
-    return (contents, "")
+    return contents, ""
 
 
 def split_first_sentence(text):
@@ -399,7 +414,7 @@ def split_first_sentence(text):
         previous_delimiter = delimiter
         delimiter = ""
 
-    return (sentence, delimiter + rest)
+    return sentence, delimiter + rest
 
 
 def is_some_sort_of_list(text):
@@ -421,7 +436,7 @@ def is_some_sort_of_list(text):
             re.match(r"\s*$", line)
             or
             # "1. item"
-            re.match(r"\s*[0-9]\.", line)
+            re.match(r"\s*\d\.", line)
             or
             # "@parameter"
             re.match(r"\s*[\-*:=@]", line)
@@ -482,22 +497,42 @@ def normalize_line_endings(lines, newline):
     return "".join([normalize_line(line, newline) for line in lines])
 
 
-def strip_docstring(docstring: str) -> str:
-    """Return contents of docstring.
+def strip_docstring(docstring: str) -> Tuple[str, str]:
+    """Return contents of docstring and opening quote type.
+
+    Strips the docstring of its triple quotes, trailing white space,
+    and line returns.  Determines type of docstring quote (either string,
+    raw, or unicode) and returns the opening quotes, including the type
+    identifier, with single quotes replaced by double quotes.
+
+    Parameters
+    ----------
+    docstring: str
+        The docstring, including the opening and closing triple quotes.
+
+    Returns
+    -------
+    (docstring, open_quote) : tuple
+        The docstring with the triple quotes removed.
+        The opening quote type with single quotes replaced by double quotes.
 
-    :param docstring: the docstring, including the opening and closing triple
-        quotes.
-    :return: docstring with the triple quotes removed.
-    :rtype: str
     """
     docstring = docstring.strip()
 
     for quote in QUOTE_TYPES:
-        if docstring.startswith(quote) and docstring.endswith(quote):
-            return docstring.split(quote, 1)[1].rsplit(quote, 1)[0].strip()
+        if quote in RAW_QUOTE_TYPES + UCODE_QUOTE_TYPES and (
+            docstring.startswith(quote) and docstring.endswith(quote[1:])
+        ):
+            return docstring.split(quote, 1)[1].rsplit(quote[1:], 1)[
+                0
+            ].strip(), quote.replace("'", '"')
+        elif docstring.startswith(quote) and docstring.endswith(quote):
+            return docstring.split(quote, 1)[1].rsplit(quote, 1)[
+                0
+            ].strip(), quote.replace("'", '"')
 
     raise ValueError(
-        "docformatter only handles strings that start with triple quotes"
+        "docformatter only handles triple-quoted (single or double) strings"
     )
 
 
@@ -624,8 +659,8 @@ def detect_encoding(filename):
             encoding = lib2to3_tokenize.detect_encoding(input_file.readline)[0]
 
             # Check for correctness of encoding.
-            with open_with_encoding(filename, encoding) as input_file:
-                input_file.read()
+            with open_with_encoding(filename, encoding) as check_file:
+                check_file.read()
 
         return encoding
     except (SyntaxError, LookupError, UnicodeDecodeError):
@@ -729,13 +764,13 @@ def _main(argv, standard_out, standard_error, standard_in):
         "-i",
         "--in-place",
         action="store_true",
-        help="make changes to files instead of printing " "diffs",
+        help="make changes to files instead of printing diffs",
     )
     changes.add_argument(
         "-c",
         "--check",
         action="store_true",
-        help="only check and report incorrectly formatted " "files",
+        help="only check and report incorrectly formatted files",
     )
     parser.add_argument(
         "-r",
@@ -779,7 +814,7 @@ def _main(argv, standard_out, standard_error, standard_in):
         "--pre-summary-newline",
         action="store_true",
         default=bool(flargs.get("pre-summary-newline", False)),
-        help="add a newline before the summary of a " "multi-line docstring",
+        help="add a newline before the summary of a multi-line docstring",
     )
     parser.add_argument(
         "--make-summary-multi-line",
@@ -812,7 +847,7 @@ def _main(argv, standard_out, standard_error, standard_in):
         default=flargs.get("docstring-length", None),
         type=int,
         nargs=2,
-        help="apply docformatter to docstrings of given " "length range",
+        help="apply docformatter to docstrings of given length range",
     )
     parser.add_argument(
         "--version", action="version", version=f"%(prog)s {__version__}"
@@ -971,13 +1006,10 @@ def _format_files(args, standard_out, standard_error):
 
 def main():
     """Run main entry point."""
-    try:
+    # SIGPIPE is not available on Windows.
+    with contextlib.suppress(AttributeError):
         # Exit on broken pipe.
         signal.signal(signal.SIGPIPE, signal.SIG_DFL)
-    except AttributeError:  # pragma: no cover
-        # SIGPIPE is not available on Windows.
-        pass
-
     try:
         return _main(
             sys.argv,
diff --git a/pyproject.toml b/pyproject.toml
index a68a04c..eca3d45 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -47,7 +47,7 @@ mock = "^4.0.0"
 isort = "^5.7.0"
 black = [
     {version = "^22.0.0", python = ">=3.6.2"},
-    ]
+]
 
 [tool.poetry.scripts]
 docformatter = "docformatter:main"
@@ -180,7 +180,6 @@ deps =
     pylint
     toml
 commands =
-    pip install .[tomli]
     pycodestyle docformatter.py setup.py
     pydocstyle docformatter.py setup.py
     pylint docformatter.py setup.py
diff --git a/setup.py b/setup.py
index da7d983..854aff2 100755
--- a/setup.py
+++ b/setup.py
@@ -15,7 +15,7 @@ from setuptools import setup
 
 def version():
     """Return version string."""
-    with open('docformatter.py') as input_file:
+    with open('docformatter.py', encoding="ascii") as input_file:
         for line in input_file:
             if line.startswith('__version__'):
                 return ast.parse(line).body[0].value.s
@@ -24,7 +24,7 @@ def version():
 setup(name='docformatter',
       version=version(),
       description='Formats docstrings to follow PEP 257.',
-      long_description=Path('README.rst').read_text(),
+      long_description=Path('README.rst').read_text(encoding="ascii"),
       license='Expat License',
       author='Steven Myint',
       url='https://github.com/myint/docformatter',


Test patch:
diff --git a/test_docformatter.py b/test_docformatter.py
index b2c8f44..0e225e6 100755
--- a/test_docformatter.py
+++ b/test_docformatter.py
@@ -578,36 +578,130 @@ def foo():
     def test_method_no_chr_92(): the501(92) # \
 '''.lstrip()))
 
-    def test_format_code_skip_complex(self):
-        """We do not handle r/u/b prefixed strings."""
+    def test_format_code_raw_docstring_double_quotes(self):
+        """Should format raw docstrings with triple double quotes.
+
+        See requirement #2.  See issue #54 for request to handle raw
+        docstrings.
+        """
         self.assertEqual(
             '''\
+def foo():
+    r"""Hello foo."""
+''',
+            docformatter.format_code(
+                '''\
 def foo():
     r"""
     Hello foo.
     """
+'''))
+
+        self.assertEqual(
+        '''\
+def foo():
+    R"""Hello foo."""
 ''',
             docformatter.format_code(
-                '''\
+            '''\
 def foo():
-    r"""
+    R"""
     Hello foo.
     """
 '''))
 
-    def test_format_code_skip_complex_single(self):
-        """We do not handle r/u/b prefixed strings."""
+    def test_format_code_raw_docstring_single_quotes(self):
+        """Should format raw docstrings with triple single quotes.
+
+        See requirement #2.  See issue #54 for request to handle raw
+        docstrings.
+        """
         self.assertEqual(
-            """\
+            '''\
+def foo():
+    r"""Hello foo."""
+''',
+            docformatter.format_code(
+                """\
 def foo():
     r'''
     Hello foo.
     '''
-""",
+"""))
+
+        self.assertEqual(
+        '''\
+def foo():
+    R"""Hello foo."""
+''',
+           docformatter.format_code(
+            """\
+def foo():
+    R'''
+    Hello foo.
+    '''
+"""))
+
+    def test_format_code_unicode_docstring_double_quotes(self):
+        """Should format unicode docstrings with triple double quotes.
+
+        See requirement #3.  See issue #54 for request to handle raw
+        docstrings.
+        """
+        self.assertEqual(
+            '''\
+def foo():
+    u"""Hello foo."""
+''',
+            docformatter.format_code(
+                '''\
+def foo():
+    u"""
+    Hello foo.
+    """
+'''))
+
+        self.assertEqual(
+            '''\
+def foo():
+    U"""Hello foo."""
+''',
+            docformatter.format_code(
+                '''\
+def foo():
+    U"""
+    Hello foo.
+    """
+'''))
+
+    def test_format_code_unicode_docstring_single_quotes(self):
+        """Should format unicode docstrings with triple single quotes.
+
+        See requirement #3.  See issue #54 for request to handle raw
+        docstrings.
+        """
+        self.assertEqual(
+            '''\
+def foo():
+    u"""Hello foo."""
+''',
+            docformatter.format_code(
+                """\
+def foo():
+    u'''
+    Hello foo.
+    '''
+"""))
+
+        self.assertEqual(
+            '''\
+def foo():
+    U"""Hello foo."""
+''',
             docformatter.format_code(
                 """\
 def foo():
-    r'''
+    U'''
     Hello foo.
     '''
 """))
diff --git a/tests/test_string_functions.py b/tests/test_string_functions.py
index 57a8d24..87163f8 100644
--- a/tests/test_string_functions.py
+++ b/tests/test_string_functions.py
@@ -150,7 +150,6 @@ The below should be indented with spaces:
         )
 
 
-
 class TestNormalizers:
     """Class for testing the string normalizing functions.
 
@@ -452,7 +451,7 @@ class TestStrippers:
     @pytest.mark.unit
     def test_strip_docstring(self):
         """Strip triple double quotes from docstring."""
-        assert "Hello." == docformatter.strip_docstring(
+        docstring, open_quote = docformatter.strip_docstring(
             '''
     """Hello.
 
@@ -460,11 +459,13 @@ class TestStrippers:
 
     '''
         )
+        assert docstring == "Hello."
+        assert open_quote == '"""'
 
     @pytest.mark.unit
     def test_strip_docstring_with_single_quotes(self):
         """Strip triple single quotes from docstring."""
-        assert "Hello." == docformatter.strip_docstring(
+        docstring, open_quote == docformatter.strip_docstring(
             """
     '''Hello.
 
@@ -472,17 +473,37 @@ class TestStrippers:
 
     """
         )
+        assert docstring == "Hello."
+        assert open_quote == '"""'
 
     @pytest.mark.unit
     def test_strip_docstring_with_empty_string(self):
         """Return series of six double quotes when passed empty string."""
-        assert "" == docformatter.strip_docstring('""""""')
+        docstring, open_quote = docformatter.strip_docstring('""""""')
+        assert docstring == ""
+        assert open_quote == '"""'
 
     @pytest.mark.unit
-    def test_strip_docstring_with_unhandled(self):
-        """Raise ValueError with raw docstrings."""
-        with pytest.raises(ValueError):
-            docformatter.strip_docstring('r"""foo"""')
+    def test_strip_docstring_with_raw_string(self):
+        """Return docstring and raw open quote."""
+        docstring, open_quote = docformatter.strip_docstring('r"""foo"""')
+        assert docstring == "foo"
+        assert open_quote == 'r"""'
+
+        docstring, open_quote = docformatter.strip_docstring("R'''foo'''")
+        assert docstring == "foo"
+        assert open_quote == 'R"""'
+
+    @pytest.mark.unit
+    def test_strip_docstring_with_unicode_string(self):
+        """Return docstring and unicode open quote."""
+        docstring, open_quote = docformatter.strip_docstring("u'''foo'''")
+        assert docstring == "foo"
+        assert open_quote == 'u"""'
+
+        docstring, open_quote = docformatter.strip_docstring('U"""foo"""')
+        assert docstring == "foo"
+        assert open_quote == 'U"""'
 
     @pytest.mark.unit
     def test_strip_docstring_with_unknown(self):
@@ -494,9 +515,8 @@ class TestStrippers:
     def test_strip_docstring_with_single_quotes(self):
         """Raise ValueError when strings begin with single single quotes.
 
-        See requirement 1, always use triple double quotes. See issue
-        #66 for example of docformatter breaking code when encountering
-        single quote.
+        See requirement #1.  See issue #66 for example of docformatter breaking
+        code when encountering single quote.
         """
         with pytest.raises(ValueError):
             docformatter.strip_docstring("'hello\\''")
@@ -505,9 +525,8 @@ class TestStrippers:
     def test_strip_docstring_with_double_quotes(self):
         """Raise ValueError when strings begin with single double quotes.
 
-        See requirement 1, always use triple double quotes. See issue
-        #66 for example of docformatter breaking code when encountering
-        single quote.
+        See requirement #1.  See issue #66 for example of docformatter
+        breaking code when encountering single quote.
         """
         with pytest.raises(ValueError):
             docformatter.strip_docstring('"hello\\""')