Stack—Python

Implementing a Stack is a common problem that computer science students are asked to do while learning about data structures. Stacks are a data structure that implemenet the LIFO (last in, first out) principle. We can implement stacks using a linked list methodology or an array backed list.

In the real world, Python’s built in list object is one of many production tested objects that can and should be used for stacks. Our stack is a linked list implementation of a simple stack.

Here is the example code that we will work with followed by an explanation about how it works.

import unittest

class Node:
    def __init__(self, element=None, next_node=None):
        self.element = element
        self.next_node = next_node

    def __str__(self):
        if self.element:
            return self.element.__str__()
        else:
            return 'Empty Node'

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

class EmptyStackException(Exception):
    pass

class Stack:
    def __init__(self):
        self.top = None
        self.size = 0

    def push(self, data):
        self.top = Node(data, self.top)
        self.size += 1

    def pop(self):
        if self.top:
            val = self.top.element
            self.top = self.top.next_node
            self.size -= 1
            return val
        else:
            raise EmptyStackException

    def peek(self):
        if self.top:
            return self.top.element
        else:
            raise EmptyStackException

    def empty(self):
        return self.size == 0

    def __str__(self):
        elements = []
        curr = self.top
        while curr is not None:
            elements.append(curr.element)
            curr = curr.next_node
        return elements.__str__()

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

class TestStack(unittest.TestCase):
    def __init__(self, method_name='runTest'):
        super().__init__(method_name)
        self.names = ['Bob Belcher',
                      'Linda Belcher',
                      'Tina Belcher',
                      'Gene Belcher',
                      'Louise Belcher']

    def test_init(self):
        stack = Stack()
        self.assertIsNone(stack.top)
        self.assertEqual(stack.size, 0)

    def test_push(self):
        stack = Stack()
        for name in self.names:
            stack.push(name)

        names = list(self.names)
        names.reverse()

        self.assertEqual(names.__str__(), stack.__str__())
        self.assertEqual(len(names), stack.size)

    def test_pop(self):
        stack = Stack()
        for name in self.names:
            stack.push(name)

        self.assertEqual(stack.pop(), 'Louise Belcher')
        self.assertEqual(stack.size, 4)

        self.assertEqual(stack.pop(), 'Gene Belcher')
        self.assertEqual(stack.size, 3)

        self.assertEqual(stack.pop(), 'Tina Belcher')
        self.assertEqual(stack.size, 2)

        self.assertEqual(stack.pop(), 'Linda Belcher')
        self.assertEqual(stack.size, 1)

        self.assertEqual(stack.pop(), 'Bob Belcher')
        self.assertEqual(stack.size, 0)

        with self.assertRaises(EmptyStackException):
            stack.pop()

    def test_peek(self):
        stack = Stack()
        for name in self.names:
            stack.push(name)

        self.assertEqual(stack.peek(), 'Louise Belcher')
        self.assertEqual(stack.size, 5)

        stack = Stack()
        with self.assertRaises(EmptyStackException):
            stack.peek()

    def test_empty(self):
        stack = Stack()
        self.assertTrue(stack.empty())

        for name in self.names:
            stack.push(name)

        self.assertFalse(stack.empty())

if __name__ == '__main__':
    unittest.main()

Node

Since this is a linked list implementation of a stack, we are going to begin with the Node class. The Node does the work of holding the data in the stack and contains a reference to the next item in the stack.

class Node:
    def __init__(self, element=None, next_node=None):
        self.element = element
        self.next_node = next_node

    def __str__(self):
        if self.element:
            return self.element.__str__()
        else:
            return 'Empty Node'

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

There isn’t a lot going on in this class in terms of methods. It has an __init__ method which takes optional element and next_node parameters that default to None. This class also implements __str__ and __repr__ so that debuggers such as PyCharm print out user readable information.

EmptyStackException

The EmptyStackException class is a custom exception that gets raised when client code attempts to remove items from an empty stack.

class EmptyStackException(Exception):
    pass

This class doesn’t do anything other than subclass the Exception base class. For this reason, it’s implemented with a pass statement.

Stack

Stack is our main class the implements a Stack. We begin with the code for this class followed by an explanation about how it works.

class Stack:
    def __init__(self):
        self.top = None
        self.size = 0

    def push(self, data):
        self.top = Node(data, self.top)
        self.size += 1

    def pop(self):
        if self.top:
            val = self.top.element
            self.top = self.top.next_node
            self.size -= 1
            return val
        else:
            raise EmptyStackException

    def peek(self):
        if self.top:
            return self.top.element
        else:
            raise EmptyStackException

    def empty(self):
        return self.size == 0

    def __str__(self):
        elements = []
        curr = self.top
        while curr is not None:
            elements.append(curr.element)
            curr = curr.next_node
        return elements.__str__()

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

__init__

The __init__ method intializes the data structure to an empty stack.

def __init__(self):
    self.top = None
    self.size = 0

We have two class variables in our stack. The first variable, self.top, is a Node that represents the last item inserted into the Stack. It’s next_node property points to the next item in the stack, and so one. The other class variable is self.size, which represents the number of items that are placed in the stack.

push

The push method is used to add items to the stack.

def push(self, data):
    self.top = Node(data, self.top)
    self.size += 1

Whenever we add items to a Stack, the newest Node becomes the top of the stack. If you remember back from our Node class, it’s __init__ method has an optional next_node argument we can use to initialize the new Node with it’s next_node. This works well for us here because we can pass self.top as the next_node of the new Node and then immediatly assign the new Node to self.top. In this way, the last item in the Stack get’s shifted right and the new item becomes the top of the Stack. The next line of code simply increments the size of the stack by one, thus completing the operation of adding (or pushing) an item onto the Stack.

pop

The pop method is opposite operation to the push method. This method removes an item from the Stack.

def pop(self):
    if self.top:
        val = self.top.element
        self.top = self.top.next_node
        self.size -= 1
        return val
    else:
        raise EmptyStackException

The pop method needs to consider two possible cases. The fist case involves a Stack that has items on it and needs an item removed. The other case involves an empty stack. Let’s begin with the first case.

We start by checking if the stack has items. If the stack is empty, self.top is None. Assuming that self.top is not None, we begin by storing the data contained in self.top.element in a variable called val. Our next job is to delete that Node from the Stack by changing self.top to point at self.top.next_node. The original Node will get garbage collected by the Python runtime. Now that we have removed the Node, we need to shrink the size of the stack by one. After we decrease self.size, we can return val to the caller thus completing the pop operation.

If it turns our that the stack is empty, we need to notify the caller. I think it would be a very bad practice to simply return None, since calling pop() on an empty Stack would indicate a programming error. Thus, we raise EmptyStackException. The client code will either need to handle the Exception or allow the program to crash.

peek

The peek operation lets client code take a look at the first element in the Stack without removing the item.

def peek(self):
    if self.top:
        return self.top.element
    else:
        raise EmptyStackException

In this case, we just just need to return self.top.element if the stack has an item, or raise an EmptyStackException if the stack is empty.

empty

Clients of this class need to check if the Stack is empty prior to calling pop() or peek()

def empty(self):
    return self.size == 0

This method simply returns True if self.size == 0 or False if self.size is non-zero.

__str__ and __repr__

We don’t need these methods to represent a Stack, but if you choose to implement them, debuggers such as the on found in PyCharm will use these methods to print more user friendly data to the debugger window.

def __str__(self):
    elements = []
    curr = self.top
    while curr is not None:
        elements.append(curr.element)
        curr = curr.next_node
    return elements.__str__()

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

We are going to leverage Python’s list object to get a reader friendly String representation of our Stack. We start by making an empty list called elements. Then we create a curr variable that points at self.stop. Now, we are going to loop until the end of the Stack by checking if curr is None.

During each iteration of the while loop, we add curr.element to elements. Then we advance to the next node by assigning curr = curr.next_node. When we are done, we will return elements.__str__() which creates a String for us. The __repr__ method works by calling self.__str__().

Conclusion

This article is a simple linked list implementation of a Stack. Stacks are useful in cases where you need a LIFO data structure to track the order of which items are added to the stack. As you can see, a Stack is a much simplier data structure to implement than a linked list.

Doubly Linked List—Python

We saw in Singly Linked List the benefits of using a linked list over an array. Linked lists are data structures that grow and expand as needed. Not only is it easy to add an element at the beginning or end of a linked list, but it is also easy to insert elements at arbitrary positions within the list. We can also easily remove elements from the linked list when they are no longer needed.

The doubly linked list is a varient of the singly linked list. The main complaint about a singly linked list is that it can only traverse the list in one direction starting at the head and working until it reaches the end of the list. Clients may not notice a performance hit when operating on small lists, but large lists will almost certainly have a performance hit.

Doubly linked lists work by tracking both the next node and previous nodes in the list. Tracking both nodes creates more overhead when inserting or removing from the list, but it allows the list to work in a bi-directional fashion. That can allow for a major performance boost when operating on large lists.

Here is the entire code followed by an explanation as to how it works. Note that the topic is the Doubly Linked List so I won’t be covering the unit testing code in this module, but I did include for those who are interested.

class Node:
    def __init__(self, element=None, next_node=None, prev_node=None):
        self.element = element
        self.next_node = next_node
        self.prev_node = prev_node

    def __str__(self):
        if self.element:
            return self.element.__str__()
        else:
            return 'Empty Node'

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


class DoublyLinkedList:
    def __init__(self):
        self.head = Node(element='Head')
        self.tail = Node(element='Tail')

        self.head.next_node = self.tail
        self.tail.prev_node = self.head

    def size(self):
        count = 0
        current = self.head.next_node

        while current is not None and current != self.tail:
            count += 1
            current = current.next_node

        return count

    def insert_front(self, data):
        node = Node(element=data, next_node=self.head.next_node, prev_node=self.head)
        self.head.next_node.prev_node = node
        self.head.next_node = node

    def insert_last(self, data):
        node = Node(element=data, next_node=self.tail, prev_node=self.tail.prev_node)
        self.tail.prev_node.next_node = node
        self.tail.prev_node = node

    def insert(self, data, position):
        if position == 0:
            self.insert_front(data)
        elif position == self.size():
            self.insert_last(data)
        else:
            if 0 < position < self.size():
                current_node = self.head.next_node
                count = 0
                while count < (position - 1):
                    current_node = current_node.next_node
                    count += 1

                node = Node(element=data, next_node=current_node.next_node, prev_node=current_node)
                current_node.next_node.prev_node = node
                current_node.next_node = node
            else:
                raise IndexError

    def remove_first(self):
        self.head = self.head.next_node
        self.head.prev_node = None

    def remove_last(self):
        self.tail = self.tail.prev_node
        self.tail.next_node = None

    def remove(self, position):
        if position == 0:
            self.remove_first()
        elif position == self.size():
            self.remove_last()
        else:
            if 0 < position < self.size():
                current_node = self.head.next_node
                current_pos = 0

                while current_pos < position:
                    current_node = current_node.next_node
                    current_pos += 1

                next_node = current_node.next_node
                prev_node = current_node.prev_node

                next_node.prev_node = prev_node
                prev_node.next_node = next_node
            else:
                raise IndexError

    def fetch(self, position):
        if 0 <= position < self.size():
            current_node = self.head.next_node
            current_pos = 0

            while current_pos < position:
                current_node = current_node.next_node
                current_pos += 1

            return current_node.element
        else:
            raise IndexError


import unittest
from random import randint


class TestDoublyLinkedList(unittest.TestCase):
    names = ['Bob Belcher',
             'Linda Belcher',
             'Tina Belcher',
             'Gene Belcher',
             'Louise Belcher']

    def test_init(self):
        dll = DoublyLinkedList()
        self.assertIsNotNone(dll.head)
        self.assertIsNotNone(dll.tail)
        self.assertEqual(dll.size(), 0)

    def test_insert_front(self):
        dll = DoublyLinkedList()
        for name in TestDoublyLinkedList.names:
            dll.insert_front(name)

        self.assertEqual(dll.fetch(0), TestDoublyLinkedList.names[4])
        self.assertEqual(dll.fetch(1), TestDoublyLinkedList.names[3])
        self.assertEqual(dll.fetch(2), TestDoublyLinkedList.names[2])
        self.assertEqual(dll.fetch(3), TestDoublyLinkedList.names[1])
        self.assertEqual(dll.fetch(4), TestDoublyLinkedList.names[0])

    def test_insert_last(self):
        dll = DoublyLinkedList()
        for name in TestDoublyLinkedList.names:
            dll.insert_last(name)

        for i in range(len(TestDoublyLinkedList.names) - 1):
            self.assertEqual(dll.fetch(i), TestDoublyLinkedList.names[i])

    def test_insert(self):
        dll = DoublyLinkedList()
        for name in TestDoublyLinkedList.names:
            dll.insert_last(name)

        pos = randint(0, len(TestDoublyLinkedList.names) - 1)

        dll.insert('Teddy', pos)
        self.assertEqual(dll.fetch(pos), 'Teddy')

    def test_remove_first(self):
        dll = DoublyLinkedList()
        for name in TestDoublyLinkedList.names:
            dll.insert_last(name)

        for i in range(dll.size(), 0, -1):
            self.assertEqual(dll.size(), i)
            dll.remove_first()

    def test_remove_last(self):
        dll = DoublyLinkedList()
        for name in TestDoublyLinkedList.names:
            dll.insert_last(name)

        for i in range(dll.size(), 0, -1):
            self.assertEqual(dll.size(), i)
            dll.remove_last()

    def test_remove(self):
        dll = DoublyLinkedList()
        for name in TestDoublyLinkedList.names:
            dll.insert_last(name)

        dll.remove(1)

        self.assertEqual(dll.fetch(0), 'Bob Belcher')
        self.assertEqual(dll.fetch(1), 'Tina Belcher')
        self.assertEqual(dll.fetch(2), 'Gene Belcher')
        self.assertEqual(dll.fetch(3), 'Louise Belcher')


if __name__ == '__main__':
    unittest.main()

Node

Like the singly linked list, the doubly linked list begins with a Node class that holds the data contained in the list and references to the previous and next nodes. Here is the code for the Node.

class Node:
    def __init__(self, element=None, next_node=None, prev_node=None):
        self.element = element
        self.next_node = next_node
        self.prev_node = prev_node

    def __str__(self):
        if self.element:
            return self.element.__str__()
        else:
            return 'Empty Node'

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

Here is a break down of each methods in the Node class.

__init__

The __init__ method creates the node. All three of its parameters are optional, but basically we are setting the data contained in the node, and building refrences to next_node and previous_node.

def __init__(self, element=None, next_node=None, prev_node=None):
    self.element = element
    self.next_node = next_node
    self.prev_node = prev_node

__str__ and __repr__

I found that there was more work invloved with debugging doubly linked list, so in this version of the Node class, I chose to implement __str__ and __repr__ so that I could easily identify each Node in my debugger.

def __str__(self):
    # Check if self.element is null
    if self.element:
        # Just return the string representation
        # of self.element
        return self.element.__str__()
    else:
        # Otherwise return Empty Node
        # to indicate the node does not
        # hold data
        return 'Empty Node'

# We are just going to reuse the
# code in __str__ here
def __repr__(self):
    return self.__str__()

Doubly Linked List

This is an example of a doubly linked list. It’s not opitmized for the simply fact that we are learning. If you need an optimized list structure, you Python’s list object.

class DoublyLinkedList:
    def __init__(self):
        self.head = Node(element='Head')
        self.tail = Node(element='Tail')

        self.head.next_node = self.tail
        self.tail.prev_node = self.head

    def size(self):
        count = 0
        current = self.head.next_node

        while current is not None and current != self.tail:
            count += 1
            current = current.next_node

        return count

    def insert_front(self, data):
        node = Node(element=data, next_node=self.head.next_node, prev_node=self.head)
        self.head.next_node.prev_node = node
        self.head.next_node = node

    def insert_last(self, data):
        node = Node(element=data, next_node=self.tail, prev_node=self.tail.prev_node)
        self.tail.prev_node.next_node = node
        self.tail.prev_node = node

    def insert(self, data, position):
        if position == 0:
            self.insert_front(data)
        elif position == self.size():
            self.insert_last(data)
        else:
            if 0 < position < self.size():
                current_node = self.head.next_node
                count = 0
                while count < (position - 1):
                    current_node = current_node.next_node
                    count += 1

                node = Node(element=data, next_node=current_node.next_node, prev_node=current_node)
                current_node.next_node.prev_node = node
                current_node.next_node = node
            else:
                raise IndexError

    def remove_first(self):
        self.head = self.head.next_node
        self.head.prev_node = None

    def remove_last(self):
        self.tail = self.tail.prev_node
        self.tail.next_node = None

    def remove(self, position):
        if position == 0:
            self.remove_first()
        elif position == self.size():
            self.remove_last()
        else:
            if 0 < position < self.size():
                current_node = self.head.next_node
                current_pos = 0

                while current_pos < position:
                    current_node = current_node.next_node
                    current_pos += 1

                next_node = current_node.next_node
                prev_node = current_node.prev_node

                next_node.prev_node = prev_node
                prev_node.next_node = next_node
            else:
                raise IndexError

    def fetch(self, position):
        if 0 <= position < self.size():
            current_node = self.head.next_node
            current_pos = 0

            while current_pos < position:
                current_node = current_node.next_node
                current_pos += 1

            return current_node.element
        else:
            raise IndexError

__init__

This doubly linked list implementation makes use of a head and a tail node. We traversing the list, the code will look for either head or tail as a means to detect if we are at the end of the list.

def __init__(self):
    self.head = Node(element='Head')
    self.tail = Node(element='Tail')

    self.head.next_node = self.tail
    self.tail.prev_node = self.head

We pass the strings “Head” and “Tail” as the element data for each of these nodes for debugging purpose. Since Node implements __str__ and __repr__, we will see Heads and Tail in the debugger (at least when using PyCharm).

The next two lines do the work of pointing head and tail at each other. When the list is created, head.next_node is tail. Conversly, tail.prev_node is head. This indicates an empty list.

size

This code inefficient on purpose. A better implementation would use a length varaible and increment or decrment it as Nodes are added and removed. However, in this case, I wanted to show how to traverse a list without the clutter of adding or removing Nodes.

def size(self):
    count = 0
    current = self.head.next_node

    while current is not None and current != self.tail:
        count += 1
        current = current.next_node

    return count

We start by making a count variable and a current variable that points at self.head.next_node. It’s really important that we use self.head.next_node instead of self.head because self.head’s purpose is to mark the beginning of the list, not contain data. If we failed to make this distinction, the size of the list will be off by one.

Now, we are going to traverse the list increment count by one an each iteration of the loop. We should check for None for defensive programming purposes, but the critical part of the loop condition is if current != self.tail. The self.tail Node marks the end of the list and so we do not wish to include it in the size of our list.

insert_front

This method inserts a new Node at the front of the Linked List.

def insert_front(self, data):
    node = Node(element=data, next_node=self.head.next_node, prev_node=self.head)
    self.head.next_node.prev_node = node
    self.head.next_node = node

Nodes inserted at the front of the list need their prev_node to point at self.head and their next_node to point at the Node located at self.head.next_node.

To complete the insertion, we now need to update self.head.next_node.prev_node to point back at the new Node that we created (otherwise, it would still point at self.head). Likewise, we need to update self.head.next_node to point at our new Node.

insert_last

The code for this is almost identical to insert_head with the main difference being that we are working on the tail Node rather than the head Node.

def insert_last(self, data):
    node = Node(element=data, next_node=self.tail, prev_node=self.tail.prev_node)
    self.tail.prev_node.next_node = node
    self.tail.prev_node = node

Once again, we create a new Node. It’s next_node has to point at self.tail and it’s prev_node needs to point at self.tail.prev_node.

To complete the insertion, we need to update self.tail.prev_node.next_node to point at our new Node (otherwise it will continue to point at self.tail). We also need to point self.tail.prev_node at our new Node.

insert

This method handles the insertion of a Node into the middle of the list. Keep in mind that it has to handle four possible cases.

  • Position could be 0 (front of list)
  • Position could be equal to size() (end of list)
  • Position is in the middle of the list
  • Position is size() (out of bounds)
def insert(self, data, position):
    if position == 0:
        # First case, we insert at front
        self.insert_front(data)
    elif position == self.size():
        # Second case, we insert at end
        self.insert_last(data)
    else:
        if 0 < position < self.size():
            # Third case, insert in middle
            current_node = self.head.next_node
            count = 0
            while count < (position - 1):
                current_node = current_node.next_node
                count += 1

            node = Node(element=data, next_node=current_node.next_node, prev_node=current_node)
            current_node.next_node.prev_node = node
            current_node.next_node = node
        else:
            # Fourth case, index out of bounds
            raise IndexError

The comments point out how this code handles each of the possible cases. Let's focus on the insertion. We begin by creating a count variable and current_node variable. Now, we need to traverse the list until we arrive at the node right before the desired position. Each interation of the loop requires us to point current_node at current_node.next_node.

Once we arrive at our destination, we create a new Node. We will point it's next_node at current_node.next_node and its prev_node at current_node. Doing the insertion at this point in the list causes the new node to appear at the position index specified in the method parameters.

To complete the insertion, we point current_node.next_node.prev_node at the new Node (otherwise it would still point at current_node). Likewise, we point current_node.next_node at our new Node.

Again, I should mention that this code is inefficient again. Normally we would make use of the bidirectional capabilities of the list and decide if we want to traverse the list in forward (like we do here) or in reverse. For example, if we are trying insert into a list at size() – 2, it doesn't make sense to start at the head node and traverse forward to find our insertion point when we could start at tail and move backwards.

remove_first

It’s really simple to remove nodes from the head of a doubly linked list.

def remove_first(self):
     self.head = self.head.next_node
     self.head.prev_node = None

You will notice that all we need to do is point self.head at self.head.next_node. Then we just set self.head.prev_node to None so that it doesn’t continue to point at the old self.head. The old Node will get garbage collected by the Python runtime.

remove_last

Removing the tail from the list is just as easy as removing the head.

def remove_last(self):
    self.tail = self.tail.prev_node
    self.tail.next_node = None

In this case, we just point self.tail at self.tail.prev_node. To remove the old Node, we next poitn self.tail.next_node to None. The old Node will get garbage collected by the Python runtime.

remove

Remove needs to handle the same cases that insert has to handle. Otherwise it works by traversing the list of the position to remove and deletes the Node.

def remove(self, position):
    if position == 0:
        # First case, remove at front
        self.remove_first()
    elif position == self.size():
        # Second case, remove from end
        self.remove_last()
    else:
        if 0 < position < self.size():
            # 3rd case, remove at middle
            current_node = self.head.next_node
            current_pos = 0

            while current_pos < position:
                current_node = current_node.next_node
                current_pos += 1

            next_node = current_node.next_node
            prev_node = current_node.prev_node

            next_node.prev_node = prev_node
            prev_node.next_node = next_node
        else:
            # 4th case, invalid position
            raise IndexError

Again, we will focus on the removal part of this code. We begin by traversing the list to find our desired position. This time, we stop at the actual position rather than the position before it. Once we arrive at our desired position, we store current_node.next_node and current_node.prev_node into their respective variables. To remove current_node, we simply need to point next_node.prev_node at prev_node and likewise point prev_node.next_node to next_node. Since there are no longer any references to current_node, it is garbage collected by the Python runtime.

fetch

Our final method is the fetch method, which let’s use retreive items from the Linked List.

def fetch(self, position):
    if 0 <= position < self.size():
        current_node = self.head.next_node
        current_pos = 0

        while current_pos < position:
             current_node = current_node.next_node
             current_pos += 1

        return current_node.element
    else:
        raise IndexError

By now readers should be familiar with how the list is traversed. We simply traverse the list to the desired position and return the Node.element to the caller.

Conclusion

Doubly linked list are a more advance form of linked list that supports bi-directional traversals. Bi-directional navigation is possible because each Node in the linked list stores a refrence to both the next and previous Node in the list.

Our linked list implementation only utilizes forward direction traversal to help keep the implementation simple. However, it would only require a small amount of work to make use of reverse traversals.

Singly Linked List—Python

Many of my programming students get asked to implement Linked Lists as a way to learn about data structures in programming. Now I am going to be very honest about this topic when it comes to Python. Python does not use fixed sized arrays list Java or C++ use. Instead, Python has a list object as a built in data type that grows and shrinks as needed.

For this reason, I can’t see any practical purpose to implementing a Linked List in Python other than for learning purposes (of course, that said, Java and C++ libraries have data structures that shrink and grow as needed also, so again, not sure why we would ever need to write our own linked list). That being said, I think there is value in learning about data structures such as Linked Lists.

Arrays

Many programming languages have a data structure called an array. Arrays are a lot like an egg carton. There are x number of slots in which you can place an egg. Here is an example of an array in Java.

String [] phoneNumbers = new String[12];
phoneNumbers[0] = "867-5309";
phoneNumbers[1] = "978-6410";
//and so on...

We use arrays for the same reason that we use lists in Python. They allow us to group common data together into a single variable. So we could iterate through this example array like this…

for (String num : phoneNumbers){
    System.out.println(num);
}

Arrays are extremely efficient in that you can easy create an array, fill it with data, and process that data. Nevertheless, Array’s have a major limitation. They are a fixed size. We are not able to grow or shrink and Array.

Linked List

Linked Lists are a data structure that give us the convience that is offered by an Array, but also allows us to grow and shrink the data structure as needed. We even get the added bonus of being able to insert elements into random positions in the array.

Linked list work on the idea that inside of the Linked List we have a Node object that contains the data for that particular node and a reference to the next node in the list. By manipulating where the node points, we can expand or shrink the list as needed. We can also insert items into the middle of the list by pointing the node at different Node objects within the list.

The Linked List itself is a container class for the Nodes. It does the work of creating Nodes, removing Nodes, and updating Nodes. Before we get into complete detail, here is the code for a simple Singly Linked List written in Python.

class Node:
    def __init__(self, element=None, next_node=None):
        self.element = element
        self.next_node = next_node


class SinglyLinkedList:
    def __init__(self):
        self.head = None

    def size(self):
        count = 0
        current = self.head

        while current is not None:
            count += 1
            current = current.next_node

        return count

    def insert_front(self, data):
        node = Node(element=data, next_node=self.head)
        self.head = node

    def insert_last(self, data):
        if not self.head:
            self.head = Node(element=data)
        else:
            current_node = self.head
            while current_node.next_node is not None:
                current_node = current_node.next_node
            current_node.next_node = Node(element=data)

    def insert(self, data, position):
        if self.head is None:
            self.head = Node(element=data)
        else:
            if position > self.size() or position < 0:
                raise IndexError
            else:
                if position == 0:
                    self.insert_front(data)
                elif position == self.size():
                    self.insert_last(data)
                else:
                    temp = self.head
                    pos = 0
                    while pos < (position - 1):
                        temp = temp.next_node
                        pos += 1

                    next_node = temp.next_node
                    temp.next_node = Node(element=data, next_node=next_node)

    def remove_first(self):
        if self.head is not None:
            self.head = self.head.next_node

    def remove_last(self):
        if self.head is not None:
            current_node = self.head
            prev = None

            while current_node.next_node is not None:
                prev = current_node
                current_node = current_node.next_node

            if prev is None:
                self.head = None
            else:
                prev.next_node = None

    def remove(self, position):
        if self.head is not None and position == 0:
            self.remove_first()
        elif self.head is not None and position == self.size():
            self.remove_last()
        else:
            if position  self.size():
                raise IndexError
            pos = 0
            current_node = self.head
            next_node = self.head.next_node
            while pos < (position - 1):
                pos += 1
                current_node = next_node
                next_node = current_node.next_node

            current_node.next_node = next_node.next_node

    def fetch(self, position):
        if self.head is None:
            return None
        elif position  self.size():
            raise IndexError
        else:
            current_node = self.head
            pos = 0
            while pos != position:
                pos += 1
                current_node = current_node.next_node

            return current_node.element


import unittest
from random import randint


class TestSinglyLinkedList(unittest.TestCase):
    names = ['Bob Belcher',
             'Linda Belcher',
             'Tina Belcher',
             'Gene Belcher',
             'Louise Belcher']

    def test_init(self):
        sll = SinglyLinkedList()
        self.assertIsNone(sll.head)

    def test_insert_front(self):
        sll = SinglyLinkedList()
        for name in TestSinglyLinkedList.names:
            sll.insert_front(name)

        self.assertEqual(sll.fetch(0), TestSinglyLinkedList.names[4])
        self.assertEqual(sll.fetch(1), TestSinglyLinkedList.names[3])
        self.assertEqual(sll.fetch(2), TestSinglyLinkedList.names[2])
        self.assertEqual(sll.fetch(3), TestSinglyLinkedList.names[1])
        self.assertEqual(sll.fetch(4), TestSinglyLinkedList.names[0])

    def test_insert_last(self):
        sll = SinglyLinkedList()
        for name in TestSinglyLinkedList.names:
            sll.insert_last(name)

        for i in range(len(TestSinglyLinkedList.names) - 1):
            self.assertEqual(sll.fetch(i), TestSinglyLinkedList.names[i])

    def test_insert(self):
        sll = SinglyLinkedList()
        for name in TestSinglyLinkedList.names:
            sll.insert_last(name)

        pos = randint(0, len(TestSinglyLinkedList.names) - 1)

        sll.insert('Teddy', pos)
        self.assertEqual(sll.fetch(pos), 'Teddy')

    def test_remove_first(self):
        sll = SinglyLinkedList()
        for name in TestSinglyLinkedList.names:
            sll.insert_last(name)

        for i in range(sll.size(), 0, -1):
            self.assertEqual(sll.size(), i)
            sll.remove_first()

    def test_remove_last(self):
        sll = SinglyLinkedList()
        for name in TestSinglyLinkedList.names:
            sll.insert_last(name)

        for i in range(sll.size(), 0, -1):
            self.assertEqual(sll.size(), i)
            sll.remove_last()

    def test_remove(self):
        sll = SinglyLinkedList()
        for name in TestSinglyLinkedList.names:
            sll.insert_last(name)

        sll.remove(1)

        self.assertEqual(sll.fetch(0), 'Bob Belcher')
        self.assertEqual(sll.fetch(1), 'Tina Belcher')
        self.assertEqual(sll.fetch(2), 'Gene Belcher')
        self.assertEqual(sll.fetch(3), 'Louise Belcher')

if __name__ == '__main__':
    unittest.main()

Implementation Details

As you can see, we are using Python’s OOP features to implement this linked list. That being said, this could have been down procedurally also. There is nothing that says linked lists have to be implemented in terms of classes and objects.

Node

The Node is the most basic element in the linked list. It has the responsibility to contain the data and point to the next node. That’s all it does. Here is the code for our Node class

class Node:
    def __init__(self, element=None, next_node=None):
        self.element = element
        self.next_node = next_node

SinglyLinkedList

The SinglyLinkedList class is the actualy linked list implementation. This is called SinglyLinkedList because each of the Nodes only link in a single direction (as opposed to a doubly linked list, which is bi-directional).

Here is the code for SinglyLinkedList followed by an explanation of each method.

class SinglyLinkedList:
    def __init__(self):
        self.head = None

    def size(self):
        count = 0
        current = self.head

        while current is not None:
            count += 1
            current = current.next_node

        return count

    def insert_front(self, data):
        node = Node(element=data, next_node=self.head)
        self.head = node

    def insert_last(self, data):
        if not self.head:
            self.head = Node(element=data)
        else:
            current_node = self.head
            while current_node.next_node is not None:
                current_node = current_node.next_node
            current_node.next_node = Node(element=data)

    def insert(self, data, position):
        if self.head is None:
            self.head = Node(element=data)
        else:
            if position > self.size() or position < 0:
                raise IndexError
            else:
                if position == 0:
                    self.insert_front(data)
                elif position == self.size():
                    self.insert_last(data)
                else:
                    temp = self.head
                    pos = 0
                    while pos < (position - 1):
                        temp = temp.next_node
                        pos += 1

                    next_node = temp.next_node
                    temp.next_node = Node(element=data, next_node=next_node)

    def remove_first(self):
        if self.head is not None:
            self.head = self.head.next_node

    def remove_last(self):
        if self.head is not None:
            current_node = self.head
            prev = None

            while current_node.next_node is not None:
                prev = current_node
                current_node = current_node.next_node

            if prev is None:
                self.head = None
            else:
                prev.next_node = None

    def remove(self, position):
        if self.head is not None and position == 0:
            self.remove_first()
        elif self.head is not None and position == self.size():
            self.remove_last()
        else:
            if position  self.size():
                raise IndexError
            pos = 0
            current_node = self.head
            next_node = self.head.next_node
            while pos < (position - 1):
                pos += 1
                current_node = next_node
                next_node = current_node.next_node

            current_node.next_node = next_node.next_node

    def fetch(self, position):
        if self.head is None:
            return None
        elif position  self.size():
            raise IndexError
        else:
            current_node = self.head
            pos = 0
            while pos != position:
                pos += 1
                current_node = current_node.next_node

            return current_node.element

__init__

The __init__ method in SinglyLinkedList is small but important. This method creates the self.head variable, which is the first Node in the list. You will notice that we set it to None to indicate an empty list.

def __init__(self):
    self.head = None

size

The size method is used to calculate the size of the linked list. It works by traversing every single node in the list and keeping a count of each node it passes.

def size(self):
    count = 0
    current = self.head

    while current is not None:
        count += 1
        # Advance to the next node by setting
        # current to the next node
        current = current.next_node

    return count

This is a good method to help us understand the mechanics of the linked list. You will notice how each node has a next_node object attached to it. That next_node is either a Node or it is None. None is used to indicate that we have reached the end of the list.

We begin by creating a varaible current and referring it to self.head. Next we start a loop that terminates when current is None. Inside of the loop, we increment count by one, then advance to the next node. When current is None, we are at the end of the list and we can return the count as the size of the list.

insert_front

This method let’s us quickly add an item to the beginning of the linked list.

def insert_front(self, data):
    node = Node(element=data, next_node=self.head)
    self.head = node

We create a new node and set it’s next_node to self.head. Then we just update self.head to refer to the new node. Since the new node is now the head of the list, we have added the item to the front of the list.

insert_last

This method let’s us add an item to the end of the list really quickly.

def insert_last(self, data):
    if not self.head:
        # If this is the first insertion,
        # then we can just make it the head
        self.head = Node(element=data)
    else:
        # We need to advance to the 
        # end of the list
        current_node = self.head
        while current_node.next_node is not None:
            current_node = current_node.next_node
        
        # Now we can grow the list by adding
        # a new node and pointing current_node.next_node at it
        current_node.next_node = Node(element=data)

The first thing to do is check if the list if empty. We know it’s empty if self.head is None. If that’s the case, then just make self.head point at a brand new node.

If the list isn’t empty, then we need to advance to the end of the list and insert the new new node there. It’s not hard to do this. We start by creating a current_node variable and referring it to self.head. Then we just loop until current_node.next_node is None. In each iteration of the loop, we point current_node at current_node.next_node.

Finally we just set current_node.next_node to a new Node object. At this point, current_node referring to the last node in our list, so pointing it’s next_node at the new node is what adds to the list.

insert

The insert method is a little more complicated than the other two inserts. In this case, we are trying to insert a new Node into the middle of the list at an arbitrary position. The mechanics of this isn’t that difficult. What we need to do is find the node at the specified position and store it’s next_node in a variable. Then we create a new Node and set current_node.next_node to the new Node. The new Node’s next_node becomes the old curent_node.next_node.

However, we have a couple of conditions to check for first. For one thing, the list may be empty. The client may try to insert an item at a negative position or a position that is beyond the list’s size. They may also pass in a position that is 0, which means they are inserting in the front of the list. They could also specify a position that is equal to size, which means they could be trying to add to the end of the list. Therefore, our insert method needs to consider all of these cases.

def insert(self, data, position):
    if self.head is None:
        # This is the empty list case
        # Once again, we can just insert at head
        self.head = Node(element=data)
    else:
        if position > self.size() or position < 0:
            # This is the case where they are
            # trying to insert at a negative index
            # or beyond the size of the list.
            # Just raise an exception since that's a
            # programming error on the client's part
            raise IndexError
        else:
            if position == 0:
                # If they are trying to insert at 0, we
                # can use self.insert_front to do the work
                # for us.
                self.insert_front(data)
            elif position == self.size():
                # Likewise, we can just use
                # self.insert_last to handle cases
                # where position is the size() of the list
                self.insert_last(data)
            else:
                # Start with a temp variable
                # to hold the current node
                temp = self.head
                pos = 0 # track the position

                # We actually want to stop at right before
                # the position so that the new node is
                # inserted at the specified position
                while pos < (position - 1):
                    # Advance to the next node
                    temp = temp.next_node
                    # Update position
                    pos += 1

                # Store the next node into a tempory variable
                next_node = temp.next_node
                
                # Now set temp.next_node to a new Node object (and set the
                # now node's next_node to the old next_node)
                temp.next_node = Node(element=data, next_node=next_node)

You'll want to read through the comments of this code to get a better understanding of what is happening. Most of the code here we have seen already and we are just reusing code when possible. The important part to understand is when we do the insertion. We need to traverse the linked list to one node before where we want to insert the node.

Now keep in mind, that the current node, temp, has a next_node variable. We need to store that so that we don't lose the last half of the list. So we store it in a next_node variable. Now to actually perform the insertion, we point temp.next_node to a new Node object. Since the Node's __init__ method can take an option next_node argument, we just pass the next_node that we saved to the __init__ method. Thus, we insert a new node at the expected position.

remove_first

When we want to remove the first element in a linked list, we just need to set head to the next node in the list.

def remove_first(self):
    if self.head is not None:
        self.head = self.head.next_node

The only real thing we need to watch out for is if the list is empty. As long as the list isn’t empty, we just set self.head to self.head.next_node and the first item will get garbage collected by the Python runtime.

remove_last

In the case of removing the last item from the list, we can just traverse to the end of the linked list and set the 2nd to last Node’s next_node to None.

def remove_last(self):
    if self.head is not None:
        # Start at the head
        current_node = self.head
        prev = None

        # Loop 
        while current_node.next_node is not None:
            prev = current_node
            current_node = current_node.next_node

        if prev is None:
            self.head = None
        else:
            prev.next_node = None

Once again, we need to make sure the list isn’t empty. Then we go through the list until we get to the end of the list. While we traverse the list, we keep a reference to the node that is before current_node. It is this node’s next_node that we are setting to None, rather than current_node.next_node. The result will be that current_node is disconnected from the linked list and garbage collected.

We do have to account for the possibility that we are removing all items in the linked list. If prev is none, then we need to set self.head to None, which results in an empty linked list.

remove

This method let’s us remove a node at a specified position. It has the same special cases as it’s insert counter part so we aren’t going to cover them here. The idea is that we are taking a Node out of the list by taking the previous nodes next_node and pointing it at the deleted node’s next_node. The deleted Node gets garbage collected by the runtime.

def remove(self, position):
    if self.head is not None and position == 0:
        # Just use remove_first() remove the first item
        self.remove_first()
    elif self.head is not None and position == self.size():
        # or remove_last() to get rid of the last item
        self.remove_last()
    else:
        # Throw an exception if we are out of bounds
        if position  self.size():
            raise IndexError
        # Track the current position
        pos = 0
        
        # Track the current and next_nodes
        current_node = self.head
        next_node = self.head.next_node

        # Traverse through the list but stop at the node
        # right before the one we are deleting
        while pos < (position - 1):
            pos += 1
            current_node = next_node
            next_node = current_node.next_node
        
        # Now, make current_node.next_node point to
        # next_node.next_node. This removes next_node
        current_node.next_node = next_node.next_node

Once again, the comments will be helpful here. The idea is that we want to remove the node that is in between current_node and next_node.next_node. We can do that very easily by pointing current_node.next_node to next_node.next_node. The next_node object in between the two is severed from the list and garbage collected.

fetch

Our final method is used to retreive items from the list. By now, you have seen plenty of examples on how to traverse the list. In this case, we just traverse the list to the specified position and then return Node.element

def fetch(self, position):
    if self.head is None:
        return None
    elif position  self.size():
        raise IndexError
    else:
        current_node = self.head
        pos = 0
        while pos != position:
            pos += 1
            current_node = current_node.next_node

       return current_node.element

Of course, we need to check for empty lists and perform bounds checking. Otherwise, it’s simple enough to look up the value at the specified position.

Unit Testing

We aren’t going to cover unit testing in detail here, but I did provide an example class that tests this linked list implementation. Not only does it provide an example of Python’s unit testing framework, but it also shows how this class could be used. However, don’t use this class in production code. Python’s list object is a much better choice.

Conclusion

Linked lists are a way to create dynamically expanding collections of data. This tutorial demonstrated how to create a singly linked list. The key to understanding linked lists is to understand how the lists makes use of it’s Nodes. Once you have a solid understanding of how the nodes work, it’s relatively straight forward to make a linked list.

Hangman—PyQt5 (Part 5)

This is the part in a serious of tutorials that describe how to make a Hangman game using Python and PyQt5. You can view the other parts of this series at the following links:

Putting it all together

So far, we have been making panels that inherit from QWidget. Although we tested each of those panels in isolation, the intended use of these panels is to group them together into a main application window. The other job we have to do is wire up our controls. Right now, none of our QPushButtons do anything when they are clicked. This section of the tutorial will demonstrate how to connect click events to functions that handle the event.

For reference, here is the module code

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_())

HangmanWindow

HangmanWindow is our main application window. One difference you will notice right off of the bat is that this class inherits from QMainWindow instead of QWidget.

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

This class is much larger than our other panel classes for the simple reason that it creates all of our panels and then connects the controls to event handler functions.

__init__

We begin with the __init__ method. There is a lot going on in this method so we are going to take it a little bit at a time.

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

As always, we start with calling super().__init__() to intialize the Qt window. Our next job is to assign the hangman variable to self.hangman. Doing this allows use to use the Hangman class to control the application’s logic.

Our next job is create our panels. We create a WinPanel (TODO: Link) (self.win_panel) and then we create a HangmanPanel (TODO: Link) (assign it to self.hangman_panel) and supply it with the file names of our frames. Then we create a WordPanel (TODO: Link) (assign it to self.word_panel), then a GuessedLetterPanel (self.guessed_letter_panel), and a LetterPanel (TODO: Link) (self.letter_panel).

Now, we can’t simply add these widgets to our window. QMainWindow uses a centeral widget that contains all of our widgets. We create a central_widget object and then on the following line assign a QHBoxLayout to this widget. Next we create two more nested QWidgets. The first widget is left_widget. This widget gets a QVBoxLayout and then we add self.win_panel (WinPanel), self.hangman_panel (HangmanPanel), self.word_panel (WordPanel), and self.guessed_letter_panel (GuessedLetterPanel) to this widget. That makes everything but the letter buttons appear on the left hand side of the screen.

Now we make another QWidget called right_widget. This widget gets a QHBoxLayout and then we added self.letter_panel (LetterPanel) to this widget. Now that we have our left_widget and right_widget, we can add them both to central_widget. The two widgets will stack up left to right.

Now that we have layed our screen, we call self.connect_listeners() to wire up the buttons to their event handling code. We then call setCentralWidget and pass central_widget to it. This places all of the controls on the QMainWindow. The next line of code sets the title of the window to “Hangman”. Finally, we call self.show() to actually show the window.

connect_listeners

Right now we have 26 QPushButtons on our window that don’t do anything. We are going to address that issue in this method.

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

If you remember from LetterPanel (TODO: Link), LetterPanel has a tuple of 26 buttons. QPushButton maintains a clicked object that has a connect method. The clicked is the name of the event (which fires when the user clicks on the button). The connect is a method that takes a Callable, (functions for the most part, but can be lambdas or classes that implement Callable). In our case, we are going to connect a click to our class’s on_letter_button_click method.

on_letter_button_click

This function gets called anytime we we click on a button. We connected this function to QPushButton’s clicked event in the connect_listeners function.

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

The first thing we need to do is get the source of the click event. We can get this using QMainWindow's sender() method. The next thing to do is get the letter. Remember that we set the label of each QPushButton to a letter in the alphabit. Therefore, we can get the guessed letter but using the text() method on QPushButton. Next we call sender.setEnabled to false so that they can't guess the same letter twice.

Now it's time for our HangmanObject to do some work. We start by grabbing the current number of wrong guesses but accessing hangman.guesses. On the next line, we pass the guessed letter to hangman.guess_letter. It's going to do the work of making a determination if the letter was a correct guess or not.

After guessing a letter, we call self.guessed_letter_panel.update_letters() to update the guessed letter portion of the UI. Then we call self.word_panel.update_word() to update the current word portion of the UI.

Next we need to see if we need to show the man in hangman getting hanged. We compare the current_guess against hangman.guesses. If they guessed wrong, then hangman.guessess will be larger than current_guess. Otherwise the are equal. If they guessed wrong, we update the HangmanPanel of the UI by calling self.hangman_panel.advance_frame()

Finally we need to check if the user has won or lost the game. We use hangman.check_win to see if they have won. If they have won, we need to update the WinsPanel by calling self.wins_panel.update_wins() and then call self.show_win() to notify the user that they have won.

Alternatively, the user may have lost the game. If that's the case, we call self.show_lose()

show_win

If the user wins, we want to show a dialog window that tells them they have won. It looks like this screen shot
wins copy.png
Here is the code for this function

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

This function starts out by creating a tuple that contains the two choices we are presenting to the user. Then we call QInputDialog.getItem to get the users choice. This creates a dialog box that is populated with the choices ‘Yes’ or ‘No’. It returns a tuple that we unpack into the variables item and ok. Item is the users choice and ok if if they clicked the Ok button rather than cancel.

If they clicked ok and selected Yes, we call self.reset_game() to start a new game. Otherwise we use QCoreApplication.instance().quit() to exit the application.

show_lose

This code does the exact same thing as show_win but it shows a different error message. Here is the code, but there isn’t anything new to explain.

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

reset_game

The final function in this class is the reset_game function. It does the job of reseting the UI and Hangman to their default states so that we can play a new game.

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

We call hangman.start_game to reset Hangman. We also update the WinsPanel portion of the UI. Next we need to update the HangmanPanel to it’s first frame by calling reset_hangman().

Since Hangman has picked a new word at this point, we need to call WordPanel.update_word() to replace the word with dashes again. We also need to clear out the guessed letters so we call GuessedLetterPanel.update_letters() to erase all of the guessed letters. Finally we have to enable all of the QPushButtons so use LetterPanel.activate_all() to re-enable all of our QPushButtons.

Run the program

We run the program like this

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

We begin by initializing Qt by creating a QApplication object and passing the command line arguments to it. Next we create a Hangman object and pass the files with our word bank and the number of allowed guesses to it. The next line creates our HangmanWindow. Finally we call sys.exit and pass the QApplication.exec_() function to it so that the application exits properly.

Conclusion

We came a long way in this tutorial. We had a lot of practice with object orientated programming (OOP) and we got a tour of PyQt5. This program read a file line by line, joined strings, and even handled GUI event programming. Here is alink to the complete project! Enjoy playing Hangman!

Hangman—PyQt5 (Part 4)

This is part 4 in a tutorial that describes how to make a Hangman game using Python and PyQt5. You can view the other parts of this series at the following links:

We have 4 small classes that we are going to cover in this potion of the tutorial. They are WinsPanel, DisplayPanel, WordPanel, and GuessedLetterPanel. In this portion, we will see QVBoxLayout, join a string together, create mock objects for unit testing, and us interhitence to promote code reuse.

Here is the complete module code for reference followed by a discussion of the individual classes for this portion of the tutorial.


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_())

WinsPanel

Our application supports tracking the number of times the user wins the game. We can display this information with a panel that creates two QLabels and displays them horizontally.

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

As always, we can test this panel in isolation by using the following code snippet.

if __name__ == '__main__':
    app = QApplication(sys.argv)
    win = WinsPanel(None)
    win.show()
    sys.exit(app.exec_())

When run, the panel looks like the following screen shot
WinsPanel copy

Let’s walk through the code

__init__

The WinPanel’s __init__ method does the job of creating two QLabel objects and a QHBoxLayout. Here is the code for __init__ followed by an explanation.

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)

There really isn’t anything here that we haven’t seen before. We begin by calling QWidget’s __init__ method so that we can add widgets to this panel. This panel also takes a Hangman object. The next two lines create a QLabel with the text ‘Wins’.

The following the creation of the first QLabel, we create another QLabel and assign it to self.win_label with a text of ‘0’. This label will get updated in the update_wins function below. It will display the number of games of Hangman the user won.

The final three lines create a QHBoxLayout to arrange controls from left to right and then we use addWidget to add the two QLabel objects that we created.

update_wins

The update_wins function get’s called by the application to update the win_label’s text.

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

The Hangman object has a wins property. Remember that our application’s logic is seperate from the presentation (the UI), which is why this class is grabbing the value from Hangman rather than keeping it’s own win count. All we are doing is using QLabel’s setText method to update the text in the label. Since hangman.wins is an integer, we need to convert it to a string using the str function to avoid a type error.

DisplayPanel

WordPanel and GuessedLetterPanel are incredibly similar and even use the same controls and layouts. Whenever we have patterns in our code, we should use the “Do Not Repeat” principle. In this case, we are going to define a class that is only used as a base class for WordPanel and GuessedLetterPanel. Here is the code followed by an explanation.

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)

__init__

The DisplayPanel class only has one method __init__. This class does the job of assigning a hagnman object for later use and creating two QLabel objects. You will notice that the QLabel objects do not have their text set.

We also meet a new layout manager here. The QVBoxLayout is used to stack widgets from top to bottom. We create the QVBoxLayout and then add both of our labels to the layout manager.

WordPanel

WordPanel does the job of displaying the user’s correct guesses. It works with Hangman’s display_letters list object to display the letters the user has guessed correctly or show’s dashes (‘-‘) for letters that are still unknown to the user. Here is the code for this class.

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

Using this code, we can run this panel in isolation

if __name__ == '__main__':
    class MockHangman:
        def __init__(self):
            self.display_letters = ['-', '-', 'A', '-', '-']

    app = QApplication(sys.argv)
    win = WordPanel(MockHangman())
    win.show()
    sys.exit(app.exec_())

You will notice in this case we need a special MockHangman class to test WordPanel. Python is a dynamically typed language. As long as objects satisfy an interface, Python could care less about the kind of object. This feature (often called duck typing) is a major advantage of Python.

WordPanel needs a Hangman object that has a display_letters variable. Since our MackHangman has this variable, it can satisfy WordPanel’s Hangman dependency. Defining classes on the fly like we did above makes it really easy to test Python objects in isolation.

Our panel looks like this when it’s run
WordPanel copy

Now let’s discuss the code

__init__

What is special about WordPanel’s __init__ is that it is so short. Here is the code for the __init__ method for this class.

def __init__(self, hangman):
    super().__init__(hangman)

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

You will notice that we call DisplayPanel’s __init__ and pass the hangman object to the parent class’s constructor. We set the text of the label also, but we never create the QLabel object here. That’s already been done for us in DisplayPanel’s __init__. We have direct access to the QLabel right away. We don’t even worry about creating a layout manager here since that also was taken care of for us already.

update_word

We are going to see how to combine a list into a string.

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

The ' '.join(self.hangman.display_letters) is the code of interest here. Whenever we want to combine elements of an iterable (lists are iterables) into a string, we start with a string that is used as a seperator. In our case, we are using a blank space ‘ ‘ that appears between every letter. If you don’t want a space, just use an empty string ”. Now we use the join method on string, which accepts any iterable. Our iterable is self.hangman.display_letters. So each element in self.hangman.display_letters is joined into a single string.

At this point, we just use QLabels.setText method to update the text in the string.

GuessedLetterPanel

GuessedLetterPanel’s job is to display the letters the user has guessed so far. Here is the code for this class.

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

We can test this class as a stand alone component. However, to do so, we need a Mock Hangman object. Here is the code to run this panel in isolation.

if __name__ == '__main__':
    class MockHangman:
        def __init__(self):
            self.guessed_letters = ['A', 'E', 'I', 'O', 'U']

    app = QApplication(sys.argv)
    win = GuessedLetterPanel(MockHangman())
    win.show()
    sys.exit(app.exec_())

This is what our panel looks like when it’s ran in isolation.
GuessedLettersPanel copy

__init__

Once again, GuessedLetterPanel is a child class of DisplayPanel. For this reason, we don’t need to worry about things like creating our QLabel objects or our layout. We just set the variables to the values we need them at and let the parent class do the work for us.

def __init__(self, hangman):
    super().__init__(hangman)

    self.label.setText("Letters Already Guessed")
    self.update_letters()
[/code

<h3>update_letters<h3>
This method is another example of how to join an iterable into a string.

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

In this case, we use ', ' as our letter seperator rather than a blank space.

Conclusion

We convered quite a bit of ground in this section. The code in these classes showed us how to use QVBoxLayout. We learned how to join strings together into a single string using String's join method. Furthermore, we saw how to create mock objects that help us with unit testing our classes. Finally, we saw how to use inheritance to group similar code together.

Hangman—PyQt5 (Part 3)

This is part 3 in a tutorial that describes how to make a Hangman game using Python and PyQt5. You can view the other parts of this series at the following links:

The next step…

In Part 2, we created a panel that shows our man getting hanged as the user inputs incorrect guesses. We are now going to continue by discussing the LetterPanel, which is a panel that holds 26 QPushButtons. Each QPushButton will correspond to a letter of the alphabit.

This is the code for the entire module for reference.

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_())

We are going to focus on the LetterPanel of this code. The LetterPanel explores some other features of PyQt5 which include GridLayout and QPushButton.

Letter Panel

This is the code for the letter panel

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)

Just like HangmanPanel, this panel inherits QWidget so that it can be used a GUI component. This allows us to nest this panel into an application window or run it as a stand alone window.

We can run it in a standalone window using the following code

if __name__ == '__main__':
    app = QApplication(sys.argv)
    win = LetterPanel(None)
    win.show()
    sys.exit(app.exec_())

This is what the panel looks like when run in isolation.
LetterPanel copy

Now let’s discuss the code in this class.

__init__

By now you should be familiar with Python’s __init__ method. Here is the code for LetterPanel’s __init__ followed by an explanation.

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

We start by calling QWidget’s __init__ function so that we can add controls to this panel. We also supply a Hangman object to this class also.

The next line does the work of creating 26 buttons that get placed on this panel. We are going to do this in a seperate create_buttons() function. Next we need to layout the buttons into a grid pattern. QGridLayout is a powerful class that does the job of laying out components in a grid like fashion.

At this point, we have 26 buttons and a grid, but we need to actually add the buttons to the grid. Once again, we are going to use a seperate function position_buttons() and delegate this work to that function.

create_buttons

We met the create_buttons function in __init__. Here is the code followed by an explanation.

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)

We begin by creating a tuple of upper case letters, followed by an empty list of buttons. Since all of the buttons are going to do a similar job (which is sending a guessed letter to Hangman), it is much easier and much more maintainable to use a list to hold our QPushButton objects rather than using 26 or more variables for each button.

The next thing to do is iterate through each letter an create a QPushButton. The first argument of QPushButton’s __init__ method takes a string that will eventually contains the button’s text. So when we do QPushButton(l, self) we are going to set the label of the button to the current letter. Then we add the newly created QPushButton to the buttons list.

We don’t plan on adding, changing, or shuffling our buttons after the GUI is created. To write protect our data, we convert buttons to a tuple and then assign it to self.buttons.

position_buttons

We met position_buttons in the __init__ method. This function does the job of adding the buttons created in create_button to the screen.

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)

QGridLayout uses an x,y position to add controls to the grid. So our first line is using Python’s generator statement to create a 2D list of x,y positions. We will end up with a list that is 7 rows deep and 4 columns wide.

Now, this is the one case where we need to manipulate the order of our buttons. We create a buttons list out of self.buttons (remember that self.buttons is a tuple) so that we can reverse the order of the buttons. If we forget to do this, we would have the letter ‘Z’ appear first in our grid rather than last in grid. This is becuase we are going to use the pop() method to remove the last element in a list. Therefore, we need ‘Z’ to be at the first position in the list and ‘A’ at the last position.

Now we are going to add the buttons to the grid. We are going to iterate through the positions list object. The variable pos is a tuple that contains the x,y pair of coordinates.

Now we need to check that buttons isn’t empty, so we use if buttons first to make sure we aren’t calling pop() on an empty list. As long as we still have buttons, we can add them to self.grid.addWidget. The *pos is a shortcut way of adding the x,y pair into the addWidget’s method without having to create two variables.

activate_all

This last method is used later on when we want to reset the game. It simply re-enables all disabled buttons (which get disabled after the user clicks on them).

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

Conclusion

We covered some new ground in this portion of the tutorial. QPushButton is a widget that creates a regular push button on the street. QGridLayout is a layout manager that positions controls on the screen in a grid like fashion. When you add widgets to the screen using grid layout, you need to supply an x,y pair so that the grid layout knows where to position each control.

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.

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.