An exception chain starts when a new exception is raised during the handling of another, for example from an except clause:
try:
open("foo.bar")
except OSError:
raise RuntimeError("oops")
Traceback (most recent call last):
File "<stdin>", line 2, in <module>
FileNotFoundError: [Errno 2] No such file or directory: 'foo.bar'
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "<stdin>", line 4, in <module>
RuntimeError: oops
This is called an implicit chain (hence the "During handling ..."). To make it explicit and clearly state an exception is the cause of another, one can use the special raise ... from :
try:
open("foo.bar")
except OSError as e:
raise RuntimeError("oops") from e
Traceback (most recent call last):
File "<stdin>", line 2, in <module>
FileNotFoundError: [Errno 2] No such file or directory: 'foo.bar'
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "<stdin>", line 4, in <module>
RuntimeError: oops
As you can see, we now have another log message: "was the direct cause of". Of course, chains can be longer than two.
To go back to the context attributes of an exception:
__suppress_context__ is false by default.
When raising a new exception while another exception is already being handled (in except, finally or with), the new exceptionβs __context__ attribute is automatically set to the handled exception.
When using raise ... from , the supplied exception will additionally be saved in the __cause__ attribute of the raised exception, and __suppress_context__ will be set to true.
The default traceback uses those attributes to display stacktraces in the following way:
if __cause__ is present, always show it
if __cause__ is None, show the __context__ only if __suppress_context__ is false.
To ensure you followed, what does this valid Python code prints?
try:
1/0
except ZeroDivisionError:
raise RuntimeError("zero!") from None
It only shows the RuntimeError, because the from will set the __cause__ (to None) and the __suppress_context__ (to true).
Now, this doesn't completely swallow the original exception. Even when using a from, the initial ZeroDivisionError is still in the __context__, just ignored when printing the stacktrace.
Back to the problem in the introduction, I simply catch the exception e raised by the library (in Token.__init__), and then use e.__context__.args[0] to get the initial exception message.
Bare except vs except Exception
I learned this one from the ruff rule bare-except (E722). When you don't care about which exception is raised, you may be tempted to use a bare except (but should NOT):
try:
do_something()
except: # <- no exception class is called bare except
print("oops")
A bare except catches BaseException
BaseException is the common base class of all exceptions. One of its subclasses, Exception, is the base class of all the non-fatal exceptions. Exceptions which are not subclasses of Exception are not typically handled, because they are used to indicate that the program should terminate.
This except thus catches Exception, but also KeyboardInterrupt, SystemExit, and other fatal errors, making it hard to interrupt the program (e.g., with Ctrl-C) and potentially disguising other problems or leaving the program in an unexpected state.
So instead, always specify an exception type, or simply Exception if you are in doubt:
try:
do_something()
except Exception: # now we are good
print("oops")
Raising shorthands
When re-raising inside an except clause, you don't need to pass an argument to raise, as it re-raises the caught exception by default.
try:
x / y
except ZeroDivisionError: # no need to add "as e"
log.error("we got a zero here")
raise
Similarly, when raising an exception with no argument, no need for parentheses. If an exception class is passed to raise, it will be implicitly instantiated by calling its constructor with no arguments.
Hence the following is perfectly valid and more concise (see ruff rule unnecessary-paren-on-raise-exception (RSE102)):
raise ValueError
Annotating exceptions (3.11+)
Since Python 3.11, it is possible to attach notes to exceptions, effectively enriching their context. This is a very interesting feature, that could replace re-raising an exception with a different message.
try:
try:
raise ValueError
except Exception as e:
e.add_note("This was raised as an example")
raise
except Exception as e:
e.add_note("Great article btw!")
raise
Traceback (most recent call last):
File "<stdin>", line 3, in <module>
ValueError
This was raised as an example
Great article btw!
Those notes are saved in the __notes__ attribute (list of strings).
What about UserWarning?
If you look at the Python documentation, you will see some strange built-in exceptions such as UserWarning, DeprecationWarning, etc. They all inherit from Warning (which itself inherits from Exception) but they are NOT meant to be raised. Instead, they are used as warning categories.
In short, Warning exceptions are to be used with the warnings module. There is much more to it, but let's look at a simple example:
import warnings
def foo():
# Explicit category
warnings.warn("Don't use me anymore!", DeprecationWarning)
# Implicit category
warnings.warn("bar") # <- default to UserWarning
What is nice about warning is that users have complete control over what is reported, thanks to the warning filter:
foo()
# <stdin>:3: DeprecationWarning: Don't use me anymore!
# <stdin>:5: UserWarning: bar
foo() # second time, no more warnings printed
---
warnings.simplefilter("ignore")
foo() # -> nothing printed
---
warnings.simplefilter("error")
foo() # -> raises!
# Traceback (most recent call last):
# File "<stdin>", line 1, in <module>
# File "<stdin>", line 3, in foo
# DeprecationWarning: Don't use me anymore!
For all available filters, see The Warnings Filter.
So, why use exceptions for that? You guessed it, it simplifies turning warnings into exceptions (error filter): one just has to raise it.
Bonus
This article is already way too long, so here is a bullet list of other interesting subjects and picks:
Python 3.11 introduced ExceptionGroup, a nice way to pack multiple exceptions into one. The new syntax except* allows filtering groups efficiently. Find out more in the documentation.
Python supports try-except-else-finally, although I never found a good use case for the else (also supported in for loops). The else block is executed after the try block, but before the finally block (if the except block doesn't run). Exceptions raised inside the else block are not caught by the except. See Handling exceptions for more info.
The NotImplementedError (not to confuse with the constant NotImplemented) signals a missing implementation that should come one day. If the feature will never be implemented, raise a TypeError instead.
Exceptions store their __init__ arguments in the args attribute.
I am always tempted to name my custom exception classes with the Exception suffix (a remnant of Java perhaps?). However, PEP 8 clearly states we should use the suffix Error for exception class names.
It is possible to catch multiple exceptions using parentheses: except (FooException, BarException)
Never return in a finally: this will override whatever return you may have inside the try or the except.
I haven't written in a while, so I hope you enjoyed this article.
- With β€οΈ, @derlin