IS4010: AI-Enhanced Application Development

Week 5: Functions & Error Handling

Brandon M. Greenwell

Welcome to Functions & Error Handling 🔧

Building robust, reusable code 🏗️

  • Functions are the building blocks of maintainable software
  • Error handling makes your applications bulletproof
  • Career relevance: Essential skills for any software development role
  • Code quality: Write once, use everywhere - the foundation of professional development
  • User experience: Graceful error handling prevents crashes and frustrated users

Session 1: Writing clean functions

Why use functions? 🤔

  • The most important reason is the DRY principle: don’t repeat yourself
  • Functions allow us to break down large, complex problems into smaller, logical, and more manageable pieces
  • This makes our code easier to read, test, and debug
  • It also promotes reusability. You can write a function once and use it many times throughout your application
  • Performance: Well-designed functions can be optimized and cached

The anatomy of a Python function 🔍

  • Let’s break down the core components of a modern Python function
  • We use the def keyword, a descriptive name, parameters with type hints, a return type, and a docstring (here we’re using a numpy-style docstring)
  • Professional standard: Type hints help catch bugs and make code self-documenting
  • Documentation: Good docstrings explain the ‘why’, not just the ‘what’
def calculate_area(length: float, width: float) -> float:
    """Calculate the area of a rectangle.

    Parameters
    ----------
    length : float
        The length of the rectangle.
    width : float
        The width of the rectangle.

    Returns
    -------
    float
        The calculated area of the rectangle.
    """
    return length * width

Understanding variable scope 🎯

  • Scope refers to the region of code where a variable is accessible
  • Variables created inside a function have local scope. They only exist within that function
  • Variables created outside of any function have global scope
  • This is a good thing! It prevents functions from accidentally modifying variables they shouldn’t have access to
  • LEGB Rule: Local → Enclosing → Global → Built-in (scope resolution order)
def my_function():
    # secret_number has local scope
    secret_number = 42
    print("Inside the function, the number is", secret_number)

my_function()
# This next line would cause a NameError!
# print("Outside the function, the number is", secret_number)

Function parameters: the basics 📝

  • Parameters are variables that receive values when a function is called
  • Arguments are the actual values passed to the function
  • Positional arguments: Order matters - first argument goes to first parameter
  • Keyword arguments: Use parameter names - order doesn’t matter
  • Best practice: Use keyword arguments for clarity in function calls
def create_user_profile(name: str, age: int, city: str) -> dict:
    """Create a user profile dictionary."""
    return {
        "name": name,
        "age": age,
        "city": city,
        "account_created": "today"
    }

# Positional arguments (order matters)
profile1 = create_user_profile("Alice", 30, "Cincinnati")

# Keyword arguments (order doesn't matter)
profile2 = create_user_profile(city="Columbus", name="Bob", age=25)

Default parameters: making functions flexible 🔧

  • Default parameters provide fallback values when arguments aren’t provided
  • Make your functions more user-friendly and reduce duplicate code
  • Rule: Default parameters must come after non-default parameters
  • Common pattern: Use None as default, then check and assign actual default in function body
  • Gotcha: Never use mutable objects (lists, dicts) as default values
def send_notification(message: str, urgency: str = "normal",
                     channels: list = None) -> dict:
    """Send a notification through specified channels."""
    if channels is None:
        channels = ["email"]  # Safe default assignment

    return {
        "message": message,
        "urgency": urgency,
        "channels": channels,
        "timestamp": "2024-03-15T10:30:00"
    }

# Uses defaults
result1 = send_notification("Server backup complete")

# Overrides defaults
result2 = send_notification("Critical error!", "high", ["email", "slack", "phone"])

Variable-length arguments: *args and **kwargs 🌟

  • *args: Accepts any number of positional arguments as a tuple
  • **kwargs: Accepts any number of keyword arguments as a dictionary
  • Use cases: Building flexible APIs, wrapper functions, decorator patterns
  • Convention: Names args and kwargs are standard (but you can use other names)
  • Order matters: def func(required, *args, **kwargs):
def analyze_user_data(user_id: int, *metrics: str, **options) -> dict:
    """Analyze user data with flexible metrics and options."""
    print(f"Analyzing user {user_id}")
    print(f"Metrics to analyze: {metrics}")  # Tuple of strings
    print(f"Analysis options: {options}")     # Dictionary

    results = {"user_id": user_id, "metrics_count": len(metrics)}

    # Process each metric
    for metric in metrics:
        results[f"{metric}_score"] = 85  # Simulated analysis

    # Apply options
    if options.get("detailed", False):
        results["detailed_breakdown"] = "Available"

    return results

# Flexible function calls
result1 = analyze_user_data(123, "engagement", "retention")
result2 = analyze_user_data(456, "clicks", "views", "shares",
                           detailed=True, format="json")

Working with dictionaries and lists 📊

  • Passing dictionaries: Functions can accept and return complex data structures
  • Dictionary unpacking: Use **dict to pass dictionary as keyword arguments
  • List unpacking: Use *list to pass list items as positional arguments
  • Modifying vs. returning: Be clear about whether you modify in-place or return new data
  • Performance consideration: Large dictionaries are passed by reference, not copied
def update_user_settings(user_data: dict, **new_settings) -> dict:
    """Update user settings, returning a new dictionary."""
    # Create a copy to avoid modifying original
    updated_data = user_data.copy()
    updated_data.update(new_settings)
    return updated_data

def calculate_statistics(numbers: list[float]) -> dict:
    """Calculate basic statistics from a list of numbers."""
    if not numbers:
        return {"error": "Empty list provided"}

    return {
        "count": len(numbers),
        "sum": sum(numbers),
        "average": sum(numbers) / len(numbers),
        "min": min(numbers),
        "max": max(numbers)
    }

# Example usage
user = {"name": "Alice", "theme": "dark", "notifications": True}
preferences = {"theme": "light", "language": "es"}

# Update settings using dictionary unpacking
updated_user = update_user_settings(user, **preferences)

# Work with lists
scores = [85.5, 92.0, 78.5, 96.0, 89.5]
stats = calculate_statistics(scores)

Function design principles 🎨

  • Single Responsibility: Each function should do one thing well
  • Pure functions: Same input → same output, no side effects (when possible)
  • Clear naming: Function names should describe what they do, not how
  • Reasonable length: If a function is over 20-30 lines, consider splitting it
  • Return consistency: Always return the same type, or use Union types with type hints
# GOOD: Single responsibility, clear purpose
def calculate_order_total(items: list[dict]) -> float:
    """Calculate the total cost of items in an order."""
    return sum(item["price"] * item["quantity"] for item in items)

def apply_discount(total: float, discount_percent: float) -> float:
    """Apply a percentage discount to a total."""
    return total * (1 - discount_percent / 100)

def format_currency(amount: float) -> str:
    """Format a number as currency."""
    return f"${amount:.2f}"

# BAD: Multiple responsibilities in one function
def process_order_badly(items, discount, tax_rate):
    total = 0
    for item in items:
        total += item["price"] * item["quantity"]

    discounted = total * (1 - discount / 100)
    with_tax = discounted * (1 + tax_rate / 100)
    formatted = f"${with_tax:.2f}"

    print(f"Order processed: {formatted}")  # Side effect!
    return with_tax  # Returns number, but also prints

AI-assisted refactoring 🤖

  • One of the most powerful uses for an AI partner is refactoring code
  • You can give it a messy, procedural script and ask it to break it down into clean, reusable functions
  • Career skill: Refactoring is a daily activity for professional developers
  • Collaborative process: Use AI suggestions as starting points, then apply your judgment
  • Quality improvement: Focus on readability, testability, and maintainability

Effective refactoring prompts:

"Act as a senior Python developer. Review this script and refactor it into
clean, reusable functions. Each function should:
- Have a single, clear responsibility
- Include type hints and numpy-style docstrings
- Handle edge cases appropriately
- Follow PEP 8 naming conventions"

What to ask for: - Specific function separation suggestions - Error handling improvements - Performance optimization opportunities - Code organization and module structure

Session 2: Building robust code

Why error handling matters 💪

  • User experience: Crashes vs. graceful error messages make the difference
  • Production systems: Unhandled errors can take down entire applications
  • Data integrity: Proper error handling prevents corrupt data
  • Debugging: Good error handling provides clear information about what went wrong
  • Professional development: Error handling separates amateur from professional code

When things go wrong: exceptions 🚨

  • A syntax error is like bad grammar; Python doesn’t even understand the code, so it won’t run
  • A runtime error, or exception, happens while the program is running
  • The code is syntactically correct, but something unexpected occurred
  • Common examples: Dividing by zero, opening nonexistent files, accessing invalid list indices
  • Unhandled exceptions will crash your program and frustrate users
# Syntax error - won't even run
# print("Hello world"  # Missing closing parenthesis

# Runtime errors - code runs until exception occurs
numbers = [1, 2, 3]
print(numbers[10])  # IndexError: list index out of range

result = 10 / 0     # ZeroDivisionError: division by zero

age = int("not a number")  # ValueError: invalid literal for int()

Handling exceptions with try and except 🛡️

  • We can handle these errors gracefully using a try…except block
  • The try block contains the code that might fail
  • The except block contains the code that runs only if an error occurs in the try block
  • Best practice: Catch specific exceptions so you can handle different errors appropriately
  • Never use bare except: Always specify which exceptions you’re catching
def safe_divide(a: float, b: float) -> float | None:
    """Safely divide two numbers, returning None if division fails."""
    try:
        result = a / b
        return result
    except ZeroDivisionError:
        print(f"Cannot divide {a} by zero")
        return None
    except TypeError:
        print("Both arguments must be numbers")
        return None

# Usage examples
print(safe_divide(10, 2))    # 5.0
print(safe_divide(10, 0))    # Cannot divide 10 by zero, returns None
print(safe_divide(10, "x"))  # Both arguments must be numbers, returns None

Python’s exception hierarchy 🌳

  • Exception hierarchy: All exceptions inherit from BaseException
  • Most exceptions inherit from Exception (the ones you typically catch)
  • Common built-in exceptions: Each serves a specific purpose
  • Inheritance matters: Catching Exception catches all its subclasses
  • Order matters: More specific exceptions must come before general ones
# Common exception types and when they occur
def demonstrate_exceptions():
    examples = {
        "ValueError": "int('not_a_number')",
        "TypeError": "'hello' + 5",
        "KeyError": "{'a': 1}['missing_key']",
        "IndexError": "[1, 2, 3][10]",
        "FileNotFoundError": "open('nonexistent.txt')",
        "ZeroDivisionError": "10 / 0",
        "AttributeError": "'hello'.nonexistent_method()"
    }

    for exception_type, code in examples.items():
        print(f"{exception_type}: {code}")

# Multiple exception handling
def robust_data_processing(data: dict, key: str) -> str:
    try:
        value = data[key]           # Could raise KeyError
        number = int(value)         # Could raise ValueError
        result = 100 / number       # Could raise ZeroDivisionError
        return f"Result: {result}"
    except KeyError:
        return f"Key '{key}' not found in data"
    except ValueError:
        return f"Value '{data[key]}' is not a valid number"
    except ZeroDivisionError:
        return "Cannot divide by zero"
    except Exception as e:  # Catch any other unexpected exceptions
        return f"Unexpected error: {e}"

Creating custom exceptions 🎨

  • Custom exceptions: Create your own exception types for specific scenarios
  • Inherit from Exception: Custom exceptions should inherit from Exception or its subclasses
  • Clear naming: Exception names should end with “Error” and describe the problem
  • Add context: Custom exceptions can carry additional information
  • When to use: Domain-specific errors that don’t fit built-in exception types
class InsufficientFundsError(Exception):
    """Raised when account doesn't have enough money for transaction."""
    def __init__(self, balance: float, amount: float):
        self.balance = balance
        self.amount = amount
        self.shortfall = amount - balance
        super().__init__(f"Insufficient funds: need ${amount}, have ${balance}")

class InvalidUserAgeError(Exception):
    """Raised when user age is outside valid range."""
    pass

def withdraw_money(balance: float, amount: float) -> float:
    """Withdraw money from account, raising custom exception if insufficient funds."""
    if amount > balance:
        raise InsufficientFundsError(balance, amount)
    return balance - amount

def validate_user_age(age: int) -> bool:
    """Validate user age, raising custom exception if invalid."""
    if not (13 <= age <= 120):
        raise InvalidUserAgeError(f"Age {age} is not valid (must be 13-120)")
    return True

# Usage with custom exception handling
try:
    new_balance = withdraw_money(50.0, 75.0)
except InsufficientFundsError as e:
    print(f"Transaction failed: {e}")
    print(f"You need ${e.shortfall:.2f} more")

The else and finally clauses 🎯

  • We can add two more optional clauses to our error handling
  • The else block runs only if the try block completes successfully (no exception was raised)
  • The finally block runs no matter what - perfect for cleanup code
  • Best practice: Use finally for resource cleanup (files, network connections, locks)
  • Performance consideration: finally ensures cleanup even if exceptions are re-raised
def process_user_data(filename: str) -> dict:
    """Process user data from file with proper cleanup."""
    file_handle = None
    try:
        file_handle = open(filename, 'r')
        data = file_handle.read()
        parsed_data = eval(data)  # Dangerous! Just for demonstration
        return {"status": "success", "data": parsed_data}
    except FileNotFoundError:
        return {"status": "error", "message": f"File {filename} not found"}
    except SyntaxError:
        return {"status": "error", "message": "Invalid data format in file"}
    else:
        print("File processed successfully - no exceptions occurred")
        return {"status": "success", "message": "Data processed"}
    finally:
        # This ALWAYS runs - exception or not
        if file_handle and not file_handle.closed:
            file_handle.close()
            print("File closed in finally block")

# Demonstrate all clauses
result = process_user_data("user_data.txt")
print(f"Result: {result}")

Context managers: the with statement 🔐

  • Context managers: Automatically handle setup and cleanup operations
  • The with statement: Guarantees cleanup even if exceptions occur
  • Most common use: File operations - automatically closes files
  • Professional standard: Always use with for file operations
  • Under the hood: Uses __enter__ and __exit__ methods
# OLD WAY: Manual file handling (error-prone)
def read_config_old(filename: str) -> dict:
    try:
        file = open(filename, 'r')
        content = file.read()
        return {"content": content}
    except FileNotFoundError:
        return {"error": "File not found"}
    finally:
        if 'file' in locals() and not file.closed:
            file.close()

# NEW WAY: Context manager (automatic cleanup)
def read_config_new(filename: str) -> dict:
    try:
        with open(filename, 'r') as file:
            content = file.read()
            return {"content": content}
    except FileNotFoundError:
        return {"error": "File not found"}
    # File automatically closed here, even if exception occurs!

# Multiple files with context managers
def compare_files(file1: str, file2: str) -> bool:
    try:
        with open(file1, 'r') as f1, open(file2, 'r') as f2:
            return f1.read() == f2.read()
    except FileNotFoundError:
        return False
    # Both files automatically closed

Defensive programming: input validation 🛡️

  • Defensive programming: Write code that protects against invalid inputs
  • Input validation: Check arguments before processing them
  • Early return pattern: Validate first, process later
  • Clear error messages: Help users understand what went wrong
  • Type checking: Use type hints and runtime validation together
def create_user_account(username: str, age: int, email: str) -> dict:
    """Create user account with comprehensive input validation."""

    # Input validation with clear error messages
    if not isinstance(username, str):
        raise TypeError("Username must be a string")

    if not username or len(username.strip()) == 0:
        raise ValueError("Username cannot be empty or only whitespace")

    if len(username) < 3:
        raise ValueError("Username must be at least 3 characters long")

    if not isinstance(age, int):
        raise TypeError("Age must be an integer")

    if not (13 <= age <= 120):
        raise ValueError("Age must be between 13 and 120")

    if not isinstance(email, str) or '@' not in email:
        raise ValueError("Email must be a valid email address")

    # Only process if all validation passes
    return {
        "username": username.strip().lower(),
        "age": age,
        "email": email.lower(),
        "account_id": f"user_{username}_{age}",
        "status": "active"
    }

# Safe usage with validation
try:
    user = create_user_account("Alice123", 25, "alice@example.com")
    print(f"Created user: {user}")
except (TypeError, ValueError) as e:
    print(f"Invalid input: {e}")

Error handling patterns: EAFP vs LBYL 🤔

  • EAFP: “Easier to Ask for Forgiveness than Permission” - try it and handle exceptions
  • LBYL: “Look Before You Leap” - check conditions before attempting operations
  • Python philosophy: Generally prefers EAFP approach
  • Performance: EAFP can be faster when exceptions are rare
  • Choose wisely: Some situations favor one approach over the other
# LBYL Approach: Check first, then act
def get_user_score_lbyl(users: dict, user_id: str) -> float:
    """Get user score using Look Before You Leap pattern."""
    if user_id not in users:
        return 0.0

    user = users[user_id]
    if 'scores' not in user:
        return 0.0

    if len(user['scores']) == 0:
        return 0.0

    return sum(user['scores']) / len(user['scores'])

# EAFP Approach: Try it and handle exceptions
def get_user_score_eafp(users: dict, user_id: str) -> float:
    """Get user score using Easier to Ask Forgiveness pattern."""
    try:
        scores = users[user_id]['scores']
        return sum(scores) / len(scores)
    except KeyError:
        return 0.0  # User or scores key doesn't exist
    except ZeroDivisionError:
        return 0.0  # Empty scores list

# When to use each approach
def demonstrate_patterns():
    users = {
        "alice": {"scores": [85, 92, 78]},
        "bob": {"scores": []},
        "charlie": {}
    }

    # Both approaches handle the same edge cases
    print("LBYL results:", [get_user_score_lbyl(users, uid)
                          for uid in ["alice", "bob", "charlie", "diana"]])
    print("EAFP results:", [get_user_score_eafp(users, uid)
                          for uid in ["alice", "bob", "charlie", "diana"]])

Introducing Lab 05: Functions & Error Handling 🚀

  • This week’s lab will have two parts, mirroring our two sessions
  • Part 1: The refactor challenge - Transform messy code into clean, professional functions
  • Part 2: Bulletproof your code - Add comprehensive error handling to make your code production-ready
  • Skills practiced: Function design, parameter handling, exception management, defensive programming
  • AI assistance: Use your AI partner strategically for both refactoring and error handling strategies

What you’ll accomplish:

# From this (messy, fragile code)
total = 0
for user in users:
    if user.get("age"):
        total += user["age"]
print("Average:", total/len(users))  # Will crash if no users!

# To this (clean, robust functions)
def calculate_average_age(users: list[dict]) -> float | None:
    """Calculate average age with proper error handling."""
    try:
        valid_ages = [user["age"] for user in users
                     if isinstance(user.get("age"), (int, float))]
        return sum(valid_ages) / len(valid_ages) if valid_ages else None
    except (KeyError, TypeError) as e:
        logger.warning(f"Error calculating average age: {e}")
        return None

Lab objectives: Clean code organization, defensive programming patterns, professional error handling

Summary: Professional Python development 🎯

  • Functions: The foundation of maintainable, testable code
  • Error handling: The difference between amateur and professional software
  • Career skills: These concepts appear in every technical interview and code review
  • AI partnership: Use AI to learn patterns, then apply your judgment
  • Next steps: Practice these patterns until they become second nature

Key takeaways for your development journey: - Design functions that do one thing well with clear interfaces - Handle errors proactively rather than reactively - Validate inputs to prevent problems before they occur - Use context managers for automatic resource management - Choose EAFP vs LBYL based on your specific use case - Write code that your future self (and teammates) will thank you for