Divide click commands into sections in cli documentation

Question:

This code:

#!/usr/bin env python3

import click


def f(*a, **kw):
    print(a, kw)


commands = [click.Command("cmd1", callback=f), click.Command("cmd2", callback=f)]


cli = click.Group(commands={c.name: c for c in commands})

if __name__ == "__main__":
    cli()

generates this help:

# Usage: cli.py [OPTIONS] COMMAND [ARGS]...

# Options:
#   --help  Show this message and exit.

# Commands:
#   cmd1
#   cmd2

I have a lot of subcommands, so I want to divide them into sections in the help like this:

# Usage: cli.py [OPTIONS] COMMAND [ARGS]...

# Options:
#   --help  Show this message and exit.

# Commands:
#   cmd1
#   cmd2
# 
# Extra other commands:
#   cmd3
#   cmd4

How can I split the commands into sections in the help like that, without affecting the functionality?

Asked By: Hatshepsut

||

Answers:

If you define your own group class you can overide the help generation like:

Custom Class:

class SectionedHelpGroup(click.Group):
    """Sections commands into help groups"""

    def __init__(self, *args, **kwargs):
        self.grouped_commands = kwargs.pop('grouped_commands', {})
        commands = {}
        for group, command_list in self.grouped_commands.items():
            for cmd in command_list:
                cmd.help_group = group
                commands[cmd.name] = cmd

        super(SectionedHelpGroup, self).__init__(
            *args, commands=commands, **kwargs)

    def command(self, *args, **kwargs):
        help_group = kwargs.pop('help_group')
        decorator = super(SectionedHelpGroup, self).command(*args, **kwargs)

        def new_decorator(f):
            cmd = decorator(f)
            cmd.help_group = help_group
            self.grouped_commands.setdefault(help_group, []).append(cmd)
            return cmd

        return new_decorator

    def format_commands(self, ctx, formatter):
        for group, cmds in self.grouped_commands.items():
            rows = []
            for subcommand in self.list_commands(ctx):
                cmd = self.get_command(ctx, subcommand)
                if cmd is None or cmd.help_group != group:
                    continue
                rows.append((subcommand, cmd.short_help or ''))

            if rows:
                with formatter.section(group):
                    formatter.write_dl(rows)

Using the Custom Class:

Pass the Custom Class to click.group() using cls parameter like:

@click.group(cls=SectionedHelpGroup)
def cli():
    """"""

when defining commands, pass the help group the command belongs to like:

@cli.command(help_group='my help group')
def a_command(*args, **kwargs):
    ....        

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 the desired methods.

In this case, we hook the command() decorator to allow the help_group to be identified. We also override the format_commands() method to print the commands help into the groups.

Test Code:

import click

def f(*args, **kwargs):
    click.echo(args, kwargs)

commands = {
    'help group 1': [
        click.Command("cmd1", callback=f),
        click.Command("cmd2", callback=f)
    ],
    'help group 2': [
        click.Command("cmd3", callback=f),
        click.Command("cmd4", callback=f)
    ]
}

cli = SectionedHelpGroup(grouped_commands=commands)

@cli.command(help_group='help group 3')
def a_command(*args, **kwargs):
    """My command"""
    click.echo(args, kwargs)


if __name__ == "__main__":
    cli(['--help'])

Results:

Usage: test.py [OPTIONS] COMMAND [ARGS]...

Options:
  --help  Show this message and exit.

help group 1:
  cmd1
  cmd2

help group 2:
  cmd3
  cmd4

help group 3:
  a_command  My command
Answered By: Stephen Rauch

Stephen’s answer above gives the general principle. If you only add new commands to a group using add_command, it can be simplified slightly:

import click
import collections


class SectionedHelpGroup(click.Group):
    """Organize commands as sections"""

    def __init__(self, *args, **kwargs):
        self.section_commands = collections.defaultdict(list)
        super().__init__(*args, **kwargs)

    def add_command(self, cmd, name=None, section=None):
        self.section_commands[section].append(cmd)
        super().add_command(cmd, name=name)

    def format_commands(self, ctx, formatter):
        for group, cmds in self.section_commands.items():
            with formatter.section(group):
                formatter.write_dl(
                    [(cmd.name, cmd.get_short_help_str() or "") for cmd in cmds]
                )

Example

def f(*args, **kwargs):
    click.echo(args, kwargs)

commands = {
    'help group 1': [
        click.Command("cmd1", callback=f),
        click.Command("cmd2", callback=f)
    ],
    'help group 2': [
        click.Command("cmd3", callback=f),
        click.Command("cmd4", callback=f)
    ]
}

@click.group(
    help=f"Sectioned Commands CLI",
    cls=SectionedHelpGroup
)
def cli():
    pass

for (section, cmds) in commands.items():
    for cmd in cmds:
        cli.add_command(cmd, section=section)

if __name__ == "__main__":
    cli()
Answered By: Stefan van der Walt