Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add multilevel subcommands with class objects support to CLI #438

Open
Kenji-Hata opened this issue Jan 26, 2024 · 3 comments
Open

Add multilevel subcommands with class objects support to CLI #438

Kenji-Hata opened this issue Jan 26, 2024 · 3 comments
Labels
enhancement New feature or request

Comments

@Kenji-Hata
Copy link

🚀 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 Kenji-Hata added the enhancement New feature or request label Jan 26, 2024
@mauvilsa
Copy link
Member

mauvilsa commented Feb 6, 2024

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
Copy link

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
Copy link
Member

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

3 participants