Repository: omni-us/jsonargparse

Issue:
[Feature request] Add arguments description as comments in print_config output
Hello and thank you for the great library !

### Feature request
The `--print_config` feature is useful but limited: this would be great if there was a way to print a config file in YAML format containing comments to describe the arguments. I'm aware that this kind of feature depends on the format used for the dump, because JSON does not support comments.

Suggestions are:
- add an optional argument to the `ArgumentParser.dump` method to allow printing comments (only for YAML files). This will need a modification of the way the YAML is dumped
- or add list of actions and groups to public API to allow custom implementation of this feature (as is, if I'm correct, there is no way to access Action objects except `parser._actions` which is not public)

### Example script

```python
import jsonargparse

parser = jsonargparse.ArgumentParser(parse_as_dict=True, description="Test print-config")

parser.add_argument("--argument1", default=1, help="Description argument 1")
parser.add_argument("--argument2", default='VAL2', help="Description argument 2")

group = parser.add_argument_group(title='Group title')
group.add_argument("--level.argument3", default=3, help="Description argument 3")
group.add_argument("--level.argument4", default=4, required=True, help="Description argument 4")

parser.parse_args()
```

### Current behaviour

Using the --print_config option produce this output:
```
argument1: 1   
argument2: VAL2
level:
  argument3: 3 
  argument4: 4
```

### Wanted behaviour

This would be great if I could produce this kind of output (using `--print-config` or `ArgumentParser.dump` method):

```
# optional arguments:
# ----------------------

# Description argument 1 (default: 1)
argument1: 1

# Description argument 2 (default: VAL2)
argument2: VAL2

# Group title
# ------------

level:
  # Description argument 3 (default: 3)
  argument3: 3 

  # Description argument 4 (required, default: 4)
  argument4: 4
```
The file here should still be a valid YAML file. Comments used here could be the same as for the `--help` method (with types, default values, ...). 


Discussion:
sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!

[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug.png' alt='Bug' width='16' height='16' />](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=56&resolved=false&types=BUG) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=56&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=56&resolved=false&types=BUG)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability.png' alt='Vulnerability' width='16' height='16' />](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=56&resolved=false&types=VULNERABILITY) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=56&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=56&resolved=false&types=VULNERABILITY)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot.png' alt='Security Hotspot' width='16' height='16' />](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=56&resolved=false&types=SECURITY_HOTSPOT) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=56&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=56&resolved=false&types=SECURITY_HOTSPOT)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell.png' alt='Code Smell' width='16' height='16' />](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=56&resolved=false&types=CODE_SMELL) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=56&resolved=false&types=CODE_SMELL) [2 Code Smells](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=56&resolved=false&types=CODE_SMELL)

[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo.png' alt='No Coverage information' width='16' height='16' />](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=56) No Coverage information  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3.png' alt='0.0%' width='16' height='16' />](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=56&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=56&metric=new_duplicated_lines_density&view=list)



Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 0457565..b668dce 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -17,6 +17,7 @@ Added
 ^^^^^
 - Path support for fsspec file systems using the 's' mode flag.
 - set_config_read_mode function that can enable fsspec for config reading.
+- Option for print_config and dump with help as yaml comments.
 
 Deprecated
 ^^^^^^^^^^
diff --git a/README.rst b/README.rst
index 246b085..f9ddbf6 100644
--- a/README.rst
+++ b/README.rst
@@ -386,10 +386,12 @@ string respectively.
 
 All parsers include a :code:`--print_config` option. This is useful particularly
 for command line tools with a large set of options to create an initial config
-file including all default values. By default all entries are included, even the
-ones with :code:`null` values. If this argument is given as
-:code:`--print_config=skip_null`, then the entries with :code:`null` values will
-be excluded.
+file including all default values. If the `ruyaml
+<https://ruyaml.readthedocs.io>`__ package is installed, the config can be
+printed having the help descriptions content as yaml comments by using
+:code:`--print_config=comments`. Another option is
+:code:`--print_config=skip_null` which skips entries whose value is
+:code:`null`.
 
 
 .. _environment-variables:
diff --git a/jsonargparse/actions.py b/jsonargparse/actions.py
index 3b9fea8..cd64248 100644
--- a/jsonargparse/actions.py
+++ b/jsonargparse/actions.py
@@ -8,7 +8,7 @@ import argparse
 from typing import Callable, Tuple, Type, Union
 from argparse import Namespace, Action, SUPPRESS, _HelpAction, _SubParsersAction
 
-from .optionals import get_config_read_mode, FilesCompleterMethod
+from .optionals import FilesCompleterMethod, get_config_read_mode, ruyaml_support
 from .typing import path_type
 from .util import (
     yamlParserError,
@@ -177,15 +177,19 @@ class _ActionPrintConfig(Action):
                          dest=dest,
                          default=default,
                          nargs='?',
-                         metavar='skip_null',
+                         metavar='comments,skip_null',
                          help='Print configuration and exit.')
 
     def __call__(self, parser, namespace, value, option_string=None):
         kwargs = {'subparser': parser, 'key': None, 'skip_none': False, 'skip_check': True}
+        valid_flags = {'': None, 'comments': 'yaml_comments', 'skip_null': 'skip_none'}
         if value is not None:
-            if value not in {'skip_null', ''}:
-                raise ParserError('Invalid option "'+str(value)+'" for '+option_string)
-            kwargs['skip_none'] = True
+            flags = value.split(',')
+            invalid_flags = [f for f in flags if f not in valid_flags]
+            if len(invalid_flags) > 0:
+                raise ParserError('Invalid option "'+str(invalid_flags[0])+'" for '+option_string)
+            for flag in [f for f in flags if f != '']:
+                kwargs[valid_flags[flag]] = True
         while hasattr(parser, 'parent_parser'):
             kwargs['key'] = parser.subcommand if kwargs['key'] is None else parser.subcommand+'.'+kwargs['key']
             parser = parser.parent_parser
diff --git a/jsonargparse/core.py b/jsonargparse/core.py
index 7455908..2441425 100644
--- a/jsonargparse/core.py
+++ b/jsonargparse/core.py
@@ -708,6 +708,7 @@ class ArgumentParser(_ActionsContainer, argparse.ArgumentParser, LoggerProperty)
         format: str = 'parser_mode',
         skip_none: bool = True,
         skip_check: bool = False,
+        yaml_comments: bool = False,
     ) -> str:
         """Generates a yaml or json string for the given configuration object.
 
@@ -716,6 +717,7 @@ class ArgumentParser(_ActionsContainer, argparse.ArgumentParser, LoggerProperty)
             format: The output format: "yaml", "json", "json_indented" or "parser_mode".
             skip_none: Whether to exclude entries whose value is None.
             skip_check: Whether to skip parser checking.
+            yaml_comments: Whether to add help content as comments.
 
         Returns:
             The configuration in yaml or json format.
@@ -756,7 +758,11 @@ class ArgumentParser(_ActionsContainer, argparse.ArgumentParser, LoggerProperty)
         if format == 'parser_mode':
             format = 'yaml' if self.parser_mode == 'yaml' else 'json_indented'
         if format == 'yaml':
-            return yaml.safe_dump(cfg, **self.dump_yaml_kwargs)  # type: ignore
+            dump = yaml.safe_dump(cfg, **self.dump_yaml_kwargs)  # type: ignore
+            if yaml_comments:
+                formatter = self.formatter_class(self.prog)
+                dump = formatter.add_yaml_comments(dump)  # type: ignore
+            return dump
         elif format == 'json_indented':
             return json.dumps(cfg, indent=2, **self.dump_json_kwargs)+'\n'  # type: ignore
         elif format == 'json':
diff --git a/jsonargparse/formatters.py b/jsonargparse/formatters.py
index 6d50412..2c686a8 100644
--- a/jsonargparse/formatters.py
+++ b/jsonargparse/formatters.py
@@ -1,9 +1,20 @@
 """Formatter classes."""
 
+import re
 from argparse import _HelpAction, HelpFormatter, OPTIONAL, SUPPRESS, ZERO_OR_MORE
 from enum import Enum
-
-from .actions import ActionConfigFile, ActionYesNo, _ActionLink
+from io import StringIO
+
+from .actions import (
+    ActionConfigFile,
+    ActionYesNo,
+    _ActionConfigLoad,
+    _ActionLink,
+    _ActionSubCommands,
+    _find_action,
+    filter_default_actions,
+)
+from .optionals import import_ruyaml
 from .typehints import ActionTypeHint, type_to_str
 from .util import _get_env_var, _get_key_value
 
@@ -94,3 +105,108 @@ class DefaultHelpFormatter(HelpFormatter):
     def add_usage(self, usage, actions, groups, prefix=None):
         actions = [a for a in actions if not isinstance(a, _ActionLink)]
         super().add_usage(usage, actions, groups, prefix=prefix)
+
+
+    def add_yaml_comments(self, cfg: str) -> str:
+        """Adds help text as yaml comments."""
+        ruyaml = import_ruyaml('add_yaml_comments')
+        yaml = ruyaml.YAML()
+        cfg = yaml.load(cfg)
+
+        def get_subparsers(parser, prefix=''):
+            subparsers = {}
+            if parser._subparsers is not None:
+                for key, subparser in parser._subparsers._group_actions[0].choices.items():
+                    full_key = (prefix+'.' if prefix else '')+key
+                    subparsers[full_key] = subparser
+                    subparsers.update(get_subparsers(subparser, prefix=full_key))
+            return subparsers
+
+        parsers = get_subparsers(self._parser)  # type: ignore
+        parsers[None] = self._parser  # type: ignore
+
+        group_titles = {}
+        for prefix, parser in parsers.items():
+            for group in parser._action_groups:
+                actions = filter_default_actions(group._group_actions)
+                actions = [a for a in actions if not isinstance(a, (_ActionConfigLoad, ActionConfigFile, _ActionSubCommands))]
+                keys = set(re.sub(r'\.?[^.]+$', '', a.dest) for a in actions)
+                if len(keys) == 1:
+                    key = keys.pop()
+                    full_key = (prefix+('.' if key else '') if prefix else '')+key
+                    group_titles[full_key] = group.title
+
+        def set_comments(cfg, prefix='', depth=0):
+            for key in cfg.keys():
+                full_key = (prefix+'.' if prefix else '')+key
+                action = _find_action(self._parser, full_key, within_subcommands=True)
+                if isinstance(action, tuple):
+                    action = action[0]
+                text = None
+                if full_key in group_titles and isinstance(cfg[key], dict):
+                    text = group_titles[full_key]
+                elif action is not None and action.help not in {None, SUPPRESS}:
+                    text = self._expand_help(action)
+                if isinstance(cfg[key], dict):
+                    if text:
+                        self.set_yaml_group_comment(text, cfg, key, depth)
+                    set_comments(cfg[key], full_key, depth+1)
+                elif text:
+                    self.set_yaml_argument_comment(text, cfg, key, depth)
+
+        if self._parser.description is not None:  # type: ignore
+            self.set_yaml_start_comment(self._parser.description, cfg)  # type: ignore
+        set_comments(cfg)
+        out = StringIO()
+        yaml.dump(cfg, out)
+        return out.getvalue()
+
+
+    def set_yaml_start_comment(
+        self,
+        text: str,
+        cfg: 'ruyaml.comments.CommentedMap',  # type: ignore
+    ):
+        """Sets the start comment to a ruyaml object.
+
+        Args:
+            text: The content to use for the comment.
+            cfg: The ruyaml object.
+        """
+        cfg.yaml_set_start_comment(text)
+
+
+    def set_yaml_group_comment(
+        self,
+        text: str,
+        cfg: 'ruyaml.comments.CommentedMap',  # type: ignore
+        key: str,
+        depth: int,
+    ):
+        """Sets the comment for a group to a ruyaml object.
+
+        Args:
+            text: The content to use for the comment.
+            cfg: The parent ruyaml object.
+            key: The key of the group.
+            depth: The nested level of the group.
+        """
+        cfg.yaml_set_comment_before_after_key(key, before='\n'+text, indent=2*depth)
+
+
+    def set_yaml_argument_comment(
+        self,
+        text: str,
+        cfg: 'ruyaml.comments.CommentedMap',  # type: ignore
+        key: str,
+        depth: int,
+    ):
+        """Sets the comment for an argument to a ruyaml object.
+
+        Args:
+            text: The content to use for the comment.
+            cfg: The parent ruyaml object.
+            key: The key of the argument.
+            depth: The nested level of the argument.
+        """
+        cfg.yaml_set_comment_before_after_key(key, before='\n'+text, indent=2*depth)
diff --git a/jsonargparse/optionals.py b/jsonargparse/optionals.py
index 2859428..2088b15 100644
--- a/jsonargparse/optionals.py
+++ b/jsonargparse/optionals.py
@@ -21,6 +21,7 @@ _docstring_parser = find_spec('docstring_parser')
 _argcomplete = find_spec('argcomplete')
 _dataclasses = find_spec('dataclasses')
 _fsspec = find_spec('fsspec')
+_ruyaml = find_spec('ruyaml')
 
 jsonschema_support = False if _jsonschema is None else True
 jsonnet_support = False if _jsonnet is None else True
@@ -29,6 +30,7 @@ docstring_parser_support = False if _docstring_parser is None else True
 argcomplete_support = False if _argcomplete is None else True
 dataclasses_support = False if _dataclasses is None else True
 fsspec_support = False if _fsspec is None else True
+ruyaml_support = False if _ruyaml is None else True
 
 _config_read_mode = 'fr'
 
@@ -116,6 +118,14 @@ def import_fsspec(importer):
         raise ImportError('fsspec package is required by '+importer+' :: '+str(ex)) from ex
 
 
+def import_ruyaml(importer):
+    try:
+        import ruyaml
+        return ruyaml
+    except (ImportError, ModuleNotFound) as ex:
+        raise ImportError('ruyaml package is required by '+importer+' :: '+str(ex)) from ex
+
+
 def set_config_read_mode(
     urls_enabled: bool = False,
     fsspec_enabled: bool = False,
diff --git a/setup.cfg b/setup.cfg
index 664f4d2..85c3251 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -14,6 +14,7 @@ all =
     %(urls)s
     %(fsspec)s
     %(argcomplete)s
+    %(ruyaml)s
     %(reconplogger)s
 signatures =
     docstring-parser>=0.7.3
@@ -28,6 +29,8 @@ fsspec =
     fsspec>=0.8.4; python_version >= '3.6'
 argcomplete =
     argcomplete>=1.12.1
+ruyaml =
+    ruyaml>=0.20.0; python_version >= '3.6'
 reconplogger =
     reconplogger>=4.4.0
 test =
@@ -115,6 +118,7 @@ extras =
     urls
     fsspec
     argcomplete
+    ruyaml
 changedir = jsonargparse_tests
 commands = discover --pattern='*_tests.py'
 deps = discover


Test patch:
diff --git a/jsonargparse_tests/base.py b/jsonargparse_tests/base.py
index 5d21785..d763f12 100644
--- a/jsonargparse_tests/base.py
+++ b/jsonargparse_tests/base.py
@@ -14,6 +14,7 @@ from jsonargparse.optionals import (
     dataclasses_support, import_dataclasses,
     fsspec_support, import_fsspec,
     get_config_read_mode,
+    ruyaml_support,
     ModuleNotFound,
 )
 
diff --git a/jsonargparse_tests/cli_tests.py b/jsonargparse_tests/cli_tests.py
index 5f048da..deed36e 100755
--- a/jsonargparse_tests/cli_tests.py
+++ b/jsonargparse_tests/cli_tests.py
@@ -150,6 +150,13 @@ class CLITests(unittest.TestCase):
             CLI(components, args=['--print_config=', 'Cmd2', 'method2'])
         self.assertEqual('Cmd2:\n  i1: d\n  method2:\n    m2: 0\n', out.getvalue())
 
+        if docstring_parser_support and ruyaml_support:
+            out = StringIO()
+            with redirect_stdout(out), self.assertRaises(SystemExit):
+                CLI(components, args=['--print_config=comments', 'Cmd2', 'method2'])
+            self.assertIn('# Description of Cmd2', out.getvalue())
+            self.assertIn('# Description of method2', out.getvalue())
+
 
     def test_empty_context(self):
         def empty_context():
diff --git a/jsonargparse_tests/core_tests.py b/jsonargparse_tests/core_tests.py
index abd1104..9811d84 100755
--- a/jsonargparse_tests/core_tests.py
+++ b/jsonargparse_tests/core_tests.py
@@ -691,7 +691,7 @@ class OutputTests(TempDirTestCase):
 
 
     def test_print_config(self):
-        parser = ArgumentParser(error_handler=None)
+        parser = ArgumentParser(error_handler=None, description='cli tool')
         parser.add_argument('--v0', help=SUPPRESS, default='0')
         parser.add_argument('--v1', help='Option v1.', default=1)
         parser.add_argument('--g1.v2', help='Option v2.', default='2')
@@ -709,12 +709,19 @@ class OutputTests(TempDirTestCase):
         out = StringIO()
         with redirect_stdout(out), self.assertRaises(SystemExit):
             parser.parse_args(['--print_config=skip_null'])
-
         outval = yaml.safe_load(out.getvalue())
         self.assertEqual(outval, {'g1': {'v2': '2'}, 'v1': 1})
 
         self.assertRaises(ParserError, lambda: parser.parse_args(['--print_config=bad']))
 
+        if docstring_parser_support and ruyaml_support:
+            out = StringIO()
+            with redirect_stdout(out), self.assertRaises(SystemExit):
+                parser.parse_args(['--print_config=comments'])
+            self.assertIn('# cli tool', out.getvalue())
+            self.assertIn('# Option v1. (default: 1)', out.getvalue())
+            self.assertIn('# Option v2. (default: 2)', out.getvalue())
+
 
 class ConfigFilesTests(TempDirTestCase):