Introduction to Python Programming - Errors and Exceptions
Published: 2024-02-23 19:40:07

## Table of Contents

  - [Table of Contents](#table-of-contents)
  - [Introduction](#introduction)
  - [Common Exceptions](#common-exceptions)
    - [AssertionError](#assertionerror)
    - [AttributeError](#attributeerror)
    - [ImportError](#importerror)
    - [ModuleNotFoundError](#modulenotfounderror)
    - [IndexError](#indexerror)
    - [KeyError](#keyerror)
    - [KeyboardInterrupt](#keyboardinterrupt)
    - [NameError](#nameerror)
    - [IndentationError](#indentationerror)
    - [TypeError](#typeerror)
    - [ValueError](#valueerror)
    - [ZeroDivisionError](#zerodivisionerror)
    - [File Errors](#file-errors)
  - [Handling Exceptions](#handling-exceptions)
  - [Including an Else Clause](#including-an-else-clause)
  - [Adding a Finally Clause](#adding-a-finally-clause)
  - [Raising Exceptions](#raising-exceptions)
  - [Adding More Information to Exceptions](#adding-more-information-to-exceptions)
  - [User-Defined Exceptions](#user-defined-exceptions)
  - [Summary](#summary)

## Introduction
<a id="introduction"></a>

As you write code, errors may occur due to wrong syntax or program execution. The former is known as a syntax error, the latter exception. An unhandled exception will cause your program to crash. Therefore, it is necessary to understand and handle them.

This article focuses on exceptions. Syntax errors are already visible mistakes.

## Common Exceptions
<a id="common-exceptions"></a>

Some exceptions are visible to the user. These include `NameError`, `KeyError`, `IndexError`, and so on. Other exceptions are not. These include but are not limited to `StopIteration` and `GeneratorExit` exceptions.

Below are some of the exceptions you are likely to encounter.

### AssertionError
<a id="assertionerror"></a>

When an `assert` statement fails, it raises an assertion error. For example:

```python
assert (4 + 5) == 8
```

```cmd
Traceback (most recent call last):
  File "C:\articles\python\introduction\codes\exceptions.py", line 1, in <module>
    assert (4 + 5) == 8
AssertionError
```

You can customize the message associated with an assertion error. For example:

```python
assert 7 == 8, 'Not sure 7 is equal to 8'
```

```cmd
Traceback (most recent call last):
  File "C:\articles\python\introduction\codes\exceptions.py", line 3, in <module>
    assert 7 == 8, 'Not sure 7 is equal to 8'
AssertionError: Not sure 7 is equal to 8
```

Use the assert statement to ensure your function meets a particular condition during unit testing with a package such as [pytest](https://docs.pytest.org/en/8.0.x/).

### AttributeError
<a id="attributeerror"></a>

Attribute errors occur when you try to access a property (or method) that does not exist in an object. For example:

```python
class Person:
    pass

person = Person()
person.age
```

```cmd
Traceback (most recent call last):
  ...
AttributeError: 'Person' object has no attribute 'age'
```

Another example

```python
# Empty dictionary
records = {}

# Works! items() method exist
records.items()

# Throws as AttributeError exception
records.age()
```

```cmd
Traceback (most recent call last):
  ...
AttributeError: 'dict' object has no attribute 'age'
```

Ensure you are using attributes that exist to avoid this error.

### ImportError
<a id="importerror"></a>

An import error usually occurs when you try to import a non-existent variable, function, or module from another module. For example, try to import `Fish` from the abstract base class (`abc`) module:

```python
from abc import Fish
```

```cmd
...
ImportError: cannot import name 'Fish' from 'abc'
...
```

### ModuleNotFoundError
<a id="modulenotfounderror"></a>

A `ModuleNotFoundError`  is a subclass of `ImportError`.  When a module does not exist or is not reachable, Python raises a `ModuleNotFoundError`. For example, I don't have a module called `sky`:

```python
import sky
```

```cmd
...
ModuleNotFoundError: No module named 'sky'
```

The solution is to ensure that a file `sky.py` exists within your project directory.

### IndexError
<a id="indexerror"></a>

An `IndexError` is raised when you supply a sequence (e.g. list, tuple, etc.) with an index that is out of range. Slice indices are silently truncated to fall in the allowed range. For example:

```python
favorites = ['Peter', 'Stewie', 'Brian', 'Quagmire', 'Joe']

# These works
print(favorites[1]) # Stewie
print(favorites[1:2000])
print(favorites[1000:2000]) # []

# This fails: index is from 0 - 4
print(favorites[10])
```

```cmd
Stewie
['Stewie', 'Brian', 'Quagmire', 'Joe']
[]

Traceback (most recent call last):
...
IndexError: list index out of range
```

### KeyError
<a id="keyerror"></a>

A KeyError occurs when you try to access a dictionary key that does not exist. For example:

```python
oldest_person = { 'name': 'Methuselah Doe', 'age':  1000 }

# Ok!
print(oldest_person['name'])

# Oops!
print(oldest_person['occupation'])
```

```cmd
Traceback (most recent call last):
...
KeyError: 'occupation'
```

To avoid `KeyError`, use the dictionary's `.get()` method. `get()` returns `None` for a non-existent key. For example:

```python
print(oldest_person.get('occupation')) # None
```

Also, `.get()` optionally takes a default value that gets returned when the key does not exist. For example:

```python
print(oldest_person.get('occupation', 'Musician')) # Musician
```

### KeyboardInterrupt
<a id="keyboardinterrupt"></a>

When you hit the interrupt key (Control-C or Delete), Python raises a keyboardinterrupt exception. For example:

```python
# Simulate a long-running program
import time

while True:
    print(time.time())
    
    # add some delay
    time.sleep(1)
```

```cmd
1707814961.0006871
1707814962.0126708
1707814963.0137038
1707814964.014804
Traceback (most recent call last):
  ...
KeyboardInterrupt
^C
```

### NameError
<a id="nameerror"></a>

Python raises a `NameError` when you use a global or local name or variable (defined within a function) that you have not created. For example:

```python
print(favorite_food)
```

```cmd
Traceback (most recent call last):
...
NameError: name 'favorite_food' is not defined
```

The solution is to ensure you define a variable before using it. For example:

```python
favorite_food = "Whatever works!"

# Works!
print(favorite_food) 
```

### IndentationError
<a id="indentationerror"></a>

An indentation error is a syntax error that occurs when your code is not properly indented. Thankfully, most code editors will help you avoid this kind of error. In the example below, I inserted two spaces before calling `print()`

```python
  print('Hello')

# IndentationError: unexpected indent
```

### TypeError
<a id="typeerror"></a>

A `TypeError` occurs when you perform an operation or apply a function to an object of inappropriate type. For example, summing two dictionaries or converting an inappropriate string to an integer will raise a `TypeError`.

```python
class Person:
    pass

# Fails!
map(lambda x: x * 2, Person())
# TypeError: 'Person' object is not iterable

# Fails!
print({} + {})
# TypeError: unsupported operand type(s) for +: 'dict' and 'dict'
```

### ValueError
<a id="valueerror"></a>

A `ValueError` exception occurs when an operation or function receives an argument with the right type but an inappropriate value. A classic example is when you try to convert an inappropriate string to an integer.

```python
print(int('abc'))

# ValueError: invalid literal for int() with base 10: 'abc'
```

### ZeroDivisionError
<a id="zerodivisionerror"></a>

A `ZeroDivisionError` exception occurs when you attempt to divide (or modulo) a value by zero. For example:

```python
print(4 / 0)
# ZeroDivisionError: division by zero

print(3 % 0)
# ZeroDivisionError: integer modulo by zero
```

The base class of `ZeroDivisionError` is the `ArithmeticError`. The `ArithmeticError` is also the parent class of `OverflowError`, an exception raised when the result of an arithmetic operation is too large to be represented.

### File Errors
<a id="file-errors"></a>

Other exceptions you might encounter include:

- `FileExistsError`: Occurs when trying to create a file or directory which already exists
- `FileNotFoundError`: Raised when a file or directory is requested but doesn’t exist.

```python
with open('hello.txt', mode='rt', encoding='utf-8') as f:
    print(f.read())

# Side note: The program never gets here
print('I will never get here if hello.txt does not exist')
```

```cmd
...
FileNotFoundError: [Errno 2] No such file or directory: 'hello.txt'
```

Do check the Python documentation for more types of exceptions.

## Handling Exceptions
<a id="handling-exceptions"></a>

To prevent your program from crashing you need to handle possible exceptions. In Python, you use the `try-except` statement to handle exceptions. A classic example is asking for a user's age until he provides the expected value:

```python
while True:
    try:
        age = int(input("Please enter your age: "))
        if age >= 18:
            break
        else:
            print("The age you provided is less than 18!")
    except ValueError:
        print("Oops!  That was no valid number.  Try again...")

```

```cmd
Please enter your age: 12
The age you provided is less than 18!
Please enter your age: weerr
Oops!  That was no valid number.  Try again...
Please enter your age: 45
```

Let's handle the `FileNotFoundError` you saw earlier.

```python
try:
    with open('hello.txt', mode='rt', encoding='utf-8') as f:
        print(f.read())
except FileNotFoundError as e:
    print(e)

# Our program continues because we handled the exception above
print('I will run even if hello.txt does not exist')
print('Me too!')
```

```cmd
[Errno 2] No such file or directory: 'hello.txt'
I will run even if hello.txt does not exist
Me too!
```

A `try` statement can have multiple `except` blocks or clauses. Also, an `except` clause may have many exceptions in a tuple with parenthesis. Here is a contrived example modified from Python documentation:

```python
def divide(a, b):
    return a / b


try:
    f = open('myfile.txt')
    s = f.readline()
    i = int(s.strip())
    divide(10, 0)
except OSError as err:
    print("OS error:", err)
except ValueError:
    print("Could not convert data to an integer.")
except ZeroDivisionError as err:
    print("Zero division error:", err)
except Exception as err:
    print(f"Unexpected {err=}, {type(err)=}")
```

You can shorten the code above to

```python
try:
    f = open('myfile.txt')
    s = f.readline()
    i = int(s.strip())
    divide(10, 0)
except (OSError, ValueError, ZeroDivisionError) as err:
    print(err)
except Exception as err:
    print(f"Unexpected {err=}, {type(err)=}")
```

- A call to `open()` might raise a `FileNotFoundError` or any other exception that is a subclass of `OSError`, so we use `OSError` as a catch-all for these exception types.
- Converting an invalid string to an integer will raise a `ValueError`. We handle that directly.
- A call to `divide(10, 0)` raises a `ZeroDivisionError` exception. We handle the exception. A `try-except` statement can handle any exception raised from a function.
- All built-in, non-system-exiting exceptions are subclasses of the `Exception` class, so we handle any other form of exception that might occur. The `Exception` class derives from the `BaseException` class, the mother of all exception classes.

> Consider using specific exception types such as FileNotFoundError before using the general ones like OSError.

## Including an Else Clause
<a id="including-an-else-clause"></a>

You can add an optional else clause to the `try-except` statement. The else clause must follow all except clauses. Use it for code that must run if the try clause does not raise an exception. For example:

```python
for arg in sys.argv[1:]:
    try:
        f = open(arg, 'r')
    except OSError:
        print('cannot open', arg)
    else:
        print(arg, 'has', len(f.readlines()), 'lines')
        f.close()
```

Using the else clause is better than adding additional code to the try clause because it avoids accidentally catching an exception from another code you did not intend to protect with the `try-except` statement.

## Adding a Finally Clause
<a id="adding-a-finally-clause"></a>

Also, you can include an optional `finally` block if you need to run some form of "clean up" code. Unlike the **else** clause, the code in the **finally** block will always run regardless of an exception. For example:

```python
def divide(x, y):
    try:
        result = x / y
    except TypeError:
        print(f"divide({x}, {y}): You cannot divide {x} by {y}")
    except ZeroDivisionError:
        print(f"divide({x}, {y}): You cannot divide {x} by zero.")
    else:
        # Only runs if the try block succeeds
        print(f"divide({x}, {y}): result is {result}")
    finally:
        # Always runs
        print("executing finally clause")
```

- Dividing with unsupported types such as strings will raise a `TypeError` which is handled in the first `except` block. The `finally` block runs. The program ends.
- The second `except` block catches a `ZeroDivisionError`. The `finally` block runs. The program ends.
- The `else` clause runs when `divide()` succeeds. The `finally` block runs. The program ends.

```python
divide('hello', 'world')
print() # for space
divide(4, 0)
print() # for space
divide(3, 2)
```

```cmd
divide(45, 2): You cannot divide hello by world
executing finally clause

divide(4, 0): You cannot divide 4 by zero.
executing finally clause

divide(3, 2): result is 1.5
executing finally clause
```

## Raising Exceptions
<a id="raising-exceptions"></a>

There are instances where you want to raise exceptions if a user's input does not meet the condition you have specified. You can do so using the `raise` statement. For example:

```python
def divide_only_ints(num1: int, num2: int):
    if isinstance(num1, int) and isinstance(num2, int):
        print(f'num1 / num2 = {num1 / num2 :.2f}')
    else:
        raise TypeError(f'num1 and num2 must be ints!\nGot {num1} and {num2} instead')
    
# Works!
divide_only_ints(4, 3) # num1 / num2 = 1.33

# Fails
divide_only_ints(4.5, 12.3)
```

```cmd
Traceback (most recent call last):
...
TypeError: num1 and num2 must be ints!
Got 4.5 and 12.3 instead
```

> `raise Exception` is the same as `raise Exception()`. You can optionally include a custom message. For example, `raise Exception('My custom message')`.

With the `raise` keyword, you can re-raise or propagate an exception if you don't want to handle it. For example:

```python
try:
    divide_only_ints("a", "b")
except TypeError:
    print('An exception occurred! Sending it your way!')
    raise
```

```cmd
An exception occurred! Sending it your way!
...
TypeError: num1 and num2 must be ints!
Got a and b instead
```

You can also propagate multiple exceptions from an `except` block using a `raise` or `raise ... from` statement. This propagation is known as **exception chaining**. For example:

```python
try:
    divide_only_ints("a", "b")
except TypeError:
    raise ValueError('Just because I can')
```

Or using `raise ... from`:

```python
try:
    divide_only_ints("a", "b")
except TypeError as err:
    raise ValueError('Just because I can') from err
```

```cmd
Traceback (most recent call last):
  ...
TypeError: num1 and num2 must be ints!
Got a and b instead

During the handling of the above exception, another exception occurred:

Traceback (most recent call last):
  ...
ValueError: Just because I can
```

## Adding More Information to Exceptions
<a id="adding-more-information-to-exceptions"></a>

> The section requires Python >= 3.11

Sometimes, you want to add more information to an exception before re-raising it. You can use the exception's `.add_notes()` method for such. For example:

```python
try:
    divide_only_ints("a", "b")
except TypeError as err:
    err.add_note('I used the divide_only_ints() function')
    err.add_note('The left operand was the letter a')
    err.add_note('The right operand was the letter b')
    print('An exception occurred! So Sending it your way!')
    raise
```

```cmd
An exception occurred! So Sending it your way!
Traceback (most recent call last):
  ...
TypeError: num1 and num2 must be ints!
Got a and b instead
I used the divide_only_ints() function
The left operand was the letter a
The right operand was the letter b
```

## User-Defined Exceptions
<a id="user-defined-exceptions"></a>

Finally, you can define your exceptions by deriving from the Exception class directly or indirectly. For example:

```python
class DivideByIntOnlyError(Exception):
    """An example of a user-defined exception."""

    def __init__(self, *args: object) -> None:
        super().__init__(*args)

    def add_note(self, __note: str) -> None:
        print('About to start adding notes for useless logging purposes')
        return super().add_note(__note)


def divide_only_ints(num1: int, num2: int):
    if isinstance(num1, int) and isinstance(num2, int):
        print(f'num1 / num2 = {num1 / num2 :.2f}')
    else:
        raise DivideByIntOnlyError(
            f'num1 and num2 must be ints!\nGot {num1} and {num2} instead')
```

```python
try:
    divide_only_ints("a", "b")
except DivideByIntOnlyError as err:
    err.add_note('I used the divide_only_ints() function')
    err.add_note('The left operand was the letter a')
    err.add_note('The right operand was the letter b')
    print('An exception occurred! So Sending it your way!')
    raise
```

```cmd
About to start adding notes for useless logging purposes
About to start adding notes for useless logging purposes
About to start adding notes for useless logging purposes
An exception occurred! So Sending it your way!
Traceback (most recent call last):
  ...
DivideByIntOnlyError: num1 and num2 must be ints!
Got a and b instead
I used the divide_only_ints() function
The left operand was the letter a
The right operand was the letter b
```

> Note that overriding the `__init__()` and `add_note()` methods within `DivideByIntOnlyError` is unnecessary in most cases. I've included it here for demo purposes only.
> Also note that While you can define `Exception` classes to do anything any other class can, it is best to keep it simple.

## Summary
<a id="summary"></a>

In this article, you saw examples of exceptions like `IndexError` and `KeyError` and how they may occur. You also saw how to handle exceptions using the `try-except` statement with or without `else` or `finally`.  Next, you learned how to raise exceptions, chain them, and add more details to the exceptions. Lastly, you learned how to define your exception by subclassing the `Exception` class.

Thank you for reading.