Python Exception Handling and Debugging: A Complete Guide


Python Exception Handling and Debugging: A Complete Guide

Writing code that handles errors gracefully and can be easily debugged is crucial for developing robust applications. This guide covers Python's exception handling mechanisms and various debugging techniques to help you write more reliable code.

Exception Handling Basics

Try-Except Blocks

try:
    # Code that might raise an exception
    result = 10 / 0
except ZeroDivisionError:
    print("Cannot divide by zero!")
except Exception as e:
    print(f"An error occurred: {e}")

Multiple Exception Types

def process_data(data):
    try:
        value = int(data)
        result = 100 / value
        return result
    except ValueError:
        print("Invalid input: Please enter a number")
    except ZeroDivisionError:
        print("Cannot divide by zero")
    except Exception as e:
        print(f"Unexpected error: {e}")

Try-Except-Else-Finally

def read_file(filename):
    try:
        file = open(filename, 'r')
    except FileNotFoundError:
        print(f"File {filename} not found")
        return None
    else:
        content = file.read()
        return content
    finally:
        if 'file' in locals():
            file.close()

Custom Exceptions

Creating custom exceptions helps make your code more maintainable and descriptive:

class ValidationError(Exception):
    """Raised when input validation fails"""
    pass

class DatabaseError(Exception):
    """Raised when database operations fail"""
    def __init__(self, message, error_code):
        self.message = message
        self.error_code = error_code
        super().__init__(self.message)

# Using custom exceptions
def validate_age(age):
    if age < 0:
        raise ValidationError("Age cannot be negative")
    if age > 150:
        raise ValidationError("Age seems invalid")

Context Managers

Using context managers with the with statement ensures proper resource cleanup:

class DatabaseConnection:
    def __enter__(self):
        print("Connecting to database")
        return self
    
    def __exit__(self, exc_type, exc_value, traceback):
        print("Closing database connection")
        if exc_type is not None:
            print(f"An error occurred: {exc_value}")
        return False  # Re-raise any exceptions

# Using the context manager
with DatabaseConnection() as db:
    # Database operations here
    pass

Logging

Python's logging module is essential for debugging and monitoring applications:

import logging

# Configure logging
logging.basicConfig(
    level=logging.DEBUG,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
    filename='app.log'
)

# Create a logger
logger = logging.getLogger(__name__)

def divide_numbers(a, b):
    try:
        logger.debug(f"Dividing {a} by {b}")
        result = a / b
        logger.info(f"Result: {result}")
        return result
    except ZeroDivisionError:
        logger.error("Division by zero attempted")
        raise
    except Exception as e:
        logger.exception("Unexpected error occurred")
        raise

Debugging Techniques

Using pdb (Python Debugger)

def complex_calculation(x, y):
    import pdb; pdb.set_trace()  # Start debugger
    result = x * y
    for i in range(result):
        if i % 2 == 0:
            result += i
    return result

# Common pdb commands:
# n (next line)
# s (step into)
# c (continue)
# p variable (print variable)
# l (list source code)
# q (quit)

Using breakpoint()

def analyze_data(data):
    processed = []
    for item in data:
        breakpoint()  # Python 3.7+ built-in debugger
        processed.append(item * 2)
    return processed

Project: Debug Toolkit

Let's create a debug toolkit that combines various debugging and error handling techniques:

import logging
import traceback
import sys
from functools import wraps
from datetime import datetime

class DebugToolkit:
    def __init__(self, log_file='debug.log'):
        # Configure logging
        self.logger = logging.getLogger('DebugToolkit')
        self.logger.setLevel(logging.DEBUG)
        
        # File handler
        fh = logging.FileHandler(log_file)
        fh.setLevel(logging.DEBUG)
        
        # Console handler
        ch = logging.StreamHandler()
        ch.setLevel(logging.INFO)
        
        # Formatter
        formatter = logging.Formatter(
            '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
        )
        fh.setFormatter(formatter)
        ch.setFormatter(formatter)
        
        self.logger.addHandler(fh)
        self.logger.addHandler(ch)
    
    def log_exceptions(self, func):
        """Decorator to log exceptions"""
        @wraps(func)
        def wrapper(*args, **kwargs):
            try:
                return func(*args, **kwargs)
            except Exception as e:
                self.logger.exception(
                    f"Exception in {func.__name__}: {str(e)}"
                )
                raise
        return wrapper
    
    def measure_time(self, func):
        """Decorator to measure function execution time"""
        @wraps(func)
        def wrapper(*args, **kwargs):
            start = datetime.now()
            result = func(*args, **kwargs)
            end = datetime.now()
            duration = end - start
            self.logger.info(
                f"{func.__name__} took {duration.total_seconds():.2f} seconds"
            )
            return result
        return wrapper
    
    def trace_calls(self, func):
        """Decorator to trace function calls"""
        @wraps(func)
        def wrapper(*args, **kwargs):
            self.logger.debug(
                f"Calling {func.__name__} with args: {args}, kwargs: {kwargs}"
            )
            result = func(*args, **kwargs)
            self.logger.debug(f"{func.__name__} returned: {result}")
            return result
        return wrapper
    
    def debug_context(self, name="DebugContext"):
        """Context manager for debugging blocks of code"""
        class DebugContextManager:
            def __init__(self, toolkit, name):
                self.toolkit = toolkit
                self.name = name
            
            def __enter__(self):
                self.toolkit.logger.debug(f"Entering {self.name}")
                return self
            
            def __exit__(self, exc_type, exc_val, exc_tb):
                if exc_type is not None:
                    self.toolkit.logger.error(
                        f"Error in {self.name}: {exc_val}"
                    )
                    self.toolkit.logger.debug(
                        "Traceback:\n" + 
                        "".join(traceback.format_tb(exc_tb))
                    )
                self.toolkit.logger.debug(f"Exiting {self.name}")
                return False
        
        return DebugContextManager(self, name)

# Example usage
debug = DebugToolkit()

@debug.log_exceptions
@debug.measure_time
@debug.trace_calls
def process_data(data):
    """Example function using debug toolkit"""
    processed = []
    with debug.debug_context("data_processing"):
        for item in data:
            if not isinstance(item, (int, float)):
                raise ValueError(f"Invalid item: {item}")
            processed.append(item * 2)
    return processed

# Test the debug toolkit
if __name__ == '__main__':
    test_data = [1, 2, "3", 4, 5]
    try:
        result = process_data(test_data)
    except ValueError as e:
        print(f"Error: {e}")

Advanced Debugging Tips

  1. Using Python's -i Flag

    python -i script.py  # Enter interactive mode after script execution
    
  2. Post-Mortem Debugging

    import sys
    import pdb
    
    try:
        # Your code here
        problematic_function()
    except:
        type, value, tb = sys.exc_info()
        pdb.post_mortem(tb)
    
  3. Remote Debugging

    import rpdb
    rpdb.set_trace()  # Connect using telnet localhost 4444
    

Best Practices

  1. Exception Handling

    • Catch specific exceptions rather than using bare except
    • Keep try blocks small and focused
    • Always clean up resources in finally blocks
    • Use context managers when possible
  2. Logging

    • Use appropriate log levels (DEBUG, INFO, WARNING, ERROR, CRITICAL)
    • Include relevant context in log messages
    • Configure logging at application startup
    • Use structured logging for complex applications
  3. Debugging

    • Use meaningful variable names
    • Add debug prints strategically
    • Leverage IDE debugging features
    • Use logging instead of print statements
  4. Error Messages

    • Make error messages clear and actionable
    • Include relevant context in custom exceptions
    • Document expected exceptions in function docstrings

Common Debugging Scenarios

  1. Infinite Loops

    import sys
    
    def process_with_limit(data, limit=1000):
        iterations = 0
        while True:
            iterations += 1
            if iterations > limit:
                raise RuntimeError("Iteration limit exceeded")
            # Process data
    
  2. Memory Leaks

    import tracemalloc
    
    tracemalloc.start()
    # Your code here
    snapshot = tracemalloc.take_snapshot()
    top_stats = snapshot.statistics('lineno')
    print("Memory usage:")
    for stat in top_stats[:10]:
        print(stat)
    

Conclusion

Effective exception handling and debugging are crucial skills that:

  • Improve code reliability
  • Make troubleshooting easier
  • Enhance application maintainability
  • Help identify and fix issues quickly

Practice these techniques regularly and incorporate them into your development workflow to become a more effective Python developer.


Further Reading