CLI tools are the backbone of developer productivity. Whether you're automating deployments, processing data, or managing infrastructure, a well-built CLI saves hours of repetitive work. Python makes this surprisingly easy — and with the right patterns, your tool can feel as polished as any Go or Rust CLI.
What you'll build in this guide:
Start with Python's built-in argparse — no external dependencies needed:
#!/usr/bin/env python3
"""filemgr — A smart file management CLI tool."""
import argparse
import sys
from pathlib import Path
def create_parser():
parser = argparse.ArgumentParser(
prog="filemgr",
description="Smart file management from the command line",
epilog="Run '%(prog)s COMMAND --help' for command-specific help.",
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="Enable verbose output",
)
parser.add_argument(
"--version",
action="version",
version="%(prog)s 1.0.0",
)
return parser
def main():
parser = create_parser()
args = parser.parse_args()
if args.verbose:
print("Verbose mode enabled")
# No subcommand given
parser.print_help()
return 0
if __name__ == "__main__":
sys.exit(main())
Real CLI tools use subcommands (git commit, docker build). Here's how:
def create_parser():
parser = argparse.ArgumentParser(
prog="filemgr",
description="Smart file management CLI",
)
parser.add_argument("-v", "--verbose", action="store_true")
parser.add_argument("--version", action="version", version="%(prog)s 1.0.0")
subparsers = parser.add_subparsers(dest="command", help="Available commands")
# 'organize' subcommand
organize = subparsers.add_parser("organize", help="Organize files by type")
organize.add_argument("directory", type=Path, help="Directory to organize")
organize.add_argument(
"-d", "--dry-run",
action="store_true",
help="Show what would be done without doing it",
)
organize.add_argument(
"--pattern",
choices=["type", "date", "size"],
default="type",
help="Organization pattern (default: type)",
)
# 'find' subcommand
find = subparsers.add_parser("find", help="Find files matching criteria")
find.add_argument("pattern", help="Filename pattern (glob)")
find.add_argument(
"-d", "--directory",
type=Path,
default=Path("."),
help="Search directory (default: current)",
)
find.add_argument(
"--min-size",
type=str,
help="Minimum file size (e.g., 10MB, 1GB)",
)
find.add_argument(
"--max-age",
type=int,
metavar="DAYS",
help="Maximum file age in days",
)
# 'stats' subcommand
stats = subparsers.add_parser("stats", help="Show directory statistics")
stats.add_argument("directory", type=Path, nargs="?", default=Path("."))
stats.add_argument("--json", action="store_true", help="Output as JSON")
return parser
def main():
parser = create_parser()
args = parser.parse_args()
if not args.command:
parser.print_help()
return 0
commands = {
"organize": cmd_organize,
"find": cmd_find,
"stats": cmd_stats,
}
return commands[args.command](args)
Let users customize behavior without flags every time:
import tomllib # Python 3.11+ (use tomli for older versions)
from dataclasses import dataclass, field
@dataclass
class Config:
"""Application configuration with sensible defaults."""
verbose: bool = False
default_pattern: str = "type"
ignore_patterns: list[str] = field(
default_factory=lambda: [".git", "__pycache__", "node_modules"]
)
max_depth: int = 10
color: bool = True
@classmethod
def load(cls, path: Path | None = None) -> "Config":
"""Load config from TOML file, falling back to defaults."""
search_paths = [
path,
Path(".filemgr.toml"),
Path.home() / ".config" / "filemgr" / "config.toml",
]
for p in search_paths:
if p and p.exists():
with open(p, "rb") as f:
data = tomllib.load(f)
return cls(**{
k: v for k, v in data.items()
if k in cls.__dataclass_fields__
})
return cls() # All defaults
# Example .filemgr.toml:
# verbose = false
# default_pattern = "type"
# ignore_patterns = [".git", "__pycache__", "node_modules", ".venv"]
# max_depth = 5
# color = true
Make your CLI beautiful with the rich library:
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.text import Text
console = Console()
def cmd_stats(args):
"""Show directory statistics with rich formatting."""
directory = args.directory.resolve()
if not directory.exists():
console.print(f"[red]Error:[/red] Directory not found: {directory}")
return 1
# Collect stats
stats = collect_dir_stats(directory)
if args.json:
import json
print(json.dumps(stats, indent=2))
return 0
# Rich table output
table = Table(
title=f"📁 Directory Stats: {directory.name}",
show_header=True,
header_style="bold cyan",
)
table.add_column("File Type", style="bold")
table.add_column("Count", justify="right")
table.add_column("Total Size", justify="right", style="green")
table.add_column("Largest", justify="right")
for ext, info in sorted(stats["by_type"].items()):
table.add_row(
ext or "(no extension)",
str(info["count"]),
format_size(info["total_size"]),
format_size(info["largest"]),
)
console.print(table)
# Summary panel
summary = Text()
summary.append(f"Total files: ", style="bold")
summary.append(f"{stats['total_files']}\n")
summary.append(f"Total size: ", style="bold")
summary.append(f"{format_size(stats['total_size'])}\n", style="green")
summary.append(f"Directories: ", style="bold")
summary.append(f"{stats['total_dirs']}")
console.print(Panel(summary, title="Summary", border_style="blue"))
return 0
def collect_dir_stats(directory: Path) -> dict:
"""Walk directory and collect file statistics."""
stats = {
"total_files": 0,
"total_size": 0,
"total_dirs": 0,
"by_type": {},
}
for item in directory.rglob("*"):
if item.is_dir():
stats["total_dirs"] += 1
continue
stats["total_files"] += 1
size = item.stat().st_size
stats["total_size"] += size
ext = item.suffix.lower() or "(none)"
if ext not in stats["by_type"]:
stats["by_type"][ext] = {
"count": 0,
"total_size": 0,
"largest": 0,
}
type_stats = stats["by_type"][ext]
type_stats["count"] += 1
type_stats["total_size"] += size
type_stats["largest"] = max(type_stats["largest"], size)
return stats
def format_size(size: int) -> str:
"""Format bytes to human-readable size."""
for unit in ["B", "KB", "MB", "GB", "TB"]:
if size < 1024:
return f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} PB"
For long-running operations, show progress:
from rich.progress import (
Progress, SpinnerColumn, TextColumn,
BarColumn, TaskProgressColumn, TimeRemainingColumn,
)
def cmd_organize(args):
"""Organize files with progress tracking."""
directory = args.directory.resolve()
files = list(directory.iterdir())
if not files:
console.print("[yellow]No files to organize.[/yellow]")
return 0
# Progress bar for file operations
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TaskProgressColumn(),
TimeRemainingColumn(),
console=console,
) as progress:
task = progress.add_task("Organizing files...", total=len(files))
moved = 0
skipped = 0
for file_path in files:
if file_path.is_dir():
progress.advance(task)
skipped += 1
continue
dest_dir = get_destination(file_path, args.pattern)
if args.dry_run:
console.print(
f" [dim]Would move:[/dim] {file_path.name} → {dest_dir}/",
highlight=False,
)
else:
dest_dir.mkdir(exist_ok=True)
file_path.rename(dest_dir / file_path.name)
moved += 1
progress.advance(task)
# Summary
action = "Would move" if args.dry_run else "Moved"
console.print(
f"\n[green]✓[/green] {action} {moved} files, skipped {skipped}"
)
return 0
def get_destination(file_path: Path, pattern: str) -> Path:
"""Determine destination directory based on pattern."""
parent = file_path.parent
if pattern == "type":
ext = file_path.suffix.lower().lstrip(".")
categories = {
"images": {"jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"},
"documents": {"pdf", "doc", "docx", "txt", "md", "rtf", "odt"},
"code": {"py", "js", "ts", "go", "rs", "java", "c", "cpp", "h"},
"data": {"csv", "json", "xml", "yaml", "yml", "toml", "sql"},
"archives": {"zip", "tar", "gz", "bz2", "7z", "rar"},
"media": {"mp4", "mp3", "wav", "avi", "mkv", "flac", "ogg"},
}
for category, extensions in categories.items():
if ext in extensions:
return parent / category
return parent / "other"
elif pattern == "date":
from datetime import datetime
mtime = file_path.stat().st_mtime
date = datetime.fromtimestamp(mtime)
return parent / date.strftime("%Y-%m")
elif pattern == "size":
size = file_path.stat().st_size
if size < 1024 * 100: # < 100KB
return parent / "small"
elif size < 1024 * 1024 * 10: # < 10MB
return parent / "medium"
else:
return parent / "large"
Sometimes you need user confirmation or input:
from rich.prompt import Prompt, Confirm
def cmd_cleanup(args):
"""Remove empty directories and duplicates."""
directory = args.directory.resolve()
# Find empty directories
empty_dirs = [
d for d in directory.rglob("*")
if d.is_dir() and not any(d.iterdir())
]
if not empty_dirs:
console.print("[green]No empty directories found.[/green]")
return 0
console.print(f"\nFound [bold]{len(empty_dirs)}[/bold] empty directories:")
for d in empty_dirs[:10]:
console.print(f" [dim]{d.relative_to(directory)}[/dim]")
if len(empty_dirs) > 10:
console.print(f" ... and {len(empty_dirs) - 10} more")
if not args.force:
if not Confirm.ask("\nDelete these directories?", default=False):
console.print("[yellow]Cancelled.[/yellow]")
return 0
for d in empty_dirs:
d.rmdir()
console.print(f"[green]✓ Removed {len(empty_dirs)} empty directories[/green]")
return 0
A professional CLI handles errors gracefully:
import traceback
class CLIError(Exception):
"""Base exception for CLI errors."""
def __init__(self, message: str, exit_code: int = 1):
self.message = message
self.exit_code = exit_code
super().__init__(message)
def main():
parser = create_parser()
args = parser.parse_args()
try:
if not args.command:
parser.print_help()
return 0
config = Config.load()
# Merge CLI flags with config
if args.verbose:
config.verbose = True
commands = {
"organize": cmd_organize,
"find": cmd_find,
"stats": cmd_stats,
}
return commands[args.command](args)
except CLIError as e:
console.print(f"[red]Error:[/red] {e.message}")
return e.exit_code
except KeyboardInterrupt:
console.print("\n[yellow]Interrupted.[/yellow]")
return 130 # Standard exit code for SIGINT
except PermissionError as e:
console.print(f"[red]Permission denied:[/red] {e.filename}")
return 1
except Exception as e:
if args.verbose:
console.print_exception()
else:
console.print(f"[red]Unexpected error:[/red] {e}")
console.print("[dim]Run with --verbose for full traceback[/dim]")
return 1
Test CLI tools by capturing stdout and checking exit codes:
# test_cli.py
import subprocess
import json
from pathlib import Path
import pytest
def run_cli(*args) -> subprocess.CompletedProcess:
"""Helper to run the CLI and capture output."""
return subprocess.run(
["python", "-m", "filemgr", *args],
capture_output=True,
text=True,
)
class TestStats:
def test_stats_current_dir(self):
result = run_cli("stats", ".")
assert result.returncode == 0
assert "Total files" in result.stdout or "total_files" in result.stdout
def test_stats_json(self):
result = run_cli("stats", ".", "--json")
assert result.returncode == 0
data = json.loads(result.stdout)
assert "total_files" in data
assert "total_size" in data
def test_stats_nonexistent(self):
result = run_cli("stats", "/nonexistent/path")
assert result.returncode == 1
class TestOrganize:
def test_dry_run(self, tmp_path):
# Create test files
(tmp_path / "photo.jpg").touch()
(tmp_path / "doc.pdf").touch()
(tmp_path / "script.py").touch()
result = run_cli("organize", str(tmp_path), "--dry-run")
assert result.returncode == 0
assert "Would move" in result.stdout
# Files should NOT have moved
assert (tmp_path / "photo.jpg").exists()
def test_organize_by_type(self, tmp_path):
(tmp_path / "photo.jpg").touch()
(tmp_path / "doc.pdf").touch()
result = run_cli("organize", str(tmp_path))
assert result.returncode == 0
assert (tmp_path / "images" / "photo.jpg").exists()
assert (tmp_path / "documents" / "doc.pdf").exists()
class TestVersion:
def test_version(self):
result = run_cli("--version")
assert "1.0.0" in result.stdout
# Run with: pytest test_cli.py -v
Make your tool installable via pip install:
# pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "filemgr"
version = "1.0.0"
description = "Smart file management from the command line"
readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
authors = [
{ name = "Your Name", email = "you@example.com" },
]
dependencies = [
"rich>=13.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0",
"ruff>=0.1",
]
[project.scripts]
filemgr = "filemgr.cli:main"
[tool.ruff]
line-length = 88
target-version = "py311"
Project structure:
filemgr/
├── pyproject.toml
├── README.md
├── src/
│ └── filemgr/
│ ├── __init__.py
│ ├── cli.py # Parser + main()
│ ├── commands/
│ │ ├── __init__.py
│ │ ├── organize.py
│ │ ├── find.py
│ │ └── stats.py
│ ├── config.py
│ └── utils.py
└── tests/
├── conftest.py
└── test_cli.py
Install and use:
# Install in development mode
pip install -e ".[dev]"
# Now you can run it directly
filemgr stats .
filemgr organize ~/Downloads --dry-run
filemgr find "*.py" --min-size 10KB
# Build for distribution
python -m build
pip install dist/filemgr-1.0.0-py3-none-any.whl
Here's everything wired together in a single file you can start with:
#!/usr/bin/env python3
"""filemgr — Smart file management CLI.
Usage:
filemgr organize <directory> [--dry-run] [--pattern type|date|size]
filemgr find <pattern> [-d directory] [--min-size SIZE] [--max-age DAYS]
filemgr stats [directory] [--json]
filemgr --version
"""
import argparse
import json
import sys
from datetime import datetime
from pathlib import Path
try:
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn
RICH = True
except ImportError:
RICH = False
__version__ = "1.0.0"
console = Console() if RICH else None
def print_msg(msg: str, style: str = ""):
"""Print with rich if available, plain otherwise."""
if RICH:
console.print(f"[{style}]{msg}[/{style}]" if style else msg)
else:
print(msg)
def format_size(size: int) -> str:
for unit in ["B", "KB", "MB", "GB", "TB"]:
if size < 1024:
return f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} PB"
def parse_size(size_str: str) -> int:
"""Parse human-readable size like '10MB' to bytes."""
units = {"B": 1, "KB": 1024, "MB": 1024**2, "GB": 1024**3}
size_str = size_str.strip().upper()
for unit, multiplier in sorted(units.items(), key=lambda x: -len(x[0])):
if size_str.endswith(unit):
return int(float(size_str[:-len(unit)]) * multiplier)
return int(size_str)
def cmd_stats(args) -> int:
directory = Path(args.directory).resolve()
if not directory.exists():
print_msg(f"Error: {directory} not found", "red")
return 1
by_type = {}
total_files = total_size = total_dirs = 0
for item in directory.rglob("*"):
if item.is_dir():
total_dirs += 1
continue
total_files += 1
size = item.stat().st_size
total_size += size
ext = item.suffix.lower() or "(none)"
entry = by_type.setdefault(ext, {"count": 0, "size": 0, "largest": 0})
entry["count"] += 1
entry["size"] += size
entry["largest"] = max(entry["largest"], size)
if getattr(args, "json", False):
print(json.dumps({
"total_files": total_files,
"total_size": total_size,
"total_dirs": total_dirs,
"by_type": by_type,
}, indent=2))
elif RICH:
table = Table(title=f"📁 {directory.name}")
table.add_column("Type", style="bold")
table.add_column("Count", justify="right")
table.add_column("Size", justify="right", style="green")
for ext, info in sorted(by_type.items(), key=lambda x: -x[1]["size"]):
table.add_row(ext, str(info["count"]), format_size(info["size"]))
console.print(table)
console.print(f"\n[bold]{total_files}[/bold] files, "
f"[green]{format_size(total_size)}[/green], "
f"{total_dirs} dirs")
else:
print(f"Files: {total_files}, Size: {format_size(total_size)}, "
f"Dirs: {total_dirs}")
return 0
def cmd_find(args) -> int:
directory = Path(args.directory).resolve()
min_size = parse_size(args.min_size) if args.min_size else 0
max_age_ts = (
datetime.now().timestamp() - (args.max_age * 86400)
if args.max_age else 0
)
found = 0
for f in directory.rglob(args.pattern):
if f.is_dir():
continue
stat = f.stat()
if stat.st_size < min_size:
continue
if max_age_ts and stat.st_mtime < max_age_ts:
continue
rel = f.relative_to(directory)
print_msg(f" {rel} ({format_size(stat.st_size)})")
found += 1
print_msg(f"\n{found} file(s) found", "bold")
return 0
def main() -> int:
parser = argparse.ArgumentParser(prog="filemgr")
parser.add_argument("-v", "--verbose", action="store_true")
parser.add_argument("--version", action="version", version=__version__)
sub = parser.add_subparsers(dest="command")
org = sub.add_parser("organize")
org.add_argument("directory", type=Path)
org.add_argument("-d", "--dry-run", action="store_true")
org.add_argument("--pattern", choices=["type", "date", "size"], default="type")
fnd = sub.add_parser("find")
fnd.add_argument("pattern")
fnd.add_argument("-d", "--directory", default=".")
fnd.add_argument("--min-size", type=str)
fnd.add_argument("--max-age", type=int)
sts = sub.add_parser("stats")
sts.add_argument("directory", nargs="?", default=".")
sts.add_argument("--json", action="store_true")
args = parser.parse_args()
try:
if not args.command:
parser.print_help()
return 0
return {"stats": cmd_stats, "find": cmd_find}[args.command](args)
except KeyboardInterrupt:
print("\nInterrupted.")
return 130
except Exception as e:
print_msg(f"Error: {e}", "red")
if args.verbose:
import traceback; traceback.print_exc()
return 1
if __name__ == "__main__":
sys.exit(main())
CLI tools, scrapers, data processors, automation workflows, AI integrations — all tested and ready to use.
Get the Full Toolkit — $19