jsonargparse icon indicating copy to clipboard operation
jsonargparse copied to clipboard

Add multilevel subcommands with class objects support to CLI

Open Kenji-Hata opened this issue 5 months ago • 3 comments

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

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:

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.

python -m app sub2 sub3 command3

This is equivalent to the following Python code.

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

python -m app sub2 --x=1 sub3 --y=2 command3
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.

Kenji-Hata avatar Jan 26 '24 16:01 Kenji-Hata