Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Using a numeric identifier for value selection in click.Choice

Tags:

python-click

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.

like image 819
Adeel Ahmad Avatar asked Oct 17 '22 07:10

Adeel Ahmad


2 Answers

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

like image 193
M.Vanderlee Avatar answered Oct 19 '22 22:10

M.Vanderlee


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.

like image 33
Adeel Ahmad Avatar answered Oct 20 '22 00:10

Adeel Ahmad