| name | typer |
| description | [Applies to: **/*.py] Definitive guidelines for building robust, maintainable, and user-friendly command-line interfaces with Typer, emphasizing modern Python type hints and modular design. |
| source | cursor_mdc |
typer Best Practices
Typer is the gold standard for building Python CLIs. It leverages type hints to generate powerful, intuitive interfaces. These rules ensure your Typer applications are consistently well-structured, type-safe, and easy to maintain.
1. Code Organization and Structure
Always treat your Typer instance as a first-class object. For any CLI beyond a single-file script, modularize your commands.
✅ GOOD: Modular CLI with dedicated cli.py
Create a main cli.py that imports and registers commands from other modules. This enables dependency injection, easier testing, and better scalability.
import typer
from typing_extensions import Annotated
def create_user(
name: Annotated[str, typer.Argument(help="Name of the new user")],
email: Annotated[str, typer.Option(help="Email for the new user")],
admin: Annotated[bool, typer.Option("--admin", "-a", help="Grant admin privileges")] = False,
):
"""Creates a new user."""
print(f"Creating user: {name} <{email}> (Admin: {admin})")
def delete_user(user_id: Annotated[int, typer.Argument(help="ID of the user to delete")]):
"""Deletes a user by ID."""
print(f"Deleting user with ID: {user_id}")
user_app = typer.Typer(name="user", help="Manage users")
user_app.command("create")(create_user)
user_app.command("delete")(delete_user)
import typer
from typing_extensions import Annotated
def list_projects(
status: Annotated[str, typer.Option(help="Filter by project status")] = "active"
):
"""Lists projects."""
print(f"Listing projects with status: {status}")
project_app = typer.Typer(name="project", help="Manage projects")
project_app.command("list")(list_projects)
import typer
from my_cli_app.commands.user import user_app
from my_cli_app.commands.project import project_app
from typing_extensions import Annotated
from rich.console import Console
console = Console()
def version_callback(print_version: bool):
if print_version:
console.print("[bold green]My CLI App[/] version [yellow]1.0.0[/]")
raise typer.Exit()
app = typer.Typer(
name="my-cli-app",
help="A powerful CLI application.",
no_args_is_help=True
)
@app.callback()
def main(
version: Annotated[
bool,
typer.Option(
"--version",
"-v",
help="Show the application version and exit.",
callback=version_callback,
is_eager=True,
),
] = False,
):
"""
Manage your application resources.
"""
app.extra["console"] = console
app.add_typer(user_app)
app.add_typer(project_app)
if __name__ == "__main__":
app()
❌ BAD: Monolithic cli.py or typer.run() for complex apps
Avoid a single, giant file for all commands or using typer.run() when you have multiple commands. This quickly becomes unmanageable.
import typer
app = typer.Typer()
@app.command()
def create_user(name: str, email: str, admin: bool = False):
print(f"Creating user: {name} <{email}> (Admin: {admin})")
@app.command()
def delete_user(user_id: int):
print(f"Deleting user with ID: {user_id}")
@app.command()
def list_projects(status: str = "active"):
print(f"Listing projects with status: {status}")
if __name__ == "__main__":
app()
2. Type Hints and Annotated
Always use explicit type hints. For Typer-specific metadata (help text, defaults, etc.), use typing_extensions.Annotated. This keeps your code clean and leverages Typer's full power.
✅ GOOD: Annotated for all Typer parameters
Clearly define types and Typer options/arguments. Use ... for required options.
from typing_extensions import Annotated
import typer
def process_data(
input_file: Annotated[str, typer.Argument(help="Path to the input file")],
output_dir: Annotated[str, typer.Option(help="Directory for output files", rich_help_panel="File Paths")],
chunk_size: Annotated[int, typer.Option("--chunk-size", "-c", help="Processing chunk size")] = 1024,
force: Annotated[bool, typer.Option("--force", "-f", help="Force overwrite existing files")] = False,
config_path: Annotated[str, typer.Option(help="Path to configuration file")] = ...,
):
"""Processes data from an input file."""
print(f"Processing '{input_file}' to '{output_dir}' with chunk size {chunk_size}.")
if force:
print("Forcing overwrite.")
print(f"Using config from: {config_path}")
❌ BAD: Implicit types or typer.Option() without Annotated
This sacrifices clarity, editor support, and future-proofing.
import typer
def process_data(input_file, output_dir=typer.Option(help="Output dir"), chunk_size: int = 1024):
print("Processing...")
def old_style_option(name: str = typer.Option("World")):
print(f"Hello {name}")
3. Packaging and Entry Points
For installable CLIs, use pyproject.toml to define your project and console_scripts entry points. This makes your tool easily installable via pip install . and executable from the shell.
✅ GOOD: pyproject.toml with console_scripts
[project]
name = "my-cli-app"
version = "1.0.0"
dependencies = [
"typer[rich]>=0.12.3",
"pydantic>=2.0",
"typing-extensions>=4.0",
]
[project.scripts]
my-cli = "my_cli_app.cli:app"
4. Rich Output and Pydantic Validation
Enhance user experience with Rich for styled output and robust validation with Pydantic models for complex configurations.
✅ GOOD: Rich for styled output, Pydantic for config
from pydantic import BaseModel, Field
from typing import Optional
class AppSettings(BaseModel):
log_level: str = Field("INFO", description="Logging level")
api_key: Optional[str] = Field(None, description="API key for external service")
timeout: int = Field(30, description="Request timeout in seconds")
import typer
from rich.console import Console
from rich.panel import Panel
from my_cli_app.config import AppSettings
from typing_extensions import Annotated
console = Console()
@app.command()
def run_task(
task_id: Annotated[str, typer.Argument(help="ID of the task to run")],
settings_path: Annotated[Optional[str], typer.Option(help="Path to settings file")] = None,
):
"""Executes a specific task."""
console.print(Panel(f"[bold blue]Running Task: {task_id}[/bold blue]", expand=False))
settings = AppSettings()
settings_path:
console.()
console.()
console.()
settings.api_key:
console.()
:
console.()
console = app.extra.get(, Console())
console.log()
5. Argument Parsing and Validation
Understand the distinction between arguments and options. Leverage Typer's features for robust validation.
✅ GOOD: Clear arguments/options, Enums for choices, callbacks for complex logic
from typing_extensions import Annotated
import typer
from enum import Enum
class Environment(str, Enum):
DEV = "dev"
STAGING = "staging"
PROD = "prod"
def validate_port(value: int):
if not 1024 <= value <= 65535:
raise typer.BadParameter("Port must be between 1024 and 65535.")
return value
@app.command()
def deploy(
env: Annotated[Environment, typer.Argument(help="Deployment environment")],
branch: Annotated[str, typer.Option(help="Git branch to deploy")] = "main",
port: Annotated[int, typer.Option(help="Port for the service", callback=validate_port)] = 8000,
):
"""Deploys the application to a specified environment."""
print(f"Deploying branch '{branch}' to {env.value} on port {port}.")
❌ BAD: Mutable default arguments, manual validation, unclear distinctions
Mutable defaults can lead to unexpected behavior. Manual validation bypasses Typer's error handling.
def add_item(items: list = []):
items.append("new_item")
print(items)
def start_server(port: int):
if not 1024 <= port <= 65535:
print("Error: Port must be between 1024 and 65535.")
return
print(f"Starting server on port {port}")