Make a command available for two groups in python click? - python

I'm using click to write a cli program in Python, and I need to write something like this:
import click
#click.group()
def root():
"""root"""
pass
#root.group()
def cli():
"""test"""
pass
#root.group()
def cli2():
"""test"""
pass
#cli.command('test1')
#cli2.command('test1')
def test1():
"""test2"""
print 1234
return
root()
but this will fail with:
TypeError: Attempted to convert a callback into a command twice.
How can I share the command between multiple groups?

The group.command() decorator is a short cut which performs two functions. One is to create a command, the other is to attach the command to a group.
So, to share a command with multiple groups, you can decorate the command for one group like:
#cli.command('test1')
Then, since the command has already been created, you can simply add the click command object to other groups like:
cli2.add_command(test1)
Test Code:
import click
#click.group()
def root():
"""root"""
pass
#root.group()
def cli1():
click.echo('cli1')
#root.group()
def cli2():
click.echo('cli2')
#cli1.command('test1')
#click.argument('arg1')
def test1(arg1):
click.echo('test1: %s' % arg1)
cli2.add_command(test1)
root('cli2 test1 an_arg'.split())
Results:
cli2
test1: an_arg

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

Click : options of first command not usable after using CommandCollection()

I want to have two separate sub-commands, each with different options.
E.g. -
command first --one --two
command second --three
The options one and two are just for sub-command first and three for sub-command second.
My code is of the form:
#click.group()
#click.option('--one')
#click.option('--two')
def cli1():
print("clione")
#cli1.command()
def first():
pass
#click.group()
#click.option('--three')
def cli2():
print("clitwo")
#cli2.command()
def second():
pass
cli = click.CommandCollection(sources=[cli1, cli2])
if __name__ == '__main__':
cli()
But after running it, I'm not able to run any of the options for each sub-command.
I used this : Merging Multi Commands
I find the easiest way to do sub-commands is to only use one group, and I usually name that group cli like:
#click.group()
def cli():
pass
Using the name of the group, declare commands like:
#cli.command()
def name_of_command():
....
Test Code:
import click
#click.group()
def cli():
pass
#cli.command()
#click.option('--one')
#click.option('--two')
def first(one, two):
click.echo("clione %s %s" % (one, two))
#cli.command()
#click.option('--three')
def second(three):
click.echo("clitwo %s" % three)
cli('first --one 4'.split())
Results
clione 4 None

Using click.CommandCollection drops group options

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

Categories