I would like to create a CLI that receives an action and resource parameters to work, in a kubectl fashion. Eg.
myctl init config
myctl create issue|pr|branch
myctl delete issue|pr|branch|config
myctl command should always receive 2 arguments, so that the user wouldn't try something like: myctl init config delete issue. Also the should not be able to execute impossible combinations such as myctl create config.
I though of some code like:
import click
#click.command()
#click.group()
#click.option(
"init,create,delete",
type=str,
help="Action name.",
)
#click.option(
"config,issue,pr,branch",
type=str,
help="Resource name.",
)
def main(action: str, resource: str) -> None:
pass
if __name__ == "__main__":
main(prog_name="myctl")
I am not really sure what click elements should I use in order to structure that (arguments, options, groups, etc) and how to put that together.
I've achieved this in the past by creating multiple nested groups.
import click
#click.group('cli')
def cli():
pass
#click.group('init')
def init():
pass
#click.group('create')
def create():
pass
#click.group('delete')
def delete():
pass
#init.command('config')
def config():
print('Configuration complete')
#create.command('issue')
#click.argument('name')
def issue(name):
print(f'Created {name}')
#create.command('pr')
#click.argument('base_branch', required=False, default='master')
def pr(base_branch):
print(f'Created PR against {base_branch}')
#create.command('branch')
#click.argument('name')
def branch(name):
print(f'Create branch {name}')
#delete.command('issue')
#click.argument('issue_number')
def delete_issue(issue_number):
print(f'Deleting issue {issue_number}')
#delete.command('pr')
#click.argument('pr_number')
def delete_pr(pr_number):
print(f'Deleting PR {pr_number}')
#delete.command('branch')
#click.argument('branch_name')
def delete_branch(branch_name):
print(f'Deleting branch {branch_name}')
#delete.command('config')
#click.argument('config_name')
def delete_config(config_name):
print(f'Deleting config {config_name}')
cli.add_command(init)
cli.add_command(create)
cli.add_command(delete)
def run_cli():
cli()
if __name__ == "__main__":
run_cli()
You can then expand this however you want, with invocations looking like the following. I've called my CLI play.
❯ play
Usage: play [OPTIONS] COMMAND [ARGS]...
Options:
--help Show this message and exit.
Commands:
create
delete
init
❯ play init
Usage: play init [OPTIONS] COMMAND [ARGS]...
Options:
--help Show this message and exit.
Commands:
config
❯ play init config
Configuration complete
❯ play create
Usage: play create [OPTIONS] COMMAND [ARGS]...
Options:
--help Show this message and exit.
Commands:
branch
issue
pr
❯ play create branch feature/the-coolest
Create branch feature/the-coolest
You can then proceed to add short help messages and customise it to your application.
Related
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).
I want to make a simple CLI program that would accept --edit-config option to open its config in an editor. However, I can't figure out how to make Click ignore Missing Argument error if I specify the --edit-config flag only.
import click
#click.group(invoke_without_command=True)
#click.argument("name")
#click.option("--edit-config", is_flag=True)
def cli(name, edit_config):
# If we run it like
# $ myprog --edit-config | Missing Argument: NAME
if edit_config:
click.echo("Editing config")
...
exit(0)
# But this is the main functionality
# $ myprog John
click.echo(f"Hello, {name}")
#cli.command()
def command_a():
...
...
The first command causes Missing Argument error. There can be workarounds, like nargs=-1 or required=False, however, it seems that this makes the help page to either go completely or the name argument to be marked as optional in the help message, which is not true in the majority of use cases.
Is there is a simple way to make Click not raise Missing Argument error if a certain option is specified?
After reading the docs further I've found this section, which provides a way of implementing --edit-config or any other option that completely alters the execution flow.
def edit_config(ctx, param, value):
if not value or ctx.resilient_parsing:
return
click.echo("Editing config")
ctx.exit()
#click.group(invoke_without_command=True)
#click.argument("name")
#click.option("--edit-config", is_flag=True, callback=edit_config,
is_eager=True, expose_value=False)
def cli(name):
print(f"Hello, {name}!")
#cli.command()
def howareyou():
print(f"How are you?")
$ myapp John
Hello, John!
$ myapp John howareyou
Hello, John!
How are you?
$ myapp --edit-config
Editing config
$ myapp --help
Usage: myapp [OPTIONS] NAME COMMAND [ARGS]...
Options:
--edit-config
--help Show this message and exit.
Commands:
howareyou
I'm building a click 7.x application with Python 3.6 and am having some issues getting help to work for sub commands. I have a global option that is required and this option is being reported as missing when I run help on any sub command.
For example, given the following dummy script cli.py:
import click
#click.group()
#click.option('--directory', required=True)
def cli(directory):
"""
this is a tool that has an add and remove command
"""
click.echo(directory)
#cli.command()
#click.overwrite('--overwrite', is_flag=True)
def add(overwrite):
"""
this is the add command
"""
click.echo("add overwrite={}".format(overwrite))
#cli.command()
def remove():
"""
this is the remove command
"""
click.echo('remove')
if __name__ == '__main__':
cli()
When I run the following:
python cli.py --help
I get the desired output of:
Usage cli.py [OPTIONS] COMMAND [ARGS]...
this is a tool that has an add and remove command
Options:
--directory TEXT [required]
--help Show this message and exit.
Commands:
add this is the add command
remove this is the remove command
But if I run this:
python cli.py add --help
I get the following error:
Usage cli.py [OPTIONS] COMMAND [ARGS]...
Try "cli.py --help" for help.
Error: Missing option "--directory"
How do I get the help for the add command to show without having to supply the --directory option?
You can use a custom click.Group class to ignore the required args when --help is requested like:
Custom Class:
class IgnoreRequiredWithHelp(click.Group):
def parse_args(self, ctx, args):
try:
return super(IgnoreRequiredWithHelp, self).parse_args(ctx, args)
except click.MissingParameter as exc:
if '--help' not in args:
raise
# remove the required params so that help can display
for param in self.params:
param.required = False
return super(IgnoreRequiredWithHelp, self).parse_args(ctx, args)
Using the Custom Class:
To use the custom class, pass it as the cls argument to the group decorator like:
#click.group(cls=IgnoreRequiredWithHelp)
....
def my_group():
....
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 desired methods.
In this case we over ride click.Group.parse_args() and catch the click.MissingParameter exception. We then negate the required attribute from all of the params, and retry the parse.
Test Code:
import click
#click.group(cls=IgnoreRequiredWithHelp)
#click.option('--directory', required=True)
def cli(directory):
"""
this is a tool that has an add and remove command
"""
click.echo(directory)
#cli.command()
#click.option('--overwrite', is_flag=True)
def add(overwrite):
"""
this is the add command
"""
click.echo("add overwrite={}".format(overwrite))
#cli.command()
def remove():
"""
this is the remove command
"""
click.echo('remove')
if __name__ == "__main__":
commands = (
'add --help',
'--help',
'--directory a_dir add'
'',
)
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)]
-----------
> add --help
Usage: test.py add [OPTIONS]
this is the add command
Options:
--overwrite
--help Show this message and exit.
-----------
> --help
Usage: test.py [OPTIONS] COMMAND [ARGS]...
this is a tool that has an add and remove command
Options:
--directory TEXT
--help Show this message and exit.
Commands:
add this is the add command
remove this is the remove command
-----------
> --directory a_dir add
a_dir
add overwrite=False
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.
I am using Click under a virtualenv and use the entry_point directive in setuptools to map the root to a function called dispatch.
My tool exposes two subcommands serve and config, I am using an option on the top level group to ensure that the user always passes a --path directive. However the usage turns out as follows:
mycommand --path=/tmp serve
both the serve and config sub commands need to ensure that the user always passes a path in and ideally I would like to present the cli as:
mycommand serve /tmp` or `mycommand config validate /tmp
current Click based implemenation is as follows:
# cli root
#click.group()
#click.option('--path', type=click.Path(writable=True))
#click.version_option(__version__)
#click.pass_context
def dispatch(ctx, path):
"""My project description"""
ctx.obj = Project(path="config.yaml")
# serve
#dispatch.command()
#pass_project
def serve(project):
"""Starts WSGI server using the configuration"""
print "hello"
# config
#dispatch.group()
#pass_project
def config(project):
"""Validate or initalise a configuration file"""
pass
#config.command("validate")
#pass_project
def config_validate(project):
"""Reports on the validity of a configuration file"""
pass
#config.command("init")
#pass_project
def config_init(project):
"""Initialises a skeleton configuration file"""
pass
Is this possible without adding the path argument to each sub command?
If there is a specific argument that you would like to decorate only onto the group, but be applicable to all commands as needed, you can do that with a bit of extra plumbing like:
Custom Class:
import click
class GroupArgForCommands(click.Group):
"""Add special argument on group to front of command list"""
def __init__(self, *args, **kwargs):
super(GroupArgForCommands, self).__init__(*args, **kwargs)
cls = GroupArgForCommands.CommandArgument
# gather the special arguments
self._cmd_args = {
a.name: a for a in self.params if isinstance(a, cls)}
# strip out the special arguments
self.params = [a for a in self.params if not isinstance(a, cls)]
# hook the original add_command method
self._orig_add_command = click.Group.add_command.__get__(self)
class CommandArgument(click.Argument):
"""class to allow us to find our special arguments"""
#staticmethod
def command_argument(*param_decls, **attrs):
"""turn argument type into type we can find later"""
assert 'cls' not in attrs, "Not designed for custom arguments"
attrs['cls'] = GroupArgForCommands.CommandArgument
def decorator(f):
click.argument(*param_decls, **attrs)(f)
return f
return decorator
def add_command(self, cmd, name=None):
# hook add_command for any sub groups
if hasattr(cmd, 'add_command'):
cmd._orig_add_command = cmd.add_command
cmd.add_command = GroupArgForCommands.add_command.__get__(cmd)
cmd.cmd_args = self._cmd_args
# call original add_command
self._orig_add_command(cmd, name)
# if this command's callback has desired parameters add them
import inspect
args = inspect.signature(cmd.callback)
for arg_name in reversed(list(args.parameters)):
if arg_name in self._cmd_args:
cmd.params[:] = [self._cmd_args[arg_name]] + cmd.params
Using the Custom Class:
To use the custom class, pass the cls parameter to the click.group() decorator, use the #GroupArgForCommands.command_argument decorator for special arguments, and then add a parameter of the same name as the special argument to any commands as needed.
#click.group(cls=GroupArgForCommands)
#GroupArgForCommands.command_argument('special')
def a_group():
"""My project description"""
#a_group.command()
def a_command(special):
"""a command under the group"""
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 desired methods.
In this case we over ride click.Group.add_command() so that when a command is added we can examine the command callback parameters to see if they have the same name as any of our special arguments. If they match, the argument is added to the command's arguments just as if it had been decorated directly.
In addition GroupArgForCommands implements a command_argument() method. This method is used as a decorator when adding a special argument instead of using click.argument()
Test Code:
def process_path_to_project(ctx, cmd, value):
"""param callback example to convert path to project"""
# Use 'path' to construct a project.
# For this example we will just annotate and pass through
return 'converted {}'.format(value)
#click.group(cls=GroupArgForCommands)
#GroupArgForCommands.command_argument('path',
callback=process_path_to_project)
def dispatch():
"""My project description"""
#dispatch.command()
def serve(path):
"""Starts WSGI server using the configuration"""
click.echo('serve {}'.format(path))
#dispatch.group()
def config():
"""Validate or initalise a configuration file"""
pass
#config.command("validate")
def config_validate():
"""Reports on the validity of a configuration file"""
click.echo('config_validate')
#config.command("init")
def config_init(path):
"""Initialises a skeleton configuration file"""
click.echo('config_init {}'.format(path))
if __name__ == "__main__":
commands = (
'config init a_path',
'config init',
'config validate a_path',
'config validate',
'config a_path',
'config',
'serve a_path',
'serve',
'config init --help',
'config validate --help',
'',
)
import sys, time
time.sleep(1)
print('Click Version: {}'.format(click.__version__))
print('Python Version: {}'.format(sys.version))
for command in commands:
try:
time.sleep(0.1)
print('-----------')
print('> ' + command)
time.sleep(0.1)
dispatch(command.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)]
-----------
> config init a_path
config_init converted a_path
-----------
> config init
Usage: test.py config init [OPTIONS] PATH
Error: Missing argument "path".
-----------
> config validate a_path
Usage: test.py config validate [OPTIONS]
Error: Got unexpected extra argument (a_path)
-----------
> config validate
config_validate
-----------
> config a_path
Usage: test.py config [OPTIONS] COMMAND [ARGS]...
Error: No such command "a_path".
-----------
> config
Usage: test.py config [OPTIONS] COMMAND [ARGS]...
Validate or initalise a configuration file
Options:
--help Show this message and exit.
Commands:
init Initialises a skeleton configuration file
validate Reports on the validity of a configuration...
-----------
> serve a_path
serve converted a_path
-----------
> serve
Usage: test.py serve [OPTIONS] PATH
Error: Missing argument "path".
-----------
> config init --help
Usage: test.py config init [OPTIONS] PATH
Initialises a skeleton configuration file
Options:
--help Show this message and exit.
-----------
> config validate --help
Usage: test.py config validate [OPTIONS]
Reports on the validity of a configuration file
Options:
--help Show this message and exit.
-----------
>
Usage: test.py [OPTIONS] COMMAND [ARGS]...
My project description
Options:
--help Show this message and exit.
Commands:
config Validate or initalise a configuration file
serve Starts WSGI server using the configuration