Debugging in Python, part 8: Modeling playing cards

MP 149: In pursuit of logical errors.

Note: This post is part of an ongoing series about debugging in Python. The posts in this series will only be available to paid subscribers for the first 6 weeks. After that they will be available to everyone. Thank you to everyone who supports my ongoing work on Mostly Python.

Every bug we've looked at so far has resulted in a traceback. It's important to learn how to debug your code when you see a traceback, but that's not the only kind of bug you'll face. The more common, and often more difficult kind of bug you'll need to resolve is a logical error. A logical error doesn't cause your program to crash; instead it causes the program to generate incorrect output. These kinds of issues can be much harder to debug, because Python doesn't give you any insight into how to address them. You have to reason about what's happening, diagnose the issue, and come up with a fix.

Why focus on cards?

When I first started working on this post, I tried to introduce some logical errors into the strat_players.py project. It was hard to come up with any meaningful examples, because most of the logic in that project is implemented inside the pandas and Plotly libraries. We need a project with more of our own code in order to focus meaningfully on logical errors.

If you've never done so, I'd encourage you to write your own implementations of a playing card, a hand of playing cards, and a deck of cards. There are many ways to do this, and you'll almost certainly learn something by writing your own code to model this seemingly simple context. I'm going to walk through my own implementation of these three things, and then we'll use the resulting codebase to work through some logical errors in the final posts of this series.

These implementations aren't meant to be perfect or optimized. I'm choosing this context to close out the series because there's a lot of simple but subtle behavior to implement when working on card games. Choices that seem reasonable when modeling cards, hands, and decks can become surprisingly problematic once you start using your models. Some logical errors will almost certainly come up if you try to do this on your own, and it's easy to introduce a variety of logical errors into this kind of codebase. py-bugger doesn't currently allow you to generate logical errors in a project, but I'm looking forward to building that functionality into it over the coming months.

ace of spades playing card
Modeling a playing card, along with hands and decks of cards, is a much more interesting exercise than many people realize. (image source)

Modeling a playing card

The first thing we'll do is model a single playing card. Here's my first pass at a class called Card:

class Card:
    
    def __init__(self, rank, suit):
        self.rank = rank
        self.suit = suit
card_models.py

Let's think about this by considering a specific card, say an Ace of Spades. There are two parts to this card, "ace" and "spades". At first I referred to "ace" as value, but after reading a bit about playing cards I learned that each card has a rank, such as "ace", and a suit such as "spades".

Let's make an ace of spades:

class Card:
    ...

ace_spades = Card("Ace", "Spades")
print(ace_spades)

Here's the output:

$ python card_models.py
<__main__.Card object at 0x10498af90>

That's not super helpful! Python doesn't know how to represent an instance of Card. It just tells us that a Card object has been created, at the memory address 0x10498af90.

Let's tell Python how to represent a card:

class Card:
    ...
    def __repr__(self):
        return f"{self.rank} of {self.suit}"

ace_spades = Card("Ace", "Spades")
print(ace_spades)

When Python needs to represent an instance in an environment such as a terminal, it looks for a __repr__() method. Here we're telling Python to print the rank and suit whenever it needs to represent an instance of Card.

Now we can see exactly what kind of card we made:

$ python card_models.py
Ace of Spades

This is good, but it's hard to build a useful model of hands and decks with such verbose output. Let's simplify the way we're representing cards.

Unicode playing card symbols

It turns out there are Unicode symbols for all the playing card suits, and they make it reasonable to work with cards in a terminal environment.

The Unicode sequence for spades is U+2660. Here's how you can print the corresponding symbol in Python:

>>> print("\u2660")

This suggests a nice compact way to represent a single card:

>>> print("A\u2660")
A♠

This is great for displaying cards, but we don't want to refer to suits by their Unicode sequence! It became clear pretty quickly that I needed a mapping between suit names and their Unicode symbols.

I made a separate data file with the following dictionary:

suits_symbols = {
    "spades": "\u2660", 
    "hearts": "\u2665",
    "diamonds": "\u2666",
    "clubs": "\u2663",
}
card_data.py

The keys are the suit names that we want to use when writing code. The values are the symbols we want to use when displaying cards.

Now we can use this dictionary to write a much better __repr__() method:

import card_data

class Card:
    
    def __init__(self, rank, suit):
        ...

    def __repr__(self):
        symbol = card_data.suits_symbols[self.suit]
        return f"{self.rank}{symbol}"

ace_spades = Card("A", "spades")
print(ace_spades)
card_models.py

In the __repr__() method, we look up the symbol for the current Card instance. We then return a string consisting of the rank and the symbol. When creating a card, we use a single letter for the rank, so we end up with a clear, concise representation of a single card:

$ python card_models.py 
A♠

This is going to be much nicer to work with as we implement models for hands and decks.

Card values

We're going to want to compare cards, so we need a way to determine the value for each card. Numerical cards are easy to compare, but what about face cards? It's time for another mapping:

...
ranks_values = {
    "2": 2,
    "3": 3,
    "4": 4,
    ...
    "10": 10,
    "J": 11, 
    "Q": 12,
    "K": 13, 
    "A": 14,
}
card_data.py

The keys are the ranks of each card, and the values are the numerical value of each card. This mapping makes aces more valuable than any other card; we'll assume aces are always high.

Here's how we'll use this mapping in a value() property:

...
class Card:
    ...
    @property
    def value(self):
        """Get a numerical value for the card."""
        return card_data.ranks_values[self.rank]

ace_spades = Card("A", "spades")
print(ace_spades)
print(ace_spades.value)

We use the @property decorator so we can access value as a read-only attribute, rather than calling it as a method. Whenever the code card.value is evaluated, we look up the value associated with self.rank, and return that value:

$ python card_models.py 
A♠
14

Now we can write some comparison methods, and have a useful Card model.

Comparison methods

We'll almost always be working with more than one card, and we'll want to make a variety of comparisons. First, we'll want to know if one card is equivalent to another, of higher value, or of lesser value. We can use the ==, >, and < operators for these comparisons:

...
class Card:
    ...
    def __repr__(self):
        ...

    def __eq__(self, card_2):
        """Two cards are equal if their ranks match."""
        return self.rank == card_2.rank

    def __gt__(self, card_2):
        """A card is greater if its value is higher."""
        return self.value > card_2.value

    def __lt__(self, card_2):
        """A card is lesser if its value is lesser."""
        return self.value < card_2.value

    @property
    def value(self):
        ...

Overriding Python's dunder methods is a powerful way to write your own comparison operators. To see if these work, let's make some cards and compare them in a terminal session:

>>> from card_models import Card
>>> ace_spades = Card("A", "spades")
>>> ace_clubs = Card("A", "clubs")
>>> ten_spades = Card("10", "spades")
>>> ace_spades == ace_clubs
True
>>> ace_spades > ten_spades
True
>>> ace_spades < ten_spades
False

We make three cards: two aces and a ten. The two aces are in fact considered equal in value, and an ace is considered greater than a ten. Also, an ace is not less than a ten.

Comparing suits

I want to make one more comparison function, to check if a number of cards have the same suit. This could be two cards, or an arbitrary number of cards. Since this isn't really a comparison against a single card, it's probably better written as a standalone function than as a method in the Card class.

Here's the function same_suit():

...
class Card:
    ...
    
def same_suit(cards):
    """Check if all cards have the same suit."""
    suits = [c.suit for c in cards]
    return len(set(suits)) == 1
card_models.py

This function first makes a list of all the suits from the cards that were passed in. It then turns that into a set, which keeps only one instance of each repeated item in the list. If the length of the set suits is one, then all the cards have the same suit.

Let's see if this works, again in a terminal session:

>>> from card_models import Card, same_suit
>>> ace_spades = Card("A", "spades")
>>> ace_clubs = Card("A", "clubs")
>>> ten_spades = Card("10", "spades")
>>> same_suit([ace_spades, ten_spades])
True
>>> same_suit([ace_spades, ace_clubs, ten_spades])
False

Passing two spades returns True, and passing a mix of spades and clubs returns False.

A small set of tests

I love writing tests. I don't test every bit of code I write, but if there's any behavior I care about, or if I'm modeling something that has any subtle logic, I'll write a few tests. Sometimes it's faster and easier to write a handful of tests than to come up with manual checks in a terminal session that everything is working. We're going to build on the Card model from this point forward, so it's well worth the time to write a few tests.

I won't reproduce the entire test code here, but I'll show the three tests I wrote:

from card_models import Card
import card_models as cm

def test_create_card():
    """Make sure cards can be created in intended ways."""
    ...

def test_comparisons():
    """Test equality, gt, and lt comparisons."""
    ...

def test_same_suit_comparison():
    """Test same_suit() method."""
    ...
test_cards.py

I want to know that cards can be created correctly, that we can make comparisons between cards based on their value, and that we can check whether cards have the same suit or not.

If you download the code from this post, you can run these three tests:

$ pytest
========== test session starts ==========
collected 3 items
test_cards.py ...
                                    100%]
========== 3 passed in 0.01s ============

This small set of tests will be helpful as we build out the Hand and Deck models, and we'll add to the test suite as we address the logical errors that come up.

I think it's worth mentioning here that I don't use AI assistants to write tests for me. We need to guard against AI assistants writing incorrect code, and your test suite is one of the best tool you have to ensure that your code does what you want it to, and nothing else. If we delegate that work to an AI assistant, we lose one of the best reassurances we have that our code is indeed working as we intend it to.

Conclusion

We now have a Card model, which we can use to implement Hand and Deck models. There's a lot of simple but subtle and interrelated logic here, which will make a great context for creating and working through logical errors. (I already know about one logical error in the code in this post, but we'll work through that a little later. Maybe you spotted the potential issue as well?)

In the next post we'll implement the Hand and Deck models. If you haven't done so already and you have a bit of free time, consider writing your own versions of these two classes before the next post comes out. If you haven't done this kind of exercise before you'll almost certainly learn something from coming up with your own implementation.

Resources

You can find the code for this in the Mostly Python GitHub repository.