← Computer Programming I

1. Introduction to Exceptions

Until this point in the course, when your program encountered an error during execution - such as dividing by zero or attempting to access a list index that doesn’t exist - the Python interpreter would immediately stop and display a “Traceback” error message. These are known as Runtime Exceptions.

While useful for debugging during development, allowing a program to crash in front of a user is poor practice. Exception Handling allows us to “catch” these errors when they occur and determine how the program should proceed, rather than terminating abruptly. This is the foundation of writing robust software - programs that can handle unexpected inputs or states gracefully.

The try and except Block

The primary mechanism for handling errors in Python is the try and except block.

  • try block: Contains the code that might raise an exception (the “risky” code).
  • except block: Contains the code that executes only if an error occurs in the try block.

Syntax:

try:
    # Code that might crash
    unsafe_operation()
except ValueError:
    # Code that runs only if a ValueError occurs
    print("A value error was caught.")

Implementation Note: It is best practice to keep the try block as small as possible. Only wrap the specific lines of code that are expected to potentially fail.

Example: Robust Average Calculator

In this example, we calculate the average of a list containing mixed data types. Without exception handling, the string "N/A" would cause the program to crash.

def calculate_safe_average(data_list):
    total = 0
    count = 0
    
    for item in data_list:
        try:
            # Attempt to convert to float and add to total
            # If item is "N/A" or "missing", float() raises ValueError
            number = float(item) 
            total += number
            count += 1
        except ValueError:
            # This block runs if float(item) fails
            # We explicitly ignore the bad data and continue the loop
            print(f"Skipping invalid data: {item}")
            
    try:
        # Attempt division
        average = total / count
        return average
    except ZeroDivisionError:
        # This block runs if count is 0 (empty list or no valid numbers)
        print("Warning: No valid numbers to average.")
        return 0.0

# Usage
raw_data = [100, "missing", 95, 80, "N/A"]
result = calculate_safe_average(raw_data)
print(f"Average: {result}")

Key Takeaways:

  1. Granularity: The try/except block is placed inside the loop. This ensures that a single bad data point allows the loop to continue to the next item, rather than stopping the entire process.
  2. Specific Errors: We catch specific errors (ValueError, ZeroDivisionError) rather than a generic error.

2. Handling Multiple Exceptions & The Exception Object

Real-world applications can fail in many ways. You often need to react differently depending on the specific nature of the error. Python allows you to stack multiple except blocks to handle different error types distinctively.

The Exception Object (as e)

Sometimes you need access to the specific error message generated by the system to log it or display it to the user. You can access the Exception Object using the as keyword. This variable holds the details of what went wrong.

Syntax:

try:
    risky_code()
except IndexError:
    handle_index_error()
except (ValueError, TypeError) as e:
    # 'e' allows access to the system error message
    print(f"A data error occurred: {e}")

Example: Data Structure Navigator

Consider a function that retrieves values from a list of dictionaries. Several things can go wrong: the list index might be out of range, or the dictionary key might not exist.

def get_user_role(users_list, index, key):
    try:
        # This line can raise multiple types of exceptions:
        user = users_list[index]  # IndexError (if index too high)
        value = user[key]         # KeyError (if key doesn't exist)
        return value
        
    except IndexError:
        return "Error: User ID out of range."
        
    except KeyError as e:
        # 'e' contains the specific key that was missing
        return f"Error: The attribute {e} does not exist for this user."
        
    except TypeError as e:
        # Catches cases where index is not an integer
        return f"Error: Indices must be integers. Details: {e}"

# Usage
users = [{"name": "Alice", "role": "admin"}, {"name": "Bob", "role": "user"}]
print(get_user_role(users, 5, 'role'))   # Triggers IndexError
print(get_user_role(users, 0, 'age'))    # Triggers KeyError

Implementation Note: Python checks except blocks from top to bottom. Once an exception is matched, the corresponding block executes, and the remaining blocks are skipped.


3. The else and finally Blocks

The try statement includes two optional blocks that provide finer control over program flow:

  1. else block: Executes only if the try block completes successfully (i.e., no exceptions were raised). This is the ideal place for code that requires the try operation to succeed but does not require error handling itself.
  2. finally block: Executes always, regardless of whether an exception occurred or not. This is used for “cleanup” actions, such as closing files, resetting variables, or logging transactions.

Example: Transaction Processor

In this banking simulation, we ensure logs are saved regardless of whether the transaction succeeds or fails.

def process_transaction(balance, amount):
    print(f"\nAttempting to withdraw ${amount}...")
    
    try:
        # Logic that might raise an exception
        amount_float = float(amount) # Raises ValueError if string
        new_balance = balance - amount_float
        
    except ValueError:
        print("Error: Invalid amount entered.")
        return balance 
        
    else:
        # Runs ONLY if the try block succeeded
        print("Transaction Approved.")
        return new_balance
        
    finally:
        # Runs ALWAYS (even if we returned early in except/else!)
        print("System: Saving transaction logs...")

# Usage
process_transaction(1000, 200)      # Triggers else and finally
process_transaction(1000, "fifty")  # Triggers except and finally

Crucial Logic: Notice that the finally block runs even if a return statement is executed in the except or else blocks. It is the absolute last thing to happen before the function exits.


4. Raising Exceptions (raise) & Defensive Coding

Sometimes, code is syntactically correct (Python can run it without crashing), but it is logically incorrect for your specific application. For example, depositing a negative amount of money is valid mathematics, but it violates banking rules.

We use the raise keyword to trigger an exception manually when a specific rule is violated. This is known as Defensive Programming. We deliberately interrupt the program to prevent corrupt or invalid data from being processed.

Syntax:

if variable < 0:
    raise ValueError("Variables cannot be negative")

Example: User Registration Validator

Here, we enforce constraints on user input. We do not just print an error; we raise it. This allows the part of the code calling this function to decide how to handle the error (e.g., ask the user for input again).

def register_user(username, age):
    # Defensive checks for Username
    if type(username) is not str:
        raise TypeError("Username must be a string.")
        
    if len(username) < 4:
        raise ValueError(f"Username '{username}' is too short (min 4 chars).")

    # Defensive checks for Age
    if type(age) is not int:
        raise TypeError("Age must be an integer.")
        
    if age < 0 or age > 120:
        raise ValueError(f"Age {age} is invalid. Must be 0-120.")

    # If we reach this line, data is valid
    return {"name": username, "age": age, "status": "active"}

# Testing the validation
try:
    user = register_user("usr", 25)
except ValueError as e:
    print(f"Registration failed: {e}")

Why Raise instead of Print? Functions should often be silent workers. If register_user simply printed “Error”, the program might continue running with a missing user, causing a crash later. By raising an exception, we force the program to acknowledge that the operation failed.

Week 11: Consolidation & Practice Problems

The following problems combine exception handling with previously learned concepts such as lists, dictionaries, and loops. They illustrate how error handling is applied in realistic scenarios like data cleaning, inventory management, and command processing.

Problem 1: The Sensor Data Cleaner

Context: In real-world data science and engineering, data often comes from unreliable sources. Sensors may glitch, producing text instead of numbers, or values that are physically impossible. Before analysis can begin, this “dirty” data must be cleaned.

Task: We need to process a list of raw temperature readings. The function clean_sensor_data must:

  1. Iterate through raw readings.
  2. Convert strings to floats.
  3. Filter out values that are not numbers (catch ValueError).
  4. Filter out values that are outside the operational range of -50.0 to 60.0 (using raise to trigger a custom error).
  5. Return a clean list of valid floats.

Solution:

def clean_sensor_data(raw_readings):
    valid_data = []
    
    for reading in raw_readings:
        try:
            # 1. Attempt conversion
            # This raises ValueError if reading is "error", "N/A", etc.
            temp = float(reading)
            
            # 2. Check Logic (Defensive Coding)
            # We raise a ValueError manually if the number is mathematically valid
            # but logically impossible for our sensor.
            if temp < -50.0 or temp > 60.0:
                raise ValueError(f"Out of range: {temp}")
            
            # 3. If we get here, data is good
            valid_data.append(temp)
            
        except ValueError as e:
            # This block catches both:
            # - Conversion errors (from step 1)
            # - Custom range errors (from step 2)
            print(f"Skipping entry '{reading}': {e}")
            # The 'continue' keyword ensures we move to the next item
            continue
            
    return valid_data

# Example Usage
data = ["23.5", "error", "105.0", "-10.0", "N/A", "40.5"]
cleaned = clean_sensor_data(data)
print(f"Cleaned Data: {cleaned}")

Analysis: Notice how we use a single except ValueError block to handle two different problems. Whether the data is non-numeric (“error”) or logically invalid (105.0), we treat it as “bad data” and skip it. The use of continue explicitly signals that we are done with the current iteration and moving to the next.


Problem 2: The Robust Inventory Manager

Context: When managing state - such as the inventory of a store - it is critical to prevent the system from entering an invalid state. For example, a store cannot have a negative number of apples. We use Defensive Programming to validate inputs before making any changes to the data.

Task: The update_inventory function manages a dictionary of items. It must handle three specific error cases using distinct exceptions:

  1. TypeError: If the quantity provided is not an integer.
  2. KeyError: If attempting to remove an item that does not exist in the dictionary.
  3. ValueError: If removing stock results in a negative total.

Solution:

def update_inventory(inventory, item, quantity):
    # 1. Type Validation
    # We check the type before doing anything else
    if type(quantity) is not int:
        raise TypeError(f"Quantity must be an integer, got {type(quantity).__name__}")
    
    # 2. Key Validation for removal
    # We cannot remove items that don't exist
    if item not in inventory and quantity < 0:
        raise KeyError(f"Cannot remove {item}: Item not found in inventory.")
    
    # Get current stock (default to 0 if new item)
    current_stock = inventory.get(item, 0)
    new_stock = current_stock + quantity
    
    # 3. Logic Validation
    # Ensure we don't end up with negative stock
    if new_stock < 0:
        raise ValueError(f"Insufficient stock. Current: {current_stock}, Requested removal: {abs(quantity)}")
        
    # If all checks pass, commit the change
    inventory[item] = new_stock
    return new_stock

# Example Usage
store = {"Apples": 10, "Bananas": 5}

# List of transactions: (Item, Quantity)
transactions = [
    ("Apples", 5),      # Valid add
    ("Bananas", -20),   # Invalid logic (too many)
    ("Oranges", "ten")  # Invalid type
]

for item, qty in transactions:
    print(f"\nProcessing: {item} ({qty})")
    try:
        new_total = update_inventory(store, item, qty)
        print(f"Success. New Stock: {new_total}")
    except (ValueError, KeyError, TypeError) as e:
        # We catch any of the three errors raised above
        print(f"Transaction Failed: {e}")

Analysis: This example demonstrates the importance of the order of operations. We perform all checks (type(), existence, negative balance) before we modify the inventory dictionary. This ensures that if an error occurs, the data remains consistent and is not left halfway updated.


Problem 3: The Safe Command Processor

Context: Complex applications often execute pipelines of commands where each step might fail for different reasons. Using the full try/except/else/finally structure allows us to handle errors gracefully, confirm successes, and perform necessary cleanup or logging after every step, regardless of the outcome.

Task: The process_pipeline function takes a list of instruction tuples (command, value). It processes them as follows:

  1. Commands:
    • div_100: Calculates 100 / value.
    • parse_int: Converts string input to an integer.
    • verify_positive: Checks if a number is positive; if not, it manually raises a ValueError.
  2. Error Handling:
    • Catches ZeroDivisionError for division by zero.
    • Catches ValueError for conversion failures or validation checks.
    • Catches TypeError if operations are attempted on incompatible types (like dividing by a string).
  3. Structure: uses else to print success messages and finally to print a completion footer for every step.

Solution:

def process_pipeline(instructions):
    for cmd, val in instructions:
        print(f"Action: {cmd} on {val}")
        
        try:
            result = None
            
            if cmd == "div_100":
                # Can raise ZeroDivisionError or TypeError (if input is string)
                result = 100 / val
                
            elif cmd == "parse_int":
                # Can raise ValueError (if input is "hello")
                result = int(val)
                
            elif cmd == "verify_positive":
                # Defensive coding: Custom logic check
                if val <= 0:
                    raise ValueError(f"Value {val} is not positive")
                result = "Verified"
                
            else:
                # Handle unknown commands
                raise ValueError(f"Unknown command '{cmd}'")
                
        except ZeroDivisionError:
            print("Error: Cannot divide by zero")
            
        except ValueError as e:
            # Captures both int() failures and our custom raised errors
            print(f"Error: {e}")
            
        except TypeError:
            print("Error: Input has incorrect data type")
            
        else:
            # The 'else' block runs ONLY if the try block succeeds.
            # This separates "doing the work" from "reporting the success."
            print(f"Success: {result}")
            
        finally:
            # The 'finally' block runs EVERY time, whether an error occurred or not.
            # Ideal for cleanup tasks or logs.
            print("--- Processing Complete ---\n")

# Example Usage
commands = [
    ("div_100", 20),           # Success
    ("div_100", 0),            # ZeroDivisionError
    ("parse_int", "hello"),    # ValueError (conversion)
    ("verify_positive", -10),  # ValueError (custom raise)
    ("div_100", "50"),         # TypeError (dividing by string)
    ("dance", 5)               # ValueError (unknown command)
]

process_pipeline(commands)

Analysis: This problem highlights the specific utility of the else and finally blocks:

  1. The else block: We place the success print statement here rather than inside the try block. This is best practice because it ensures we only catch errors that occur during the calculation, not errors that might occur during the printing or result handling.
  2. The finally block: Notice in the output that “— Processing Complete —” appears for every command. Without finally, we would have to duplicate this print statement inside the try block and every single except block to guarantee it runs. finally simplifies code maintenance.