BLOG POSTS
How to Create a CLI with Python Fire on Ubuntu 24

How to Create a CLI with Python Fire on Ubuntu 24

Python Fire is a library that automatically generates command-line interfaces from Python objects, making it incredibly easy to turn any Python function or class into a CLI tool. For developers working on Ubuntu 24, this approach eliminates the boilerplate code typically required with argparse or click while providing powerful introspection and debugging capabilities. In this guide, you’ll learn how to install Python Fire, create various types of CLI applications, handle edge cases, and implement best practices for production-ready command-line tools.

Understanding Python Fire’s Magic

Python Fire works by using introspection to analyze your Python objects at runtime and automatically generating a command-line interface. Unlike traditional CLI frameworks that require explicit argument definitions, Fire reads your function signatures, docstrings, and type hints to create an intuitive command structure.

import fire

def greet(name, greeting="Hello"):
    """Greet someone with a custom message."""
    return f"{greeting}, {name}!"

if __name__ == '__main__':
    fire.Fire(greet)

When you run this script, Fire automatically creates flags for each parameter, handles type conversion, and provides help documentation based on your docstring. The magic happens through Python’s inspect module and dynamic argument parsing.

Setting Up Python Fire on Ubuntu 24

First, ensure you have Python 3.12 (the default on Ubuntu 24) and pip installed:

sudo apt update
sudo apt install python3 python3-pip python3-venv
python3 --version

Create a virtual environment to avoid dependency conflicts:

mkdir ~/cli-projects
cd ~/cli-projects
python3 -m venv fire-env
source fire-env/bin/activate

Install Python Fire and verify the installation:

pip install fire
python -c "import fire; print(fire.__version__)"

For development work, also install helpful debugging tools:

pip install ipython rich

Creating Your First CLI Application

Let’s build a file management CLI that demonstrates Fire’s core capabilities:

#!/usr/bin/env python3
"""
File Manager CLI - A simple file management tool using Python Fire
"""
import os
import shutil
import fire
from pathlib import Path

class FileManager:
    """A simple file management CLI tool."""
    
    def list_files(self, directory=".", pattern="*", show_hidden=False):
        """List files in a directory with optional pattern matching.
        
        Args:
            directory: Directory to list (default: current directory)
            pattern: File pattern to match (default: all files)
            show_hidden: Include hidden files (default: False)
        """
        path = Path(directory)
        if not path.exists():
            return f"Error: Directory {directory} does not exist"
        
        files = []
        for item in path.glob(pattern):
            if not show_hidden and item.name.startswith('.'):
                continue
            files.append({
                'name': item.name,
                'size': item.stat().st_size if item.is_file() else 'DIR',
                'type': 'file' if item.is_file() else 'directory'
            })
        
        return files
    
    def copy_file(self, source, destination, overwrite=False):
        """Copy a file to a new location.
        
        Args:
            source: Source file path
            destination: Destination file path
            overwrite: Overwrite if destination exists (default: False)
        """
        if not Path(source).exists():
            return f"Error: Source file {source} does not exist"
        
        if Path(destination).exists() and not overwrite:
            return f"Error: Destination {destination} exists. Use --overwrite to replace"
        
        try:
            shutil.copy2(source, destination)
            return f"Successfully copied {source} to {destination}"
        except Exception as e:
            return f"Error copying file: {str(e)}"
    
    def create_backup(self, file_path, backup_dir="./backups"):
        """Create a backup of a file with timestamp.
        
        Args:
            file_path: Path to file to backup
            backup_dir: Directory to store backups (default: ./backups)
        """
        import datetime
        
        source = Path(file_path)
        if not source.exists():
            return f"Error: File {file_path} does not exist"
        
        backup_path = Path(backup_dir)
        backup_path.mkdir(exist_ok=True)
        
        timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
        backup_name = f"{source.stem}_{timestamp}{source.suffix}"
        destination = backup_path / backup_name
        
        try:
            shutil.copy2(source, destination)
            return f"Backup created: {destination}"
        except Exception as e:
            return f"Error creating backup: {str(e)}"

if __name__ == '__main__':
    fire.Fire(FileManager)

Save this as filemanager.py and make it executable:

chmod +x filemanager.py

Now you can use your CLI in multiple ways:

# List files in current directory
./filemanager.py list_files

# List Python files in a specific directory
./filemanager.py list_files /home/user/projects "*.py" --show_hidden

# Copy a file
./filemanager.py copy_file README.md README_backup.md

# Create a timestamped backup
./filemanager.py create_backup important_file.txt

Advanced CLI Patterns and Real-World Examples

Here’s a more sophisticated example that demonstrates nested commands, configuration handling, and error management:

#!/usr/bin/env python3
"""
System Monitor CLI - Advanced Python Fire example
"""
import fire
import json
import subprocess
import psutil
from pathlib import Path
from typing import Dict, List, Optional

class SystemMonitor:
    """Advanced system monitoring CLI with nested commands."""
    
    def __init__(self, config_file: Optional[str] = None):
        self.config = self._load_config(config_file)
    
    def _load_config(self, config_file: Optional[str]) -> Dict:
        """Load configuration from file or use defaults."""
        default_config = {
            "alert_thresholds": {
                "cpu_percent": 80,
                "memory_percent": 85,
                "disk_percent": 90
            },
            "monitoring_interval": 5
        }
        
        if config_file and Path(config_file).exists():
            try:
                with open(config_file, 'r') as f:
                    user_config = json.load(f)
                    default_config.update(user_config)
            except Exception as e:
                print(f"Warning: Could not load config {config_file}: {e}")
        
        return default_config
    
    def cpu(self, interval: int = 1, per_cpu: bool = False) -> Dict:
        """Get CPU usage statistics.
        
        Args:
            interval: Sampling interval in seconds
            per_cpu: Show per-CPU statistics
        """
        if per_cpu:
            cpu_percent = psutil.cpu_percent(interval=interval, percpu=True)
            return {
                "total_cpus": len(cpu_percent),
                "per_cpu_usage": [f"CPU{i}: {usage:.1f}%" 
                                 for i, usage in enumerate(cpu_percent)],
                "average": sum(cpu_percent) / len(cpu_percent)
            }
        else:
            return {
                "cpu_percent": psutil.cpu_percent(interval=interval),
                "cpu_count": psutil.cpu_count(),
                "cpu_freq": psutil.cpu_freq()._asdict() if psutil.cpu_freq() else None
            }
    
    def memory(self, format_bytes: bool = True) -> Dict:
        """Get memory usage statistics.
        
        Args:
            format_bytes: Format bytes in human-readable units
        """
        mem = psutil.virtual_memory()
        swap = psutil.swap_memory()
        
        def format_size(bytes_val):
            if not format_bytes:
                return bytes_val
            for unit in ['B', 'KB', 'MB', 'GB']:
                if bytes_val < 1024.0:
                    return f"{bytes_val:.1f}{unit}"
                bytes_val /= 1024.0
            return f"{bytes_val:.1f}TB"
        
        return {
            "memory": {
                "total": format_size(mem.total),
                "available": format_size(mem.available),
                "used": format_size(mem.used),
                "percent": mem.percent
            },
            "swap": {
                "total": format_size(swap.total),
                "used": format_size(swap.used),
                "percent": swap.percent
            }
        }
    
    def disk(self, path: str = "/") -> Dict:
        """Get disk usage for specified path.
        
        Args:
            path: Path to check disk usage for
        """
        try:
            usage = psutil.disk_usage(path)
            return {
                "path": path,
                "total": f"{usage.total / (1024**3):.1f}GB",
                "used": f"{usage.used / (1024**3):.1f}GB",
                "free": f"{usage.free / (1024**3):.1f}GB",
                "percent": (usage.used / usage.total) * 100
            }
        except Exception as e:
            return {"error": f"Could not get disk usage for {path}: {e}"}
    
    def processes(self, top: int = 10, sort_by: str = "cpu") -> List[Dict]:
        """Get top processes by resource usage.
        
        Args:
            top: Number of top processes to show
            sort_by: Sort by 'cpu', 'memory', or 'pid'
        """
        processes = []
        for proc in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent']):
            try:
                processes.append(proc.info)
            except (psutil.NoSuchProcess, psutil.AccessDenied):
                pass
        
        # Sort processes
        reverse = True
        if sort_by == "pid":
            reverse = False
        
        key_map = {
            "cpu": "cpu_percent",
            "memory": "memory_percent", 
            "pid": "pid"
        }
        
        if sort_by in key_map:
            processes.sort(key=lambda x: x[key_map[sort_by]] or 0, reverse=reverse)
        
        return processes[:top]
    
    def alert(self) -> Dict:
        """Check system metrics against configured thresholds."""
        alerts = []
        thresholds = self.config["alert_thresholds"]
        
        # Check CPU
        cpu_percent = psutil.cpu_percent(interval=1)
        if cpu_percent > thresholds["cpu_percent"]:
            alerts.append(f"HIGH CPU: {cpu_percent:.1f}% (threshold: {thresholds['cpu_percent']}%)")
        
        # Check Memory
        memory_percent = psutil.virtual_memory().percent
        if memory_percent > thresholds["memory_percent"]:
            alerts.append(f"HIGH MEMORY: {memory_percent:.1f}% (threshold: {thresholds['memory_percent']}%)")
        
        # Check Disk
        disk_percent = (psutil.disk_usage("/").used / psutil.disk_usage("/").total) * 100
        if disk_percent > thresholds["disk_percent"]:
            alerts.append(f"HIGH DISK: {disk_percent:.1f}% (threshold: {thresholds['disk_percent']}%)")
        
        return {
            "timestamp": str(psutil.boot_time()),
            "alerts": alerts,
            "status": "OK" if not alerts else "ALERT"
        }

def create_config_template(output_file: str = "sysmon_config.json"):
    """Create a configuration template file."""
    template = {
        "alert_thresholds": {
            "cpu_percent": 80,
            "memory_percent": 85,
            "disk_percent": 90
        },
        "monitoring_interval": 5
    }
    
    with open(output_file, 'w') as f:
        json.dump(template, f, indent=2)
    
    return f"Configuration template created: {output_file}"

if __name__ == '__main__':
    fire.Fire({
        'monitor': SystemMonitor,
        'create-config': create_config_template
    })

Usage examples for the advanced CLI:

# Check CPU usage with per-core breakdown
./sysmonitor.py monitor cpu --per_cpu

# Get memory info with raw bytes
./sysmonitor.py monitor memory --format_bytes=False

# Show top 5 processes by memory usage
./sysmonitor.py monitor processes --top=5 --sort_by=memory

# Check for system alerts
./sysmonitor.py monitor alert

# Create configuration template
./sysmonitor.py create-config custom_config.json

# Use custom configuration
./sysmonitor.py monitor --config_file=custom_config.json alert

Comparison with Alternative CLI Frameworks

Feature Python Fire Click Argparse Typer
Setup Complexity Minimal – One line Medium – Decorators High – Manual setup Low – Type hints
Type Conversion Automatic Manual specification Manual specification Automatic from hints
Nested Commands Natural (classes/modules) Groups and commands Subparsers Classes and functions
Help Generation From docstrings From decorators Manual help text From docstrings + hints
Learning Curve Very Low Medium High Low
Flexibility Medium High Very High High

Best Practices and Common Pitfalls

Error Handling and Validation

Always validate inputs and provide meaningful error messages:

def process_file(filename: str, output_format: str = "json"):
    """Process a file with proper error handling."""
    # Validate file exists
    if not Path(filename).exists():
        raise FileNotFoundError(f"File not found: {filename}")
    
    # Validate format parameter
    valid_formats = ["json", "yaml", "xml"]
    if output_format not in valid_formats:
        raise ValueError(f"Invalid format '{output_format}'. Must be one of: {valid_formats}")
    
    try:
        # Process file
        return f"Processed {filename} as {output_format}"
    except Exception as e:
        raise RuntimeError(f"Processing failed: {str(e)}")

Documentation Best Practices

Use comprehensive docstrings with type information:

def backup_database(
    database_name: str,
    backup_path: str = "./backups",
    compress: bool = True,
    exclude_tables: List[str] = None
) -> Dict[str, str]:
    """Create a database backup with optional compression.
    
    This function creates a backup of the specified database,
    optionally compressing the output and excluding specific tables.
    
    Args:
        database_name: Name of the database to backup
        backup_path: Directory to store backup files (default: ./backups)
        compress: Whether to compress the backup file (default: True)
        exclude_tables: List of table names to exclude from backup
    
    Returns:
        Dict containing backup status and file path
        
    Example:
        backup_database("mydb", compress=False, exclude_tables=["logs", "temp"])
    """
    if exclude_tables is None:
        exclude_tables = []
    
    # Implementation here
    return {
        "status": "success",
        "backup_file": f"{backup_path}/{database_name}_backup.sql"
    }

Common Pitfalls to Avoid

  • Mutable Default Arguments: Always use None for mutable defaults and initialize inside the function
  • Complex Return Types: Fire works best with simple return types. Use dictionaries for complex data
  • Missing Type Hints: While not required, type hints improve CLI usability and help Fire with type conversion
  • Poor Error Messages: Fire passes through Python exceptions, so make them user-friendly
  • Overly Complex Classes: Keep CLI classes focused. Use composition for complex functionality

Integration with System Tools and Deployment

Create a proper package structure for your CLI tool:

mkdir -p mycli/{mycli,tests}
cd mycli

# Create setup.py
cat > setup.py << 'EOF'
from setuptools import setup, find_packages

setup(
    name="mycli",
    version="1.0.0",
    packages=find_packages(),
    install_requires=[
        "fire>=0.4.0",
        "psutil>=5.8.0",
    ],
    entry_points={
        'console_scripts': [
            'mycli=mycli.main:main',
        ],
    },
    python_requires=">=3.8",
)
EOF

# Create main module
mkdir -p mycli
cat > mycli/main.py << 'EOF'
import fire
from .commands import SystemCommands

def main():
    fire.Fire(SystemCommands)

if __name__ == '__main__':
    main()
EOF

# Install in development mode
pip install -e .

For system-wide installation on Ubuntu 24:

# Build wheel
python setup.py bdist_wheel

# Install system-wide
sudo pip install dist/mycli-1.0.0-py3-none-any.whl

# Or create a .deb package
sudo apt install python3-stdeb dh-python
python setup.py --command-packages=stdeb.command bdist_deb
sudo dpkg -i deb_dist/python3-mycli_1.0.0-1_all.deb

Performance Considerations and Optimization

Fire adds minimal overhead, but you can optimize for better performance:

# Lazy loading for expensive imports
class OptimizedCLI:
    def __init__(self):
        self._heavy_module = None
    
    @property
    def heavy_module(self):
        if self._heavy_module is None:
            import heavy_computation_library
            self._heavy_module = heavy_computation_library
        return self._heavy_module
    
    def process_data(self, data_file: str):
        """Process data using heavy computation library."""
        return self.heavy_module.process(data_file)

# Use generators for large datasets
def process_large_file(filename: str, batch_size: int = 1000):
    """Process large files in batches."""
    def batch_generator():
        with open(filename) as f:
            batch = []
            for line in f:
                batch.append(line.strip())
                if len(batch) >= batch_size:
                    yield batch
                    batch = []
            if batch:
                yield batch
    
    results = []
    for batch in batch_generator():
        # Process batch
        results.extend(f"Processed: {item}" for item in batch)
    
    return f"Processed {len(results)} items from {filename}"

Python Fire excels at rapid prototyping and creating intuitive command-line interfaces with minimal boilerplate. While it may not offer the fine-grained control of argparse or the decorator elegance of Click, its automatic introspection and natural command structure make it perfect for internal tools, system administration scripts, and rapid CLI development. The examples and patterns shown here provide a solid foundation for building production-ready command-line applications on Ubuntu 24.

For more advanced usage and complete documentation, check out the official Python Fire repository and the Python Fire documentation.



This article incorporates information and material from various online sources. We acknowledge and appreciate the work of all original authors, publishers, and websites. While every effort has been made to appropriately credit the source material, any unintentional oversight or omission does not constitute a copyright infringement. All trademarks, logos, and images mentioned are the property of their respective owners. If you believe that any content used in this article infringes upon your copyright, please contact us immediately for review and prompt action.

This article is intended for informational and educational purposes only and does not infringe on the rights of the copyright owners. If any copyrighted material has been used without proper credit or in violation of copyright laws, it is unintentional and we will rectify it promptly upon notification. Please note that the republishing, redistribution, or reproduction of part or all of the contents in any form is prohibited without express written permission from the author and website owner. For permissions or further inquiries, please contact us.

Leave a reply

Your email address will not be published. Required fields are marked