Skip to content

Latest commit

 

History

History
560 lines (434 loc) · 16.3 KB

handling-exception-training.md

File metadata and controls

560 lines (434 loc) · 16.3 KB

Python: Handling and Raising Exception

👋 To know more
Python's raise: Effectively Raising Exceptions in Your Code
Python Exceptions: An Introduction
How to Catch Multiple Exceptions in Python
Ever Better Error Messages
LBYL vs EAFP: Preventing or Handling Errors in Python
Following Best Practices When Raising Exceptions
Take the test

Summary

Chapters
What is an Exception
Different ways of handling an Exception
Different ways of raising an Exception
Good practice when handling/raising Exceptions
Annexe

Before starting

  • Version Française 🔹
  • This training is aimed at the backend developer, but everybody is welcome to read it.

What is an Exception

An Exception in Python indicates that something went wrong in your code. It can take the form of either an error (classic case), a warning, or an exceptional situation.

❕ Note that not all exceptions in Python are errors. The best example is the StopIteration object which is a subclass of Exception

When an Exception represents an error, it is common standard to add the Error suffix to its name. Example:

  • ValueError, DivisionByZeroError, etc.

When the exception is supposed to be a Warning, Python offers the class Warning to raise the flag on conditions that don't need to terminate the program.

There are two kinds of exceptions in Python:

  • Built-in exceptions: Those exceptions are built into the language.
  • User-defined exceptions: Custom exceptions defined by the developers. They are typically in a module for a specific project

You can find the built-in exception hierarchy here

Creating a custom Exception example

# Always use Exception for your code
# Do not use BaseException
class MyException(Exception):
    pass

# For examples see error link
class MyExceptionError(Exception):
    pass

# UserWarning is for warning generated by user code
# Warning is the base class for warning categories
class MyWarning(UserWarning):
    pass

In Python, it is common practice to create a placeholder class for exceptions with the keyword pass as the most important feature of the class is its name.

⚠️ In Python, a good custom exception name communicates the underlying issue
GenericException(Exception)
SpecificToProjectException(Exception)

Here is a list of the principal exception's attributes:

Attributes Type Actions
args tuple or str Contain all the value pass at the class at instanciation
__traceback__ dunder attribute hold the traceback object of the exception
__cause__ dunder attribute store the expression passed to the from keyword
.with_traceback() methods Update the exception's traceback object
.add_notes() methods Add notes to the exception traceback. (__note__)

When you instantiate an Exception, you can give it a message (string) or a tuple of messages (tuple of string)

# The usual case
raise Exception("an error occurred")

# With multiple arguments
raise Exception("an error occurred", "unexpected value", 42)

.__tracback__ contains a traceback object

A traceback object is also call a Stack trace, Stack traceback, and backtrace Exceptions have more than sixty dunder attributes.

Link to documentation for different exception types

⚠️ 💥 ⁉️
Warning Error Exception

Different ways of handling an Exception

3 Steps to Handle Exception

  1. Predict what exceptions can happen. You can even fail the program voluntarily to discover what exceptions are raised.
  2. If custom exception, use the raise keyword.
  3. Determine where the exception needs to be handled in your code.

Handling Exception Example

Basic handling of error.

colors = ["red", "blue", "green"]

try:
    colors[10]
except IndexError:
    print("your list doesn't have that index :-(")

You can also handle multiple errors in the same except statement.

colors = ["red", "blue", "green"]

try:
  colors[10]
except (ValueError, IndexError) as error:
  if isinstance(ValueError, error):
    print("this error is a value error")

  if isinstance(IndexError, error):
    print("this is an index error")

When handling you might want to do extra computing before raising the error.

>>> import logging
>>> try:
...     result = 42 / 0
... except Exception as error:
...     logging.error(error)
...     raise

ERROR:root:division by zero
Traceback (most recent call last):
  File "<stdin>", line 5, in <module> # will only appear if 'raise error' was called
  File "<stdin>", line 2, in <module>
ZeroDivisionError: division by zero

Different ways of raising an Exception

The raise keyword

In Python, exceptions are raised, while in other languages like Java and C++, exceptions are thrown

# ...
raise [expression [from another_expression]]
# ...

The from clause is optional.

⚠️ A raise keyword with no argument and no exception raised beforehand will lead to a RuntimeError exception since no exceptions are raised or reraised

The raise keyword can take any expression that returns an exception class or instance

# From a function returning an exception
def exception_factory(exception, message):
  return exception(message)

raise exception_factory(ValueErrpr, "invalid value")

# From a class instantiation
class MyException(Exception)
  pass

raise MyException("an eror occurred")
⚠️ Raising direct instance of Exception is not considered best practice
Always raise custom or built-in exceptions

Raising Custom Exception Example

# grades .py

# Creating our custom exception
class GradeValueError(Exception):
  pass

def calculate_avergae_grade(grades):
  total = 0
  count = 0
  for grade in grades:
    if grade < 0 or grade > 100:
      # Raise our custom exception
      raise GradeValueError(
        "grade values must be between 0 and 100 inclusive"
      )
    total += grade
    count += 1
  return round(total / count, 2)

We could have used ValueError, but GradeValueError is more specific and best describe the error.

You should raise an exception to:

  • Signal errors and exceptional situations
  • Reraise exception after doing some additional processing (Example: logging)

Python encourages the Easier to ask forgiveness than permission (EAFP) over Look before you lead (LBYL).

📑 It's up to the developer to decide when to handle the exception.

Different raise keyword situation

Alone
def some_func(arg):
    try:
        do_something(arg)
    except Exception as error:
        logging.error(error)
        raise
    # Here raise will reraise the Exception that was raised by do_something
Conditionally
>>> from math import sqrt

>>> def is_prime(number):
...     if not isinstance(number, int):
...         raise TypeError(
...             f"integer number expected, got {type(number).__name__}"
...         )
...     if number < 2:
...         raise ValueError(f"integer above 1 expected, got {number}")
...     for candidate in range(2, int(sqrt(number)) + 1):
...         if number % candidate == 0:
...             return False
...     return True
...

✅ Raising exceptions early, before doing any computation, is considered best practice.

Wrap Exception into another one
>>> class MathLibraryError(Exception):
...     pass
...

>>> def divide(a, b):
...     try:
...         return a / b
...     except ZeroDivisionError as error:
...         raise MathLibraryError(error)
...

>>> divide(1, 0)
Traceback (most recent call last):
  File "<stdin>", line 3, in divide
ZeroDivisionError: division by zero

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

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 5, in divide
MathLibraryError: division by zero

❗ Even if the above can greatly improve your code, the from syntax offers better alternatives.

Using the from clause

The optional clause from allows the developers to chain another exception to the active one.

If the argument passed to from is an instance of an exception, it will directly attach itself to the dunder attribute .__cause__, if it's an exception class, Python will instantiate it before attaching it to .__cause__.

When using from, you can expect both exception tracebacks on the screen.

>>> try:
...     result = 42 / 0
... except Exception as error:
...     raise ValueError("operation not allowed") from error
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
ZeroDivisionError: division by zero

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

Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
ValueError: operation not allowed

In this example, Python indicates that the first error (ZeroDivisionError) is the reason for the second error (ValueError). It is very useful when writing code that can raise multiple types of exceptions. For example:

>>> def divide(x, y):
...     for arg in (x, y):
...         if not isinstance(arg, int | float):
...             raise TypeError(
...                 f"number expected, got {type(arg).__name__}"
...             )
...     if y == 0:
...         raise ValueError("denominator can't be zero")
...     return x / y
...

The divide function here raises different types of error: TypeError and ValueError. Here's how it will behave with the from clause for raise:

>>> try:
...     divide(42, 0)
... except Exception as error:
...     raise ValueError("invalid argument") from error
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
  File "<stdin>", line 6, in divide
ValueError: denominator can't be zero

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

Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
ValueError: invalid argument

>>> try:
...     divide("One", 42)
... except Exception as error:
...     raise ValueError("invalid argument") from error
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
  File "<stdin>", line 4, in divide
TypeError: number expected, got str

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

Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
ValueError: invalid argument

Without from, the traceback does not indicate a direct link between the errors.

  • With from:
    • The above exception was the direct cause of the following exception
  • Without from:
    • During handling of the above exception, another exception occurred
With None

You want to use the argument None with from when you want to suppress built-in traceback from custom error or when the original traceback is not necessary or informative. A use case is a package to consume an external REST API where you don't want to expose the requests library (or urllib3 library) exception.

>>> import requests

>>> class APIError(Exception):
...     pass
...

>>> def call_external_api(url):
...     try:
...         response = requests.get(url)
...         response.raise_for_status()
...         data = response.json()
...     except requests.RequestException as error:
...         raise APIError(f"{error}") from None
...     return data
...

>>> call_external_api("https://api.github.com/events")
[
    {
        'id': '29376567903',
        'type': 'PushEvent',
    ...
]

>>> # No error happened

>>> call_external_api("https://api.github.com/event")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 7, in call_external_api
__main__.APIError: 404 Client Error: Not Found for url:
  https://api.github.com/event

>>> # An error happened

With this example, you can see that the original exception (requests.RequestException) was absent from the traceback.

Good practice when handling/raising Exceptions

⚠️ Catching generic Exception is considered bad practice.
Doing so could result in critical errors not being found.
  • Favor specific exceptions over generic ones
  • Provide informative error messages and avoid exceptions with no message
  • Favor built-in exceptions over custom exceptions
  • Avoid raising the AssertionError Exception
  • Raise Exception as soon as possible
  • Explain the raised exceptions in your code's documentation

AssertionError are raised only in tests, that's why they should not be raised manually in your code.

Error message writing convention

When writing an error message follow these rules:

  • Message start with a lowercase letter and don't end with a period
  • Error message should clearly and concisely describe what is the issue that caused the exception to be raised.
  • Remember that the message needs to be specific enough to help the developer in in the debugging process.
⚠️ Error Message
"Invalid age."
"age must not be negative"

Creating Custom Exception Convention

⚠️ Convention to follow while creating custom exception
👉 naming convention for class (CapWords convention)
👉 Add the suffix Error when representing an error
👉 Don't add suffix for non-error exceptions
👉 Add the Warning suffix when defining custom warning

Document

It is considered best practice to list and document all the exceptions your code can raise with a brief description of how the users can handle them.

Annexe 1

ExceptionGroup

A ExceptionGroup is a subclass of Exception that use the except* syntaxe. The traceback will have a different syntax.

>>> raise ExceptionGroup(
...  "several errors",
...  [
...    ValueError("invalid value"),
...    TypeError("invalid type"),
...    KeyError("missing key")
...  ]
... )
...
  + Exception Group Traceback (most recent call last):
  |   File "<stdin>", line 1, in <module>
  | ExceptionGroup: several errors (3 sub-exceptions)
  +-+---------------- 1 ----------------
    | ValueError: invalid value
    +---------------- 2 ----------------
    | TypeError: invalid type
    +---------------- 3 ----------------
    | KeyError: 'missing key'
    +------------------------------------

When catching ExceptionGroup, you can use the except* syntaxe...

>>> try:
...     raise ExceptionGroup(
...         "several errors",
...         [
...             ValueError("invalid value"),
...             TypeError("invalid type"),
...             KeyError("missing key"),
...         ]
...     )
... except* ValueError:
...     print("Handling ValueError")
... except* TypeError:
...     print("Handling TypeError")
... except* KeyError:
...     print("Handling KeyError")
...
Handling ValueError
Handling TypeError
Handling KeyError

... or catch them like any other exception.

>>> try:
...     raise ExceptionGroup(
...         "several errors",
...         [
...             ValueError("invalid value"),
...             TypeError("invalid type"),
...             KeyError("missing key"),
...         ]
...     )
... except ExceptionGroup:
...     print("Got an exception group!")
...
Got an exception group!