Repository: omni-us/jsonargparse
Pull request #485 (2024-04-09 06:01:28)

New option in CLI to provide subcommands help when components given in a dict
## What does this PR do?

Implements https://github.com/Lightning-AI/litgpt/issues/996#issuecomment-1996201548

## Before submitting

- [x] Did you read the [contributing guideline](https://github.com/omni-us/jsonargparse/blob/main/CONTRIBUTING.rst)?
- [x] Did you update **the documentation**? (readme and public docstrings)
- [x] Did you write **unit tests** such that there is 100% coverage on related code? (required for bug fixes and new features)
- [x] Did you verify that new and existing **tests pass locally**?
- [x] Did you make sure that all changes preserve **backward compatibility**?
- [x] Did you update **the CHANGELOG**? (not for typos, docs, test updates, or minor internal changes/refactors)


sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=485) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=485&resolved=false&inNewCodePeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=485&metric=new_accepted_issues&view=list)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=485&resolved=false&inNewCodePeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/no-data-16px.png '') No data about Coverage  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=485&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=485)


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/omni-us/jsonargparse/pull/485?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`c9732a5`)](https://app.codecov.io/gh/omni-us/jsonargparse/commit/c9732a517040f193446f09a99eb98c2c5c29f3ec?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) to head [(`a83cb6b`)](https://app.codecov.io/gh/omni-us/jsonargparse/pull/485?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us).


<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##              main      #485   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           21        21           
  Lines         6013      6016    +3     
=========================================
+ Hits          6013      6016    +3     
```

| [Flag](https://app.codecov.io/gh/omni-us/jsonargparse/pull/485/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | Coverage Δ | |
|---|---|---|
| [py3.10](https://app.codecov.io/gh/omni-us/jsonargparse/pull/485/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `88.41% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.10_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/485/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.73% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.10_pydantic1](https://app.codecov.io/gh/omni-us/jsonargparse/pull/485/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `48.90% <0.00%> (-0.03%)` | :arrow_down: |
| [py3.10_pydantic2](https://app.codecov.io/gh/omni-us/jsonargparse/pull/485/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `48.62% <0.00%> (-0.03%)` | :arrow_down: |
| [py3.10_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/485/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.73% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.11](https://app.codecov.io/gh/omni-us/jsonargparse/pull/485/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `88.39% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.11_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/485/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.73% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.11_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/485/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.73% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.12](https://app.codecov.io/gh/omni-us/jsonargparse/pull/485/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `88.39% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.12_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/485/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.73% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.12_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/485/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.73% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.7](https://app.codecov.io/gh/omni-us/jsonargparse/pull/485/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `88.98% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.7_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/485/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.49% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.7_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/485/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.48% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.8](https://app.codecov.io/gh/omni-us/jsonargparse/pull/485/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `89.12% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.8_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/485/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.51% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.8_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/485/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.50% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.9](https://app.codecov.io/gh/omni-us/jsonargparse/pull/485/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `88.97% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.9_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/485/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.38% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.9_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/485/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.38% <100.00%> (+<0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us#carryforward-flags-in-the-pull-request-comment) to find out more.


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/omni-us/jsonargparse/pull/485?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us).

sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=485) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=485&resolved=false&inNewCodePeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=485&metric=new_accepted_issues&view=list)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=485&resolved=false&inNewCodePeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/no-data-16px.png '') No data about Coverage  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=485&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=485)



Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index e78942d..20b975a 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -19,6 +19,10 @@ Added
 ^^^^^
 - Support for "-" as value for Path class initialization so that user
   can ask to use standard input/output instead of file.
+- Option in ``CLI`` to provide subcommands help when components given in a dict
+  (`litgpt#996 comment
+  <https://github.com/Lightning-AI/litgpt/issues/996#issuecomment-1996201548>`__).
+
 
 Fixed
 ^^^^^
diff --git a/DOCUMENTATION.rst b/DOCUMENTATION.rst
index 41a2b4d..aa64d56 100644
--- a/DOCUMENTATION.rst
+++ b/DOCUMENTATION.rst
@@ -147,10 +147,12 @@ Arbitrary levels of sub-commands with custom names can be defined by providing a
 
     components = {
         "weekday": {
+            "_help": "Raffles for weekdays",
             "tier1": Raffle(prize=100),
             "tier2": Raffle(prize=50),
         },
         "weekend": {
+            "_help": "Raffles for weekends",
             "tier1": Raffle(prize=300),
             "tier2": Raffle(prize=75),
         },
diff --git a/jsonargparse/_cli.py b/jsonargparse/_cli.py
index 7817a63..43a08ba 100644
--- a/jsonargparse/_cli.py
+++ b/jsonargparse/_cli.py
@@ -75,7 +75,7 @@ def CLI(
         unexpected = [c for c in components if not (inspect.isclass(c) or callable(c))]
     elif isinstance(components, dict):
         ns = dict_to_namespace(components)
-        unexpected = [c for c in ns.values() if not (inspect.isclass(c) or callable(c))]
+        unexpected = [c for k, c in ns.items() if not k.endswith("._help") and not (inspect.isclass(c) or callable(c))]
     else:
         unexpected = [c for c in [components] if not (inspect.isclass(c) or callable(c))]
     if unexpected:
@@ -120,6 +120,8 @@ def CLI(
 
 
 def get_help_str(component, logger):
+    if isinstance(component, dict):
+        return component.get("_help")
     help_str = get_doc_short_description(component, logger=logger)
     if not help_str:
         help_str = str(component)
@@ -135,14 +137,15 @@ def _add_subcommands(
 ) -> None:
     subcommands = parser.add_subcommands(required=True)
     for name, component in components.items():
+        if name == "_help":
+            continue
         description = get_help_str(component, parser.logger)
         subparser = type(parser)(description=description)
         subparser.add_argument("--config", action=ActionConfigFile, help=config_help)
+        subcommands.add_subcommand(name, subparser, help=description)
         if isinstance(component, dict):
-            subcommands.add_subcommand(name, subparser)
             _add_subcommands(component, subparser, config_help, as_positional, fail_untyped)
         else:
-            subcommands.add_subcommand(name, subparser, help=get_help_str(component, parser.logger))
             added_args = _add_component_to_parser(component, subparser, as_positional, fail_untyped, config_help)
             if not added_args:
                 remove_actions(subparser, (ActionConfigFile, _ActionPrintConfig))


Test patch:
diff --git a/jsonargparse_tests/test_cli.py b/jsonargparse_tests/test_cli.py
index 0bb933b..06d4879 100644
--- a/jsonargparse_tests/test_cli.py
+++ b/jsonargparse_tests/test_cli.py
@@ -50,6 +50,7 @@ def test_conflicting_subcommand_key():
 
 
 def single_function(a1: float):
+    """Description of single_function"""
     return a1
 
 
@@ -68,13 +69,18 @@ def test_single_function_set_defaults():
 def test_single_function_help():
     out = get_cli_stdout(single_function, args=["--help"])
     assert "a1" in out
-    assert "function single_function" in out
+    if docstring_parser_support:
+        assert "Description of single_function" in out
+    else:
+        assert "function single_function" in out
 
 
 # callable class tests
 
 
 class CallableClass:
+    """Description of CallableClass"""
+
     def __call__(self, x: int):
         return x
 
@@ -398,32 +404,69 @@ def test_named_components_shallow():
     components = {"cmd1": single_function, "cmd2": callable_instance}
     assert 3.4 == CLI(components, args=["cmd1", "3.4"])
     assert 5 == CLI(components, as_positional=False, args=["cmd2", "--x=5"])
+    out = get_cli_stdout(components, args=["--help"])
+    if docstring_parser_support:
+        assert "Description of single_function" in out
+        assert "Description of CallableClass" in out
 
 
 def test_named_components_deep():
     components = {
-        "lv1_a": {"lv2_x": single_function, "lv2_y": {"lv3_p": callable_instance}},
-        "lv1_b": {"lv2_z": {"lv3_q": Class1}},
+        "lv1_a": {
+            "_help": "Description of lv1_a",
+            "lv2_x": single_function,
+            "lv2_y": {"_help": "Description of lv2_y", "lv3_p": callable_instance},
+        },
+        "lv1_b": {
+            "_help": "Description of lv1_b",
+            "lv2_z": {
+                "_help": "Description of lv2_z",
+                "lv3_q": Class1,
+            },
+        },
     }
     kw = {"as_positional": False}
     out = get_cli_stdout(components, args=["--help"], **kw)
     assert " {lv1_a,lv1_b} ..." in out
+    assert "Description of lv1_a" in out
+    assert "Description of lv1_b" in out
     out = get_cli_stdout(components, args=["lv1_a", "--help"], **kw)
     assert " {lv2_x,lv2_y} ..." in out
+    assert "Description of lv1_a" in out
+    if docstring_parser_support:
+        assert "Description of single_function" in out
+    assert "Description of lv2_y" in out
     out = get_cli_stdout(components, args=["lv1_a", "lv2_x", "--help"], **kw)
     assert " --a1 A1" in out
+    if docstring_parser_support:
+        assert "Description of single_function" in out
     out = get_cli_stdout(components, args=["lv1_a", "lv2_y", "--help"], **kw)
     assert " {lv3_p} ..." in out
+    assert "Description of lv2_y" in out
+    if docstring_parser_support:
+        assert "Description of CallableClass" in out
     out = get_cli_stdout(components, args=["lv1_a", "lv2_y", "lv3_p", "--help"], **kw)
     assert " --x X" in out
+    if docstring_parser_support:
+        assert "Description of CallableClass" in out
     out = get_cli_stdout(components, args=["lv1_b", "--help"], **kw)
     assert " {lv2_z} ..." in out
+    assert "Description of lv1_b" in out
+    assert "Description of lv2_z" in out
     out = get_cli_stdout(components, args=["lv1_b", "lv2_z", "--help"], **kw)
     assert " {lv3_q} ..." in out
+    assert "Description of lv2_z" in out
+    if docstring_parser_support:
+        assert "Description of Class1" in out
     out = get_cli_stdout(components, args=["lv1_b", "lv2_z", "lv3_q", "--help"], **kw)
     assert " {method1} ..." in out
+    if docstring_parser_support:
+        assert "Description of Class1" in out
+        assert "Description of method1" in out
     out = get_cli_stdout(components, args=["lv1_b", "lv2_z", "lv3_q", "method1", "--help"], **kw)
     assert " --m1 M1" in out
+    if docstring_parser_support:
+        assert "Description of method1" in out
 
     assert 5.6 == CLI(components, args=["lv1_a", "lv2_x", "--a1=5.6"], **kw)
     assert 7 == CLI(components, args=["lv1_a", "lv2_y", "lv3_p", "--x=7"], **kw)