I have a click application with three commands:
import click
#click.group(chain=True)
def cli():
print("MAIN")
#cli.command()
def initialize():
print("INITIALIZING")
#cli.command()
def update():
print("UPDATING")
#cli.command()
def process():
print("PROCESSING")
Defined this way, all of the commands can be chained.
But, how can I make initialize and update mutually exclusive? IE: it should be:
legal to run:
initialize -> process
and
update -> process
not legal to run:
initialize -> update -> process
You can mark chainable commands as mutually exclusive by creating a custom click.Group class.
Custom Class
class MutuallyExclusiveCommandGroup(click.Group):
def __init__(self, *args, **kwargs):
kwargs['chain'] = True
self.mutually_exclusive = []
super().__init__(*args, **kwargs)
def command(self, *args, mutually_exclusive=False, **kwargs):
"""Track the commands marked as mutually exclusive"""
super_decorator = super().command(*args, **kwargs)
def decorator(f):
command = super_decorator(f)
if mutually_exclusive:
self.mutually_exclusive.append(command)
return command
return decorator
def resolve_command(self, ctx, args):
"""Hook the command resolving and verify mutual exclusivity"""
cmd_name, cmd, args = super().resolve_command(ctx, args)
# find the commands which are going to be run
if not hasattr(ctx, 'resolved_commands'):
ctx.resolved_commands = set()
ctx.resolved_commands.add(cmd_name)
# if args is empty we have have found all of the commands to be run
if not args:
mutually_exclusive = ctx.resolved_commands & set(
cmd.name for cmd in self.mutually_exclusive)
if len(mutually_exclusive) > 1:
raise click.UsageError(
"Illegal usage: commands: `{}` are mutually exclusive".format(
', '.join(mutually_exclusive)))
return cmd_name, cmd, args
def get_help(self, ctx):
"""Extend the short help for the mutually exclusive commands"""
for cmd in self.mutually_exclusive:
mutually_exclusive = set(self.mutually_exclusive)
if not cmd.short_help:
cmd.short_help = 'mutually exclusive with: {}'.format(', '.join(
c.name for c in mutually_exclusive if c.name != cmd.name))
return super().get_help(ctx)
Using the Custom Class:
To use the custom class, pass it as the cls argument to the click.group decorator like:
#click.group(cls=MutuallyExclusiveCommandGroup)
#click.pass_context
def cli(ctx):
...
Then use the mutually_exclusive argument to the cli.command decorator to mark commands
as part of the mutually exclusive group.
#cli.command(mutually_exclusive=True)
def update():
...
How does this work?
This works because click is a well designed OO framework. The #click.group() decorator
usually instantiates a click.Group object but allows this behavior to be over ridden with the cls
parameter. So it is a relatively easy matter to inherit from click.Group in our own class and over
ride the desired methods.
In this case we override three methods: command(), resolve_command() & get_help(). The overridden
command() method allows us to track which commands are marked with the mutually_exclusive flag. The
overridden resolve_command() method is used to watch the command resolution process, and note which
commands are going to be run. If mutually exclusive commands are going to be run, it throws an error. The
overridden get_help method sets the short_help attribute to show which commands are mutually exclusive.
Test Code:
import click
#click.group(chain=True, cls=MutuallyExclusiveCommandGroup)
#click.pass_context
def cli(ctx):
print("MAIN")
#cli.command()
def initialize():
print("INITIALIZING")
#cli.command(mutually_exclusive=True)
def update():
print("UPDATING")
#cli.command(mutually_exclusive=True)
def process():
print("PROCESSING")
if __name__ == "__main__":
commands = (
'',
'initialize',
'update',
'process',
'initialize process',
'update process',
'initialize update process',
'--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)
cli(cmd.split())
except BaseException as exc:
if str(exc) != '0' and \
not isinstance(exc, (click.ClickException, SystemExit)):
raise
Test Results:
Click Version: 7.1.2
Python Version: 3.8.5 (tags/v3.8.5:580fbb0, Jul 20 2020, 15:57:54) [MSC v.1924 64 bit (AMD64)]
-----------
>
Usage: test_code.py [OPTIONS] COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]...
Options:
--help Show this message and exit.
Commands:
initialize
process mutually exclusive with: update
update mutually exclusive with: process
-----------
> initialize
MAIN
INITIALIZING
-----------
> update
MAIN
UPDATING
-----------
> process
MAIN
PROCESSING
-----------
> initialize process
MAIN
INITIALIZING
PROCESSING
-----------
> update process
MAIN
Error: Illegal usage: commands: `update, process` are mutually exclusive
-----------
> initialize update process
MAIN
Error: Illegal usage: commands: `update, process` are mutually exclusive
-----------
> --help
Usage: test_code.py [OPTIONS] COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]...
Options:
--help Show this message and exit.
Commands:
initialize
process mutually exclusive with: update
update mutually exclusive with: process.
Related
I'm building a click 7.x application with Python 3.6 and am having some issues getting help to work for sub commands. I have a global option that is required and this option is being reported as missing when I run help on any sub command.
For example, given the following dummy script cli.py:
import click
#click.group()
#click.option('--directory', required=True)
def cli(directory):
"""
this is a tool that has an add and remove command
"""
click.echo(directory)
#cli.command()
#click.overwrite('--overwrite', is_flag=True)
def add(overwrite):
"""
this is the add command
"""
click.echo("add overwrite={}".format(overwrite))
#cli.command()
def remove():
"""
this is the remove command
"""
click.echo('remove')
if __name__ == '__main__':
cli()
When I run the following:
python cli.py --help
I get the desired output of:
Usage cli.py [OPTIONS] COMMAND [ARGS]...
this is a tool that has an add and remove command
Options:
--directory TEXT [required]
--help Show this message and exit.
Commands:
add this is the add command
remove this is the remove command
But if I run this:
python cli.py add --help
I get the following error:
Usage cli.py [OPTIONS] COMMAND [ARGS]...
Try "cli.py --help" for help.
Error: Missing option "--directory"
How do I get the help for the add command to show without having to supply the --directory option?
You can use a custom click.Group class to ignore the required args when --help is requested like:
Custom Class:
class IgnoreRequiredWithHelp(click.Group):
def parse_args(self, ctx, args):
try:
return super(IgnoreRequiredWithHelp, self).parse_args(ctx, args)
except click.MissingParameter as exc:
if '--help' not in args:
raise
# remove the required params so that help can display
for param in self.params:
param.required = False
return super(IgnoreRequiredWithHelp, self).parse_args(ctx, args)
Using the Custom Class:
To use the custom class, pass it as the cls argument to the group decorator like:
#click.group(cls=IgnoreRequiredWithHelp)
....
def my_group():
....
How does this work?
This works because click is a well designed OO framework. The #click.group() decorator usually instantiates a click.Group object but allows this behavior to be over ridden with the cls parameter. So it is a relatively easy matter to inherit from click.Group in our own class and over ride desired methods.
In this case we over ride click.Group.parse_args() and catch the click.MissingParameter exception. We then negate the required attribute from all of the params, and retry the parse.
Test Code:
import click
#click.group(cls=IgnoreRequiredWithHelp)
#click.option('--directory', required=True)
def cli(directory):
"""
this is a tool that has an add and remove command
"""
click.echo(directory)
#cli.command()
#click.option('--overwrite', is_flag=True)
def add(overwrite):
"""
this is the add command
"""
click.echo("add overwrite={}".format(overwrite))
#cli.command()
def remove():
"""
this is the remove command
"""
click.echo('remove')
if __name__ == "__main__":
commands = (
'add --help',
'--help',
'--directory a_dir add'
'',
)
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)
cli(cmd.split())
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)]
-----------
> add --help
Usage: test.py add [OPTIONS]
this is the add command
Options:
--overwrite
--help Show this message and exit.
-----------
> --help
Usage: test.py [OPTIONS] COMMAND [ARGS]...
this is a tool that has an add and remove command
Options:
--directory TEXT
--help Show this message and exit.
Commands:
add this is the add command
remove this is the remove command
-----------
> --directory a_dir add
a_dir
add overwrite=False
I have a class that gets initialized with a previously unknown number of arguments and I want it to be done on CLI using Python's click package. My issue is that I can't manage to initialize it and run a click command:
$ python mycode.py arg1 arg2 ... argN click_command
Setting a defined number of arguments, like nargs=5, solves the issue of missing command but obligates me to input 5 arguments before my command. With variadic arguments like nargs=-1, click doesn't recognize click_command as a command.
How can I input n-many arguments, and then run the command using click?
import click
class Foo(object):
def __init__(self, *args):
self.args = args
def log(self):
print('self.args:', self.args)
pass_foo = click.make_pass_decorator(Foo)
#click.group()
#click.argument('myargs', nargs=-1)
#click.pass_context
def main(ctx, myargs):
ctx.obj = Foo(myargs)
print("arguments: ", myargs)
#main.command()
#pass_foo
def log(foo):
foo.log()
main()
I expect to be able to run a click command after passing n-many args to my Foo() class, so I can initialize it and run its log() method as a CLI command, but the output is:
Error: Missing command
I am not entirely sure what you are trying to do is the best way to approach this problem. I would think that placing the variadic arguments after the command would be a bit more logical, and would definitely more align with the way click works. But, you can do what you are after with this:
Custom Class:
class CommandAfterArgs(click.Group):
def parse_args(self, ctx, args):
parsed_args = super(CommandAfterArgs, self).parse_args(ctx, args)
possible_command = ctx.params['myargs'][-1]
if possible_command in self.commands:
ctx.protected_args = [possible_command]
ctx.params['myargs'] = ctx.params['myargs'][:-1]
elif possible_command in ('-h', '--help'):
if len(ctx.params['myargs']) > 1 and \
ctx.params['myargs'][-2] in self.commands:
ctx.protected_args = [ctx.params['myargs'][-2]]
parsed_args = ['--help']
ctx.params['myargs'] = ctx.params['myargs'][:-2]
ctx.args = [possible_command]
return parsed_args
Using Custom Class:
Then to use the custom class, pass it as the cls argument to the group decorator like:
#click.group(cls=CommandAfterArgs)
#click.argument('myargs', nargs=-1)
def main(myargs):
...
Test Code:
import click
class Foo(object):
def __init__(self, *args):
self.args = args
def log(self):
print('self.args:', self.args)
pass_foo = click.make_pass_decorator(Foo)
#click.group(cls=CommandAfterArgs)
#click.argument('myargs', nargs=-1)
#click.pass_context
def main(ctx, myargs):
ctx.obj = Foo(*myargs)
print("arguments: ", myargs)
#main.command()
#pass_foo
def log(foo):
foo.log()
if __name__ == "__main__":
commands = (
'arg1 arg2 log',
'log --help',
'--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)
main(cmd.split())
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)]
-----------
> arg1 arg2 log
arguments: ('arg1', 'arg2')
self.args: ('arg1', 'arg2')
-----------
> log --help
arguments: ()
Usage: test.py log [OPTIONS]
Options:
--help Show this message and exit.
-----------
> --help
Usage: test.py [OPTIONS] [MYARGS]... COMMAND [ARGS]...
Options:
--help Show this message and exit.
Commands:
log
I want to have a command line tool with a usage like this:
$ program <arg> does something, no command name required
$ program cut <arg>
$ program eat <arg>
The Click code would look like this:
#click.group()
def main() :
pass
#main.command()
#click.argument('arg')
def noname(arg) :
# does stuff
#main.command()
#click.argument('arg')
def cut(arg) :
# cuts stuff
#main.command()
#click.argument('arg')
def eat(arg) :
# eats stuff
My problem is that with this code, there is always a required command name, ie: I need to run $ program noname arg. But I want to be able to run $ program arg.
There is an option for you, "Group Invocation Without Command
":
#click.group(invoke_without_command=True)
#click.pass_context
def main(ctx):
if not ctx.invoked_subcommand:
print('main stuff')
click-default-group is doing what you are looking for. It is part of the click-contrib collection.
The advantage of that instead of using invoke_without_command is that it passes the options and arguments flawlessly to the default command, something that is not trivial (or even possible) with the built-in functionality.
Example code:
import click
from click_default_group import DefaultGroup
#click.group(cls=DefaultGroup, default='foo', default_if_no_args=True)
def cli():
print("group execution")
#cli.command()
#click.option('--config', default=None)
def foo(config):
click.echo('foo execution')
if config:
click.echo(config)
Then, it's possible to call foo command with its option as:
$ program foo --config bar <-- normal way to call foo
$ program --config bar <-- foo is called and the option is forwarded.
Not possible with vanilla Click.
Your scheme has some challenges because of the ambiguity introduce with the default command. Regardless, here is one way that can be achieved with click. As shown in the test results, the generated help with be less than ideal, but likely OK.
Custom Class:
import click
class DefaultCommandGroup(click.Group):
"""allow a default command for a group"""
def command(self, *args, **kwargs):
default_command = kwargs.pop('default_command', False)
if default_command and not args:
kwargs['name'] = kwargs.get('name', '<>')
decorator = super(
DefaultCommandGroup, self).command(*args, **kwargs)
if default_command:
def new_decorator(f):
cmd = decorator(f)
self.default_command = cmd.name
return cmd
return new_decorator
return decorator
def resolve_command(self, ctx, args):
try:
# test if the command parses
return super(
DefaultCommandGroup, self).resolve_command(ctx, args)
except click.UsageError:
# command did not parse, assume it is the default command
args.insert(0, self.default_command)
return super(
DefaultCommandGroup, self).resolve_command(ctx, args)
Using the Custom Class
To use the custom class, pass the cls parameter to the click.group() decorator. Then pass default_command=True for the command which will be the default.
#click.group(cls=DefaultCommandGroup)
def a_group():
"""My Amazing Group"""
#a_group.command(default_command=True)
def a_command():
"""a command under the group"""
How does this work?
This works because click is a well designed OO framework. The #click.group() decorator usually instantiates a click.Group object but allows this behavior to be over ridden with the cls parameter. So it is a relatively easy matter to inherit from click.Group in our own class and over ride desired methods.
In this case we over ride click.Group.command() so that when a command is added we find the default command. In addition we override click.Group.resolve_command() so that we can insert the default command name if the first resolution is unsuccessful.
Test Code:
#click.group(cls=DefaultCommandGroup)
def main():
pass
#main.command(default_command=True)
#click.argument('arg')
def noname(arg):
""" does stuff """
click.echo('default: {}'.format(arg))
#main.command()
#click.argument('arg')
def cut(arg):
""" cuts stuff """
click.echo('cut: {}'.format(arg))
#main.command()
#click.argument('arg')
def eat(arg):
""" eats stuff """
click.echo('eat: {}'.format(arg))
if __name__ == "__main__":
commands = (
'an_arg',
'cut cut_arg',
'eat eat_arg',
'--help',
'cut --help',
'eat --help',
'',
)
import sys, time
time.sleep(1)
print('Click Version: {}'.format(click.__version__))
print('Python Version: {}'.format(sys.version))
for command in commands:
try:
time.sleep(0.1)
print('-----------')
print('> ' + command)
time.sleep(0.1)
main(command.split())
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)]
-----------
> an_arg
default: an_arg
-----------
> cut cut_arg
cut: cut_arg
-----------
> eat eat_arg
eat: eat_arg
-----------
> --help
Usage: test.py [OPTIONS] COMMAND [ARGS]...
Options:
--help Show this message and exit.
Commands:
<> does stuff
cut cuts stuff
eat eats stuff
-----------
> cut --help
Usage: test.py cut [OPTIONS] ARG
cuts stuff
Options:
--help Show this message and exit.
-----------
> eat --help
Usage: test.py eat [OPTIONS] ARG
eats stuff
Options:
--help Show this message and exit.
-----------
>
Usage: test.py [OPTIONS] COMMAND [ARGS]...
Options:
--help Show this message and exit.
Commands:
<> does stuff
cut cuts stuff
eat eats stuff
An end-to-end practical example:
$ test_click add -a 1 -b 2
---
add: 3
------------------
$ test_click sub -a 5 -b 1
---
sub: 4
------------------
$ test_click
enter a operation (add or sub): add
enter a number 1: 1
enter a number 2: 2
---
add: 3
------------------
$ test_click
enter a operation (add or sub): sub
enter a number 1: 5
enter a number 2: 1
---
sub: 4
the code:
import click
#click.group(invoke_without_command=True)
#click.pass_context
def mycommands(ctx):
if ctx.invoked_subcommand is None:
manual_mode()
pass
def manual_mode():
tipo = input('enter a operation (add or sub): ')
arg1 = input('enter a number 1: ')
arg2 = input('enter a number 2: ')
if tipo == 'add':
add_f(int(arg1), int(arg2))
elif tipo == 'sub':
sub_f(int(arg1), int(arg2))
else:
print('type not know')
def add_f(arg1,
arg2):
print('add:', arg1 + arg2)
def sub_f(arg1,
arg2):
print('sub:', arg1 - arg2)
#click.option('-a', 'arg1',
type=click.INT)
#click.option('-b', 'arg2',
type=click.INT)
#mycommands.command()
def add(arg1, arg2):
add_f(arg1, arg2)
#click.option('-a', 'arg1',
type=click.INT)
#click.option('-b', 'arg2',
type=click.INT)
#mycommands.command()
def sub(arg1,
arg2):
sub_f(arg1, arg2)
if __name__ == '__main__':
mycommands()
Building on my initial question, I'd like to be able to have the body of a parent group run before I run my callback.
I have a case where I'd like to automatically run a common function, check_upgrade(), for most of my click commands and sub-commands, but there are a few cases where I don't want to run it. I was thinking I could have a decorator that one can add (e.g. #bypass_upgrade_check) for commands where check_upgrade() should not run.
For example:
def do_upgrade():
print("Performing upgrade")
bypass_upgrade_check = make_exclude_hook_group(do_upgrade)
#click.group(cls=bypass_upgrade_check())
#click.option('--arg1', default=DFLT_ARG1)
#click.option('--arg2', default=DFLT_ARG2)
#click.pass_context
def cli(ctx, arg1, arg2):
config.call_me_before_upgrade_check(arg1, arg2)
#bypass_upgrade_check
#cli.command()
def top_cmd1():
click.echo('cmd1')
#cli.command()
def top_cmd2():
click.echo('cmd2')
#cli.group()
def sub_cmd_group():
click.echo('sub_cmd_group')
#bypass_upgrade_check
#sub_cmd_group.command()
def sub_cmd1():
click.echo('sub_cmd1')
#sub_cmd_group.command()
def sub_cmd2():
click.echo('sub_cmd2')
I'd like things to function like explained in the initial question, but instead of executing do_upgrade() before executing the body of cli(), I'd like it to call:
cli() --> do_upgrade() --> top_cmd1()
for example. Or for a nested command:
cli() --> sub_cmd_group() --> do_upgrade() --> sub_cmd1()
So I guess another way to phrase the question is: is it possible to have the functionality from the original question, but have the callback be called right before the subcommand itself runs instead of being called before any of the Group blocks run?
The reason I need this is because the arguments passed in to the top-level CLI command indicate the server address to check for an upgrade. I need this information to process do_upgrade(). I can't pass this information directly to do_upgrade() because this server information is also used elsewhere in the application. I can query it from do_upgrade() with something like config.get_server().
In a similar fashion to the original question, one way to solve this is to build a custom decorator that pairs with a custom click.Group class. The added complication is to hook the Command.invoke() instead of the Group.invoke() so that the callback will be invoked immediately preceding the Command.invoke() and thus will be invoked after any Group.invoke():
Custom Decorator Builder:
import click
def make_exclude_hook_command(callback):
""" for any command that is not decorated, call the callback """
hook_attr_name = 'hook_' + callback.__name__
class HookGroup(click.Group):
""" group to hook context invoke to see if the callback is needed"""
def group(self, *args, **kwargs):
""" new group decorator to make sure sub groups are also hooked """
if 'cls' not in kwargs:
kwargs['cls'] = type(self)
return super(HookGroup, self).group(*args, **kwargs)
def command(self, *args, **kwargs):
""" new command decorator to monkey patch command invoke """
cmd = super(HookGroup, self).command(*args, **kwargs)
def hook_command_decorate(f):
# decorate the command
ret = cmd(f)
# grab the original command invoke
orig_invoke = ret.invoke
def invoke(ctx):
"""call the call back right before command invoke"""
parent = ctx.parent
sub_cmd = parent and parent.command.commands[
parent.invoked_subcommand]
if not sub_cmd or \
not isinstance(sub_cmd, click.Group) and \
getattr(sub_cmd, hook_attr_name, True):
# invoke the callback
callback()
return orig_invoke(ctx)
# hook our command invoke to command and return cmd
ret.invoke = invoke
return ret
# return hooked command decorator
return hook_command_decorate
def decorator(func=None):
if func is None:
# if called other than as decorator, return group class
return HookGroup
setattr(func, hook_attr_name, False)
return decorator
Using the decorator builder:
To use the decorator we first need to build the decorator like:
bypass_upgrade = make_exclude_hook_command(do_upgrade)
Then we need to use it as a custom class to click.group() like:
#click.group(cls=bypass_upgrade())
...
And finally, we can decorate any commands or sub-commands to the group that need to not use the callback like:
#bypass_upgrade
#my_group.command()
def my_click_command_without_upgrade():
...
How does this work?
This works because click is a well designed OO framework. The #click.group() decorator usually instantiates a click.Group object but allows this behavior to be over-ridden with the cls parameter. So it is a relatively easy matter to inherit from click.Group in our own class and over ride the desired methods.
In this case, we build a decorator that sets an attribute on any click function that does not need the callback called. Then in our custom group, we overide both the group() and the command() decorators so that we can we monkey patch invoke() on the command and if the command that is about to be executed has not been decorated, we call the callback.
Test Code:
def do_upgrade():
click.echo("Performing upgrade")
bypass_upgrade = make_exclude_hook_command(do_upgrade)
#click.group(cls=bypass_upgrade())
#click.pass_context
def cli(ctx):
click.echo('cli')
#bypass_upgrade
#cli.command()
def top_cmd1():
click.echo('cmd1')
#cli.command()
def top_cmd2():
click.echo('cmd2')
#cli.group()
def sub_cmd_group():
click.echo('sub_cmd_group')
#bypass_upgrade
#sub_cmd_group.command()
def sub_cmd1():
click.echo('sub_cmd1')
#sub_cmd_group.command()
def sub_cmd2():
click.echo('sub_cmd2')
if __name__ == "__main__":
commands = (
'top_cmd1',
'top_cmd2',
'sub_cmd_group sub_cmd1',
'sub_cmd_group sub_cmd2',
'--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)
cli(cmd.split())
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)]
-----------
> sub_cmd_group sub_cmd2
cli
sub_cmd_group
Performing upgrade
sub_cmd2
-----------
> top_cmd1
cli
cmd1
-----------
> top_cmd2
cli
Performing upgrade
cmd2
-----------
> sub_cmd_group sub_cmd1
cli
sub_cmd_group
sub_cmd1
-----------
> sub_cmd_group sub_cmd2
cli
sub_cmd_group
Performing upgrade
sub_cmd2
-----------
> --help
Usage: test.py [OPTIONS] COMMAND [ARGS]...
Options:
--arg1 TEXT
--arg2 TEXT
--help Show this message and exit.
Commands:
sub_cmd_group
top_cmd1
top_cmd2
I am using Click under a virtualenv and use the entry_point directive in setuptools to map the root to a function called dispatch.
My tool exposes two subcommands serve and config, I am using an option on the top level group to ensure that the user always passes a --path directive. However the usage turns out as follows:
mycommand --path=/tmp serve
both the serve and config sub commands need to ensure that the user always passes a path in and ideally I would like to present the cli as:
mycommand serve /tmp` or `mycommand config validate /tmp
current Click based implemenation is as follows:
# cli root
#click.group()
#click.option('--path', type=click.Path(writable=True))
#click.version_option(__version__)
#click.pass_context
def dispatch(ctx, path):
"""My project description"""
ctx.obj = Project(path="config.yaml")
# serve
#dispatch.command()
#pass_project
def serve(project):
"""Starts WSGI server using the configuration"""
print "hello"
# config
#dispatch.group()
#pass_project
def config(project):
"""Validate or initalise a configuration file"""
pass
#config.command("validate")
#pass_project
def config_validate(project):
"""Reports on the validity of a configuration file"""
pass
#config.command("init")
#pass_project
def config_init(project):
"""Initialises a skeleton configuration file"""
pass
Is this possible without adding the path argument to each sub command?
If there is a specific argument that you would like to decorate only onto the group, but be applicable to all commands as needed, you can do that with a bit of extra plumbing like:
Custom Class:
import click
class GroupArgForCommands(click.Group):
"""Add special argument on group to front of command list"""
def __init__(self, *args, **kwargs):
super(GroupArgForCommands, self).__init__(*args, **kwargs)
cls = GroupArgForCommands.CommandArgument
# gather the special arguments
self._cmd_args = {
a.name: a for a in self.params if isinstance(a, cls)}
# strip out the special arguments
self.params = [a for a in self.params if not isinstance(a, cls)]
# hook the original add_command method
self._orig_add_command = click.Group.add_command.__get__(self)
class CommandArgument(click.Argument):
"""class to allow us to find our special arguments"""
#staticmethod
def command_argument(*param_decls, **attrs):
"""turn argument type into type we can find later"""
assert 'cls' not in attrs, "Not designed for custom arguments"
attrs['cls'] = GroupArgForCommands.CommandArgument
def decorator(f):
click.argument(*param_decls, **attrs)(f)
return f
return decorator
def add_command(self, cmd, name=None):
# hook add_command for any sub groups
if hasattr(cmd, 'add_command'):
cmd._orig_add_command = cmd.add_command
cmd.add_command = GroupArgForCommands.add_command.__get__(cmd)
cmd.cmd_args = self._cmd_args
# call original add_command
self._orig_add_command(cmd, name)
# if this command's callback has desired parameters add them
import inspect
args = inspect.signature(cmd.callback)
for arg_name in reversed(list(args.parameters)):
if arg_name in self._cmd_args:
cmd.params[:] = [self._cmd_args[arg_name]] + cmd.params
Using the Custom Class:
To use the custom class, pass the cls parameter to the click.group() decorator, use the #GroupArgForCommands.command_argument decorator for special arguments, and then add a parameter of the same name as the special argument to any commands as needed.
#click.group(cls=GroupArgForCommands)
#GroupArgForCommands.command_argument('special')
def a_group():
"""My project description"""
#a_group.command()
def a_command(special):
"""a command under the group"""
How does this work?
This works because click is a well designed OO framework. The #click.group() decorator usually instantiates a click.Group object but allows this behavior to be over ridden with the cls parameter. So it is a relatively easy matter to inherit from click.Group in our own class and over ride desired methods.
In this case we over ride click.Group.add_command() so that when a command is added we can examine the command callback parameters to see if they have the same name as any of our special arguments. If they match, the argument is added to the command's arguments just as if it had been decorated directly.
In addition GroupArgForCommands implements a command_argument() method. This method is used as a decorator when adding a special argument instead of using click.argument()
Test Code:
def process_path_to_project(ctx, cmd, value):
"""param callback example to convert path to project"""
# Use 'path' to construct a project.
# For this example we will just annotate and pass through
return 'converted {}'.format(value)
#click.group(cls=GroupArgForCommands)
#GroupArgForCommands.command_argument('path',
callback=process_path_to_project)
def dispatch():
"""My project description"""
#dispatch.command()
def serve(path):
"""Starts WSGI server using the configuration"""
click.echo('serve {}'.format(path))
#dispatch.group()
def config():
"""Validate or initalise a configuration file"""
pass
#config.command("validate")
def config_validate():
"""Reports on the validity of a configuration file"""
click.echo('config_validate')
#config.command("init")
def config_init(path):
"""Initialises a skeleton configuration file"""
click.echo('config_init {}'.format(path))
if __name__ == "__main__":
commands = (
'config init a_path',
'config init',
'config validate a_path',
'config validate',
'config a_path',
'config',
'serve a_path',
'serve',
'config init --help',
'config validate --help',
'',
)
import sys, time
time.sleep(1)
print('Click Version: {}'.format(click.__version__))
print('Python Version: {}'.format(sys.version))
for command in commands:
try:
time.sleep(0.1)
print('-----------')
print('> ' + command)
time.sleep(0.1)
dispatch(command.split())
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)]
-----------
> config init a_path
config_init converted a_path
-----------
> config init
Usage: test.py config init [OPTIONS] PATH
Error: Missing argument "path".
-----------
> config validate a_path
Usage: test.py config validate [OPTIONS]
Error: Got unexpected extra argument (a_path)
-----------
> config validate
config_validate
-----------
> config a_path
Usage: test.py config [OPTIONS] COMMAND [ARGS]...
Error: No such command "a_path".
-----------
> config
Usage: test.py config [OPTIONS] COMMAND [ARGS]...
Validate or initalise a configuration file
Options:
--help Show this message and exit.
Commands:
init Initialises a skeleton configuration file
validate Reports on the validity of a configuration...
-----------
> serve a_path
serve converted a_path
-----------
> serve
Usage: test.py serve [OPTIONS] PATH
Error: Missing argument "path".
-----------
> config init --help
Usage: test.py config init [OPTIONS] PATH
Initialises a skeleton configuration file
Options:
--help Show this message and exit.
-----------
> config validate --help
Usage: test.py config validate [OPTIONS]
Reports on the validity of a configuration file
Options:
--help Show this message and exit.
-----------
>
Usage: test.py [OPTIONS] COMMAND [ARGS]...
My project description
Options:
--help Show this message and exit.
Commands:
config Validate or initalise a configuration file
serve Starts WSGI server using the configuration