Skip to content

Asking questions from a command (text, hidden secrets, confirmation, single and multiple choice, destructive confirmation) and how each behaves without a terminal.

Six prompts, all available on the command.

name = self.ask("Project name", default="myapp")
password = self.secret("Password", confirm=True)
if self.confirm("Continue?", default=True):
...

A prompt that cannot be shown: no terminal, a pipe, CI, falls back to its default. A prompt that cannot be shown and has no default raises.

That is deliberate, and it is the difference between a scripted invocation that stops and one that silently creates the wrong account. Guessing on the user’s behalf is never the safe option.

The practical consequence: pass default= to every prompt a command might hit in CI, and the same command works in both places.

self.ask("Project name")
self.ask("Project name", default="myapp")
self.ask("Port", validate=positive_int)

Free text. validate is any callable. Return the converted value, or raise ValueError with a message the user should see:

def positive_int(raw: str) -> int:
value = int(raw)
if value <= 0:
raise ValueError("must be greater than zero")
return value

The question is re-asked until the validator is happy or the user cancels.

password = self.secret() # "Password"
password = self.secret("New password", confirm=True)

Reads without echoing. With confirm=True it asks twice and requires a match, re-asking on a mismatch.

if not self.confirm("Drop every recorded failure?", default=False):
self.muted("Nothing done.")
return 1

Yes or no. The default is what a bare Enter means, and what a non-interactive run gets, so default=False on anything destructive.

driver = self.choice(
"Which database?",
["sqlite", "postgres", "mysql"],
)

One from a list. Arrow keys move, Enter selects. Options are either plain strings, used as both value and label, or (value, label) pairs when the two should differ:

driver = self.choice("Which database?", [
("aiosqlite", "SQLite — no server to run"),
("asyncpg", "PostgreSQL"),
("asyncmy", "MySQL / MariaDB"),
])

default= sets the initially highlighted value, and is what a non-interactive run returns.

extras = self.multichoice(
"Which features?",
["record", "cache", "jwt", "mail"],
defaults=["record"],
minimum=1,
)

Several from a list. Space toggles, Enter accepts. minimum refuses to accept fewer than that many, which is how you make “at least one” a rule rather than a hint. Returns a list.

agreed = self.prompt.confirm_destructive(
"This unapplies every migration and drops the tables they made.",
"zero",
)

Requires the user to type a phrase back. For operations where a mistyped y is expensive (dropping a database, rolling back to zero) so that muscle memory cannot approve them.

It is on self.prompt rather than the command, because it is rare enough that it should read as a deliberate escalation.

Non-interactively it returns False. There is no default that could be correct: a destructive operation that proceeds because nobody was there to stop it is exactly what this exists to prevent. Give the command a --force flag for scripts, the way db:rollback and queue:flush do.

Ctrl-C at any prompt raises Abort, which the console reports as Cancelled. and exits 130. Abort is a separate exception from KeyboardInterrupt so a command can catch an abandoned prompt without also catching a Ctrl-C aimed at its own work.

Force interactivity off, and every prompt takes its default:

console = Console(prog="tools.py", interactive=False)
assert console.run(["posts:backfill"]) == 0

For a prompt with no default this raises, which is the behaviour you want a test to catch: it means the command cannot run unattended.