You've built a Python script. It works on your machine. You share it with your team, and suddenly you're drowning in bug reports: "It crashed with no error message", "How do I enable debug mode?", "Is there a newer version?"
Sound familiar? π―
I've been there. After building dozens of CLI tools for automation and DevOps workflows, I learned the hard way: the difference between a script and a professional tool isn't the core logicβit's everything around it.
In this guide, I'll show you how to transform a basic Python script into a production-ready CLI tool with:
-
Structured logging (not just
print()statements) - Graceful error handling (no more ugly stack traces for users)
- Automatic version checking and updates (users always have the latest)
- Progress indicators (because users hate silent commands)
- Configuration management (sensible defaults + easy overrides)
By the end, you'll have a professional CLI tool that your users actually enjoy using. Let's build something maintainable.
1. Project Structure That Scales
First, let's set up a proper project structure. This isn't overkillβit's how you avoid technical debt.
my-cli-tool/
βββ my_cli/
β βββ __init__.py
β βββ __version__.py # Version info
β βββ cli.py # CLI entry point
β βββ core.py # Core logic
β βββ logging_config.py # Logging setup
β βββ exceptions.py # Custom exceptions
β βββ updater.py # Auto-update logic
βββ tests/
β βββ __init__.py
β βββ test_core.py
βββ setup.py # Installation config
βββ requirements.txt
βββ README.md
Why this structure?
- β Separation of concerns: CLI interface (cli.py) is separate from business logic (core.py)
- β Testability: Core logic can be tested without invoking the CLI
- β Maintainability: Each module has a single responsibility
- β Scalability: Easy to add new commands or features
Quick Win: Complete CLI Patterns in 30 Minutes
Instead of implementing all 5 patterns from scratch, I've built a production-ready Python CLI template with:
- All patterns fully implemented
- Pytest + GitHub Actions CI/CD
- Docker configuration included
- Complete deployment guide
- 30-day email support
Get the Python CLI Starter Kit on Gumroad ($14.99) β saves 10+ hours of boilerplate coding.
2. Smart Logging (Not Print Debugging)
Stop using print() for debugging. Here's a production-grade logging setup:
my_cli/logging_config.py
import logging
import sys
from pathlib import Path
def setup_logging(verbose: bool = False, log_file: str = None):
"""
Configure logging with console + optional file output.
Args:
verbose: Enable DEBUG level logging
log_file: Optional file path for logs
"""
# Create logger
logger = logging.getLogger("my_cli")
logger.setLevel(logging.DEBUG if verbose else logging.INFO)
# Remove existing handlers (prevents duplicate logs)
logger.handlers.clear()
# Console handler with colored output
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(logging.DEBUG if verbose else logging.INFO)
# Different formats for verbose vs normal
if verbose:
console_format = logging.Formatter(
'%(asctime)s [%(levelname)s] %(name)s.%(funcName)s:%(lineno)d - %(message)s',
datefmt='%H:%M:%S'
)
else:
console_format = logging.Formatter(
'%(levelname)s: %(message)s'
)
console_handler.setFormatter(console_format)
logger.addHandler(console_handler)
# Optional file handler for persistent logs
if log_file:
file_handler = logging.FileHandler(log_file)
file_handler.setLevel(logging.DEBUG)
file_format = logging.Formatter(
'%(asctime)s [%(levelname)s] %(name)s.%(funcName)s:%(lineno)d - %(message)s'
)
file_handler.setFormatter(file_format)
logger.addHandler(file_handler)
return logger
Usage in your code:
from my_cli.logging_config import setup_logging
logger = setup_logging(verbose=True)
logger.debug("This only shows in verbose mode")
logger.info("Processing file: example.txt")
logger.warning("Rate limit approaching")
logger.error("Failed to connect to API", exc_info=True)
Why this approach wins:
- β Structured output (timestamp, level, module, line number)
- β
Verbosity control (
--verboseflag) - β File logging for debugging production issues
- β
No more scattered
print()statements - β Easy to add colors and custom formatting
3. Graceful Error Handling
Users should never see raw Python stack traces. Here's how:
my_cli/exceptions.py
class CLIError(Exception):
"""Base exception for all CLI errors."""
def __init__(self, message: str, exit_code: int = 1):
self.message = message
self.exit_code = exit_code
super().__init__(self.message)
class ConfigurationError(CLIError):
"""Raised when configuration is invalid."""
pass
class NetworkError(CLIError):
"""Raised when network operations fail."""
pass
class ValidationError(CLIError):
"""Raised when input validation fails."""
pass
my_cli/cli.py (main entry point)
import sys
import click
from my_cli.logging_config import setup_logging
from my_cli.exceptions import CLIError
from my_cli.core import run_main_logic
@click.command()
@click.option('--verbose', '-v', is_flag=True, help='Enable verbose logging')
@click.option('--config', '-c', type=click.Path(), help='Path to config file')
def main(verbose: bool, config: str):
"""
My CLI Tool - A production-ready Python CLI application.
"""
logger = setup_logging(verbose=verbose)
try:
# Your main logic here
result = run_main_logic(config=config)
logger.info(f"β Operation completed successfully")
sys.exit(0)
except CLIError as e:
# Expected errors: show user-friendly message
logger.error(f"β {e.message}")
sys.exit(e.exit_code)
except KeyboardInterrupt:
# User cancelled: clean exit
logger.info("\nOperation cancelled by user")
sys.exit(130)
except Exception as e:
# Unexpected errors: log full trace in verbose mode
if verbose:
logger.exception("Unexpected error occurred")
else:
logger.error(f"Unexpected error: {e}")
logger.info("Run with --verbose for detailed error information")
sys.exit(1)
if __name__ == '__main__':
main()
This achieves:
- β Custom exceptions for different error types
- β User-friendly error messages (no scary stack traces)
- β Proper exit codes (important for CI/CD pipelines)
- β Verbose mode for power users and debugging
4. Automatic Version Updates
Nothing frustrates users more than outdated tools. Here's automatic update checking:
my_cli/__version__.py
__version__ = "1.0.3"
my_cli/updater.py
import requests
import logging
from packaging import version
from my_cli.__version__ import __version__
logger = logging.getLogger("my_cli.updater")
def check_for_updates(package_name: str = "my-cli-tool", timeout: int = 3) -> dict:
"""
Check PyPI for newer versions.
Returns:
dict: {'update_available': bool, 'latest_version': str, 'current_version': str}
"""
try:
response = requests.get(
f"https://pypi.org/pypi/{package_name}/json",
timeout=timeout
)
response.raise_for_status()
latest_version = response.json()["info"]["version"]
current_version = __version__
update_available = version.parse(latest_version) > version.parse(current_version)
return {
"update_available": update_available,
"latest_version": latest_version,
"current_version": current_version
}
except Exception as e:
logger.debug(f"Could not check for updates: {e}")
return {
"update_available": False,
"latest_version": None,
"current_version": __version__
}
def notify_if_outdated():
"""Show a non-intrusive update message if newer version exists."""
result = check_for_updates()
if result["update_available"]:
print(f"\nπ‘ Update available: {result['current_version']} β {result['latest_version']}")
print(f" Run: pip install --upgrade my-cli-tool\n")
Add to your CLI entry point:
from my_cli.updater import notify_if_outdated
@click.command()
def main():
# Check for updates (non-blocking)
notify_if_outdated()
# Rest of your CLI logic...
Why this works:
- β Non-blocking (doesn't slow down the tool)
- β Respects user's time (3-second timeout)
- β Encourages users to stay updated
- β Simple one-liner to integrate
5. Progress Indicators for Long Operations
Users hate silent commands. Show them what's happening:
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
def process_files(file_list: list):
"""Process multiple files with progress indication."""
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
) as progress:
task = progress.add_task("[cyan]Processing files...", total=len(file_list))
for file_path in file_list:
# Your processing logic
process_single_file(file_path)
progress.update(task, advance=1, description=f"[cyan]Processing {file_path.name}")
Install rich: pip install rich
Why it matters:
- β Users know the tool is working (not frozen)
- β Provides time estimates
- β Professional appearance
- β Increases confidence in your tool
Key Takeaways
Building production-ready CLI tools isn't about fancy featuresβit's about respecting your users' time and making debugging effortless.
The 5 critical patterns:
β
Structured logging (not print debugging)
β
Graceful error handling (no ugly stack traces)
β
Automatic updates (users stay current)
β
Progress indicators (show what's happening)
β
Smart configuration (defaults + easy overrides)
Use these patterns together, and you'll build tools your team actually wants to use.
π Free: Python Debugging Cheat Sheet
Before you add another print statement β save yourself the pain.
I timed 3 debugging methods on the same bug:
- β± print() marathon: 47 minutes
- β± pdb.set_trace(): 12 minutes
- β± icecream (Technique #4): 3 minutes
I packed the 7 techniques that actually work into a cheat sheet: breakpoint(), logging, traceback, icecream, @dataclass, hunter, and faulthandler. Based on real production debugging sessions.
π Download the Python Debugging Cheat Sheet (Free) β PDF, copy-paste ready, just enter your email.
Ready to Level Up Your CLI Tools? π
This guide covers the essentials, but production tools need more advanced patterns.
π Get the Complete CLI Starter Kit
I've built a battle-tested Python CLI template with everything in this guide plus advanced patterns. Includes:
β
Full working example with all patterns
β
Automated testing setup (pytest + CI/CD)
β
Docker integration
β
Advanced error handling
β
Performance profiling
β
Deployment guides
Save yourself 10+ hours of boilerplate. Get the Python CLI Starter Kit on Gumroad β comes with complete source code, examples, and documentation.
Questions or improvements? Drop a comment below. I read every single one.
Happy coding! π
π Free Download: Top 10 Python One-Liners Cheat Sheet
Want to write cleaner, more Pythonic code? Grab my free Python One-Liners Cheat Sheet β 10 battle-tested one-liners that I use every day in production.
β
Flatten nested lists
β
Safe dictionary access
β
Efficient deduplication
β
Performance benchmarks included
Download now (free, no credit card) β Just enter your email and it's yours.
π Related Articles
If you found this useful, check out these related posts:
- I Tested 5 AI Code Review Tools β Here's What Works (With Data) β Benchmark of production-ready code review tools
- How to Set Up a Self-Improving AI Content Pipeline (With Code) β Build automation systems that learn from data
Top comments (1)
π»