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
-
Using Python's -i Flag
python -i script.py # Enter interactive mode after script execution
-
Post-Mortem Debugging
import sys import pdb try: # Your code here problematic_function() except: type, value, tb = sys.exc_info() pdb.post_mortem(tb)
-
Remote Debugging
import rpdb rpdb.set_trace() # Connect using telnet localhost 4444
Best Practices
-
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
-
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
-
Debugging
- Use meaningful variable names
- Add debug prints strategically
- Leverage IDE debugging features
- Use logging instead of print statements
-
Error Messages
- Make error messages clear and actionable
- Include relevant context in custom exceptions
- Document expected exceptions in function docstrings
Common Debugging Scenarios
-
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
-
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.