Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Equivalent of argparse's add_argument_group, help sections #373

Open
Diaoul opened this issue Jul 12, 2015 · 28 comments
Open

Equivalent of argparse's add_argument_group, help sections #373

Diaoul opened this issue Jul 12, 2015 · 28 comments

Comments

@Diaoul
Copy link
Contributor

Diaoul commented Jul 12, 2015

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?

@untitaker
Copy link
Contributor

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.

@untitaker
Copy link
Contributor

It seems that one could subclass Command to override format_options to implement this behavior.

@Diaoul
Copy link
Contributor Author

Diaoul commented Jul 12, 2015

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

@Diaoul
Copy link
Contributor Author

Diaoul commented Jul 12, 2015

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

@untitaker
Copy link
Contributor

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.

@Diaoul
Copy link
Contributor Author

Diaoul commented Jul 12, 2015

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.

@Diaoul
Copy link
Contributor Author

Diaoul commented Jul 12, 2015

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

@sscherfke
Copy link
Contributor

What’s the current state of this issue? Is there already some code to share?

@jtrakk
Copy link
Contributor

jtrakk commented Jul 17, 2019

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

@chrisjsewell
Copy link

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.

@jcrotts
Copy link
Contributor

jcrotts commented Jul 26, 2019

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.

@chrisjsewell
Copy link

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

@janluke
Copy link
Contributor

janluke commented Sep 23, 2019

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
Copy link
Contributor

janluke commented Feb 17, 2020

@thedrow Do you already know of https://github.com/click-contrib/click-option-group?

@janluke
Copy link
Contributor

janluke commented Feb 25, 2020

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

@espdev
Copy link

espdev commented Mar 5, 2020

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

@janluke
Copy link
Contributor

janluke commented Mar 6, 2020

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

@espdev
Copy link

espdev commented Mar 7, 2020

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

@pratzz
Copy link

pratzz commented Apr 22, 2020

@jtrakk Could you give an example of how to add arguments and options to the commands?

@jtrakk
Copy link
Contributor

jtrakk commented Apr 22, 2020

@pratzz I'd recommend using the better packaged versions instead of my original hack. :-)

@pratzz
Copy link

pratzz commented Apr 23, 2020

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

@janluke
Copy link
Contributor

janluke commented Apr 23, 2020

@pratzz In cloup you can group both subcommands and options. See https://github.com/janLuke/cloup/blob/master/README.rst#subcommand-sections

@pratzz
Copy link

pratzz commented Apr 23, 2020

@pratzz In cloup you can group both subcommands and options. See https://github.com/janLuke/cloup/blob/master/README.rst#subcommand-sections

Cool, thanks :)

@janluke
Copy link
Contributor

janluke commented Jan 24, 2021

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: janluke/cloup#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.)

@reitzig
Copy link

reitzig commented Aug 30, 2022

FWIW, I think ewels/rich-click implements something similar as well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

12 participants