Feature flag for Python click commands - python

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

Related

Using Boolean Flags in Python Click Library (command line arguments)

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()

Python Click - only execute subcommand if parent command executed successfully

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.

How to set the default option as -h for Python click?

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()

How do I create an exception to a group action in click

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

Alias for a chain of commands

I have a tool with commands: step1, step2 and step3.
I can chain them by calling:
$ tool step1 step2 step3
I would like to have an alias named all to run all the steps by calling:
$ tool all
I have found a solution that works but it doesn't seem right for me because of calling cli() twice under the hood:
#click.group(chain=True)
def cli():
print('cli() has been called')
...
#cli.command()
def all():
cli(args=['step1', 'step2', 'step3'])
How else could this be done without the side effect of calling cli() twice?
One way to provide some aliases is to intercept the command and directly manipulate the args list. That can be done with a custom class like:
Custom Class
This class overrides the click.Group.__call__() method to allow editing the args list before calling the command processor. In addition it overrides format_epilog to add help documentation for the aliases.
class ExpandAliasesGroup(click.Group):
def __init__(self, *args, **kwargs):
self.aliases = kwargs.pop('aliases', {})
super(ExpandAliasesGroup, self).__init__(*args, **kwargs)
def __call__(self, *args, **kwargs):
if args and args[0] and args[0][0] in self.aliases:
alias = self.aliases[args[0][0]]
args[0].pop(0)
for command in reversed(alias):
args[0].insert(0, command)
return super(ExpandAliasesGroup, self).__call__(*args, **kwargs)
#property
def alias_help(self):
return '\n'.join(
'{}: {}'.format(alias, ' '.join(commands))
for alias, commands in sorted(self.aliases.items())
)
def format_epilog(self, ctx, formatter):
"""Inject our aliases into the help string"""
if self.aliases:
formatter.write_paragraph()
formatter.write_text('Aliases:')
with formatter.indentation():
formatter.write_text(self.alias_help)
# call the original epilog
super(ExpandAliasesGroup, self).format_epilog(ctx, formatter)
Using the Custom Class
By passing the cls parameter, and a dict of aliases to the click.group() decorator, the ExpandAliasesGroup class can do alias expansion.
aliases = dict(all='command1 command2 command3'.split())
#click.group(chain=True, cls=ExpandAliasesGroup, aliases=aliases)
def cli():
....
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.
By overriding the __call__ method we can intercept all command calls. Then if the list of args starts with a known alias, we edit the args list by removing that aliased command and replacing it with the aliases.
By overriding the format_epilog method we can add help documentation for the aliases.
Test Code:
import click
aliases = dict(all='command1 command2 command3'.split())
#click.group(cls=ExpandAliasesGroup, chain=True, aliases=aliases)
def cli():
pass
#cli.command()
def command1():
click.echo('Command 1')
#cli.command()
def command2():
click.echo('Command 2')
#cli.command()
def command3():
click.echo('Command 3')
if __name__ == "__main__":
commands = (
'command1',
'command3',
'command1 command2',
'all',
'--help',
)
for cmd in commands:
try:
print('-----------')
print('> ' + cmd)
cli(cmd.split())
except:
pass
Test Results:
-----------
> command1
Command 1
-----------
> command3
Command 3
-----------
> command1 command2
Command 1
Command 2
-----------
> all
Command 1
Command 2
Command 3
-----------
> --help
Usage: test.py [OPTIONS] COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]...
Options:
--help Show this message and exit.
Commands:
command1 Command #1 comes first
command2 Command #2 is after command #1
command3 Command #3 saves the best for last
Aliases:
all: command1 command2 command3

Categories

Resources