KCH / src /chapters /ch02-building-with-typer.qmd
bsamadi's picture
Update to pixi env
c032460
# 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://typer.tiangolo.com/)
- [Rich Documentation](https://rich.readthedocs.io/)
- [Typer Tutorial](https://typer.tiangolo.com/tutorial/)