Click: Can't access help of subcommands without going through parent's prompts

Created on 6 Feb 2015  路  15Comments  路  Source: pallets/click

I have a script with subcommands that both require a username/password. I abstracted this to the parent. Here's a short example demonstrating the issue:

import click

@click.group()
@click.option('--username', default='admin', prompt=True)
@click.password_option()
def cli(username, password):
    """This is my parent description"""
    pass

@cli.command()
@click.option('--reference', help="I should be able to see this without entering a username and password.")
def subcommand(reference):
    """This is my subcommand"""
    pass

If you access the main help:

my_prog --help

It displays the help page without any prompting for username or password, as expected. However, if you try to access the help page for the subcommand:

my_prog subcommand --help

It prompts for username and password before displaying the help page of the subcommand. This makes sense, but it still isn't expected behavior by the end user. As my sample program says, I should be able to see help without entering a username and password.

What's the correct way to model my desired behavior? Do I have to move and duplicate the username and password prompt options into each of my sub commands?

prompt

Most helpful comment

This is a tough one to fix and I am not sure if it will be entirely possible without causing more issues elsewhere. :(

All 15 comments

@philipsd6 Same issue, I was going to approach this problem by removing the prompt=True on the option and checking for the required options but realized that sub-commands would have to do the same. Is there no directive for Ignoring prompts on certain options

+1 for fixing this.

Have you tried using an eager option?
http://click.pocoo.org/3/options/#callbacks-and-eager-options

@ryanneufeld This is a nice suggestion but how do you define that for the default provided --help option ?

http://click.pocoo.org/3/advanced/#callback-evaluation-order

@warsamebashir you can override the help command with your own.

@ryanneufeld do you have an example of how we can do that. I couldn't find anything in the docs. Is it just the traditional @click.option decorator with the is_flag=True set ?

This is a tough one to fix and I am not sure if it will be entirely possible without causing more issues elsewhere. :(

+1 for fixing this as well.

In a similar situation, instead of trying to pull the common behavior up to the group level, I defined a decorator that implemented the behavior and used that on the individual commands. For example, something like (I haven't actually checked that this code works...):

from functools import wraps

def needs_username_and_password(f):
    @wraps(f)
    @click.option('--username', default='admin', prompt=True)
    @click.password_option()
    def wrapper(*args, **kwargs):
        return f(*args, **kwargs)

@click.group()
def cli():
    """This is my parent description"""
    pass

@cli.command()
@click.option('--reference', help="I should be able to see this without entering a username and password.")
@needs_username_and_password
def subcommand(username, password, reference):
    """This is my subcommand"""
    pass

Hey @vokal-isaac, thanks for your solution! It was throwing an error as-is for me but I was able to fix.

$ python -V; ./test.py subcommand --help
Python 2.7.15
Traceback (most recent call last):
  File "./test.py", line 25, in <module>
    @needs_username_and_password
  File "/usr/local/lib/python2.7/site-packages/click/decorators.py", line 170, in decorator
    _param_memo(f, OptionClass(param_decls, **attrs))
  File "/usr/local/lib/python2.7/site-packages/click/decorators.py", line 135, in _param_memo
    f.__click_params__ = []
AttributeError: 'NoneType' object has no attribute '__click_params__'

Adding return wrapper to needs_username_and_password resolved issue. Complete example:

#!/usr/bin/env python
# -*- coding: utf-8 -*-
import click

from functools import wraps


def needs_username_and_password(f):
    @wraps(f)
    @click.option('--username', default='admin', prompt=True)
    @click.password_option()
    def wrapper(*args, **kwargs):
        return f(*args, **kwargs)
    return wrapper # **** Added return ****


@click.group()
def cli():
    """This is my parent description"""
    pass


@cli.command()
@click.option('--reference', help="I should be able to see this without entering a username and password.")
@needs_username_and_password
def subcommand(username, password, reference):
    """This is my subcommand"""
    pass


if __name__ == '__main__':
    cli()

@druchoo Ah, yeah, looking at it now, it was definitely missing that return wrapper. (As I'd said, "I haven't actually checked that this code works..." 馃槈)

I like the solution above a lot, but I ran into issues trying to "generalize" this by using multiple decorators (I've posted on SO as well). The issue with the code below is that only the decorator that is "applied" to the bare function will properly get the @click.options in its argument list.

Is there a way to properly propagate wrap the functions as to propagate the __click_params__ that @click.option adds to a function?

from functools import wraps
import click

def init_session(f):
  @wraps(f)
  @click.option('--argument-B')
  def wrapper(*args, **kwargs):
    # do something with argument-B, and add session to list of arguments.
    del kwargs['argument-B']
    kwargs['session'] = session_created_above

    return f(*args,**kwargs)
  return wrapper

def parse_config_file(f):
  @wraps(f)
  @click.option('--argument-C')
  def wrapper(*args, **kwargs):
    # do something with argument-C, and add config to list of arguments.
    del kwargs['argument-C']
    kwargs['config'] = config_parsed_above

    return f(*args,**kwargs)
  return wrapper

@click.group()
def main():
  pass

@main.command()
@click.option('--argument-a')
@parse_config_file
@init_session
def do_something_in_session(argument_a, config, session):
   # code
   return

The issue with the decorator solution is that all of your group options get moved to command options, meaning the "global stuff" that all your commands need now has to be specified after the command, i.e this:
cli --some-global-option command
Becomes:
cli command --some-global-option
I don't know, but for some reason this irks me, I guess because of the mental model of group options applying to all the commands through context it makes intuitive sense to me to specify them before the command.

I ran into this issue with a required group option, I implemented a solution based on the answer of @ryanneufeld. I don't know if it works with every corner cases, but it shows the command help when the help flag is given or no arguments given. And returns an error when trying to use the command without help. I also tested it with the first example in this issue and it seems to work.
hope it can help someone

import sys

import click

CONTEXT = {"help_option_names": ["-h", "--help"]}


def print_cmd_help(ctx, param, value):
    group = ctx.command
    # parse the cmdline and get the command and its arguments
    parser = group.make_parser(ctx)
    global_opts, args, _ = parser.parse_args(args=sys.argv[1:])
    if global_opts.get("help") is True or not args:
        # global help wanted
        click.echo(ctx.get_help())
        ctx.exit()

    # create the command object manually and parse its arguments
    name, cmd, args = group.resolve_command(ctx, args)
    help_names = cmd.get_help_option_names(ctx)

    if set(args) & help_names or not args:
        # return command help when a user wants it or when he does not provide
        # arguments
        # taken from https://github.com/pallets/click/blob/7.1.1/src/click/core.py#L597
        cmd_ctx = click.Context(cmd, info_name=cmd.name, parent=ctx)
        click.echo(cmd_ctx.get_help())
        cmd_ctx.exit()


@click.group(context_settings=CONTEXT)
@click.option("--username", default="admin", prompt=True)
@click.password_option()
@click.option(
    "-h",
    "--help",
    is_flag=True,
    callback=print_cmd_help,
    expose_value=False,
    is_eager=True,
)
def cli(username, password):
    ...


@cli.command(help="this is the help of test")
@click.argument("name")
def test(name):
    click.echo(name)


cli()

I don't think this is solvable within Click in a general way.

Click parses and process all args and callbacks for a command before moving on to a subcommand. The subcommand can be resolved dynamically based on the current command's context, so it's not possible to guarantee what commands and params will be processed before actually running the pipeline. Additionally, the help option isn't guaranteed to be --help, and --help could also show up as the value of another option, so it's not possible to guarantee that --help showing up in the raw args means the help callback will be executed.

There are a few different solutions a project can implement:

  • #1618 will introduce a new prompt_required argument, that changes the behavior of prompts so they're only shown if the option is required or if the flag without a value is given. This applies to prompts, but it doesn't apply to missing required parameters.
  • Instead of using prompt=True in the parameters, use a helper function before the values are actually used that will call click.prompt() if they weren't provided.
  • The parameters can be moved to each command, with a decorator to avoid repetition.
  • The group --help param can be customized to figure out the subcommand and show its help.
  • A dedicated help command (like git help <command name>) is fairly straightforward to implement using group.get_command(ctx, name) and command.get_help(ctx).
  • Use a project like sphinx-click or click-man to generate external documentation.
Was this page helpful?
0 / 5 - 0 ratings