typer-patterns.md 6.6 KB

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