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?
You can use a custom click.Command
class to automatically insert the --
where needed like:
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)
To use the custom class, pass it as the cls
argument to the command decorator like:
@click.command(cls=RealNargsMinusOne)
....
def my_command():
....
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.
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
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".