Optional argument in command with click - python

I am trying to accomplish something not very standard for CLI parsing with Click and it only works partially:
main CLI has multiple sub-commands (in sample below 'show' and 'check')
both those commands might have optional argument, but the argument is preceding them not following
I decided to handle that argument in the group "above" it and pass the value in the context
Sample:
import click
#click.group()
#click.argument('hostname', required=False)
#click.pass_context
def cli(ctx, hostname=None):
""""""
ctx.obj = hostname
click.echo("cli: hostname={}".format(hostname))
#cli.command()
#click.pass_obj
def check(hostname):
click.echo("check: hostname={}".format(hostname))
#cli.command()
#click.pass_obj
def show(hostname):
click.echo("check: hostname={}".format(hostname))
if __name__ == '__main__':
cli()
The part WITH the hostname works:
> pipenv run python cli.py localhost check
cli: hostname=localhost
check: hostname=localhost
> pipenv run python cli.py localhost show
cli: hostname=localhost
check: hostname=localhost
But the part WITHOUT the hostname DOES NOT:
> pipenv run python cli.py show
Usage: cli.py [OPTIONS] [HOSTNAME] COMMAND [ARGS]...
Error: Missing command.
Anybody has an idea about the direction I should start looking into?

This can be done by over riding the click.Group argument parser like:
Custom Class:
class MyGroup(click.Group):
def parse_args(self, ctx, args):
if args[0] in self.commands:
if len(args) == 1 or args[1] not in self.commands:
args.insert(0, '')
super(MyGroup, self).parse_args(ctx, args)
Using Custom Class:
Then to use the custom group, pass it as the cls argument to the group decorator like:
#click.group(cls=MyGroup)
#click.argument('hostname', required=False)
#click.pass_context
def cli(ctx, hostname=None):
....
How?
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 if the first parameter matches a command and the second parameter does not, then we insert an empty string as the first parameter. This puts everything back where the parser expects it to be.

Related

Combine cloup.group and click-default-group

I'm using cloup for my CLI for its constraints feature.
I have some commands a and b which have no common arguments.
import cloup
#cloup.group()
def cli():
pass
#cli.command(show_constraints=True)
#cloup.option("--foo")
def a(**kwargs):
print("hello")
#cli.command(show_constraints=True)
#cloup.option("--bar")
def b():
pass
cli()
I want a to be the default command. So, I'd like the following output:
$ python3 main.py
Usage: main.py [OPTIONS] COMMAND [ARGS]...
Options:
--help Show this message and exit.
Commands:
a
b
$ python3 main.py a --foo hey
hello
So far, this works as expected. Now I also want a to be the default command, thus I'd like to see:
$ python3 main.py --foo hey
hello
I know that I can have behaviour in cli as follows:
#cloup.group(invoke_without_command=True)
def cli():
print("custom behaviour")
That will give
$ python3 main.py
custom behaviour
I thought that I could forward the call to a in the cli function, but the group cli does not know the option --foo of command a:
$ python3 main.py --foo hey
Usage: main.py [OPTIONS] COMMAND [ARGS]...
Try 'main.py --help' for help.
Error: No such option: --foo
I'm stuck here. I found an answer to the question here (A command without name, in Click), but I have to use cloup.group. So if I applied the solution there ...
#cloup.group(cls=DefaultGroup, default='a',default_if_no_args=True)
def cli():
pass
... I'd get
Traceback (most recent call last):
File "main.py", line 11, in <module>
#cloup.option("--foo")
File "<SNIP>/.venv_3.6.9/lib/python3.6/site-packages/click/core.py", line 1834, in decorator
cmd = command(*args, **kwargs)(f)
File "<SNIP>/.venv_3.6.9/lib/python3.6/site-packages/click/decorators.py", line 184, in decorator
cmd = _make_command(f, name, attrs, cls) # type: ignore
File "<SNIP>/.venv_3.6.9/lib/python3.6/site-packages/click/decorators.py", line 152, in _make_command
**attrs,
TypeError: __init__() got an unexpected keyword argument 'show_constraints'
And that's only the tip of the spear - any other features from cloup.group also become unavailable.
I guess one could merge the groups of cloup and click-default-group, but that looks horribly time-consuming. Is there an easier way to get a default command in cloup?
I also found https://click.palletsprojects.com/en/8.0.x/api/?highlight=group#click.Context.ignore_unknown_options. But if I understood correctly, only commands have a context and groups do not, so it wouldn't help.
Author of Cloup here. You can try this:
"""
This example requires click-default-group.
"""
import cloup
from click import Context, HelpFormatter
from click_default_group import DefaultGroup
class GroupWithDefaultCommand(cloup.Group, DefaultGroup):
# Optional: mark default command with "*"
def format_subcommand_name(
self, ctx: click.Context, name: str, cmd: click.Command
) -> str:
if name == self.default_cmd_name:
name = name + "*"
return super().format_subcommand_name(ctx, name, cmd)
#cloup.group(cls=GroupWithDefaultCommand, default='alice')
def cli():
pass
#cli.command()
#cloup.option("--foo")
def alice(**kwargs):
print("Called alice with", kwargs)
#cli.command()
#cloup.option("--bar")
def bob(**kwargs):
print("Called bob with", kwargs)
if __name__ == '__main__':
cli()
AFAICS now, you'll only lose the "Did you mean" suggestion for mistyped commands (from Cloup) and the "*" indicating the dafault command (from click-default-group) (it was actually pretty easy to implement that with the method Group.format_subcommand_name introduced by Cloup). Let me know if you find any other problems. If it works well, I'll maybe add it to the examples folder.
Nonetheless, I'd suggest you to not use a default command at all. In click-default-group issue tracker, you can see it conflicts with click-help-colors and click-repl. So, unless you're not afraid of fixing issue that may potentially arise from having a default command, don't have one. As an alternative, you can just suggest your users to define an alias for the default command (e.g. by using the alias unix command).

How to add common options to sub commands which can go *after* the name of the sub command

Using the CLI library click I have an application script app.py with the two sub commands read and write:
#click.group()
#click.pass_context
def cli(ctx):
pass
#cli.command()
#click.pass_context
def read(ctx):
print("read")
#cli.command()
#click.pass_context
def write(ctx):
print("write")
I want to declare a common option --format. I know I can add it as an option to the command group via
#click.group()
#click.option('--format', default='json')
#click.pass_context
def cli(ctx, format):
ctx.obj['format'] = format
But then I cannot give the option after the command, which in my use case is a lot more natural. I want to be able to issue in the shell:
app.py read --format XXX
But with the outlined set-up I get the message Error: no such option: --format. The script only accepts the option before the command.
So my question is: How can I add a common option to both sub commands so that it works as if the option were given to each sub command?
AFAICT, this is not possible with Click. The docs state that:
Click strictly separates parameters between commands and subcommands.
What this means is that options and arguments for a specific command
have to be specified after the command name itself, but before any
other command names.
A possible workaround is writing a common_options decorator. The following example is using the fact that click.option is a function that returns a decorator function which expects to be applied in series. IOW, the following:
#click.option("-a")
#click.option("-b")
def hello(a, b):
pass
is equivalent to the following:
def hello(a, b):
pass
hello = click.option("-a")(click.option("-b")(hello))
The drawback is that you need to have the common argument set on all your subcommands. This can be resolved through **kwargs, which collects keyword arguments as a dict.
(Alternately, you could write a more advanced decorator that would feed the arguments into the context or something like that, but my simple attempt didn't work and i'm not ready to try more advanced approaches. I might edit the answer later and add them.)
With that, we can make a program:
import click
import functools
#click.group()
def cli():
pass
def common_options(f):
options = [
click.option("-a", is_flag=True),
click.option("-b", is_flag=True),
]
return functools.reduce(lambda x, opt: opt(x), options, f)
#cli.command()
#common_options
def hello(**kwargs):
print(kwargs)
# to get the value of b:
print(kwargs["b"])
#cli.command()
#common_options
#click.option("-c", "--citrus")
def world(citrus, a, **kwargs):
print("citrus is", citrus)
if a:
print(kwargs)
else:
print("a was not passed")
if __name__ == "__main__":
cli()
Yes you can add common options to sub commands which can go after the name of the sub command.
You can have options on parent command as well as on sub commands.
Check out below code snippet
import click
from functools import wraps
#click.group()
def cli():
pass
def common_options(f):
#wraps(f)
#click.option('--option1', '-op1', help='Option 1 help text', type=click.FLOAT)
#click.option('--option2', '-op2', help='Option 2 help text', type=click.FLOAT)
def wrapper(*args, **kwargs):
return f(*args, **kwargs)
return wrapper
#cli.group(invoke_without_command=True)
#common_options
#click.pass_context
def parent(ctx, option1, option2):
ctx.ensure_object(dict)
if ctx.invoked_subcommand is None:
click.secho('Parent group is invoked. Perform specific tasks to do!', fg='bright_green')
#parent.command()
#click.option('--sub_option1', '-sop1', help='Sub option 1 help text', type=click.FLOAT)
#common_options
def sub_command1(option1, option2, sub_option1):
click.secho('Perform sub command 1 operations', fg='bright_green')
#parent.command()
#click.option('--sub_option2', '-sop2', help='Sub option 2 help text', type=click.FLOAT)
#common_options
def sub_command2(option1, option2, sub_option2):
click.secho('Perform sub command 2 operations', fg='bright_green')
if __name__ == "__main__":
cli()
Usage
parent --help
=> prints parent group help text with options and sub commands
parent --option1 10 --option2 12
=> Parent group is invoked. Perform specific tasks to do!
parent sub_command1 --help
=> prints sub command 1 help text with options on sub commands
parent sub_command1 --option1 15 --option2 7 --sub_option1 5
=> Perform sub command 1 operations
parent sub_command2 --option1 15 --option2 7 --sub_option2 4
=> Perform sub command 2 operations

Click: how do I apply an action to all commands and subcommands but allow a command to opt-out (part duex)?

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

Python Click multiple group names

Is it possible to do something like this with Python Click? I want to use different names for the same click.Group.
import click
class CustomMultiGroup(click.Group):
def group(self, *args, **kwargs):
"""Behaves the same as `click.Group.group()` except if passed
a list of names, all after the first will be aliases for the first.
"""
def decorator(f):
if isinstance(args[0], list):
_args = [args[0][0]] + list(args[1:])
for alias in args[0][1:]:
cmd = super(CustomMultiCommand, self).group(
alias, *args[1:], **kwargs)(f)
cmd.short_help = "Alias for '{}'".format(_args[0])
else:
_args = args
cmd = super(CustomMultiCommand, self).group(
*_args, **kwargs)(f)
return cmd
return decorator
#click.group(cls=CustomMultiGroup)
def mycli():
pass
#cli.group(['my-group', 'my-grp'])
def my_group():
pass
#my_group.command()
def my_command():
pass
I want my command lines to be something like:
mycli my-group my-command
and
mycli my-grp my-command
but reference the same function.
This post is a reference to Python Click multiple command names
click.Group and click.Command behave differently so you need to modify the example to allow the aliasing group to access the aliased group's commands:
Custom Class
This class overides the click.Group.group() method which is used to decorate group functions. It adds the ability to pass a list of group aliases. This class also adds a short help which references the aliased group.
import click
class CustomMultiGroup(click.Group):
def group(self, *args, **kwargs):
"""Behaves the same as `click.Group.group()` except if passed
a list of names, all after the first will be aliases for the first.
"""
def decorator(f):
aliased_group = []
if isinstance(args[0], list):
# we have a list so create group aliases
_args = [args[0][0]] + list(args[1:])
for alias in args[0][1:]:
grp = super(CustomMultiGroup, self).group(
alias, *args[1:], **kwargs)(f)
grp.short_help = "Alias for '{}'".format(_args[0])
aliased_group.append(grp)
else:
_args = args
# create the main group
grp = super(CustomMultiGroup, self).group(*_args, **kwargs)(f)
# for all of the aliased groups, share the main group commands
for aliased in aliased_group:
aliased.commands = grp.commands
return grp
return decorator
Test Code:
#click.group(cls=CustomMultiGroup)
def cli():
pass
#cli.group(['my-group', 'my-grp'])
def my_group():
""" My Sub Command """
pass
#my_group.command('my-command')
def my_command():
click.echo("My Command")
cli('--help'.split())
cli('my-grp --help'.split())
cli('my-group --help'.split())
cli('my-grp my-command'.split())
cli('my-group my-command'.split())
Test Results:
Usage: my_cli [OPTIONS] COMMAND [ARGS]...
Options:
--help Show this message and exit.
Commands:
my-group My Sub Command
my-grp Alias for 'my-group'
Process finished with exit code 0
The easiest way to have command aliases in Click is using one of the following packages:
click-aliases
Cloup, which also adds several other features other than aliases.
Disclaimer: I'm the author of Cloup.
The two implementations are slightly different.
Cloup stores aliases of a command in the command itself. This allows Cloup to document aliases of a command (also) in the --help of the command itself.
click-aliases always shows aliases of (sub)commands in the "Commands" section of a group. In Cloup, you can enable/disable this behavior according to your preference (by default, aliases are not shown).

Python Click multiple command names

Is it possible to do something like this with Python Click?
#click.command(name=['my-command', 'my-cmd'])
def my_command():
pass
I want my command lines to be something like:
mycli my-command
and
mycli my-cmd
but reference the same function.
Do I need to do a class like AliasedGroup?
AliasedGroup is not what you are after, since it allows a shortest prefix match, and it appears you need actual aliases. But that example does provide hints in a direction that can work. It inherits from click.Group and overides some behavior.
Here is a one way to approach what you are after:
Custom Class
This class overides the click.Group.command() method which is used to decorate command functions. It adds the ability to pass a list of command aliases. This class also adds a short help which references the aliased command.
class CustomMultiCommand(click.Group):
def command(self, *args, **kwargs):
"""Behaves the same as `click.Group.command()` except if passed
a list of names, all after the first will be aliases for the first.
"""
def decorator(f):
if isinstance(args[0], list):
_args = [args[0][0]] + list(args[1:])
for alias in args[0][1:]:
cmd = super(CustomMultiCommand, self).command(
alias, *args[1:], **kwargs)(f)
cmd.short_help = "Alias for '{}'".format(_args[0])
else:
_args = args
cmd = super(CustomMultiCommand, self).command(
*_args, **kwargs)(f)
return cmd
return decorator
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() can be passed a list of command names.
#click.group(cls=CustomMultiCommand)
def cli():
"""My Excellent CLI"""
#cli.command(['my-command', 'my-cmd'])
def my_command():
....
Test Code:
import click
#click.group(cls=CustomMultiCommand)
def cli():
"""My Excellent CLI"""
#cli.command(['my-command', 'my-cmd'])
def my_command():
"""This is my command"""
print('Running the command')
if __name__ == '__main__':
cli('--help'.split())
Test Results:
Usage: my_cli [OPTIONS] COMMAND [ARGS]...
My Excellent CLI
Options:
--help Show this message and exit.
Commands:
my-cmd Alias for 'my-command'
my-command This is my command
Here is a simpler way to solve the same thing:
class AliasedGroup(click.Group):
def get_command(self, ctx, cmd_name):
try:
cmd_name = ALIASES[cmd_name].name
except KeyError:
pass
return super().get_command(ctx, cmd_name)
#click.command(cls=AliasedGroup)
def cli():
...
#click.command()
def install():
...
#click.command()
def remove():
....
cli.add_command(install)
cli.add_command(remove)
ALIASES = {
"it": install,
"rm": remove,
}
Since this question has been asked, a click-aliases library has been created.
It works a bit like the other answers except that you don’t have to declare the command class by yourself:
import click
from click_aliases import ClickAliasedGroup
#click.group(cls=ClickAliasedGroup)
def cli():
pass
#cli.command(aliases=['my-cmd'])
def my_command():
pass

Categories