Reputation: 1409
I'm using click to implement a command-line interface in Python. Click has a feature that can prompt for a value if one isn't specified. Like so:
@click.command()
@click.option('--name', prompt=True)
def hello(name):
click.echo(f"Hello {name}!")
This is great when a CLI is being used interactively, but prompting during a non-interactive run is bad (can hang a shell script, for example). I'd like to add a -q
flag that would globally disable prompting for every other option.
Is there a straightforward way to do this?
Upvotes: 2
Views: 3279
Reputation: 29608
You can define a Command Group that accepts a -q
flag, then attach all the commands that you would want to be affected by this -q
flag as subcommands of that group. Save the -q
existence/non-existence in the group function, and pass it around to the subcommands as part of the command Context.
https://click.palletsprojects.com/en/8.0.x/commands/#nested-handling-and-contexts
Each time a command is invoked, a new context is created and linked with the parent context. Normally, you can’t see these contexts, but they are there. Contexts are passed to parameter callbacks together with the value automatically.
import click
@click.group()
@click.option("-q", help="Disable all prompts", flag_value=True, default=False)
@click.pass_context
def cli(ctx, q):
# Ensure that ctx.obj exists and is a dict
ctx.ensure_object(dict)
# Apply group-wide feature switches
ctx.obj["q"] = q
print(f"In group: {ctx.obj}")
@cli.command()
@click.option("--name", prompt=True)
def cmd1(name):
click.echo(f"Hello {name}!")
$ python cli.py
Usage: cli.py [OPTIONS] COMMAND [ARGS]...
Options:
-q Disable all prompts
...
Commands:
cmd1
$ python cli.py cmd1
In group: {'q': False}
Name: abc
Hello abc!
$ python cli.py cmd1 --name=xyz
In group: {'q': False}
Hello xyz!
$ python cli.py -q cmd1 --name=xyz
In group: {'q': True}
Hello xyz!
Here, notice that -q
is a flag on the entire CLI, not just for cmd1
, which, as I understand, is what you wanted. It doesn't affect cmd1
yet, but it's there in the context for all commands on that group.
How then do we actually disable the prompt based on what's q
in the context? You can define a custom click.Option
class and override the prompt_for_value(ctx)
method.
import click
DEFAULTS = {
"name": "automatically-set-name",
}
class DynamicPromptOption(click.Option):
def prompt_for_value(self, ctx):
q = ctx.obj.get("q")
print(f"In option {self.name}: q={q}")
if q:
return DEFAULTS[self.name]
return super().prompt_for_value(ctx)
Here, you skip the base implementation call to' prompt_for_value
when -q
was passed (technically, when q
is True
-ish), and just return some automatic default value directly (in my example above, maybe from a global constant or dictionary of values).
Then, make sure to set the cls=<custom class>
parameter on your @click.option
decorator to use your custom Option
class.
@cli.command()
@click.option("--name", prompt=True, cls=DynamicPromptOption)
def cmd1(name):
click.echo(f"Hello {name}!")
$ python cli.py cmd1
In group: {'q': False}
In option name: q=False
Name: abc
Hello abc!
$ python cli.py -q cmd1
In group: {'q': True}
In option name: q=True
Hello automatically-set-name!
The nice thing is that the interactive usage should be unaffected:
$ python cli.py cmd1
In group: {'q': False}
In option name: q=False
Name: abc
Hello abc!
$ python cli.py cmd1 --name=xyz
In group: {'q': False}
Hello xyz!
$ python cli.py cmd1 --name
In group: {'q': False}
Error: Option '--name' requires an argument.
Upvotes: 3
Reputation: 29608
If a -q
flag is not a hard requirement, and the goal is to basically override the prompting behavior for automation purposes, a typical solution is to get the option values from environment variables. I think this is a much simpler solution than my other answer which does implement the -q
flag.
https://click.palletsprojects.com/en/8.0.x/options/#values-from-environment-variables
A very useful feature of Click is the ability to accept parameters from environment variables in addition to regular parameters. This allows tools to be automated much easier. For instance, you might want to pass a configuration file with a
--config
parameter but also support exporting aTOOL_CONFIG=hello.cfg
key-value pair for a nicer development experience.
...
To enable this feature, theauto_envvar_prefix
parameter needs to be passed to the script that is invoked. Each command and parameter is then added as an uppercase underscore-separated variable. If you have a subcommand calledrun
taking an option calledreload
and the prefix isWEB
, then the variable isWEB_RUN_RELOAD
.
import click
@click.group()
def cli():
pass
@cli.command()
@click.option("--name", prompt=True, show_envvar=True)
def cmd1(name):
click.echo(f"Hello {name}!")
if __name__ == "__main__":
cli(auto_envvar_prefix="NON_INTERACTIVE")
$ python cli.py cmd1 --help
Usage: cli.py cmd1 [OPTIONS]
Options:
--name TEXT [env var: NON_INTERACTIVE_CMD1_NAME]
--help Show this message and exit.
$ python cli.py cmd1
Name: abc
Hello abc!
$ python cli.py cmd1 --name=xyz
Hello xyz!
$ export NON_INTERACTIVE_CMD1_NAME=xyz
$ python cli.py cmd1
Hello xyz!
$ unset NON_INTERACTIVE_CMD1_NAME
$ python cli.py cmd1
Name: abc
Hello abc!
Rather than a -q
that you need to handle within your CLI code, you instead implement it from whichever system is automating or running your CLI. You can then create different .env files for the different sets of default values, and then read them in as needed (or not, if you want to use the CLI interactively):
$ cat defaults.env
NON_INTERACTIVE_CMD1_NAME=XYZ
NON_INTERACTIVE_CMD2_VERSION=1.0.0
NON_INTERACTIVE_CMD3_PORT=3000
$ cat automate.sh
#!/usr/bin/env bash
# https://stackoverflow.com/a/45971167/2745495
set -a
source defaults.env
set +a
python cli.py cmd1
$ ./automate.sh
Hello XYZ!
$ python cli.py cmd1
Name: xyz
Hello xyz!
Upvotes: 0