Type Checking and Interfaces

Beginner

This tutorial is from open-source community. Access the source code

Introduction

In this lab, you will learn to enhance your understanding of type checking and interfaces in Python. By extending a table formatting module, you'll implement concepts such as abstract base classes and interface validation to create more robust and maintainable code.

This lab builds upon concepts from previous exercises, focusing on type safety and interface design patterns. Your objectives include implementing type checking for function parameters, creating and using interfaces with abstract base classes, and applying the template method pattern to reduce code duplication. You will modify tableformat.py, a module for formatting data as tables, and reader.py, a module for reading CSV files.

This is a Guided Lab, which provides step-by-step instructions to help you learn and practice. Follow the instructions carefully to complete each step and gain hands-on experience. Historical data shows that this is a beginner level lab with a 92% completion rate. It has received a 90% positive review rate from learners.

In this step, we're going to make the print_table() function in the tableformat.py file better. We'll add a check to see if the formatter parameter is a valid TableFormatter instance. Why do we need this? Well, type checking is like a safety net for your code. It helps make sure that the data you're working with is of the right type, which can prevent a lot of hard - to - find bugs.

Understanding Type Checking in Python

Type checking is a really useful technique in programming. It allows you to catch errors early in the development process. In Python, we often deal with different types of objects, and sometimes we expect a certain type of object to be passed to a function. To check if an object is of a specific type or a subclass of it, we can use the isinstance() function. For example, if you have a function that expects a list, you can use isinstance() to make sure the input is indeed a list.

First, open the tableformat.py file in your code editor. Scroll down to the bottom of the file, and you'll find the print_table() function. Here's what it looks like initially:

def print_table(data, columns, formatter):
    '''
    Print a table showing selected columns from a data source
    using the given formatter.
    '''
    formatter.headings(columns)
    for item in data:
        rowdata = [str(getattr(item, col)) for col in columns]
        formatter.row(rowdata)

This function takes in some data, a list of columns, and a formatter. It then uses the formatter to print a table. But right now, it doesn't check if the formatter is of the right type.

Let's modify it to add the type check. We'll use the isinstance() function to check if the formatter parameter is an instance of TableFormatter. If it's not, we'll raise a TypeError with a clear message. Here's the modified code:

def print_table(data, columns, formatter):
    '''
    Print a table showing selected columns from a data source
    using the given formatter.
    '''
    if not isinstance(formatter, TableFormatter):
        raise TypeError("Expected a TableFormatter")

    formatter.headings(columns)
    for item in data:
        rowdata = [str(getattr(item, col)) for col in columns]
        formatter.row(rowdata)

Testing Your Type Checking Implementation

Now that we've added the type check, we need to make sure it works. Let's create a new Python file called test_tableformat.py. Here's the code you should put in it:

import stock
import reader
import tableformat

## Read portfolio data
portfolio = reader.read_csv_as_instances('portfolio.csv', stock.Stock)

## Define a formatter that doesn't inherit from TableFormatter
class MyFormatter:
    def headings(self, headers):
        pass
    def row(self, rowdata):
        pass

## Try to use the non-compliant formatter
try:
    tableformat.print_table(portfolio, ['name', 'shares', 'price'], MyFormatter())
    print("Test failed - type checking not implemented")
except TypeError as e:
    print(f"Test passed - caught error: {e}")

In this code, we first read some portfolio data. Then we define a new formatter class called MyFormatter that doesn't inherit from TableFormatter. We try to use this non - compliant formatter in the print_table() function. If our type check is working, it should raise a TypeError.

To run the test, open your terminal and navigate to the directory where the test_tableformat.py file is located. Then run the following command:

python test_tableformat.py

If everything is working correctly, you should see an output like this:

Test passed - caught error: Expected a TableFormatter

This output confirms that our type checking is working as expected. Now, the print_table() function will only accept a formatter that is an instance of TableFormatter or one of its subclasses.

Implementing an Abstract Base Class

In this step, we're going to convert the TableFormatter class into a proper abstract base class (ABC) using Python's abc module. But first, let's understand what an abstract base class is and why we need it.

Understanding Abstract Base Classes

An abstract base class is a special type of class in Python. It's a class that you can't create an object from directly, which means you can't instantiate it. The main purpose of an abstract base class is to define a common interface for its subclasses. It sets a set of rules that all subclasses must follow. Specifically, it requires subclasses to implement certain methods.

Here are some key concepts about abstract base classes:

  • We use the abc module in Python to create abstract base classes.
  • Methods marked with the @abstractmethod decorator are like rules. Any subclass that inherits from an abstract base class must implement these methods.
  • If you try to create an object of a class that inherits from an abstract base class but hasn't implemented all the required methods, Python will raise an error.

Now that you understand the basics of abstract base classes, let's see how we can modify the TableFormatter class to become one.

Modifying the TableFormatter Class

Open the tableformat.py file. We're going to make some changes to the TableFormatter class so that it uses the abc module and becomes an abstract base class.

  1. First, we need to import the necessary things from the abc module. Add the following import statement at the top of the file:
## tableformat.py
from abc import ABC, abstractmethod

This import statement brings in two important things: ABC, which is a base class for all abstract base classes in Python, and abstractmethod, which is a decorator we'll use to mark methods as abstract.

  1. Next, we'll modify the TableFormatter class. It should inherit from ABC to become an abstract base class, and we'll mark its methods as abstract using the @abstractmethod decorator. Here's how the modified class should look:
class TableFormatter(ABC):
    @abstractmethod
    def headings(self, headers):
        '''
        Emit the table headings.
        '''
        pass

    @abstractmethod
    def row(self, rowdata):
        '''
        Emit a single row of table data.
        '''
        pass

Notice a few things about this modified class:

  • The class now inherits from ABC, which means it's officially an abstract base class.
  • Both the headings and row methods are decorated with @abstractmethod. This tells Python that any subclass of TableFormatter must implement these methods.
  • We replaced the NotImplementedError with pass. The @abstractmethod decorator takes care of making sure subclasses implement these methods, so we don't need the NotImplementedError anymore.

Testing Your Abstract Base Class

Now that we've made the TableFormatter class an abstract base class, let's test if it works correctly. We'll create a file called test_abc.py with the following code:

from tableformat import TableFormatter

## Test case 1: Define a class with a misspelled method
try:
    class NewFormatter(TableFormatter):
        def headers(self, headings):  ## Misspelled 'headings'
            pass
        def row(self, rowdata):
            pass

    f = NewFormatter()
    print("Test 1 failed - abstract method enforcement not working")
except TypeError as e:
    print(f"Test 1 passed - caught error: {e}")

## Test case 2: Define a class that properly implements all methods
try:
    class ProperFormatter(TableFormatter):
        def headings(self, headers):
            pass
        def row(self, rowdata):
            pass

    f = ProperFormatter()
    print("Test 2 passed - proper implementation works")
except TypeError as e:
    print(f"Test 2 failed - error: {e}")

In this code, we have two test cases. The first test case defines a class NewFormatter that tries to inherit from TableFormatter but has a misspelled method name. The second test case defines a class ProperFormatter that correctly implements all the required methods.

To run the test, open your terminal and run the following command:

python test_abc.py

You should see output similar to this:

Test 1 passed - caught error: Can't instantiate abstract class NewFormatter with abstract methods headings
Test 2 passed - proper implementation works

This output confirms that our abstract base class is working as expected. The first test case fails because the NewFormatter class didn't implement the headings method correctly. The second test case passes because the ProperFormatter class implemented all the required methods.

Creating Algorithm Template Classes

In this step, we're going to use abstract base classes to implement a template method pattern. The goal is to reduce code duplication in the CSV parsing functionality. Code duplication can make your code harder to maintain and update. By using the template method pattern, we can create a common structure for our CSV parsing code and let sub - classes handle the specific details.

Understanding the Template Method Pattern

The template method pattern is a behavioral design pattern. It's like a blueprint for an algorithm. In a method, it defines the overall structure or the "skeleton" of an algorithm. However, it doesn't fully implement all the steps. Instead, it defers some of the steps to sub - classes. This means that sub - classes can redefine certain parts of the algorithm without changing its overall structure.

In our case, if you look at the reader.py file, you'll notice that the read_csv_as_dicts() and read_csv_as_instances() functions have a lot of similar code. The main difference between them is how they create records from the rows in the CSV file. By using the template method pattern, we can avoid writing the same code multiple times.

Adding the CSVParser Base Class

Let's start by adding an abstract base class for our CSV parsing. Open the reader.py file. We'll add the CSVParser abstract base class right at the top of the file, right after the import statements.

## reader.py
import csv
from abc import ABC, abstractmethod

class CSVParser(ABC):
    def parse(self, filename):
        records = []
        with open(filename) as f:
            rows = csv.reader(f)
            headers = next(rows)
            for row in rows:
                record = self.make_record(headers, row)
                records.append(record)
        return records

    @abstractmethod
    def make_record(self, headers, row):
        pass

This CSVParser class serves as a template for CSV parsing. The parse method contains the common steps for reading a CSV file, like opening the file, getting the headers, and iterating over the rows. The specific logic for creating a record from a row is abstracted into the make_record() method. Since it's an abstract method, any class that inherits from CSVParser must implement this method.

Implementing the Concrete Parser Classes

Now that we have our base class, we need to create the concrete parser classes. These classes will implement the specific record - creation logic.

class DictCSVParser(CSVParser):
    def __init__(self, types):
        self.types = types

    def make_record(self, headers, row):
        return { name: func(val) for name, func, val in zip(headers, self.types, row) }

class InstanceCSVParser(CSVParser):
    def __init__(self, cls):
        self.cls = cls

    def make_record(self, headers, row):
        return self.cls.from_row(row)

The DictCSVParser class is used to create records as dictionaries. It takes a list of types in its constructor. The make_record method uses these types to convert the values in the row and create a dictionary.

The InstanceCSVParser class is used to create records as instances of a class. It takes a class in its constructor. The make_record method calls the from_row method of that class to create an instance from the row.

Refactoring the Original Functions

Now, let's refactor the original read_csv_as_dicts() and read_csv_as_instances() functions to use these new classes.

def read_csv_as_dicts(filename, types):
    '''
    Read a CSV file into a list of dictionaries with appropriate type conversion.
    '''
    parser = DictCSVParser(types)
    return parser.parse(filename)

def read_csv_as_instances(filename, cls):
    '''
    Read a CSV file into a list of instances of a class.
    '''
    parser = InstanceCSVParser(cls)
    return parser.parse(filename)

These refactored functions have the same interface as the original ones. But internally, they use the new parser classes we just created. This way, we've separated the common CSV parsing logic from the specific record - creation logic.

Testing Your Implementation

Let's check if our refactored code works correctly. Create a file named test_reader.py and add the following code to it.

import reader
import stock

## Test the refactored read_csv_as_instances function
portfolio = reader.read_csv_as_instances('portfolio.csv', stock.Stock)
print("First stock:", portfolio[0])

## Test the refactored read_csv_as_dicts function
portfolio_dicts = reader.read_csv_as_dicts('portfolio.csv', [str, int, float])
print("First stock as dict:", portfolio_dicts[0])

## Test direct use of a parser
parser = reader.DictCSVParser([str, int, float])
portfolio_dicts2 = parser.parse('portfolio.csv')
print("First stock from direct parser:", portfolio_dicts2[0])

To run the test, open your terminal and execute the following command:

python test_reader.py

You should see output similar to this:

First stock: Stock('AA', 100, 32.2)
First stock as dict: {'name': 'AA', 'shares': 100, 'price': 32.2}
First stock from direct parser: {'name': 'AA', 'shares': 100, 'price': 32.2}

If you see this output, it means your refactored code is working correctly. Both the original functions and the direct use of parsers are producing the expected results.

Summary

In this lab, you have learned several key object - oriented programming concepts to enhance Python code. First, you implemented type checking in the print_table() function, which ensures that only valid formatters are used, thus improving the code's robustness. Second, you transformed the TableFormatter class into an abstract base class, enforcing subclasses to implement specific methods.

Moreover, you applied the template method pattern by creating the CSVParser abstract base class and its concrete implementations. This reduces code duplication while maintaining a consistent algorithm structure. These techniques are crucial for creating more maintainable and robust Python code, especially in large - scale applications. To further your learning, explore type hints in Python (PEP 484), protocol classes, and design patterns in Python.