Input & UX

Building Interactive Prompts and Menus in Python CLIs

Add arrow-key menus, checkboxes and validated prompts to a Python CLI with questionary, keeping flag parity, non-interactive fallbacks and testable prompt logic.

Updated

Some commands benefit from a conversation. Creating a new project, configuring credentials for the first time, choosing which environments to deploy to — tasks done rarely, with several decisions, by people who do not remember the flags. Typing a template name correctly from memory is harder than picking it from an arrow-key menu; ticking three features in a checkbox list is easier than recalling --feature spelled three times. Typer and Click provide simple text prompts and confirmations; for menus, checkboxes and autocompletion, questionary (built on prompt_toolkit) is the usual choice. This guide builds a guided new command that asks only for what flags did not supply, validates as the user types, confirms a summary, prints the equivalent command for next time — and never prompts where nobody can answer. It belongs to the interactive terminal UI with Rich topic.

Prerequisites

Choosing the right kind of question

Choosing the right question Types of interactive prompt, what each is for, and which library provides it. Choosing the right question Prompt Use for Provided by Text names, free input typer.prompt, questionary Confirm yes/no before acting typer.confirm Select one of a list questionary.select Checkbox several of a list questionary.checkbox Autocomplete / fuzzy long lists questionary.autocomplete An arrow-key menu beats typing a value for anything with fewer than a few dozen options.

Match the question to the answer. Text for free-form values such as names, always with validation. Confirm for a yes/no before doing something. Select — an arrow-key menu — for one choice from a short list, which removes typos entirely. Checkbox for several choices. Autocomplete for long lists where typing a few characters narrows the options. Typer's typer.prompt and typer.confirm cover the first two with no extra dependency; questionary adds the rest.

The recipe: prompts that fill gaps

A guided flow with flag parity Each question in the interactive flow is skipped if the matching flag was given, the answers are validated, a summary is shown, and the equivalent command line is printed. A guided flow with flag parity Flags given? skip those Ask the rest select, text Confirm summary y/N Print command for next time gaps answers done Printing the equivalent flags teaches users the fast path from the slow one.

Keep the prompt logic separate from both the command and the prompt library. The flow is a function that receives an Asker — anything with text, select, checkbox and confirm methods — so production code passes a questionary-backed implementation and tests pass a fake:

# src/mytool/wizard.py
from __future__ import annotations

import re
import shlex
from dataclasses import dataclass
from typing import Protocol

TEMPLATES = {"basic": "A minimal CLI", "service": "A CLI with an HTTP client", "data": "A data pipeline CLI"}
FEATURES = {"docker": "Dockerfile", "ci": "GitHub Actions workflow", "docs": "MkDocs site"}
NAME_RE = re.compile(r"^[a-z][a-z0-9-]{1,30}$")


class Asker(Protocol):
    def text(self, message: str, validate) -> str: ...
    def select(self, message: str, choices: dict[str, str], default: str) -> str: ...
    def checkbox(self, message: str, choices: dict[str, str]) -> list[str]: ...
    def confirm(self, message: str) -> bool: ...


@dataclass
class Answers:
    name: str | None = None
    template: str | None = None
    features: list[str] | None = None

    def command(self) -> str:
        parts = ["mytool", "new", "--name", self.name or "", "--template", self.template or ""]
        for f in self.features or []:
            parts += ["--feature", f]
        return shlex.join(parts)


def validate_name(value: str) -> bool | str:
    return True if NAME_RE.fullmatch(value) else "use 2-31 lowercase letters, digits and dashes"


def fill_gaps(given: Answers, ask: Asker) -> Answers | None:
    """Ask only for what flags did not provide; None if the user declines at the end."""
    name = given.name or ask.text("Project name:", validate=validate_name)
    template = given.template or ask.select("Template:", TEMPLATES, default="basic")
    features = given.features if given.features is not None else ask.checkbox("Extras:", FEATURES)
    answers = Answers(name, template, features)
    summary = f"Create {name!r} from the {template!r} template with {', '.join(features) or 'no extras'}?"
    return answers if ask.confirm(summary) else None


class QuestionaryAsker:
    """The real implementation. Imported lazily so non-interactive runs never load it."""

    def text(self, message, validate):
        import questionary
        return questionary.text(message, validate=validate).unsafe_ask()

    def select(self, message, choices, default):
        import questionary
        opts = [questionary.Choice(f"{k:<8} {v}", value=k) for k, v in choices.items()]
        return questionary.select(message, choices=opts, default=next(o for o in opts if o.value == default)).unsafe_ask()

    def checkbox(self, message, choices):
        import questionary
        return questionary.checkbox(message, choices=[questionary.Choice(v, value=k) for k, v in choices.items()]).unsafe_ask()

    def confirm(self, message):
        import questionary
        return questionary.confirm(message, default=True).unsafe_ask()
# src/mytool/cli.py
from __future__ import annotations

import sys
from typing import Annotated

import typer

from mytool.wizard import FEATURES, TEMPLATES, Answers, QuestionaryAsker, fill_gaps

app = typer.Typer()


def interactive() -> bool:
    return sys.stdin.isatty() and sys.stdout.isatty()


@app.callback()
def main() -> None:
    """Project scaffolding."""


@app.command()
def new(
    name: Annotated[str | None, typer.Option(help="Project name.")] = None,
    template: Annotated[str | None, typer.Option(help=f"One of: {', '.join(TEMPLATES)}.")] = None,
    feature: Annotated[list[str] | None, typer.Option(help=f"Extras: {', '.join(FEATURES)} (repeatable).")] = None,
    no_input: Annotated[bool, typer.Option("--no-input", help="Never prompt.")] = False,
) -> None:
    """Create a project; prompts for anything not given as a flag."""
    given = Answers(name, template, feature)
    complete = name is not None and template is not None
    if not complete and (no_input or not interactive()):
        typer.echo("error: --name and --template are required when not running interactively", err=True)
        raise typer.Exit(2)
    if complete:
        answers = Answers(name, template, feature or [])
    else:
        try:
            answers = fill_gaps(given, QuestionaryAsker())
        except KeyboardInterrupt:
            typer.echo("\ncancelled", err=True)
            raise typer.Exit(130)
        if answers is None:
            typer.echo("nothing created", err=True)
            raise typer.Exit(1)
        typer.echo(f"next time: {answers.command()}", err=True)
    typer.echo(f"created {answers.name} ({answers.template}; {', '.join(answers.features) or 'no extras'})")


if __name__ == "__main__":
    app()

What the design gives you

Flag parity. Every question corresponds to a flag. mytool new --name billing --template service --feature ci runs without a single prompt, so documentation, scripts and CI use the same command a newcomer uses interactively.

Prompts fill gaps only. fill_gaps asks only for values the flags did not provide. Someone who remembers the template but not the extras types mytool new --template service and is asked two questions, not four.

Validation while typing. questionary runs the validate callback on each keystroke and shows the message inline, so an invalid name never gets as far as the command. The same validate_name function is used for flags, keeping the rules identical.

A summary before acting. The final confirmation restates what will happen, the moment to catch a wrong template before files are created.

The equivalent command, printed at the end. "next time: mytool new --name billing --template service --feature ci" teaches the fast path from the slow one, and gives users something to paste into docs or scripts. shlex.join quotes it correctly for the shell.

Lazy import. questionary and prompt_toolkit are imported inside QuestionaryAsker's methods, so commands run from scripts never pay their import cost — consistent with lazy-loading subcommands for faster startup.

unsafe_ask() and Ctrl+C. questionary's ask() swallows Ctrl+C and returns None; unsafe_ask() raises KeyboardInterrupt, which the command catches to print "cancelled" and exit 130 — the convention from handling KeyboardInterrupt cleanly. Returning None from a prompt silently is how half-configured projects get created.

UX considerations

An arrow-key menu Terminal output of an interactive selection prompt with a highlighted option, followed by the equivalent command printed at the end. An arrow-key menu bash $ mytool new ? Template: basic A minimal CLI » service A CLI with an HTTP client data A data pipeline CLI next time: mytool new --template service --name billing Menus prevent typos entirely; the printed command makes the choice repeatable.
  • Never prompt without a terminal. The command checks that stdin and stdout are TTYs and that --no-input was not given; otherwise it fails immediately, naming the missing flags. A prompt in CI hangs until the job times out. Detecting CI environments and non-interactive shells covers the detection in detail.
  • Sensible defaults. Pre-select the most common option (default="basic") so Enter does the usual thing.
  • Descriptions in menus. "service A CLI with an HTTP client" is more useful than "service" alone; menus are the one place where a short description costs nothing.
  • Keep flows short. Three to five questions is comfortable; beyond that, write the answers to a config file the user can edit, rather than asking every time.
  • Prompts go to the terminal, not stdout. questionary writes to the terminal directly, so a command's stdout remains clean data — the final created ... line here — even after an interactive session.

Testing the behaviour

Because the flow depends on an Asker protocol, tests exercise it with a fake that records which questions were asked:

# tests/test_wizard.py
from typer.testing import CliRunner

from mytool.cli import app
from mytool.wizard import Answers, fill_gaps, validate_name

runner = CliRunner()


class FakeAsker:
    def __init__(self, **answers):
        self.answers = answers
        self.asked: list[str] = []

    def text(self, message, validate):
        self.asked.append("name")
        value = self.answers["name"]
        assert validate(value) is True
        return value

    def select(self, message, choices, default):
        self.asked.append("template")
        return self.answers["template"]

    def checkbox(self, message, choices):
        self.asked.append("features")
        return self.answers["features"]

    def confirm(self, message):
        return self.answers.get("confirm", True)


def test_only_missing_values_are_asked():
    ask = FakeAsker(template="service", features=["ci"])
    result = fill_gaps(Answers(name="billing"), ask)
    assert ask.asked == ["template", "features"]
    assert result.command() == "mytool new --name billing --template service --feature ci"


def test_declining_the_summary_returns_none():
    assert fill_gaps(Answers("a1", "basic", []), FakeAsker(confirm=False)) is None


def test_name_validation():
    assert validate_name("billing-api") is True
    assert "lowercase" in validate_name("Billing API")


def test_flags_alone_never_prompt():
    result = runner.invoke(app, ["new", "--name", "web", "--template", "basic", "--feature", "docker"])
    assert result.exit_code == 0 and "created web (basic; docker)" in result.output


def test_missing_values_without_a_terminal_fail_fast():
    result = runner.invoke(app, ["new", "--name", "web"])
    assert result.exit_code == 2 and "--template" in result.output

The first test pins the most important behaviour — only missing values are asked — and the last two pin the non-interactive contract that keeps automation working. For end-to-end tests of the real questionary prompts, prompt_toolkit supports piped input: create one with prompt_toolkit.input.create_pipe_input(), send keystrokes such as "j\r" (down, Enter), and pass it with output=DummyOutput() to questionary.select(..., input=..., output=...). Keep such tests few; the fake covers the logic.

Conclusion

Interactive prompts make rare, multi-decision tasks approachable, provided they sit on top of flags rather than replacing them. Ask only for what flags did not provide, choose the right question type — menus and checkboxes over free text where possible — validate as the user types, confirm a summary, and print the equivalent command at the end. Refuse to prompt without a terminal, raise on Ctrl+C instead of returning None, import the prompt library lazily, and keep the flow testable behind a small protocol.

Frequently asked questions

questionary, InquirerPy or Rich prompts?

Rich's Prompt and Confirm handle text and yes/no with nice styling but no menus. questionary and InquirerPy both provide menus, checkboxes and fuzzy selection on prompt_toolkit; questionary has a simpler API and wide use. Pick one and wrap it, as the Asker protocol does, so switching later is local.

Do these prompts work on Windows?

Yes — prompt_toolkit supports Windows consoles and Windows Terminal. Legacy consoles may render some glyphs (pointer symbols, checkbox marks) as plain characters, which remains usable.

How do I prompt for passwords?

Use questionary.password or typer.prompt(..., hide_input=True), and follow the rules in prompting for passwords securely: never echo, confirm new secrets, and always offer a non-interactive path.

Can I show a menu of values fetched from an API?

Yes, but fetch before showing the prompt, with a spinner and a timeout, and cache the list if it is reused. A menu that freezes while loading feels broken.