Search code examples
pythoncommand-line-interfacepython-click

Divide click commands into sections in cli documentation


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?


Solution

  • 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