Search code examples
pythoncommand-line-interfacepython-click

SSH- or sudo-like behaviour with python click


I use click for my Python script command line handling. I'd like to achieve behaviour similar to SSH or Sudo, i.e. parse some known arguments and take everything else as is without any processing.

For example, consider this command line:

ssh -v myhost echo -n -e foo bar

Notice that -v will be processed by SSH, but echo and everything after it will not be processed as options.

Here is my current implementation:

@click.command()
@click.option('-v', '--verbose', is_flag=True)
@click.argument('target')
@click.argument('command', nargs=-1)
def my_command(verbose, target, command):
    print('verbose:', verbose)
    print('target:', target)
    print('command:', command)

It does not work as expected:

$ python test.py -v hostname echo -e foo
Usage: test.py [OPTIONS] TARGET [COMMAND]...
Try "test.py --help" for help.

Error: no such option: -e

I can add -- delimiter to force the expected behavior:

$ python /tmp/test.py -v hostname -- echo -e foo
verbose: True
target: hostname
command: ('echo', '-e', 'foo')

But it is not what I want.

I can also add ignore_unknown_options=True:

@click.command(context_settings=dict(ignore_unknown_options=True))
...
$ python /tmp/test.py -v hostname echo -e foo
verbose: True
target: hostname
command: ('echo', '-e', 'foo')

But it won't work with known options, like -v in this case.

So the question is: how to instruct click to stop handling any options after certain argument is encountered?


Solution

  • You can use a custom click.Command class to automatically insert the -- where needed like:

    Custom Class:

    class RealNargsMinusOne(click.Command):
    
        def parse_args(self, ctx, args):
            orig_args = list(args)
            try:
                return super(RealNargsMinusOne, self).parse_args(ctx, args)
            except click.NoSuchOption as exc:
                first_unknown = str(exc).split()[-1]
                position_unknown = orig_args.index(first_unknown)
                orig_args.insert(position_unknown, '--')
                return super(RealNargsMinusOne, self).parse_args(ctx, orig_args)
    

    Using the Custom Class:

    To use the custom class, pass it as the cls argument to the command decorator like:

    @click.command(cls=RealNargsMinusOne)
    ....
    def my_command():
        ....
    

    How does this work?

    This works because click is a well designed OO framework. The @click.command() decorator usually instantiates a click.Command object but allows this behavior to be over ridden with the cls parameter. So it is a relatively easy matter to inherit from click.Command in our own class and over ride desired methods.

    In this case we over ride click.Command.parse_args() and catch the click.NoSuchOption exception. We then amend the argument list with a -- in front of the offending option and retry the parse.

    Test Code:

    import click
    
    @click.command(cls=RealNargsMinusOne)
    @click.option('-v', '--verbose', is_flag=True)
    @click.argument('target')
    @click.argument('command', nargs=-1)
    def my_command(verbose, target, command):
        print('verbose:', verbose)
        print('target:', target)
        print('command:', command)
    
    
    if __name__ == "__main__":
        commands = (
            '-v hostname echo -e foo',
            '-v hostname -x echo -e foo',
            '-x hostname -x echo -e foo',
            '--help',
            '',
        )
    
        import sys, time
    
        time.sleep(1)
        print('Click Version: {}'.format(click.__version__))
        print('Python Version: {}'.format(sys.version))
        for cmd in commands:
            try:
                time.sleep(0.1)
                print('-----------')
                print('> ' + cmd)
                time.sleep(0.1)
                my_command(cmd.split(), allow_extra_args=True)
    
            except BaseException as exc:
                if str(exc) != '0' and \
                        not isinstance(exc, (click.ClickException, SystemExit)):
                    raise
    

    Results:

    Click Version: 6.7
    Python Version: 3.6.3 (v3.6.3:2c5fed8, Oct  3 2017, 18:11:49) [MSC v.1900 64 bit (AMD64)]
    -----------
    > -v hostname echo -e foo
    verbose: True
    target: hostname
    command: ('echo', '-e', 'foo')
    -----------
    > -v hostname -x echo -e foo
    verbose: True
    target: hostname
    command: ('-x', 'echo', '-e', 'foo')
    -----------
    > -x hostname -x echo -e foo
    verbose: False
    target: -x
    command: ('hostname', '-x', 'echo', '-e', 'foo')
    -----------
    > --help
    Usage: test.py [OPTIONS] TARGET [COMMAND]...
    
    Options:
      -v, --verbose
      --help         Show this message and exit.
    -----------
    > 
    Usage: test.py [OPTIONS] TARGET [COMMAND]...
    
    Error: Missing argument "target".