In Python Click how do I see --help for Subcommands whose parents have required arguments?
Asked Answered
S

3

10

My program uses Click for command line processing. It has a main command that takes a required argument. This command has subcommands that take optional arguments. Different subcommands take different options, but they all require the same argument from their parent. I'd like to have the command line look like this:

python myprogram.py argument-value subcommand1 --option-1=value

I can write this using Click like so

import click

@click.group()
@click.argument("argument")
@click.pass_context
def main(context, argument):
    """ARGUMENT is required for both subcommands"""
    context.obj = {"argument": argument}


@click.command()
@click.option("--option-1", help="option for subcommand 1")
@click.pass_context
def subcommand1(context, option_1):
    print("subcommand 1: %s %s" % (context.obj["argument"], option_1))


@click.command()
@click.option("--option-2", help="option for subcommand 2")
@click.pass_context
def subcommand2(context, option_2):
    print("subcommand 2: %s %s" % (context.obj["argument"], option_2))


main.add_command(subcommand1)
main.add_command(subcommand2)

if __name__ == "__main__":
    main()

The top-level help message is what I want.

python myprogram.py --help
Usage: myprogram.py [OPTIONS] ARGUMENT COMMAND [ARGS]...

  ARGUMENT is required for both subcommands

Options:
  --help  Show this message and exit.

Commands:
  subcommand1
  subcommand2

I can get help for a subcommand if I pass in the required argument.

python myprogram.py dummy-argument subcommand1 --help
Usage: myprogram.py subcommand1 [OPTIONS]

Options:
  --option-1 TEXT  option for subcommand 1
  --help           Show this message and exit.

However, I'd like to get the subcommand help without requiring the user to pass in a dummy argument. I want to be able to run python myprogram.py subcommand1 --help and see the same output as above, but instead I just get the help text for the top level.

python myprogram.py subcommand1 --help
Usage: myprogram.py [OPTIONS] ARGUMENT COMMAND [ARGS]...

  ARGUMENT is required for both subcommands

Options:
  --help  Show this message and exit.

Commands:
  subcommand1
  subcommand2

Is there a way to get the behavior I want? I realize that Click puts a premium on having each of its commands be self-contained, but this seems like a common scenario.

Synovia answered 22/11, 2017 at 14:43 Comment(3)
What behavior would you expect if a user wanted the argument to be the same as the subcommand, or are these name spaces unique?Zerline
Good point. I hadn't thought of that. Maybe that's a good reason for Click to explicitly disallow the scenario I want. (I'd still want to know if Click explicitly disallows it though.)Synovia
Click should make the lives of devs easier not harder, I can't understand how this is not yet solved after 8 stable versions, it's a really basic scenario that any command with sub-commands faces, there are plenty of people reporting this same error in the Github Click repo and they just close all the issues marking them as duplicated, or as a feature, not a bug. Solution for newcomers: don't start your project with Click, maybe you are just fine with argparse that is built-in.Epidote
Z
3

There is an inherent ambiguity in your requirements, in that the subcommand name could possibly be the same as a valid value for the common argument.

So, some way to disambiguate is required. I present one possible solution below.

When finding a value of the argument which matches a subcommand name, the proposed solution will search for the presence of --help. If found it then assumes that help is being requested for the subcommand, and will populate the dummy-argument automatically.

Custom Class:

import click

class PerCommandArgWantSubCmdHelp(click.Argument):

    def handle_parse_result(self, ctx, opts, args):
        # check to see if there is a --help on the command line
        if any(arg in ctx.help_option_names for arg in args):

            # if asking for help see if we are a subcommand name
            for arg in opts.values():
                if arg in ctx.command.commands:

                    # this matches a sub command name, and --help is
                    # present, let's assume the user wants help for the
                    # subcommand
                    args = [arg] + args

        return super(PerCommandArgWantSubCmdHelp, self).handle_parse_result(
            ctx, opts, args)

Using Custom Class:

To use the custom class, pass the cls parameter to @click.argument() decorator like:

@click.argument("argument", cls=PerCommandArgWantSubCmdHelp)

How does this work?

This works because click is a well designed OO framework. The @click.argument() decorator usually instantiates a click.Argument object but allows this behavior to be over ridden with the cls parameter. So it is a relatively easy matter to inherit from click.Argument in our own class and over ride the desired methods.

In this case we over ride click.Argument.handle_parse_result() and look for the pattern of a subcommand name followed by --help. When found, we then doctor the arguments list to get the pattern click needs to parse this the way want to show the subcommand help.

Test Code:

@click.group()
@click.argument("argument", cls=PerCommandArgWantSubCmdHelp)
@click.pass_context
def main(context, argument):
    """ARGUMENT is required for both subcommands"""
    context.obj = {"argument": argument}


@click.command()
@click.option("--option-1", help="option for subcommand 1")
@click.pass_context
def subcommand1(context, option_1):
    print("subcommand 1: %s %s" % (context.obj["argument"], option_1))


@click.command()
@click.option("--option-2", help="option for subcommand 2")
@click.pass_context
def subcommand2(context, option_2):
    print("subcommand 2: %s %s" % (context.obj["argument"], option_2))


main.add_command(subcommand1)
main.add_command(subcommand2)

if __name__ == "__main__":
    commands = (
        'subcommand1 --help',
        'subcommand2 --help',
        'dummy-argument subcommand1 --help',
    )

    for cmd in commands:
        try:
            print('-----------')
            print('> ' + cmd)
            main(cmd.split())
        except:
            pass

Test Results:

-----------
> subcommand1 --help
Backend TkAgg is interactive backend. Turning interactive mode on.
Usage: test.py subcommand1 [OPTIONS]

Options:
  --option-1 TEXT  option for subcommand 1
  --help           Show this message and exit.
-----------
> subcommand2 --help
Usage: test.py subcommand2 [OPTIONS]

Options:
  --option-2 TEXT  option for subcommand 2
  --help           Show this message and exit.
-----------
> dummy-argument subcommand1 --help
Usage: test.py subcommand1 [OPTIONS]

Options:
  --option-1 TEXT  option for subcommand 1
  --help           Show this message and exit.                
Zerline answered 22/11, 2017 at 19:57 Comment(0)
M
1

Another possible approach is to define a custom decorator that adds the common argument/option and use this decorator on every subcommand. This way, click will not ask for the argument/option when issuing subcommand --help.

A possible implementation is outlined in click's GitHub Issue 295. The example code shows how to add a general purpose decorator which can add different - yet shared - options. It does however solve OP's issue.

Maraca answered 6/8, 2018 at 13:32 Comment(1)
This moves the common argument behind the subcommand, i.e. python myprogram.py subcommand1 argument-value --option-1=value instead of python myprogram.py argument-value subcommand1 --option-1=value, though.Maraca
T
-1

I managed to do it this way

def create(config):
if config is None:
    click.echo('ERROR: Configuration File not found!')
    main(datalake(create(['--help'])))
Toth answered 2/3, 2021 at 19:40 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.