Chapters |
---|
What is an Exception |
Different ways of handling an Exception |
Different ways of raising an Exception |
Good practice when handling/raising Exceptions |
Annexe |
- Version Française 🔹
- This training is aimed at the backend developer, but everybody is welcome to read it.
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 ofException
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
# 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.
💥 | ||
---|---|---|
Warning | Error | Exception |
- Predict what exceptions can happen. You can even fail the program voluntarily to discover what exceptions are raised.
- If custom exception, use the raise keyword.
- Determine where the exception needs to be handled in your code.
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
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.
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 |
# 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. |
---|
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
>>> 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.
>>> 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.
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
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.
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.
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" |
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 |
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.
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!