Equivalent of argparse's add_argument_group, help sections
Is this possible to have some kind of help sections? I have a rather long list of options and arguments, I'd like to classify them somehow in the rendered help.
argparse has a add_argument_group which renders like this
Is it possible to achieve the same behavior?
Unfortunately, no, not at the moment.
I think it's sensible to add this to Click itself, but I think Click is flexible enough such that this could also be implemented as a third-party extension.
It seems that one could subclass Command to override format_options to implement this behavior.
I thought something like this:
@click.command()
@click.option_group('Output options:', ['verbose', 'output'], indent=2)
@click.option('--verbose')
@click.option('--output')
def cli(verbose, output):
pass
Thanks for the pointers, I'll look into it. This is a very common use case for any CLI that has more than just a few options. See httpie
Or maybe that would be more efficient for nesting groups. And it looks nice as we can see sections just by reading the code.
Options outside option groups would fall under current format_options behavior.
@click.command()
@click.option_group('Output options:', [
click.option('--verbose'),
click.option('--output')
])
def cli(verbose, output):
pass
I like the second variant better, but we have to think about what happens if the user does this (or forbid it):
@click.option_group('General options:', [
click.option_group(...)
])
IMO we should simply forbid this.
It would create another section with indentation and so recursively. I don't see the issue here except that it would hurt readability, but that's on the developer.
For starters forbid it and add support for this later on if someone complains and proves it is useful.
This should also cover arguments so I suggest the name section rather than option_group, shorter and more explicit:
@click.command()
@click.section('Request:', [
click.option('--json'),
click.argument('method'),
click.argument('url')
])
@click.section('Output options:', [
click.option('--quiet'),
click.option('--verbose'),
click.option('--output')
])
def cli(verbose, output):
pass
Would require #375
What’s the current state of this issue? Is there already some code to share?
This is rather verbose and hacky, but it seems to work.
#!/usr/bin env python3
import click
class SectionedFormatter(click.formatting.HelpFormatter):
def __init__(self, *args, sections, **kwargs):
self.sections = sections
super().__init__(*args, **kwargs)
def write_dl(self, rows, *args, **kwargs):
cmd_to_section = {}
for section, commands in self.sections.items():
for command in commands:
cmd_to_section[command] = section
sections = {}
for subcommand, help in rows:
sections.setdefault(cmd_to_section.get(subcommand, "Commands"), []).append(
(subcommand, help)
)
for section_name, rows in sections.items():
if rows[0][0][0] == "-":
super().write_dl(rows)
else:
with super().section(section_name):
super().write_dl(rows)
class SectionedContext(click.Context):
def __init__(self, *args, sections, **kwargs):
self.sections = sections
super().__init__(*args, **kwargs)
def make_formatter(self):
"""Creates the formatter for the help and usage output."""
return SectionedFormatter(
sections=self.sections,
width=self.terminal_width,
max_width=self.max_content_width,
)
class SectionedGroup(click.Group):
def __init__(self, *args, sections, **kwargs):
self.sections = sections
super().__init__(self, *args, **kwargs)
def make_context(self, info_name, args, parent=None, **extra):
"""This function when given an info name and arguments will kick
off the parsing and create a new :class:`Context`. It does not
invoke the actual command callback though.
:param info_name: the info name for this invokation. Generally this
is the most descriptive name for the script or
command. For the toplevel script it's usually
the name of the script, for commands below it it's
the name of the script.
:param args: the arguments to parse as list of strings.
:param parent: the parent context if available.
:param extra: extra keyword arguments forwarded to the context
constructor.
"""
for key, value in click._compat.iteritems(self.context_settings):
if key not in extra:
extra[key] = value
ctx = SectionedContext(
self, info_name=info_name, parent=parent, sections=self.sections, **extra
)
with ctx.scope(cleanup=False):
self.parse_args(ctx, args)
return ctx
def format_commands(self, ctx, formatter):
"""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.get_short_help_str(limit)
rows.append((subcommand, help))
if rows:
formatter.write_dl(rows)
# User code:
def f(*args, **kwargs):
print(args, kwargs)
SECTIONS = {"Primary": ["cmd1", "cmd2"], "Extras": ["cmd3", "cmd4"]}
commands = [
click.Command("cmd1", callback=f, help="first"),
click.Command("cmd2", callback=f, help="second"),
click.Command("cmd3", callback=f, help="third"),
click.Command("cmd4", callback=f, help="fourth"),
]
cli = SectionedGroup(commands={c.name: c for c in commands}, sections=SECTIONS)
if __name__ == "__main__":
cli()
Generates help:
python ~/tmp/cli.py --help
Usage: cli.py [OPTIONS] COMMAND [ARGS]...
Options:
--help Show this message and exit.
Primary:
cmd1 first
cmd2 second
Extras:
cmd3 third
cmd4 fourth
I have a simple solution for this, that could easily be merged into the core code:
from collections import OrderedDict
import click
class CustomOption(click.Option):
def __init__(self, *args, **kwargs):
self.help_group = kwargs.pop('help_group', None)
super(CustomOption, self).__init__(*args, **kwargs)
class CustomCommand(click.Command):
def format_options(self, ctx, formatter):
"""Writes all the options into the formatter if they exist."""
opts = OrderedDict()
for param in self.get_params(ctx):
rv = param.get_help_record(ctx)
if rv is not None:
if hasattr(param, 'help_group') and param.help_group:
opts.setdefault(str(param.help_group), []).append(rv)
else:
opts.setdefault('Options', []).append(rv)
for name, opts_group in opts.items():
with formatter.section(name):
formatter.write_dl(opts_group)
Which you can use like so:
@click.group()
def cli():
pass
@cli.command(cls=CustomCommand)
@click.option('--option1', cls=CustomOption, help_group="Group1")
@click.option('--option2', cls=CustomOption, help_group="Group2")
@click.option('--option3', cls=CustomOption, help_group="Group1")
def mycmnd(option1):
pass
and will give you:
$ cli mycmnd --help
Usage: test.py mycmnd [OPTIONS]
Group1:
--option1 TEXT
--option3 TEXT
Group2:
--option2 TEXT
Options:
--help Show this message and exit.
I'm not sure about putting into click core itself, since in general this is pretty easy to implement with sub-classing (as you demonstrated).
Perhaps it could be added to the docs here https://click.palletsprojects.com/en/7.x/documentation/
I've also thought about maintaining an external list of these sorts of sub classing examples.
I'm not sure about putting into click core itself
I'll leave that up to you :) I'm happy sub-classing, but obviously bringing in to core protects against any API changes in future versions
I've also thought about maintaining an external list of these sorts of sub classing examples.
Always good to have more "official" best practice examples of utilizing click
@chrisjsewell's solution works well. I'd suggest to include it in the core since it's a useful and very common usecase. It shouldn't complicate the code much.
Is there a reason not to include it?
My two cents... From an API point of view, the solution proposed by @Diaoul is the best option for me. But I would rather avoid to write ":" at the end of section names. The formatting should be "standard" and consistent across applications, so ":" should be automatically included.
@click.command()
@click.argument('path')
@click.section('Section A', [
click.option('--a1'),
click.option('--a2')
])
@click.section('Section B', [
click.option('--b1'),
click.option('--b2')
])
def cli(path, a1, a2, b1, b2):
pass
About (mandatory) arguments, I would rather forbid to include them in these "option sections". Even if click allowed to attach a help string to them, they should be listed in their own (unnamed) section at the beginning of the command help message. You don't want to dig around sections to see what you have to provide to a command.
Finally, I agree to forbid nested option groups: that would complicate implementation and likely worsen help readability.
@janLuke I really like your design. Do you think you could create a contrib package for it?
@thedrow Do you already know of https://github.com/click-contrib/click-option-group?
No but that's awesome!
@thedrow I've just packaged and uploaded a modified and extended version of @chrisjsewell code which I've used in a couple of projects (see https://github.com/janLuke/cloup). It just handles help formatting (no option group constraints like click-option-group). It's probably a superfluous package, but I don't like click-option-group's help formatting and I prefer to handle that kind of constraints inside functions. Though it's probably easy to modify how click-option-group formats the help, I already had this code ready to be used, so...
@janLuke
... I don't like click-option-group's help formatting and I prefer to handle that kind of constraints inside functions. Though it's probably easy to modify how click-option-group formats the help ...
I'm the author of click-option-group and I can say: PRs are welcome! :)
Collectively, we could try to make click-option-group convenient and helpful for most developers. Better API, better help formatting, better code.
The following API that you proposed looks nicely:
@click.command()
@click.argument('path')
@click.section('Section A', [
click.option('--a1'),
click.option('--a2')
])
@click.section('Section B', [
click.option('--b1'),
click.option('--b2')
])
def cli(path, a1, a2, b1, b2):
pass
This more clean and unambiguous API solves the issues and cavities with decorators ordering and looks more convenient.
Also using custom command class we can make better help formatting without any dirty hacks with creating fake option (currently, this way is used in click-option-group). And we still can use constraints and relationships among options using custom group/section and option classes.
@espdev Hey :) As I wrote, I didn't have much time for this. I gave a very (very) quick look to click-option-group before packaging cloup and I saw it has a very different approach (with a different API) and a broader goal (constraints). I also saw you cited many issue pages in your README, so I was pretty sure you were aware of everything had been said in this discussion and I didn't even think to propose a something as "radical" as a change of approach or API, even because I think current ones are totally fine. I just shallowly evaluated if I could easily modify click-option-group to format the command help as I wanted and I didn't immediately find a solution (I've no much experience with click internals and I didn't try hard). So I just packaged cloup which worked perfectly fine for my use case.
I have very little time and motivation to open a PR at the moment but of course you can feel free to take any idea from cloup if you think it can help. Currently, I would rather like to have "command groups" in one of my applications, so I may work on extending cloup to support them if that's quick and easy.
@janLuke
I was pretty sure you were aware of everything had been said in this discussion
Yes, I once read this discussion, but switched to something else and forgot about it. I already added the link to README. :)
Thanks for detailed clarification of your viewpoint. I completely understand you. I will try to take ideas from cloup and your approach and apply it in click-option-group.
@jtrakk Could you give an example of how to add arguments and options to the commands?
@pratzz I'd recommend using the better packaged versions instead of my original hack. :-)
@pratzz I'd recommend using the better packaged versions instead of my original hack. :-)
Other solutions seem to be for grouping options or arguments, I wish to group commands which is why your solution works for me.
@pratzz In cloup you can group both subcommands and options. See https://github.com/janLuke/cloup/blob/master/README.rst#subcommand-sections
@pratzz In cloup you can group both subcommands and options. See https://github.com/janLuke/cloup/blob/master/README.rst#subcommand-sections
Cool, thanks :)
UPDATE: the PR was merged after several changes and released with Cloup v0.5.0. For more info, see the documentation (do a full refresh of the pages with Ctrl+F5 if you see the old docs).
Despite I was reluctant months ago, I recently worked on adding "parameter group constraints" to cloup. I've opened a PR on my own repo because I'd like to have some feedback: https://github.com/janLuke/cloup/pull/4. There you can find an overview of the API but honestly you can get the gist of it by looking at this example. I'll leave the PR open for some days.
The approach is quite different from click-option-group. Any feedback, especially from @espdev or anyone involved in the development of click-option-group or click, is highly appreciated.
(Sorry, this is probably off-topic but this seems to be the only issue about option groups still open.)
FWIW, I think ewels/rich-click implements something similar as well.