๐Ÿ“˜ Phase A ยท Module 1

Python Deep

โฑ ~10 hours ๐Ÿ“… Week 1 ๐ŸŽฏ Read this, then /learn A1

By the end of this module you will:

OOP Type hints Exception handling Comprehensions Generators Decorators Virtual environments

Why Python, and why go deep?

Python is the language of the AI engineering stack. Not because it's the best language โ€” it isn't โ€” but because the entire ecosystem converged on it. NumPy, PyTorch, FastAPI, LangChain, OpenAI's SDK โ€” all Python. When you're working as an AI engineer, every tool you reach for will be Python. The community decided, and that decision is final for this decade.

But here's the thing about Python that trips up AI-assisted coders: Python is easy to write badly and hard to read when written badly. AI tools generate syntactically correct Python constantly. Your job โ€” especially given what your role needs โ€” is to read that output and know whether it's good Python or just working Python. There's a big difference.

This module goes deeper than "variables and loops." You already know those. We're going to the parts where real Python diverges from beginner Python: proper OOP, type safety, error discipline, and the idioms that make a codebase readable six months later.

Object-Oriented Programming done right

OOP in Python gets misused constantly. Here's what you actually need to know:

Classes: what they're for

A class bundles state (data) and behaviour (methods) that belong together. The test: if you find yourself passing the same three variables into every function, they want to be a class.

# Bad โ€” passing the same state around everywhere
def get_flight_status(flight_number, departure, arrival):
    ...
def delay_flight(flight_number, departure, arrival, minutes):
    ...

# Good โ€” state lives in one place
class Flight:
    def __init__(self, number: str, departure: datetime, arrival: datetime):
        self.number = number
        self.departure = departure
        self.arrival = arrival

    def delay(self, minutes: int) -> None:
        self.departure += timedelta(minutes=minutes)
        self.arrival += timedelta(minutes=minutes)

The dunder methods you must know

Python's "dunder" (double underscore) methods make your classes work with the language itself. These aren't advanced โ€” they're what makes your code readable:

class Task:
    def __init__(self, title: str, priority: int = 1):
        self.title = title
        self.priority = priority
        self.done = False

    def __repr__(self) -> str:
        status = "โœ“" if self.done else "โ—‹"
        return f"Task({status} [{self.priority}] {self.title!r})"

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Task):
            return NotImplemented
        return self.title == other.title

Inheritance: use sparingly

Inheritance is tempting but frequently overused. The rule: prefer composition over inheritance. If you're inheriting more than one level deep ("this class extends that class which extends that other class"), you've probably made a design mistake. Flat is better than nested.

When to actually use inheritance: when objects share both state and behaviour, and there's a genuine "is-a" relationship. A PriorityTask is a Task. An Employee is a Person. Use it for that; reach for composition for everything else.

Type hints โ€” write them everywhere

Python is dynamically typed, which means the interpreter won't stop you from passing a string where you meant an integer. Type hints don't fix that at runtime, but they do three things that matter enormously:

  1. Your editor catches bugs before you run anything โ€” a function that expects int gets called with str? Your IDE underlines it immediately.
  2. Code is self-documenting โ€” you never have to wonder what a function takes as arguments.
  3. AI tools generate better code โ€” when your codebase has type hints, Cody can infer what types flow where and generate more correct completions.
from typing import Optional, List, Dict

# Without type hints โ€” what does this return?
def find_user(user_id, active_only=True):
    ...

# With type hints โ€” immediately clear
def find_user(
    user_id: int,
    active_only: bool = True
) -> Optional["User"]:
    """Return User by ID, or None if not found."""
    ...

Key types to know: str, int, float, bool, None, Optional[X] (might be None), List[X], Dict[K,V], Tuple[X,Y], Union[X,Y]. In Python 3.10+: use X | None instead of Optional[X].

Exception handling โ€” the right way

This is one of the most common places AI-generated code goes wrong, and one of the easiest flaws to spot in a review.

The #1 Python sin: bare except

except: or except Exception: without re-raising or logging is almost always wrong. It silently swallows errors. A function that "works" but silently eats exceptions will cause mysterious failures at 3am.

# โŒ Wrong โ€” silently swallows ALL errors including KeyboardInterrupt
try:
    result = process_data(data)
except:
    pass

# โŒ Wrong โ€” too broad, and eating the error
try:
    result = process_data(data)
except Exception:
    result = None

# โœ“ Right โ€” catch specific exceptions, log them, re-raise or return meaningful state
try:
    result = process_data(data)
except ValueError as e:
    logger.error(f"Invalid data format: {e}")
    raise
except IOError as e:
    logger.error(f"File operation failed: {e}")
    return None  # acceptable if callers handle None

Custom exceptions

Define custom exceptions for your domain โ€” they make error handling explicit and readable. The caller can catch InsufficientFundsError and handle it specifically, rather than catching ValueError and guessing what went wrong.

class AppError(Exception):
    """Base exception for this application."""

class NotFoundError(AppError):
    def __init__(self, resource: str, id_: int):
        self.resource = resource
        self.id_ = id_
        super().__init__(f"{resource} with id={id_} not found")

class ValidationError(AppError):
    def __init__(self, field: str, message: str):
        super().__init__(f"Validation failed for '{field}': {message}")

Comprehensions โ€” readable, not clever

List, dict, and set comprehensions are one of Python's strengths. They're concise and fast. But they become unreadable when abused.

# โœ“ Good โ€” clear and concise
active_users = [u for u in users if u.is_active]
email_map = {u.id: u.email for u in users}
unique_domains = {u.email.split("@")[1] for u in users}

# โŒ Bad โ€” nested comprehension that requires re-reading three times
result = [item for sublist in [[x*2 for x in row] for row in matrix] for item in sublist]

# โœ“ Better โ€” just use a loop when it's complex
result = []
for row in matrix:
    for x in row:
        result.append(x * 2)

Rule of thumb: if a comprehension doesn't fit on one line without squinting, replace it with a loop.

Generators โ€” lazy is good

A generator is a function that produces values one at a time instead of building a full list in memory. This matters when you're processing large files, database results, or streams.

# Without generator โ€” loads ALL lines into memory at once
def read_big_file(path: str) -> List[str]:
    with open(path) as f:
        return f.readlines()  # 10GB file = 10GB in RAM

# With generator โ€” yields one line at a time
def read_big_file(path: str):
    with open(path) as f:
        for line in f:
            yield line.strip()

# Caller code is identical either way
for line in read_big_file("huge.log"):
    process(line)

Spot this pattern in AI-generated code: if a function builds a list and returns it, ask yourself โ€” does the caller actually need all of it at once? If not, it should probably be a generator.

The mutable default argument trap

This is one of Python's most famous gotchas, and AI tools still generate this bug regularly. Learn to spot it.

# โŒ Bug โ€” the list is created ONCE when the function is defined
# Every call that doesn't pass `items` shares the SAME list
def add_item(item: str, items: List[str] = []) -> List[str]:
    items.append(item)
    return items

add_item("a")  # ['a']
add_item("b")  # ['a', 'b']  โ† 'a' is still there!

# โœ“ Correct โ€” use None as default, create fresh list inside
def add_item(item: str, items: Optional[List[str]] = None) -> List[str]:
    if items is None:
        items = []
    items.append(item)
    return items
Flaw-spotting shortcut

Any time you see def func(arg=[]): or def func(arg={}):, that's a bug waiting to happen. Same for def func(arg=SomeClass()). Flag it immediately in a code review.

Decorators โ€” what they actually are

Decorators sound scary but they're just functions that wrap other functions. You'll see them constantly in FastAPI, Flask, and testing frameworks. You need to read them, not necessarily write them from scratch.

# A decorator is just a function that takes a function and returns a function
def log_call(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result}")
        return result
    return wrapper

# @log_call is syntactic sugar for: greet = log_call(greet)
@log_call
def greet(name: str) -> str:
    return f"Hello, {name}"

greet("John")
# Calling greet
# greet returned Hello, John

In FastAPI you'll write @app.get("/users") โ€” that's a decorator registering your function as an HTTP route handler. In pytest you'll use @pytest.fixture. You don't need to build these, but you must be able to read them and understand what they're doing.

Enums โ€” stop using magic strings

Using raw strings for a finite set of options is a common code smell AI tools produce. When you see status = "active" compared against if status == "actve": (typo!), you've found the bug. Enums eliminate this class of error entirely.

from enum import Enum, auto

# โŒ Magic strings โ€” typo-prone, no IDE completion
def process(status: str):  # "active", "inactive", "pending"???
    if status == "actve":   # typo, silently wrong
        ...

# โœ“ Enum โ€” self-documenting, IDE-completable, typo-proof
class Status(Enum):
    ACTIVE = "active"
    INACTIVE = "inactive"
    PENDING = "pending"

def process(status: Status) -> None:
    if status == Status.ACTIVE:  # IDE autocompletes, can't typo
        ...

Resources for this module

๐Ÿ’ป Exercise โ€” do this with Cody

Open Claude Code and say: /learn A1

Cody will walk you through building a typed Task manager: a Task class with type hints, a Priority Enum, custom exceptions, __repr__, and iteration support. You write the code; Cody reviews each piece and explains what's good and what to improve.

Estimated time: 2โ€“3 hours of coding. Read this lesson first, then sit down and build.

๐Ÿ”
Flaw Drill โ€” after you finish the exercise
Find the bugs before running the code. This is the skill.

The code below has 5 bugs. Read it carefully, identify all 5, and write what you'd say in a code review. Then run /learn A1 flaw-drill and Cody will grade your review.

from typing import List

class TaskManager:
    def __init__(self, tasks: List = []):   # Bug 1
        self.tasks = tasks

    def add_task(self, title: str, priority: str = "high"):  # Bug 2
        self.tasks.append({"title": title, "priority": priority})

    def complete_task(self, index: int) -> bool:
        try:
            self.tasks[index]["done"] = True
            return True
        except:              # Bug 3
            return False

    def get_high_priority(self) -> List:  # Bug 4
        return [t for t in self.tasks if t["priority"] == "high"]

    def export(self) -> str:
        db_query = f"INSERT INTO tasks VALUES ('{self.tasks}')"  # Bug 5
        return db_query

Write your findings, then verify with /learn A1 flaw-drill in Cody.

โ† Phase A Next: Problem-Solving โ†’