Search code examples
pythoncommand-line-interfaceargparsepython-click

Python multi-command CLI with common options


I am adding CLI for my Python application. The CLI should allow to run multiple commands in a time. The commands should have common options and personal options.

Example:

$ python mycliapp.py --common-option1 value1 --common-option2 value2 cmd1 --cmd1-option cmd2 --cmd2-option somevalue cmd3

The example has two common options used by all commands and each command can have or not the option used by the command only.

I have considered Python Click. It has rich functionality, but it does not allow (at least I didn't found) to use common options without some main command.

The above example will look as follows with Click:

$ python mycliapp.py maincmd --common-option1 value1 --common-option2 value2 cmd1 --cmd1-option cmd2 --cmd2-option somevalue cmd3

Also, considered Python Argparse. It looks that it can do what I need and I have managed to write a code, which works with common options and single command, but cannot manage to use multiple commands. This page Python argparse - Add argument to multiple subparsers has good example, but seems that command2 should be a sub-command of command1. It is a bit different since I need that the commands can be executed in any order.


Solution

  • Click absolutely supports this sort of syntax. A simple example looks something like:

    import click
    
    
    @click.group(chain=True)
    @click.option('--common-option1')
    @click.option('--common-option2')
    def main(common_option1, common_option2):
        pass
    
    
    @main.command()
    @click.option('--cmd1-option', is_flag=True)
    def cmd1(cmd1_option):
        pass
    
    
    @main.command()
    @click.option('--cmd2-option')
    def cmd2(cmd2_option):
        pass
    
    
    @main.command()
    def cmd3():
        pass
    
    
    if __name__ == '__main__':
        main()
    

    Assuming the above is in mycliapp.py, we see the common help output:

    $ python example.py --help
    Usage: example.py [OPTIONS] COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]...
    
    Options:
      --common-option1 TEXT
      --common-option2 TEXT
      --help                 Show this message and exit.
    
    Commands:
      cmd1
      cmd2
      cmd3
    

    And for cmd1:

    $ python mycliapp.py cmd1 --help
    Usage: mycliapp.py cmd1 [OPTIONS]
    
    Options:
      --cmd1-option
      --help         Show this message and exit.
    

    And for cmd2:

    $ python mycliapp.py cmd2 --help
    Usage: mycliapp.py cmd2 [OPTIONS]
    
    Options:
      --cmd2-option TEXT
      --help              Show this message and exit.
    

    Etc.

    With this we can run the command line from your question:

    python mycliapp.py --common-option1 value1 --common-option2 value2 \
      cmd1 --cmd1-option \
      cmd2 --cmd2-option somevalue \
      cmd3
    

    Update 1

    Here's an example that implements pipelines using the callback model suggested in the documentation:

    import click
    
    
    @click.group(chain=True)
    @click.option('--common-option1')
    @click.option('--common-option2')
    @click.pass_context
    def main(ctx, common_option1, common_option2):
        ctx.obj = {
            'common_option1': common_option1,
            'common_option2': common_option2,
        }
    
    
    @main.resultcallback()
    def process_pipeline(processors, common_option1, common_option2):
        print('common_option1 is', common_option1)
        for func in processors:
            res = func()
            if not res:
                raise click.ClickException('Failed processing!')
    
    
    @main.command()
    @click.option('--cmd1-option', is_flag=True)
    def cmd1(cmd1_option):
        def process():
            print('This is cmd1')
            return cmd1_option
    
        return process
    
    
    @main.command()
    @click.option('--cmd2-option')
    def cmd2(cmd2_option):
        def process():
            print('This is cmd2')
            return cmd2_option != 'fail'
    
        return process
    
    
    @main.command()
    @click.pass_context
    def cmd3(ctx):
        def process():
            print('This is cmd3 (common option 1 is: {common_option1}'.format(**ctx.obj))
            return True
    
        return process
    
    
    if __name__ == '__main__':
        main()
    

    Each command returns a boolean indicating whether or not it was successful. A failed command will abort pipeline processing. For example, here cmd1 fails so cmd2 never executes:

    $ python mycliapp.py cmd1 cmd2
    This is cmd1
    Error: Failed processing!
    

    But if we make cmd1 happy, it works:

    $ python mycliapp.py cmd1 --cmd1-option cmd2
    This is cmd1
    This is cmd2
    

    And similarly, compare this:

    $ python mycliapp.py cmd1 --cmd1-option cmd2 --cmd2-option fail cmd3
    This is cmd1
    This is cmd2
    Error: Failed processing!
    

    With this:

    $ python mycliapp.py cmd1 --cmd1-option cmd2  cmd3
    This is cmd1
    This is cmd2
    This is cmd3
    

    And of course you don't need to call things in order:

    $ python mycliapp.py cmd2 cmd1 --cmd1-option
    This is cmd2
    This is cmd1