Repository: PyCQA/docformatter (python)

Issue:
Bug: Docformatter incorrectly detects first line as a docstring
Consider this unfortunate construction:
```
def _send_notification(line):
    'email' in line and send_notification(line['email'])
```

docformatter changes it to:
```
 def _send_notification(line):
    """email.""" in line and send_notification(line['email'])
```

I think it should remain unchanged. 

Pull request:
fix: Don't format lines beginning with single quotes
When a function/method has no docstring, but the first line of code begins with a string, docformatter will treat this string as a docstring and format it.  This obviously breaks user code (see #66).

PEP 257 requires the use of triple double quotes (""").  This PR simply removes the single single quotes and single double quotes from the list of quote types to check against for docstrings.

Supporting single quoted strings as docstrings was added in commit 35b6a2e8053548129089d9aae244a03cabdd046b in January 2014.  There is no issue or PR associated with this change, so the basis for the change is unknown.  Since single quotes is not PEP 257 compliant, this should be removed.

Closes #66 

Patch:
diff --git a/AUTHORS.rst b/AUTHORS.rst
index 71adf43..65db8d2 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -10,3 +10,4 @@ Patches
 - Peter Boothe (https://github.com/pboothe)
 - Kapshuna Alexander (https://github.com/kapsh)
 - Serhiy Yevtushenko (https://github.com/serhiy-yevtushenko)
+- Doyle Rowland (https://github.com/weibullguy)
diff --git a/Makefile b/Makefile
index 8c6a8c9..ff1eebc 100644
--- a/Makefile
+++ b/Makefile
@@ -7,7 +7,6 @@ check:
 		setup.py
 	pylint \
 		--reports=no \
-		--disable=bad-continuation \
 		--disable=fixme \
 		--disable=import-outside-toplevel \
 		--disable=inconsistent-return-statements \
diff --git a/docformatter.py b/docformatter.py
index 46faa0c..57f598c 100755
--- a/docformatter.py
+++ b/docformatter.py
@@ -41,6 +41,12 @@ import tokenize
 import sysconfig
 import untokenize
 
+try:
+    import tomli
+
+    TOMLI_INSTALLED = True
+except ImportError:
+    TOMLI_INSTALLED = False
 
 __version__ = '1.4'
 
@@ -56,6 +62,7 @@ HEURISTIC_MIN_LIST_ASPECT_RATIO = .4
 CR = '\r'
 LF = '\n'
 CRLF = '\r\n'
+QUOTE_TYPES = ('"""', "'''", )
 
 _PYTHON_LIBS = set(sysconfig.get_paths().values())
 
@@ -137,7 +144,7 @@ def _format_code(source,
 
         if (
             token_type == tokenize.STRING and
-            token_string.startswith(('"', "'")) and
+            token_string.startswith(QUOTE_TYPES) and
             (previous_token_type == tokenize.INDENT or
                 only_comments_so_far) and
             is_in_range(line_range, start[0], end[0]) and
@@ -193,7 +200,7 @@ def format_docstring(indentation, docstring,
     contents = strip_docstring(docstring)
 
     # Skip if there are nested triple double quotes
-    if contents.count('"""'):
+    if contents.count(QUOTE_TYPES[0]):
         return docstring
 
     # Do not modify things that start with doctests.
@@ -237,25 +244,25 @@ def format_docstring(indentation, docstring,
             post_description=('\n' if post_description_blank else ''),
             indentation=indentation)
     else:
-        if make_summary_multi_line:
-            beginning = '"""\n' + indentation
-            ending = '\n' + indentation + '"""'
-            summary_wrapped = wrap_summary(
-                normalize_summary(contents),
-                wrap_length=summary_wrap_length,
-                initial_indent=indentation,
-                subsequent_indent=indentation).strip()
-            return '{beginning}{summary}{ending}'.format(
-                beginning=beginning,
-                summary=summary_wrapped,
-                ending=ending
-            )
-        else:
+        if not make_summary_multi_line:
             return wrap_summary('"""' + normalize_summary(contents) + '"""',
                                 wrap_length=summary_wrap_length,
                                 initial_indent=indentation,
                                 subsequent_indent=indentation).strip()
 
+        beginning = '"""\n' + indentation
+        ending = '\n' + indentation + '"""'
+        summary_wrapped = wrap_summary(
+            normalize_summary(contents),
+            wrap_length=summary_wrap_length,
+            initial_indent=indentation,
+            subsequent_indent=indentation).strip()
+        return '{beginning}{summary}{ending}'.format(
+            beginning=beginning,
+            summary=summary_wrapped,
+            ending=ending
+        )
+
 
 def reindent(text, indentation):
     """Return reindented text that matches indentation."""
@@ -448,16 +455,22 @@ def normalize_line_endings(lines, newline):
     return ''.join([normalize_line(line, newline) for line in lines])
 
 
-def strip_docstring(docstring):
-    """Return contents of docstring."""
+def strip_docstring(docstring: str) -> str:
+    """Return contents of docstring.
+
+    :param docstring: the docstring, including the opening and closing triple
+        quotes.
+    :return: docstring with the triple quotes removed.
+    :rtype: str
+    """
     docstring = docstring.strip()
-    quote_types = ["'''", '"""', "'", '"']
 
-    for quote in quote_types:
+    for quote in QUOTE_TYPES:
         if docstring.startswith(quote) and docstring.endswith(quote):
             return docstring.split(quote, 1)[1].rsplit(quote, 1)[0].strip()
 
-    raise ValueError('We only handle strings that start with quotes')
+    raise ValueError("docformatter only handles strings that start with "
+                     "triple quotes")
 
 
 def normalize_summary(summary):
@@ -594,12 +607,12 @@ def format_file(filename, args, standard_out):
                 output_file.write(formatted_source)
         else:
             import difflib
-            diff = difflib.unified_diff(
-                source.splitlines(),
-                formatted_source.splitlines(),
-                'before/' + filename,
-                'after/' + filename,
-                lineterm='')
+            diff = difflib.unified_diff(source.splitlines(),
+                                        formatted_source.splitlines(),
+                                        f'before/{filename}',
+                                        f'after/{filename}',
+                                        lineterm='')
+
             standard_out.write('\n'.join(list(diff) + ['']))
 
     return FormatResult.ok
@@ -640,16 +653,14 @@ def read_configuration_from_file(configfile):
     fullpath, ext = os.path.splitext(configfile)
     filename = os.path.basename(fullpath)
 
-    if ext == ".toml":
-        import tomli
+    if ext == ".toml" and TOMLI_INSTALLED and filename == "pyproject":
+        with open(configfile, "rb") as f:
+            config = tomli.load(f)
 
-        if filename == "pyproject":
-            with open(configfile, "rb") as f:
-                config = tomli.load(f)
-            result = config.get("tool", {}).get("docformatter", None)
-            if result is not None:
-                flargs = {k: v if isinstance(v, list) else str(v)
-                          for k, v in result.items()}
+        result = config.get("tool", {}).get("docformatter", None)
+        if result is not None:
+            flargs = {k: v if isinstance(v, list) else str(v)
+                      for k, v in result.items()}
 
     return flargs
 
@@ -658,10 +669,7 @@ def _main(argv, standard_out, standard_error, standard_in):
     """Run internal main entry point."""
     import argparse
 
-    flargs = {}
-    if "--config" in argv:
-        flargs = find_config_file(argv)
-
+    flargs = find_config_file(argv) if "--config" in argv else {}
     parser = argparse.ArgumentParser(description=__doc__, prog='docformatter')
     changes = parser.add_mutually_exclusive_group()
     changes.add_argument('-i', '--in-place', action='store_true',
@@ -718,8 +726,10 @@ def _main(argv, standard_out, standard_error, standard_in):
                         type=int, nargs=2,
                         help='apply docformatter to docstrings of given '
                              'length range')
-    parser.add_argument('--version', action='version',
-                        version='%(prog)s ' + __version__)
+    parser.add_argument('--version',
+                        action='version',
+                        version=f'%(prog)s {__version__}')
+
     parser.add_argument('--config',
                         help='path to file containing docformatter options')
     parser.add_argument('files', nargs='+',
diff --git a/setup.py b/setup.py
index 71dd1f6..a9aae38 100755
--- a/setup.py
+++ b/setup.py
@@ -22,28 +22,31 @@ def version():
 
 
 setup(name='docformatter',
-        version=version(),
-        description='Formats docstrings to follow PEP 257.',
-        long_description=Path('README.rst').read_text(),
-        license='Expat License',
-        author='Steven Myint',
-        url='https://github.com/myint/docformatter',
-        classifiers=['Intended Audience :: Developers',
-                    'Environment :: Console',
-                    'Programming Language :: Python :: 3',
-                    'Programming Language :: Python :: 3.6',
-                    'Programming Language :: Python :: 3.7',
-                    'Programming Language :: Python :: 3.8',
-                    'Programming Language :: Python :: 3.9',
-                    'Programming Language :: Python :: 3.10',
-                    'Programming Language :: Python :: Implementation',
-                    'Programming Language :: Python :: Implementation :: PyPy',
-                    'Programming Language :: Python :: Implementation :: CPython',
-                    'License :: OSI Approved :: MIT License'],
-        keywords='PEP 257, pep257, style, formatter, docstrings',
-        py_modules=['docformatter'],
-        entry_points={
-            'console_scripts': ['docformatter = docformatter:main']},
-        install_requires=['untokenize'],
-        extras_require={"tomli": ["tomli"]},
-        test_suite='test_docformatter')
+      version=version(),
+      description='Formats docstrings to follow PEP 257.',
+      long_description=Path('README.rst').read_text(),
+      license='Expat License',
+      author='Steven Myint',
+      url='https://github.com/myint/docformatter',
+      classifiers=[
+          'Intended Audience :: Developers',
+          'Environment :: Console',
+          'Programming Language :: Python :: 3',
+          'Programming Language :: Python :: 3.6',
+          'Programming Language :: Python :: 3.7',
+          'Programming Language :: Python :: 3.8',
+          'Programming Language :: Python :: 3.9',
+          'Programming Language :: Python :: 3.10',
+          'Programming Language :: Python :: Implementation',
+          'Programming Language :: Python :: Implementation :: PyPy',
+          'Programming Language :: Python :: Implementation :: CPython',
+          'License :: OSI Approved :: MIT License',
+      ],
+      keywords='PEP 257, pep257, style, formatter, docstrings',
+      py_modules=['docformatter'],
+      entry_points={
+          'console_scripts': ['docformatter = docformatter:main']},
+      install_requires=['untokenize'],
+      extras_require={"tomli": ["tomli"]},
+      test_suite='test_docformatter')
+


Test patch:
diff --git a/test_docformatter.py b/test_docformatter.py
index 572f691..37638b0 100755
--- a/test_docformatter.py
+++ b/test_docformatter.py
@@ -86,14 +86,6 @@ class TestUnits(unittest.TestCase):
     def test_strip_docstring_with_empty_string(self):
         self.assertEqual('', docformatter.strip_docstring('""""""'))
 
-    def test_strip_docstring_with_escaped_quotes(self):
-        self.assertEqual("hello\\'",
-                         docformatter.strip_docstring("'hello\\''"))
-
-    def test_strip_docstring_with_escaped_double_quotes(self):
-        self.assertEqual('hello\\"',
-                         docformatter.strip_docstring('"hello\\""'))
-
     def test_strip_docstring_with_unhandled(self):
         with self.assertRaises(ValueError):
             docformatter.strip_docstring('r"""foo"""')
@@ -788,30 +780,62 @@ def foo():
     """
 ''')))
 
-    def test_format_code_with_double_quote(self):
+    def test_ignore_code_with_single_quote(self):
+        """Single single quote on first line of code should remain untouched.
+
+        See requirement 1, always use triple double quotes.
+        See issue #66 for example of docformatter breaking code when encountering
+        single quote.
+        """
         self.assertEqual(
-            '''\
+                '''\
 def foo():
-    """Just a regular string."""
+    'Just a regular string'
 ''',
             docformatter.format_code(
                 '''\
 def foo():
-    "Just a regular string"
+    'Just a regular string'
 '''))
 
-    def test_format_code_with_single_quote(self):
+    def test_ignore_string_with_escaped_quotes(self):
+        """Strings beginning with single single quotes should raise a ValueError.
+
+        See requirement 1, always use triple double quotes.
+        See issue #66 for example of docformatter breaking code when encountering
+        single quote.
+        """
+        with self.assertRaises(ValueError):
+            docformatter.strip_docstring("'hello\\''")
+
+    def test_ignore_code_with_double_quote(self):
+        """Single double quotes on first line of code should remain untouched.
+
+        See requirement 1, always use triple double quotes.
+        See issue #66 for example of docformatter breaking code when encountering
+        single quote.
+        """
         self.assertEqual(
-            '''\
+                '''\
 def foo():
-    """Just a regular string."""
+    "Just a regular string"
 ''',
             docformatter.format_code(
                 '''\
 def foo():
-    'Just a regular string'
+    "Just a regular string"
 '''))
 
+    def test_strip_docstring_with_escaped_double_quotes(self):
+        """Strings beginning with single double quotes should raise a ValueError.
+
+        See requirement 1, always use triple double quotes.
+        See issue #66 for example of docformatter breaking code when encountering
+        single quote.
+        """
+        with self.assertRaises(ValueError):
+            docformatter.strip_docstring('"hello\\""')
+
     def test_format_code_with_should_skip_nested_triple_quotes(self):
         line = '''\
 def foo():
@@ -1276,7 +1300,8 @@ num_iterations is the number of updates - instead of a better definition of conv
         try:
             f.write('[tool.docformatter]\nrecursive = true\nwrap-summaries = 82\n')
             f.close()
-            self.assertEqual(docformatter.find_config_file(['--config', 'pyproject.toml']),
+            self.assertEqual(docformatter.find_config_file(['--config',
+                                                            './tests/_data/pyproject.toml']),
                          {'recursive': 'True', 'wrap-summaries': '82'})
         finally:
             os.remove(f.name)
diff --git a/tests/_data/pyproject.toml b/tests/_data/pyproject.toml
new file mode 100644
index 0000000..5503646
--- /dev/null
+++ b/tests/_data/pyproject.toml
@@ -0,0 +1,4 @@
+[tool.docformatter]
+recursive = true
+wrap-summaries = 82
+