3. Program Structure and Control Flow

This chapter covers the details of program structure and control flow. Topics include conditionals, looping, exceptions, and context managers.

3.1 Program Structure and Execution

Python programs are structured as a sequence of statements. All language features, including variable assignment, expressions, function definitions, classes, and module imports, are statements that have equal status with all other statements—meaning that any statement can be placed almost anywhere in a program (although certain statements such as return can only appear inside a function). For example, this code defines two different versions of a function inside a conditional:

if debug:
    def square(x):
        if not isinstance(x,float):
            raise TypeError('Expected a float')
        return x * x
else:
    def square(x):
        return x * x

When loading source files, the interpreter executes statements in the order they appear until there are no more statements to execute. This execution model applies both to files you run as the main program and to library files that are loaded via import.

3.2 Conditional Execution

The if, else, and elif statements control conditional code execution. The general format of a conditional statement is

if expression:
    statements
elif expression:
    statements
elif expression:
    statements
...
else:
    statements

If no action is to be taken, you can omit both the else and elif clauses of a conditional. Use the pass statement if no statements exist for a particular clause:

if expression:
    pass            # To do: please implement
else:
    statements

3.3 Loops and Iteration

You implement loops using the for and while statements. Here’s an example:

while expression:
    statements

for i in s:
    statements

The while statement executes statements until the associated expression evaluates to false. The for statement iterates over all the elements of s until no more elements are available. The for statement works with any object that supports iteration. This includes the built-in sequence types such as lists, tuples, and strings, but also any object that implements the iterator protocol.

In the statement for i in s, the variable i is known as the iteration variable. On each iteration of the loop, it receives a new value from s. The scope of the iteration variable is not private to the for statement. If a previously defined variable has the same name, that value will be overwritten. Moreover, the iteration variable retains the last value after the loop has completed.

If the elements produced by iteration are iterables of identical size, you can unpack their values into separate iteration variables using a statement such as this:

s = [ (1, 2, 3), (4, 5, 6) ]

for x, y, z in s:
    statements

In this example, s must contain or produce iterables, each with three elements. On each iteration, the contents of the variables x, y, and z are assigned the items of the corresponding iterable. Although it is most common to see this used when s is a sequence of tuples, unpacking works when the items in s are any kind of iterable, including lists, generators, and strings.

Sometimes a throw-away variable such as _ is used while unpacking. For example:

for x, _, z in s:
    statements

In this example, a value is still placed into the _ variable, but the variable’s name implies that it’s not interesting or of use in the statements that follow.

If the items produced by an iterable have varying sizes, you can use wildcard unpacking to place multiple values into a variable. For example:

s = [ (1, 2), (3, 4, 5), (6, 7, 8, 9) ]

for x, y, *extra in s:
    statements            # x = 1, y = 2, extra = []
                          # x = 3, y = 4, extra = [5]
                          # x = 6, y = 7, extra = [8, 9]
                          # ...

In this example, at least two values x and y are required, but *extra receives any extra values that might also be present. These values are always placed in a list. At most, only one starred variable can appear in a single unpacking, but it can appear in any position. So, both of these variants are legal:

for *first, x, y in s:
    ...

for x, *middle, y in s:
    ...

When looping, it is sometimes useful to keep track of a numerical index in addition to the data values. Here’s an example:

i = 0
for x in s:
    statements
    i += 1

Python provides a built-in function, enumerate(), that can be used to simplify this code:

for i, x in enumerate(s):
    statements

enumerate(s) creates an iterator that produces tuples (0, s[0]), (1, s[1]), (2, s[2]), and so on. A different starting value for the count can be provided with the start keyword argument to enumerate():

for i, x in enumerate(s, start=100):
    statements

In this case, tuples of the form (100, s[0]), (101, s[1]), and so on will be produced.

Another common looping problem is iterating in parallel over two or more iterables— for example, writing a loop where you take items from different sequences on each iteration:

# s and t are two sequences
i = 0
while i < len(s) and i < len(t):
    x = s[i]     # Take an item from s
    y = t[i]     # Take an item from t
    statements
    i += 1

This code can be simplified using the zip() function. For example:

# s and t are two sequences
for x, y in zip(s, t):
    statements

zip(s, t) combines iterables s and t into an iterable of tuples (s[0], t[0]), (s[1], t[1]), (s[2], t[2]), and so forth, stopping with the shortest of s and t should they be of unequal length. The result of zip() is an iterator that produces the results when iterated. If you want the result converted to a list, use list(zip(s, t)).

To break out of a loop, use the break statement. For example, this code reads lines of text from a file until an empty line of text is encountered:

with open('foo.txt') as file:
    for line in file:
        stripped = line.strip()
        if not stripped:
            break          # A blank line, stop reading
        # process the stripped line
        ...

To jump to the next iteration of a loop (skipping the remainder of the loop body), use the continue statement. This statement is useful when reversing a test and indenting another level would make the program too deeply nested or unnecessarily complicated. As an example, the following loop skips all of the blank lines in a file:

with open('foo.txt') as file:
    for line in file:
        stripped = line.strip()
        if not stripped:
            continue      # Skip the blank line
        # process the stripped line
        ...

The break and continue statements apply only to the innermost loop being executed. If you need to break out of a deeply nested loop structure, you can use an exception. Python doesn’t provide a “goto” statement. You can also attach the else statement to loop constructs, as in the following example:

# for-else
with open('foo.txt') as file:
    for line in file:
        stripped = line.strip()
        if not stripped:
            break
        # process the stripped line
        ...
    else:
        raise RuntimeError('Missing section separator')

The else clause of a loop executes only if the loop runs to completion. This either occurs immediately (if the loop wouldn’t execute at all) or after the last iteration. If the loop is terminated early using the break statement, the else clause is skipped.

The primary use case for the looping else clause is in code that iterates over data but needs to set or check some kind of flag or condition if the loop breaks prematurely. For example, if you didn’t use else, the previous code might have to be rewritten with a flag variable as follows:

found_separator = False

with open('foo.txt') as file:
    for line in file:
        stripped = line.strip()
        if not stripped:
            found_separator = True
            break
        # process the stripped line
        ...
    if not found_separator:
        raise RuntimeError('Missing section separator')

3.4 Exceptions

Exceptions indicate errors and break out of the normal control flow of a program. An exception is raised using the raise statement. The general format of the raise statement is raise Exception([value]), where Exception is the exception type and value is an optional value giving specific details about the exception. Here’s an example:

raise RuntimeError('Unrecoverable Error')

To catch an exception, use the try and except statements, as shown here:

try:
    file = open('foo.txt', 'rt')
except FileNotFoundError as e:
    statements

When an exception occurs, the interpreter stops executing statements in the try block and looks for an except clause that matches the exception type that has occurred. If one is found, control is passed to the first statement in the except clause. After the except clause is executed, control continues with the first statement that appears after the entire try-except block.

It is not necessary for a try statement to match all possible exceptions that might occur. If no matching except clause can be found, an exception continues to propagate and might be caught in a different try-except block that can actually handle the exception elsewhere. As a matter of programming style, you should only catch exceptions from which your code can actually recover. If recovery is not possible, it’s often better to let the exception propagate.

If an exception works its way up to the top level of a program without being caught, the interpreter aborts with an error message.

If the raise statement is used by itself, the last exception generated is raised again. This works only while handling a previously raised exception. For example:

try:
    file = open('foo.txt', 'rt')
except FileNotFoundError:
    print("Well, that didn't work.")
    raise       # Re-raises current exception

Each except clause may be used with an as var modifier that gives the name of a variable into which an instance of the exception type is placed if an exception occurs. Exception handlers can examine this value to find out more about the cause of the exception. For example, you can use isinstance() to check the exception type.

Exceptions have a few standard attributes that might be useful in code that needs to perform further actions in response to an error:

e.args

The tuple of arguments supplied when raising the exception. In most cases, this is a one-item tuple with a string describing the error. For OSError exceptions, the value is a 2-tuple or 3-tuple containing an integer error number, string error message, and an optional filename.

e.__cause__

Previous exception if the exception was intentionally raised in response to handling another exception. See the later section on chained exceptions.

e.__context__

Previous exception if the exception was raised while handling another exception.

e.__traceback__

Stack traceback object associated with the exception.

The variable used to hold an exception value is only accessible inside the associated except block. Once control leaves the block, the variable becomes undefined. For example:

try:
    int('N/A')               # Raises ValueError
except ValueError as e:
    print('Failed:', e)

print(e)     # Fails -> NameError. 'e' not defined.

Multiple exception-handling blocks can be specified using multiple except clauses:

try:
    do something
except TypeError as e:
    # Handle Type error
    ...
except ValueError as e:
    # Handle Value error
    ...

A single handler clause can catch multiple exception types like this:

try:
    do something
except (TypeError, ValueError) as e:
    # Handle Type or Value errors
    ...

To ignore an exception, use the pass statement as follows:

try:
    do something
except ValueError:
    pass              # Do nothing (shrug).

Silently ignoring errors is often dangerous and a source of hard-to-find mistakes. Even if ignored, it is often wise to optionally report the error in a log or some other place where you can inspect it later.

To catch all exceptions except those related to program exit, use Exception like this:

try:
    do something
except Exception as e:
    print(f'An error occurred : {e!r}')

When catching all exceptions, you should take great care to report accurate error information to the user. For example, in the previous code, an error message and the associated exception value are being printed. If you don’t include any information about the exception value, it can make it very difficult to debug code that is failing for reasons you don’t expect.

The try statement also supports an else clause, which must follow the last except clause. This code is executed if the code in the try block doesn’t raise an exception. Here’s an example:

try:
    file = open('foo.txt', 'rt')
except FileNotFoundError as e:
    print(f'Unable to open foo : {e}')
    data = ''
else:
    data = file.read()
    file.close()

The finally statement defines a cleanup action that must execute regardless of what happens in a try-except block. Here’s an example:

file = open('foo.txt', 'rt')
try:
    # Do some stuff
    ...
finally:
    file.close()
    # File closed regardless of what happened

The finally clause isn’t used to catch errors. Rather, it’s used for code that must always be executed, regardless of whether an error occurs. If no exception is raised, the code in the finally clause is executed immediately after the code in the try block. If an exception occurs, a matching except block (if any) is executed first and then control is passed to the first statement of the finally clause. If, after this code has executed, an exception is still pending, that exception is reraised to be caught by another exception handler.

3.4.1 The Exception Hierarchy

One challenge of working with exceptions is managing the vast number of exceptions that might potentially occur in your program. For example, there are more than 60 built-in exceptions alone. Factor in the rest of the standard library and it turns into hundreds of possible exceptions. Moreover, there is often no way to easily determine in advance what kind of exceptions any portion of code might raise. Exceptions aren’t recorded as part of a function’s calling signature nor is there any sort of compiler to verify correct exception handling in your code. As a result, exception handling can sometimes feel haphazard and disorganized.

It helps to realize that exceptions are organized into a hierarchy via inheritance. Instead of targeting specific errors, it might be easier to focus on more general categories of errors. For example, consider the different errors that might arise when looking values up in a container:

try:
    item = items[index]
except IndexError:      # Raised if items is a sequence
    ...
except KeyError:        # Raised if items is a mapping
    ...

Instead of writing code to handle two highly specific exceptions, it might be easier to do this:

try:
    item = items[index]
except LookupError:
    ...

LookupError is a class that represents a higher-level grouping of exceptions. IndexError and KeyError both inherit from LookupError, so this except clause would catch either one. Yet, LookupError isn’t so broad as to include errors unrelated to the lookup.

Table 3.1 describes the most common categories of built-in exceptions.

Table 3.1 Exception Categories

Exception Class

Description

BaseException

The root class for all exceptions

Exception

Base class for all program-related errors

ArithmeticError

Base class for all math-related errors

ImportError

Base class for import-related errors

LookupError

Base class for all container lookup errors

OSError

Base class for all system-related errors. IOError and EnvironmentError are aliases.

ValueError

Base class for value-related errors, including Unicode

UnicodeError

Base class for a Unicode string encoding-related errors

The BaseException class is rarely used directly in exception handling because it matches all possible exceptions whatsoever. This includes special exceptions that affect the control flow such as SystemExit, KeyboardInterrupt, and StopIteration. Catching these is rarely what you want. Instead, all normal program-related errors inherit from Exception. ArithmeticError is the base for all math-related errors such as ZeroDivisionError, FloatingPointError, and OverflowError. ImportError is a base for all import-related errors. LookupError is a base for all container lookup-related errors. OSError is a base for all errors originating from the operating system and environment. OSError encompasses a wide range of exceptions related to files, network connections, permissions, pipes, timeouts, and more. The ValueError exception is commonly raised when a bad input value is given to an operation. UnicodeError is a subclass of ValueError grouping all Unicode-related encoding and decoding errors.

Table 3.2 shows some common built-in exceptions that inherit directly from Exception but aren’t part of a larger exception group.

Table 3.2 Other Built-in Exceptions

Exception Class

Description

AssertionError

Failed assert statement

AttributeError

Bad attribute lookup on an object

EOFError

End of file

MemoryError

Recoverable out-of-memory error

NameError

Name not found in the local or global namespace

NotImplementedError

Unimplemented feature

RuntimeError

A generic “something bad happened” error

TypeError

Operation applied to an object of the wrong type

UnboundLocalError

Usage of a local variable before a value is assigned

3.4.2 Exceptions and Control Flow

Normally, exceptions are reserved for the handling of errors. However, a few exceptions are used to alter the control flow. These exceptions, shown in Table 3.3, inherit from BaseException directly.

Table 3.3 Exceptions Used for Control Flow

Exception Class

Description

SystemExit

Raised to indicate program exit

KeyboardInterrupt

Raised when a program is interrupted via Control-C

StopIteration

Raised to signal the end of iteration

The SystemExit exception is used to make a program terminate on purpose. As an argument, you can either provide an integer exit code or a string message. If a string is given, it is printed to sys.stderr and the program is terminated with an exit code of 1.

import sys

if len(sys.argv) != 2:
    raise SystemExit(f'Usage: {sys.argv[0]} filename)

filename = sys.argv[1]

The KeyboardInterrupt exception is raised when the program receives a SIGINT signal (typically by pressing Control-C in a terminal). This exception is a bit unusual in that it is asynchronous—meaning that it could occur at almost any time and on any statement in your program. The default behavior of Python is to simply terminate when this happens. If you want to control the delivery of SIGINT, the signal library module can be used (see chapter 9).

The StopIteration exception is part of the iteration protocol and signals the end of iteration.

3.4.3 Defining New Exceptions

All the built-in exceptions are defined in terms of classes. To create a new exception, create a new class definition that inherits from Exception, such as the following:

class NetworkError(Exception):
    pass

To use your new exception, use the raise statement as follows:

raise NetworkError('Cannot find host')

When raising an exception, the optional values supplied with the raise statement are used as the arguments to the exception’s class constructor. Most of the time, this is a string containing some kind of error message. However, user-defined exceptions can be written to take one or more exception values, as shown in this example:

class DeviceError(Exception):
     def __init__(self, errno, msg):
         self.args = (errno, msg)
         self.errno = errno
         self.errmsg = msg

# Raises an exception (multiple arguments)
raise DeviceError(1, 'Not Responding')

When you create a custom exception class that redefines __init__(), it is important to assign a tuple containing the arguments of __init__() to the attribute self.args as shown. This attribute is used when printing exception traceback messages. If you leave it undefined, users won’t be able to see any useful information about the exception when an error occurs.

Exceptions can be organized into a hierarchy using inheritance. For instance, the NetworkError exception defined earlier could serve as a base class for a variety of more specific errors. Here’s an example:

class HostnameError(NetworkError):
    pass

class TimeoutError(NetworkError):
    pass

def error1():
    raise HostnameError('Unknown host')

def error2():
    raise TimeoutError('Timed out')

try:
    error1()
except NetworkError as e:
    if type(e) is HostnameError:
        # Perform special actions for this kind of error
        ...

In this case, the except NetworkError clause catches any exception derived from NetworkError. To find the specific type of error that was raised, examine the type of the execution value with type().

3.4.4 Chained Exceptions

Sometimes, in response to an exception, you might want to raise a different exception. To do this, raise a chained exception:

class ApplicationError(Exception):
    pass

def do_something():
    x = int('N/A')    # raises ValueError

def spam():
    try:
        do_something()
    except Exception as e:
        raise ApplicationError('It failed') from e

If an uncaught ApplicationError occurs, you will get a message that includes both exceptions. For example:

>>> spam()
Traceback (most recent call last):
  File "c.py", line 9, in spam
    do_something()
  File "c.py", line 5, in do_something
    x = int('N/A')
ValueError: invalid literal for int() with base 10: 'N/A'

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

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "c.py", line 11, in spam
    raise ApplicationError('It failed') from e
__main__.ApplicationError: It failed
>>>

If you catch an ApplicationError, the __cause__ attribute of the resulting exception will contain the other exception. For example:

try:
    spam()
except ApplicationError as e:
    print('It failed. Reason:', e.__cause__)

If you want to raise a new exception without including the chain of other exceptions, raise an error from None like this:

def spam():
    try:
        do_something()
    except Exception as e:
        raise ApplicationError('It failed') from None

A programming mistake that appears in an except block will also result in a chained exception, but that works in a slightly different way. For example, suppose you have some buggy code like this:

def spam():
    try:
        do_something()
    except Exception as e:
        print('It failed:', err)      # err undefined (typo)

The resulting exception traceback message is slightly different:

>>> spam()
Traceback (most recent call last):
  File "d.py", line 9, in spam
    do_something()
  File "d.py", line 5, in do_something
    x = int('N/A')
ValueError: invalid literal for int() with base 10: 'N/A'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "d.py", line 11, in spam
    print('It failed. Reason:', err)
NameError: name 'err' is not defined
>>>

If an unexpected exception is raised while handling another exception, the __context__ attribute (instead of __cause__) holds information about the exception that was being handled when the error occurred. For example:

try:
    spam()
except Exception as e:
    print('It failed. Reason:', e)
    if e.__context__:
        print('While handling:', e.__context__)

There is an important distinction between expected and unexpected exceptions in exception chains. In the first example, the code was written so that the possibility of an exception was anticipated. For example, code was explicitly wrapped in a try-except block:

try:
    do_something()
except Exception as e:
    raise ApplicationError('It failed') from e

In the second case, there was a programming mistake in the except block:

try:
    do_something()
except Exception as e:
    print('It failed:', err)     # err undefined

The difference between these two cases is subtle but important. That is why exception chaining information is placed into either the __cause__ or the __context__ attribute. The __cause__ attribute is reserved for when you’re expecting the possibility of a failure. The __context__ attribute is set in both cases, but would be the only source of information for an unexpected exception raised while handling another exception.

3.4.5 Exception Tracebacks

Exceptions have an associated stack traceback that provides information about where an error occurred. The traceback is stored in the __traceback__ attribute of an exception. For the purposes of reporting or debugging, you might want to produce the traceback message yourself. The traceback module can be used to do this. For example:

import traceback

try:
    spam()
except Exception as e:
    tblines = traceback.format_exception(type(e), e, e.__traceback__)
    tbmsg = ''.join(tblines)
    print('It failed:')
    print(tbmsg)

In this code, format_exception() produces a list of strings containing the output Python would normally produce in a traceback message. As input, you provide the exception type, value, and traceback.

3.4.6 Exception Handling Advice

Exception handling is one of the most difficult things to get right in larger programs. However, there are a few rules of thumb that make it easier.

The first rule is to not catch exceptions that can’t be handled at that specific location in the code. Consider a function like this:

def read_data(filename):
    with open(filename, 'rt') as file:
         rows = []
         for line in file:
             row = line.split()
             rows.append((row[0], int(row[1]), float(row[2]))
    return rows

Suppose the open() function fails due to a bad filename. Is this an error that should be caught with a try-except statement in this function? Probably not. If the caller gives a bad filename, there is no sensible way to recover. There is no file to open, no data to read, and nothing else that’s possible. It’s better to let the operation fail and report an exception back to the caller. Avoiding an error check in read_data() doesn’t mean that the exception would never be handled anywhere—it just means that it’s not the role of read_data() to do it. Perhaps the code that prompted a user for a filename would handle this exception.

This advice might seem contrary to the experience of programmers accustomed to languages that rely upon special error codes or wrapped result types. In those languages, great care is made to make sure you always check return codes for errors on all operations. You don’t do this in Python. If an operation can fail and there’s nothing you can do to recover, it’s better to just let it fail. The exception will propagate to upper levels of the program where it is usually the responsibility of some other code to handle it.

On the other hand, a function might be able to recover from bad data. For example:

def read_data(filename):
    with open(filename, 'rt') as file:
         rows = []
         for line in file:
             row = line.split()
             try:
                 rows.append((row[0], int(row[1]), float(row[2]))
             except ValueError as e:
                 print('Bad row:', row)
                 print('Reason:', e)
    return rows

When catching errors, try to make your except clauses as narrow as reasonable. The above code could have been written to catch all errors by using except Exception. However, doing that would make the code catch legitimate programming errors that probably shouldn’t be ignored. Don’t do that—it will make debugging difficult.

Finally, if you’re explicitly raising an exception, consider making your own exception types. For example:

class ApplicationError(Exception):
    pass

class UnauthorizedUserError(ApplicationError):
    pass

def spam():
    ...
    raise UnauthorizedUserError('Go away')
    ...

One of the more challenging problems in large code bases is assigning blame for program failures. If you make your own exceptions, you’ll be better able to distinguish between intentionally raised errors and legitimate programming mistakes. If your program crashes with some kind of ApplicationError defined above, you’ll know immediately why that error got raised—because you wrote the code to do it. On the other hand, if the program crashes with one of Python’s built-in exceptions (such as TypeError or ValueError), that might indicate a more serious problem.

3.5 Context Managers and the with Statement

Proper management of system resources such as files, locks, and connections is often a tricky problem when combined with exceptions. For example, a raised exception can cause control flow to bypass statements responsible for releasing critical resources, such as a lock.

The with statement allows a series of statements to execute inside a runtime context that is controlled by an object serving as a context manager. Here is an example:

with open('debuglog', 'wt') as file:
     file.write('Debugging\n')
     statements
     file.write('Done\n')

import threading
lock = threading.Lock()
with lock:
     # Critical section
     statements
     # End critical section

In the first example, the with statement automatically causes the opened file to be closed when the control flow leaves the block of statements that follow. In the second example, the with statement automatically acquires and releases a lock when control enters and leaves the block of statements that follow.

The with obj statement allows the object obj to manage what happens when the control flow enters and exits the associated block of statements that follow. When the with obj statement executes, it calls the method obj.__enter__() to signal that a new context is being entered. When control flow leaves the context, the method obj.__exit__(type, value, traceback) executes. If no exception has been raised, the three arguments to __exit__() are all set to None. Otherwise, they contain the type, value, and traceback associated with the exception that has caused the control flow to leave the context. If the __exit__() method returns True, it indicates that the raised exception was handled and should no longer be propagated. Returning None or False will cause the exception to propagate.

The with obj statement accepts an optional as var specifier. If given, the value returned by obj.__enter__() is placed into var. This value is commonly the same as obj because this allows an object to be constructed and used as a context manager in the same step. For example, consider this class:

class Manager:
    def __init__(self, x):
        self.x = x

    def yow(self):
        pass

    def __enter__(self):
        return self

    def __exit__(self, ty, val, tb):
        pass

With it, you can create and use an instance as a context manager in a single step:

with Manager(42) as m:
    m.yow()

Here is a more interesting example involving list transactions:

class ListTransaction:
    def __init__(self,thelist):
        self.thelist = thelist

    def __enter__(self):
        self.workingcopy = list(self.thelist)
        return self.workingcopy

    def __exit__(self,type,value,tb):
        if type is None:
            self.thelist[:] = self.workingcopy
        return False

This class allows you to make a sequence of modifications to an existing list. However, the modifications only take effect if no exceptions occur. Otherwise, the original list is left unmodified. For example:

items = [1,2,3]

with ListTransaction(items) as working:
    working.append(4)
    working.append(5)
print(items)       # Produces [1,2,3,4,5]

try:
    with ListTransaction(items) as working:
        working.append(6)
        working.append(7)
        raise RuntimeError("We're hosed!")
except RuntimeError:
    pass

print(items)   # Produces [1,2,3,4,5]

The contextlib standard library module contains functionality related to more advanced uses of context managers. If you find yourself regularly creating context managers, it might be worth a look.

3.6 Assertions and __debug__

The assert statement can introduce debugging code into a program. The general form of assert is

assert test [, msg]

where test is an expression that should evaluate to True or False. If test evaluates to False, assert raises an AssertionError exception with the optional message msg supplied to the assert statement. Here’s an example:

def write_data(file, data):
    assert file, 'write_data: file not defined!'
    ...

The assert statement should not be used for code that must be executed to make the program correct, because it won’t be executed if Python is run in optimized mode (specified with the -O option to the interpreter). In particular, it’s an error to use assert to check user input or the success of some important operation. Instead, assert statements are used to check invariants that should always be true; if one is violated, it represents a bug in the program, not an error by the user.

For example, if the function write_data(), shown previously, were intended for use by an end user, the assert statement should be replaced by a conventional if statement and the desired error handling.

A common use of assert is in testing. For example, you might use it to include a minimal test of a function:

def factorial(n):
    result = 1
    while n > 1:
        result *= n
        n -= 1
    return result

assert factorial(5) == 120

The purpose of such a test is not to be exhaustive, but to serve as a kind of “smoke test.” If something obvious is broken in the function, the code will crash immediately with a failed assertion upon import.

Assertions can also be useful in specifying a kind of programming contract on expected inputs and outputs. For example:

def factorial(n):
    assert n > 0, "must supply a positive value"
    result = 1
    while n > 1:
        result *= n
        n -= 1
    return result

Again, this is not meant to check for user input. It’s more of a check for internal program consistency. If some other code tries to compute negative factorials, the assertion will fail and point at the offending code so that you could debug it.

3.7 Final Words

Although Python supports a variety of different programming styles involving functions and objects, the fundamental model of program execution is that of imperative programming. That is, programs are made up of statements that execute one after the other in the order they appear within a source file. There are only three basic control flow constructs: the if statement, the while loop, and the for loop. There are few mysteries when it comes to understanding how Python executes your program.

By far the most complicated and potentially error-prone feature is the exceptions. In fact, much of this chapter focused on how to properly think about exception handling. Even if you follow this advice, exceptions remain a delicate part of designing libraries, frameworks, and APIs. Exceptions can also play havoc with proper management of resources—this is a problem addressed via the use of context managers and the with statement.

Not covered in this chapter are the techniques that can be used to customize almost every Python language feature—including the built-in operators and even aspects of the control flow described in this chapter. Although Python programs often appear superficially simple in structure, a surprising amount of magic can often be at work behind the scenes. Much of this is described in the next chapter.