Python Exception Class Hierarchy

Python Exception Handling – NameError

Our journey continues through our detailed Python Exception Handling series with a deep look at the NameError found in Python. Just as with many other programming languages, Python source code (typically found in .py files) is initially compiled into bytecode, which is a low level representation of source code that can be executed by a virtual machine via the CPython interpreter. Part of this process involves loading local or global objects into the callstack. However, when Python attempts to load an object that doesn’t exist elsewhere in the callstack it will forcefully raise a NameError indicating as much.

In today’s article we’ll explore the NameError by looking at where it resides in the overall Python Exception Class Hierarchy. We’ll also look at some functional sample code that illustrates the basic compilation process Python source code goes through to turn into bytecode, and how improper references can result in NameErrors during this process. Let’s get right into it!

The Technical Rundown

All Python exceptions inherit from the BaseException class, or extend from an inherited class therein. The full exception hierarchy of this error is:

Full Code Sample

Below is the full code sample we’ll be using in this article. It can be copied and pasted if you’d like to play with the code yourself and see how everything works.

This code sample also uses the Logging utility class, the source of which can be found here on GitHub.

When Should You Use It?

As mentioned in the introduction, a NameError will occur when the CPython interpreter does not recognize a local or global object name that has been provided in the Python source code. Let’s jump right into some example code in normal Python, after which we’ll see how we can disassemble this code into the bytecode that CPython actually reads and interprets.

We begin with two extremely simple functions, log_object(value) and log_invalid_object(value):

The majority of the code for each of these is merely there for error handling, as the core functionality takes place on a single line: Logging.log(value) and Logging.log(valu), respectively. In essence, we’re merely using these two functions to log the content of the passed value parameter to the console. However, in the case of log_invalid_object() we have a slight typo of valu instead of value.

Let’s test these out by creating a simple Book object instance and passing it to each of our two log_ functions:

As you can probably guess, executing this code produces an expected Book object output, followed by raising a NameError because our typo of valu is not a recognized name:

That’s all well and good, but Python is a powerful language that allows us to look “under the hood” a bit and see the actual bytecode that each of these log_ functions generates for the CPython interpreter. We’ll be using the built-in dis disassembler module, which was created for this very purpose. By passing a function reference to the dis.dis() method we are provided a full output of the disassembled bytecode that CPython interprets during execution. Our local disassemble_object(value) function is a small wrapper for this purpose:

Thus, we can see what the bytecode of the log_object(value) function looks like by running the following:

This produces the following output:

This may appear a bit overwhelming at first, but this data is actually quite easy to interpret with a bit of knowledge about what we’re looking at in each column. The first column (e.g. 41, 42, 4348) is the actual line number in the source doe for the corresponding set of instructions. Thus, we can see that all of the following instructions…

…were generated from a single line of source code (#42):

The column with multiples of two (0, 2, 4, etc) is the memory address in the underlying bytecode for the given instruction. Modern Python stores instructions using two bytes of data, hence the multiples of two. The next column contains the opname (i.e. instruction) that should be executed, all of which can be found in the official documentation.

The column after that contains any arguments, if applicable, that each particular instruction will use. The final column provides a human-friendly version of the instruction, so we can better visualize how the bytecode instruction correlates to source code.

Thus, let’s look back at the single line 42 source code of Logging.log(value) and the generated bytecode instruction set to see what’s going on:

It starts with LOAD_GLOBAL to load the global name Logging onto the stack. It then loads the log attribute onto the top of the stack (TOS). LOAD_FAST pushes a reference to a local variable called value onto the stack. Next, CALL_FUNCTION calls the function at argument stack 1, which is the log method added two instructions prior. POP_TOP removes the most recent item added onto the stack, which is the local value object. Every frame of execution contains a stack of code blocks, which are the logical groupings we see and create when writing source code that is locally grouped. For example, a nested loop or, in this case, a try-except block, is contained within a separate code block in the stack. Since the next instruction that we’re jumping to with JUMP_FORWARD 88 is exiting the end of the try block found in our source code, POP_BLOCK is used to remove the top (current) block from the code block stack.

Cool, so let’s see how this compiled bytecode for log_object differs from the slightly modified log_invalid_object function:

We’ll ignore the majority of the bytecode produced here since it is identical to that produced by log_object, but here we have the instruction set from the same corresponding Logging.log(valu) source code line we examined before:

Everything looks exactly the same as before with two exceptions: The line number of 58 is obviously different, since we’re compiling a different line of source code. The second difference is the third instruction, which changed from LOAD_FAST 0 (value) to LOAD_GLOBAL 2 (valu). Why? Because the compiler cannot reconcile a local object named valu, since the actual local parameter passed into the function is value, without the typo. Therefore, the compiler assumes valu is a global name, and tries to load it via LOAD_GLOBAL. As we know from executing the log_invalid_object function earlier, the CPython interpreter is unable to locate a global named valu during execution, so a NameError is raised to indicate as much. Neat!

Airbrake’s robust error monitoring software provides real-time error monitoring and automatic exception reporting for all your development projects. Airbrake’s state of the art web dashboard ensures you receive round-the-clock status updates on your application’s health and error rates. No matter what you’re working on, Airbrake easily integrates with all the most popular languages and frameworks. Plus, Airbrake makes it easy to customize exception parameters, while giving you complete control of the active error filter system, so you only gather the errors that matter most.

Check out Airbrake’s error monitoring software today and see for yourself why so many of the world’s best engineering teams use Airbrake to revolutionize their exception handling practices!