Custom Exceptions in Python

Week 3: Wednesday Afternoon Session

Introduction to Custom Exceptions

Welcome to our deep dive into custom exceptions in Python! Today we'll explore how to move beyond Python's built-in exception types to create our own, tailored specifically to our applications. This is a powerful tool that will help make your code more robust, maintainable, and communicative.

By the end of this session, you'll understand not just how to create custom exceptions, but when and why they're valuable in real-world applications. We'll explore this topic with lots of practical examples and analogies to cement your understanding.

Review: The Exception Hierarchy

Before we dive into custom exceptions, let's refresh our understanding of Python's exception system. Think of exceptions as Python's way of saying, "Something unexpected happened, and I need to tell you about it."

All Python exceptions inherit from the base BaseException class, but most exceptions you'll work with are subclasses of Exception. Here's a simplified view of the hierarchy:

BaseException
 ├── SystemExit
 ├── KeyboardInterrupt
 ├── GeneratorExit
 └── Exception
      ├── StopIteration
      ├── ArithmeticError
      │    ├── FloatingPointError
      │    ├── OverflowError
      │    └── ZeroDivisionError
      ├── AssertionError
      ├── AttributeError
      ├── BufferError
      ├── EOFError
      ├── ImportError
      │    └── ModuleNotFoundError
      ├── LookupError
      │    ├── IndexError
      │    └── KeyError
      ├── MemoryError
      ├── NameError
      │    └── UnboundLocalError
      ├── OSError
      │    ├── BlockingIOError
      │    ├── ChildProcessError
      │    ├── ConnectionError
      │    │    ├── BrokenPipeError
      │    │    ├── ConnectionAbortedError
      │    │    ├── ConnectionRefusedError
      │    │    └── ConnectionResetError
      │    ├── FileExistsError
      │    ├── FileNotFoundError
      │    ├── InterruptedError
      │    ├── IsADirectoryError
      │    ├── NotADirectoryError
      │    ├── PermissionError
      │    ├── ProcessLookupError
      │    └── TimeoutError
      ├── ReferenceError
      ├── RuntimeError
      │    ├── NotImplementedError
      │    └── RecursionError
      ├── SyntaxError
      │    └── IndentationError
      │         └── TabError
      ├── SystemError
      ├── TypeError
      ├── ValueError
      │    └── UnicodeError
      │         ├── UnicodeDecodeError
      │         ├── UnicodeEncodeError
      │         └── UnicodeTranslateError
      └── Warning

This hierarchy is like a family tree for errors. When you create custom exceptions, you're essentially adding new branches to this tree, typically extending from Exception or one of its subclasses.

Why Create Custom Exceptions?

You might wonder: "With all these built-in exceptions, why would I need to create my own?"

Think of built-in exceptions as generic tools, like a basic set of wrenches. They'll handle common problems, but specialized tasks require specialized tools. Custom exceptions are your specialized tools. Here are compelling reasons to create them:

  1. Domain-Specific Error Handling: Your application likely deals with unique scenarios that aren't covered by generic exceptions.
  2. Improved Code Readability: When someone sees InsufficientFundsError instead of generic ValueError, the intent is immediately clear.
  3. Hierarchical Error Processing: Create exception hierarchies specific to your application domains.
  4. Better Error Recovery: Catch specific exceptions to handle specific error scenarios.
  5. Documentation: Custom exceptions effectively document expected failure modes of your code.

Real-World Analogy: Think of exceptions like warning signs. Built-in exceptions are like generic signs ("Caution" or "Warning"), while custom exceptions are specific signs ("Slippery When Wet" or "High Voltage"). The more specific the sign, the better prepared people are to handle the situation appropriately.

Creating Your First Custom Exception

Creating a custom exception in Python is remarkably simple. At minimum, all you need is to define a new class that inherits from Exception (or any of its subclasses):

class MyCustomError(Exception):
    pass

That's it! You've just created a custom exception. Let's use it:

def some_function(value):
    if value < 0:
        raise MyCustomError("Value cannot be negative")
    return value * 2

# Using the function
try:
    result = some_function(-5)
except MyCustomError as e:
    print(f"Caught an error: {e}")

This will output: Caught an error: Value cannot be negative

But this basic example only scratches the surface. Let's explore more sophisticated approaches.

Adding Context and Functionality to Custom Exceptions

Custom exceptions become more powerful when you add context-specific information and functionality. Let's enhance our custom exception:

class ValueTooSmallError(Exception):
    """Raised when the input value is too small"""
    
    def __init__(self, value, min_value, message=None):
        self.value = value
        self.min_value = min_value
        if message is None:
            message = f"Value {value} is smaller than the minimum allowed value {min_value}"
        super().__init__(message)
    
    def how_much_smaller(self):
        """Returns how much smaller the value is than the minimum"""
        return self.min_value - self.value

Now our exception carries useful context:

def process_positive_number(value):
    if value < 0:
        raise ValueTooSmallError(value, 0)
    return value * 2

try:
    result = process_positive_number(-10)
except ValueTooSmallError as e:
    print(f"Error: {e}")
    print(f"The value is {e.how_much_smaller()} below the minimum")

Output:

Error: Value -10 is smaller than the minimum allowed value 0
The value is 10 below the minimum

This approach gives the exception handler rich information about what went wrong and by how much, enabling more intelligent error recovery.

Creating Exception Hierarchies

Just as Python's built-in exceptions form a hierarchy, you can create hierarchies of custom exceptions. This is particularly useful for large applications with multiple error categories.

Let's build a hierarchy for a banking application:

class BankingError(Exception):
    """Base class for all banking-related errors"""
    pass

class AccountError(BankingError):
    """Errors related to account operations"""
    pass

class TransactionError(BankingError):
    """Errors related to transactions"""
    pass

class InsufficientFundsError(TransactionError):
    """Raised when a transaction would result in a negative balance"""
    
    def __init__(self, account_id, amount_requested, available_balance):
        self.account_id = account_id
        self.amount_requested = amount_requested
        self.available_balance = available_balance
        self.deficit = amount_requested - available_balance
        message = f"Account {account_id} has insufficient funds. " \
                 f"Requested: ${amount_requested}, Available: ${available_balance}"
        super().__init__(message)

class AccountFrozenError(AccountError):
    """Raised when operations are attempted on a frozen account"""
    
    def __init__(self, account_id, freeze_reason=None):
        self.account_id = account_id
        self.freeze_reason = freeze_reason
        message = f"Account {account_id} is frozen"
        if freeze_reason:
            message += f" due to: {freeze_reason}"
        super().__init__(message)

Now we can use these in our banking system:

class BankAccount:
    def __init__(self, account_id, initial_balance=0):
        self.account_id = account_id
        self.balance = initial_balance
        self.frozen = False
        self.freeze_reason = None
    
    def withdraw(self, amount):
        if self.frozen:
            raise AccountFrozenError(self.account_id, self.freeze_reason)
        
        if amount > self.balance:
            raise InsufficientFundsError(self.account_id, amount, self.balance)
        
        self.balance -= amount
        return amount
    
    def freeze_account(self, reason=None):
        self.frozen = True
        self.freeze_reason = reason

# Using our custom exceptions
account = BankAccount("12345", 100)

try:
    account.withdraw(150)
except InsufficientFundsError as e:
    print(f"Transaction failed: {e}")
    print(f"You need ${e.deficit} more to complete this transaction")

# Now freeze the account
account.freeze_account("suspicious activity")

try:
    account.withdraw(50)  # This amount would normally be fine
except AccountFrozenError as e:
    print(f"Transaction failed: {e}")

Output:

Transaction failed: Account 12345 has insufficient funds. Requested: $150, Available: $100
You need $50 more to complete this transaction
Transaction failed: Account 12345 is frozen due to: suspicious activity

The power of this approach is that we can catch exceptions at different levels of specificity:

try:
    # Some banking operation
    account.withdraw(50)
except InsufficientFundsError as e:
    # Handle specifically insufficient funds
    print(f"Please deposit more money. You need ${e.deficit} more.")
except AccountError as e:
    # Handle any account-related error
    print(f"Account issue: {e}")
except BankingError as e:
    # Handle any banking error not caught above
    print(f"Banking system error: {e}")
except Exception as e:
    # Handle any other exception
    print(f"Unexpected error: {e}")

Real-World Analogy: This hierarchical approach is like medical diagnosis. A doctor might first determine you have an infection (general category), then a respiratory infection (more specific), and finally pneumonia (most specific). Each level of specificity enables more targeted treatment.

Best Practices for Custom Exceptions

Creating effective custom exceptions involves more than just subclassing Exception. Here are best practices to follow:

  1. Naming Convention: Always end exception class names with "Error" (e.g., InsufficientFundsError), making it immediately clear it's an exception class.
  2. Choose Appropriate Base Classes: Inherit from the most specific built-in exception that makes sense. If your error is related to values, consider extending ValueError.
  3. Include Useful Context: Store all relevant information that might help diagnose or recover from the error.
  4. Clear Error Messages: Write descriptive error messages that explain what went wrong and possibly how to fix it.
  5. Document Exceptions: Use docstrings to explain when and why your exception might be raised.
  6. Create Exception Hierarchies: For complex applications, develop a hierarchy of exceptions that reflects your application's domain.
  7. Keep Exceptions in a Dedicated Module: For large projects, place all custom exceptions in a dedicated module (e.g., exceptions.py or errors.py).

Example of a well-structured exception module:

# File: app/exceptions.py

"""Custom exceptions for the application."""

class AppError(Exception):
    """Base exception for all application errors."""
    pass

# Database Errors
class DatabaseError(AppError):
    """Base exception for database-related errors."""
    pass

class ConnectionError(DatabaseError):
    """Raised when database connection fails."""
    
    def __init__(self, db_url, message=None):
        self.db_url = db_url
        if message is None:
            message = f"Failed to connect to database at {db_url}"
        super().__init__(message)

# Validation Errors
class ValidationError(AppError):
    """Base exception for data validation errors."""
    pass

class RequiredFieldError(ValidationError):
    """Raised when a required field is missing."""
    
    def __init__(self, field_name, entity_type=None):
        self.field_name = field_name
        self.entity_type = entity_type
        message = f"Required field '{field_name}' is missing"
        if entity_type:
            message += f" for {entity_type}"
        super().__init__(message)

Then in your application code:

from app.exceptions import ConnectionError, RequiredFieldError

def save_user(user_data):
    if 'username' not in user_data:
        raise RequiredFieldError('username', 'User')
    
    try:
        # Database operations...
        pass
    except SomeLibraryDatabaseError:
        # Convert third-party exceptions to our own
        raise ConnectionError("db.example.com")

Converting Between Exception Types

A common pattern in well-designed libraries is to convert between exception types, especially when dealing with third-party libraries. This creates a consistent exception interface for your application, regardless of the underlying libraries used.

import requests
from app.exceptions import NetworkError, APIError, AuthenticationError

def fetch_user_data(user_id):
    """Fetch user data from an external API."""
    try:
        response = requests.get(f"https://api.example.com/users/{user_id}")
        response.raise_for_status()  # Raises HTTPError for bad responses
        return response.json()
    except requests.ConnectionError as e:
        # Convert requests.ConnectionError to our custom NetworkError
        raise NetworkError(f"Failed to connect to API: {e}")
    except requests.HTTPError as e:
        if e.response.status_code == 401:
            raise AuthenticationError("API authentication failed")
        elif e.response.status_code == 404:
            raise APIError(f"User {user_id} not found")
        else:
            raise APIError(f"API error: {e}")
    except requests.RequestException as e:
        # Catch any other requests exceptions
        raise NetworkError(f"Request failed: {e}")
    except ValueError as e:
        # This could happen with response.json() if response isn't valid JSON
        raise APIError(f"Invalid API response: {e}")

This approach creates a clean abstraction that shields the rest of your code from the specific exceptions of the requests library. If you later decide to use a different HTTP library, you only need to update this function, not all the calling code.

Real-World Analogy: This is like having a universal power adapter when traveling internationally. The adapter converts various socket types to the type your devices expect, providing a consistent interface regardless of the country you're in.

Exception Chaining

When converting between exception types, it's often useful to preserve the original exception. Python provides a mechanism for this called exception chaining. This allows you to raise a new exception while keeping track of the original cause.

try:
    # Some operation that might fail
    result = int("not a number")
except ValueError as e:
    # Raise a custom exception, but keep the original as the cause
    raise DataProcessingError("Failed to convert input") from e

When this exception is printed, Python will show both exceptions:

Traceback (most recent call last):
  File "example.py", line 3, in <module>
    result = int("not a number")
ValueError: invalid literal for int() with base 10: 'not a number'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "example.py", line 5, in <module>
    raise DataProcessingError("Failed to convert input") from e
DataProcessingError: Failed to convert input

This provides valuable context for debugging, maintaining the complete chain of what went wrong.

To suppress the original exception (though generally not recommended), you can use:

raise DataProcessingError("Failed to convert input") from None

Testing Custom Exceptions

Testing that your code raises the right exceptions in the right circumstances is an important part of a comprehensive test suite. Here's how to test custom exceptions using pytest:

import pytest
from myapp.exceptions import InsufficientFundsError
from myapp.banking import BankAccount

def test_insufficient_funds_error():
    account = BankAccount("12345", initial_balance=100)
    
    # Test that the right exception is raised
    with pytest.raises(InsufficientFundsError) as excinfo:
        account.withdraw(150)
    
    # Test exception attributes
    assert excinfo.value.account_id == "12345"
    assert excinfo.value.amount_requested == 150
    assert excinfo.value.available_balance == 100
    assert excinfo.value.deficit == 50
    
    # Test the exception message
    assert "insufficient funds" in str(excinfo.value).lower()
    assert "Requested: $150" in str(excinfo.value)

This confirms not only that the right type of exception is raised, but also that it contains the expected information and message.

Real-World Examples of Custom Exceptions

E-commerce System

class EcommerceError(Exception):
    """Base exception for e-commerce system."""
    pass

class InventoryError(EcommerceError):
    """Inventory-related errors."""
    pass

class OutOfStockError(InventoryError):
    """Raised when trying to purchase an out-of-stock item."""
    
    def __init__(self, product_id, requested_quantity, available_quantity):
        self.product_id = product_id
        self.requested_quantity = requested_quantity
        self.available_quantity = available_quantity
        message = f"Product {product_id} is out of stock. " \
                 f"Requested: {requested_quantity}, Available: {available_quantity}"
        super().__init__(message)

class PaymentError(EcommerceError):
    """Payment-related errors."""
    pass

class PaymentDeclinedError(PaymentError):
    """Raised when a payment is declined."""
    
    def __init__(self, payment_id, reason=None, suggestion=None):
        self.payment_id = payment_id
        self.reason = reason
        self.suggestion = suggestion
        message = f"Payment {payment_id} was declined"
        if reason:
            message += f": {reason}"
        super().__init__(message)
        
    def get_user_message(self):
        """Returns a user-friendly message, possibly with a suggestion."""
        message = str(self)
        if self.suggestion:
            message += f" {self.suggestion}"
        return message

# Using these exceptions
def process_order(cart, payment_info):
    try:
        # Check inventory
        for item in cart.items:
            if not inventory.is_available(item.product_id, item.quantity):
                available = inventory.get_quantity(item.product_id)
                raise OutOfStockError(item.product_id, item.quantity, available)
        
        # Process payment
        try:
            payment_result = payment_gateway.charge(payment_info, cart.total)
            if not payment_result.success:
                suggestion = None
                if payment_result.error_code == "INSUFFICIENT_FUNDS":
                    suggestion = "Please try another payment method."
                raise PaymentDeclinedError(
                    payment_result.transaction_id,
                    payment_result.error_message,
                    suggestion
                )
        except payment_gateway.GatewayError as e:
            # Convert third-party exception to our own
            raise PaymentError(f"Payment gateway error: {e}") from e
        
        # Complete order
        order = create_order(cart, payment_result.transaction_id)
        return order
        
    except OutOfStockError as e:
        # Log the error
        log.error(f"Inventory error: {e}")
        # Maybe suggest alternatives
        alternatives = inventory.find_alternatives(e.product_id)
        # Re-raise for the caller to handle
        raise
    except PaymentDeclinedError as e:
        # Display user-friendly message
        return {"error": e.get_user_message()}
    except EcommerceError as e:
        # Handle any other e-commerce errors
        log.error(f"Order processing error: {e}")
        return {"error": "We couldn't process your order. Please try again later."}

Data Processing Pipeline

class DataError(Exception):
    """Base exception for data processing errors."""
    pass

class ValidationError(DataError):
    """Data validation errors."""
    
    def __init__(self, field, value, reason):
        self.field = field
        self.value = value
        self.reason = reason
        message = f"Validation error for field '{field}': {reason}"
        super().__init__(message)

class SchemaError(DataError):
    """Schema-related errors."""
    pass

class MissingFieldError(SchemaError):
    """Raised when a required field is missing."""
    
    def __init__(self, field, schema_name=None):
        self.field = field
        self.schema_name = schema_name
        message = f"Required field '{field}' is missing"
        if schema_name:
            message += f" in schema '{schema_name}'"
        super().__init__(message)

class UnknownFieldError(SchemaError):
    """Raised when an unknown field is present."""
    
    def __init__(self, field, schema_name=None):
        self.field = field
        self.schema_name = schema_name
        message = f"Unknown field '{field}'"
        if schema_name:
            message += f" in schema '{schema_name}'"
        super().__init__(message)

class ProcessingError(DataError):
    """Error during data processing."""
    pass

# Using these exceptions in a data pipeline
def process_dataset(data, schema):
    errors = []
    processed_records = []
    
    for i, record in enumerate(data):
        try:
            # Validate schema
            for field in schema['required']:
                if field not in record:
                    raise MissingFieldError(field, schema['name'])
            
            for field in record:
                if field not in schema['fields']:
                    raise UnknownFieldError(field, schema['name'])
            
            # Validate data
            for field, value in record.items():
                validator = schema['fields'][field].get('validator')
                if validator and not validator(value):
                    raise ValidationError(
                        field, 
                        value, 
                        schema['fields'][field].get('error_message', 'Invalid value')
                    )
            
            # Process record
            try:
                processed = transform_record(record)
                processed_records.append(processed)
            except Exception as e:
                raise ProcessingError(f"Failed to process record: {e}") from e
                
        except DataError as e:
            # Track error with record index
            errors.append({
                'index': i,
                'error': str(e),
                'type': type(e).__name__
            })
            # Continue processing other records
            continue
    
    return {
        'processed': processed_records,
        'errors': errors,
        'success_rate': len(processed_records) / len(data) if data else 0
    }

Practical Exercise: Building a Validation System

Let's put our knowledge into practice by building a simple but robust data validation system using custom exceptions:

class ValidationError(Exception):
    """Base exception for validation errors."""
    pass

class TypeValidationError(ValidationError):
    """Raised when a value has the wrong type."""
    
    def __init__(self, field, expected_type, actual_type):
        self.field = field
        self.expected_type = expected_type
        self.actual_type = actual_type
        message = f"Field '{field}' expected type {expected_type.__name__}, got {actual_type.__name__}"
        super().__init__(message)

class RangeValidationError(ValidationError):
    """Raised when a value is outside the allowed range."""
    
    def __init__(self, field, value, min_value=None, max_value=None):
        self.field = field
        self.value = value
        self.min_value = min_value
        self.max_value = max_value
        
        if min_value is not None and max_value is not None:
            message = f"Field '{field}' must be between {min_value} and {max_value}, got {value}"
        elif min_value is not None:
            message = f"Field '{field}' must be at least {min_value}, got {value}"
        elif max_value is not None:
            message = f"Field '{field}' must be at most {max_value}, got {value}"
        else:
            message = f"Field '{field}' value {value} is out of range"
            
        super().__init__(message)

class PatternValidationError(ValidationError):
    """Raised when a value doesn't match the required pattern."""
    
    def __init__(self, field, value, pattern, description=None):
        self.field = field
        self.value = value
        self.pattern = pattern
        self.description = description
        
        message = f"Field '{field}' value '{value}' does not match the required pattern"
        if description:
            message += f" ({description})"
            
        super().__init__(message)

# Let's build a validator class using our custom exceptions
import re
from typing import Any, Type, Optional, Pattern, Dict, List, Union, Callable

class Validator:
    def validate_type(self, field: str, value: Any, expected_type: Type) -> None:
        """Validate that the value is of the expected type."""
        if not isinstance(value, expected_type):
            raise TypeValidationError(field, expected_type, type(value))
    
    def validate_range(self, field: str, value: Any, 
                       min_value: Optional[Any] = None, 
                       max_value: Optional[Any] = None) -> None:
        """Validate that the value is within the specified range."""
        if min_value is not None and value < min_value:
            raise RangeValidationError(field, value, min_value, max_value)
        if max_value is not None and value > max_value:
            raise RangeValidationError(field, value, min_value, max_value)
    
    def validate_pattern(self, field: str, value: str, 
                         pattern: Union[str, Pattern], 
                         description: Optional[str] = None) -> None:
        """Validate that the string value matches the specified pattern."""
        if not re.match(pattern, value):
            raise PatternValidationError(field, value, pattern, description)
    
    def validate_data(self, data: Dict[str, Any], rules: Dict[str, Dict[str, Any]]) -> List[ValidationError]:
        """Validate all fields according to the specified rules."""
        errors = []
        
        for field, field_rules in rules.items():
            # Skip validation if field is not present and not required
            if field not in data:
                if field_rules.get('required', False):
                    errors.append(ValidationError(f"Required field '{field}' is missing"))
                continue
                
            value = data[field]
            
            # Apply each validation rule
            try:
                # Type validation
                if 'type' in field_rules:
                    self.validate_type(field, value, field_rules['type'])
                
                # Range validation
                if ('min_value' in field_rules or 'max_value' in field_rules) and \
                   isinstance(value, (int, float, str)):
                    self.validate_range(
                        field, value, 
                        field_rules.get('min_value'), 
                        field_rules.get('max_value')
                    )
                
                # Pattern validation
                if 'pattern' in field_rules and isinstance(value, str):
                    self.validate_pattern(
                        field, value, 
                        field_rules['pattern'],
                        field_rules.get('pattern_description')
                    )
                
                # Custom validation
                if 'custom_validator' in field_rules and callable(field_rules['custom_validator']):
                    try:
                        field_rules['custom_validator'](field, value)
                    except ValidationError as e:
                        errors.append(e)
                
            except ValidationError as e:
                errors.append(e)
        
        return errors

# Using our validation system
def validate_user_profile():
    # Define validation rules
    validation_rules = {
        'username': {
            'required': True,
            'type': str,
            'min_value': 3,  # Minimum length
            'max_value': 20,  # Maximum length
            'pattern': r'^[a-zA-Z0-9_]+$',
            'pattern_description': 'alphanumeric characters and underscores only'
        },
        'email': {
            'required': True,
            'type': str,
            'pattern': r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$',
            'pattern_description': 'valid email format'
        },
        'age': {
            'type': int,
            'min_value': 13,
            'max_value': 120
        },
        'website': {
            'type': str,
            'pattern': r'^https?://.+\..+$',
            'pattern_description': 'valid URL starting with http:// or https://'
        }
    }
    
    # Sample user data
    user_data = {
        'username': 'john123',
        'email': 'invalid-email',
        'age': 10,
        'website': 'example.com'  # Missing http://
    }
    
    # Validate the data
    validator = Validator()
    errors = validator.validate_data(user_data, validation_rules)
    
    if errors:
        print(f"Found {len(errors)} validation errors:")
        for i, error in enumerate(errors, 1):
            print(f"{i}. {error}")
    else:
        print("All data is valid!")
        
    return len(errors) == 0

# Run the validation
is_valid = validate_user_profile()

This example demonstrates a complete validation system built around custom exceptions. It shows how exceptions can carry rich context about the validation failures, making it easier to process and display meaningful error messages to users.

Conclusion and Key Takeaways

Custom exceptions are a powerful way to enhance your Python applications. They allow you to:

Remember that well-designed exceptions are a form of communication—they tell users and developers what went wrong, why it went wrong, and often how to fix it. Invest the time to design a thoughtful exception hierarchy, and you'll be rewarded with code that's more robust, maintainable, and user-friendly.

In our next session, we'll explore more advanced Python features, building on the foundations we've established so far. For now, practice creating custom exceptions for your own applications, focusing on making them both informative and helpful.

Assignment: Custom Exception Library

Your task is to create a small library of custom exceptions for a specific domain of your choice (e.g., a file system, a game, a web application, etc.). Your library should include:

  1. A base exception for your domain
  2. At least two categories of exceptions (subclassing from your base exception)
  3. At least two specific exceptions in each category
  4. Context information in each specific exception
  5. A helper method in at least one exception
  6. A simple demo program that raises and handles your custom exceptions

Submit your code as a Python module with appropriate documentation. Be prepared to explain your design decisions in our next session.