how to handle Exceptions in programs


preview

I have these tests by the end of the chapter

exceptions/tests/test_exceptions.py
 1import src.exceptions
 2import unittest
 3
 4
 5class TestExceptions(unittest.TestCase):
 6
 7    @staticmethod
 8    def assert_raises(code, exception):
 9        try:
10            exec(code)
11        except exception:
12            pass
13        else:
14            raise AssertionError(f'{exception} not raised')
exceptions/tests/test_exceptions.py
82    def test_catching_exceptions_w_messages(self):
83        with self.assertRaisesRegex(
84            Exception, 'BOOM!!!'
85        ):
86            src.exceptions.raise_exception()
87
88    def test_catching_failure(self):
89        self.assertEqual(
90            src.exceptions.an_exception_handler(
91                src.exceptions.raise_exception
92            ),
93            'failed'
94        )
exceptions/tests/test_exceptions.py
 96    def test_catching_success(self):
 97        self.assertEqual(
 98            src.exceptions.an_exception_handler(
 99                src.exceptions.function_name
100            ),
101            'succeeded'
102        )
103
104
105# Exceptions seen
106# AssertionError
107# ModuleNotFoundError
108# NameError
109# AttributeError
110# TypeError
111# SyntaxError
112# IndexError
113# KeyError
114# ZeroDivisionError

requirements

how to test that an Exception is raised


open the project

  • I change directory to the exceptions folder

    cd exceptions
    
  • I use pytest-watcher to run the tests

    uv run pytest-watcher . --now
    

    the terminal is my friend, and shows

    rootdir: .../pumping_python/exceptions
    configfile: pyproject.toml
    collected 8 items
    
    tests/test_exceptions.py ........                      [100%]
    
    ==================== 8 passed in X.YZs ======================
    
  • I hold ctrl on the keyboard, then click on tests/test_exceptions.py to open it


test_catching_exceptions_w_messages

RED: make it fail


  • I add a failing test to tests/test_exceptions.py

    77    def test_catching_exceptions(self):
    78        self.assert_raises('raise Exception', Exception)
    79        with self.assertRaises(Exception):
    80            raise Exception
    81
    82    def test_catching_exceptions_w_messages(self):
    83        src.exceptions.raise_exception()
    84
    85
    86# Exceptions seen
    

    the terminal is my friend, and shows AttributeError

    AttributeError: module 'src.exceptions'
                    has no attribute 'raise_exception'
    

GREEN: make it pass


  • I add the name to src/exceptions/__init__.py

    1def function_name():
    2    return None
    3
    4
    5raise_exception
    

    the terminal is my friend, and shows NameError

    NameError: name 'raise_exception' is not defined
    
  • I point it to None to define it

    5raise_exception = None
    

    the terminal is my friend, and shows TypeError

    TypeError: 'NoneType' object is not callable
    
  • I make raise_exception a function to make it callable

    5def raise_exception():
    6    return None
    

    the test passes.


REFACTOR: make it better



how to test the message of an Exception

I can use the assertRaisesRegex method to test what message I get with an Exception. It helps tell the difference between two Exceptions with the same name.


RED: make it fail


I change assertRaises to assertRaisesRegex in test_catching_exceptions_w_messages in tests/test_exceptions.py

82    def test_catching_exceptions_w_messages(self):
83        with self.assertRaisesRegex(
84            Exception, 'BOOM!!!'
85        ):
86            src.exceptions.raise_exception()

the terminal is my friend, and shows AssertionError

AssertionError: "BOOM!!!" does not match ""

because the Exception raised by the raise_exception function has no message and the assertRaisesRegex method checks that the code in its context (src.exceptions.raise_exception()) raises the Exception it is given, with the message it is given ('BOOM!!!').

The default message of the Exception is the empty string ('') and the assertion expects "BOOM!!!"


GREEN: make it pass


I add the expected message in src/exceptions/__init__.py

5def raise_exception():
6    raise Exception('BOOM!!!')

the test passes.


REFACTOR: make it better


This means I can make my assert_raises method have a message like unittest.TestCase.assertRaises.

  • I add an f-string to my assert_raises method in tests/test_exceptions.py

     7    @staticmethod
     8    def assert_raises(code, exception):
     9        try:
    10            exec(code)
    11        except exception:
    12            pass
    13        else:
    14            raise AssertionError(f'{exception} not raised')
    15
    16    def test_catching_module_not_found_error(self):
    
  • I change the statement in test_catching_module_not_found_error to make the test fail

    16    def test_catching_module_not_found_error(self):
    17        self.assert_raises(
    18            # 'import does_not_exist', ModuleNotFoundError
    19            'import src.exceptions', ModuleNotFoundError
    20        )
    21        with self.assertRaises(ModuleNotFoundError):
    22            import does_not_exist
    23
    24    def test_catching_name_error(self):
    

    the terminal is my friend, and shows AssertionError

    AssertionError: <class 'ModuleNotFoundError'> not raised
    

    it is now closer to the message of the assertRaises method, just not as good.

  • I undo the change to test_catching_module_not_found_error

    16def test_catching_module_not_found_error(self):
    17    self.assert_raises(
    18        'import does_not_exist', ModuleNotFoundError
    19    )
    20    with self.assertRaises(ModuleNotFoundError):
    21        import does_not_exist
    22
    23def test_catching_name_error(self):
    

    the test is green again.

  • I add a git commit message

    git commit -am 'add test_catching_exceptions_w_messages'
    

In some cases I want to send a message to the user instead of the Exception which they may not understand.

I might also want the program to make a decision if an Exception happens so it continues without stopping.

I want the program to process its input and return failed if an Exception is raised while processing the input or return success if an Exception is NOT raised.

Exception

output

raised

failed

NOT raised

success

test_catching_failure

RED: make it fail


I add a new test for if a function is called and an Exception is raised to tests/test_exceptions.py

Exception

output

raised

failed

82      def test_catching_exceptions_w_messages(self):
83          with self.assertRaisesRegex(
84              Exception, 'BOOM!!!'
85          ):
86              src.exceptions.raise_exception()
87
88      def test_catching_failure(self):
89          self.assertEqual(
90              src.exceptions.an_exception_handler(
91                  src.exceptions.raise_exception
92              ),
93              'failed'
94          )

the terminal is my friend, and shows AttributeError

AttributeError: module 'src.exceptions'
                has no attribute 'an_exception_handler'

GREEN: make it pass


  • I add an_exception_handler to src/exceptions/__init__.py

    5def raise_exception():
    6    raise Exception('BOOM!!!')
    7
    8
    9an_exception_handler
    

    the terminal is my friend, and shows NameError

    NameError: name 'an_exception_handler' is not defined
    
  • I point an_exception_handler to None to define it

    9an_exception_handler = None
    

    the terminal is my friend, and shows TypeError

    TypeError: 'NoneType' object is not callable
    
  • I make an_exception_handler a function

     9def an_exception_handler():
    10    return None
    

    the terminal is my friend, and shows TypeError

    TypeError: an_exception_handler() takes
               0 positional arguments but 1 was given
    
  • I make an_exception_handler take input

     9def an_exception_handler(the_input):
    10    return None
    

    the terminal is my friend, and shows AssertionError

    AssertionError: None != 'failed'
    

    the result of the call to src.exceptions.an_exception_handler is None and the assertion expects 'failed'.

  • I change the return statement to match the expectation

     9def an_exception_handler(the_input):
    10    return 'failed'
    

    the test passes.

  • I add a git commit message


test_catching_success

RED: make it fail


I add a test for if an_exception_handler is called and an Exception is NOT raised, in tests/test_exceptions.py

Exception

output

NOT raised

success

 88    def test_catching_failure(self):
 89        self.assertEqual(
 90            src.exceptions.an_exception_handler(
 91                src.exceptions.raise_exception
 92            ),
 93            'failed'
 94        )
 95
 96    def test_catching_success(self):
 97        self.assertEqual(
 98            src.exceptions.an_exception_handler(
 99                src.exceptions.function_name
100            ),
101            'succeeded'
102        )
103
104
105# Exceptions seen

the terminal is my friend, and shows AssertionError

AssertionError: 'failed' != 'succeeded'

src.exceptions.an_exception_handler always returns 'failed', the assertion expects 'succeeded'


GREEN: make it pass


  • I make an_exception_handler return its input

     9def an_exception_handler(the_input):
    10    return the_input
    11    return 'failed'
    

    the terminal is my friend, and shows AssertionError

    ...test_catching_failure - AssertionError:
            <function raise_exception at 0xabcd12e34567> != 'failed'
    ...test_catching_success - AssertionError:
            <function function_name at 0xfecdb8a7f6e5> != 'succeeded'
    

    both tests fail because an_exception_handler returns the name and address in the computer of the function it receives.

  • I change the name of the input parameter to make it clearer

     9def an_exception_handler(a_function):
    10    return a_function
    11    return 'failed'
    
  • I make an_exception_handler return the result of a call to its input

     9def an_exception_handler(a_function):
    10    return a_function()
    11    return 'failed'
    

    the terminal is my friend, and shows Exception and AssertionError

    FAILED ...test_catching_failure -
        Exception: BOOM!!!
    FAILED ...test_catching_success -
        AssertionError: None != 'succeeded'
    

    because if an_exception_handler is called, it calls the input it receives

    • If the call to its input raises an Exception, the program stops

      src.exceptions.an_exception_handler(
          src.exceptions.raise_exception
      )
      └── def an_exception_handler(a_function):
          └── return a_function()
              └── def raise_exception():
                  └── raise Exception('BOOM!!!')
      
    • If the call to its input does NOT raise an Exception, it returns the result of the call to its input

      src.exceptions.an_exception_handler(
          src.exceptions.function_name
      )
      └── def an_exception_handler(a_function):
          └── return a_function()
              └── def function_name():
                  └── return None
      
  • I add a try statement to an_exception_handler to make it choose what to do if an Exception is raised, in src/exceptions/__init__.py

     9def an_exception_handler(a_function):
    10    try:
    11        return a_function()
    12    except Exception:
    13        return 'failed'
    

    test_catching_failure passes. The terminal still shows AssertionError for test_catching_success

    AssertionError: None != 'succeeded'
    

    because an_exception_handler returns the result of calling the function_name function which is None.

  • I add else to the try statement for if a_function() runs and does NOT raise an Exception, to make it clearer

     9def an_exception_handler(a_function):
    10    try:
    11        a_function()
    12    except Exception:
    13        return 'failed'
    14    else:
    15        return None
    

    the terminal still shows AssertionError.

  • I change the return statement in the else clause to give the test what it wants

     9def an_exception_handler(a_function):
    10    try:
    11        a_function()
    12    except Exception:
    13        return 'failed'
    14    else:
    15        return 'succeeded'
    

    the test passes.

  • I can be more explicit with the Exception in the except block

     9def an_exception_handler(a_function):
    10    try:
    11        a_function()
    12    # except Exception:
    13    except ModuleNotFoundError:
    14        return 'failed'
    15    else:
    16        return 'succeeded'
    

    the terminal is my friend, and shows Exception for test_catching_failure

    Exception: BOOM!!!
    

    because Exception is not ModuleNotFoundError and I cannot use a child Exceptions to catch its parent Exception.

    The try statement only catches the Exception given in the except clause and its children (subclasses), all other Exceptions are raised.

  • I change it back to what works

     9def an_exception_handler(a_function):
    10    try:
    11        a_function()
    12    except Exception:
    13        return 'failed'
    14    else:
    15        return 'succeeded'
    

    the test is green again!

  • I add a git commit message


The try statement is used to catch or handle Exceptions in Python. It allows the program to choose what to do if it runs into an Exception. I think of it as

  • try something

  • except Exception - if something raises Exception, run the code in this block

  • else - something does NOT raise Exception, run the code in this block

In this case

  • try a_function()

    def an_exception_handler(a_function):
    └── try:
        └── a_function()
        ...
    
  • except Exception - if a_function() raises Exception return 'failed'

    src.exceptions.an_exception_handler(
        src.exceptions.raise_exception
    )
    └── def an_exception_handler(a_function):
        └── try:
            └── a_function()
                └── def raise_exception():
        ┌───────────┴── raise Exception('BOOM!!!')
        └── except Exception:
            └── return 'failed'
            else:
                return 'succeeded'
    
  • else - if a_function() does NOT raise Exception return 'succeeded'

    src.exceptions.an_exception_handler(
        src.exceptions.function_name
    )
    └── def an_exception_handler(a_function):
        └── try:
        ┌───┴── a_function()
               └── def function_name():
                   └── return None
           except Exception:
               return 'failed'
        └── else:
            └── return 'succeeded'
    

The try statement is how I think of Test Driven Development or the scientific method

  • Try something

  • if it fails, try something else

  • do this as many times as you can until you get what you want

or in the words of a famous singer …


close the project

  • I close src/exceptions/__init__.py and tests/test_exceptions.py

  • I click in the terminal where the tests are running

  • I use q on the keyboard to leave the tests. The terminal goes back to the command line.

  • I change directory to the parent of exceptions

    cd ..
    

review

I ran tests to show that


How many questions can you answer after going through this chapter?


code from the chapter

Do you want to see all the CODE I typed in this chapter?


what is next?

Would you like to test making a Person with loops?


rate pumping python

If this has been a 7 star experience for you, please CLICK HERE to leave a 5 star review of pumping python. It helps other people get into the book too.