Repository: PyCQA/docformatter (python)

Issue:
Global config with "docformatterrc"
It would be great to have a `docformatterrc` like many other commands similar to this (flake8, autopep8, etc)


Pull request description:
Add pyproject.toml support for config (Issue #10)
I submit for your consideration a potential solution to issue #10 .

This adds two functions:
* One to find the config file specified with the new --config option and compare it against a list of supported config files (currently only pyproject.toml).
* A second function to read the contents of the config file into a dict which is returned to _main().

The dict is used to set default values in the add_argument() methods, thus ensuring command line options take precedence.  Adding additional config files (e.g., setup.cfg, tox.ini) should be reasonably trivial with these functions.

Four tests have been added to the test suite to test these two functions and a section added to the README to explain the use of pyproject.toml.  All tests pass as well as all static checks.  I've been successfully using my fork for my use cases; command line, key-mapped in IDE, pre-commit and GH actions.

Closes #10

Patch:
diff --git a/README.rst b/README.rst
index c01f8ae..a7d9c5f 100644
--- a/README.rst
+++ b/README.rst
@@ -167,6 +167,7 @@ Below is the help output::
       --range line line     apply docformatter to docstrings between these lines;
                             line numbers are indexed at 1
       --version             show program's version number and exit
+      --config CONFIG       path to file containing docformatter options
 
 
 Possible exit codes:
@@ -174,6 +175,20 @@ Possible exit codes:
 - **1** - if any error encountered
 - **3** - if any file needs to be formatted (in ``--check`` mode)
 
+docformatter options can also be stored in a configuration file.  Currently only
+pyproject.toml is supported.  Add section [tool.docformatter] with options listed using
+the same name as command line options.  For example::
+
+      [tool.docformatter]
+      recursive = true
+      wrap-summaries = 82
+      blank = true
+
+Command line options take precedence.  The configuration file can be passed with a full
+path, otherwise docformatter will look in the current directory.  For example::
+
+      docformatter --config ~/.secret/path/to/pyproject.toml
+
 Wrapping descriptions
 =====================
 
diff --git a/docformatter.py b/docformatter.py
index 07213e7..d9e24de 100755
--- a/docformatter.py
+++ b/docformatter.py
@@ -618,9 +618,50 @@ def _format_code_with_args(source, args):
         line_range=args.line_range)
 
 
+def find_config_file(args):
+    """Find the configuration file the user specified."""
+    config_files = ["pyproject.toml"]
+    flargs = {}
+
+    config_file = args[args.index('--config') + 1]
+
+    if os.path.isfile(config_file):
+        argfile = os.path.basename(config_file)
+        for f in config_files:
+            if argfile == f:
+                flargs = read_configuration_from_file(config_file)
+
+    return flargs
+
+
+def read_configuration_from_file(configfile):
+    """Read docformatter options from a configuration file."""
+    flargs = {}
+    fullpath, ext = os.path.splitext(configfile)
+    filename = os.path.basename(fullpath)
+
+    if ext == ".toml":
+        import tomli
+
+        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()}
+
+    return flargs
+
+
 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)
+
     parser = argparse.ArgumentParser(description=__doc__, prog='docformatter')
     changes = parser.add_mutually_exclusive_group()
     changes.add_argument('-i', '--in-place', action='store_true',
@@ -630,44 +671,57 @@ def _main(argv, standard_out, standard_error, standard_in):
                          help='only check and report incorrectly formatted '
                               'files')
     parser.add_argument('-r', '--recursive', action='store_true',
+                        default=bool(flargs.get('recursive', False)),
                         help='drill down directories recursively')
     parser.add_argument('-e', '--exclude', nargs='*',
                         help='exclude directories and files by names')
-    parser.add_argument('--wrap-summaries', default=79, type=int,
+    parser.add_argument('--wrap-summaries',
+                        default=int(flargs.get('wrap-summaries', 79)),
+                        type=int,
                         metavar='length',
                         help='wrap long summary lines at this length; '
                              'set to 0 to disable wrapping '
                              '(default: %(default)s)')
-    parser.add_argument('--wrap-descriptions', default=72, type=int,
+    parser.add_argument('--wrap-descriptions',
+                        default=int(flargs.get('wrap-descriptions', 72)),
+                        type=int,
                         metavar='length',
                         help='wrap descriptions at this length; '
                              'set to 0 to disable wrapping '
                              '(default: %(default)s)')
     parser.add_argument('--blank', dest='post_description_blank',
                         action='store_true',
+                        default=bool(flargs.get('blank', False)),
                         help='add blank line after description')
     parser.add_argument('--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')
     parser.add_argument('--make-summary-multi-line',
                         action='store_true',
+                        default=bool(flargs.get('make-summary-multi-line',
+                                                False)),
                         help='add a newline before and after the summary of a '
                              'one-line docstring')
     parser.add_argument('--force-wrap', action='store_true',
+                        default=bool(flargs.get('force-wrap', False)),
                         help='force descriptions to be wrapped even if it may '
                              'result in a mess')
     parser.add_argument('--range', metavar='line', dest='line_range',
-                        default=None, type=int, nargs=2,
+                        default=flargs.get('range', None), type=int, nargs=2,
                         help='apply docformatter to docstrings between these '
                              'lines; line numbers are indexed at 1')
     parser.add_argument('--docstring-length', metavar='length',
                         dest='length_range',
-                        default=None, type=int, nargs=2,
+                        default=flargs.get('docstring-length', None),
+                        type=int, nargs=2,
                         help='apply docformatter to docstrings of given '
                              'length')
     parser.add_argument('--version', action='version',
                         version='%(prog)s ' + __version__)
+    parser.add_argument('--config',
+                        help='path to file containing docformatter options')
     parser.add_argument('files', nargs='+',
                         help="files to format or '-' for standard in")
 


Test patch:
diff --git a/test_docformatter.py b/test_docformatter.py
index 0908135..572f691 100755
--- a/test_docformatter.py
+++ b/test_docformatter.py
@@ -1271,6 +1271,24 @@ num_iterations is the number of updates - instead of a better definition of conv
             self.assertEqual(test_exclude_nothing, ['/root/folder_one/one.py', '/root/folder_one/folder_three/three.py',
                                                     '/root/folder_two/two.py'])
 
+    def test_read_config_file(self):
+        f = open('pyproject.toml', 'w+')
+        try:
+            f.write('[tool.docformatter]\nrecursive = true\nwrap-summaries = 82\n')
+            f.close()
+            self.assertEqual(docformatter.find_config_file(['--config', 'pyproject.toml']),
+                         {'recursive': 'True', 'wrap-summaries': '82'})
+        finally:
+            os.remove(f.name)
+
+    def test_missing_config_file(self):
+        self.assertEqual(docformatter.find_config_file(['--config', 'pyproject.toml']),
+                         {})
+
+    def test_unsupported_config_file(self):
+        self.assertEqual(docformatter.find_config_file(['--config', 'tox.ini']),
+                         {})
+
 class TestSystem(unittest.TestCase):
 
     def test_diff(self):
@@ -1506,6 +1524,30 @@ Print my path and return error code
             self.assertEqual(stderr.getvalue().strip(), filename,
                              msg='Changed file should be reported')
 
+    def test_cli_override_config_file(self):
+        f = open('pyproject.toml', 'w+')
+        try:
+            f.write('[tool.docformatter]\nrecursive = true\nwrap-summaries = 82\n')
+            f.close()
+            with temporary_file('''\
+def foo():
+    """
+    Hello world
+    """
+''') as filename:
+                process = run_docformatter(['--wrap-summaries=79',
+                                            filename])
+                self.assertEqual('''\
+@@ -1,4 +1,2 @@
+ def foo():
+-    """
+-    Hello world
+-    """
++    """Hello world."""
+''', '\n'.join(process.communicate()[0].decode().split('\n')[2:]))
+        finally:
+            os.remove(f.name)
+
 
 def generate_random_docstring(max_indentation_length=32,
                               max_word_length=20,