Click: Allow listing Commands in order of appearance

Created on 4 Feb 2016  路  14Comments  路  Source: pallets/click

Currently when I show help for a command with couple of subcommands, subcommands are listed in alphabetical order:

Usage: listflat [OPTIONS] COMMAND [ARGS]...

  List content of flat bucket.

  For lists use `basekeys`, `days` and `versions`.

  For stats use `timerange` and `statdays`.

  To generate copy scripts use `cpdays` or `cpversions`.

Options:
  --bucket TEXT  Name of flat bucket  [default: flat.dp.ce-traffic.com]
  --help         Show this message and exit.

Commands:
  basekeys    List all basekey names found on the bucket.
  cpdays      Generate script to copy couple of days.
  cpversions  Generate script to copy one day versions.
  days        List all days for base key BKEY.
  statdays    Print statistics for all BKEY days.
  timerange   Find oldest and newest version of a BKEY.
  versions    List all versions for BKEY and DAY.

My code provides the subcommands in different order, which I would prefer to be followed also in the help as they have some natural order of usage.

So the resulting help would list commands in better order and I could skip my general helpstring.

Usage: listflat [OPTIONS] COMMAND [ARGS]...

  List content of flat bucket.

Options:
  --bucket TEXT  Name of flat bucket  [default: flat.dp.ce-traffic.com]
  --help         Show this message and exit.

Commands:
  basekeys    List all basekey names found on the bucket.
  days        List all days for base key BKEY.
  versions    List all versions for BKEY and DAY.
  timerange   Find oldest and newest version of a BKEY.
  statdays    Print statistics for all BKEY days.
  cpdays      Generate script to copy couple of days.
  cpversions  Generate script to copy one day versions.

Is there such an option?

Most helpful comment

@tedmiston
For CPython 3.6+ use the following code (as dictionary there behaves as OrderedDict). For other versions, remove the comments.

# from collections import OrderedDict

import click


class NaturalOrderGroup(click.Group):
    # def __init__(self, name=None, commands=None, **attrs):
    #     if commands is None:
    #         commands = OrderedDict()
    #     elif not isinstance(commands, OrderedDict):
    #         commands = OrderedDict(commands)
    #     click.Group.__init__(self, name=name,
    #                          commands=commands,
    #                          **attrs)

    def list_commands(self, ctx):
        return self.commands.keys()


@click.group(cls=NaturalOrderGroup)
def cli():
    pass


@cli.command()
def setup():
    pass


@cli.command()
def install():
    pass


@cli.command()
def run():
    pass


if __name__ == "__main__":
    cli()

All 14 comments

Learning from #672 piece of code by @chrisdl I managed to get my order as defined in the code:

class NaturalOrderGroup(click.Group):
    """Command group trying to list subcommands in the order they were added.

    Make sure you initialize the `self.commands` with OrderedDict instance.

    With decorator, use::

        @click.group(cls=NaturalOrderGroup, commands=OrderedDict())
    """

    def list_commands(self, ctx):
        """List command names as they are in commands dict.

        If the dict is OrderedDict, it will preserve the order commands
        were added.
        """
        return self.commands.keys()

Alternative trying to ensure use of OrderedDict internally:

    class NaturalOrderGroup(click.Group):
        """Command group trying to list subcommands in the order they were added.
        Example use::

        @click.group(cls=NaturalOrderGroup)

        If passing dict of commands from other sources, ensure they are of type
        OrderedDict and properly ordered, otherwise order of them will be random
        and newly added will come to the end.
        """
        def __init__(self, name=None, commands=None, **attrs):
        if commands is None:
            commands = OrderedDict()
        elif not isinstance(commands, OrderedDict):
            commands = OrderedDict(commands)
        click.Group.__init__(self, name=name,
                     commands=commands,
                     **attrs)

        def list_commands(self, ctx):
        """List command names as they are in commands dict.

        If the dict is OrderedDict, it will preserve the order commands
        were added.
        """
        return self.commands.keys()

@vlcinsky I like the thought of using an ordered dict so you don't have to keep a list of string method names as my solution was. I will upgrade to do it this way =)

馃憤

I have a similar need: I use a single command and plugins can contribute options (using py.test pluggy). Ideally, I want to plugin each option in a given, predefined group of options for a given hook. Is ordering options display in help something you consider in this ticket? If not I will craft a new one. Actually never mind... there is #604 for this

@vlcinsky I tried your solution, but it didn't seem to add the commands to the help text in the order that I added them in the code. I didn't investigate deeply though, is there some part i'm missing?

I don't think there is a strong argument to change the default behavior of the help text, because it seems straightforward to subclass.

Going to close this, can reopen if there is a lot of interest in changing the behavior.

@jcrotts I am fine with keeping default order as now (thus alphabetical).

On the other hand, I would like to see very simple method to use click in such a way, it allows showing subcommand in my own order.

Subclassing is technically an option, but click users deserve straightforward option.

My proposal for next actions is:

  • we shall try to find out the simplest method to control how commands are ordered (keep default as now, possibly use extra argument to switch ordering method)
  • someone (I could try) create PR
  • update documentation to explain such option.

It would be great to keep this issue open.

Subclassing is technically an option, but click users deserve straightforward option.

Subclassing is an intended way of overriding behavior in Click and all other Pallets projects. Despite some of the API surface at this point, Click's goal is to be opinionated, not provide parameters for everything. I don't find the opinion "commands are in alphabetical order" particularly problematic in a way that overriding list_commands can't address.

@davidism you are probably much more familiar with what click goals are.

Anyway https://palletsprojects.com/p/click/ claims about Click:

Click is a Python package for creating beautiful command line interfaces in a composable way with as little code as necessary. It's the "Command Line Interface Creation Kit". It's highly configurable but comes with sensible defaults out of the box.

It aims to make the process of writing command line tools quick and fun while also preventing any frustration caused by the inability to implement an intended CLI API.

From this point of view:

  • with natural order of commands one can save lines of code explaining intended usage and simplify CLI use (less text to read).
  • click.group or context parameter or dedicated click.ngroup are much simpler to use than non-trivial subclassing, which adds extra lines of code and increase chance for broken code.

While subclassing is one of ways to overriding behaviour in Click, it is not the only one, using constructor parameter with specific value is very common way of modifying behaviour (into pre-designed alternative).

To summarize: What I am asking for is you to consider adding sensible alternative to click behaviour supported by some easy to use configuration method.

I believe this would contribute to (already really high) value click provides to it's users (after argparse, plac and docopt finally click became my real long term valid CLI framework and I am very happy with it already).

I'm working on a CLI where in my use case, the order of commands in a group follows a logical order of the flow they're used in.

For this use case it would be very nice to be able to maintain this natural order by having a kwarg e.g., sorted=False on the group.

Example app.py:

import click

@click.group(sorted=False)
def cli(): pass

@cli.command()
def setup(): ...

@cli.command()
def install(): ...

@cli.command()
def run(): ...

if __name__ == '__main__':
    cli()

Ideal output:

$ python app.py --help
Usage: app.py [OPTIONS] COMMAND [ARGS]...

Options:
  --help  Show this message and exit.

Commands:
  setup
  install
  run

@tedmiston
For CPython 3.6+ use the following code (as dictionary there behaves as OrderedDict). For other versions, remove the comments.

# from collections import OrderedDict

import click


class NaturalOrderGroup(click.Group):
    # def __init__(self, name=None, commands=None, **attrs):
    #     if commands is None:
    #         commands = OrderedDict()
    #     elif not isinstance(commands, OrderedDict):
    #         commands = OrderedDict(commands)
    #     click.Group.__init__(self, name=name,
    #                          commands=commands,
    #                          **attrs)

    def list_commands(self, ctx):
        return self.commands.keys()


@click.group(cls=NaturalOrderGroup)
def cli():
    pass


@cli.command()
def setup():
    pass


@cli.command()
def install():
    pass


@cli.command()
def run():
    pass


if __name__ == "__main__":
    cli()

@vlcinsky Thank you for the elegant solution. This works great.

For the sake of others finding this closed issue, I would like to point out that the code shown in the examples above:

class NaturalOrderGroup(click.Group):
    def __init__(self, name=None, commands=None, **attrs):
        if commands is None:
            commands = OrderedDict()
        elif not isinstance(commands, OrderedDict):
            commands = OrderedDict(commands)
        click.Group.__init__(self, name=name,
                             commands=commands,
                             **attrs)

    def list_commands(self, ctx):
        return self.commands.keys()

does not do what it intends to do on Python < 3.6 (as also pointed out in https://github.com/pallets/click/issues/513#issuecomment-314548607). The reason for that is the following code in Click:

class Group(MultiCommand):
    def __init__(self, name=None, commands=None, **attrs):
        MultiCommand.__init__(self, name, **attrs)
        self.commands = commands or {}

If commands is passed as an empty OrderedDict , the assignment still replaces it with the standard dict. Since the standard dict is not ordered on Python < 3.6, it looses the ordering again that way.

What works though, is this (more ugly) piece of code:

class NaturalOrderGroup(click.Group):

    def __init__(self, name=None, commands=None, **attrs):
        super(NaturalOrderGroup, self).__init__(
            name=name, commands=None, **attrs)
        if commands is None:
            commands = OrderedDict()
        elif not isinstance(commands, OrderedDict):
            commands = OrderedDict(commands)
        self.commands = commands

    def list_commands(self, ctx):
        return self.commands.keys()

PS: It is more ugly because it overwrites the self.commands attribute.
PPS: I chose to use super() since click.Group is a new style class.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

omribahumi picture omribahumi  路  14Comments

chadrik picture chadrik  路  13Comments

georgexsh picture georgexsh  路  13Comments

mchwalisz picture mchwalisz  路  14Comments

mahmoudimus picture mahmoudimus  路  25Comments