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.

import datetime
import dis

from gw_utility.book import Book
from gw_utility.logging import Logging


def main():
    try:
        # Create Book.
        book = Book("The Hobbit", "J.R.R. Tolkien", 366, datetime.date(1937, 9, 15))

        # Log book object.
        Logging.line_separator("log_object(book)", 60)
        log_object(book)

        # Log invalid object.
        Logging.line_separator("log_invalid_object(book)", 60)
        log_invalid_object(book)

        # Disassemble both log_ functions.
        Logging.line_separator("DISASSEMBLY OF log_object()", 60)
        disassemble_object(log_object)

        Logging.line_separator("DISASSEMBLY OF log_invalid_object()", 60)
        disassemble_object(log_invalid_object)
    except NameError as error:
        # Output expected NameErrors.
        Logging.log_exception(error)
    except Exception as exception:
        # Output unexpected Exceptions.
        Logging.log_exception(exception, False)


def log_object(value):
    """Logs passed value parameter to console.

    :param value: Value to be logged.
    :return: None
    """
    try:
        Logging.log(value)
    except NameError as error:
        # Output expected NameErrors.
        Logging.log_exception(error)
    except Exception as exception:
        # Output unexpected Exceptions.
        Logging.log_exception(exception, False)


def log_invalid_object(value):
    """Attempts to log invalid object (valu) to console.

    :param value: Value intended to be logged, but which is instead ignored.
    :return: None
    """
    try:
        Logging.log(valu)
    except NameError as error:
        # Output expected NameErrors.
        Logging.log_exception(error)
    except Exception as exception:
        # Output unexpected Exceptions.
        Logging.log_exception(exception, False)


def disassemble_object(value):
    """Outputs disassembly of passed object.

    :param value: Object to be disassembled.
    :return: None
    """
    dis.dis(value)


if __name__ == "__main__":
    main()

# book.py
import datetime


class Book:
    author: str
    page_count: int
    publication_date: datetime.date
    title: str

    def __eq__(self, other):
        """Determines if passed object is equivalent to current object."""
        return self.__dict__ == other.__dict__

    def __init__(self,
                 title: str = None,
                 author: str = None,
                 page_count: int = None,
                 publication_date: datetime.date = None):
        """Initializes Book instance.

        :param title: Title of Book.
        :param author: Author of Book.
        :param page_count: Page Count of Book.
        :param publication_date: Publication Date of Book.
        """
        self.author = author
        self.page_count = page_count
        self.publication_date = publication_date
        self.title = title

    def __getattr__(self, name: str):
        """Returns the attribute matching passed name."""
        # Get internal dict value matching name.
        value = self.__dict__.get(name)
        if not value:
            # Raise AttributeError if attribute value not found.
            raise AttributeError(f'{self.__class__.__name__}.{name} is invalid.')
        # Return attribute value.
        return value

    def __len__(self):
        """Returns the length of title."""
        return len(self.title)

    def __str__(self):
        """Returns a formatted string representation of Book."""
        date = '' if self.publication_date is None else f', published on {self.publication_date.__format__("%B %d, %Y")}'
        pages = '' if self.page_count is None else f' at {self.page_count} pages'
        return f'\'{self.title}\' by {self.author}{pages}{date}.'

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):

def log_object(value):
    """Logs passed value parameter to console.

    :param value: Value to be logged.
    :return: None
    """
    try:
        Logging.log(value)
    except NameError as error:
        # Output expected NameErrors.
        Logging.log_exception(error)
    except Exception as exception:
        # Output unexpected Exceptions.
        Logging.log_exception(exception, False)


def log_invalid_object(value):
    """Attempts to log invalid object (valu) to console.

    :param value: Value intended to be logged, but which is instead ignored.
    :return: None
    """
    try:
        Logging.log(valu)
    except NameError as error:
        # Output expected NameErrors.
        Logging.log_exception(error)
    except Exception as exception:
        # Output unexpected Exceptions.
        Logging.log_exception(exception, False)

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:

# Create Book.
book = Book("The Hobbit", "J.R.R. Tolkien", 366, datetime.date(1937, 9, 15))

# Log book object.
Logging.line_separator("log_object(book)", 60)
log_object(book)

# Log invalid object.
Logging.line_separator("log_invalid_object(book)", 60)
log_invalid_object(book)

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:

--------------------- log_object(book) ---------------------
'The Hobbit' by J.R.R. Tolkien at 366 pages, published on September 15, 1937.

----------------- log_invalid_object(book) -----------------
[EXPECTED] NameError: name 'valu' is not defined

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:

def disassemble_object(value):
    """Outputs disassembly of passed object.

    :param value: Object to be disassembled.
    :return: None
    """
    dis.dis(value)

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

# Disassemble both log_ functions.
Logging.line_separator("DISASSEMBLY OF log_object()", 60)
disassemble_object(log_object)

This produces the following output:

--------------- DISASSEMBLY OF log_object() ----------------
 41           0 SETUP_EXCEPT            14 (to 16)

 42           2 LOAD_GLOBAL              0 (Logging)
              4 LOAD_ATTR                1 (log)
              6 LOAD_FAST                0 (value)
              8 CALL_FUNCTION            1
             10 POP_TOP
             12 POP_BLOCK
             14 JUMP_FORWARD            88 (to 104)

 43     >>   16 DUP_TOP
             18 LOAD_GLOBAL              2 (NameError)
             20 COMPARE_OP              10 (exception match)
             22 POP_JUMP_IF_FALSE       58
             24 POP_TOP
             26 STORE_FAST               1 (error)
             28 POP_TOP
             30 SETUP_FINALLY           16 (to 48)

 45          32 LOAD_GLOBAL              0 (Logging)
             34 LOAD_ATTR                3 (log_exception)
             36 LOAD_FAST                1 (error)
             38 CALL_FUNCTION            1
             40 POP_TOP
             42 POP_BLOCK
             44 POP_EXCEPT
             46 LOAD_CONST               1 (None)
        >>   48 LOAD_CONST               1 (None)
             50 STORE_FAST               1 (error)
             52 DELETE_FAST              1 (error)
             54 END_FINALLY
             56 JUMP_FORWARD            46 (to 104)

 46     >>   58 DUP_TOP
             60 LOAD_GLOBAL              4 (Exception)
             62 COMPARE_OP              10 (exception match)
             64 POP_JUMP_IF_FALSE      102
             66 POP_TOP
             68 STORE_FAST               2 (exception)
             70 POP_TOP
             72 SETUP_FINALLY           18 (to 92)

 48          74 LOAD_GLOBAL              0 (Logging)
             76 LOAD_ATTR                3 (log_exception)
             78 LOAD_FAST                2 (exception)
             80 LOAD_CONST               2 (False)
             82 CALL_FUNCTION            2
             84 POP_TOP
             86 POP_BLOCK
             88 POP_EXCEPT
             90 LOAD_CONST               1 (None)
        >>   92 LOAD_CONST               1 (None)
             94 STORE_FAST               2 (exception)
             96 DELETE_FAST              2 (exception)
             98 END_FINALLY
            100 JUMP_FORWARD             2 (to 104)
        >>  102 END_FINALLY
        >>  104 LOAD_CONST               1 (None)
            106 RETURN_VALUE

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…

 42           2 LOAD_GLOBAL              0 (Logging)
              4 LOAD_ATTR                1 (log)
              6 LOAD_FAST                0 (value)
              8 CALL_FUNCTION            1
             10 POP_TOP
             12 POP_BLOCK
             14 JUMP_FORWARD            88 (to 104)

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

Logging.log(value)

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:

 42           2 LOAD_GLOBAL              0 (Logging)
              4 LOAD_ATTR                1 (log)
              6 LOAD_FAST                0 (value)
              8 CALL_FUNCTION            1
             10 POP_TOP
             12 POP_BLOCK
             14 JUMP_FORWARD            88 (to 104)

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:

Logging.line_separator("DISASSEMBLY OF log_invalid_object()", 60)
disassemble_object(log_invalid_object)

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:

 58           2 LOAD_GLOBAL              0 (Logging)
              4 LOAD_ATTR                1 (log)
              6 LOAD_GLOBAL              2 (valu)
              8 CALL_FUNCTION            1
             10 POP_TOP
             12 POP_BLOCK
             14 JUMP_FORWARD            88 (to 104)

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!