Advanced Typer Patterns
Modern CLI development patterns with Typer.
Application Structure
import typer
from typing import Optional
from enum import Enum
# Create app with metadata
app = typer.Typer(
name="myapp",
help="My CLI application",
add_completion=True,
no_args_is_help=True, # Show help if no command given
rich_markup_mode="rich", # Enable Rich formatting in help
)
# State object for shared options
class State:
def __init__(self):
self.verbose: bool = False
self.config_path: str = ""
state = State()
@app.callback()
def main(
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
config: str = typer.Option("config.yaml", "--config", "-c", help="Config file"),
):
"""
My awesome CLI application.
Use --help on any command for more info.
"""
state.verbose = verbose
state.config_path = config
Type-Safe Arguments
from typing import Annotated
from enum import Enum
from pathlib import Path
class OutputFormat(str, Enum):
json = "json"
yaml = "yaml"
table = "table"
@app.command()
def export(
# Required argument
query: Annotated[str, typer.Argument(help="Search query")],
# Optional argument with default
limit: Annotated[int, typer.Argument()] = 10,
# Path validation
output: Annotated[
Path,
typer.Option(
"--output", "-o",
help="Output file path",
exists=False, # Must not exist
file_okay=True,
dir_okay=False,
writable=True,
resolve_path=True,
)
] = None,
# Input file (must exist)
input_file: Annotated[
Path,
typer.Option(
"--input", "-i",
exists=True, # Must exist
readable=True,
)
] = None,
# Enum choices
format: Annotated[
OutputFormat,
typer.Option("--format", "-f", case_sensitive=False)
] = OutputFormat.table,
# Multiple values
tags: Annotated[
list[str],
typer.Option("--tag", "-t", help="Tags to filter")
] = None,
):
"""Export data with various options."""
typer.echo(f"Query: {query}, Format: {format.value}")
Interactive Prompts
import typer
@app.command()
def create_user():
"""Create a new user interactively."""
# Text prompt
name = typer.prompt("What's your name?")
# With default
email = typer.prompt("Email", default=f"{name.lower()}@example.com")
# Hidden input (password)
password = typer.prompt("Password", hide_input=True)
# Confirmation
password_confirm = typer.prompt("Confirm password", hide_input=True)
if password != password_confirm:
typer.echo("Passwords don't match!")
raise typer.Abort()
# Yes/No confirmation
if typer.confirm("Create this user?"):
typer.echo(f"Creating user: {name}")
else:
typer.echo("Cancelled")
raise typer.Abort()
# Non-interactive with --yes flag
@app.command()
def delete_all(
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
):
"""Delete all items."""
if not yes:
yes = typer.confirm("Are you sure?")
if yes:
typer.echo("Deleting...")
else:
raise typer.Abort()
Context and Dependency Injection
import typer
from typing import Annotated
# Create a context type
class Context:
def __init__(self, db_url: str, debug: bool):
self.db_url = db_url
self.debug = debug
self.db = None
def connect(self):
self.db = create_connection(self.db_url)
# Store in typer context
@app.callback()
def main(
ctx: typer.Context,
db_url: str = typer.Option("sqlite:///app.db", envvar="DATABASE_URL"),
debug: bool = typer.Option(False, "--debug"),
):
"""Initialize application context."""
ctx.obj = Context(db_url=db_url, debug=debug)
ctx.obj.connect()
@app.command()
def query(
ctx: typer.Context,
sql: str,
):
"""Run a SQL query."""
result = ctx.obj.db.execute(sql)
for row in result:
typer.echo(row)
Subcommands and Nested Apps
import typer
# Main app
app = typer.Typer()
# Sub-applications
db_app = typer.Typer(help="Database operations")
cache_app = typer.Typer(help="Cache operations")
# Register sub-apps
app.add_typer(db_app, name="db")
app.add_typer(cache_app, name="cache")
@db_app.command("migrate")
def db_migrate():
"""Run database migrations."""
typer.echo("Running migrations...")
@db_app.command("seed")
def db_seed():
"""Seed database with test data."""
typer.echo("Seeding database...")
@cache_app.command("clear")
def cache_clear():
"""Clear cache."""
typer.echo("Clearing cache...")
# Usage:
# myapp db migrate
# myapp db seed
# myapp cache clear
Async Commands
import typer
import asyncio
app = typer.Typer()
async def async_operation():
await asyncio.sleep(1)
return "Done"
@app.command()
def fetch():
"""Fetch data asynchronously."""
result = asyncio.run(async_main())
typer.echo(result)
async def async_main():
results = await asyncio.gather(
async_operation(),
async_operation(),
)
return results
Testing CLI Apps
from typer.testing import CliRunner
import pytest
runner = CliRunner()
def test_hello():
result = runner.invoke(app, ["hello", "World"])
assert result.exit_code == 0
assert "Hello, World!" in result.stdout
def test_hello_with_options():
result = runner.invoke(app, ["hello", "World", "--count", "3", "--loud"])
assert result.exit_code == 0
assert "HELLO, WORLD!" in result.stdout
assert result.stdout.count("HELLO") == 3
def test_invalid_input():
result = runner.invoke(app, ["process", "nonexistent.txt"])
assert result.exit_code == 1
assert "not found" in result.stdout.lower()
# With environment variables
def test_with_env():
result = runner.invoke(
app,
["connect"],
env={"DATABASE_URL": "sqlite:///test.db"}
)
assert result.exit_code == 0
Quick Reference
| Pattern |
Syntax |
| App callback |
@app.callback() for global options |
| Context |
ctx: typer.Context + ctx.obj |
| Envvar |
typer.Option(envvar="VAR_NAME") |
| Prompt |
typer.prompt("Question") |
| Confirm |
typer.confirm("Sure?") |
| Abort |
raise typer.Abort() |
| Exit |
raise typer.Exit(code=1) |
| Progress |
Use Rich track() |
| Decorator |
Purpose |
@app.command() |
Define a command |
@app.callback() |
App initialization |
@sub_app.command() |
Subcommand |