Using click.CommandCollection drops group options - python

import click
#click.group()
#click.option('--username')
def cli1(username):
click.echo(username)
#cli1.command()
def something():
click.echo('howdy')
#click.group()
def cli2():
pass
#cli2.command()
def somethingelse():
click.echo('doody')
cli = click.CommandCollection(sources=[cli1, cli2])
if __name__ == '__main__':
cli()
I would expect that this would allow me to pass --username to something, but when I run this script:
python script.py something --username hi
I get:
Error: no such option: --username
It seems like using the CommandCollection is breaking my options. Has anyone else dealt with this before? There is an open ticket in the click repo for this that hasn't been touched since 2015 and has no solution.

With a bit of new plumbing this can be done.
How??
You can inherit from click.Group and then pass the created class to click.group() like:
#click.group(cls=GroupWithCommandOptions)
In the new class, the options on the group can be applied to the command for parsing, and then during command invocation, the group function can be called with the appropriate options.
New Group Class:
import click
class GroupWithCommandOptions(click.Group):
""" Allow application of options to group with multi command """
def add_command(self, cmd, name=None):
""" Hook the added command and put the group options on the command """
click.Group.add_command(self, cmd, name=name)
# add the group parameters to the command
for param in self.params:
cmd.params.append(param)
# hook the command's invoke with our own
cmd.invoke = self.build_command_invoke(cmd.invoke)
self.invoke_without_command = True
def build_command_invoke(self, original_invoke):
def command_invoke(ctx):
""" insert invocation of group function """
# separate the group parameters
ctx.obj = dict(_params=dict())
for param in self.params:
name = param.name
ctx.obj['_params'][name] = ctx.params[name]
del ctx.params[name]
# call the group function with its parameters
params = ctx.params
ctx.params = ctx.obj['_params']
self.invoke(ctx)
ctx.params = params
# now call (invoke) the original command
original_invoke(ctx)
return command_invoke
Test Code:
# Pass new group class to our group which needs options
#click.group(cls=GroupWithCommandOptions)
#click.option('--username')
def cli1(username):
click.echo(username)
#cli1.command()
def something():
click.echo('howdy')
#click.group()
def cli2():
pass
#cli2.command()
def somethingelse():
click.echo('doody')
cli = click.CommandCollection(sources=[cli1, cli2])
if __name__ == '__main__':
cli('something --username hi'.split())
Results:
hi
howdy

Related

Display full help text in python click

I am having the following problem and I am fearful there isn't a straghtforward way to solve it so I am asking here. I am using Click to implement a CLI and I have created several grouped commands under the main command. This is the code:
#click.group()
def main():
pass
#main.command()
def getq():
'''Parameters: --questionnaire_id, --question_id, --session_id, --option_id'''
click.echo('Question Answers')
When I type the main command alone in my terminal it lists all the subcommands with the help text next to each one. However, the text is not displayed fully for the case of getq. Instead, it displays only "Parameters: --questionnaire_id, --question_id,... ."
Is there a way to display it all?
Thank You
The easiest way to do this is to use the command's short_help argument:
#click.group()
def main():
pass
#main.command(short_help='Parameters: --questionnaire_id, --question_id, --session_id, --option_id')
def getq():
click.echo('Question Answers')
If you insist to use the docstring for this and want to override the automatic shortening of it, then you could use a custom Group class overriding the format_commands method to directly use cmd.help instead of the get_short_help_str method:
import click
from gettext import gettext as _
class FullHelpGroup(click.Group):
def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
"""Extra format methods for multi methods that adds all the commands
after the options.
"""
commands = []
for subcommand in self.list_commands(ctx):
cmd = self.get_command(ctx, subcommand)
# What is this, the tool lied about a command. Ignore it
if cmd is None:
continue
if cmd.hidden:
continue
commands.append((subcommand, cmd))
# allow for 3 times the default spacing
if len(commands):
limit = formatter.width - 6 - max(len(cmd[0]) for cmd in commands)
rows = []
for subcommand, cmd in commands:
help = cmd.help if cmd.help is not None else ""
rows.append((subcommand, help))
if rows:
with formatter.section(_("Commands")):
formatter.write_dl(rows)
#click.group(cls=FullHelpGroup)
def main():
pass
#main.command()
def getq():
'''Parameters: --questionnaire_id, --question_id, --session_id, --option_id'''
click.echo('Question Answers')
if __name__ == "__main__":
main()
You most probably want to override the max_content_width (at most 80 columns by default) also. You could do this by overriding the context settings:
import shutil
#click.group(cls=FullHelpGroup,
context_settings={'max_content_width': shutil.get_terminal_size().columns - 10})
def main():
pass

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

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

Optional argument in command with click

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.

Categories