User-Defined Exceptions and Logging in Python

User-Defined Exceptions and Logging in Python

Exception Handling in Python: Custom Exceptions and Logging Best Practices

When dealing with application-specific fault scenarios, Python not only provides a comprehensive collection of built-in exceptions but also allows you to define your custom exceptions. Logging exceptions is also a good practice to guarantee effective error handling and debugging. In this post, we'll look at both user-defined exceptions and how to efficiently report exceptions in Python.

User-Defined Exceptions

Define a custom exception class

User-defined exceptions are custom exception classes created by inheriting from Python's basic 'Exception' class or any of its subclasses. These custom exceptions assist you in dealing with error scenarios that are peculiar to your application.

class MyCustomException(Exception):
    def __init__(self, message):
        super().__init__(message)

Raise the custom exception

When you encounter an error condition in your code, you can raise your custom exception using the raise keyword. You can include a custom error message to provide information about the exception

def some_function(x):
    if x < 0:
        raise MyCustomException("Input should be a positive number.")
    return x * 2

Handle the custom exception

A try and except block may be used to handle the custom exception. In this block, you can catch the specified exception type and take the following actions

try:
    result = some_function(-5)
except MyCustomException as e:
    print(f"Caught an exception: {e}")
else:
    print(f"Result: {result}")

If some_function(-5) is called, it raises a MyCustomException, executing the code inside the except block and printing the error message, while some_function(10) does not raise an exception.

Example 1: Managing bank accounts

Building a library for managing bank accounts ensures balances are never negative, raising a custom exception called InsufficientFundsException if withdrawals would result in a negative balance.

class InsufficientFundsException(Exception):
    def __init__(self, account_number, balance, amount):
        self.account_number = account_number
        self.balance = balance
        self.amount = amount
        super().__init__(f"Account {account_number} has insufficient funds. "
                         f"Balance: {balance}, Withdrawal amount: {amount}")


class BankAccount:
    def __init__(self, account_number, initial_balance):
        self.account_number = account_number
        self.balance = initial_balance

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("Withdrawal amount must be greater than 0.")
        if self.balance < amount:
            raise InsufficientFundsException(self.account_number, self.balance, amount)
        self.balance -= amount

# Example usage
account1 = BankAccount("12345", 1000)

try:
    account1.withdraw(1500)  # This will raise InsufficientFundsException
except InsufficientFundsException as e:
    print(e)

try:
    account1.withdraw(500)  # This will succeed
except InsufficientFundsException as e:
    print(e)
else:
    print(f"Withdrawal successful. New balance: {account1.balance}")

The BankAccount class raises a custom exception InsufficientFundsException when a withdrawal results in insufficient funds, providing informative error messages and allowing for handling the exception in a try and except block.

Logging Exceptions

Exception logging is an essential technique in software development. Logging allows you to capture information about errors that occur in your program, which is useful for troubleshooting and debugging.

Import the logging module

Begin by importing the 'logging' module, which contains logging methods and classes in Python.

import logging

Configure the logging settings (optional)

You can tailor the logging settings to your requirements. You can, for example, change the log level, select a log file, or specify the log type.

logging.basicConfig(
    filename='app.log',  # Specify a log file
    level=logging.ERROR,  # Set the log level to ERROR or higher
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)

This option will report only error-level and above messages to the app.log file in the format provided.

Wrap your code in a try-except block

Use a 'try' and 'except' block to surround the code that you wish to monitor for errors. Log the exception [ information in the 'except' block.

try:
    # Code that may raise an exception
except Exception as e:
    # Log the exception
    logging.error(f"An exception occurred: {str(e)}")

If you wish to treat distinct errors differently, you may catch a specific exception type, such as except 'MyCustomException as e:'.

Add log messages

You can report extra information about the exception, such as the stack trace or any relevant context, inside the unless block.

try:
    # Code that may raise an exception
except Exception as e:
    # Log the exception and additional information
    logging.error(f"An exception occurred: {str(e)}", exc_info=True)

The exc_info=True parameter will provide the stack trace of the exception in the log.

Use different log levels

Depending on the severity of the error, you can utilize different log levels (DEBUG, INFO, WARNING, ERROR, CRITICAL). For example, for significant mistakes, use logging.error() and logging.warning() for less serious concerns.

import logging

# Configure the logging settings
logging.basicConfig(
    filename='app.log',
    level=logging.DEBUG,  # Set the lowest log level to DEBUG
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)

class CustomException(Exception):
    pass

def perform_operation(value):
    try:
        result = 10 / value  # Example operation that may raise an exception
        logging.debug("Operation succeeded: %s", result)
    except ZeroDivisionError:
        logging.warning("Division by zero occurred.")
        result = None  # Provide a fallback value or behavior
    except Exception as e:
        logging.error("An unexpected error occurred: %s", str(e))
        raise

    return result

def higher_level_function():
    try:
        result = perform_operation(0)
        if result is None:
            logging.warning("Fallback behavior: Unable to perform the operation.")
        else:
            logging.info("Result of the operation: %s", result)
    except CustomException as e:
        logging.error("Custom exception handled: %s", str(e))
    except Exception as e:
        logging.critical("An unexpected error occurred in higher_level_function: %s", str(e))

if __name__ == "__main__":
    try:
        higher_level_function()
    except Exception as e:
        logging.critical("An error occurred in the main application: %s", str(e))

Handle exceptions as needed

Depending on the needs of your application, you may opt to handle errors graciously (for example, by offering a fallback behavior) or propagate them up the call stack.

import logging

class CustomException(Exception):
    pass

def perform_operation(value):
    try:
        result = 10 / value  # Example operation that may raise an exception
    except ZeroDivisionError:
        logging.error("Division by zero occurred.")
        result = None  # Provide a fallback value or behavior
    except Exception as e:
        logging.error(f"An unexpected error occurred: {str(e)}")
        raise  # Propagate the exception up the call stack for higher-level code to handle

    return result

def higher_level_function():
    try:
        result = perform_operation(0)  # Call the function that may raise an exception
        if result is None:
            print("Fallback behavior: Unable to perform the operation.")
        else:
            print(f"Result of the operation: {result}")
    except CustomException as e:
        print(f"Custom exception handled: {str(e)}")
    except Exception as e:
        print(f"An unexpected error occurred in higher_level_function: {str(e)}")

if __name__ == "__main__":
    try:
        higher_level_function()  # Call the higher-level function
    except Exception as e:
        logging.error(f"An error occurred in the main application: {str(e)}")

The assert Statement

The assert statement in Python checks if a condition is True, raising an AssertionError exception if False, used for debugging during development and testing, but not for production error handling.

assert condition, message
  • condition is the expression to check.

  • message (optional) provides additional error information.

def divide(a, b):
    assert b != 0, "Division by zero is not allowed"
    return a / b

result = divide(10, 2)  # No error
result = divide(10, 0)  # AssertionError with the specified message
Summary
User-defined exceptions allow you to gracefully handle application-specific mistakes while recording exceptions with Python's logging module allows you to collect fault data for debugging and maintenance. Combining these strategies strengthens and maintains the resilience and maintainability of your Python programs.