| # Building CLI Applications with Typer |
|
|
| ## Learning Objectives |
|
|
| - Understand Typer's type-hint based approach to CLI development |
| - Create commands with arguments and options |
| - Implement subcommands for complex CLIs |
| - Add rich formatting and user-friendly output |
| - Handle errors and validation gracefully |
|
|
| ## Introduction to Typer |
|
|
| Typer is a modern CLI framework that uses Python type hints to automatically generate command-line interfaces. It's built on top of Click but provides a more intuitive, type-safe API. |
|
|
| ### Why Typer? |
|
|
| - **Type hints**: Uses Python's type system for automatic validation |
| - **Auto-completion**: Built-in shell completion support |
| - **Rich integration**: Beautiful terminal output out of the box |
| - **Less boilerplate**: Minimal code for maximum functionality |
| - **Great docs**: Excellent documentation and examples |
|
|
| ## Installation |
|
|
| ```bash |
| pixi add typer rich |
| ``` |
|
|
| ## Your First Typer CLI |
|
|
| Create `src/my_cli/cli.py`: |
|
|
| ```python |
| import typer |
| from rich.console import Console |
|
|
| app = typer.Typer(help="My awesome CLI tool") |
| console = Console() |
|
|
| @app.command() |
| def hello(name: str = typer.Argument(..., help="Your name")): |
| """Say hello to NAME.""" |
| console.print(f"[bold green]Hello {name}![/bold green]") |
|
|
| if __name__ == "__main__": |
| app() |
| ``` |
|
|
| Run it: |
|
|
| ```bash |
| pixi run python -m my_cli.cli hello World |
| # Output: Hello World! |
| ``` |
|
|
| ## Commands, Arguments, and Options |
|
|
| ### Arguments |
|
|
| Required positional parameters: |
|
|
| ```python |
| @app.command() |
| def process( |
| filename: str = typer.Argument(..., help="File to process"), |
| output: str = typer.Argument("output.txt", help="Output file") |
| ): |
| """Process FILENAME and save to OUTPUT.""" |
| console.print(f"Processing {filename} → {output}") |
| ``` |
|
|
| ### Options |
|
|
| Optional named parameters: |
|
|
| ```python |
| from pathlib import Path |
|
|
| @app.command() |
| def organize( |
| directory: Path = typer.Option( |
| ".", |
| "--dir", |
| "-d", |
| help="Directory to organize", |
| exists=True, |
| file_okay=False, |
| dir_okay=True |
| ), |
| dry_run: bool = typer.Option( |
| False, |
| "--dry-run", |
| help="Preview changes without executing" |
| ), |
| verbose: bool = typer.Option( |
| False, |
| "--verbose", |
| "-v", |
| help="Show detailed output" |
| ) |
| ): |
| """Organize files in DIRECTORY.""" |
| if dry_run: |
| console.print("[yellow]DRY RUN MODE[/yellow]") |
| |
| if verbose: |
| console.print(f"[blue]Processing: {directory}[/blue]") |
| ``` |
|
|
| ## Rich Output Formatting |
|
|
| ### Console Printing |
|
|
| ```python |
| from rich.console import Console |
| from rich.table import Table |
| from rich.progress import track |
| import time |
|
|
| console = Console() |
|
|
| @app.command() |
| def stats(directory: Path): |
| """Show statistics about files.""" |
| |
| # Colored output |
| console.print("[bold cyan]File Statistics[/bold cyan]") |
| |
| # Tables |
| table = Table(title="File Types") |
| table.add_column("Extension", style="cyan") |
| table.add_column("Count", justify="right", style="green") |
| |
| table.add_row(".py", "42") |
| table.add_row(".txt", "15") |
| table.add_row(".md", "8") |
| |
| console.print(table) |
| |
| # Progress bars |
| console.print("\n[bold]Processing files...[/bold]") |
| for _ in track(range(100), description="Scanning..."): |
| time.sleep(0.01) |
| ``` |
|
|
| ## Subcommands |
|
|
| For complex CLIs, organize commands into groups: |
|
|
| ```python |
| import typer |
|
|
| app = typer.Typer() |
|
|
| # Create subcommand groups |
| files_app = typer.Typer(help="File operations") |
| config_app = typer.Typer(help="Configuration management") |
|
|
| app.add_typer(files_app, name="files") |
| app.add_typer(config_app, name="config") |
|
|
| # File commands |
| @files_app.command("organize") |
| def files_organize(directory: Path): |
| """Organize files in directory.""" |
| console.print(f"Organizing {directory}") |
|
|
| @files_app.command("stats") |
| def files_stats(directory: Path): |
| """Show file statistics.""" |
| console.print(f"Stats for {directory}") |
|
|
| # Config commands |
| @config_app.command("show") |
| def config_show(): |
| """Show current configuration.""" |
| console.print("Current config...") |
|
|
| @config_app.command("set") |
| def config_set(key: str, value: str): |
| """Set configuration value.""" |
| console.print(f"Set {key} = {value}") |
| ``` |
|
|
| Usage: |
|
|
| ```bash |
| my-cli files organize ./data |
| my-cli files stats ./data |
| my-cli config show |
| my-cli config set theme dark |
| ``` |
|
|
| ## Input Validation |
|
|
| ### Type Validation |
|
|
| ```python |
| from typing import Optional |
| from enum import Enum |
|
|
| class OutputFormat(str, Enum): |
| JSON = "json" |
| YAML = "yaml" |
| TEXT = "text" |
|
|
| @app.command() |
| def export( |
| count: int = typer.Option(10, min=1, max=100), |
| format: OutputFormat = typer.Option(OutputFormat.JSON), |
| email: Optional[str] = typer.Option(None) |
| ): |
| """Export data in specified format.""" |
| if email and "@" not in email: |
| raise typer.BadParameter("Invalid email address") |
| |
| console.print(f"Exporting {count} items as {format.value}") |
| ``` |
|
|
| ### Custom Validation |
|
|
| ```python |
| def validate_path(path: Path) -> Path: |
| """Validate that path exists and is readable.""" |
| if not path.exists(): |
| raise typer.BadParameter(f"Path does not exist: {path}") |
| if not path.is_dir(): |
| raise typer.BadParameter(f"Path is not a directory: {path}") |
| return path |
|
|
| @app.command() |
| def process( |
| directory: Path = typer.Argument(..., callback=validate_path) |
| ): |
| """Process files in directory.""" |
| console.print(f"Processing {directory}") |
| ``` |
|
|
| ## Error Handling |
|
|
| ```python |
| from rich.console import Console |
|
|
| console = Console() |
|
|
| @app.command() |
| def risky_operation(filename: str): |
| """Perform a risky operation.""" |
| try: |
| with open(filename) as f: |
| data = f.read() |
| console.print("[green]Success![/green]") |
| except FileNotFoundError: |
| console.print(f"[red]Error: File not found: {filename}[/red]") |
| raise typer.Exit(code=1) |
| except PermissionError: |
| console.print(f"[red]Error: Permission denied: {filename}[/red]") |
| raise typer.Exit(code=1) |
| except Exception as e: |
| console.print(f"[red]Unexpected error: {e}[/red]") |
| raise typer.Exit(code=1) |
| ``` |
|
|
| ## Interactive Prompts |
|
|
| ```python |
| @app.command() |
| def delete(filename: str): |
| """Delete a file with confirmation.""" |
| if not typer.confirm(f"Are you sure you want to delete {filename}?"): |
| console.print("[yellow]Cancelled[/yellow]") |
| raise typer.Abort() |
| |
| # Proceed with deletion |
| console.print(f"[red]Deleted {filename}[/red]") |
| ``` |
|
|
| ## Complete Example: File Organizer CLI |
|
|
| ```python |
| import typer |
| from pathlib import Path |
| from rich.console import Console |
| from rich.table import Table |
| from rich.progress import track |
| from typing import Optional |
| from enum import Enum |
|
|
| app = typer.Typer( |
| name="file-organizer", |
| help="Organize files intelligently", |
| add_completion=True |
| ) |
| console = Console() |
|
|
| class Strategy(str, Enum): |
| EXTENSION = "extension" |
| DATE = "date" |
| SIZE = "size" |
|
|
| @app.command() |
| def organize( |
| directory: Path = typer.Argument( |
| ..., |
| help="Directory to organize", |
| exists=True, |
| file_okay=False, |
| dir_okay=True |
| ), |
| strategy: Strategy = typer.Option( |
| Strategy.EXTENSION, |
| "--strategy", |
| "-s", |
| help="Organization strategy" |
| ), |
| dry_run: bool = typer.Option( |
| False, |
| "--dry-run", |
| help="Preview without making changes" |
| ), |
| verbose: bool = typer.Option( |
| False, |
| "--verbose", |
| "-v", |
| help="Show detailed output" |
| ) |
| ): |
| """Organize files in DIRECTORY using STRATEGY.""" |
| |
| if dry_run: |
| console.print("[yellow]DRY RUN - No changes will be made[/yellow]\n") |
| |
| console.print(f"[bold cyan]Organizing {directory}[/bold cyan]") |
| console.print(f"Strategy: [green]{strategy.value}[/green]\n") |
| |
| # Scan files |
| files = list(directory.glob("*")) |
| files = [f for f in files if f.is_file()] |
| |
| if verbose: |
| console.print(f"Found {len(files)} files\n") |
| |
| # Process files |
| for file in track(files, description="Processing..."): |
| if verbose: |
| console.print(f" Processing: {file.name}") |
| |
| console.print("\n[bold green]✓ Complete![/bold green]") |
|
|
| @app.command() |
| def stats( |
| directory: Path = typer.Argument(..., exists=True, file_okay=False) |
| ): |
| """Show statistics about files in DIRECTORY.""" |
| |
| files = list(directory.glob("*")) |
| files = [f for f in files if f.is_file()] |
| |
| # Count by extension |
| extensions = {} |
| for file in files: |
| ext = file.suffix or "no extension" |
| extensions[ext] = extensions.get(ext, 0) + 1 |
| |
| # Display table |
| table = Table(title=f"File Statistics: {directory}") |
| table.add_column("Extension", style="cyan") |
| table.add_column("Count", justify="right", style="green") |
| |
| for ext, count in sorted(extensions.items(), key=lambda x: x[1], reverse=True): |
| table.add_row(ext, str(count)) |
| |
| console.print(table) |
| console.print(f"\n[bold]Total files: {len(files)}[/bold]") |
|
|
| if __name__ == "__main__": |
| app() |
| ``` |
|
|
| ## Testing Your CLI |
|
|
| ```python |
| # tests/test_cli.py |
| from typer.testing import CliRunner |
| from my_cli.cli import app |
|
|
| runner = CliRunner() |
|
|
| def test_organize_dry_run(): |
| result = runner.invoke(app, ["organize", ".", "--dry-run"]) |
| assert result.exit_code == 0 |
| assert "DRY RUN" in result.stdout |
|
|
| def test_stats(): |
| result = runner.invoke(app, ["stats", "."]) |
| assert result.exit_code == 0 |
| ``` |
|
|
| ## Using Copilot |
|
|
| Ask Copilot to help with: |
|
|
| - "Create a Typer command that accepts a file path and validates it exists" |
| - "Add a progress bar for processing multiple files" |
| - "Generate help text and docstrings for CLI commands" |
| - "Create an enum for output format options" |
|
|
| ## Next Steps |
|
|
| Now that you can build CLIs with Typer, the next section covers configuration management for your applications. |
|
|
| ## Resources |
|
|
| - [Typer Documentation](https: |
| - [Rich Documentation](https: |
| - [Typer Tutorial](https: |
|
|