A document from MCS 275 Spring 2022, instructor David Dumas. You can also get the notebook file.

MCS 275 Spring 2022 Worksheet 5 Solutions

  • Course instructor: David Dumas
  • Solutions prepared by: Jennifer Vaccaro, Johnny Joyce

Topics

The main topics of this worksheet are:

  • Context managers
  • Debugging Python programs with
    • print(...)
    • pdb

It's important to get some practice on each topic, so for example, don't spend the entire lab period working on the context manager questions.

Also, please don't miss the opportunity to try out pdb. I think you can debug these puzzles by various methods, but it would be good to get some experience running a program in the debugger.

Resources

These things might be helpful while working on the problems. Remember that for worksheets, we don't strictly limit what resources you can consult, so these are only suggestions.

1. Fetch me a CSV file, please

This problem is about writing a context manager. To remind you how these work, here is a context manager that makes sure a file with a given name exists when a with block starts.

In [39]:
import os

class EnsureExists:
    "Context manager that creates a file with a given name if it doesn't exist already"
    def __init__(self,fn):
        "Set the filename of the file to be created"
        self.fn = fn
    def __enter__(self):
        "Do the setup upon entering a with block: Create the file if needed"
        if not os.path.exists(self.fn):
            print("---EnsureExists is creating file '{}'".format(self.fn))
            open(self.fn,"w").close()
        else:
            print("---EnsureExists didn't need to to anything, as '{}' already exists'".format(self.fn))
    def __exit__(self,*args,**kwargs):
        "Called for cleanup upon leaving a with block; this context manager doesn't do any cleanup!"
        print("---EnsureExists is cleaning up (by doing nothing)")

Here's an example of using it. Notice that inside the with-block, we can safely open the file for reading because the context manager created it.

In [40]:
with EnsureExists("sample.txt"):
    print("Checking if sample.txt exists:")
    if os.path.exists("sample.txt"):
        print("It does.")
    else:
        print("ERROR: It's missing!")
    with open("sample.txt","r") as infile:
        print("It contains {} characters.".format( len(infile.read()) ))
---EnsureExists is creating file 'sample.txt'
Checking if sample.txt exists:
It does.
It contains 0 characters.
---EnsureExists is cleaning up (by doing nothing)

And if we run the same code a second time, it will not need to create the file.

In [41]:
with EnsureExists("sample.txt"):
    print("Checking if sample.txt exists:")
    if os.path.exists("sample.txt"):
        print("It does.")
    else:
        print("ERROR: It's missing!")
    with open("sample.txt","r") as infile:
        print("It contains {} characters.".format( len(infile.read()) ))
---EnsureExists didn't need to to anything, as 'sample.txt' already exists'
Checking if sample.txt exists:
It does.
It contains 0 characters.
---EnsureExists is cleaning up (by doing nothing)

Using this example as a reference, make a new context manager called TemporaryCSV that generates a CSV file containing random data and writes it to a given filename. That way, you can test code that works with a CSV file by wrapping it in a with-block using this context manager. Use the class definition below as a starting point.

In [3]:
import csv
import os
import random

class TemporaryCSV:
    """
    Context manager that creates a CSV file with a given name containing some random data,
    optionally removing during cleanup.
    """
    def __init__(self,fn,columns=3,rows=5,header=True,remove=False):
        """
        Setup context manager that will create a CSV file named `fn` upon entering the `with`-block,
        containing `columns` columns and `rows` rows of random integers.  If `header` is True, also write
        some random strings as column headers.
        
        If `remove` is True (the default), the CSV file is deleted at the end of the `with`-block.
        """

    def __enter__(self):
        "Create the CSV"
        # YOU WRITE THIS METHOD!
        
    def __exit__(self,*args,**kwargs):  # There are specific arguments passed here, but let's not worry about them
        "Remove the CSV if configured to do so"
        # YOU WRITE THIS METHOD!

Here's an example of what using the final context manager should look like:

In [4]:
import csv

# A sample function to use on a CSV file
def csv_stats(fn):
    "Return stats about a CSV file"
    rowcount = 0
    with open(fn,"r",newline="",encoding="UTF-8") as infile:
        rdr = csv.DictReader(infile)
        for row in rdr:
            rowcount += 1
            colnames = list(row.keys())
    return {"rowcount":rowcount, "colnames":colnames}


with TemporaryCSV("temp.csv",columns=2,rows=10,remove=True):
    d = csv_stats("temp.csv")
    print("Found the CSV file to have {} rows and {} columns, named {}".format(
        d["rowcount"],
        len(d["colnames"]),
        ", ".join( "'{}'".format(s) for s in d["colnames"])
    ))
    
if not os.path.exists("temp.csv"):
    print("And hey, the CSV file is gone now.")
Found the CSV file to have 10 rows and 2 columns, named 'soOxN', 'kZCgS'

Solution

In [2]:
import random
import csv
import os

class TemporaryCSV:
    """
    Context manager that creates a CSV file with a given name containing some random data,
    optionally removing during cleanup.
    """
    
    def __init__(self,fn,columns=3,rows=5,header=True,remove=True):
        """
        Setup context manager that will create a CSV file named `fn` upon entering the `with`-block,
        containing `columns` columns and `rows` rows of random integers.  If `header` is True, also write
        some random strings as column headers.
        
        If `remove` is True (the default), the CSV file is deleted at the end of the `with`-block.
        """
        self.fn = fn
        self.columns = columns
        self.rows = rows
        self.header = header
        self.remove = remove
        
        
    def randstring(self,length=5):
        """Returns random string uppercase and lowercase letters of length `length` """
        alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
        return "".join( [random.choice(alphabet) for _ in range(length)] )
    
    
    def randint(self,maxint=1000):
        """Returns random integer between `0` and `maxint`"""
        return random.randrange(maxint)
    
    
    def __enter__(self):
        "Create the CSV"
        
        with open(self.fn,"w",newline="",encoding="UTF-8") as outfile:
            writer = csv.writer(outfile)
            
            # Write the header for each column as a randomly-generated string
            writer.writerow([ self.randstring() for _ in range(self.columns) ])
            
            for _ in range(self.rows): # Write each row one-by-one
                writer.writerow([ self.randint() for _ in range(self.columns) ]) # Fill with random numbers
                

    def __exit__(self,*args,**kwargs):  # There are specific arguments passed here, but let's not worry about them
        "Remove the CSV if configured to do so"
        if self.remove:
            os.unlink(self.fn)

2. Broken vending machine

Download the Python program linked below.

It simulates a vending machine, and is similar to a project assigned in MCS 260 in Fall 2020. Run it, use the "help" command in its text interface to learn about the available commands, and try them out.

This script has a bug which affects certain purchases. For example, try starting the script and depositing \$1.15 as four quarters, one dime, and one nickel, and then selecting item 4 (costing \\$1.10). The expected behavior would be: The item is purchased, and \$0.05 is given in change. Instead, the script will get stuck (and may require you to press Control-C in the terminal to exit).

Debug the program using an active debugging technique (print(...) or pdb) and find the cause of the infinite loop that prevents the program from executing as intended. Devise and test a fix.

First solution: Minimal change

The problem is that the given program stores the current credit as a float, and float addition is not exact. For example, in Python

0.1 + 0.05 == 0.15 evaluates to False! But 0.01+0.05 and 0.15 are quite close.

A minimal correction to the broken vending machine program would replace a check like cents == 0 with one that only looks for cents to be near zero, allowing for a small amount of floating point error.

The code below does that. It would be hard to verify the correctness of this program, however. How do we know that the error won't accumulate over time and eventually be large enough to fail even the approximate equality test?

In real programs, you should never use floats for financial calculations.

In [4]:
import sys

# Mapping from coin add commands to values (in dollars)
coin_values = { 
    "quarter": 0.25,
    "q": 0.25,
    "dime": 0.10,
    "d": 0.10,
    "nickel": 0.05,
    "n": 0.05
}

# Starting inventory.  Prices in dollars.
inventory = [
    { "stock":6, "price": 1.25, "name": "Cheesy dibbles" },
    { "stock":8, "price": 1.30, "name": "Slice of cake" },
    { "stock":10, "price": 0.75, "name": "1 kg trimethyl orthoformate" },
    { "stock":5, "price": 1.50, "name": "Single leaf of lettuce" },
    { "stock":5, "price": 1.10, "name": "\u00C9poisses on rye crackers" },
    { "stock":7, "price": 0.90, "name": "Lunar water" },
]


def dispense_change(cents):
    """print lines to simulate return of `cents` cents"""
    while cents >= 0.25:
        print("RETURN: quarter")
        cents = cents - 0.25
    while cents >= 0.10:
        print("RETURN: dime")
        cents = cents - 0.10
    while cents > 10**-5: 
        # FIXED Changed comparison to a small positive number, to account for float math.
        # A more permanent fix would require changing cents to be stored as an integer.
        # However, by changing the comparator from !=0 to > 10**-5, the vending machine works.
        print("RETURN: nickel")
        cents = cents - 0.05


def show_inventory(inventory):
    """Display the `inventory` (list of dicts) in the format required by 
    the project description
    """
    for idx,itemdata in enumerate(inventory):
        print(idx,itemdata["name"],
              "${:.2f} ({} available)".format(itemdata["price"],
                                              itemdata["stock"]))

def vend(inventory):
    """Run the vending machine simulator with a given `inventory`."""    

    # Determine the maximum price of an item
    maxprice = 0
    for d in inventory:
        if d["price"] > maxprice:
            maxprice = d["price"]

    invitems = [ str(x) for x in range(len(inventory)) ]

    # Command loop
    credit = 0.0
    while True:
        print("CREDIT: ${:.2f}".format(credit))
        cmd = input(">")

        if cmd in ["q","d","n","quarter","dime","nickel"]:
            # Coin inserted
            if credit >= maxprice:
                print("RETURN:",cmd)
            else:
                credit = credit + coin_values[cmd]
        elif cmd in invitems:
            # Snack purchase request
            i = int(cmd)  # 0-based index
            if inventory[i]["stock"] == 0:
                print("MSG: Out of stock")
            elif credit < inventory[i]["price"]:
                print("MSG: Insufficient credit")
            else:
                inventory[i]["stock"] = inventory[i]["stock"] - 1
                print("VEND:",inventory[i]["name"])
                dispense_change(credit - inventory[i]["price"])
                credit = 0
        elif cmd in ["inventory","i"]:
            # Request to display inventory
            show_inventory(inventory)
        elif cmd in ["return","r"]:
            # Return current credit
            dispense_change(credit)
            credit = 0
        elif cmd in ["help","h"]:
            # Display help
            print("Vending machine simulator knows about these commands:")
            print("h or help - show this message")
            print("i or inventory - show numbered list of items available for purchase")
            print("q or quarter - deposit a quarter ($0.25)")
            print("d or dime - deposit a dime ($0.10)")
            print("n or nickel - deposit a nickel ($0.05)")
            print("r or return - return all credit currently in machine")
            print("0, 1, 2, ... - buy an item by number")
        elif cmd == "exit":
            # exit the simulation
            return
        else:
            # Unknown command
            print("Unknown command: {}".format(cmd))

if __name__ == "__main__":
    # Start the simulation.
    vend(inventory)
CREDIT: $0.00
>q
CREDIT: $0.25
>q
CREDIT: $0.50
>q
CREDIT: $0.75
>q
CREDIT: $1.00
>d
CREDIT: $1.10
>d
CREDIT: $1.20
>n
CREDIT: $1.25
>4
VEND: Époisses on rye crackers
RETURN: dime
RETURN: nickel
CREDIT: $0.00
>exit

Second solution: Use integers everywhere

A correction that involves more code changes---but which is guaranteed to behave correctly no matter how many arithmetic operations are performed---is to never store anything as a float, but instead to work with prices as integers, measured in cents.

In [5]:
import sys

# Mapping from coin add commands to values (in cents)
coin_values = { 
    "quarter": 25,
    "q": 25,
    "dime": 10,
    "d": 10,
    "nickel": 5,
    "n": 5
}

# Starting inventory.  Prices in cents.
inventory = [
    { "stock":6, "price": 125, "name": "Cheesy dibbles" },
    { "stock":8, "price": 130, "name": "Slice of cake" },
    { "stock":10, "price": 75, "name": "1 kg trimethyl orthoformate" },
    { "stock":5, "price": 150, "name": "Single leaf of lettuce" },
    { "stock":5, "price": 110, "name": "\u00C9poisses on rye crackers" },
    { "stock":7, "price": 90, "name": "Lunar water" },
]


def dispense_change(cents):
    """print lines to simulate return of `cents` cents"""
    while cents >= 25:
        print("RETURN: quarter")
        cents = cents - 25
    while cents >= 10:
        print("RETURN: dime")
        cents = cents - 10
    while cents >= 5: 
        # FIXED Changed comparison to a small positive number, to account for float math.
        # A more permanent fix would require changing cents to be stored as an integer.
        # However, by changing the comparator from !=0 to > 10**-5, the vending machine works.
        print("RETURN: nickel")
        cents = cents - 5
    assert cents==0, "This vending machine requires all prices to be multiples of $0.05"


def show_inventory(inventory):
    """Display the `inventory` (list of dicts) in the format required by 
    the project description
    """
    for idx,itemdata in enumerate(inventory):
        print(idx,itemdata["name"],
              "${:.2f} ({} available)".format(itemdata["price"]/100,
                                              itemdata["stock"]))

def vend(inventory):
    """Run the vending machine simulator with a given `inventory`."""    

    # Determine the maximum price of an item
    maxprice = 0
    for d in inventory:
        if d["price"] > maxprice:
            maxprice = d["price"]

    invitems = [ str(x) for x in range(len(inventory)) ]

    # Command loop
    credit = 0
    while True:
        print("CREDIT: ${:.2f}".format(credit/100))
        cmd = input(">")

        if cmd in ["q","d","n","quarter","dime","nickel"]:
            # Coin inserted
            if credit >= maxprice:
                print("RETURN:",cmd)
            else:
                credit = credit + coin_values[cmd]
        elif cmd in invitems:
            # Snack purchase request
            i = int(cmd)  # 0-based index
            if inventory[i]["stock"] == 0:
                print("MSG: Out of stock")
            elif credit < inventory[i]["price"]:
                print("MSG: Insufficient credit")
            else:
                inventory[i]["stock"] = inventory[i]["stock"] - 1
                print("VEND:",inventory[i]["name"])
                dispense_change(credit - inventory[i]["price"])
                credit = 0
        elif cmd in ["inventory","i"]:
            # Request to display inventory
            show_inventory(inventory)
        elif cmd in ["return","r"]:
            # Return current credit
            dispense_change(credit)
            credit = 0
        elif cmd in ["help","h"]:
            # Display help
            print("Vending machine simulator knows about these commands:")
            print("h or help - show this message")
            print("i or inventory - show numbered list of items available for purchase")
            print("q or quarter - deposit a quarter ($0.25)")
            print("d or dime - deposit a dime ($0.10)")
            print("n or nickel - deposit a nickel ($0.05)")
            print("r or return - return all credit currently in machine")
            print("0, 1, 2, ... - buy an item by number")
        elif cmd == "exit":
            # exit the simulation
            return
        else:
            # Unknown command
            print("Unknown command: {}".format(cmd))

if __name__ == "__main__":
    # Start the simulation.
    vend(inventory)
CREDIT: $0.00
>q
CREDIT: $0.25
>q
CREDIT: $0.50
>q
CREDIT: $0.75
>q
CREDIT: $1.00
>d
CREDIT: $1.10
>d
CREDIT: $1.20
>n
CREDIT: $1.25
>4
VEND: Époisses on rye crackers
RETURN: dime
RETURN: nickel
CREDIT: $0.00
>exit

3. Is this function misbehaving?

The function below is supposed to compute the character histogram of a string, with the option to update an existing histogram so that a large text can be processed in chunks. However, it doesn't work: As you'll see form the example code, it doesn't start from a blank histogram in some cases. Why not?

Use debugging techniques to find the first place where the behavior of the function differs from the documented intention of the programmer.

In [9]:
"""Histogram example for debugging"""
# MCS 275 Spring 2022 - David Dumas

def update_char_histogram(s,hist = dict()):
    """
    Make a dictionary mapping letters to the number of times they occur in string `s`, i.e.
    a histogram.  If `hist` is provided, assume this is an existing histogram that should be
    updated (incremented) to account for the additional letters in `s`.  Thus, for example,
    this function could be called once for each line of a text file to incrementally compute
    a histogram of the entire file with needing to read the entire file into memory.
    """
    for c in s:
        if c.isspace():
            continue
        if c not in hist:
            hist[c] = 0
        hist[c] += 1
    return hist


# Develop a histogram in two steps
print("Histogram of 'laser-wielding maniac':")
h = update_char_histogram("laser-wielding maniac") # no hist given, so start from scratch
print(h)
print("Histogram of previous line and 'creeping dread of infinitely dividing bacteria':")
h = update_char_histogram("creeping dread of infinitely dividing bacteria",h)
print(h)

# Start from scratch again, develop a new histogram
print("Histogram of the word 'silent sea':") # no hist given, so start from scratch
h = update_char_histogram("silent sea")
print(h) # Why does it think there are twelve "i"s in "silent sea"?!?!
Histogram of 'laser-wielding maniac':
{'l': 2, 'a': 3, 's': 1, 'e': 2, 'r': 1, '-': 1, 'w': 1, 'i': 3, 'd': 1, 'n': 2, 'g': 1, 'm': 1, 'c': 1}
Histogram of previous line and 'creeping dread of infinitely dividing bacteria':
{'l': 3, 'a': 6, 's': 1, 'e': 7, 'r': 4, '-': 1, 'w': 1, 'i': 11, 'd': 5, 'n': 6, 'g': 3, 'm': 1, 'c': 3, 'p': 1, 'o': 1, 'f': 2, 't': 2, 'y': 1, 'v': 1, 'b': 1}
Histogram of the word 'silent sea':
{'l': 4, 'a': 7, 's': 3, 'e': 9, 'r': 4, '-': 1, 'w': 1, 'i': 12, 'd': 5, 'n': 7, 'g': 3, 'm': 1, 'c': 3, 'p': 1, 'o': 1, 'f': 2, 't': 3, 'y': 1, 'v': 1, 'b': 1}

Solution

In [6]:
def update_char_histogram(s,hist = None): # FIXED changed hist default argument to None type
    """Take a string `s` and look at the non-space characters in it. If `hist` is not given,
    compose a histogram of the characters in `s` and return it.  If `hist` is given, assume it contains
    a histogram of another part of the same text, and update it to take into account the text in `s`."""
    if hist == None: # Check whether a non-None argument was given
        hist = dict() # If yes, create a new empty dict()
    for c in s:
        if c.isspace():
            continue
        if c not in hist:
            hist[c] = 0
        hist[c] += 1
    return hist


# Develop a histogram in two steps
print("Histogram of 'laser-wielding maniac':")
h = update_char_histogram("laser-wielding maniac") # no hist given, so start from scratch
print(h)
print("Histogram of previous line and 'creeping dread of infinitely dividing bacteria':")
h = update_char_histogram("creeping dread of infinitely dividing bacteria",h)
print(h)

# Start from scratch again, develop a new histogram
print("Histogram of the word 'silent sea':") # no hist given, so start from scratch
h = update_char_histogram("silent sea")
print(h) # Why does it think there are twelve "i"s in "silent sea"?!?!
Histogram of 'laser-wielding maniac':
{'l': 2, 'a': 3, 's': 1, 'e': 2, 'r': 1, '-': 1, 'w': 1, 'i': 3, 'd': 1, 'n': 2, 'g': 1, 'm': 1, 'c': 1}
Histogram of previous line and 'creeping dread of infinitely dividing bacteria':
{'l': 3, 'a': 6, 's': 1, 'e': 7, 'r': 4, '-': 1, 'w': 1, 'i': 11, 'd': 5, 'n': 6, 'g': 3, 'm': 1, 'c': 3, 'p': 1, 'o': 1, 'f': 2, 't': 2, 'y': 1, 'v': 1, 'b': 1}
Histogram of the word 'silent sea':
{'s': 2, 'i': 1, 'l': 1, 'e': 2, 'n': 1, 't': 1, 'a': 1}

4. Tic-tac-toe too easily won

Download the program:

It is a tic-tac-toe game: two players take turns placing 'X' or 'O' on a 3x3 board. The first player to get 3 in a row horizontally, vertically, or diagonally wins.

But this game has a strange bug: No matter where the first player puts their 'X', they win.

Use debugging techniques to find the first place where the behavior of the program differs from the programmer's intention, and to fix it.

Note: This example was based on a collection of unintuitive Python language behaviors collected by Satwik Kansal. The URL for that source document is included below, but be warned, it contains spoilers for this problem (and some crude language). Opening the page is not recommended until after discussion is over:

  • https://github.com/satwikkansal/wtfpython#-a-tic-tac-toe-where-x-wins-in-the-first-attempt

Solution

As discussed in the given link, the board variable is created by creating three copies of an empty row and putting them into a list. The error occurs because each row exists as a reference to the empty_row variable - so when an "X" is placed, it places an "X" in each row. The code below contains a fixed version.

In [ ]:
"""Broken tic-tac-toe game"""

# MCS 275 Spring 2022 - David Dumas

# Inspired by an example from https://github.com/satwikkansal/wtfpython#-a-tic-tac-toe-where-x-wins-in-the-first-attempt
# Warning: link above contains major spoilers, as well as some crude language.
# You don't need to look at that link at all, and *definitely* shouldn't look
# until after you're done.

def winner(b):
    """Given a TTT board `b`, determine who has won and return.  If no one has won,
    return None"""
    # Row of three
    for row in b:
        if row[0] == " ":
            # First row entry is blank; ignore!
            continue
        if row[0]==row[1] and row[1]==row[2]:
            return row[0]
    # Column of three
    for i in range(3):
        if b[0][i] == " ":
            # First column entry is blank; ignore!
            continue
        if b[0][i]==b[1][i] and b[1][i]==b[2][i]:
            return b[0][i]
    # Diagonals
    if b[1][1] != " ":
        # Middle entry not blank, so diagonal win 
        # is a possibility.
        if b[0][0] == b[1][1] and b[1][1]==b[2][2]:
            return b[0][0]
        if b[0][2] == b[1][1] and b[1][1]==b[2][0]:
            return b[0][2]
    # implicit return None


def print_board(b):
    """Show the board with 1-based coordinates"""
    print("  A|B|C")
    for i,r in enumerate(b):
        print("{}".format(i+1),"|".join(r))
        if i<2:
            print("---+-+-")


COLS = ["a","b","c"]

def apply_move(b,player,move):
    """Modify board b (list of lists) to account for move in string `move`.
    If the move is illegal, raises an exception."""
    move = move.strip().lower()
    if len(move)!=2:
        raise Exception("Valid move is two characters (e.g. A2 or B3)")
    if move[0] not in COLS:
        move = move[::-1]
    if move[0] not in COLS:
        raise Exception("No column spec found")
    j = COLS.index(move[0])
    i = int(move[1])-1
    if b[i][j] != " ":
        raise Exception("Another move already filled that position")
    b[i][j] = player


if __name__=="__main__":
    print("Tic-tac-toe: enter moves by coordinates (e.g. A1 or B3)")
    print()
    board = [[ " " for i in range(3)] for j in range(3)] # create empty 3x3 grid
    cur,other = "X","O"
    while True:
        print_board(board)
        print()
        w = winner(board)
        if w:
            print("+----------------+")
            print("| Player {} wins! |".format(w))
            print("+----------------+")
            break
        print("It is {}'s turn.".format(cur,other))
        m = input("Move? ")
        print()
        try:
            apply_move(board,cur,m)
        except Exception:
            print("That move was not recognized, or not possible.\n")
            continue
        # switch active player
        cur,other = other,cur