# 5.2 General Rules for try-except

## If there is a suitable handler

When an exception is raised,
if there is a try-except around the line of code that raised it,
Python checks each of the `except` clauses, in order, to see if it handles the type of exception that was raised.
The first `except` clause that does will handle the exception: execution will jump to its `except` clause, skipping over any additional lines that may
exist in the `try` block.
Then the program will continue with whatever comes after
the whole try-except statement.

For example, consider what happens in this function if `num2` is 0:

```python
def divide(num1: int, num2: int) -> None:
    try:
        answer = num1 / num2
        print(f'The answer is {answer}')
    except ZeroDivisionError:
        print(f'Cannot divide {num1} by zero!')


if __name__ == '__main__':
    divide(23, 0)
```

The assignment statement `answer = num1 / num2` will raise a `ZeroDivisionError`.
The `except` clause matches it, so we immediately leave the `try` block
and print `Cannot divide 23 by zero!`.
The message saying "The answer is ..." is skipped.

It is possible to end a try-except statement with a "bare" except clause,
that is, one with no specific type of exception named.

```python
    try:
        # Some code goes here.
        pass
    except ZeroDivisionError:
        print('Something went wrong: attempt to divide by zero!')
    except TypeError:
        print('Something went wrong: type error!')
    except:   # No type of exception specified
        print('Something went wrong: I have no idea what!')
```

Similarly, we can use `Exception` as a wildcard that catches (almost)
every kind of exception:

```python
    try:
        # Some code goes here.
        pass
    except ZeroDivisionError:
        print('Something went wrong: attempt to divide by zero!')
    except TypeError:
        print('Something went wrong: type error!')
    except Exception:  # Very broad type of exception specified
        print('Something went wrong: I have no idea what!')
```

In either case, PyCharm will warn us that this is a
"Too broad exception clause".
You might wonder why, since it is similar to an if-statement that has a
final `else` clause with no condition.  While fine for if-statements, this is
considered bad style for exceptions.
It is good practice to be as specific as possible with the types of exceptions that we intend to handle.
If there is a kind of exception that we didn't specifically anticipate,
or we don't have specific code to handle,
we should allow the exception to propagate on to other code
that is better prepared to handle it.


## How could there be more than one `except` clause for a given exception?

Inheritance!

A clause that says `except <X>` will catch
an exception of type X or any descendant of X.
In the example below,
function `nonsense` handles exceptions that are part
of a little inheritance hierarchy:

```python
class TopException(Exception):
    pass

class MiddleException(TopException):
    pass

class BottomException(MiddleException):
    pass

def nonsense(num: int) -> None:
    try:
        if num > 100:
            raise TopException
        elif num < 0:
            raise MiddleException
        elif num == 0:
            raise BottomException
        else:
            print('All is well.')
    except MiddleException:
        print('A MiddleException occurred!')
```

<!--
Makes sense because of "is-a".
Doesn't accept something more general.
Also makes sense; if you can handle a car, you can handle a Toyota or BMW,
but you can't handle a helicopter!
-->

If we call `nonsense(-3)`, a `MiddleException` will be raised
and then caught, and we will see the message `A MiddleException occurred!`.
But a `BottomException` is-a kind of `MiddleException`, so
if we call `nonsense(0)`, the `BottomeException` that is raised
will be caught and handled just the same.
What if we call `nonsense(142)`?
This raises a `TopException`.
Since `TopException` is *not* a kind of `MiddleException`,
the exception is not caught;
instead the stack frame is popped and the exception propogates to the caller.

Suppose we want to write the code so that it can specifically handle
each of these types of exception.
Because of the way that inheritance influences the matching of a raised exception
to an `except` clause, we have to be careful about the order in which
we place the `except` clauses.
For example, here we put `except TopException` first:

```python
def nonsense_v2(num: int) -> None:
    try:
        if num > 100:
            raise TopException
        elif num < 0:
            raise MiddleException
        elif num == 0:
            raise BottomException
        else:
            print('All is well.')
    except TopException:  # Catches all 3 types of exception!
        print('A TopException occurred!')
    except MiddleException:  # Cannot be reached.
        print('A MiddleException occurred!')
    except BottomException:  # Cannot be reached.
        print('A BottomException occurred!')
```

But `except TopException` catches all three of these types of exception, so
neither of the subsequent `except` clauses can ever be reached.
If we want all three `except` clauses to contribute, we must
catch the exceptions in order from most-specific to least-specific:

```python
def nonsense_v3(num: int) -> None:
    try:
        if num > 100:
            raise TopException
        elif num < 0:
            raise MiddleException
        elif num == 0:
            raise BottomException
        else:
            print('All is well.')
    except BottomException:
        print('A BottomException occurred!')
    except MiddleException:
        print('A MiddleException occurred! (and it was not a BottomException)')
    except TopException:
        print('A TopException occurred (and it was not a Bottom or MiddleException)')
```


## If there is no suitable handler

Suppose function `<X>` calls function `<Y>`
and `<Y>` raises an exception.
If there is no try-except around the line of code that raises the exception, or
there is one but it lacks an except clause for the particular kind of exception
raised,
then the stack frame for `<Y>` *immediately* is popped.
We come back to the line
of code in `<X>` that called `<Y>`---and that line of code receives the
exception.
This process continues until either some function on the stack handles
the exception, or the the whole stack has been popped empty. In that case, the
user sees the exception.

Here's an example where the call stack has several frames on it when
an exception may occur:

```python
def f3() -> None:
    x = input('Enter a number: ')
    print(100 / int(x))
    print('That went well')


def f2() -> None:
    f3()


def f1() -> None:
    f2()


if __name__ == '__main__':
    f1()
    print('All done.')
```

When the code reaches `f3`, the stack has on it frames for
the main block (on the bottom), `f1`, `f2`, and `f3` (on the top).
If the user enters either 0 or something other than an integer,
an exception will be raised in `f3`.
Since there is no `try-except` clause, the function will immediately
return, sending the exception to `f2`.
At this point, it's no different than if `f2` had raised the exception itself.
Since `f2` has no `try-except` clause either,
it immediately returns, sending the exception to `f1`,
and `f1` does the same, sending the exception to the main block.
We have just that one frame left on the stack, and it is popped too,
leaving the error message to land in the lap of the user:

```python
Enter a number: no thanks
Traceback (most recent call last):
  File "148-materials/notes/exceptions/code/popall.py", line 16, in <module>
    f1()
  File "148-materials/notes/exceptions/code/popall.py", line 12, in f1
    f2()
  File "148-materials/notes/exceptions/code/popall.py", line 8, in f2
    f3()
  File "148-materials/notes/exceptions/code/popall.py", line 3, in f3
    print(100 / int(x))
ValueError: invalid literal for int() with base 10: 'no thanks'

Process finished with exit code 1
```

Below is a new version where we *handle* both kinds of exception.
We chose to handle any `ZeroDivisionError` in `f2` and any
`ValueError` in `f1`,
but we could have put these handlers anywhere along the chain of function calls.

```python
def f3() -> None:
    x = input('Enter a number: ')
    print(100 / int(x))
    print('That went well')


def f2() -> None:
    try:
        f3()
    except ZeroDivisionError:
        print('In f2 and my call to f3 raised a ZeroDivisionError')


def f1() -> None:
    try:
        f2()
    except ValueError:
        print('In f1 and my call to f2 raised a ValueError')


if __name__ == '__main__':
    f1()
    print('All done.')
```

If the user enters 0, a `ZeroDivisionError` exception is raised and
the frame for `f3` is popped as before, but now in `f2`
there is a handler that takes care of the exception and the program
can continue as if there had never been an exception raised.

```
Enter a number: 0
In f2 and my call to f3 raised a ZeroDivisionError
All done.
```

Or if the user enters something other than an integer,
a `ValueError` is raised in `f3`, the frame for `f3` is popped,
as is the frame for `f2`, since it can't handle that type of exception.
But then we reach `f1`, which has a handler for `ValueError`s.
`f1` catches the exception, and the program continues.

```
Enter a number: hee hee!
In f1 and my call to f2 raised a ValueError
All done.
```


## If an `except` clause itself raises an exception

Here, an `except` caluse itself raises an exception:

```python
    try:
        # Some code goes here.
        pass
    except ZeroDivisionError:
        n = int('ridiculous!')   # Can't be handled by this try-except.
    except TypeError:
        print('Something went wrong: type error!')
```

The `except` clauses in this try-except statement only handle exceptions that occur in *this* `try` clause.
So
Python immediately stops, pops the stack, and passes the exception on to
the code that called this method or function.
That is, unless the try-except is nested inside *another* try-except.
In CSC148, we won't go further into this or some of the other special cases
that can occur.
See the Python documentation if you'd like to learn more.
