Using a numeric identifier for value selection in click.Choice
Asked Answered
B

2

7

The Click package allows a range of values to be selected from a list using the click.Choice method.

In my case the values are relatively long strings, so using:

    choice_names = [u'Vulnerable BMC (IPMI)', u'IoT Vulnerability', u'SMBv1', u'BadHTTPStatus', u'Compromised']

    @click.option('--category', prompt='\nPlease enter the category of incident.\n\n -- Options:\n{}\n\n'.format(
        format_choices(choice_names)), type=click.Choice(choice_names))

will list the values as:

-> Vulnerable BMC (IPMI)
-> IoT Vulnerability
-> SMBv1
-> BadHTTPStatus
-> Compromised

This requires the user to enter the full string, which is inconvenient. Does Click provide a functionality to select a value using only a numeric identifier? So, the above options could be listed as:

-> Vulnerable BMC (IPMI) [1]
-> IoT Vulnerability [2]
-> SMBv1 [3]
-> BadHTTPStatus [4]
-> Compromised [5]

and to select the first option, the user would need to enter 1. This could be possible by defining a custom validation function, but I couldn't find any existing functionality offered by Click.

Broad answered 22/1, 2019 at 15:3 Comment(0)
T
7

I came up with this:

class ChoiceOption(click.Option):
    def __init__(self, param_decls=None, **attrs):
        click.Option.__init__(self, param_decls, **attrs)
        if not isinstance(self.type, click.Choice):
            raise Exception('ChoiceOption type arg must be click.Choice')

        if self.prompt:
            prompt_text = '{}:\n{}\n'.format(
                self.prompt,
                '\n'.join(f'{idx: >4}: {c}' for idx, c in enumerate(self.type.choices, start=1))
            )
            self.prompt = prompt_text

    def process_prompt_value(self, ctx, value, prompt_type):
        if value is not None:
            index = prompt_type(value, self, ctx)
            return self.type.choices[index - 1]

    def prompt_for_value(self, ctx):
        # Calculate the default before prompting anything to be stable.
        default = self.get_default(ctx)

        prompt_type = click.IntRange(min=1, max=len(self.type.choices))
        return click.prompt(
            self.prompt, default=default, type=prompt_type,
            hide_input=self.hide_input, show_choices=False,
            confirmation_prompt=self.confirmation_prompt,
            value_proc=lambda x: self.process_prompt_value(ctx, x, prompt_type))

@click.command()
@click.option('--hash-type', prompt='Hash', type=click.Choice(['MD5', 'SHA1'], case_sensitive=False), cls=ChoiceOption)
def cli(**kwargs):
    print(kwargs)

Result:

> cli --help
Usage: cli [OPTIONS]                          

Options:                                           
  --hash-type [MD5|SHA1]                           
  --help                  Show this message and exit.

> cli --hash-type MD5
{'hash_type': 'MD5'}

> cli
Hash:                                              
  1: MD5                                          
  2: SHA1                                         
: 4                                                
Error: 4 is not in the valid range of 1 to 2.      
Hash:                                              
  1: MD5                                          
  2: SHA1                                         
: 2                                                
{'hash_type': 'SHA1'}

Edit May 25, 2020: I recently came across questionary and integrated it with click

import click
import questionary


class QuestionaryOption(click.Option):

    def __init__(self, param_decls=None, **attrs):
        click.Option.__init__(self, param_decls, **attrs)
        if not isinstance(self.type, click.Choice):
            raise Exception('ChoiceOption type arg must be click.Choice')

    def prompt_for_value(self, ctx):
        val = questionary.select(self.prompt, choices=self.type.choices).unsafe_ask()
        return val



@click.command()
@click.option('--hash-type', prompt='Hash', type=click.Choice(['MD5', 'SHA1'], case_sensitive=False), cls=QuestionaryOption)
def cli(**kwargs):
    print(kwargs)


if __name__ == "__main__":
    cli()

asciicast

Tanagra answered 27/2, 2020 at 3:3 Comment(0)
B
1

Since Click does not seem to provide a functionality of this kind, this custom validation function fulfills the purpose:

def validate_choice(ctx, param, value):
    # Check if the passed value is an integer.
    try:
        index = int(value) - 1
        # Return the value at the given index.
        try:
            return choice_names[index]
        # If the index does not exist.
        except IndexError:
            click.echo('Please select a valid index.')
    # If the value is of a different type, for example, String.
    except (TypeError, ValueError):
        # Return the value if it exists in the list of choices.
        if value in choice_names:
            return value
        else:
            click.echo('Please select a valid value from the choices {}.'.format(choice_names))

    # Prompt the user for an input.
    value = click.prompt(param.prompt)
    return validate_choice(ctx, param, value)

@click.option('--category', prompt='\nPlease enter the category.\n\n -- Options:\n{}\n\n'.format(choice_names),
              help='Category of the incident', callback=validate_category)

This allows a user to select a choice either by entering the choice name or by entering the index value. In case an invalid value is entered, the user is prompted again for an input.

Broad answered 23/1, 2019 at 8:19 Comment(3)
This code prompts during validation, yet validation will be run when this value is specified from the command line. But generally prompting only happens when a value is not specified. So this seems to be mixing concerns.Andreas
The help would be shown similar to other commands (I have updated my code). Actually, the code prompts for input only if the validation fails. But I agree that this part should be separated from the validate_choice() function, but I don't believe Click provides any other way of doing this.Broad
@StephenRauch On a different note, is there a way of manually passing the context (ctx) to a function? Currently I have the validate_choice() function duplicated in different files. So instead of writing callback=validate_category, is there a way of doing callback=validate_category(ctx, param, ...)?Broad

© 2022 - 2024 — McMap. All rights reserved.