Repository: omni-us/jsonargparse

Issue:
Add multilevel subcommands with class objects support to CLI
## 🚀 Feature request

I would like to request the ability to define multilevel subcommands using only class objects.

### Motivation

Related: #334

We cannot define multilevel subcommands using class objects. While it's possible to define such subcommands using dictionaries, in this case, generating help from docstrings is not feasible (see below).

```python
class Sub3:
    """Docstring for Sub3."""

    def command3(self):
        ...


class Sub1:
    """Docstring for Sub1."""

    def command1(self):
        ...


if __name__ == "__main__":
    import jsonargparse

    jsonargparse.CLI(
        {
            "sub1": Sub1,
            "sub2": {  # sub2 has no help string.
                "sub3": Sub3,
            }
        }
    )
```

```
$ python -m app -h           
usage: app.py [-h] [--config CONFIG] [--print_config[=flags]] {sub1,sub2} ...

options:
  -h, --help            Show this help message and exit.
  --config CONFIG       Path to a configuration file.
  --print_config[=flags]
                        Print the configuration after applying all other arguments and exit. The optional   
                        flags customizes the output and are one or more keywords separated by comma. The    
                        supported flags are: comments, skip_default, skip_null.

subcommands:
  For more details of each subcommand, add it as an argument followed by --help.

  Available subcommands:
    sub1                Docstring for Sub1.
    sub2
```

```
$ python -m app sub2 -h
usage: app.py [options] sub2 [-h] [--config CONFIG] [--print_config[=flags]] {sub3} ...

dict() -> new empty dictionary

options:
  -h, --help            Show this help message and exit.
  --config CONFIG       Path to a configuration file.
  --print_config[=flags]
                        Print the configuration after applying all other arguments and exit. The optional
                        flags customizes the output and are one or more keywords separated by comma. The
                        supported flags are: comments, skip_default, skip_null.

subcommands:
  For more details of each subcommand, add it as an argument followed by --help.

  Available subcommands:
    sub3                Docstring for Sub3.
```

So, we have to choose between multilevel subcommands or complete help.

Also, I personally don't like using a dictionary to define CLIs. It is not intuitive and can easily get messy.

### Pitch

One idea I came up with is to use properties as references to the sub-subcommands.

- The name of the property defines the name of the sub-subcommand.
- The type hint of the return value of the property defines the component of the sub-subcommand.

This allows us to define arbitrary levels of subcommands using only class objects and generate complete help using only docstrings.

Example:

```python
class Sub3:
    """Docstring for Sub3."""

    def command3(self):
        """Docstring for command3."""
        ...


class Sub2:
    """Docstring for Sub2."""

    def command2(self):
        """Docstring for command2."""
        ...

    @property
    def sub3(self) -> Sub3:
        return Sub3()


class Main:
    def command1(self):
        """Docstring for command1."""
        ...

    @property
    def sub2(self) -> Sub2:
        return Sub2()


if __name__ == "__main__":
    import jsonargparse

    jsonargparse.CLI(Main)
```

In a shell, it can be called like this.

```shell
python -m app sub2 sub3 command3
```

This is equivalent to the following Python code.

```python
Main().sub2.sub3.command3()
```

As a proof of concept, I implemented this idea.
https://github.com/Kenji-Hata/jsonargparse/pull/1/files

The changes required seem small, but I'm new to jsonargparse, so I may be missing something.

### Alternatives

As an alternative to multilevel subcommands, another solution is [chaining function calls](https://google.github.io/python-fire/guide/#chaining-function-calls). That is, if a command returns a class object or function, use it as the next component and run the CLI with the remaining arguments.

With this approach, we could give additional arguments to each level of the subcommand.

```shell
python -m app sub2 --x=1 sub3 --y=2 command3
```

```python
Main().sub2(x=1).sub3(y=2).command3()
```

This is not what I requested, as I do not design CLIs like this, but if there are plans to add such a feature, then the feature I suggested would be redundant.

Hints from issue comments:
@mauvilsa: Thank you very much for the proposal!

I have several comments:

- The proposal makes `@property` behave differently to how `CLI` works currently. More precisely, it goes against the design: from the docs 
  > CLI() returns the value and it is up to the developer to decide what to do with it.

  Only now I realize that `@property` methods are currently ignored. But for consistency, I think `@property` should work like any other class method, returning the value. If `@property` would force a subcommand behavior, then it wouldn't be possible for someone to use its value, instead of requiring to run a method of the object.

- It does not seem intuitive that to define deeper levels of subcommands, you need to chain returns of class instances. In fact, using a dict I would say is more intuitive. And note that this might not be desired for all instances. For example a property that returns a string (i.e. an instance of `str`) would require to run a method of that class.

- Only considering the case without options for each of the subcommands, is quite limited. You mention to have a use case, but I have trouble coming up with general use cases that would benefit lots of people.

- The alternative of chaining calls would fit better, because of being more intuitive, not imposing limitations and more generally applicable.

Since it was mentioned, I can give some ideas for a potential chaining feature. I am not completely convinced on how Fire does chaining. I would probable go for something more explicit, for instance using double dash `--` to request to chain. And I can think of some cases:
- method returns self and the user wants to execute another method of self.
- method returns some object and the user wants to run a method of the returned object.
- method returns some object and the user wants to run another method of the same class instance as the executed method, and using as input to the second the returned object.@tensorcopy: Hi @mauvilsa , just came across with this comment and found my issue might relate to it. I have two arguments linked from data to model when `instantiate`. The linked property is managed by `@property`. This was working until I upgrade `jsonargparse` beyond `4.19`. Wonder if there is a workaround of this. Thanks!@mauvilsa: @tensorcopy I don't really understand what you mean about `@property` and how it relates to my comment. If there is some behavior that used to work before v4.19, please create a bug issue, hopefully with a minimal reproduction so that it can be easy to understand what the problem is.@mauvilsa: Adding help when defining subcommands with a dict is already possible like:
```python
jsonargparse.auto_cli(
        {
            "sub1": Sub1,
            "sub2": {
                "_help": "Description for sub2",
                "sub3": Sub3,
            }
        }
    )
```

Properties will not be used to define subcommands. If support for properties is added, it will be only to return the value of the property.

If someone is interested in chaining calls, please create a new issue. For now I will close this as not planned.

Solution patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 58783556..feef512c 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -21,6 +21,8 @@ Added
   <https://github.com/omni-us/jsonargparse/pull/699>`__).
 - ``shtab`` support for optionals parsed as positionals (`#700
   <https://github.com/omni-us/jsonargparse/pull/700>`__).
+- ``auto_cli`` now supports class ``@property`` (`#701
+  <https://github.com/omni-us/jsonargparse/pull/701>`__).
 
 Changed
 ^^^^^^^
diff --git a/jsonargparse/_cli.py b/jsonargparse/_cli.py
index 73352560..32953bbb 100644
--- a/jsonargparse/_cli.py
+++ b/jsonargparse/_cli.py
@@ -157,7 +157,7 @@ def _add_subcommands(
                 remove_actions(subparser, (ActionConfigFile, _ActionPrintConfig))
 
 
-def has_parameter(component, name):
+def has_parameter(component, name) -> bool:
     return name in inspect.signature(component).parameters.keys()
 
 
@@ -170,7 +170,9 @@ def _add_component_to_parser(
 ):
     kwargs: dict = dict(as_positional=as_positional, fail_untyped=fail_untyped, sub_configs=True)
     if inspect.isclass(component):
-        class_methods = [k for k, v in inspect.getmembers(component) if callable(v) and k[0] != "_"]
+        class_methods = [
+            k for k, v in inspect.getmembers(component) if (callable(v) or isinstance(v, property)) and k[0] != "_"
+        ]
         if not class_methods:
             added_args = parser.add_class_arguments(component, as_group=False, **kwargs)
             if not parser.description:
@@ -182,12 +184,13 @@ def _add_component_to_parser(
             method_object = getattr(component, method)
             description = get_help_str(method_object, parser.logger)
             subparser = type(parser)(description=description)
-            if not has_parameter(getattr(component, method), "config"):
-                subparser.add_argument("--config", action=ActionConfigFile, help=config_help)
-            added_subargs = subparser.add_method_arguments(component, method, as_group=False, **kwargs)
-            added_args += [f"{method}.{a}" for a in added_subargs]
-            if not added_subargs:
-                remove_actions(subparser, (ActionConfigFile, _ActionPrintConfig))
+            if not isinstance(method_object, property):
+                if not has_parameter(method_object, "config"):
+                    subparser.add_argument("--config", action=ActionConfigFile, help=config_help)
+                added_subargs = subparser.add_method_arguments(component, method, as_group=False, **kwargs)
+                added_args += [f"{method}.{a}" for a in added_subargs]
+                if not added_subargs:
+                    remove_actions(subparser, (ActionConfigFile, _ActionPrintConfig))
             subcommands.add_subcommand(method, subparser, help=get_help_str(method_object, parser.logger))
     else:
         added_args = parser.add_function_arguments(component, as_group=False, **kwargs)
@@ -203,6 +206,8 @@ def _run_component(component, cfg):
         subcommand_cfg = cfg.pop(subcommand, {})
         subcommand_cfg.pop("config", None)
         component_obj = component(**cfg)
+        if isinstance(getattr(component, subcommand), property):
+            return getattr(component_obj, subcommand)
         component = getattr(component_obj, subcommand)
         cfg = subcommand_cfg
     if inspect.iscoroutinefunction(component):


Test patch:
diff --git a/jsonargparse_tests/test_cli.py b/jsonargparse_tests/test_cli.py
index 507cd3f2..ea860fa7 100644
--- a/jsonargparse_tests/test_cli.py
+++ b/jsonargparse_tests/test_cli.py
@@ -237,6 +237,25 @@ def test_method_with_config_parameter():
     assert "p1: 1, config: {}" == out.strip()
 
 
+class WithProperty:
+    def __init__(self, num: int = 1):
+        self._num = num
+
+    @property
+    def prop(self):
+        """Description of property"""
+        return self._num + 1
+
+
+def test_class_property_value():
+    assert 3 == auto_cli(WithProperty, args=["--num=2", "prop"])
+    if docstring_parser_support:
+        help_str = get_cli_stdout(WithProperty, args=["--help"])
+        assert "Description of property" in help_str
+        help_str = get_cli_stdout(WithProperty, args=["prop", "--help"])
+        assert "Description of property" in help_str
+
+
 # function and class tests