Sometimes we can just use our programming skills to make games. Card games have been popular for centuries. To make card games, we need card objects.
Let’s take what we learned about classes from the previous lab to make playing cards more than just a tuple containing a suit and a rank.
Dataclasses Unit Testing Immutable Objects Input Validation List comprehensions
Let’s jump right in and make the folder ~/cmsi1010/lab11 with the files card.py and card_test.py. (If you feel like you need command line practice, use mkdir and touch to do this.)
The first of these files will feature a new type for playing cards. A playing card has two things: a suit, which is one of Spades (♠), Hearts (♥), Diamonds (♦), or Clubs (♣), and a rank, which is one of Ace (A), 2, 3, 4, 5, 6, 7, 8, 9, 10, Jack (J), Queen (Q), or King (K).
We learned how to make classes in the previous labs, so you know that we can begin like this:
class Card:
def __init__(self, suit, rank):
self.suit = suit
self.rank = rank
The __init__ method is always just so formulaic. It takes the self parameter and all the attributes and you have to load them up yourself. There must be a better way.
There is! With a Python dataclass, the initializer is automatically generated for you! That’s right, all we need is:
from dataclasses import dataclass
@dataclass
class Card:
suit: str
rank: int
That’s... so... pretty. 😭
You might find yourself using dataclasses more than regular classes, and that’s a good thing. When to use regular classes (rarely, but they still matter!) and when to use dataclasses (yeah, probably more often) is a topic we might get to later. For now, for cards, dataclasses are the way to go.
We’re not quite ready to code yet. While it is true that the suit of a card is a string and its rank is an integer, these types are not specific enough. Only certain strings and certain ranks are valid. And there’s something else about cards we want to capture.
As a programmer, you need to capture constraints about the world. Here are two constraints that matter here:
For constraint #1 we need to do a post-initialization validation of our attributes, and for constraint #2, we want our objects to be frozen. The syntax takes some getting used to, so we’ll learn by typing it in directly and discussing the salient points during class. Start card.py with the following:
from dataclasses import dataclass @dataclass(frozen=True) class Card: suit: str rank: int def __post_init__(self): if self.suit not in ("S", "H", "D", "C"): raise ValueError("suit must be one of 'S', 'H', 'D', 'C'") if self.rank not in range(1, 14): raise ValueError("rank must be an integer between 1 and 13") # Temporary prints, just to see if things are ok print(Card("S", 1)) print(Card("H", 11)) print(Card(rank=3, suit="C")) print(Card("X", 1))
Run the program. Your output should be something like:
Card(suit='S', rank=1)
Card(suit='H', rank=11)
Card(suit='C', rank=3)
ValueError: suit must be one of 'S', 'H', 'D', 'C'
print(Card("H", 14)) to see the other error message.
That__post_init__thing was kind of beautiful, right?We’ve done something considered very important in secure software development: input validation. Python calls this special method behind the scenes after setting the attributes from the constructor, and this is your chance to reject bad input.
By doing this correctly, we are guaranteed that every card we create is legal!
To fix up the way cards are printed, we will add the special __str__ method we learned about in a previous lab. We’d love to get output like A♦, J♠, 10♥, or 3♣.Let’s think about how to do this.
The thought process goes something like this: “The thing I want looks like J♠”, namely a rank string followed by a suit string, but my internal representation is different, holding in this case 11 and "S". So I need a way to translate my internal rank numbers and suit characters into different strings!
As beginning programmers, your mind might first drift to if statements for doing the translation:
def __str__(self):
if self.suit == "S":
suit_str = "♠"
elif self.suit == "H":
suit_str = "♥"
elif self.suit == "D":
suit_str = "♦"
else:
suit_str = "♣"
if self.rank == 1:
rank_str = "A"
elif self.rank == 11:
rank_str = "J"
elif self.rank == 12:
rank_str = "Q"
elif self.rank == 13:
rank_str = "K"
else:
rank_str = str(self.rank)
return f"{rank_str}{suit_str}"
else parts acceptable here? Why doesn’t it need to raise an error for an invalid suit or rank?
But you look at that code and you kind of cringe, noting all those == comparisons. Then you remember the great match statement from earlier labs, and you think maybe this is better:
def __str__(self):
match self.suit:
case "S": suit_str = "♠"
case "H": suit_str = "♥"
case "D": suit_str = "♦"
case _: suit_str = "♣"
match self.rank:
case 1: rank_str = "A"
case 11: rank_str = "J"
case 12: rank_str = "Q"
case 13: rank_str = "K"
case _: rank_str = str(self.rank)
return f"{rank_str}{suit_str}"
But darn, there is still some code duplication (suit_str and rank_str are assigned to many times). As you advance as a programmer, you start to get the feeling that something deeper is going on here—and that something is that we are doing a mapping from one kind of thing (our internal representation) to another (the printed representation).
And in Python, the whole purpose of dictionaries is to do mappings. In fact dict is literally known, to the Python designers and maintainers, as a mapping type.
So knowing that we can store the mappings of our ranks and suits in dictionaries, we come up with an elegant (tho admittedly terse-for-beginners) implementation of __str__:
def __str__(self): suit_str = {"S": "♠", "H": "♥", "D": "♦", "C": "♣"}[self.suit] rank_str = {1: "A", 11: "J", 12: "Q", 13: "K"}.get(self.rank, str(self.rank)) return f"{rank_str}{suit_str}"
Add this method to your class and see how the prints go now.
The little print statements we dropped into the bottom of our file might have helped us “test” our code, but they are actually now how professionals test! They actually pollute the code. Testing needs to be done outside this card file. And you know what else: Testing should not be done by humans looking at the code, but by machines that can test automatically for us. We’re now going to write our first automated unit tests.
Make sure you are in your virtual environment, and invoke:
pip install pytest
In the file card_test.py, add this code:
import pytest from card import Card def test_valid_cards(): assert str(Card(suit="H", rank=10)) == "10♥" assert str(Card(suit="S", rank=1)) == "A♠" assert str(Card(suit="D", rank=11)) == "J♦" assert str(Card(suit="C", rank=12)) == "Q♣" assert str(Card(suit="H", rank=13)) == "K♥" assert str(Card(suit="S", rank=5)) == "5♠" assert str(Card(suit="D", rank=3)) == "3♦" assert str(Card(suit="C", rank=2)) == "2♣" def test_invalid_suit(): with pytest.raises(ValueError): Card(suit="X", rank=5) with pytest.raises(ValueError): Card(suit="SPADES", rank=10) def test_invalid_rank(): with pytest.raises(ValueError): Card(suit="S", rank=14) with pytest.raises(ValueError): Card(suit="D", rank=0) with pytest.raises(ValueError): Card(suit="D", rank=3.5) with pytest.raises(ValueError): Card(suit="D", rank="10") def test_cards_are_truly_immutable(): card = Card(suit="H", rank=10) with pytest.raises(AttributeError): card.suit = "S" with pytest.raises(AttributeError): card.rank = 5 with pytest.raises(AttributeError): card.dog = "dog"
Run the test with pytest card_test.py and enjoy the output, which will be something like this:
(env) $ pytest card_test.py
======================= test session starts =======================
platform darwin -- Python 3.13.2, pytest-8.4.1, pluggy-1.6.0
collected 4 items
card_test.py ....... [100%]
======================== 4 passed in 0.01s ========================
The Joy of Unit TestingYes, you will learn to love unit testing. Why? Because with good unit tests you can code fearlessly! You can make changes and improvements to your code without worrying that you might break something. Just run the tests after every change. If the tests break, your change probably has a bug. Fix it quickly! If the tests work, you can be confident your change was ok.
In many card games, there’s this idea of a deck as an ordered collection of all 52 distinct cards, and a hand, which is a set of cards pulled from teh deck. Remember your datatypes:
Should we create classes for decks and hands? Maybe. This is much easier to do in languages other than Python, because Python makes security pretty hard. Rather than worrying about security implications just yet, we’ll just write functions for generating decks and hands. Add from random import shuffle to the very top of card.py, then add these three functions directly the bottom of the file, not inside the class, but outside of it:
def standard_deck(): return [Card(suit, rank) for suit in "SHDC" for rank in range(1, 14)] def shuffled_deck(): cards = standard_deck() shuffle(cards) return cards def deal_one_five_card_hand(): deck = shuffled_deck() return set(deck[:5])
We’ll explain the code in class. (If you are not in class, ask a LLM-enabled chatbot to explain it to you. If still confused, reach out for help!)
ComprehensionsThe expression
[Card(suit, rank) for suit in "SHDC" for rank in range(1, 14)]is our first look at a list comprehension. It’s not that bad really—it literally says: “the list of all cards made with suit and rank where suit comes from one of the letters in
"SHDC"and the ranks are 1 through 13, inclusive.” It could have been written like this:result = [] for suit in "SHDC": for rank in range(1, 14): result.append(Card(suit, rank)) return resultGetting used to list comprehensions is important. You are encouraged to practice with them. You’ll see a few in the recall questions below.
Each of the new functions should have tests. Writing good tests takes a lot of experience: you have to know which tests are necessary, which are superfluous, and how to deal with things like randomness and current times, and much, much more. You’re not on the hook for writing your own tests in this lab, but you should be able to take the tests written for you and run them on your own, with a little bit of understanding of what they are doing. Here, then, are tests for the previous three functions:
def test_standard_deck(): deck = standard_deck() assert isinstance(deck, list) assert len(deck) == 52 assert all(isinstance(card, Card) for card in deck) deck_as_string = ''.join(str(card) for card in deck) assert deck_as_string == ( "A♠2♠3♠4♠5♠6♠7♠8♠9♠10♠J♠Q♠K♠" "A♥2♥3♥4♥5♥6♥7♥8♥9♥10♥J♥Q♥K♥" "A♦2♦3♦4♦5♦6♦7♦8♦9♦10♦J♦Q♦K♦" "A♣2♣3♣4♣5♣6♣7♣8♣9♣10♣J♣Q♣K♣") def test_shuffled_deck(): deck = standard_deck() assert isinstance(deck, list) assert len(deck) == 52 shuffled = shuffled_deck() assert isinstance(shuffled, list) assert len(shuffled) == 52 for suit in "SHDC": for rank in range(1, 14): assert Card(suit, rank) in shuffled def test_deal_one_five_card_hand(): hand = deal_one_five_card_hand() assert isinstance(hand, set) assert len(hand) == 5 assert all(isinstance(card, Card) for card in hand)
You will need to add or modify the import clauses at the top of the file to make the tests work! Make the required changes.
from card import Card, standard_deck, shuffled_deck, deal_one_five_card_hand
Run pytest card_test.py and get all 7 tests passing. Once you do, add, commit, and push.
Now it’s your turn. Here are some ideas for you to extend the activities above:
deal_two_five_card_hands to your cards module. If you feel like you can write a test, do so!deal(number_of_hands, cards_per_hand) to the module. It is to work like follows:
int, raise a TypeErrorValueErrorValueErrorDataclasses and unit testing are big steps along your journey of becoming a great programmer. Continue your study of these topics and go further in depth with the following sources:
pytest module.all function. (Really, look for it.) Find out more about it in Think Python, 2nd edition, Chapter 19.4.We’ve covered:
__post_init__if or match statementsHere are some questions useful for your spaced repetition learning. Many of the answers are not found on this page. Some will have popped up in lecture. Others will require you to do your own research.
@dataclass? __init__ method__post_init__ method? __post_init__ raises an exception? __str__ method? pytest package? TypeError and a ValueError? TypeError is raised when an operation or function is applied to an object of inappropriate type, while a ValueError is raised when an operation or function receives an argument that has the right type but an inappropriate value.shuffle function from the random module? all function in Python? True if all elements in some kind of collection are True, or if the collection is empty. (Technically all operates not just on sequences but on a more general construct known as an iterable.)