xbe.at

Python / Testing and best practices

Exception Handling in Python A Comprehensive Guide

8 min read, 15 sections

Contents
  1. Understanding Exceptions in Python
  2. Built-in Exceptions
  3. Custom Exceptions
  4. Try-Except Blocks
  5. Handling Multiple Exceptions
  6. The else and finally Clauses
  7. Raising Exceptions
  8. Exception Chaining
  9. Context Managers and the with Statement
  10. Logging Exceptions
  11. Handling Exceptions in Asynchronous Code
  12. Best Practices in Exception Handling
  13. Real-Life Example: Web Scraping with Error Handling
  14. Real-Life Example: File Processing with Exception Handling
  15. Additional Resources

Understanding Exceptions in Python

Exceptions are events that occur during program execution which disrupt the normal flow of instructions. In Python, exceptions are objects that represent these error conditions. When an exception is raised, it propagates up the call stack until it's caught by an exception handler or causes the program to terminate.

def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

try:
    result = divide(10, 0)
except ValueError as e:
    print(f"Error occurred: {e}")

Built-in Exceptions

Python provides a variety of built-in exceptions to handle different error scenarios. Some common ones include TypeError, ValueError, IndexError, and KeyError. These exceptions help in identifying and handling specific error conditions in your code.

# Examples of built-in exceptions
try:
    # TypeError
    "2" + 2

    # ValueError
    int("abc")

    # IndexError
    list_example = [1, 2, 3]
    print(list_example[5])

    # KeyError
    dict_example = {"a": 1, "b": 2}
    print(dict_example["c"])
except (TypeError, ValueError, IndexError, KeyError) as e:
    print(f"Caught an exception: {type(e).__name__} - {e}")

Custom Exceptions

Custom exceptions allow you to define application-specific error conditions. They are created by subclassing the Exception class or any of its subclasses. Custom exceptions can carry additional information about the error and make your code more readable and maintainable.

class InsufficientFundsError(Exception):
    def __init__(self, balance, amount):
        self.balance = balance
        self.amount = amount
        super().__init__(f"Insufficient funds: balance {balance}, tried to withdraw {amount}")

def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientFundsError(balance, amount)
    return balance - amount

try:
    new_balance = withdraw(100, 150)
except InsufficientFundsError as e:
    print(f"Error: {e}")
    print(f"Current balance: {e.balance}")
    print(f"Attempted withdrawal: {e.amount}")

Try-Except Blocks

Try-except blocks are the foundation of exception handling in Python. The try block contains the code that might raise an exception, while the except block specifies how to handle the exception if it occurs. This structure allows you to gracefully manage errors and prevent your program from crashing.

def safe_divide(a, b):
    try:
        result = a / b
    except ZeroDivisionError:
        print("Error: Division by zero!")
        result = None
    except TypeError:
        print("Error: Invalid operand types!")
        result = None
    else:
        print("Division successful!")
    finally:
        print("Operation complete.")
    return result

print(safe_divide(10, 2))
print(safe_divide(10, 0))
print(safe_divide("10", 2))

Handling Multiple Exceptions

Python allows you to handle multiple exceptions in a single except block or in separate blocks. This feature enables you to provide different responses based on the type of exception that occurs, making your error handling more precise and informative.

def process_data(data):
    try:
        if len(data) > 5:
            raise ValueError("Data too long")
        result = 100 / len(data)
        print(data[10])
    except ZeroDivisionError:
        print("Error: Empty data")
    except ValueError as ve:
        print(f"Error: {ve}")
    except IndexError:
        print("Error: Accessing invalid index")
    except Exception as e:
        print(f"Unexpected error: {e}")

process_data([])
process_data([1, 2, 3, 4, 5, 6])
process_data([1, 2, 3])

The else and finally Clauses

The else clause in a try-except block is executed if no exceptions are raised. The finally clause is always executed, regardless of whether an exception occurred or not. These clauses help in structuring your code and ensuring that certain operations are always performed.

def read_file(filename):
    try:
        with open(filename, 'r') as file:
            content = file.read()
    except FileNotFoundError:
        print(f"Error: File '{filename}' not found")
    except IOError:
        print(f"Error: Unable to read file '{filename}'")
    else:
        print(f"Successfully read {len(content)} characters from '{filename}'")
        return content
    finally:
        print("File operation attempt completed")

read_file("existing_file.txt")
read_file("non_existent_file.txt")

Raising Exceptions

In Python, you can raise exceptions explicitly using the raise statement. This is useful when you want to signal an error condition in your own functions or methods. You can raise built-in exceptions or custom exceptions that you've defined.

def validate_age(age):
    if not isinstance(age, int):
        raise TypeError("Age must be an integer")
    if age < 0:
        raise ValueError("Age cannot be negative")
    if age > 150:
        raise ValueError("Age is unrealistically high")
    print(f"Age {age} is valid")

try:
    validate_age(25)
    validate_age(-5)
    validate_age("thirty")
    validate_age(200)
except (TypeError, ValueError) as e:
    print(f"Validation error: {e}")

Exception Chaining

Exception chaining allows you to associate a new exception with a previous one. This is useful when you want to raise a different type of exception while preserving information about the original error. Python provides the raise ... from ... syntax for this purpose.

def fetch_data():
    raise ConnectionError("Unable to connect to server")

def process_data():
    try:
        fetch_data()
    except ConnectionError as ce:
        raise ValueError("Data processing failed") from ce

try:
    process_data()
except ValueError as ve:
    print(f"Error: {ve}")
    if ve.__cause__:
        print(f"Caused by: {ve.__cause__}")

Context Managers and the with Statement

Context managers, used with the with statement, provide a clean way to manage resources like file handles or network connections. They ensure that resources are properly acquired and released, even if exceptions occur. This helps prevent resource leaks and makes your code more robust.

class DatabaseConnection:
    def __init__(self, db_name):
        self.db_name = db_name

    def __enter__(self):
        print(f"Connecting to database '{self.db_name}'")
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        print(f"Closing connection to database '{self.db_name}'")
        if exc_type:
            print(f"An error occurred: {exc_value}")
        return False  # Propagate exceptions

    def query(self, sql):
        if "DROP" in sql.upper():
            raise ValueError("DROP statements are not allowed")
        print(f"Executing query: {sql}")

with DatabaseConnection("mydb") as db:
    db.query("SELECT * FROM users")
    db.query("DROP TABLE users")

Logging Exceptions

Logging is crucial for debugging and monitoring applications. Python's logging module provides a flexible framework for generating log messages. When combined with exception handling, it allows you to record detailed error information for later analysis.

import logging

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')

def divide(a, b):
    try:
        result = a / b
    except ZeroDivisionError:
        logging.error("Division by zero attempted", exc_info=True)
        result = None
    except TypeError:
        logging.error("Invalid types for division", exc_info=True)
        result = None
    else:
        logging.info(f"Successfully divided {a} by {b}")
    return result

divide(10, 2)
divide(10, 0)
divide("10", 2)

Handling Exceptions in Asynchronous Code

Asynchronous programming in Python, often using the asyncio module, requires special consideration for exception handling. Exceptions in asynchronous code can be tricky to debug and manage, but Python provides tools to handle them effectively.

import asyncio

async def risky_operation(task_id):
    if task_id % 2 == 0:
        raise ValueError(f"Even task ID {task_id} not allowed")
    await asyncio.sleep(1)
    return f"Task {task_id} completed"

async def main():
    tasks = [risky_operation(i) for i in range(5)]
    results = await asyncio.gather(*tasks, return_exceptions=True)
    
    for i, result in enumerate(results):
        if isinstance(result, Exception):
            print(f"Task {i} failed: {result}")
        else:
            print(result)

asyncio.run(main())

Best Practices in Exception Handling

Effective exception handling involves following best practices to make your code more robust and maintainable. These practices include being specific with exception types, avoiding bare except clauses, and not suppressing exceptions unnecessarily.

def process_user_input(user_input):
    try:
        # Convert input to integer
        value = int(user_input)
        
        # Perform some operation
        result = 100 / value
        
        # Write result to file
        with open("results.txt", "w") as file:
            file.write(str(result))
        
        return result
    except ValueError:
        print("Invalid input: Please enter a valid integer")
    except ZeroDivisionError:
        print("Error: Cannot divide by zero")
    except IOError as e:
        print(f"File error: {e}")
    except Exception as e:
        print(f"An unexpected error occurred: {e}")
        raise  # Re-raise unexpected exceptions

# Example usage
user_inputs = ["10", "0", "abc", "5"]
for input_value in user_inputs:
    try:
        result = process_user_input(input_value)
        if result:
            print(f"Result: {result}")
    except Exception as e:
        print(f"Unhandled exception: {e}")

Real-Life Example: Web Scraping with Error Handling

Web scraping often involves dealing with network issues, parsing errors, and unexpected page structures. Proper exception handling can make your scraping scripts more resilient and informative.

import urllib.request
from urllib.error import URLError, HTTPError
from html.parser import HTMLParser

class TitleParser(HTMLParser):
    def __init__(self):
        super().__init__()
        self.title = None

    def handle_starttag(self, tag, attrs):
        if tag == 'title':
            self.title = ""

    def handle_data(self, data):
        if self.title is not None:
            self.title += data

    def handle_endtag(self, tag):
        if tag == 'title':
            self.title = self.title.strip()

def get_website_title(url):
    try:
        with urllib.request.urlopen(url) as response:
            html = response.read().decode('utf-8')
            parser = TitleParser()
            parser.feed(html)
            return parser.title
    except HTTPError as e:
        print(f"HTTP Error {e.code}: {e.reason}")
    except URLError as e:
        print(f"URL Error: {e.reason}")
    except Exception as e:
        print(f"An unexpected error occurred: {e}")
    return None

# Example usage
urls = [
    "https://www.python.org",
    "https://www.nonexistentwebsite123456.com",
    "https://httpstat.us/404",
    "https://httpstat.us/500"
]

for url in urls:
    title = get_website_title(url)
    if title:
        print(f"Title of {url}: {title}")
    else:
        print(f"Failed to retrieve title for {url}")

Real-Life Example: File Processing with Exception Handling

File processing is a common task that can benefit greatly from robust exception handling. This example demonstrates how to handle various exceptions that might occur when reading, processing, and writing files.

import os
import csv
from datetime import datetime

def process_csv_file(input_file, output_file):
    try:
        with open(input_file, 'r') as infile, open(output_file, 'w', newline='') as outfile:
            reader = csv.reader(infile)
            writer = csv.writer(outfile)
            
            headers = next(reader)
            writer.writerow(headers + ['Processed'])
            
            for row in reader:
                try:
                    # Assume the last column is a date
                    date_str = row[-1]
                    date_obj = datetime.strptime(date_str, '%Y-%m-%d')
                    
                    # Add a day to the date
                    new_date = date_obj + datetime.timedelta(days=1)
                    row[-1] = new_date.strftime('%Y-%m-%d')
                    
                    writer.writerow(row + ['Yes'])
                except ValueError as ve:
                    print(f"Error processing row {reader.line_num}: {ve}")
                    writer.writerow(row + ['No'])
        
        print(f"Processing complete. Output written to {output_file}")
    except FileNotFoundError:
        print(f"Error: Input file '{input_file}' not found")
    except PermissionError:
        print(f"Error: Permission denied when accessing '{output_file}'")
    except csv.Error as e:
        print(f"CSV Error: {e}")
    except Exception as e:
        print(f"An unexpected error occurred: {e}")

# Example usage
input_csv = 'input_data.csv'
output_csv = 'processed_data.csv'

# Create a sample input CSV file
with open(input_csv, 'w', newline='') as f:
    writer = csv.writer(f)
    writer.writerow(['Name', 'Age', 'Date'])
    writer.writerow(['Alice', '30', '2023-01-15'])
    writer.writerow(['Bob', '25', '2023-02-20'])
    writer.writerow(['Charlie', '35', 'invalid-date'])

process_csv_file(input_csv, output_csv)

# Clean up the created files
os.remove(input_csv)
os.remove(output_csv)

Additional Resources

For further exploration of exception handling in Python, consider the following resources:

  1. Python Official Documentation on Errors and Exceptions: https://docs.python.org/3/tutorial/errors.html
  2. PEP 3134 -- Exception Chaining and Embedded Tracebacks: https://www.python.org/dev/peps/pep-3134/
  3. Real Python's Guide to Python Exceptions: https://realpython.com/python-exceptions/
  4. "Effective Python: 90 Specific Ways to Write Better Python" by Brett Slatkin (Book)
  5. "Python Cookbook" by David Beazley and Brian K. Jones (Book)

These resources provide in-depth explanations, best practices, and advanced techniques for mastering exception handling in Python.

More in Testing and best practices

  1. Exploring Python Docstring Formats15 min

    The reStructuredText (reST) docstring format, originally developed for Python documentation, uses a semantic markup syntax to define documentation elements....

  2. Effective Resource Management in Python7 min

    Python's memory management system automatically handles object cleanup through garbage collection, but certain system resources like file handles and network...

  3. Exploring Python Virtual Environments3 min

    A virtual environment in Python represents an isolated working space that maintains its own independent set of Python packages and dependencies. This...

  4. Debugging Tips for Coding Challenges with Python3 min

    Reproducibility Problem Keep a log of actions when the bug appears to recreate conditions

  5. How to Create Custom Exception in Python5 min

    Custom exceptions in Python extend the built-in Exception class to create specialized error handling mechanisms. These allow developers to define...

  6. Comprehensive Python Testing Strategies5 min

    Happy path testing involves testing the primary use case or the most common scenario of an application or a function. It ensures that the code works as...