โ† Roadmap ๐Ÿ Month 1: Python
0/10

Classes & OOP

Classes are blueprints for creating objects. Think of a class like a cookie cutter โ€” it defines the shape, and each cookie you stamp out is an object with that shape but its own filling.

Learning Objectives

Why Classes? โ€” A Plain-English Analogy

Imagine you're running a pizza restaurant. You have a recipe for a Margherita pizza โ€” that recipe lists the dough, sauce, cheese, and how long to bake it. The recipe itself is not a pizza. It's the blueprint.

Every time you follow that recipe and put a pizza in the oven, you get a real pizza. Each pizza is a separate thing โ€” you can have 5 Margherita pizzas, each with slightly different sizes, but they all follow the same recipe.

โ„น๏ธ

The key terms, explained simply

  • Class = the recipe / the blueprint / the template
  • Object (or instance) = the actual thing you made from the recipe
  • Attributes = the data stored inside each object (the ingredients)
  • Methods = the actions an object can do (the cooking steps)
  • __init__ = the setup that happens when you first create an object (gathering the ingredients)
  • self = a reference to "this particular object" (so each pizza knows its own toppings)

Your First Class โ€” A Simple Bank Account

Let's start with something everyone understands: a bank account. A bank account has a balance (data) and you can deposit or withdraw money (actions).

python bank_account.py
# Step 1: Define the class (the blueprint)
class BankAccount:

    # Step 2: __init__ runs automatically when you create an account
    # 'self' refers to the specific account being created
    def __init__(self, owner_name, starting_balance=0):
        self.owner = owner_name          # store the owner's name
        self.balance = starting_balance  # store the balance

    # Step 3: Define methods (things the account can do)
    def deposit(self, amount):
        self.balance += amount
        print(f"Deposited ${amount:.2f}. New balance: ${self.balance:.2f}")

    def withdraw(self, amount):
        if amount > self.balance:
            print(f"Insufficient funds! Balance: ${self.balance:.2f}")
            return False
        self.balance -= amount
        print(f"Withdrew ${amount:.2f}. New balance: ${self.balance:.2f}")
        return True

    def check_balance(self):
        print(f"{self.owner}'s balance: ${self.balance:.2f}")

# Step 4: Create objects (real accounts) from the class
alice_account = BankAccount("Alice", 100)
bob_account = BankAccount("Bob", 50)

# Step 5: Use the objects
alice_account.check_balance()   # Alice's balance: $100.00
alice_account.deposit(25)        # Deposited $25.00. New balance: $125.00
alice_account.withdraw(10)       # Withdrew $10.00. New balance: $115.00

bob_account.check_balance()     # Bob's balance: $50.00
bob_account.withdraw(100)       # Insufficient funds! Balance: $50.00
๐Ÿ’ก

Understanding self

Think of self as saying "this particular object". When you write self.balance, you're saying "this account's balance". Without self, Python wouldn't know if you mean Alice's balance or Bob's balance โ€” both are BankAccount objects.

What's Actually Happening โ€” Step by Step

When you write alice_account = BankAccount("Alice", 100), Python does this:

text What Python does behind the scenes
1. Creates an empty object (a blank BankAccount)
2. Calls __init__(self, "Alice", 100)
   - self = that empty object (Python fills this in automatically)
   - owner_name = "Alice"
   - starting_balance = 100
3. Inside __init__, self.owner = "Alice" stores the name
4. Inside __init__, self.balance = 100 stores the balance
5. Returns the fully set-up object
6. alice_account now points to that object

You never pass self yourself. Python automatically fills it in. That's why BankAccount("Alice", 100) has 2 arguments, but __init__(self, owner_name, starting_balance) has 3 parameters โ€” the first one is handled for you.

A Real AI Example โ€” An LLM Client

Here's a class you might actually write in AI engineering โ€” a client that talks to an LLM API:

python llm_client.py
class LLMClient:
    """A simple client for calling LLM APIs."""

    # This is a class attribute โ€” shared by ALL LLMClient objects
    default_temperature = 0.7

    def __init__(self, api_key: str, model: str = "gpt-4o-mini"):
        # These are instance attributes โ€” each object has its own
        self.api_key = api_key
        self.model = model
        self.conversation_history = []    # empty list for each client
        self.total_tokens = 0           # track usage per client

    def chat(self, user_message: str) -> str:
        """Send a message and get a response."""
        # Add user message to history
        self.conversation_history.append({
            "role": "user",
            "content": user_message
        })

        # Simulate an API call (in real code, use the requests library)
        response = f"[Response from {self.model}]: You said: {user_message}"
        tokens_used = len(user_message) * 2  # fake token count

        # Add assistant response to history
        self.conversation_history.append({
            "role": "assistant",
            "content": response
        })

        self.total_tokens += tokens_used
        return response

    def clear_history(self):
        """Start a fresh conversation."""
        self.conversation_history = []
        print("Conversation history cleared.")

    def get_usage(self):
        """Show token usage for this client."""
        print(f"Model: {self.model} | Tokens used: {self.total_tokens | Messages: {len(self.conversation_history)")


# Create two different clients
chatbot = LLMClient(api_key="sk-abc123", model="gpt-4o")
coder = LLMClient(api_key="sk-abc123", model="claude-sonnet-4.6-sonnet")

# Each client has its own conversation
chatbot.chat("What's the weather?")
chatbot.chat("How about tomorrow?")   # remembers previous message

coder.chat("Write a Python function")  # separate conversation

chatbot.get_usage()   # Model: gpt-4o | Tokens: X | Messages: 4
coder.get_usage()    # Model: claude-sonnet-4.6-sonnet | Tokens: Y | Messages: 2
๐Ÿ”

Class attribute vs Instance attribute

  • Class attribute (default_temperature) โ€” shared by all objects. Like a company policy that applies to everyone.
  • Instance attribute (self.model) โ€” each object has its own copy. Like an employee's personal name.
  • Change a class attribute and it changes for all objects. Change an instance attribute and it only affects that one object.

More Examples โ€” Different Domains, Same Pattern

Example: A Todo Item

This shows how to represent a simple data object with a method that changes its state.

python todo.py
class TodoItem:
    def __init__(self, title, priority="medium"):
        self.title = title
        self.priority = priority
        self.completed = False       # all new todos start as not done

    def mark_done(self):
        self.completed = True
        print(f"โœ… Done: {self.title}")

    def reopen(self):
        self.completed = False
        print(f"๐Ÿ”„ Reopened: {self.title}")

    def __str__(self):
        """Controls what print(todo) shows."""
        status = "โœ…" if self.completed else "โฌœ"
        return f"{status} [{self.priority] {self.title"


# Create and use todo items
task1 = TodoItem("Learn Python classes", "high")
task2 = TodoItem("Call OpenAI API", "medium")

print(task1)        # โฌœ [high] Learn Python classes
task1.mark_done()   # โœ… Done: Learn Python classes
print(task1)        # โœ… [high] Learn Python classes
print(task2)        # โฌœ [medium] Call OpenAI API      
๐Ÿ’ก

The __str__ method

__str__ is a special method that controls what happens when you print() an object. Without it, you get an ugly <__main__.TodoItem object at 0x...>. With it, you get a clean, readable string. Always add __str__ to your classes.

Example: A Document for RAG

This is a real pattern from AI engineering โ€” representing a document that will be stored in a vector database.

python document.py
class Document:
    """A chunk of text that can be embedded and retrieved."""

    def __init__(self, content, source="unknown", page=None):
        self.content = content
        self.source = source
        self.page = page
        self.embedding = None          # will be filled later
        self.chunk_id = None           # will be assigned later

    def word_count(self):
        """How many words in this document chunk."""
        return len(self.content.split())

    def preview(self, max_chars=100):
        """Short preview of the content."""
        if len(self.content) <= max_chars:
            return self.content
        return self.content[:max_chars] + "..."

    def set_embedding(self, vector):
        """Store the embedding vector (list of floats)."""
        self.embedding = vector

    def to_dict(self):
        """Convert to a dict for JSON serialization."""
        return {
            "content": self.content,
            "source": self.source,
            "page": self.page,
            "chunk_id": self.chunk_id,
        }

    def __str__(self):
        return f"Document(from={self.source, words={self.word_count())"


# Create document chunks
doc1 = Document(
    content="Python is a programming language...",
    source="python_docs.pdf",
    page=1
)
doc2 = Document(
    content="Machine learning models learn from data...",
    source="ml_intro.pdf",
    page=5
)

print(doc1)                        # Document(from=python_docs.pdf, words=6)
print(doc1.preview(20))            # "Python is a program..."
doc1.set_embedding([0.1, 0.5, 0.3])   # store vector
print(doc1.to_dict())               # serialize for JSON

Inheritance โ€” Building on What Exists

Inheritance means one class copies and extends another. Think of it like a template: a "Vehicle" class has wheels and speed. A "Car" is a Vehicle, but it also has doors. A "Motorcycle" is also a Vehicle, but has no doors. Both inherit "wheels and speed" from Vehicle.

python inheritance.py
# PARENT class โ€” the base everything builds on
class LLMClient:
    def __init__(self, api_key, model):
        self.api_key = api_key
        self.model = model
        self.history = []

    def chat(self, message):
        print(f"[{self.model] Processing: {message")
        return "Generic response"

    def clear_history(self):
        self.history = []


# CHILD class โ€” inherits everything from LLMClient, adds its own stuff
class OpenAIClient(LLMClient):          # (LLMClient) = inherits from
    def __init__(self, api_key, model="gpt-4o-mini"):
        super().__init__(api_key, model)   # call parent's __init__
        self.base_url = "https://api.openai.com/v1"  # OpenAI-specific

    def chat(self, message):              # OVERRIDE the parent's chat
        # Use OpenAI-specific API format
        print(f"๐ŸŒ Calling OpenAI {self.model...")
        self.history.append({"role": "user", "content": message})
        # ... real API call would go here ...
        return "OpenAI response"


# ANOTHER child class โ€” different behavior, same interface
class AnthropicClient(LLMClient):
    def __init__(self, api_key, model="claude-sonnet-4.6-sonnet"):
        super().__init__(api_key, model)
        self.base_url = "https://api.anthropic.com/v1"

    def chat(self, message):              # OVERRIDE differently
        print(f"๐Ÿค– Calling Anthropic {self.model...")
        # ... Anthropic-specific API call ...
        return "Claude response"


# Use them โ€” same interface, different behavior
openai = OpenAIClient("sk-abc")
claude = AnthropicClient("sk-xyz")

openai.chat("Hello")    # ๐ŸŒ Calling OpenAI gpt-4o-mini...
claude.chat("Hello")    # ๐Ÿค– Calling Anthropic claude-sonnet-4.6-sonnet...

# Both inherited clear_history() from the parent
openai.clear_history()   # works even though we didn't define it
๐Ÿ’ก

Why inheritance matters for AI engineering

When you work with LangChain, LlamaIndex, FastAPI, or any AI library, you'll subclass their classes (e.g., class MyRetriever(BaseRetriever)). You rarely write parent classes yourself โ€” you inherit from the library and override the methods you need to customize.

super() โ€” Calling the Parent's Methods

super() lets a child class call methods from its parent. The most common use: calling the parent's __init__ so you don't have to repeat the setup code.

python super_example.py
class BaseRAG:
    """Base class with shared RAG functionality."""

    def __init__(self, documents, embedding_model):
        self.documents = documents
        self.embedding_model = embedding_model
        self.index = None

    def index_documents(self):
        print(f"Indexing {len(self.documents) documents...")
        # ... embedding and indexing logic ...

    def query(self, question):
        print(f"Querying: {question")
        return "Answer from documents"


class PineconeRAG(BaseRAG):
    """RAG using Pinecone as the vector store."""

    def __init__(self, documents, embedding_model, pinecone_api_key):
        # Call parent's __init__ to set up documents and embedding_model
        super().__init__(documents, embedding_model)
        # Then add Pinecone-specific setup
        self.api_key = pinecone_api_key
        self.index_name = "my-rag-index"

    def index_documents(self):
        # First do the parent's indexing logic
        super().index_documents()
        # Then add Pinecone-specific upload
        print(f"Uploading to Pinecone index: {self.index_name")


# PineconeRAG has EVERYTHING from BaseRAG plus its own additions
rag = PineconeRAG(documents=["doc1", "doc2"], embedding_model="text-embedding-3", pinecone_api_key="pk-xxx")
rag.index_documents()   # Indexing 2 documents... + Uploading to Pinecone
rag.query("What is RAG?") # Querying: What is RAG? (inherited from parent)

Special Methods (Dunder Methods)

Python has special methods with double underscores (called "dunder" methods). You've seen __init__ and __str__. Here are the ones you'll use most:

python dunder.py
class Vector:
    """A simple 2D vector โ€” shows off dunder methods."""

    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __str__(self):
        """What print() shows."""
        return f"Vector({self.x, {self.y)"

    def __repr__(self):
        """What the REPL/debugger shows."""
        return f"Vector({self.x, {self.y)"

    def __add__(self, other):
        """What the + operator does."""
        return Vector(self.x + other.x, self.y + other.y)

    def __len__(self):
        """What len() returns."""
        return int((self.x ** 2 + self.y ** 2) ** 0.5)

    def __eq__(self, other):
        """What the == operator does."""
        return self.x == other.x and self.y == other.y


# Using the special methods
v1 = Vector(3, 4)
v2 = Vector(1, 2)

print(v1)           # Vector(3, 4)  โ†’ uses __str__
print(v1 + v2)       # Vector(4, 6)  โ†’ uses __add__
print(len(v1))        # 5             โ†’ uses __len__ (3ยฒ+4ยฒ=25, โˆš25=5)
print(v1 == v2)      # False         โ†’ uses __eq__
๐Ÿ“‹

Most useful dunder methods for AI engineering

  • __init__ โ€” Setup when creating an object
  • __str__ โ€” Human-readable string (print)
  • __repr__ โ€” Developer-readable string (debugging)
  • __len__ โ€” Support len(obj)
  • __eq__ โ€” Support obj1 == obj2
  • __getitem__ โ€” Support obj[key] (like dicts/lists)
  • __contains__ โ€” Support x in obj

What You'll Actually Do โ€” Read and Use Library Classes

In real AI engineering, you'll use classes written by others far more often than you'll write your own. Here's what that looks like:

python using_libraries.py
# Using FastAPI's APIRouter class
from fastapi import FastAPI, HTTPException

app = FastAPI()                  # create a FastAPI object

# Using Pydantic's BaseModel class (inheritance!)
from pydantic import BaseModel

class ChatRequest(BaseModel):   # inherit from BaseModel
    message: str
    model: str = "gpt-4o-mini"

# Using LangChain's classes
from langchain.llms import OpenAI

llm = OpenAI(model="gpt-4o-mini", temperature=0.7)
result = llm.predict("Explain OOP")  # calling a method on the object
๐Ÿ’ก

What level of OOP do you actually need?

  • Must know: Create objects from classes, call methods, understand self
  • Should know: Write simple classes with __init__ and a few methods
  • Helpful to know: Inheritance, super(), overriding methods
  • Don't need yet: Multiple inheritance, metaclasses, decorators as classes, abstract base classes

Example: Putting It All Together

A simple prompt template system โ€” this shows classes, inheritance, and dunder methods working together:

python prompt_templates.py
class PromptTemplate:
    """Base class for prompt templates."""

    def __init__(self, template: str, variables: list):
        self.template = template
        self.variables = variables

    def format(self, **kwargs) -> str:
        """Fill in the template with provided values."""
        # Check that all required variables are provided
        missing = [v for v in self.variables if v not in kwargs]
        if missing:
            raise ValueError(f"Missing variables: {missing")

        result = self.template
        for key, value in kwargs.items():
            result = result.replace("{" + key + "}", str(value))
        return result

    def __str__(self):
        return f"PromptTemplate(vars={self.variables)"


class SystemPrompt(PromptTemplate):
    """A prompt with a system role attached."""

    def __init__(self, system_msg, template, variables):
        super().__init__(template, variables)
        self.system_message = system_msg

    def to_messages(self, **kwargs):
        """Return the full message list for an API call."""
        return [
            {"role": "system", "content": self.system_message},
            {"role": "user", "content": self.format(**kwargs)}
        ]

    def __str__(self):
        return f"SystemPrompt(sys={self.system_message[:30]...)"


# Use it
qa_prompt = SystemPrompt(
    system_msg="You are a helpful assistant. Answer based on the context.",
    template="Context: {context}\n\nQuestion: {question}",
    variables=["context", "question"]
)

messages = qa_prompt.to_messages(
    context="Python was created by Guido van Rossum",
    question="Who created Python?"
)

print(messages)
# [
#   {"role": "system", "content": "You are a helpful assistant..."},
#   {"role": "user", "content": "Context: Python was created..."}  
# ]

๐Ÿงช Exercises

๐Ÿ“

Exercise 1 โ€” Student Grade Book

Create a Student class with: name, grades (list of numbers), methods: add_grade(grade), average() (returns float), status() (returns "Pass" if average >= 60, else "Fail"). Add __str__ that returns "Alice: 85.3 (Pass)". Create 3 students and test.

๐Ÿ“

Exercise 2 โ€” API Response Wrapper

Create a APIResponse class with: status_code, data (dict), error (str or None). Methods: is_success(), get_data(key, default), __str__. Then create CachedAPIResponse that inherits from it and adds cached_at (timestamp) and is_expired(max_age_seconds).

๐Ÿ“

Exercise 3 โ€” Chat History

Create a ChatHistory class that stores messages as a list of {"role": ..., "content": ...} dicts. Methods: add_user_msg(text), add_assistant_msg(text), get_last_n(n) (returns last n messages), token_estimate() (rough: 4 chars per token), truncate_to_tokens(max_tokens) (removes oldest messages first to fit).

โš ๏ธ Common Mistakes

โœ—

Forgetting self in method definitions

def chat(message): will crash with TypeError: chat() takes 1 positional argument but 2 were given. Every method must have self as the first parameter: def chat(self, message):.

โœ—

Using mutable default arguments

Do NOT write def __init__(self, history=[]):. The list is created ONCE and shared between all instances. Instead: def __init__(self, history=None): self.history = history or [].

โœ—

Over-engineering with deep inheritance

3+ levels of inheritance makes code hard to follow. Prefer composition (having an object as an attribute) over inheritance. E.g., don't make MarkdownDocument(PDFDocument(Document)) โ€” instead give Document a format attribute.

โœ—

Confusing class attributes and instance attributes

If you define a list at the class level (class Foo: items = []), ALL instances share that same list. Always define mutable attributes in __init__ using self.items = [].

โœ—

Forgetting super().__init__()

When you override __init__ in a child class, the parent's __init__ is NOT called automatically. You must explicitly write super().__init__(...) or the parent's setup won't run.

โœ… You've completed this step when you can confirm: