I'm using Click to build a Python CLI and am running into an issue with how exceptions are handles in Click.
I'm not sure about the wording ("subcommand", "parentcommand") here but from my example you'll get the idea I hope. Let's assume this code:
#click.group()
#click.option("--something")
def mycli(something):
try:
#do something with "something" and set ctx
ctx.obj = {}
ctx.obj["somevar"] = some_result
except:
print("Something went wrong")
raise
#only if everything went fine call mycommand
#click.group()
#click.pass_context
def mygroup(ctx):
pass
#mygroup.command(name="mycommand")
#click.pass_context
def mycommand(ctx):
#this only works if somevar is set in ctx so don't call this if setting went wrong in mycli
When the application starts this is called:
if __name__ == "__main__":
mycli.add_command(mygroup)
mycli()
I then start the program like this:
python myapp --something somevalue mycommand
Expected behaviour: first mycli is called and the code in it is executed. If an exception is thrown it's caught by the except block, a message is printed and the exception is raised. Because we have no other try/except block this will result in termination of the script. The "sub"-command mycommand is never called because the program already terminated when running the "parent"-command mycli.
Actual behaviour: the exception is caughtand the message is printed, but mycommand is still called. It then fails with another exception message because the required context variable was not set.
How would I handle something like that? Basically I only want to call the subcommand mycommand only to be executed if everything in mycli went fine.
To handle the exception, but not continue onto the subcommands, you can simply call exit() like:
Code:
import click
#click.group()
#click.option("--something")
#click.pass_context
def mycli(ctx, something):
ctx.obj = dict(a_var=something)
try:
if something != '1':
raise IndexError('An Error')
except Exception as exc:
click.echo('Exception: {}'.format(exc))
exit()
Test Code:
#mycli.group()
#click.pass_context
def mygroup(ctx):
click.echo('mygroup: {}'.format(ctx.obj['a_var']))
pass
#mygroup.command()
#click.pass_context
def mycommand(ctx):
click.echo('mycommand: {}'.format(ctx.obj['a_var']))
if __name__ == "__main__":
commands = (
'mygroup mycommand',
'--something 1 mygroup mycommand',
'--something 2 mygroup mycommand',
'--help',
'--something 1 mygroup --help',
'--something 1 mygroup mycommand --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)
mycli(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)]
-----------
> mygroup mycommand
Exception: An Error
-----------
> --something 1 mygroup mycommand
mygroup: 1
mycommand: 1
-----------
> --something 2 mygroup mycommand
Exception: An Error
-----------
> --help
Usage: test.py [OPTIONS] COMMAND [ARGS]...
Options:
--something TEXT
--help Show this message and exit.
Commands:
mygroup
-----------
> --something 1 mygroup --help
Usage: test.py mygroup [OPTIONS] COMMAND [ARGS]...
Options:
--help Show this message and exit.
Commands:
mycommand
-----------
> --something 1 mygroup mycommand --help
mygroup: 1
Usage: test.py mygroup mycommand [OPTIONS]
Options:
--help Show this message and exit.
Related
I'm trying to figure out how to get help for global options shown in the help messages of sub-commands.
I created a simplistic cli:
#!/usr/bin/env python
import click
#click.group()
#click.option("-l", "--log-level", help="Set log level.")
def cli(log_level):
"CLI toolbox"
print("root")
#cli.group()
def admin():
print("admin")
#admin.command()
def invite():
print("invite")
if __name__ == "__main__":
cli()
Unfortunately the global options are not shown on the help screens of sub commands:
./cli.py --help
Usage: cli.py [OPTIONS] COMMAND [ARGS]...
CLI toolbox
Options:
-l, --log-level TEXT Set log level. <-- Option listed on global command
--help Show this message and exit.
Commands:
admin
./cli.py admin --help
root
Usage: cli.py admin [OPTIONS] COMMAND [ARGS]...
Options:
<- Option missing on command.
--help Show this message and exit.
Commands:
invite
This is by no means pretty, but it gets the options by defining a custom group so we can override the help message. It then also iterates through all subcommands, but that has been hardcoded to only reference that particular group, rather than programatically infer it.
import click
#click.group()
#click.option("-l", "--log-level", help="Set log level.")
def cli(log_level):
"CLI toolbox"
print("root")
class CustomHelpGroup(click.Group):
def format_help(self, ctx, formatter):
parent = ctx.parent
help_text = ['Greetings! Options:']
for param in parent.command.get_params(ctx):
help_text.append(' '.join(param.get_help_record(parent)))
help_text.append("\n" + ctx.get_usage() + "\n")
help_text.append('Commands:\n')
help_text.extend([f'{command_name}' for command_name, command in admin.commands.items()])
formatter.write('\n'.join(help_text))
#cli.group(cls=CustomHelpGroup)
def admin():
print("admin")
#admin.command()
def invite():
print("invite")
if __name__ == "__main__":
cli()
In my python-click-CLI script, I'm building some commands for features that should not be visible for users (to not confuse them), but visible for e.g. developers.
Is it possible to use feature flags for Python-click commands?
I would like to be able to configure (via a config file, etc) if a command is available or not. If a command-feature is disabled, the command should not be callable and the help should not show it.
Like this:
FLAG_ENABLED = False
# This command should not be shown and not be callable as long as the flag is disabled
#cli.command(name='specialfeature', active=FLAG_ENABLED)
def special_feature_command()
....
Obviously, I could change the body of my function:
#cli.command(name='specialfeature', active=FLAG_ENABLED)
def special_feature_command()
if FLAG_ENABLED:
...
else:
...
But then my command would still show up in help, which I would like to avoid.
You can add a feature flag functionality with a custom class like:
Custom Class
This class over rides the click.Group.command() method which is used to decorate command functions. It adds the ability to pass an active flag, which when False will skip adding the command to the group.
import click
class FeatureFlagCommand(click.Group):
def command(self, *args, active=True, **kwargs):
"""Behaves the same as `click.Group.command()` except added an
`active` flag which can be used to disable to command.
"""
if active:
return super(FeatureFlagCommand, self).command(*args, **kwargs)
else:
return lambda f: f
Using the Custom Class
By passing the cls parameter to the click.group() decorator, any commands added to the group via the the group.command() will be gated with the active flag.
#click.group(cls=FeatureFlagCommand)
def cli():
...
#cli.command(name='specialfeature', active=FLAG_ENABLED)
def special_feature_command()
...
How does this work?
This works because click is a well designed OO framework. It is easy to inherit from click.Group and build a new command() decorator. In the new command() decorator if the active flag is False we return the undecorated function instead of adding the function to the group.
Test Code:
#click.group(cls=FeatureFlagCommand)
def cli():
"""My Awesome Click Program"""
#cli.command(active=False)
def command1():
click.echo('Command 1')
#cli.command(active=True)
def command2():
click.echo('Command 2')
#cli.command()
def command3():
click.echo('Command 3')
if __name__ == "__main__":
commands = (
'command1',
'command2',
'command3',
'--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)]
-----------
> command1
Usage: test.py [OPTIONS] COMMAND [ARGS]...
Error: No such command "command1".
-----------
> command2
Command 2
-----------
> command3
Command 3
-----------
> --help
Usage: test.py [OPTIONS] COMMAND [ARGS]...
My Awesome Click Program
Options:
--help Show this message and exit.
Commands:
command2
command3
-----------
>
Usage: test.py [OPTIONS] COMMAND [ARGS]...
My Awesome Click Program
Options:
--help Show this message and exit.
Commands:
command2
command3
I'm trying to make a verbose flag for my Python program.
Currently, I'm doing this:
import click
#global variable
verboseFlag = False
#parse arguments
#click.command()
#click.option('--verbose', '-v', is_flag=True, help="Print more output.")
def log(verbose):
global verboseFlag
verboseFlag = True
def main():
log()
if verboseFlag:
print("Verbose on!")
if __name__ == "__main__":
main()
It'll never print "Verbose on!" even when I set the '-v' argument. My thoughts are that the log function needs a parameter, but what do I give it? Also, is there a way to check whether the verbose flag is on without global variables?
So click is not simply a command line parser. It also dispatches and processes the commands. So in your example, the log() function never returns to main(). The intention of the framework is that the decorated function, ie: log(), will do the needed work.
Code:
import click
#click.command()
#click.option('--verbose', '-v', is_flag=True, help="Print more output.")
def log(verbose):
click.echo("Verbose {}!".format('on' if verbose else 'off'))
def main(*args):
log(*args)
Test Code:
if __name__ == "__main__":
commands = (
'--verbose',
'-v',
'',
'--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)]
-----------
> --verbose
Verbose on!
-----------
> -v
Verbose on!
-----------
>
Verbose off!
-----------
> --help
Usage: test.py [OPTIONS]
Options:
-v, --verbose Print more output.
--help Show this message and exit.
The above answer was helpful, but this is what I ended up using. I thought I'd share since so many people are looking at this question:
#click.command()
#click.option('--verbose', '-v', is_flag=True, help="Print more output.")
def main(verbose):
if verbose:
# do something
if __name__ == "__main__":
# pylint: disable=no-value-for-parameter
main()
How to set the default option as -h for Python click?
By default, my script, shows nothing when no arguments is given to the duh.py:
import click
CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
#click.command(context_settings=CONTEXT_SETTINGS)
#click.option('--toduhornot', is_flag=True, help='prints "duh..."')
def duh(toduhornot):
if toduhornot:
click.echo('duh...')
if __name__ == '__main__':
duh()
[out]:
$ python3 test_click.py -h
Usage: test_click.py [OPTIONS]
Options:
--toduhornot prints "duh..."
-h, --help Show this message and exit.
$ python3 test_click.py --toduhornot
duh...
$ python3 test_click.py
Question:
As shown above, the default prints no information python3 test_click.py.
Is there a way such that, the default option is set to -h if no arguments is given, e.g.
$ python3 test_click.py
Usage: test_click.py [OPTIONS]
Options:
--toduhornot prints "duh..."
-h, --help Show this message and exit.
As of version 7.1, one can simply specify #click.command(no_args_is_help=True).
Your structure is not the recommended one, you should use:
import click
CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
#click.group(context_settings=CONTEXT_SETTINGS)
def cli():
pass
#cli.command(help='prints "duh..."')
def duh():
click.echo('duh...')
if __name__ == '__main__':
cli()
And then python test_click.py will print help message:
Usage: test_click.py [OPTIONS] COMMAND [ARGS]...
Options:
-h, --help Show this message and exit.
Commands:
duh prints "duh..."
So you can use python test_click.py duh to call duh.
Update
import click
CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
#click.command(context_settings=CONTEXT_SETTINGS)
#click.option('--toduhornot', is_flag=True, help='prints "duh..."')
def duh(toduhornot):
if toduhornot:
click.echo('duh...')
else:
with click.Context(duh) as ctx:
click.echo(ctx.get_help())
if __name__ == '__main__':
duh()
If you inherit from click.Command and override the parse_args() method, you can create a custom class to default to help like:
Custom Class
import click
class DefaultHelp(click.Command):
def __init__(self, *args, **kwargs):
context_settings = kwargs.setdefault('context_settings', {})
if 'help_option_names' not in context_settings:
context_settings['help_option_names'] = ['-h', '--help']
self.help_flag = context_settings['help_option_names'][0]
super(DefaultHelp, self).__init__(*args, **kwargs)
def parse_args(self, ctx, args):
if not args:
args = [self.help_flag]
return super(DefaultHelp, self).parse_args(ctx, args)
Using Custom Class:
To use the custom class, pass the cls parameter to #click.command() decorator like:
#click.command(cls=DefaultHelp)
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 the desired methods.
In this case we over-ride click.Command.parse_args() and check for an empty argument list. If it is empty then we invoke the help. In addition this class will default the help to ['-h', '--help'] if it is not otherwise set.
Test Code:
#click.command(cls=DefaultHelp)
#click.option('--toduhornot', is_flag=True, help='prints "duh..."')
def duh(toduhornot):
if toduhornot:
click.echo('duh...')
if __name__ == "__main__":
commands = (
'--toduhornot',
'',
'--help',
'-h',
)
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)
duh(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)]
-----------
> --toduhornot
duh...
-----------
>
Usage: test.py [OPTIONS]
Options:
--toduhornot prints "duh..."
-h, --help Show this message and exit.
-----------
> --help
Usage: test.py [OPTIONS]
Options:
--toduhornot prints "duh..."
-h, --help Show this message and exit.
-----------
> -h
Usage: test.py [OPTIONS]
Options:
--toduhornot prints "duh..."
-h, --help Show this message and exit.
Easiest approach I've found
import click
#click.command()
#click.option('--option')
#click.pass_context
def run(ctx, option):
if not option:
click.echo(ctx.get_help())
ctx.exit()
I have a case where I'd like to run a common function (check_upgrade()) for most of my click commands, but there are a few cases where I don't want to run it. Rather than relying on developers to remember to add a decorator or function call to explicitly run this check, I'd prefer to instead run it by default and then have a decorator that one can add (e.g. #bypass_upgrade_check) for commands where check_upgrade() should not run.
I was hoping for something like:
class State(object):
def __init__(self):
self.bypass_upgrade_check = False
pass_state = click.make_pass_decorator(State, ensure=True)
def bypass_upgrade_check(func):
#pass_state
def wrapper(state, *args, **kwargs):
state.bypass_upgrade_check = True
func(*args, **kwargs)
return wrapper
#click.group()
#pass_state
def common(state):
if not state.bypass_upgrade_check:
check_upgrade()
#common.command()
def cmd1():
# check_upgrade() runs here
pass
#bypass_upgrade_check
#common.command()
def cmd2():
# don't run check_upgrade() here
pass
But this doesn't work. It doesn't actually ever call the bypass_upgrade_check() function.
Is there a way to decorate a command in such a way that I can modify the state before the group code runs? Or another method altogether that accomplishes this?
To keep track of which commands bypass the upgrade check, I suggest that in the bypass marking decorator you store that state on the click.Command object. Then if you pass the click.Context to your group, you can then look at the command object to see if it is marked to allow skipping upgrade like:
Code:
def bypass_upgrade_check(func):
setattr(func, 'do_upgrade_check', False)
#click.group()
#click.pass_context
def cli(ctx):
sub_cmd = ctx.command.commands[ctx.invoked_subcommand]
if getattr(sub_cmd, 'do_upgrade_check', True):
check_upgrade()
Test Code:
import click
def check_upgrade():
click.echo('Checking Upgrade!')
def bypass_upgrade_check(func):
setattr(func, 'do_upgrade_check', False)
#click.group()
#click.pass_context
def cli(ctx):
sub_cmd = ctx.command.commands[ctx.invoked_subcommand]
if getattr(sub_cmd, 'do_upgrade_check', True):
check_upgrade()
#cli.command()
def cmd1():
# check_upgrade() runs here
click.echo('cmd1')
#bypass_upgrade_check
#cli.command()
def cmd2():
# don't run check_upgrade() here
click.echo('cmd2')
if __name__ == "__main__":
commands = (
'cmd1',
'cmd2',
)
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)]
-----------
> cmd1
Checking Upgrade!
cmd1
-----------
> cmd2
cmd2