📓 Python Programming - Error Handling and Debugging


1. Try-Except Blocks

Error handling prevents programs from crashing due to unexpected issues.

The basic structure:

try:
    # Code that might raise an error
except:
    # Code to handle the error


➤ **Example:**



```python
try:
    num = int(input("Enter a number: "))
    print(f"Square: {num**2}")
except Exception as e:
    print("Invalid input! Please enter a number.\nException:", e)

2. Catching Specific Exceptions

It's better to catch specific exceptions to handle different errors appropriately.

try:
    num = int(input("Enter a number: "))
    result = 10 / num
except ValueError:
    print("Please enter a valid number.")
except ZeroDivisionError:
    print("Division by zero is not allowed.")
except Exception as e:
    print("An unexpected error occurred:", e)

3. Using else & finally

  • else: Runs if no exceptions occur.

  • finally: Runs no matter what (cleanup code).

try:
    num = int(input("Enter a positive number: "))
except ValueError:
    print("Invalid input.")
else:
    print(f"You entered: {num}")
finally:
    print("This always runs.")

4. Generating and Creating Custom Exceptions

  • You can use raise to generate exceptions.

  • Custom exceptions are created by defining a new class that inherits from Exception.

➤ Raising Exceptions Example:

age = -5

if age < 0:
    raise ValueError("Age cannot be negative.")

➤ Custom Exception Example:

class NegativeAgeError(Exception):
    pass


age = -5

try:
    if age < 0:
        raise NegativeAgeError("Age cannot be negative.")
except NegativeAgeError as e:
    print(e)

5. Problem-Solving Strategies

✅ Understand the error message.

✅ Use print statements or debugging tools to trace the problem.

✅ Break the code into smaller parts.

✅ Handle exceptions gracefully using try-except blocks.

✅ Anticipate common errors (e.g., invalid input, division by zero).

✅ Write custom exceptions for specific situations.

6. Common Python Error Types

Understanding common errors helps you debug faster:

  • SyntaxError: Invalid Python syntax

  • NameError: Variable not defined

  • TypeError: Wrong data type used

  • IndexError: List index out of range

  • KeyError: Dictionary key doesn't exist

  • FileNotFoundError: File doesn't exist

  • ImportError: Module can't be imported

# Examples of common errors (commented out to avoid actual errors)

# 1. SyntaxError
# print("Hello World"  # Missing closing parenthesis

# 2. NameError
# print(undefined_variable)  # Variable not defined

# 3. TypeError
# result = "5" + 10  # Can't add string and integer

# 4. IndexError
# my_list = [1, 2, 3]
# print(my_list[5])  # Index 5 doesn't exist

# 5. KeyError
# my_dict = {"name": "John"}
# print(my_dict["age"])  # Key 'age' doesn't exist

# 6. FileNotFoundError
# with open("nonexistent.txt", "r") as file:
#     content = file.read()

# Let's demonstrate with proper error handling:
def demonstrate_errors():
    # Handling IndexError
    try:
        my_list = [1, 2, 3]
        print(my_list[5])
    except IndexError as e:
        print(f"IndexError caught: {e}")

    # Handling KeyError
    try:
        my_dict = {"name": "John"}
        print(my_dict["age"])
    except KeyError as e:
        print(f"KeyError caught: Missing key {e}")

    # Handling TypeError
    try:
        result = "5" + 10
    except TypeError as e:
        print(f"TypeError caught: {e}")

demonstrate_errors()

7. Debugging Techniques

Different ways to find and fix bugs in your code:

  1. Print Debugging: Add print statements to track values

  2. Python Debugger (pdb): Step through code line by line

  3. IDE Debugger: Use VS Code's built-in debugger

  4. Logging: Better than print statements for production code

➤ Print Debugging Example:

def calculate_average(numbers):
    print(f"DEBUG: Input numbers: {numbers}")  # Debug print

    total = 0
    for i, num in enumerate(numbers):
        total += num
        print(f"DEBUG: Step {i+1}, added {num}, total now: {total}")  # Debug print

    average = total / len(numbers)
    print(f"DEBUG: Final average: {average}")  # Debug print
    return average

# Test with debug prints
numbers = [10, 20, 30, 40]
result = calculate_average(numbers)
print(f"Average: {result}")

# Better approach: Use a debug flag
DEBUG = True

def debug_print(message):
    if DEBUG:
        print(f"[DEBUG] {message}")

def calculate_average_v2(numbers):
    debug_print(f"Input numbers: {numbers}")

    total = sum(numbers)
    debug_print(f"Sum: {total}")

    average = total / len(numbers)
    debug_print(f"Average: {average}")
    return average

print("\n--- Version 2 with debug flag ---")
result2 = calculate_average_v2([5, 15, 25])
print(f"Average: {result2}")

➤ Using Python's Logging Module:

import logging

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

def divide_numbers(a, b):
    logging.debug(f"Starting division: {a} / {b}")

    try:
        if b == 0:
            logging.error("Division by zero attempted!")
            raise ZeroDivisionError("Cannot divide by zero")

        result = a / b
        logging.info(f"Division successful: {a} / {b} = {result}")
        return result

    except Exception as e:
        logging.exception("An error occurred during division")
        raise

# Test logging
print("=== Testing with logging ===")
try:
    result1 = divide_numbers(10, 2)
    print(f"Result 1: {result1}")

    result2 = divide_numbers(10, 0)
    print(f"Result 2: {result2}")
except ZeroDivisionError as e:
    print(f"Caught error: {e}")

# Different log levels
logging.debug("This is a debug message")
logging.info("This is an info message")
logging.warning("This is a warning message")
logging.error("This is an error message")
logging.critical("This is a critical message")

8. Assertions and Testing

Assertions help catch bugs early by checking assumptions:

  • assert statement checks if a condition is True

  • Raises AssertionError if condition is False

  • Used for debugging and testing

# Basic assertion examples
def calculate_factorial(n):
    assert n >= 0, "Factorial is not defined for negative numbers"
    assert isinstance(n, int), "Input must be an integer"

    if n == 0 or n == 1:
        return 1

    result = 1
    for i in range(2, n + 1):
        result *= i

    # Assert the result makes sense
    assert result > 0, "Factorial should always be positive"
    return result

# Test assertions
try:
    print(f"5! = {calculate_factorial(5)}")
    print(f"0! = {calculate_factorial(0)}")

    # This will raise AssertionError
    print(f"(-1)! = {calculate_factorial(-1)}")
except AssertionError as e:
    print(f"Assertion failed: {e}")

# Assertions in testing
def test_factorial():
    # Test cases
    assert calculate_factorial(0) == 1, "0! should be 1"
    assert calculate_factorial(1) == 1, "1! should be 1"
    assert calculate_factorial(5) == 120, "5! should be 120"
    print("All factorial tests passed!")

test_factorial()

# Assertions for debugging
def divide_positive_numbers(a, b):
    assert a > 0 and b > 0, "Both numbers must be positive"

    result = a / b

    # Check our assumption about the result
    assert result > 0, "Result should be positive when dividing positive numbers"
    return result

# Test
try:
    result = divide_positive_numbers(10, 2)
    print(f"Division result: {result}")

    # This will fail the assertion
    result = divide_positive_numbers(-10, 2)
except AssertionError as e:
    print(f"Assertion error: {e}")

9. Exception Chaining and Context

Preserve error context when re-raising exceptions:

  • raise ... from ... - Explicit chaining

  • raise - Implicit chaining

  • Helps track the original cause of errors

def read_config_file(filename):
    """Example of exception chaining"""
    try:
        with open(filename, 'r') as file:
            import json
            config = json.load(file)
            return config
    except FileNotFoundError as e:
        # Chain the original exception
        raise ValueError(f"Configuration file '{filename}' not found") from e
    except json.JSONDecodeError as e:
        # Chain with more context
        raise ValueError(f"Invalid JSON in configuration file '{filename}'") from e

def process_data():
    """Example showing exception context"""
    try:
        config = read_config_file("nonexistent_config.json")
        return config
    except ValueError as e:
        print(f"Configuration error: {e}")
        print(f"Original cause: {e.__cause__}")
        # Re-raise with additional context
        raise RuntimeError("Failed to initialize application") from e

# Test exception chaining
try:
    process_data()
except RuntimeError as e:
    print(f"\nFinal error: {e}")
    print(f"Caused by: {e.__cause__}")
    print(f"Original cause: {e.__cause__.__cause__}")

# Example with suppress chaining
def clean_reraise():
    try:
        result = 10 / 0
    except ZeroDivisionError:
        # Suppress the original exception chain
        raise ValueError("Invalid calculation") from None

try:
    clean_reraise()
except ValueError as e:
    print(f"\nSuppressed chain error: {e}")
    print(f"Has cause: {e.__cause__}")  # Will be None

10. Error Handling Best Practices

Guidelines for writing robust, maintainable code:

✅ Be specific: Catch specific exceptions, not generic Exception

✅ Fail fast: Use assertions to catch bugs early

✅ Log errors: Use logging instead of print for production code

✅ Don't ignore: Always handle or log exceptions

✅ Clean up: Use finally or context managers for cleanup

✅ Document: Specify what exceptions your functions can raise

✅ Test: Write tests for both success and failure cases

# Example of good error handling practices
import logging

class DatabaseError(Exception):
    """Custom exception for database operations"""
    pass

class UserNotFoundError(DatabaseError):
    """Raised when user is not found in database"""
    pass

def get_user_by_id(user_id):
    """
    Retrieve user by ID from database.

    Args:
        user_id (int): The user ID to search for

    Returns:
        dict: User information

    Raises:
        TypeError: If user_id is not an integer
        UserNotFoundError: If user is not found
        DatabaseError: If database connection fails
    """
    # Input validation
    if not isinstance(user_id, int):
        raise TypeError(f"user_id must be an integer, got {type(user_id)}")

    if user_id <= 0:
        raise ValueError("user_id must be positive")

    # Simulate database lookup
    fake_database = {1: {"name": "Alice", "email": "[email protected]"}}

    try:
        # Simulate potential database connection error
        if user_id > 100:
            raise ConnectionError("Database connection lost")

        if user_id not in fake_database:
            raise UserNotFoundError(f"User with ID {user_id} not found")

        user = fake_database[user_id]
        logging.info(f"Successfully retrieved user {user_id}")
        return user

    except ConnectionError as e:
        logging.error(f"Database connection failed: {e}")
        raise DatabaseError("Failed to connect to database") from e

# Example usage with proper error handling
def main():
    logging.basicConfig(level=logging.INFO)

    test_cases = [1, "invalid", -1, 2, 101]

    for user_id in test_cases:
        try:
            print(f"\nTrying to get user {user_id}:")
            user = get_user_by_id(user_id)
            print(f"Success: {user}")

        except TypeError as e:
            print(f"Type error: {e}")
        except ValueError as e:
            print(f"Value error: {e}")
        except UserNotFoundError as e:
            print(f"User not found: {e}")
        except DatabaseError as e:
            print(f"Database error: {e}")
            print(f"Original cause: {e.__cause__}")

# Run the example
main()

🎯 Quick Recap

✅ Use try-except to catch errors and prevent crashes

✅ Catch specific exceptions for better error handling

✅ else and finally provide structured error handling

✅ Custom exceptions allow for clear, descriptive error messages

✅ Know common Python error types for faster debugging

✅ Use debugging techniques: print statements, logging, assertions

✅ Exception chaining preserves error context

✅ Follow best practices for robust, maintainable code

✅ Debugging is an essential skill for reliable code

📝 Practice Exercises

Basic Level:

  1. Write a program that asks for two numbers and divides them. Handle:
  2. ValueError for invalid inputs
  3. ZeroDivisionError for division by zero

  4. Create a custom exception called PasswordTooShortError if the entered password is less than 8 characters.

  5. Demonstrate the use of else and finally in a simple program that reads a file.

  6. Intentionally raise an exception if a negative number is entered for age.

Intermediate Level:

  1. Write a function that catches different types of errors (IndexError, KeyError, TypeError) and prints specific messages for each.

  2. Create a simple calculator with proper error handling and logging.

  3. Use assertions to validate input in a function that calculates square roots.

  4. Write a program that demonstrates exception chaining when processing user data.

Advanced Level:

  1. Build a file processor that handles multiple types of errors and uses logging instead of print statements.

  2. Create a custom exception hierarchy for a banking system (InsufficientFundsError, InvalidAccountError, etc.).

  3. Write a robust function with proper documentation that specifies all possible exceptions it can raise.

  4. Implement a retry mechanism that attempts an operation multiple times before giving up.