Python 30‑by‑30 Course
This week, we're learning the skills of a true software craftsperson. We'll explore a powerful way of thinking called Object-Oriented Programming (OOP), and then learn the essential practices of testing and debugging to make sure our code is robust, reliable, and easy to maintain.
In Module 4, we learned how to make our programs interact with the outside world:
with open(...)
statement to safely read from and write to text files.csv
module to read and write spreadsheet data, using DictReader
to access data by column name.argparse
module to accept arguments directly from the command line, making them more powerful and automatable.So far, we've worked with data (like strings and numbers) and actions (functions) separately. Object-Oriented Programming (OOP) is a powerful way to bundle them together. We do this by creating a class, which acts as a blueprint for creating "objects." Think of a BankAccount
class: it would hold data like the owner's name and the balance, but it would also have actions like deposit()
and withdraw()
.
The __init__
method is a special function that acts as the constructor or setup instructions. It runs automatically whenever you create a new object from the class. Inside the class, you'll see the self
keyword everywhere. This is simply how an object refers to its own attributes and methods. Once you've defined your blueprint, you can create as many instances (individual objects) as you want, each with its own separate data.
This approach helps organize complex programs by modeling real-world things. Instead of a dozen loose variables, you have a single player
object that knows its own score, position, and inventory.
Design a Dog
class. The __init__
method should take a name
and breed
. The class should have a bark()
method that prints "Woof!". Create two different Dog
instances and call their bark()
methods.
# dog_class.py
class Dog:
def __init__(self, name, breed):
"""Initializes a new Dog instance."""
self.name = name
self.breed = breed
def bark(self):
"""Makes the dog bark."""
print(f"{self.name} says: Woof!")
# Create two instances (objects) of the Dog class
dog1 = Dog("Fido", "Golden Retriever")
dog2 = Dog("Lucy", "Poodle")
# Call methods on each instance
dog1.bark()
dog2.bark()
Once you have a class, you'll often want to create a new one that is a more specialized version of it. Inheritance lets you do this. You can create a subclass that inherits all the attributes and methods of a parent class. This models an "is-a" relationship. For example, a GuideDog
*is a* special kind of Dog
that has all the normal dog behaviors plus a new guide()
method.
Another way to build complex objects is composition. This is where an object is built from other, smaller objects. This models a "has-a" relationship. For example, a Car
object isn't a type of engine, but it *has an* Engine
object inside of it. The Car
class can then delegate tasks, like car.start()
might call self.engine.ignite()
.
Which one should you use? A good rule of thumb is to "favor composition over inheritance." Composition is often more flexible. You can easily swap out a Car
's engine without changing the Car
class, but you can't easily change a GuideDog
's "parent" class. Both are powerful tools for building well-structured code.
Create a base class Character
with name
and health
attributes. Then, create two subclasses, Hero
and Villain
, that inherit from Character
. Give the Hero
class an inspire()
method and the Villain
class a cackle()
method.
# character_inheritance.py
class Character:
def __init__(self, name, health=100):
self.name = name
self.health = health
def introduce(self):
print(f"I am {self.name}.")
class Hero(Character):
def inspire(self):
print(f"{self.name} shouts: For glory!")
class Villain(Character):
def cackle(self):
print(f"{self.name} cackles: Mwahahaha!")
# Create instances
hero = Hero("Aragorn")
villain = Villain("Sauron", 500)
hero.introduce()
hero.inspire()
villain.introduce()
villain.cackle()
How do you know if your code actually works? You could run it and check the output manually, but that's slow and error-prone. A much better way is to write unit tests—small, automated scripts whose only job is to check that one tiny piece (a "unit") of your code works as expected. This creates a safety net, allowing you to change and improve your code with confidence, knowing your tests will catch you if you break something.
Python's built-in unittest
module is a great tool for this. You create a test class and write methods that start with test_
. Inside these methods, you call your code and then make assertions. An assertion is a statement that declares something must be true. For example, self.assertEqual(add(2, 2), 4)
asserts that the result of add(2, 2)
should be equal to 4. If it's not, the test fails, and you're immediately alerted to the problem.
Writing tests might feel like extra work at first, but it pays off enormously. It forces you to think clearly about what your code is supposed to do and is the hallmark of a professional developer.
Let's write a test for a simple function. First, create a function is_even(n)
that returns True
if a number is even and False
otherwise. Then, write a unittest
test case with at least two test methods: one to check a few even numbers, and another to check a few odd numbers.
# functions.py (the code we want to test)
def is_even(n):
return n % 2 == 0
# ---------------------------------------------
# test_functions.py (the test script)
import unittest
from functions import is_even
class TestEvenFunction(unittest.TestCase):
def test_even_numbers(self):
"""Test that even numbers are correctly identified."""
self.assertTrue(is_even(2))
self.assertTrue(is_even(0))
self.assertTrue(is_even(-4))
def test_odd_numbers(self):
"""Test that odd numbers are correctly identified."""
self.assertFalse(is_even(1))
self.assertFalse(is_even(7))
self.assertFalse(is_even(-3))
if __name__ == '__main__':
unittest.main()
Bugs are a normal part of programming. The skill is in finding them efficiently! When your program crashes, Python gives you a traceback, which is a map of exactly where the error happened. Learning to read this from the bottom up is the first step in your detective work.
Sometimes, the error isn't a crash but just wrong behavior. You could add print()
statements everywhere to see what's going on, but a much more powerful tool is a debugger. Python's built-in debugger, pdb
, lets you set a breakpoint in your code. When your program hits that point, it pauses, and you get a command prompt where you can inspect the values of variables, step through your code line by line, and see exactly what's happening. It's like a superpower for finding bugs.
For programs that run for a long time (like a web server), you can't always be there to debug them. This is where logging comes in. The logging
module lets you record messages from your program to the console or a file. You can record informational messages, warnings, or critical errors. This creates a detailed diary of your program's life, which is essential for diagnosing problems that happened in the past.
Take a simple function that divides two numbers. Use the logging
module to log the inputs it receives and the result it's about to return. Add a check for division by zero and log an ERROR
level message if it happens.
# logging_example.py
import logging
# Configure logging to show INFO level messages and above
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
def divide(a, b):
logging.info(f"Attempting to divide {a} by {b}")
if b == 0:
logging.error("Division by zero attempted!")
return None
result = a / b
logging.info(f"Calculation successful. Result: {result}")
return result
# Run some examples
divide(10, 2)
divide(5, 0)
divide(100, 10)
Great code isn't just about what it does; it's also about how clean and well-organized it is. Refactoring is the process of improving the internal structure of your code *without* changing its external behavior. It's like tidying up your workshop—you're not building a new thing, you're just organizing your tools and materials so it's easier to work there in the future.
We refactor when we spot "code smells"—signs that the code could be cleaner. Common smells include duplicated code, functions that are way too long, or classes that are trying to do too many different jobs. The fix is often simple: extract a piece of code into its own function, rename a variable to be clearer, or break a big class into smaller, more focused ones.
As you get more experienced, you'll start to see common problems and common solutions. These reusable solutions are called design patterns. They are like proven recipes from expert chefs that you can adapt to your own cooking. Knowing a few basic patterns can help you structure your code in a robust and elegant way from the start.
Look back at the report-generating script you wrote on Day 20. Can you refactor it? Identify the three main parts: 1) reading the data, 2) aggregating the data, and 3) formatting the report. Break the code into three separate functions, each with a single, clear responsibility.
# refactored_report.py
import csv
def read_score_data(filename):
"""Reads data from a CSV file and returns a list of dictionaries."""
with open(filename, 'r', newline='') as f:
return list(csv.DictReader(f))
def analyze_scores(data):
"""Takes score data and returns a dictionary of summary stats."""
scores = [int(row['score']) for row in data]
analysis = {
'player_count': len(data),
'average_score': sum(scores) / len(scores),
'high_score': max(scores)
}
return analysis
def format_report(stats):
"""Takes analysis stats and returns a formatted string report."""
return (
f"--- Gaming Session Report ---\n"
f"Date: 2025-08-29\n"
f"-----------------------------\n"
f"Total Players: {stats['player_count']}\n"
f"Average Score: {stats['average_score']:.0f}\n"
f"High Score: {stats['high_score']} points!\n"
)
# --- Main script logic ---
try:
score_data = read_score_data('scores.csv')
statistics = analyze_scores(score_data)
report = format_report(statistics)
print(report)
except (FileNotFoundError, ZeroDivisionError):
print("Could not generate report. Check scores.csv.")