Repository: omni-us/jsonargparse

Issue:
Add multilevel subcommands support to `CLI`
<!-- If you like this project, please ⭐ star it https://github.com/omni-us/jsonargparse/ -->

## 🚀 Feature request

Currently `jsonargparse.CLI` can handle only one level of subcommands through classes. For example:

```Python
from jsonargparse import CLI

class mycli:
    @staticmethod
    def subcommand1(): ...


if __name__ == "__main__":
    CLI(components=[mycli])
```

But there is no easy way to add more subcommands, since embedding a class in another one will cause an error.:

```Python
from jsonargparse import CLI

class mycli:
    @staticmethod
    def subcommand1(): ...

    class subcommand2:
        @staticmethod
        def subsubcommand1(): ...


if __name__ == "__main__":
    CLI(components=[mycli])
```

```Bash
ValueError: Invalid or unsupported input: class=<class '__main__.mycli'>, method_or_property=subcommand2
```

To add more subcommands, you need to use `jsonargparse.ArgumentParser` and implement all the logic manually.

### Motivation

It would be great to be able to create multilevel subcommands easily.

### Pitch

Add multilevel subcommands support to `jsonargparse.CLI`.

### Alternatives

<!-- Any alternative solutions or features you've considered, if any. -->


Discussion:
sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=346)

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

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=346) No Coverage information  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=346&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=346&metric=new_duplicated_lines_density&view=list)


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/omni-us/jsonargparse/pull/346?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) Report
Patch coverage: **`100.00%`** and no project coverage change.
> Comparison is base [(`bd044d8`)](https://app.codecov.io/gh/omni-us/jsonargparse/commit/bd044d8bacd847cebb22299add95de46178a3405?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) 100.00% compared to head [(`abef7d2`)](https://app.codecov.io/gh/omni-us/jsonargparse/pull/346?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) 100.00%.

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


```diff
@@            Coverage Diff            @@
##              main      #346   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           21        21           
  Lines         5548      5568   +20     
=========================================
+ Hits          5548      5568   +20     
```

| Flag | Coverage Δ | |
|---|---|---|
| py3.10 | `86.60% <100.00%> (+0.04%)` | :arrow_up: |
| py3.10_all | `98.34% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.10_types | `98.34% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.11 | `86.58% <100.00%> (+0.04%)` | :arrow_up: |
| py3.11_all | `98.32% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.11_types | `98.32% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.6 | `86.01% <100.00%> (+0.05%)` | :arrow_up: |
| py3.6_all | `97.82% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.7 | `87.99% <100.00%> (+0.04%)` | :arrow_up: |
| py3.7_all | `99.47% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.7_types | `99.45% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.8 | `88.14% <97.67%> (+0.02%)` | :arrow_up: |
| py3.8_all | `99.53% <97.67%> (-0.02%)` | :arrow_down: |
| py3.8_types | `99.51% <97.67%> (-0.02%)` | :arrow_down: |
| py3.9 | `88.07% <97.67%> (+0.02%)` | :arrow_up: |
| py3.9_all | `99.53% <97.67%> (-0.02%)` | :arrow_down: |
| py3.9_types | `99.53% <97.67%> (-0.02%)` | :arrow_down: |

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.

| [Files Changed](https://app.codecov.io/gh/omni-us/jsonargparse/pull/346?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | Coverage Δ | |
|---|---|---|
| [jsonargparse/\_cli.py](https://app.codecov.io/gh/omni-us/jsonargparse/pull/346?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us#diff-anNvbmFyZ3BhcnNlL19jbGkucHk=) | `100.00% <100.00%> (ø)` | |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/omni-us/jsonargparse/pull/346?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).

Borda: This is great! 
sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=346)

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

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=346) No Coverage information  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=346&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=346&metric=new_duplicated_lines_density&view=list)



Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index e44b33f..b07579f 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -19,6 +19,9 @@ Added
 ^^^^^
 - New option in ``dump`` for including link targets.
 - Support ``decimal.Decimal`` as a type.
+- ``CLI`` now accepts components as a dict, such that the keys define names of
+  the subcommands (`#334
+  <https://github.com/omni-us/jsonargparse/issues/334>`__).
 
 
 v4.23.1 (2023-08-04)
diff --git a/README.rst b/README.rst
index c52569d..1a62605 100644
--- a/README.rst
+++ b/README.rst
@@ -235,6 +235,44 @@ is given to :func:`.CLI`, to execute a method of a class, two levels of
 class and the second the name of the method, i.e. ``example.py class
 [init_arguments] method [arguments]``.
 
+Arbitrary levels of sub-commands with custom names can be defined by providing a
+``dict``. For example:
+
+.. testcode::
+
+    class Raffle:
+        def __init__(self, prize: int):
+            self.prize = prize
+
+        def __call__(self, name: str):
+            return f"{name} won {self.prize}€!"
+
+    components = {
+        "weekday": {
+            "tier1": Raffle(prize=100),
+            "tier2": Raffle(prize=50),
+        },
+        "weekend": {
+            "tier1": Raffle(prize=300),
+            "tier2": Raffle(prize=75),
+        },
+    }
+
+    if __name__ == "__main__":
+        print(CLI(components))
+
+Then in a shell:
+
+.. code-block:: bash
+
+    $ python example.py weekend tier1 Lucky
+    Lucky won 300€!
+
+.. doctest:: :hide:
+
+    >>> CLI(components, args=["weekend", "tier1", "Lucky"])
+    'Lucky won 300€!'
+
 .. note::
 
     The examples above are extremely simple, only defining parameters with
diff --git a/jsonargparse/_cli.py b/jsonargparse/_cli.py
index d832f77..f6b7eb2 100644
--- a/jsonargparse/_cli.py
+++ b/jsonargparse/_cli.py
@@ -6,14 +6,20 @@ from typing import Any, Callable, Dict, List, Optional, Type, Union
 from ._actions import ActionConfigFile, _ActionPrintConfig, remove_actions
 from ._core import ArgumentParser
 from ._deprecated import deprecation_warning_cli_return_parser
+from ._namespace import Namespace, dict_to_namespace
 from ._optionals import get_doc_short_description
 from ._util import default_config_option_help
 
 __all__ = ["CLI"]
 
 
+ComponentType = Union[Callable, Type]
+DictComponentsType = Dict[str, Union[ComponentType, "DictComponentsType"]]
+ComponentsType = Optional[Union[ComponentType, List[ComponentType], DictComponentsType]]
+
+
 def CLI(
-    components: Optional[Union[Callable, Type, List[Union[Callable, Type]]]] = None,
+    components: ComponentsType = None,
     args: Optional[List[str]] = None,
     config_help: str = default_config_option_help,
     set_defaults: Optional[Dict[str, Any]] = None,
@@ -55,26 +61,31 @@ def CLI(
         ]
         if len(components) == 0:
             raise ValueError(
-                "Either components argument must be given or there must be at least one "
+                "Either components parameter must be given or there must be at least one "
                 "function or class among the locals in the context where CLI is called."
             )
 
-    elif not isinstance(components, list):
-        components = [components]
+    if isinstance(components, list) and len(components) == 1:
+        components = components[0]
 
-    if len(components) == 0:
-        raise ValueError("components argument not allowed to be an empty list")
+    elif not components:
+        raise ValueError("components parameter expected to be non-empty")
 
-    unexpected = [c for c in components if not (inspect.isclass(c) or callable(c))]
+    if isinstance(components, list):
+        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))]
+    else:
+        unexpected = [c for c in [components] if not (inspect.isclass(c) or callable(c))]
     if unexpected:
         raise ValueError(f"Unexpected components, not class or function: {unexpected}")
 
     parser = parser_class(default_meta=False, **kwargs)
     parser.add_argument("--config", action=ActionConfigFile, help=config_help)
 
-    if len(components) == 1:
-        component = components[0]
-        _add_component_to_parser(component, parser, as_positional, fail_untyped, config_help)
+    if not isinstance(components, (list, dict)):
+        _add_component_to_parser(components, parser, as_positional, fail_untyped, config_help)
         if set_defaults is not None:
             parser.set_defaults(set_defaults)
         if return_parser:
@@ -82,18 +93,12 @@ def CLI(
             return parser
         cfg = parser.parse_args(args)
         cfg_init = parser.instantiate_classes(cfg)
-        return _run_component(component, cfg_init)
+        return _run_component(components, cfg_init)
 
-    subcommands = parser.add_subcommands(required=True)
-    comp_dict = {c.__name__: c for c in components}
-    for name, component in comp_dict.items():
-        description = get_help_str(component, parser.logger)
-        subparser = parser_class(description=description)
-        subparser.add_argument("--config", action=ActionConfigFile, help=config_help)
-        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))
+    elif isinstance(components, list):
+        components = {c.__name__: c for c in components}
+
+    _add_subcommands(components, parser, config_help, as_positional, fail_untyped)
 
     if set_defaults is not None:
         parser.set_defaults(set_defaults)
@@ -101,10 +106,17 @@ def CLI(
         deprecation_warning_cli_return_parser()
         return parser
     cfg = parser.parse_args(args)
-    cfg_init = parser.instantiate_classes(cfg)
-    subcommand = cfg_init.pop("subcommand")
-    component = comp_dict[subcommand]
-    return _run_component(component, cfg_init.get(subcommand))
+    init = parser.instantiate_classes(cfg)
+    components_ns = dict_to_namespace(components)
+    subcommand = init.get("subcommand")
+    while isinstance(init.get(subcommand), Namespace) and isinstance(init[subcommand].get("subcommand"), str):
+        subsubcommand = subcommand + "." + init[subcommand].get("subcommand")
+        if subsubcommand in components_ns:
+            subcommand = subsubcommand
+        else:
+            break
+    component = components_ns[subcommand]
+    return _run_component(component, init.get(subcommand))
 
 
 def get_help_str(component, logger):
@@ -114,6 +126,28 @@ def get_help_str(component, logger):
     return help_str
 
 
+def _add_subcommands(
+    components,
+    parser: ArgumentParser,
+    config_help: str,
+    as_positional: bool,
+    fail_untyped: bool,
+) -> None:
+    subcommands = parser.add_subcommands(required=True)
+    for name, component in components.items():
+        description = get_help_str(component, parser.logger)
+        subparser = type(parser)(description=description)
+        subparser.add_argument("--config", action=ActionConfigFile, help=config_help)
+        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))
+
+
 def _add_component_to_parser(component, parser, as_positional, fail_untyped, config_help):
     kwargs = dict(as_positional=as_positional, fail_untyped=fail_untyped, sub_configs=True)
     if inspect.isclass(component):


Test patch:
diff --git a/jsonargparse_tests/test_cli.py b/jsonargparse_tests/test_cli.py
index 77abd84..c612aa2 100644
--- a/jsonargparse_tests/test_cli.py
+++ b/jsonargparse_tests/test_cli.py
@@ -28,7 +28,7 @@ def get_cli_stdout(*args, **kwargs) -> str:
 # failure cases
 
 
-@pytest.mark.parametrize("components", [0, []])
+@pytest.mark.parametrize("components", [0, [], {"x": 0}])
 def test_unexpected_components(components):
     with pytest.raises(ValueError):
         CLI(components)
@@ -344,6 +344,45 @@ def test_dataclass_without_methods_parser_groups():
     assert parser.groups == {}
 
 
+# named components tests
+
+
+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"])
+
+
+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}},
+    }
+    kw = {"as_positional": False}
+    out = get_cli_stdout(components, args=["--help"], **kw)
+    assert " {lv1_a,lv1_b} ..." in out
+    out = get_cli_stdout(components, args=["lv1_a", "--help"], **kw)
+    assert " {lv2_x,lv2_y} ..." in out
+    out = get_cli_stdout(components, args=["lv1_a", "lv2_x", "--help"], **kw)
+    assert " --a1 A1" in out
+    out = get_cli_stdout(components, args=["lv1_a", "lv2_y", "--help"], **kw)
+    assert " {lv3_p} ..." in out
+    out = get_cli_stdout(components, args=["lv1_a", "lv2_y", "lv3_p", "--help"], **kw)
+    assert " --x X" in out
+    out = get_cli_stdout(components, args=["lv1_b", "--help"], **kw)
+    assert " {lv2_z} ..." in out
+    out = get_cli_stdout(components, args=["lv1_b", "lv2_z", "--help"], **kw)
+    assert " {lv3_q} ..." in out
+    out = get_cli_stdout(components, args=["lv1_b", "lv2_z", "lv3_q", "--help"], **kw)
+    assert " {method1} ..." in out
+    out = get_cli_stdout(components, args=["lv1_b", "lv2_z", "lv3_q", "method1", "--help"], **kw)
+    assert " --m1 M1" 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)
+    assert ("w", 9) == CLI(components, args=["lv1_b", "lv2_z", "lv3_q", "--i1=w", "method1", "--m1=9"], **kw)
+
+
 # config file tests