Hangman—PyQt5 (Part 2)

This is the second part of a tutorial on a Hangman game written in Python using PyQt5.

In the first part of this tutorial, we created a Hangman class that handles the application logic for creating a Hangman game in Python. In this part, we will write a GUI client that uses the Hangman class.

PyQt

Qt is a cross platform application framework that includes a GUI toolkit. Python ships with tkinter as it’s standard GUI API, but I am actually more comfortable using Qt. Qt is a an object orietated framework that provides us with classes that represent controls on the screen (known as widgets).

Using Python’s OOP features, we can develop a GUI in small increments and then put them together into a single application window. That’s what we are going to do in this tutorial.

Here is the complete GUI code for hangman.

import sys

from PyQt5.QtCore import QCoreApplication
from PyQt5.QtGui import QPixmap
from PyQt5.QtWidgets import (QWidget, QPushButton, QGridLayout, QApplication, QLabel, QHBoxLayout, QVBoxLayout,
                             QMainWindow, QInputDialog)

from hangman import Hangman


class HangmanPanel(QWidget):
    def __init__(self, hangman, frames):
        super().__init__()
        self.hangman = hangman
        self.frame_index = 0
        self.frames = self.create_frames(frames)
        self.label = QLabel(self)
        self.layout = QHBoxLayout(self)
        self.layout.addWidget(self.label)
        self.advance_frame()

    def create_frames(self, frames):
        frm = []
        for f in frames:
            frm.append(QPixmap(f))
        return tuple(frm)

    def advance_frame(self):
        self.label.setPixmap(self.frames[self.frame_index])
        self.frame_index += 1

        if self.frame_index >= len(self.frames):
            self.frame_index = 0

    def reset_hangman(self):
        self.frame_index = 0
        self.advance_frame()


class LetterPanel(QWidget):
    def __init__(self, hangman):
        super().__init__()
        self.hangman = hangman
        self.create_buttons()
        self.grid = QGridLayout(self)
        self.position_buttons()

    def create_buttons(self):
        letters = ('A', 'B', 'C', 'D', 'E',
                   'F', 'G', 'H', 'I', 'J',
                   'K', 'L', 'M', 'N', 'O',
                   'P', 'Q', 'R', 'S', 'T',
                   'U', 'V', 'W', 'X', 'Y', 'Z')
        buttons = []
        for l in letters:
            buttons.append(QPushButton(l, self))
        self.buttons = tuple(buttons)

    def position_buttons(self):
        positions = [(i, j) for i in range(7) for j in range(4)]
        buttons = list(self.buttons)
        buttons.reverse()

        for pos in positions:
            if buttons:
                self.grid.addWidget(buttons.pop(), *pos)

    def activate_all(self):
        for button in self.buttons:
            button.setEnabled(True)


class WinsPanel(QWidget):
    def __init__(self, hangman):
        super().__init__()
        self.hangman = hangman

        self.label = QLabel(self)
        self.label.setText('Wins')

        self.win_label = QLabel(self)
        self.win_label.setText('0')

        self.layout = QHBoxLayout(self)
        self.layout.addWidget(self.label)
        self.layout.addWidget(self.win_label)

    def update_wins(self):
        self.win_label.setText(str(hangman.wins))


class DisplayPanel(QWidget):
    def __init__(self, hangman):
        super().__init__()
        self.hangman = hangman

        self.label = QLabel(self)
        self.word_label = QLabel(self)

        self.layout = QVBoxLayout(self)
        self.layout.addWidget(self.label)
        self.layout.addWidget(self.word_label)


class WordPanel(DisplayPanel):
    def __init__(self, hangman):
        super().__init__(hangman)

        self.label.setText('Current Word')
        self.update_word()

    def update_word(self):
        self.word_label.setText(' '.join(self.hangman.display_letters))


class GuessedLetterPanel(DisplayPanel):
    def __init__(self, hangman):
        super().__init__(hangman)

        self.label.setText("Letters Already Guessed")
        self.update_letters()

    def update_letters(self):
        self.word_label.setText(', '.join(self.hangman.guessed_letters))


class HangmanWindow(QMainWindow):
    def __init__(self, hangman):
        super().__init__()
        self.hangman = hangman

        self.wins_panel = WinsPanel(hangman)
        self.hangman_panel = HangmanPanel(hangman, ['hangman_0.png',
                                                    'hangman_1.png',
                                                    'hangman_2.png',
                                                    'hangman_3.png',
                                                    'hangman_4.png',
                                                    'hangman_5.png',
                                                    'hangman_6.png',
                                                    'hangman_7.png'])
        self.word_panel = WordPanel(hangman)
        self.guessed_letter_panel = GuessedLetterPanel(hangman)
        self.letter_panel = LetterPanel(hangman)

        central_widget = QWidget()
        central_layout = QHBoxLayout(central_widget)

        left_widget = QWidget()
        left_layout = QVBoxLayout(left_widget)
        left_layout.addWidget(self.wins_panel)
        left_layout.addWidget(self.hangman_panel)
        left_layout.addWidget(self.word_panel)
        left_layout.addWidget(self.guessed_letter_panel)

        right_widget = QWidget()
        right_layout = QHBoxLayout(right_widget)
        right_layout.addWidget(self.letter_panel)

        central_layout.addWidget(left_widget)
        central_layout.addWidget(right_widget)

        self.connect_listeners()
        self.setCentralWidget(central_widget)
        self.setWindowTitle('Hangman')
        self.show()

    def connect_listeners(self):
        for button in self.letter_panel.buttons:
            button.clicked.connect(self.on_letter_button_click)

    def on_letter_button_click(self):
        sender = self.sender()

        letter = sender.text()
        sender.setEnabled(False)

        current_guess = hangman.guesses

        self.hangman.guess_letter(letter)
        self.guessed_letter_panel.update_letters()
        self.word_panel.update_word()

        if current_guess < hangman.guesses:
            self.hangman_panel.advance_frame()

        if hangman.check_win():
            self.wins_panel.update_wins()
            self.show_win()
        elif hangman.check_lose():
            self.show_lose()

    def show_win(self):
        items = ('Yes', 'No')
        item, ok = QInputDialog.getItem(self, 'You Win! Play again?', 'Choice:', items, 0, False)
        if ok and item == 'Yes':
            self.reset_game()
        else:
            QCoreApplication.instance().quit()

    def show_lose(self):
        items = ('Yes', 'No')
        item, ok = QInputDialog.getItem(self, 'You Lost! Play again?', 'Choice:', items, 0, False)
        if ok and item == 'Yes':
            self.reset_game()
        else:
            QCoreApplication.instance().quit()

    def reset_game(self):
        hangman.start_game()
        self.wins_panel.update_wins()
        self.hangman_panel.reset_hangman()
        self.word_panel.update_word()
        self.guessed_letter_panel.update_letters()
        self.letter_panel.activate_all()


if __name__ == '__main__':
    app = QApplication(sys.argv)
    hangman = Hangman('words.txt', 6)
    win = HangmanWindow(hangman)
    sys.exit(app.exec_())

When run, this code will produce a window that looks like this screen shot.
PyQt Hangman copy

HangmanPanel

The first thing we need is a panel that shows the man getting hanged in the window as the user plays the games. We begin by using 8 images for each of the frames.

Now let’s look at the code.

class HangmanPanel(QWidget):
    def __init__(self, hangman, frames):
        super().__init__()
        self.hangman = hangman
        self.frame_index = 0
        self.frames = self.create_frames(frames)
        self.label = QLabel(self)
        self.layout = QHBoxLayout(self)
        self.layout.addWidget(self.label)
        self.advance_frame()

    def create_frames(self, frames):
        frm = []
        for f in frames:
            frm.append(QPixmap(f))
        return tuple(frm)

    def advance_frame(self):
        self.label.setPixmap(self.frames[self.frame_index])
        self.frame_index += 1

        if self.frame_index >= len(self.frames):
            self.frame_index = 0

    def reset_hangman(self):
        self.frame_index = 0
        self.advance_frame()

This class extends the QWidget class. QWidget is the super class for objects that are displayed by Qt. By inheriting from QWidget, we can uses instances of this class as a GUI component as either a nested component in a window, or as a standalone window.

The ability to display QWidgets in their own window is a huge feature of PyQt. For example, we can test that this window is working properly by adding the following lines of code to our script.

if __name__ == '__main__':
    app = QApplication(sys.argv)
    win = HangmanPanel(None, ['hangman_0.png'])
    win.show()
    sys.exit(app.exec_())

When run, we get our window.
HangmanPanel copy

By using the technique described above, we can test each component of our GUI in isolation to make sure it’s working properly before we continue. Now let’s move onto a description of this class.

__init__

Python uses an __init__ function to create an object.

def __init__(self, hangman, frames):
    super().__init__()
    self.hangman = hangman
    self.frame_index = 0
    self.frames = self.create_frames(frames)
    self.label = QLabel(self)
    self.layout = QHBoxLayout(self)
    self.layout.addWidget(self.label)
    self.advance_frame()

The first line is critical. We have to call QWidget’s __init__ method before we do anything else with this component. If we forget to do so, PyQt will throw an exception and let you know about the error.

The next line of code is our Hangman object. This is an instance of the Hangman class that we made in part 1 of the tutorial. The next variable is to track what frame we are currently on. Every time the user guesses wrong, we are going to increment frame_index by one. The frame_index variable is used to show which frame of the hangman game we are going to show.

The self.frames variable is a list of all of the possible frames that we are going to show. It uses the self.create_frames function to read each file supplied in the frames variable. That function will return QPixmap objects that represent each picture that we supplied to the program.

self.label is a QLabel control. The QLabel is a widget that is capable of displaying a picture (among other things). We are going to pass QPixmap objects to this QLabel object and display our hanging man as the game progresses.

Next we need a layout manager. This is an object that is used by Qt to position controls on the screen. QHBoxLayout is used to display components from left to right in a horizontal row. The next line self.layout.addWidget(self.label) adds the QLabel object to this screen. Finally, we want to show our starting hangman from by calling self.advance_frame() described below.

create_frames

The create_frames function is used to load our picture files from the file system and wrap them into QPixmap objects. Here is the code for this function.

def create_frames(self, frames):
    frm = []
    for f in frames:
        frm.append(QPixmap(f))
    return tuple(frm)

This begins by creating an empty frm list object. Then we iterate through each item in frames. We create a new QPixmap object, which accepts the location of the frame file on the file system. The QPixmap object is added the frm list. Since we do not want to change the frames in any way after loading them, we convert frm into a tuple (think of it as a read-only list) and return it to the caller.

advance_frame

The advance_frame function does the job of moving the program to the next frame of hangman.

def advance_frame(self):
    self.label.setPixmap(self.frames[self.frame_index])
    self.frame_index += 1

    if self.frame_index >= len(self.frames):
        self.frame_index = 0

We created a QLabel object in the __init__ function and stored it in self.label. We also created our frames as a tuple of QPixmaps in create_frames. We can change the frame that is displayed by using QLabel.setPixmap() and passing a QPixmap to it.

So what this code is doing on line one is looking up a QPixmap located at the current frame_index. On the second line, we increment self.frame_index by one. Our next section of code does bounds checking on the self.frames tuple. If we at the end of the frames, we just reset self.frame_index to zero so that we don’t try and go past the number of frames that we have.

reset_hangman

The final method of the HangmanPanel class gets used to reset the game.

def reset_hangman(self):
    self.frame_index = 0
    self.advance_frame()

You will notice that this code repeats some of what is done in the constructor. It resets the frame_index to 0 and then calls advance_frame to display the first frame in the sequence.

Conclusion

This part of the tutorial discusses the first GUI component of our Hangman GUI. PyQt5 applications are easy to develop because we can work on each component in isolation and make sure things are working properly before continuing. In this post, we worked on the portion of the GUI that displays our man getting hanged as the user guesses wrong.

Advertisements

Hangman—PyQt5 (Part 1)

The is the first part of a 5 part series. You can view the other parts

Hangman games are a popular programming game that many of my students are asked to implement. I decided to do one in Python using the PyQ5 library.

Before working on this tutorial, you may need to install pyqt5 on your system.

pip3 install pyqt5

Before we begin…

We need to create a file that holds our list of words. Here is one that can help you get started.

Kitties
Python
Foliage
Assorted
Soda
Stream
Democracy
Blog
Selection
Insertion

This file will be called words.txt throughout this tutorial. It needs to get placed in the same folder as the rest of our scripts.

Application Logic

It is always advisable to seperate the logic of your application from the display. This let’s us reuse our application code but plug it into different displayes. For example, this tutorial uses PyQt5, but in theory, we may wish to have a Tkinter (the standard Python GUI toolkit) version of this program also. When we seperate application logic from display, we can write different clients for the same program.

Let’s begin with the code first and then walk through it. Here is the core application logic for our hangman game.

from random import randint

class Hangman:
    def __init__(self, word_list_file, allowed_guesses=15):
        self.words = self.read_word_file(word_list_file)
        self.allowed_guesses = allowed_guesses
        self.start_game()
        self.wins = 0

    def start_game(self):
        self.secret_word = self.pick_secret_word()
        self.display_letters = self.create_display_letters()
        self.guessed_letters = []
        self.guesses = 0

    @staticmethod
    def read_word_file(word_list_file):
        word_list = []
        with open(word_list_file, 'r') as f:
            for line in f:
                word_list.append(line.rstrip())
        return word_list

    def pick_secret_word(self):
        index = randint(0, len(self.words) - 1)
        return self.words[index].upper()

    def create_display_letters(self):
        letters = []
        for _ in self.secret_word:
            letters.append('-')
        return letters

    def guess_letter(self, letter):
        if letter not in self.guessed_letters:
            guess_wrong = True
            self.guessed_letters.append(letter)
            for i in range(len(self.secret_word)):
                if letter == self.secret_word[i]:
                    guess_wrong = False
                    self.display_letters[i] = letter
            if guess_wrong:
                self.guesses += 1

    def check_win(self):
        word = ''.join(self.display_letters)
        if word == self.secret_word and self.guesses  self.allowed_guesses

Our hangman program needs to hold data and have functions that operate on the data. This suggests that we should use Object Orientated Programming (OOP). Let’s begin by pointing out the data Hangman needs to keep track of first.

  • wins
  • secret word
  • display letters
  • guessed_letters
  • guesses
  • allowed guesses

Here is a list of behaviors that Hangman needs to perform to work on the data.

  • __init__
  • start_game
  • read_word_file
  • pick_secret_word
  • create_display_letters
  • guess_letter
  • check_win
  • check_lose

Let’s take each function one at a time…

__init__

__init__ is a function that all Python classes have that is used to initalize an object. Programmers from other OOP languages such as Java or C++ may refer to this as a constructor method.

Here is our __init__ code

def __init__(self, word_list_file, allowed_guesses=15):
    self.words = self.read_word_file(word_list_file)
    self.allowed_guesses = allowed_guesses
    self.start_game()
    self.wins = 0

You will notice that our __init__ function requires a word_list_file which is the path to a file that contains our list of words. The first thing this function does is reads the words_list_file using the read_word_file function. That function returns all of the words in the file as a list. One the next line, we set the self.allowed_guesses variable to the supplied allowed_guesses parameter. Then we call self.start_game() to initialize the game.

start_game

start_game initializes our hangman game. The reason this function is seperate from __init__ is so that clients can start a new game of hangman by calling this method, rather than recreating a new Hangman object. Here is the code for the start_game fucntion.

def start_game(self):
    self.secret_word = self.pick_secret_word()
    self.display_letters = self.create_display_letters()
    self.guessed_letters = []
    self.guesses = 0

Mostly speaking, we are just initializing our class variables in this function. The first thing we do is pick a secret word. We use self.pick_secret_word() which randomly picks a word from our word list and returns it. Next we need to create a list that holds the user’s correct guesses. At first, we want it to contain only dash (‘-‘) characters, so we use self.create_display_letters to build that list and return it to this function.

Our next line creates an empty list to hold the user’s guesses followed by a line to set their incorrect guesses to zero.

read_word_file

We met read_word_file in __init__. Now let’s talk about how it works. Here is the code.

@staticmethod
def read_word_file(word_list_file):
    word_list = []
    with open(word_list_file, 'r') as f:
        for line in f:
            word_list.append(line.rstrip())
    return word_list

This method is decorated with @staticmethod which indicate the method belongs to the class Hangman rather than individual Hangman objects. The first thing we do is create an empty word_list object that holds our words. The next thing we do is open word_list_file in read mode using the statement with open (word_list_file, 'r') as f. That statement opens the file and assigns it the variable f. It will also handle closing the file when we are done.

Now we need to read the file line by line. We do that with the for loop. As we go through each line in the file, we need to strip off any trailing ‘\n’ characters, which what line.rstrip() does. At this point, we can add our word to the word_list. When we are done reading the file, we can return the word_list to the calling method.

pick_secret_word

We met pick_secret_word in the start_game function. Here is the code for this method followed by an explanation.

def pick_secret_word(self):
    index = randint(0, len(self.words) - 1)
    return self.words[index].upper()

(note: see Random Int—Python for a post dedicated to picking random items from a list)

In this code, we need to pick a random number between 0 and the number of words – 1. We need to use -1 because lists are zero based. Once we have a number, we return a secret word from the words list and convert it to an upper cased string for easy comparisons later on in the program.

create_display_letters

Now that we have picked our secret word, we can use create_display_letters to assign dash (‘-‘) characters for each unknown letter in the game. Latter on, we will replace the dashes with correct guesses. Here is the code for create_display_letters.

def create_display_letters(self):
    letters = []
    for _ in self.secret_word:
        letters.append('-')
    return letters

In this function, we create a letters list object that is empty. Then we go through each letter in self.secret_word. We use the underscore (‘_’) to indicate that we aren’t actually interested in each letter so there is no need to assign to a variable. At each iteration of the loop, we just append a dash to the letters list. When this method returns, we have a list that is the same length as secret word but it is filled with dashes.

guess_letter

This function is the main entry point for clients that use this class. The idea here is that the client get’s the user’s guess from some sort of input (note that this class isn’t concerned with how that input is gathered from the user) and the guess is passed to this guess_letter function. Here is the code followed by an explanation.

def guess_letter(self, letter):
    if letter not in self.guessed_letters:
        guess_wrong = True
        self.guessed_letters.append(letter)
        for i in range(len(self.secret_word)):
            if letter == self.secret_word[i]:
                guess_wrong = False
                self.display_letters[i] = letter
        if guess_wrong:
            self.guesses += 1

Again, this begins with a letter variable that is supplied by the caller. We don’t care at this point how the program got the letter from the user. Now, first we need to check that that the letter wasn’t already guessed. If it was, we just ignore the guess.

Now, we are creating a boolean variable that is set to True to assume that they guessed wrong. The next line adds the guess to the guessed_letters list. Next, we are going to go through self.secret_word one letter at a time using indexes.

Inside of the loop, we check if the guessed letter matches the current letter in self.secret_word at index ‘i’. If we have a match, we set gress_wrong to False, and we replace the dash character in self.display_letters at index to the letter.

Finally, we check if they have guessed right or wrong. If guess_wrong is True, we update the self.guesses by 1 to indicate that they have guessed wrong.

check_win

The client of this class needs a way to check if the user won the game. We use the check_win function as a way to report back to the client if the user has won the game. Here’s the code followed by an explanation.

def check_win(self):
    word = ''.join(self.display_letters)
    if word == self.secret_word and self.guesses <= self.allowed_guesses:
        self.wins += 1
        return True
    else:
        return False

What we are doing here is assembling the user's guesses into a single string using ''.join(self.display_letters. The string will end up being something like ‘K-tt-es’ if they haven’t guessed all of the letters in the word yet, but if they have guessed everything right, it would be ‘Kitties’.

The next thing to do is see if word is equal to self.secret_word. As long as they match and the user hasn’t exceeded the number of allowed guesses, they win the game. We increment self.wins by 1 and return True. Alternatively, we return False to indicate they have not one the game.

check_lose

Our final function in Hangman is check_lose. This just checks if they have lost the game by exceeding the number of allowed incorrect guesses.

def check_lose(self):
    return self.guesses > self.allowed_guesses

Conclusion

The Hangman class demonstrates many important application concepts in Python. It opens files, picks numbers randomly, and tracks the progress of a game. An extremely important concept is that this class is not tied to any user interface. Instead, it contains the application logic used to play a game of hangman, but allows client modules to import it and use it as needed. The next part of this tutorial will focus on a GUI client that uses this class.

Random Int—Python

There are many applications in programming where we have to randomly pick an element in a list. Python has the random module which includes a randint function for this exact purpose. Here’s an example.

from random import randint

if __name__ == '__main__':
    names = ['Bob Belcher',
             'Linda Belcher',
             'Gene Belcher',
             'Tina Belcher',
             'Lousie Belcher']
    num = randint(0, len(names) - 1) # -1 because lists are 0 based
    print('Random number was', num)
    print('Name picked randomly is', names[num])

When run, this code produces the following output (which is different each time the script is ran).

Random number was 4
Name picked randomly is Lousie Belcher

Selection Sort—Python

Selection sorts are a very common method of sorting. Here is a selection sort that is implemented in Python.

def selection_sort(items):
    for i in range(len(items) - 1):
        # Assume that the smallest item is located at index i
        smallest = i

        # Now loop through the rest of the list
        for j in range(i + 1, len(items)):
            # Is our item at index j smaller than our smallest item?
            if items[j] < items[smallest]:
                smallest = j

        #  Now swap elements to perform the sort
        temp = items[smallest]
        items[smallest] = items[i]
        items[i] = temp

if __name__ == '__main__':
    items = ['Bob Belcher', 'Linda Belcher', 'Gene Belcher', 'Tina Belcher', 'Louise Belcher']
    print(items, '\n')

    print('Sorting...')
    selection_sort(items)
    print(items)

Selection sorts use nested loops to iterate over a list. We check if the item at index j is less than the current smallest item. If j is smaller than smaller, we assign smaller to j.

Once we complete the nested loop, we can perform a swap. Our driver program demonstrates this with strings, but thanks to Python's duck typing system, this will sort any object that implements __lt__.

When run, we get this output

['Bob Belcher', 'Linda Belcher', 'Gene Belcher', 'Tina Belcher', 'Louise Belcher'] 

Sorting...
['Bob Belcher', 'Gene Belcher', 'Linda Belcher', 'Louise Belcher', 'Tina Belcher']

Bubble Sort—Java

The bubble sort is a very common algorithm that many computer science students are expected to learn. Yesterday I did a post that shows a Python implementation of the bubble sort which you can view here. Today I will demonstrate the same technique in Java.

import java.util.Arrays;

public class BubbleSort {

public static void main(String [] args){
Integer [] nums = {9, 4, 2, 1, 10};
System.out.println("Unsorted is " + Arrays.toString(nums));

bubbleSort(nums);
System.out.println("Sorted is " + Arrays.toString(nums));
}

public static<T extends Comparable> void bubbleSort(T [] array){
int n = array.length;
T temp;

for(int i = 0; i < n; i++){
for (int j = 1; j  0){
//Swap the elements
temp = array[j - 1];
array[j - 1] = array[j];
array[j] = temp;
}
}
}
}
}

Java is a little more verbose than Python. Since it is a statically typed language (which means you have to declare the data type up front, as opposed to Python’s duck typing method), I chose to use generics to implement this sort.

Generics were introduced in JDK 1.5. They allow us to write code that is widely applicable to many types of objects without forcing us to use the Object class. You can find a wide use of generics in the java.util package.

In this case, I declared my generic method like this

public static<T extends Comparable> void bubbleSort(T [] array){

Let’s go through this one word at a time.

  1. public—This is the keyword that makes this method publically available
  2. static—This keyword attaches this method to the class rather than an object
  3. <T extends Comparable>—This is the code that declares this method to be a generic method. T is the type of the object, followed by extends Comparable which means that any object that implements the Comparable interface is accetable to this method.
  4. T [] array—This declares an array of type T (which is any class that implements Comparable in this case)

Now that we have declared this to be a generic method, this method will work with any object that implements the Comparable interface. The Comparable interface is widely used throughout Java in sorting. It declares a compareTo(T obj) method which returns a positive number when an item is greater than the comparing object, zero when the two objects are equal, and a negative number when the comparing object is less than the other object.

Since this interface exists, Java lets us sort anything that implements this interface easily. That allows programmers a high degree of freedom and flexibility when creating custom classes because the programmer gets to decide how one object is greater than, less than, or equal to another object.

We apply the Comparable interface to this bubble sort method here

if(array[j - 1].compareTo(array[j]) > 0){
//Swap the elements
temp = array[j - 1];
array[j - 1] = array[j];
array[j] = temp;
}

If the left hand portion array[j - 1].compareTo(array[j]) is 0, then these two items are 0. If the result is a negative number then array[j - 1] is less than array[j], otherwise array[j - 1] is greater than array[j] and it needs to get swapped.

When run, we get this output

Unsorted is [9, 4, 2, 1, 10]
Sorted is [1, 2, 4, 9, 10]

Python Jeopardy (Part 1)

Introduction

Once upon a time, I was tutoring a student who had taken an introduction to Java course who had a unique course project. The student’s job was to build an implementation of Jeopardy throughout the course. At the beginning, they would start simple with a basic program that read user input from the console. At the end of the semester, they had a full fledged GUI program that communicated over network sockets, communicated with a database, and even had multi-threading.

I loved this project because it literally covered every possible programming topic outside of web programming. This student had to learn out how read and react to user input from both the GUI and the console. The requirements of the program required them how to learn to read from a file. There was plenty of opportunity to learn Object Orientated Programming (OOP). I was so impressed with the material that was covered with this project that I asked the student to share the assignments with me when the semester was over. Thankfully, the student agreed!

Python Jeopardy

This is the beginning of a series of posts where we will recreate the Jeopardy program using the Python language. Our first task is to start by creating a plain text file that will contain our Jeopardy game board. Here is an excerpt of the file.

Earthquakes::Jeoportmanteau!::Ashes to Ashes::Dust To Dust::"K" Mart
400::800::1200::1600::2000
Earthquakes::400::This well-known fault is considered the main boundary between the North American & Pacific plates::the San Andreas Fault
...
FJ::Last name of Sir Clifford, whose crippling war injuries make life difficult for his wife::Chatterley

The first line of the input file lists our categories in our game of Jeopardy. Since it’s common to use commas in Jeopardy questions, we use :: to seperate each of our categories. Our requirements state that we have to have five categories or the file is invalid.

The next line of the file lists all of the possible point values. Like the categories line, we use :: to seperate each point. There have to be five points and they must be whole numbers.

The next twenty-six lines contain the questions for the game of Jeopardy. We start with the question’s category, followed by it’s point value. After the points we have the question (or the answer, depending how you wish to view it). and then finally we have the question’s answer. Our requirements state that we have to have 25 questions.

However we have a special question that can appear anywhere in this file exception for the first two lines. This is the final Jeopardy question. This line starts with FJ and the program needs to respond correctly when it sees the final jeopardy question. An example of the final jeopardy line is found on the last line of our example.

Implementation

Python supports object orientated programming, which lets us model components in the system as objects. At this point, we have two objects in our system. Our first kind of an object is our standard question. Let’s begin by making a class that models this component.

GameTile

class GameTile:
    def __init__(self, category='', points=0, question='', answer=''):
        self.category = category  # The question category
        self.points = points  # The point value of this question
        self.question = question  # The question
        self.answer = answer  # The answer

    def __str__(self):  # This method creates a string representation of this object
        # Let's store all of our properties in a dict object
        result = {'category': self.category,
                  'points': self.points,
                  'question': self.question,
                  'answer': self.answer}

        # Now we can convert the dict to a string which will give us friendly formatting
        return str(result)

    def __repr__(self):  
        # This method also creates a String representation of a Python object
        # The Python debugger calls this method rather than __str__
        # But we can just reuse our code by calling __str__
        return self.__str__()

So this is our first Python class of this project. One thing users who are familiar with other languages such as Java may notice is that none of the properties of this class are encapsulated. This may seem odd but keep in mind that Python is about readability and conciseness. We certianly could use Python’s psuedo-private techniques and then add getters and setters, but doing so will simply bloat the code.

At this point, we don’t have a lot of behavior in this class. This class defines a constructor (__init__) and it overrides the __str__ and __repr__ methods to provide us with string representations of this object. In __str__, we store all of our properties in a Python dict variable and then use str() to convert it to a String. __repr__ just simply uses the code in __str__ so that we can get String representations in the console.

Here is the next class in our script that represents the Final Jeopardy question.

FinalJeopardyTile

class FinalJeopardyTile:
    def __init__(self, question='', answer=''):
        self.question = question
        self.answer = answer

    def __str__(self):
        result = {'question': self.question, 'answer': self.answer}
        return str(result)

    def __repr__(self):
        return self.__str__()

Once again, we haven’t added much in terms of behavior in this class. That will come later. However both GameTile and FinalJeopardy serve a useful purpose in that we are grouping related information together into a single entity. Later on, we can add behavior to these classes to work on the data.

Now we have to process our game file and create these objects. Python is a lot like C++ in that we don’t have put our code exclusively in class. At this point in the project, reading the game file isn’t the job of any one particular class so we are going to define a module level function that does the job of reading the file and will return a dict of questions.

Reading the Game File

Here is enter readGameBoard function. Don’t get overwhelmed by the size of it because we are going to break it up into sections and explain each portion.

# These are custom exceptions that we are going to use.
class InvalidGameFile(Exception):
    pass


class IllegalCategoryCount(Exception):
    pass


class IllegalPointsCount(Exception):
    pass

def readGameBoard(gameFile):
    # Read the entire file into memory
    rawLines = open(gameFile, 'r').readlines()

    # Check that we have 28 lines in the game file
    if len(rawLines) != 28:
        # Throw our custom exception to indicate the file is invalid
        raise InvalidGameFile

    # Now this is going to store our files
    fileLines = []
    for line in rawLines:  # Iterate through rawLines one item at a time
        # Now we need to remove any '\n' characters and store them in fileLines
        fileLines.append(line.strip())

    points = []  # Store the points to validate the file later
    gameTiles = {}  # Store all of the questions here

    # There has to be 5 unique categories
    # We are going to split the first line apart by the :: character
    categories = fileLines[0].split('::')

    # Now check that we have 5 unique categories. We use the set() function that
    # will remove any duplicates and then we are going to check the length. It has to be
    # 5 per the requirements
    if len(set(categories)) != 5:
        raise IllegalCategoryCount  # Raise one of our custom exceptions

    # There has to be 5 unique points
    # So now we read the second line and split it one our :: character
    pointValues = fileLines[1].split('::')

    # Now, we have Strings in pointValues, but we need them to be integers
    # So we iterate through pointValues and convert each item to an int
    # before storing it in points. We use the int() function to do the conversion
    for pv in pointValues:
        points.append(int(pv))

    # Now once again, we need to for 5 unique points so we use set again to remove
    # the duplicates and then len to check the size
    if len(set(points)) != 5:
        raise IllegalPointsCount  # Throw a custom exception

    # Now read everything else
    for line in fileLines[2:]:  # Slice off the first two lines
        #  Get a line and split it into its parts
        parts = line.split('::')

        #  Now we check that the category and points are valid
        if parts[0] in categories and int(parts[1]) in points:
            # We can create a GameTile object at this point
            gameTile = GameTile(category=parts[0],
                                points=int(parts[1]),
                                question=parts[2],
                                answer=parts[3])

            # If this our first insertion, we need to create a new list object to store our
            # GameTiles in our dictionary
            if parts[0] not in gameTiles:
                gameTiles[parts[0]] = []
            else:
                # Otherwise we can add our gameTile to gameTiles. Notice that the category is the key
                gameTiles[parts[0]].append(gameTile)

        # This handles the final jeopardy case
        elif parts[0] == 'FJ':
            # gameTiles uses FJ as the key for Final Jeopardy and then we store a FinalJeopardyTile
            gameTiles['FJ'] = FinalJeopardyTile(question=parts[1], answer=parts[2])
        else:
            # Throw our custom exception
            raise InvalidGameFile
    return gameTiles  # Return our dictionary that contains our question and final Jeopardy

Read a file

The first thing we do is open a file. Python makes it super easy to read an entire file into memory with just one line of code!


The open fucntion opens the file. It takes the path to the file (which is stored in gameFile and a flag that tells it kind of open operation we are performing. In this case 'r' means read. Then we just use the readlines() method to read the entire file into memory.

<h4>Check for the proper number of lines</h4>
Our file is only valid if it has 28 lines. Let's check that next.

if len(rawLines) != 28:
    raise InvalidGameFile

Whenever you need to know the len of a string, list, tuple, etc, you can use the built-in len function. In our case, we are calling len on our rawLines variable. If the result is anything beside 28, our file is invalid and we will raise our custom InvalidGameFile exception.

Remove unwanted characters

Our next job is to remove any special characters such as newline '\n' from the game file. The way we do it is below

fileLines = []
for line in rawLines:
    fileLines.append(line.strip())

This code starts by creating an empty list object to store the modified file lines. Then we enter a for loop to go through each item in rawLines one item at a time. We use String's strip() function to remove unwanted characters and we then add it to fileLines.

Validate Categories

Our requirements expect us to have five unique categories. Here is the code that validates the categories.

categories = fileLines[0].split('::')

if len(set(categories)) != 5:
    raise IllegalCategoryCount 

Python Strings have a split method that will split a string into parts based on a supplied character. In our case, we use the :: character to section off each part of our line. Doing so removes the :: characters in the line and it returns a collection of Strings. One item for each portion of our String.

Our next job is to make sure we have five unique categories. We already met the len() function above, but now we have the set() function. This function takes an iterable and removes all duplicates. By wrapping len() around a set() and checking for five, we can be sure that we only have 5 unique categories. Any more or less categories will cause us to raise our custom exception.

Validate Points

We also require that we have 5 unique values for points. We can do this very similar to categories with only a small change.

points = []
pointValues = fileLines[1].split('::')

for pv in pointValues:
    points.append(int(pv)) # Convert to integer here

if len(set(points)) != 5:
    raise IllegalPointsCount

When we start by spliting our string, we end up with hopefully five strings. Python is a strongly typed language. If we want to make a number out of a String, we have use the appropriate function to do so. In our case, we use int() to convert a String to an integer (whole number) value.

If you attempt to call int() on a String that isn't a number, you will get an exception and your program will crash. This is a good thing because we can trace the program back to the point of failure and debug it.

Other than converting the Strings to integers, the logic of this section of code is identitcal to categories.

Creating the GameTiles and Final Jeopardy

Our last order of business is to process the remainder of the file and create our game tile and final jeopardy objets.

gameTiles = {}

for line in fileLines[2:]:  # Slice off the first two lines
    parts = line.split('::')

    if parts[0] in categories and int(parts[1]) in points:
        gameTile = GameTile(category=parts[0],
                            points=int(parts[1]),
                            question=parts[2],
                            answer=parts[3])

        if parts[0] not in gameTiles:
            gameTiles[parts[0]] = []
        else:
            gameTiles[parts[0]].append(gameTile)

    elif parts[0] == 'FJ':
        gameTiles['FJ'] = FinalJeopardyTile(question=parts[1], answer=parts[2])
    else:
        raise InvalidGameFile
return gameTiles

We begin by creating a dict object that will hold our GameTile and FinalJeopardyTile objects. The categories will be used as the keys, and the value will be a list. Combining a dict and a list in this fashion let's use associate multiple values with a single key.

Now we are going to iterate through the remaining lines in the file. Notice that we use the slice operator [2:]. This is a neat Python trick that let's use exclude the first two entries in our list, which we do because we have already processed those lines at this point.

Next we need to validate the category and points that are associated with each question. We can check that parts[0] (the category) is in our category list that we created earlier. The same holds true for points (parts[1]) but notice that we convert to int here again. If the category and points are value, we move onto to create a GameTile using it's constructor.

Next, we need to check if gameTiles has a list associated with a category. If it doesn't have one yet, we need to create it. After that, we can look up a list with a category and then append the GameTile to that list. This keeps the questions organized by their category.

The elif block handles the final jeopardy case. If the parts[0] == 'FJ', we have ran into our final jeopardy question. In this case, we create a special FinalJeopardyTile and associate it with the 'FJ' key in the dictionary. There is no list here because we only want to hold one final jeopardy object.

Finally we can return our newly constructed dictionary.

Running the code

Python make it easy to develop applications quickly because we can easily run modules as standalone applications.

if __name__ == '__main__':
    print(readGameBoard('questions.txt'))

This code only executes when the module is ran on it's own. When it is run, it will look for a file called questions.txt in the same folder as the module. When run successfully, we get this output.

{'Earthquakes': [{'category': 'Earthquakes', 'points': 800, 'question': "... (continues)

You can get the entire code for this tutorial from here: python_tutorial_1