Click Command Line Interfaces: Make options required if other optional option is unset

Viewed 18815

When writing a command-line interface (CLI) with the Python click library, is it possible to define e.g. three options where the second and third one are only required if the first (optional) one was left unset?

My use case is a log-in system which allows me to authenticate either via an authentication token (option 1), or, alternatively, via username (option 2) and password (option 3).

If the token was given, there is no need to check for username and password being defined or prompting them. Otherwise, if the token was omitted then username and password become required and must be given.

Can this be done somehow using callbacks?

My code to get started which of course does not reflect the intended pattern:

@click.command()
@click.option('--authentication-token', prompt=True, required=True)
@click.option('--username', prompt=True, required=True)
@click.option('--password', hide_input=True, prompt=True, required=True)
def login(authentication_token, username, password):
    print(authentication_token, username, password)

if __name__ == '__main__':
    login()
3 Answers

Slightly improved Stephen Rauch's answer to have multiple mutex parameters.

import click

class Mutex(click.Option):
    def __init__(self, *args, **kwargs):
        self.not_required_if:list = kwargs.pop("not_required_if")

        assert self.not_required_if, "'not_required_if' parameter required"
        kwargs["help"] = (kwargs.get("help", "") + "Option is mutually exclusive with " + ", ".join(self.not_required_if) + ".").strip()
        super(Mutex, self).__init__(*args, **kwargs)

    def handle_parse_result(self, ctx, opts, args):
        current_opt:bool = self.name in opts
        for mutex_opt in self.not_required_if:
            if mutex_opt in opts:
                if current_opt:
                    raise click.UsageError("Illegal usage: '" + str(self.name) + "' is mutually exclusive with " + str(mutex_opt) + ".")
                else:
                    self.prompt = None
        return super(Mutex, self).handle_parse_result(ctx, opts, args)

use like this:

@click.group()
@click.option("--username", prompt=True, cls=Mutex, not_required_if=["token"])
@click.option("--password", prompt=True, hide_input=True, cls=Mutex, not_required_if=["token"])
@click.option("--token", cls=Mutex, not_required_if=["username","password"])
def login(ctx=None, username:str=None, password:str=None, token:str=None) -> None:
    print("...do what you like with the params you got...")

Here's another variant, in which has not_required_if must be specified with the snake_case variant, required is used rather than prompt, and importantly, it works if the other arguments are passed through environment variables rather than on the command line by using ctx.command.get_params(...) and param.consume_value(...):

import click


class Mutex(click.Option):
    def __init__(self, *args, **kwargs):
        self.not_required_if: list = kwargs.pop("not_required_if")

        assert self.not_required_if, "'not_required_if' parameter required"
        kwargs["help"] = (kwargs.get("help", "") + "Option is mutually exclusive with " + ", ".join(self.not_required_if) + ".").strip()
        super(Mutex, self).__init__(*args, **kwargs)

    def handle_parse_result(self, ctx, opts, args):
        current_opt: bool = self.consume_value(ctx, opts)
        for other_param in ctx.command.get_params(ctx):
            if other_param is self:
                continue
            if other_param.human_readable_name in self.not_required_if:
                other_opt: bool = other_param.consume_value(ctx, opts)
                if other_opt:
                    if current_opt:
                        raise click.UsageError(
                            "Illegal usage: '" + str(self.name)
                            + "' is mutually exclusive with "
                            + str(other_param.human_readable_name) + "."
                        )
                    else:
                        self.required = None
        return super(Mutex, self).handle_parse_result(ctx, opts, args)
Related