Python Typer Subcommands, Callbacks, and Prompts
python typer subcommands callbacks and prompts: Learn how to structure Typer CLIs with subcommands, use callbacks for shared state and validation, and prompt users for...
Typer is a Python library for building command-line interfaces. When your CLI grows beyond a single command, you need a way to organize related commands, share configuration across them, and collect input from users. The combination of subcommands, callbacks, and prompts addresses these needs directly. This article shows how to use python typer subcommands callbacks and prompts to build a maintainable interactive CLI.
Why Subcommands, Callbacks, and Prompts Matter in Typer
A CLI that only has one command is simple, but most real-world tools expose multiple operations. For example, a database CLI might have init, migrate, and seed commands. Without subcommands, you end up with a flat list of commands that becomes hard to navigate and document. Typer's subcommands let you group related operations under a single parent command, making the CLI structure match the domain.
Callbacks and prompts solve two different problems. A callback runs before any subcommand and can validate global options, set up shared state, or execute common logic. A prompt asks the user for input interactively, which is useful for values that are too sensitive or too variable to pass as command-line arguments. Together, they let you build a CLI that feels like a guided conversation rather than a rigid flag parser.
Building a Typer App with Subcommands
In Typer, you create a subcommand group by using a typer.Typer() instance and adding commands with the @app.command() decorator. The top-level Typer instance becomes the group, and each decorated function becomes a subcommand. Here is a minimal example:
import typer app = typer.Typer() @app.command() def init(): typer.echo("Initializing project") @app.command() def migrate(): typer.echo("Running migrations") if __name__ == "__main__": app()
When you run this script with python cli.py init, Typer dispatches to the init function. The app object is the root command, and each function is a subcommand. You can also nest groups by creating another Typer instance and adding it with add_typer(), but for most CLIs a single level of subcommands is enough.
Using Callbacks to Manage Shared State and Validation
The @app.callback() decorator marks a function that runs before any subcommand. This is the right place to handle global options, validate the environment, or set up context that all subcommands need. The callback can receive arguments that are not part of any subcommand, and it can pass data to subcommands through the ctx object.
import typer from typing import Optional app = typer.Typer() @app.callback() def main( ctx: typer.Context, verbose: bool = typer.Option(False, "--verbose", "-v"), config_file: Optional[str] = typer.Option(None, "--config"), ): """Global options and shared state.""" ctx.obj = {"verbose": verbose, "config_file": config_file} if verbose: typer.echo("Verbose mode on") @app.command() def status(ctx: typer.Context): if ctx.obj["verbose"]: typer.echo("Fetching status with verbose output") typer.echo("Status: OK")
Here, the callback defines --verbose and --config options that are available before any subcommand. It stores them in ctx.obj, and the status subcommand reads that shared state. This pattern keeps global configuration in one place and avoids repeating the same options in every subcommand.
Callbacks also work well for validation. If the callback raises an exception, Typer stops execution and shows the error before any subcommand runs. This prevents subcommands from executing with invalid global settings.
Adding Interactive Prompts to Commands
Typer provides typer.prompt() and typer.confirm() to collect input interactively. These functions are useful when a value is not suitable for a command-line argument, such as a password, or when you want to guide the user through a setup process. Here is an example:
import typer app = typer.Typer() @app.command() def create_user(username: str): email = typer.prompt("Email address") password = typer.prompt("Password", hide_input=True, confirmation_prompt=True) typer.echo(f"Creating user {username} with email {email}")
typer.prompt() accepts a message and returns the user's input as a string. The hide_input=True parameter hides what the user types, which is essential for passwords. The confirmation_prompt=True parameter asks the user to type the value twice to avoid mistakes. You can also pass a type parameter to convert the input to an integer, float, or other type.
For yes/no questions, typer.confirm() is more appropriate:
if typer.confirm("Proceed with deletion?"): typer.echo("Deleting...") else: typer.echo("Aborted")
Prompts are blocking and interactive, so they should only be used in contexts where a user is running the CLI directly. They are not suitable for automated scripts or CI environments unless you provide a non-interactive fallback.
Combining Subcommands, Callbacks, and Prompts in One CLI
The real power of these features appears when you combine them. A common pattern is a callback that sets up shared state, followed by a subcommand that prompts for missing information. Consider a CLI that manages API tokens:
import typer from typing import Optional app = typer.Typer() @app.callback() def main(ctx: typer.Context, env: str = typer.Option("prod", "--env")): """Set environment and load shared config.""" ctx.obj = {"env": env, "token": None} @app.command() def login(ctx: typer.Context): """Log in and store a token.""" username = typer.prompt("Username") password = typer.prompt("Password", hide_input=True) # In a real app, you would call an API here. ctx.obj["token"] = f"{username}:{password}" typer.echo("Logged in") @app.command() def fetch(ctx: typer.Context): """Fetch data using the stored token.""" if ctx.obj["token"] is None: typer.echo("You must log in first.", err=True) raise typer.Exit(code=1) typer.echo(f"Fetching data for {ctx.obj['env']} with token {ctx.obj['token']}")
Here, the callback sets the environment and initializes a token slot. The login subcommand prompts for credentials and stores the result in ctx.obj. The fetch subcommand checks that the token exists before proceeding. This pattern gives you a coherent flow where the user is guided step by step.
Handling Errors and Edge Cases in Interactive Flows
Interactive prompts introduce a few failure modes that you need to handle. If the user presses Ctrl+C, Typer raises a KeyboardInterrupt by default, which can leave the terminal in an odd state. You can catch it and exit gracefully:
import typer try: name = typer.prompt("Name") except KeyboardInterrupt: typer.echo("\nAborted by user") raise typer.Exit(code=130)
Another edge case is invalid input. typer.prompt() with a type parameter will retry automatically if the user enters a value that cannot be converted. For example, if you ask for an integer and the user types abc, Typer will show an error and ask again. This is convenient, but it can be confusing if you need custom validation. In that case, use a loop:
while True: port = typer.prompt("Port", type=int) if 1 <= port <= 65535: break typer.echo("Port must be between 1 and 65535", err=True)
When combining callbacks and prompts, remember that the callback runs before any subcommand. If a prompt is inside a subcommand, it will not run if the callback fails. This is usually what you want, but it means you cannot rely on prompts to fix invalid global options. Validate global options in the callback and raise typer.BadParameter if necessary.
Testing and Maintaining a Typer CLI with These Features
Typer CLIs can be tested with typer.testing.CliRunner, which simulates command-line input and captures output. For interactive prompts, you can pass input via the input parameter of CliRunner.invoke(). Here is an example:
from typer.testing import CliRunner from your_cli import app runner = CliRunner() def test_login_prompt(): result = runner.invoke(app, ["login"], input="alice\nsecret\n") assert result.exit_code == 0 assert "Logged in" in result.output
The input string simulates the user typing alice, pressing enter, then secret, and pressing enter. This works for typer.prompt() and typer.confirm(). For callbacks, you can test the global options by passing them as arguments.
Maintainability suffers when prompts are scattered throughout subcommands without a clear structure. Keep prompt logic inside the subcommand that needs it, and use callbacks only for genuinely global concerns. If a prompt is reused across multiple commands, extract it into a helper function that returns the validated value. This keeps the CLI consistent and makes it easier to change the prompt behavior later.
Another maintenance concern is the separation between interactive and non-interactive modes. If you plan to support both, design your commands so that a prompt is only used when a required argument is missing. For example, you can use typer.Option(None) and then prompt if the value is None. This allows scripting without prompts while preserving interactivity for humans.
Finally, consider how callbacks and prompts affect the help output. Typer automatically generates help text from docstrings and type annotations. Make sure your callback and subcommand docstrings describe what the user will be prompted for, so the help is accurate even before the user runs the command.