Other Ways to open this interlude: JupyterLite | Colab | Read Only | Download

Ignore this cell — used when running JupyterLite.

from os.path import basename, exists

def download(url):
    """Download a file if it isn't already here, and return its filename."""
    filename = basename(url)
    if not exists(filename):
        from urllib.request import urlretrieve

        local, _ = urlretrieve(url, filename)
        print("Downloaded " + str(local))
    return filename

download('https://github.com/porttack/working-in-python/raw/v3/working_in_python.py');

import working_in_python
# apcsp:begin type="note" chapter="6b"
working_in_python.enable_docstring_reminders()
# apcsp:end

Docstrings and Doctests#

This interlude is not part of Think Python. It sits between Chapter 6 and Chapter 7 because of what you just learned: functions that return values. Until now, your functions drew pictures or printed text, and the only way to check them was to look. Now they hand something back, and something that gets handed back can be checked automatically.

In Chapter 4 you wrote your first docstring: a short piece of text at the top of a function that says what the function does. A docstring is a promise. It tells whoever reads your code, including you three weeks from now, what they should expect if they call it.

A promise that nobody checks is just a hope.

In this interlude we’ll add a second layer to that docstring: a few example calls, and the answer each one should give. Python can read those examples and run them. If the function keeps its promise, nothing happens. If it doesn’t, Python tells you exactly which promise was broken.

That second layer is called a doctest.

What a good docstring says#

Here is a function with no docstring at all.

def price_with_tax(price, rate):
    return price * (1 + rate)

It probably works. But answer these without scrolling: is rate a percentage like 8.5, or a decimal like 0.085? Does the result get rounded? What happens if price is negative?

You can’t answer any of those from the code alone, and neither can anyone else. A docstring answers them.

def price_with_tax(price, rate):
    """Compute a price including sales tax.

    rate: tax rate as a decimal, so 8.5% is 0.085

    Returns the total as a float, not rounded.
    """
    return price * (1 + rate)

Three rules, and they’re the whole game:

  1. A summary line that starts with a verb and says what the function does.

  2. Document a parameter only when its name doesn’t already say it. rate needed explaining. price did not. A docstring that says “price: the price” wastes everyone’s time.

  3. Say what comes back, if anything comes back.

Purpose, function, input, output#

There is a four-part frame worth learning now, because you will be asked to write it out in April and because a good docstring already contains three of the four parts.

Part

The question it answers

Where it lives in the docstring

Purpose

Why would anyone want this?

Nowhere. See below.

Function

What does it do when it runs?

The summary line

Input

What data does it receive?

The parameter notes

Output

What does it produce?

The “Returns…” line

Look back at price_with_tax. “Compute a price including sales tax” is the

**function**. The note about `rate` describes the **input**. The note about an unrounded float describes the

output.

The part a docstring never carries is purpose. “Compute a price including sales tax” tells you what the code does. It does not tell you that this is for a school store checkout, that the volunteers kept getting the tax wrong by hand, and that the whole program exists so the line moves faster. That’s purpose, and it lives outside the function entirely.

Keep the distinction. Function is behavior, described in the third person. Purpose is the need being served. Students routinely answer the purpose question with a feature list, and a feature list is neither one.

A word about words. This book says function. The AP exam says procedure for the same thing, and the exam’s written response asks about a “student-developed procedure” by name.

Historically those two words meant genuinely different things. In Pascal, a procedure did some work and handed nothing back, while a function computed a value and returned it. Fortran drew the same line between a SUBROUTINE and a FUNCTION. The distinction was useful: the name told you whether a call was worth putting on the right-hand side of an assignment.

Modern languages mostly abandoned it. Python has only def, and a function that returns nothing quietly returns None. The exam uses procedure for both cases, so the older distinction is gone from its vocabulary too.

Answer to both words. But it’s worth knowing the difference existed, because you will meet languages that still keep it, and because “does this hand something back?” remains the first question worth asking about any function you’re about to call.

The docstring is data, not a comment#

A # comment is invisible to the running program. A docstring isn’t. Python stores it on the function itself, which means the program can read its own documentation.

help(price_with_tax)
price_with_tax.__doc__

This is why docstrings use triple quotes and go inside the function, on the first line. Move them anywhere else and they turn back into ordinary strings that nobody can find.

Your first doctest#

A doctest is an example call written inside the docstring, followed by the answer you expect. Each example starts with >>>, which is the prompt Python shows when you type at it directly.

def double(x):
    """Return twice the value of x.

    >>> double(5)
    10
    >>> double(-3)
    -6
    """
    return 2 * x

Read those two lines as a sentence: if someone calls double(5), the answer should be 10. That’s a claim about the function, written in a form Python can check.

To check it, we need a helper. Import it from working_in_python:

from doctest import run_docstring_examples

def run_doctests(func):
    run_docstring_examples(func, globals(), name=func.__name__)

run_doctests(double)

Nothing happened. That’s the point: no news is good news. run_doctests only speaks up when something is wrong.

Note for later. run_doctests is a convenience that exists because we’re working in notebooks. When you start writing .py files, the same tests run a different way. We’ll get there.

When a test fails#

Here’s the same function with a bug. The docstring still makes the same promise.

def double_broken(x):
    """Return twice the value of x.

    >>> double_broken(5)
    10
    >>> double_broken(-3)
    -6
    """
    return x + 2
run_doctests(double_broken)

Notice what the failure report gives you, in order:

  • which call failed

  • Expected, what the docstring promised

  • Got, what the code actually did

And notice which test caught it. double_broken(5) returns 7, so that one fails too – but if the only test had been double_broken(2), the answer would have been 4, the test would have passed, and the bug would have shipped.

Choosing test cases is not busywork. It’s the skill.

Choosing test cases#

Each of those >>> lines is a test case: one input, paired with the output it should produce. That pairing is the whole definition, and it’s worth holding onto, because the exam asks about test cases in exactly those terms.

A test case that always passes teaches you nothing. Aim for three kinds:

Kind

What it checks

Example for is_even(n)

Typical

The ordinary case anyone would try

is_even(4)

Boundary

The value where behavior changes

is_even(0)

Edge

The unusual input you suspect is wrong

is_even(-3)

Typical cases are the ones students write. Boundary and edge cases are the ones they skip, and those are where the bugs live: zero, empty, one, the first, the last, the day the month rolls over.

def can_ride(height_inches, age):
    """Check whether someone may ride, given height and age.

    Riders must be at least 48 inches tall and at least 7 years old.

    >>> can_ride(52, 9)
    True
    >>> can_ride(48, 7)
    True
    >>> can_ride(72, 6)
    False
    """
    return height_inches >= 48 and age >= 7
run_doctests(can_ride)

The second test is the boundary: exactly 48 inches, exactly 7 years. If someone later “fixes” the code by changing >= to >, that test fails immediately.

The third test is the interesting one. A very tall six-year-old is exactly the case where a careless writer would have used or instead of and. A test case can be funny and still be the most useful test in the file.

Tests as documentation#

Read these two descriptions of the same function:

Returns the portion of the string before the first separator, or the whole string if the separator does not occur.

versus

>>> first_field('name,age,city', ',')
'name'
>>> first_field('solo', ',')
'solo'

The prose is more complete. The examples are faster. Good documentation has both, and the examples are the part people actually read.

What doctests can’t check#

Doctests compare what your function produces against text you wrote by hand. That works beautifully when the answer is predictable, and not at all when it isn’t.

def average(a, b):
    """Return the mean of two numbers.

    >>> average(1, 2)
    1.5
    >>> average(0.1, 0.2)
    0.15000000000000002
    """
    return (a + b) / 2

That second expected value looks like a typo. It isn’t. It’s the actual answer, and you met this in Chapter 1: a computer stores a number in a fixed number of bits, and some decimal values don’t fit exactly, so the answer lands a hair off. That’s called

**roundoff error**, and it's not a bug in your code. It's the

machine telling the truth about what it can represent.

We’ll come back to why when we get to binary. For now the practical consequence is enough: doctests on floats are fragile.

Three things doctests handle badly:

  • Floating-point results, for the reason above

  • Anything random, since the answer changes every run

  • Anything that depends on the current date or time

None of these mean “don’t test.” They mean the test has to be written differently. We’ll see how in a later interlude on unit testing.

Debugging#

What a doctest can and cannot catch#

You’ve met three kinds of error so far. They fail in three different ways, and a doctest is only useful against one of them.

Kind

What happens

Can a doctest catch it?

Syntax error

The code breaks Python’s grammar and never runs at all

No. The cell fails before any test exists

Run-time error

The code runs, then blows up partway with a traceback

Sort of. You get the traceback instead of a pass/fail

Semantic error

The code runs happily and gives the wrong answer

Yes. This is the whole point

The first two announce themselves. A semantic error does not: nothing turns red, nothing complains, and the program looks like it worked. That silence is why they’re the hardest to find, and it’s exactly the silence a doctest breaks.

A word about words. Downey calls this a semantic error. The AP exam calls the same thing a logic error. Both mean “it ran, and it was wrong.”

There are other kinds of error you’ll meet later, when we look at how numbers are actually stored: roundoff error, which you saw above, and overflow error, which happens when a value is too large for the bits available to hold it. Python hides overflow from you almost completely, so it needs its own demonstration. Both belong to a later interlude on binary, not to this one.

Which one is lying, the code or the test?#

When a doctest fails, one of two things is wrong: the code, or the test. Beginners assume it’s always the code. It isn’t.

Before changing anything, do a hand trace: work through the call on paper, line by line, writing down what each variable holds as you go. No running the code, no guessing. The exam will ask you to do this with nothing but a pencil, so it’s worth practicing while you still have a machine to check yourself against.

Then compare your traced answer to both the Expected and the Got.

  • If your traced answer matches Expected, the code is wrong. Fix the function.

  • If your traced answer matches Got, the test is wrong. Fix the docstring.

  • If it matches neither, you don’t yet understand the problem, and changing code at random will not help. Go back to the description.

That third case is the common one, and it’s the whole reason to trace before you type.

Glossary#

docstring: A string at the beginning of a function that documents what the function does; unlike a comment, it is stored on the function and can be read by the program. (Python’s name for what the exam calls program documentation.)

doctest: An example call and its expected result, written inside a docstring, that can be run automatically to check the function. (Python; not exam vocabulary.)

testing: Checking that a program behaves correctly by running it on chosen inputs and comparing what comes out against what should have come out.

test case: A single input, paired with the result it should produce.

boundary case: A test case at the value where a function’s behavior changes, such as zero, an empty string, or the first or last item.

edge case: A test case at an unusual or extreme input, where a function is most likely to be wrong.

expected value: What a test says the answer should be, as opposed to what the code actually produced.

pass: A test whose actual result matches its expected value.

fail: A test whose actual result does not match its expected value.

hand tracing: Working through code on paper line by line, writing down each variable’s value, in order to find an error without running the program.

semantic error: An error that lets the program run but produces a wrong result. (The exam calls this a logic error.)

roundoff error: A loss of precision that happens because a fixed number of bits cannot represent some numbers exactly.

program purpose: The need a program serves, or the problem it solves; why it exists.

program function: What a program does when it runs, described as behavior.

program input: Data a program receives while it is running.

program output: What a program produces: displayed text, a returned value, a file, a sound, a movement.

procedure: The exam’s word for a named, reusable block of code, whether or not it returns a value. This book says function. In older languages the two words were distinct: a procedure returned nothing, a function returned a value.

regression: A bug that reappears in code that used to work. Tests exist mainly to catch these. (Professional vocabulary, not exam vocabulary.)

See also: the full vocabulary glossary collects every term in this book, alphabetized, alongside the complete AP CSP exam vocabulary list.

Homework#

Do these in order. Exercises 1 through 5 are required.

# This cell tells Jupyter to provide detailed debugging information
# when a runtime error occurs. Run it before working on the exercises.

%xmode Verbose

Exercise 1#

Below is is_leap_year from Chapter 5, correct but undocumented. Add a docstring with a summary line and three doctests: one typical year, one boundary case, and one case that would catch a wrong implementation. Then run them.

(A hint on the boundary: 2000 and 1900 are both interesting, for different reasons.)

def is_leap_year(year):
    return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)


run_doctests(is_leap_year)

Exercise 2 (choose one: do Exercise 2 or Exercise 3, not both)#

Write a function can_ride(height_inches, age) that returns True if a rider is at least 48 inches tall and at least 7 years old. Write the docstring first, including three doctests, then write the body. One of your three should be a boundary case.

Then, in the markdown cell below, state the function’s purpose in one sentence. Say something the docstring does not say.

# Your function here


run_doctests(can_ride)

Purpose, one sentence:

Exercise 3 (choose one: see Exercise 2)#

Write a function letter_grade(score) that returns 'A' for 90 and above, 'B' for 80 to 89, 'C' for 70 to 79, 'D' for 60 to 69, and 'F' below 60. Write the docstring first, including three doctests, then write the body. One of your three should be a boundary case.

Then, in the markdown cell below, state the function’s purpose in one sentence. Say something the docstring does not say.

# Your function here


run_doctests(letter_grade)

Purpose, one sentence:

Exercise 4#

The function below is broken. Its doctests are correct. Run them, read the failure report, and fix the function so all tests pass. Do not change the docstring.

Before you fix it, hand trace count_down_to_zero(3) and name which kind of error this is: syntax, run-time, or semantic.

def count_down_to_zero(n):
    """Return a list of numbers from n down to 0.

    >>> count_down_to_zero(3)
    [3, 2, 1, 0]
    >>> count_down_to_zero(0)
    [0]
    """
    result = []
    while n > 0:
        result.append(n)
        n = n - 1
    return result


run_doctests(count_down_to_zero)

Exercise 5#

Here a function and its doctest disagree. Exactly one of them is wrong.

Run the test. Then, in the markdown cell below, say which one is wrong and how you know. Then fix that one, and only that one.

def middle_character(word):
    """Return the middle character of a word with an odd number of letters.

    >>> middle_character('cat')
    'a'
    >>> middle_character('hello')
    'e'
    """
    return word[len(word) // 2]


run_doctests(middle_character)

Type your answer here: which is wrong, the code or the test, and how do you know?

Exercise 6#

Reflection, three or four sentences.

You wrote docstrings in Chapter 4 without any way to check them. Now you can check them. Did knowing the test would run change how you wrote the docstring? Was there a moment where writing the examples first made the function easier to write, or harder?

Type your answer here.

Write a test that lies (extra credit)#

Write a function that is completely correct, along with a doctest that fails anyway. Then explain in a sentence why it fails.

There is more than one way to do this, and the chapter mentions three of them.

# Your function here

Type your explanation here.

from working_in_python import time_check

chapter_minutes = 0
extra_exercises_minutes = 0
longest = 0

time_check(chapter_minutes, extra_exercises_minutes, longest)

Finished? Copy your work#

This isn’t part of the exercises above. It’s a tool. Run the cell below to copy this notebook (including anything you’ve run) so you can paste it into a document.

working_in_python.show_copy_notebook_button()

Working in Python — modified by Eric Brown for a high school Computer Science Principles class. Source and modifications: github.com/porttack/working-in-python


Think Python: 3rd Edition

Copyright 2024 Allen B. Downey

Code license: MIT License

Text license: Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International


Standards alignment#

AP CSP: 1.3 Program Design and Development, 1.4 Identifying and Correcting Errors. Big Idea 1, 10-13% of the exam California 9-12: 9-12.AP.20, 9-12.AP.22 CSTA 2026: HS-PRO-TR-19 CA CTE (ICT): C4.9, C5.4

The exam asks you to choose inputs and the outputs they should produce, then use the results to find errors. A doctest is exactly that, written down. On the Create Performance Task you’ll be asked to describe two calls to a procedure you wrote, what each one tests, and what each returns. That is the same work you’re doing here, in the same order.

This interlude also names the three error types the exam distinguishes (syntax, run-time, and semantic, which the exam calls logic), and introduces the purpose / function / input / output frame that the written-response section asks you to fill in. Overflow and roundoff errors are named here but explained in a later interlude on binary, where the reason for them actually lives.

Syntax note: docstrings and >>> are Python, not exam pseudocode. The exam has no notation for documentation or testing. What transfers is the habit, not the punctuation.