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. -->


Comments:

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