Phase 2 — Python

Writing Python

By the end of Phase 2, you can write a function with tests and debug when it fails. See the Phase 2 Gate for the exact test.

Chapters 05–09Phase Gate + TaskForge
Before You Begin Phase 2

This phase assumes you can: open a terminal and run commands (Ch 03), read a short Python function and predict its output (Ch 04), and explain what variables, types, and basic operators do (Ch 02). If any of these feel shaky, revisit the relevant Phase 1 chapter first.

Chapter 05 Functions, Logic, and Control Flow

Why This Matters Now

Functions, conditionals, and loops are the three patterns that appear in virtually every program. When Claude Code generates a 200-line function, you need to recognize these patterns to judge whether the logic is correct. You can't supervise what you can't read.

In Phase 1 you learned to read code. Now you write it. This chapter covers the three building blocks that appear in every program: functions (reusable blocks), conditionals (decision points), and loops (repetition).

Functions

A function is like a recipe card in a kitchen: it has a name, a list of ingredients (parameters), and step-by-step instructions. You write the recipe once, then use it every time you need that dish. In code terms: you define it once, then call it whenever you need it. One function, one job.

def greet(name):
    return f"Hello, {name}!"

print(greet("World"))  # Hello, World!

def defines the function. name is a parameter—the input. return sends a value back. Defining a function does NOT run it; calling it does.

Comparison and Boolean Operators

Before your program can make decisions, it needs a way to ask yes-or-no questions. Comparison operators compare two values and produce a Boolean (True or False):

Python comparison operators and their results
OperatorMeaningExampleResult
==Equal to5 == 5True
!=Not equal to5 != 3True
<Less than3 < 10True
>Greater than10 > 20False
<=Less than or equal5 <= 5True
>=Greater than or equal7 >= 10False
Common Trap: = vs ==

= is assignment (put a value in a box). == is comparison (are these two values equal?). Writing if x = 5: when you mean if x == 5: is one of the most common mistakes beginners make—and one that AI-generated code almost never gets wrong.

Boolean operators combine True/False values:

Python Boolean operators for combining conditions
OperatorMeaningExampleResult
andBoth must be TrueTrue and FalseFalse
orAt least one must be TrueTrue or FalseTrue
notFlips the valuenot TrueFalse
age = 25
has_license = True
can_drive = age >= 16 and has_license  # True

The in operator tests membership—whether a value exists inside a collection:

print("a" in "apple")     # True
print(3 in [1, 2, 3])     # True
print("quit" in "quilts") # True — checks substrings too

In TaskForge, you'll see patterns like if command == "add": using comparison operators and for task in tasks: using in to iterate. These operators are the glue that connects data to decisions—and they're the prerequisite for conditionals below.

Conditionals

if/elif/else lets your program choose a path. The program executes the first true branch and skips the rest.

Input Value Check Path A Path B Path C
The program takes ONE path. Once it enters a branch, it skips all others.

Loops

for loops process each item in a collection. while loops repeat until a condition changes.

for item in [10, 20, 30, 40, 50]: total += item Iteration item total before total after 1 10 0 10 2 20 10 30 3 30 30 60 4 40 60 100 5 50 100 150
The loop processes one item at a time. The accumulator remembers the running result.

Scope

Variables defined inside a function are invisible outside it. This is called scope. It's a common source of bugs in AI-generated code—the AI sometimes references a variable that only exists inside another function.

Here's a concrete example. This function looks like it updates a counter, but it doesn't:

# BUG: this does NOT modify the outer variable
count = 0

def increment():
    count = count + 1  # UnboundLocalError!

increment()
print(count)  # never reached

Python sees count = ... inside the function and treats count as a local variable. But you're also trying to read count on the right side before it's been assigned locally—hence the error. The fix: pass the value in and return the result out.

# FIX: pass in, return out
count = 0

def increment(current):
    return current + 1

count = increment(count)
print(count)  # 1
Scope and AI-Generated Code

AI-generated code sometimes manipulates global state. Recognizing scope issues helps you catch these bugs. If you see a function reading or writing a variable that isn't a parameter or defined inside the function, that's a red flag.

Micro-Exercises

1: Write a Function
def greet(name):
    return f"Hello, {name}!"

print(greet("World"))
2: Write a Conditional
def is_positive(n):
    if n > 0:
        return True
    return False
3: Write a Loop
for num in [1, 2, 3, 4, 5]:
    print(num * 10)

TaskForge Connection

Look at TaskForge's complete_task: it uses a for loop with an if conditional inside a function. Every concept from this chapter appears in that one function.

Try This Now

Write analyze_signal(value, threshold) that returns 'CRITICAL' if value > threshold, 'WARNING' if value > threshold * 0.9, 'NORMAL' otherwise.

analyze_signal.pydef analyze_signal(value, threshold): if value > threshold: return 'CRITICAL' elif value > threshold * 0.9: return 'WARNING' else: return 'NORMAL' print(analyze_signal(95, 100)) # WARNING print(analyze_signal(105, 100)) # CRITICAL print(analyze_signal(50, 100)) # NORMAL

Verification: All 3 test cases return the expected value.

If this doesn't work: (1) All results are 'CRITICAL' → check order: CRITICAL first, then WARNING. (2) NameError → you forgot def or misspelled the function name. (3) Nothing returns → you used print() instead of return.

You just wrote a function with parameters, conditionals, and a return value. This is the same structure behind every TaskForge command and every function Claude Code generates.

String Methods

Strings are everywhere—user input, file paths, API responses, log messages. Python gives you built-in methods to slice, clean, and reassemble them. You'll use these constantly when processing text data and parsing AI output.

f-strings

f-strings let you embed expressions directly inside a string. Prefix the string with f and put variables or expressions in curly braces:

name = "Alice"
tasks = 5
print(f"{name} has {tasks} tasks")  # Alice has 5 tasks
print(f"Double: {tasks * 2}")       # Double: 10

Essential String Methods

Essential Python string methods with examples
MethodExampleResult
split(sep)"a,b,c".split(",")["a", "b", "c"]
strip()" hello ".strip()"hello"
join(list)", ".join(["a", "b"])"a, b"
replace(old, new)"hello".replace("l", "r")"herro"
startswith(s)"hello".startswith("he")True
endswith(s)"file.py".endswith(".py")True

These methods return new strings—they never modify the original. Strings in Python are immutable.

Micro-Exercise: String Pipeline

Given the string ' alice,bob,charlie ', use string methods to produce ['Alice', 'Bob', 'Charlie'].

raw = '  alice,bob,charlie  '
names = [name.strip().capitalize() for name in raw.strip().split(",")]
print(names)  # ['Alice', 'Bob', 'Charlie']

This chains strip() to remove outer whitespace, split(",") to break on commas, then capitalize() on each piece. You'll see this pattern constantly when cleaning data.

Interactive Exercises

Design Challenge: "FizzBuzz"

Write fizzbuzz(n) returning a list of strings from 1 to n. Multiples of 3 → 'Fizz', multiples of 5 → 'Buzz', both → 'FizzBuzz', otherwise the number as a string.

Use the modulo operator (%) to check divisibility.

Check divisibility by 15 before checking 3 or 5 separately.

Build a list with a for loop: for i in range(1, n+1)

Design Challenge: "Palindrome Checker"

Write is_palindrome(s) that returns True if the string is a palindrome. Ignore case and non-alphanumeric characters.

First normalize: lowercase and filter to only alphanumeric characters.

Use c.isalnum() to check if a character is alphanumeric.

Compare the cleaned string to its reverse: cleaned == cleaned[::-1]

Knowledge Check

What happens if you try to use a variable defined inside a function, outside of that function?

Functions as Values: Higher-Order Functions and Closures

In Python, functions are first-class values — you can assign them to variables, pass them as arguments, and return them from other functions. AI generates these patterns constantly.

Built-in Higher-Order Functions

map(), filter(), and sorted() with a key function are the most common. Each takes a function as an argument:

# map: apply a function to every element
names = ["alice", "bob", "charlie"]
upper_names = list(map(str.upper, names))  # ["ALICE", "BOB", "CHARLIE"]

# filter: keep elements where function returns True
numbers = [1, 2, 3, 4, 5, 6]
evens = list(filter(lambda x: x % 2 == 0, numbers))  # [2, 4, 6]

# sorted with key: sort by a custom criterion
tasks = [{"title": "B task", "priority": 2}, {"title": "A task", "priority": 1}]
by_priority = sorted(tasks, key=lambda t: t["priority"])
map/filter vs. List Comprehensions

AI generates map() and filter() frequently. In Python, list comprehensions are usually more readable: [x.upper() for x in names] vs list(map(str.upper, names)). Knowing both lets you read the AI's code and decide which is clearer for your context.

Lambda Expressions

A lambda is an anonymous function — useful as a short, throwaway argument:

# Lambda: anonymous function for one-time use
square = lambda x: x ** 2  # equivalent to def square(x): return x ** 2

# Most useful as arguments to higher-order functions
sorted_tasks = sorted(tasks, key=lambda t: t["due_date"])

Rule of thumb: If a lambda is more than one expression, use a named function instead. Readability matters more than brevity.

In practice, PEP 8 recommends using def for named functions. Lambdas shine as inline arguments: sorted(names, key=lambda n: len(n)).

Closures

A closure is a function that remembers variables from the scope where it was defined:

def make_multiplier(n):
    def multiplier(x):
        return x * n  # 'n' is remembered from the outer scope
    return multiplier

double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5))  # 10
print(triple(5))  # 15

AI generates closures in callbacks, event handlers, and factory patterns. Understanding them means you can read and debug these patterns when they appear.

Exercise: Higher-Order Functions

Implement apply_to_all(func, lst) that applies a function to every element (like map), and keep_if(func, lst) that keeps elements where func returns True (like filter).

For apply_to_all: create a new list, loop through lst, append func(item) for each item.

For keep_if: create a new list, loop through lst, append item only if func(item) is True.

apply_to_all: return [func(x) for x in lst]. keep_if: return [x for x in lst if func(x)].

Exercise: Closure — Make a Validator

Implement make_validator(min_len, max_len) that returns a function checking if a string's length is within range.

Return an inner function that takes a string and checks min_len <= len(s) <= max_len.

The inner function "closes over" min_len and max_len from the outer scope.

def make_validator(min_len, max_len): def check(s): return min_len <= len(s) <= max_len; return check

Solve & Compare: Word Frequency Counter

Step 1: Solve this problem yourself. Step 2: Click “Hint” three times to see how an AI solved it. Step 3: Compare and answer the evaluation questions in comments.

Solve it yourself first — use .lower() and .split(), then loop through words and count with a dict.

To strip punctuation, try word.strip(".,!?;:") on each word after splitting.

AI’s Solution:
from collections import Counter
def word_freq(text):
    words = text.lower().split()
    cleaned = [w.strip(".,!?;:") for w in words]
    return dict(Counter(w for w in cleaned if w))


Evaluation: The AI used Counter from collections — a one-liner that does what your loop does. Your manual approach works perfectly fine. But now you know Counter exists — add it to your vocabulary. In TaskForge, you might use it to count task statuses.

Chapter 06 Data Structures — Lists, Dicts, and How Data Lives

Why This Matters Now

Every API response, every config file, every AI tool output is structured data—usually JSON, which is just nested lists and dictionaries. If you can't navigate nested data, you can't use APIs, read AI output, or debug data issues.

Functions process data. But where does the data live? In data structures—containers that organize information. Two structures dominate Python and AI tooling: lists (ordered sequences) and dictionaries (key-value pairs).

Lists

A list is an ordered collection, indexed starting at 0:

signals = ["speed", "rpm", "fuel"]
print(signals[0])   # "speed"
print(signals[-1])  # "fuel" (last item)
signals.append("temp")  # adds to end

Dictionaries

A dictionary maps keys to values. This is THE most important structure for APIs and AI tools, because JSON (the universal data format) is essentially nested dictionaries.

warning = {"level": "critical", "system": "brakes"}
print(warning["level"])  # "critical"

Nested Structures

Real data is almost always nested—dicts containing lists containing dicts:

vehicles = [
    {"name": "eCanter", "type": "EV", "range_km": 200},
    {"name": "Super Great", "type": "diesel", "range_km": 800}
]
print(vehicles[0]["name"])  # "eCanter"
List [0] = "a" [1] = "b" [2] = "c" Ordered by position Dictionary "name" → "Alice" "age" → 30 Accessed by key name Nested "tasks" → [   {id:1, ...},   {id:2, ...} ] This is real-world data
Lists hold ordered items. Dicts hold named items. Real data is usually nested dicts containing lists of dicts.

Choosing the Right Structure

Choosing the right data structure by use case
NeedUseExample
Ordered sequenceListA queue of tasks
Lookup by nameDictionaryConfig settings
Unique items onlySetTags without duplicates
Unchangeable sequenceTupleDatabase row coordinates

TaskForge Connection

TaskForge stores tasks as a list of dictionaries: tasks = [{"id": 1, "description": "...", "status": "pending"}, ...]. This is the same pattern you'll see in every API response and every JSON file.

Micro-Exercises

1: List Access

Create a list of 5 numbers. Print the third one (index 2).

nums = [10, 20, 30, 40, 50]
print(nums[2])  # 30
2: Dict Access
person = {"name": "Alice", "age": 30, "city": "Tokyo"}
print(person["city"])  # Tokyo
Try This Now

Create a dict representing a project with: name (string), features (list of 3 strings), tech_stack (dict with 'language', 'framework', 'database'), status (string). Print project['tech_stack']['framework'].

project.pyproject = { "name": "TaskForge", "features": ["add tasks", "complete tasks", "list tasks"], "tech_stack": {"language": "Python", "framework": "Flask", "database": "JSON"}, "status": "in progress" } print(project["tech_stack"]["framework"]) # Flask

Verification: The nested access prints the framework value.

If this doesn't work: (1) KeyError → key names are case-sensitive. (2) TypeError: string indices → you defined tech_stack as a string, not a dict.

You just used JSON—the same data format that APIs and AI tools use to exchange information. When Claude Code sends your prompt to the Anthropic API, it goes as JSON that looks exactly like what you just wrote.

List Comprehensions

A list comprehension builds a new list in a single line. It's a compact alternative to a for loop that appends to a list. AI-generated Python uses comprehensions heavily, so you need to read them fluently.

Basic Syntax

[expression for item in iterable]

This reads as: "For each item in the iterable, evaluate expression and collect the results into a list."

With a Condition

[expression for item in iterable if condition]

This adds a filter: only items where condition is true get included.

Before and After

Here's the same logic written as a loop and as a comprehension:

# Loop version
squares = []
for n in range(5):
    squares.append(n ** 2)

# Comprehension version
squares = [n ** 2 for n in range(5)]
# Both produce: [0, 1, 4, 9, 16]
# Loop with filter
evens = []
for n in range(10):
    if n % 2 == 0:
        evens.append(n)

# Comprehension with filter
evens = [n for n in range(10) if n % 2 == 0]
# Both produce: [0, 2, 4, 6, 8]
Why This Matters for AI Code Review

AI-generated code uses comprehensions constantly. If you can't read them, you can't review AI output. When you see [task for task in tasks if task["status"] == "pending"], you need to instantly recognize: "filter the task list to only pending tasks."

Micro-Exercise: Comprehension Conversion

Part A: Rewrite this loop as a comprehension:

names = ["alice", "bob", "charlie"]
upper = []
for name in names:
    upper.append(name.upper())
# Answer
upper = [name.upper() for name in names]

Part B: Rewrite this comprehension as a loop:

short = [w for w in ["hi", "hello", "hey", "greetings"] if len(w) <= 3]
# Answer
short = []
for w in ["hi", "hello", "hey", "greetings"]:
    if len(w) <= 3:
        short.append(w)
# Both produce: ["hi", "hey"]

Interactive Exercises

Design Challenge: "Group By"

Write group_by(items, key_fn) that groups a list by the result of calling key_fn on each item. Return a dict mapping keys to lists.

Start with an empty dict. For each item, compute the key.

Use dict.setdefault(key, []).append(item) to add items.

Or use result[key] = result.get(key, []) + [item]

Design Challenge: "Flatten"

Write flatten(nested) that flattens an arbitrarily nested list into a single flat list.

Loop through each element. If an element is itself a list, you need to flatten that list too. One approach: call your own function inside itself (this is called recursion—a function calling itself with a smaller input).

Check with isinstance(item, list) to see if something is a list or a regular value.

Pattern: create a result = []. For each item, if it's a list, extend result with flatten(item). Otherwise, append the item directly.

Knowledge Check

What happens when you access a key that doesn't exist with my_dict['missing']?

Big O — How Fast Is This Code?

Big O notation describes how an operation's time grows as data grows. You don't need to derive it mathematically — you need to recognize it when reading AI-generated code:

Common Big O complexities and what they mean in practice
NotationNameExampleFeel
O(1)ConstantDict lookup by keyInstant, no matter the size
O(log n)LogarithmicBinary searchDoubles data? One extra step
O(n)LinearLoop through a list10x data = 10x time
O(n log n)LinearithmicSorting (good algorithms)Slightly worse than linear
O(n²)QuadraticNested loops10x data = 100x time
O(2ⁿ)ExponentialBrute force all subsetsUnusable past ~30 items

The practical rule: When AI generates a nested loop over a list, ask: "Is this O(n²)? Could it be O(n) with a dict?" That single question catches the most common performance mistake in AI-generated code.

Going Deeper: Data Structures Under the Hood

This chapter taught you to use lists, dicts, and sets. Phase 5 teaches you how they work internally—hash tables, linked lists, trees, and graphs. Understanding the internals matters because AI-generated code sometimes picks the wrong data structure, and you need the vocabulary to catch it.

Algorithms: The Recipes Behind the Code

Data structures are how you store data. Algorithms are how you process it. When AI generates a sort, a search, or a pathfinding solution, it’s applying an algorithm. Phase 6 teaches you the essential ones so you can evaluate whether the AI picked the right approach.

Practice: Data Structure Problems

Want more practice? Work through the Arrays & Hashing section on NeetCode. Start with "Two Sum" and "Group Anagrams" — both use dicts to solve problems that would be slow with lists alone.

Solve & Compare: Flatten Nested Lists

Step 1: Solve this problem yourself. Step 2: Click “Hint” three times to see how an AI solved it. Step 3: Compare and answer the evaluation questions.

You need to check each item: is it a list or a plain value? Use isinstance(item, list).

If an item is a list, you need to flatten it too — this is a recursive problem. Call flatten() on sub-lists.

AI’s Solution:
def flatten(lst):
    result = []
    for item in lst:
        if isinstance(item, list):
            result.extend(flatten(item))
        else:
            result.append(item)
    return result


Evaluation: The AI used recursion with isinstance — did you? If you used a loop with a stack, that’s also valid and avoids deep recursion limits. Notice: the AI handles [[]] correctly (empty inner list). In TaskForge, nested structures appear when tasks have sub-tasks — flattening is a real operation you’ll need.

Chapter 07 Classes and Object-Oriented Basics

Why This Matters Now

AI-generated Python code frequently uses classes. When Claude writes class Task: with self.tasks, you need to understand what you're reading.

So far you've stored data in dictionaries and processed it with functions. That works—until your data and the functions that operate on it start drifting apart across your codebase. Classes solve this by bundling data and behavior into a single unit called an object.

What Is a Class?

A class is a blueprint for creating objects. Think of it like a cookie cutter: the class defines the shape, and each cookie you stamp out is an instance—an individual object created from that blueprint. The cookie cutter itself isn't a cookie; it's the template that produces cookies. Every cookie has the same shape, but each one can have different frosting (different data).

In programming terms: a class defines what attributes (data) and methods (behavior) every instance will have. Each instance gets its own copy of the data, but they all share the same methods.

Defining a Class

Here's the simplest useful class:

class Task:
    def __init__(self, title):
        self.title = title
        self.done = False

Let's break this down piece by piece:

  • class Task: — declares a new class named Task. Class names use CamelCase by convention (not snake_case like functions).
  • __init__ — the initializer (sometimes called the constructor). Python calls this automatically when you create a new instance. The double underscores (called "dunder") mark it as a special method.
  • self — a reference to the specific instance being created. When you write self.title = title, you're saying "this particular object's title is whatever was passed in." Every method in a class receives self as its first parameter.
  • self.title and self.done — these are instance attributes. Each Task object gets its own title and done.

Methods vs Functions

A method is a function that belongs to a class. The only syntactic difference: methods take self as their first parameter, which gives them access to the instance's data. A regular function stands alone; a method is attached to an object and operates on that object's data.

# Regular function — stands alone
def mark_done(task_dict):
    task_dict["done"] = True

# Method — belongs to the class, operates on self
class Task:
    def __init__(self, title):
        self.title = title
        self.done = False

    def mark_done(self):
        self.done = True

When you call task.mark_done(), Python automatically passes the instance as self. You don't write task.mark_done(task)—Python handles that.

Creating Instances

You create an instance by calling the class like a function:

task = Task("Buy groceries")
print(task.title)  # Buy groceries
print(task.done)   # False

Notice you pass "Buy groceries" but not self. Python fills in self automatically. Each call to Task(...) creates a brand-new, independent object.

Instance Attributes

Every instance carries its own data. Changing one object doesn't affect another:

task1 = Task("Buy groceries")
task2 = Task("Write report")

task1.mark_done()
print(task1.done)  # True
print(task2.done)  # False — completely independent

You access attributes with dot notation: task.title, task.done. This is the same syntax you've been using with strings ("hello".upper()) and lists (my_list.append(x))—because strings and lists are objects too.

A Complete Example

Here's a Task class with an initializer, a behavior method, and a string representation:

class Task:
    def __init__(self, title):
        self.title = title
        self.done = False

    def mark_done(self):
        self.done = True

    def __str__(self):
        status = "done" if self.done else "pending"
        return f"[{status}] {self.title}"

# Usage
task = Task("Buy groceries")
print(task)          # [pending] Buy groceries
task.mark_done()
print(task)          # [done] Buy groceries

__str__ is another dunder method. Python calls it automatically when you print() an object or convert it to a string. Without it, printing a Task would show something unhelpful like <__main__.Task object at 0x...>.

Task ATTRIBUTES title : str done : bool id : int METHODS __init__(self, title) mark_done(self) __str__(self) Data Behavior
A class is a box with three compartments: name at top, data (attributes) in the middle, behavior (methods) at the bottom. This is the UML class diagram pattern.

Inheritance Basics

Sometimes you need a class that's almost like an existing class, but with a few extras. Inheritance lets you create a new class that builds on an existing one. The new class (called the child or subclass) inherits all attributes and methods from the existing class (the parent or superclass) and can add or override them.

Here's a PriorityTask that extends Task with a priority field:

class PriorityTask(Task):
    def __init__(self, title, priority="medium"):
        super().__init__(title)
        self.priority = priority

    def __str__(self):
        status = "done" if self.done else "pending"
        return f"[{status}] ({self.priority}) {self.title}"

The parentheses in class PriorityTask(Task) mean "PriorityTask inherits from Task." The super().__init__(title) call runs the parent's __init__, so the child gets self.title and self.done without duplicating that code. Then the child adds its own self.priority.

PriorityTask gets mark_done() for free—it inherits it from Task. But it overrides __str__ with its own version that includes the priority. This is the core idea: reuse what works, change only what's different.

task = PriorityTask("Fix login bug", "high")
print(task)          # [pending] (high) Fix login bug
task.mark_done()
print(task)          # [done] (high) Fix login bug
print(task.done)     # True — inherited from Task

When to Use Classes vs Dictionaries

Classes and dictionaries both store data. The question is: does your data need behavior?

When to use a dictionary versus a class
ScenarioUse a DictionaryUse a Class
Simple data with known keys{"name": "Alice", "age": 30}Overkill
Data from/to JSONNatural fit—dicts map directly to JSONAdd to_dict() if needed
Data + behavior togetherFunctions scattered across the filetask.mark_done() keeps it together
Multiple related typesNo inheritance mechanismPriorityTask(Task)
Quick prototypingFastest to writeMore setup, more structure

Rule of thumb: start with dictionaries. When you find yourself writing multiple functions that all take the same dictionary as their first argument, that's a sign the data and behavior want to live together in a class.

Common Misconceptions

Misconception: "Classes Are Always Better Than Dictionaries"

Not true. Dictionaries are simpler, faster to write, and map directly to JSON. If your data is just key-value pairs with no behavior, a dict is the right tool. Classes add value when you need methods that operate on the data, validation on creation, or inheritance for related types. Use the right tool for the job.

Misconception: "self Is a Keyword"

self is not a keyword—it's a convention. You could technically name it this or potato and Python wouldn't complain. But every Python programmer uses self, every AI generates self, and every linter expects self. Treat it as mandatory even though it's technically optional.

TaskForge Connection

TaskForge v0.1 stores tasks as dictionaries in a list. This works, but notice how functions like complete_task and add_task all operate on the same dictionary structure. That's a signal that a class might be a better fit. Here's the before and after, side by side:

Before — Dictionary
# TaskForge v0.1 — dictionary-based
task = {
    "id": 1,
    "title": "Buy groceries",
    "status": "pending"
}

def complete_task(tasks, task_id):
    for t in tasks:
        if t["id"] == task_id:
            t["status"] = "done"
            return True
    return False
After — Class
# TaskForge refactored — class-based
class Task:
    def __init__(self, task_id, title):
        self.id = task_id
        self.title = title
        self.status = "pending"

    def complete(self):
        self.status = "done"

    def to_dict(self):
        return {
            "id": self.id,
            "title": self.title,
            "status": self.status
        }

# Usage
task = Task(1, "Buy groceries")
task.complete()
print(task.to_dict())
# {"id": 1, "title": "Buy groceries", "status": "done"}

The class version keeps data and behavior together. task.complete() is clearer than complete_task(tasks, task_id) because the task itself knows how to mark itself done. The to_dict() method means JSON serialization still works—you get the best of both worlds.

Micro-Exercises

1: Define a Dog Class

Define a Dog class with name and breed attributes and a bark() method that returns a string.

class Dog:
    def __init__(self, name, breed):
        self.name = name
        self.breed = breed

    def bark(self):
        return f"{self.name} says Woof!"

dog = Dog("Rex", "Labrador")
print(dog.bark())   # Rex says Woof!
print(dog.breed)    # Labrador
2: Create a BankAccount Class

Create a BankAccount class with deposit() and withdraw() methods that track balance.

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance

    def deposit(self, amount):
        self.balance += amount
        return self.balance

    def withdraw(self, amount):
        if amount > self.balance:
            return "Insufficient funds"
        self.balance -= amount
        return self.balance

account = BankAccount("Alice", 100)
print(account.deposit(50))    # 150
print(account.withdraw(30))   # 120
print(account.withdraw(200))  # Insufficient funds
Try This Now

Refactor TaskForge's task dictionary to a Task class. Add a to_dict() method so JSON serialization still works.

task_class.pyimport json class Task: def __init__(self, task_id, title): self.id = task_id self.title = title self.status = "pending" def complete(self): self.status = "done" def to_dict(self): return { "id": self.id, "title": self.title, "status": self.status } def __str__(self): return f"[{self.status}] {self.title}" # Test it task = Task(1, "Buy groceries") print(task) # [pending] Buy groceries task.complete() print(task) # [done] Buy groceries print(json.dumps(task.to_dict(), indent=2)) # {"id": 1, "title": "Buy groceries", "status": "done"}

Verification: Your Task class creates objects, and to_dict() returns a dictionary matching the original format.

If this doesn't work: If self errors appear, make sure every method's first parameter is self. (1) TypeError: __init__() takes 2 positional arguments → you forgot self as the first parameter. (2) AttributeError: 'Task' has no attribute 'title' → make sure __init__ sets self.title = title. (3) to_dict() returns None → you forgot the return statement.

You just turned a loose collection of dictionaries and functions into a cohesive class. This is the same pattern Claude Code uses when it generates model classes, API handlers, and data structures. Now you can read that code and know exactly what's happening.

Interactive Exercises

Design Challenge: "Bank Account"

Create a BankAccount class with deposit(amount), withdraw(amount), and get_balance() methods. Withdrawals should raise ValueError if they would make the balance negative. Initial balance is 0.

Store balance in self.balance in __init__.

In withdraw, check if amount > self.balance before subtracting.

raise ValueError('Insufficient funds') when overdrawing.

Knowledge Check

What does self refer to in a Python method?

Design Challenge: "Counter Class"

Create a WordCounter class. Constructor takes a string. Methods: count(word) returns how many times that word appears (case-insensitive), most_common(n) returns the n most frequent words as a list of (word, count) tuples.

In __init__, split text into words and build a frequency dict.

Use .lower() for case insensitivity on both storage and lookup.

For most_common, sort the items by count (descending) and slice.

Decorators: Functions That Modify Functions

A decorator is a function that takes a function and returns a modified version. The @decorator syntax is shorthand:

# This:
@app.route("/tasks")
def get_tasks():
    pass

# Is equivalent to:
def get_tasks():
    pass
get_tasks = app.route("/tasks")(get_tasks)

You'll see decorators constantly in later chapters — @app.route() in Flask, @staticmethod in classes, @pytest.fixture in tests. AI generates decorated functions constantly. Here's how to build one:

import time

def timer(func):
    """Decorator that prints how long a function takes."""
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        elapsed = time.time() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

@timer
def slow_function():
    time.sleep(0.1)
    return "done"

slow_function()  # Prints: slow_function took 0.1001s

Production decorators should also use @functools.wraps(func) to preserve the original function's name and docstring. We skip that for simplicity here.

Exercise: Build a Logging Decorator

Implement a @log_calls decorator that prints the function name and arguments each time it's called.

Inside wrapper: print something like f"Calling {func.__name__} with args={args}, kwargs={kwargs}".

Then call result = func(*args, **kwargs) and return result.

wrapper body: print(f"Calling {func.__name__}({args}, {kwargs})"); result = func(*args, **kwargs); return result

Solve & Compare: Bank Account Class

Step 1: Solve this problem yourself. Step 2: Click “Hint” three times to see how an AI solved it. Step 3: Compare the two approaches.

Start with __init__: store self.owner and self.balance. Check amount > 0 in deposit and withdraw.

In withdraw, check if amount > self.balance: raise ValueError("Insufficient funds").

AI’s Solution:
from datetime import datetime

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance
        self.transactions = []

    def deposit(self, amount):
        self.balance += amount
        self.transactions.append({"type": "deposit", "amount": amount, "time": str(datetime.now())})
        return self.balance

    def withdraw(self, amount):
        if amount > self.balance:
            raise ValueError("Insufficient funds")
        self.balance -= amount
        self.transactions.append({"type": "withdraw", "amount": amount, "time": str(datetime.now())})
        return self.balance

    def get_balance(self):
        return self.balance

    def __repr__(self):
        return f"BankAccount({self.owner}, balance={self.balance})"


Evaluation: The AI added features you didn’t ask for — transaction history, timestamps, and a __repr__ method. This is over-engineering, one of the most common AI failure modes. Your simpler version is better for this requirement. In TaskForge, resist the urge to add features “just in case” — build what the spec asks for.

Chapter 08 Files, Modules, and the Standard Library

Why This Matters Now

Real projects have dozens of files. When Claude Code edits src/models.py, it needs to know how that file connects to src/api.py and tests/test_models.py. Modules are how code is organized across files. Without this, you can't navigate a real codebase.

So far, your data disappears when the program ends. Files make data persist. Modules let you organize code across multiple files. The standard library gives you hundreds of pre-built tools.

Reading and Writing Files

# Write
with open("data.txt", "w") as f:
    f.write("Hello, file!")

# Read
with open("data.txt", "r") as f:
    content = f.read()
    print(content)  # Hello, file!

The with statement (a context manager) automatically closes the file when you're done. Always use it.

JSON: The Universal Data Format

JSON (JavaScript Object Notation) is how AI tools and APIs exchange data. It looks almost identical to Python dictionaries:

import json

# Dict to JSON string
data = {"name": "TaskForge", "version": "0.1"}
json_string = json.dumps(data, indent=2)

# JSON string to dict
parsed = json.loads(json_string)

# Dict to JSON file
with open("config.json", "w") as f:
    json.dump(data, f, indent=2)

# JSON file to dict
with open("config.json", "r") as f:
    loaded = json.load(f)
.txt name: Alice age: 30 Unstructured .csv name,age Alice,30 Tabular .json {"name": "Alice",  "age": 30} AI & API standard .py dict {"name": "Alice",  "age": 30} Python native
Different formats store the same data differently. JSON is the format AI tools and APIs use. Learn it well.

Modules and Imports

A module is a .py file containing functions—think of it like a toolbox. Instead of carrying every tool everywhere, you import just the toolbox you need for the current job:

import json          # entire module
from pathlib import Path  # one thing from a module

The if __name__ == "__main__": pattern (which you saw in TaskForge) means "only run this block when the file is executed directly, not when it's imported."

TaskForge Connection

In Phase 2's gate exercise, you'll add save_tasks(filepath) and load_tasks(filepath) to TaskForge using json.dump and json.load. Tasks will survive between sessions.

Micro-Exercises

1: Write and Read a File
with open("hello.txt", "w") as f:
    f.write("Hello!")

with open("hello.txt", "r") as f:
    print(f.read())  # Hello!
2: Convert Dict to JSON
import json
data = {"name": "test", "count": 5}
print(json.dumps(data, indent=2))
Try This Now

Create config.json with: {"project": "my-app", "version": "0.1", "features": ["auth", "dashboard"]}. Write a Python script that reads this file, adds "api" to the features list, and writes it back.

update_config.pyimport json with open("config.json", "r") as f: config = json.load(f) config["features"].append("api") with open("config.json", "w") as f: json.dump(config, f, indent=2)

Verification: After running, config.json contains ["auth", "dashboard", "api"].

If this doesn't work: (1) FileNotFoundError → create the JSON file first. (2) JSONDecodeError → check for trailing commas or missing quotes. (3) File unchanged → you forgot to write back with json.dump().

You just read, modified, and wrote a JSON file—the exact workflow that TaskForge uses for persistence and that every API uses for data exchange.

Interactive Exercises

Design Challenge: "CSV Parser"

Write parse_csv(text) that takes a CSV string (first row is headers) and returns a list of dicts.

Split by newlines to get rows, then split each row by commas.

First row gives you the headers (keys). Remaining rows are data.

Use zip(headers, values) then dict() to create each row dict.

Knowledge Check

What does json.loads(text) do?

Solve & Compare: CSV Report Generator

Step 1: Solve this problem yourself. Step 2: Click “Hint” three times to see the AI’s version. Step 3: Evaluate the differences.

Collect all unique keys across all dicts for headers: headers = list(data[0].keys()) works if all dicts are the same.

For each row dict, use str(row.get(h, "")) to handle missing keys. Join with commas.

AI’s Solution:
def generate_report(data):
    if not data:
        return ""
    headers = list(data[0].keys())
    lines = [",".join(headers)]
    for row in data:
        lines.append(",".join(str(row[h]) for h in headers))
    return "\n".join(lines)


Evaluation: The AI hardcoded headers from only the first dict’s keys. If later dicts have extra keys (like "c"), those columns are silently dropped — and row[h] will crash with KeyError if a row is missing a key from the first dict. Did your version handle mixed keys? In TaskForge, CSV exports of tasks with optional fields hit exactly this bug. Note: the AI’s solution would actually fail the mixed-keys test above. That’s the point — always run the tests.

Chapter 09 Error Handling, Debugging, and Testing

Why This Matters Now

AI-generated code often looks correct but has subtle bugs. Without tests, you have no way to prove it works. Without error handling, one bad input crashes your program. This chapter gives you the tools to verify code—yours or the AI's.

Code breaks. That's normal. What separates professionals from beginners is how they handle and prevent breakage. This chapter covers three tools: try/except (handling errors gracefully), debugging (finding what went wrong), and testing (proving your code is correct).

Try/Except

try:
    task_id = int(input("Task ID: "))
except ValueError:
    print("Please enter a number.")

Without try/except, invalid input crashes your program. With it, you handle the error gracefully. Always catch specific exceptions—never write bare except:.

Debugging

When something goes wrong, read the stack trace from the bottom up. The last line tells you the error type and message. The lines above show you where it happened.

Testing with Assert

An assertion is a statement that must be true. If it's false, the program crashes—which is exactly what you want in a test.

assert 2 + 2 == 4      # passes silently
assert 2 + 2 == 5      # AssertionError!
Write Tests YOU define correct Describe to AI What you want AI Generates Code output Run Tests Pass = evidence
Write tests BEFORE asking the AI. Tests define the contract. The AI implements against it.
Test-Driven AI Development

Write tests before asking the AI to write code. This is the highest-leverage AI coding workflow. Your tests define "correct." The AI writes code that tries to pass them. If it fails, iterate.

What Happens Without Tests

You ask an AI to write calculate_tax(income). It returns a function that looks correct. You deploy it. A month later, you discover it's been calculating 15% instead of the correct bracket-based rate—and every invoice was wrong. Tests would have caught this in seconds. Without tests, you're trusting the AI's output on faith. With tests, you have evidence.

pytest

Put tests in files named test_*.py. Run them: python3 -m pytest. pytest discovers and runs all test functions.

# test_math.py
def test_addition():
    assert 2 + 2 == 4

def test_string_concat():
    assert "hello " + "world" == "hello world"

TaskForge Connection

TaskForge v0.1 has no error handling—int(input("Task ID: ")) crashes on non-numeric input. In the Phase 2 Gate, you'll fix this and write tests proving it works.

Micro-Exercises

1: Silent Success

Run assert 2 + 2 == 4. Nothing happens—assertions that pass are silent. Now run assert 2 + 2 == 5. You get AssertionError.

2: Graceful Error
try:
    print(1 / 0)
except ZeroDivisionError:
    print("Cannot divide by zero")

The program handles the error instead of crashing.

Try This Now

Write 3 assert statements testing a function called is_valid_email(email)—one valid email, one invalid (no @), one edge case (empty string). Don't write the function. Go to claude.ai or chatgpt.com (free) and ask: "Write a Python function is_valid_email that passes these tests: [paste your asserts]." Run the tests.

test_email.pyassert is_valid_email("user@example.com") == True assert is_valid_email("invalid-email") == False assert is_valid_email("") == False

Verification: The AI's function passes all 3 assertions.

Note: This is your first interaction with an LLM for code. You haven't formally learned about AI coding tools (that starts in Phase 5). This is a preview.

If this doesn't work: (1) NameError: is_valid_email → paste the AI's function definition ABOVE your assert statements. (2) Assertion fails → the AI's implementation has a bug. Read the function and figure out why. This is exactly the skill Phase 5 teaches.

You just used AI to write code, then verified it with tests. This is the exact workflow you'll use in Phase 5—except there, you'll do it with Claude Code on real features.

Interactive Exercises

Debug Challenge: "Fix the Test Suite"

This function and its tests have 4 bugs — 2 in the function and 2 in the tests. Fix all of them so all assertions pass.

What should find_max([]) return — 0 or None? Think about what makes sense.

If all numbers are negative, starting max_val = 0 will never get replaced. Use numbers[0] instead.

The test for empty list expects 0, but should it? And the function needs to handle the empty case before using numbers[0].

Evaluate AI-Generated Code

An AI was asked: "Write a function to calculate the average of a list of numbers." Below is what it generated. It has 3 problems: a crash on empty input, an incorrect return type, and an unnecessary import. Find and fix all three.

What happens when numbers is empty? len(numbers) is 0, and dividing by 0 crashes.

The function returns str(...) but the tests expect a float. Remove the str() wrapper.

math.fsum works but sum() is simpler and doesn't require an import. The AI over-engineered this.

Design Challenge: "Retry Decorator"

Write a function retry(fn, max_attempts=3) that calls fn(). If it raises an exception, retry up to max_attempts times. Return the result on success, or re-raise the last exception if all attempts fail.

Use a for loop: for attempt in range(max_attempts)

Wrap fn() in try/except inside the loop. On success, return immediately.

After the loop, re-raise: raise inside the except block on the last attempt.

Solve & Compare: Retry Decorator

Step 1: Solve this problem yourself. Step 2: Click “Hint” three times to see the AI’s version. Step 3: Look for a subtle but real bug in the AI’s code.

retry(max_attempts) returns a decorator. That decorator takes a function and returns a wrapper. Three levels of nesting.

The wrapper loops range(max_attempts), calls func() in a try/except. On success, return. On the last failure, re-raise.

AI’s Solution:
def retry(max_attempts=3):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for attempt in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except:
                    if attempt == max_attempts - 1:
                        raise
        return wrapper
    return decorator


Evaluation: The AI used bare except: instead of except Exception:. This catches KeyboardInterrupt and SystemExit, meaning Ctrl+C won’t stop your program during retries. This is the kind of subtle bug that only someone who understands error handling can catch. In TaskForge, a retry decorator with bare except could make your CLI tool impossible to kill.

Phase 2 Gate Checkpoint & TaskForge Extension

Minimum Competency

Write a function with parameters, conditionals, loops, and a return value. Create/manipulate nested dicts. Read/write JSON. Write meaningful test assertions.

Your Artifact

Extended TaskForge with:

  • filter_tasks(status) — returns only tasks matching the status
  • save_tasks(filepath) / load_tasks(filepath) — JSON persistence
  • 5 passing test assertions (add, complete, filter, save, load)

Verification

python3 taskforge.py runs. python3 -m pytest test_taskforge.py passes 5 assertions.

Failure Signal

If you cannot write a function that takes a list and returns filtered results → return to Chapters 05–06.

Hints — Try Without These First

If you're stuck on the gate exercise, follow these steps. Try each one on your own before reading the hint.

Step 1: filter_tasks(tasks, status)

Write a function that accepts the task list and a status string, then returns only the tasks that match. Think list comprehensions: [task for task in tasks if ...].

def filter_tasks(tasks, status):
    # Return a list of tasks where task["status"] == status
    pass  # replace with one line

Step 2: save_tasks(tasks, filepath)

Write a function that writes the task list to a JSON file. You need import json and json.dump(). Remember the with open(filepath, "w") pattern from Chapter 08.

def save_tasks(tasks, filepath):
    # Open filepath for writing, then json.dump(tasks, f, indent=2)
    pass

Step 3: load_tasks(filepath)

Write a function that reads tasks from a JSON file. Use json.load(). Handle the case where the file doesn't exist yet—return an empty list.

def load_tasks(filepath):
    # Try to open and json.load(). If FileNotFoundError, return []
    pass

Step 4: Wire into the main loop

At program start, call load_tasks to populate the task list. After any change (add/complete), call save_tasks. Add a menu option for filtering by status.

TaskForge Checkpoint

TaskForge now has filtering, persistence (JSON), and tests. Still a single directory with no git, no structure. Phase 3 fixes that.

What You Can Now Do

  • Write functions with parameters, conditionals, loops, and return values
  • Create and navigate nested data structures (lists of dicts)
  • Read, write, and manipulate JSON files
  • Handle errors gracefully and write meaningful test assertions
  • Use an AI to generate code and verify it with tests
Bridge to Phase 3

You can write Python. But right now your code lives in a single folder with no safety net. What happens when you make a bad edit and can't undo it? What happens when you need to share code with a team or an AI agent? Phase 3 introduces the professional development tools—terminal fluency, Git fundamentals, and remote collaboration with GitHub—that make your code manageable and set the stage for real project workflows.