I'm trying to create a Click based command line interface, and I've found CLI to be sufficient, however I can't seem to figure out how I should design it. So far I have the below code which creates 4 commands. However what I ideally want is something like this:
commands:
cli.py env delete NAME
cli.py env list
cli.py source delete NAME
cli.py source list
my current code:
#click.group()
#click.version_option()
def cli():
"""First paragraph.
"""
#cli.command()
def list_env():
"list env"
#cli.command()
def delete_env(name):
"Delete enviroment"
#cli.command()
def list_source():
"list source"
def delete_source(name):
"Delete source"
if __name__ == '__main__':
cli()
What you are trying to do can be done with sub-groups. The key is to declare two more groups (ie: env and source) that are sub-groups of cli, and then the commands (ie: list and delete) will be associated with the sub-groups like:
Code:
import click
#click.group()
#click.version_option()
def cli():
"""First paragraph.
"""
#cli.group()
def env():
"""env sub-command"""
#env.command('list')
def list_():
click.echo("env list")
#env.command()
#click.argument('name')
def delete(name):
click.echo("env delete %s" % name)
#cli.group()
def source():
"""source sub-command"""
#source.command('list')
def list_():
click.echo("source list")
#source.command()
#click.argument('name')
def delete(name):
click.echo("source delete %s" % name)
Test Code:
if __name__ == '__main__':
#cli('env list'.split())
cli('env delete a_name'.split())
#cli('source list'.split())
#cli('source delete a_name'.split())
Results:
env delete a_name
Related
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
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
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
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
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