angle-up arrow-clockwise arrow-counterclockwise arrow-down-up arrow-left at calendar card-list chat check envelope folder house info-circle pencil people person person-plus phone plus question-circle search tag trash x

Create your own Python custom exception classes tailored to your application

17 June 2020 Updated 17 June 2020 by Peter
In Python

Proper exception handling gives you easier to read code but also requires you to think very careful what you want to do.

post main image
https://unsplash.com/@surface

Using exceptions in Python looks easy but it is not. Probably you should study exceptions and exception handling before writing any Python code but TL;DR. There are examples on the internet, unfortunately most are very trivial. Anyway, I researched this and came up with some code I thought I share with you. Leave a comment if you have suggestions.

What is an error and what is an exception, what is the difference? In Python an exception is raised when an error occurs during program execution.The error can for example be a ValueError, ZeroDivisionError.

The advantages of exceptions over status returns can be summarized as follows, see also link below 'Why is it better to throw an exception rather than return an error code?':

  1. Exceptions leaves your code clean of all the checks necessary when testing status returns on every call
  2. Exceptions let you use the return value of functions for actual values
  3. Most importantly: exceptions can't be ignored through inaction, while status returns can

Not all people are that positive, but I am not going to start a war here.

Somewhere in our code we can have an exception handler that can be used e.g. to rollback a database insert. We can also let the exception bubble to the top most code and present an error to the user.

Logging and parameters

If something happens we must make sure that we log the error. To make locating and solving a (coding) problems more easy we we put as much information as possible in our log. I decided I wanted the following information in the log:

  • a traceback
  • the following (optional) parameters:
    • e
      This the value (often) returned by an exception
    • code
      This can be our own error code
    • message
      This is the message we want to show to the user (website visitor, API user)
    • details
      More information that can be helpful to the user, e.g. supplied parameters
    • fargs
      These are the arguments of the function where the exception occured, useful for debugging

An example

Assume we create an application that uses a database class and imap server class. We create our custom Exception handler. It is good practice to have it in a file exceptions.py that we import in our application:

# file: exceptions.py

class AppError(Exception):

    def __init__(self, e=None, code=None, message=None, details=None, fargs=None):
        self.e = e
        self.code = code
        self.message = message
        self.details = details
        self.fargs = fargs

    def get_e(self):
        return self.e

    def get_code(self):
        return self.code

    def get_message(self):
        return self.message

    def get_details(self):
        return self.details

    def __str__(self):
        s_items = []
        if self.e is not None:
            s_items.append('e = {}'.format(self.e))
        if self.code is not None:
            s_items.append('code = {}'.format(self.code))
        if self.message is not None:
            s_items.append('message = {}'.format(self.message))
        if self.details is not None:
            s_items.append('details = {}'.format(self.details))
        if self.fargs is not None:
            s_items.append('fargs = {}'.format(self.fargs))
        return ', '.join(s_items)


class DbError(AppError):
    pass


class IMAPServerError(AppError):
    pass

This is very much like creating normal classes. In this case we pass zero or more named arguments to the exception. We need the __str__() method to let Python return a string with available data that will be stored in the app.log file.If we omit __str__() then app.log only shows: 'exceptions.IMAPServerError'.

The app code:

# file: app.py
 
from exceptions import AppError, DbError, IMAPServerError

import logging

logging.basicConfig(
    format='%(asctime)s - %(levelname)s: %(message)s',
    filename='app.log',
    level=logging.DEBUG
    #level=logging.INFO
)

class A:

    def do_a(self, a):
        # connect to remote system
        b = B()
        b.do_b('unnamed parameter', b=a, c={ 'one': 'first', 'two': 'second' })

        # do something else


class B:

    def do_b(self, a, b=None, c=None):
        fname = 'B.do_b'
        fargs = locals()

        abc = 'not in locals'

        # check values
        if b not in [8, 16]:
            raise IMAPServerError(
                fargs = fargs,
                e = 'Input must be 8 or 16',
                message = 'Input must be 8 or 16, value supplied: {}'.format(b)
            )

        # connect to remote system
        try:
            # simulate something went wrong
            # raise IMAPServerError('error 123')
            if b == 8:
                d = b/0

        except (ZeroDivisionError, IMAPServerError) as e:
            raise IMAPServerError(
                fargs = fargs,
                e = e,
                message = 'Connection to remote system failed. Please check your settings',
                details = 'Connection parameters: username = John'
            ) from e


def run(i):

    a = A()
    a.do_a(i)


def do_run():

    # 7: input error
    # 8: connection error
    # 16: no error
    for i in [7, 8, 16]:

        print('Run with i = {}'.format(i))
        try:
            run(i)
            print('No error(s)')

        except (DbError, IMAPServerError) as e:
            logging.exception('Stack trace')
            print('Error: {}'.format(e.message))
            print('Details: {}'.format(e.details))

    print('Ready')


if __name__ == '__main__':
    do_run()

Note that exceptions are logged using 'logging.exception' to a file app.log. Also we use the Python locals() function to capture the arguments of the function or method where the exception occurred. In the 'connect to remote system' exception, we re-raise with all the parameters we want in the log. Finally, we use 'raise ... from e' construct here. This reveals the ZeroDivisionError as the original cause.

To run, type:

python3 app.py

The result is:

Run with i = 7
Error: Input must be 8 or 16, value supplied: 7
Details: None
Run with i = 8
Error: Connection to remote system failed. Please check your settings
Details: Connection parameters: username = John
Run with i = 16
No error(s)
Ready

and the log file app.log:

2020-06-17 15:29:41,939 - ERROR: Stack trace
Traceback (most recent call last):
  File "app.py", line 71, in do_run
    run(i)
  File "app.py", line 59, in run
    a.do_a(i)
  File "app.py", line 19, in do_a
    b.do_b('unnamed parameter', b=a, c={ 'one': 'first', 'two': 'second' })
  File "app.py", line 38, in do_b
    message = 'Input must be 8 or 16, value supplied: {}'.format(b)
exceptions.IMAPServerError: e = Input must be 8 or 16, message = Input must be 8 or 16, value supplied: 7, fargs = {'fname': 'B.do_b', 'c': {'one': 'first', 'two': 'second'}, 'b': 7, 'a': 'unnamed parameter', 'self': <__main__.B object at 0x7f20aab66748>}
2020-06-17 15:29:41,939 - ERROR: Stack trace
Traceback (most recent call last):
  File "app.py", line 46, in do_b
    d = b/0
ZeroDivisionError: division by zero

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

Traceback (most recent call last):
  File "app.py", line 71, in do_run
    run(i)
  File "app.py", line 59, in run
    a.do_a(i)
  File "app.py", line 19, in do_a
    b.do_b('unnamed parameter', b=a, c={ 'one': 'first', 'two': 'second' })
  File "app.py", line 53, in do_b
    ) from e
exceptions.IMAPServerError: e = division by zero, message = Connection to remote system failed. Please check your settings, details = Connection parameters: username = John, fargs = {'fname': 'B.do_b', 'c': {'one': 'first', 'two': 'second'}, 'b': 8, 'a': 'unnamed parameter', 'self': <__main__.B object at 0x7f20aab66748>}

app.log contains two ERROR entries. One for the 'Input must be 8 or 16' error and one for the 'Connection to remote system failed' error.

Summary

Life could be simple but it is not. Proper exception handling gives you easier to read code but also requires you to think very careful what you want to do. It would be nice if we also can add the arguments of the functions called leading to the function raising the exception but this is more difficult.

Links / credits

How to get value of arguments passed to functions on the stack?
https://stackoverflow.com/questions/6061744/how-to-get-value-of-arguments-passed-to-functions-on-the-stack

Proper way to declare custom exceptions in modern Python?
https://stackoverflow.com/questions/1319615/proper-way-to-declare-custom-exceptions-in-modern-python

The Most Diabolical Python Antipattern
https://realpython.com/the-most-diabolical-python-antipattern/

Why Exceptions Suck (ckwop.me.uk)
https://news.ycombinator.com/item?id=232890

Why is it better to throw an exception rather than return an error code?
https://stackoverflow.com/questions/4670987/why-is-it-better-to-throw-an-exception-rather-than-return-an-error-code

Your API sucks (so I’ll catch all exceptions)
https://dorinlazar.ro/your-api-sucks-catch-exceptions/

Read more:
Exceptions

Leave a comment

Comment anonymously or log in to comment.

Comments

Leave a reply

Reply anonymously or log in to reply.