The Shape of Python
Variables, strings, lists, tuples — the building blocks Claude uses in every script
Names & Values
When you ask Claude to store a username or count, this is exactly what it writes. Variables are the simplest idea in programming — and the most powerful.
Variables are Luggage Tags
Variables don't contain data — they point to data. Like a luggage tag: the tag isn't the suitcase, it's a label attached to one.
user_name = "Alice"
score = 42
is_active = True
last_login = None
Create a label "user_name" pointing to the text Alice
Label "score" pointing to the number 42
A yes/no flag — True means yes, this user is active
Intentionally empty — this user has never logged in. None is not zero, it means "no value yet"
Glossary: variable · snake_case · None
Python uses snake_case (underscores between words) for variable names. JavaScript uses camelCase. When you tell Claude to store a user name, it automatically writes user_name in Python.
Strings Are More Than Text
Strings Have Superpowers
A string is any text in Python. What makes it powerful is the dozens of methods built right in — verbs you can call on any text.
The f in f-string stands for formatted. The {} curly braces are windows into your code — whatever is inside gets substituted with its value. Claude uses f-strings constantly.
Glossary: string · method · f-string
Lists vs Tuples
Lists vs Tuples — Change vs Permanence
Both hold sequences of items. The difference is mutability — whether the contents can change after creation.
Common mistake: trying to modify a tuple. Python will throw a TypeError. This is a feature — tuples protect data that should never change after creation.
Glossary: list · tuple · mutable · immutable
Reading Lists
Indexing and Slicing
Once you have a list, you need to read specific items. Python gives you powerful tools for this — including counting from the back.
tasks = ["buy milk", "write report", "call Alice", "review PR"]
first = tasks[0] # "buy milk"
last = tasks[-1] # "review PR"
two = tasks[1:3] # ["write report", "call Alice"]
rev = tasks[::-1] # reversed list
Index 0 is the first item. Python counts from 0, not 1
Negative index: count from the back. -1 is always the last item
Slice from index 1 up to (not including) index 3
Slice with step -1: walk backward through the whole list — reverses it
Python counts from 0. The first item is at position 0. After a few days this feels natural — and you start finding 1-based indexing strange.
Glossary: index · slice · negative index
List Comprehensions
Comprehensions — Lists Built in One Line
Comprehensions are Python's compact way to build a new list from an existing one — with optional filtering and transformation. Claude uses them constantly.
numbers = [1, 2, 3, 4, 5, 6, 7, 8]
# Long way
result = []
for n in numbers:
if n % 2 == 0:
result.append(n * n)
# Comprehension — same result
result = [n * n for n in numbers if n % 2 == 0]
Long way: make empty list, loop, check if even, square it, add it
Short way: [expression for item in source if condition]
Both produce [4, 16, 36, 64]
Claude writes the short form — now you can read it
When you ask Claude to filter a list and transform each item, it almost always writes a comprehension. [x * 2 for x in items if x > 0] means: go through items, keep the positive ones, double each one.
Check Your Understanding
You have a list of filenames. Which expression gives only the .py files?
Your code has if cart_items: to check if the cart has items. A colleague says use len(cart_items) > 0. Who is right?
Match each Python type to its property:
Mutable (changeable after creation)
Immutable, ordered, used for fixed records like coordinates
Immutable sequence of characters
Making Decisions
Conditionals, truthiness, dictionaries — the logic layer behind every Claude-generated program
Truthiness — Python's Yes and No
Python evaluates more values than just True and False. Any non-empty, non-zero value is truthy. Any empty or zero value is falsy. This is how Claude checks if a list has items, if a name was provided, if a user is logged in.
Truthy Values
Non-zero numbers, non-empty strings, non-empty lists — Python treats these as True in conditions
Falsy Values
0, 0.0, empty string, empty list, empty dict, None, False — Python treats these as False
Value Equality
== checks if two values are equal: 'admin' == 'admin' is True. Use for comparing data.
Identity Check
is checks if two variables point to the same object. Only use is for None: if user is None:
users = []
name = ""
count = 0
user = None
if users: # empty list → False
send_newsletter()
if not name: # empty string → False, not flips it
ask_for_name()
if count == 0:
show_empty_state()
if user is None:
redirect_to_login()
if users: → "if users has anything in it" — no .length check needed
if not name: → "if name is empty or blank" — not flips falsy to True
if count == 0: → explicit comparison for clarity with numbers
if user is None: → always use is (not ==) to check for None
If/Elif/Else — First Match Wins
Python checks each condition in order. When it finds a True one, it runs that block and skips everything else.
The ternary expression is value_if_true if condition else value_if_false. So label = 'pass' if score >= 60 else 'fail' on one line. Claude uses this often for simple assignments.
Match/Case — Clean Routing
Python 3.10 added match/case — a cleaner way to route a single value to different code paths. You will see this in Claude-generated CLI tools and API handlers.
def handle_command(cmd: str) -> str:
match cmd:
case "create":
return create_item()
case "update":
return update_item()
case "delete":
return delete_item()
case _:
return f"Unknown: {cmd}"
match cmd: → look at cmd and find the matching case
case "create": → if cmd is exactly "create", run this block
case _: → wildcard: runs when nothing else matched (like else)
First matching case wins — no fallthrough
Dictionaries — Python Named Storage
A dictionary maps keys to values. It is the data structure Claude uses to represent almost everything structured — users, configs, API responses, form data.
When Claude parses an API response, loads a config file, or processes form input — it works with dicts. JSON data from web APIs becomes a Python dict the moment you load it. Understanding dicts means understanding how Python sees the outside world.
user = {
"name": "Alice",
"role": "admin",
"score": 0
}
name = user["name"] # "Alice"
role = user.get("role", "viewer") # safe lookup
user["score"] = 10 # update
user["email"] = "a@b.com" # add new key
del user["score"] # remove
user["name"] → Look up key "name" — returns "Alice". Crashes with KeyError if key missing
user.get("role", "viewer") → Safe: if "role" missing, return "viewer" as default — never crashes
user["score"] = 10 → Set or update the value at "score"
user["email"] = "a@b.com" → Dicts grow dynamically — add new keys anytime
del user["score"] → Remove the key and its value entirely
.get()
Nested Dicts and Comprehensions
Real-world data is rarely flat. APIs return nested dicts. Dict comprehensions let you transform key-value pairs compactly.
{"user": {"name": "Alice", "address": {"city": "NYC"}}}
data["user"] → the inner user dict
data["user"]["name"] → "Alice"
data["user"]["address"]["city"] → "NYC"
data.get("user", {}).get("city", "unknown")
scores = {"Alice": 92, "Bob": 45, "Carol": 88, "Dave": 30}
passing = {
name: score
for name, score in scores.items()
if score >= 60
}
# Result: {"Alice": 92, "Carol": 88}
scores.items() → Loop through both keys AND values — gives pairs like ("Alice", 92)
for name, score in ... → Unpack each pair into two variables
if score >= 60 → Only include this pair if condition is true
Pattern: {key: value for k, v in d.items() if condition}
Just use {} and include key: value. When Claude filters a dict or transforms all values, this is the pattern.
.items()
Check Your Understanding
You want to check if an optional "discount" field exists in a user dict. If missing, treat it as 0. Which code is correct?
Trace this code — what does it print?
x = 5
if x > 10: print("big")
elif x > 3: print("medium")
elif x > 1: print("small")
else: print("tiny")
x = 5
if x > 10: print("big")
elif x > 3: print("medium")
elif x > 1: print("small")
else: print("tiny")You are processing commands: create, update, delete, status. Each needs different code. Which Python structure is cleanest?
Keeping Things Moving
Loops, functions, scope — the engine that runs every Python program
While Loops — Keep Going Until
A while loop repeats a block of code as long as a condition is True. Unlike a for loop (which counts through a collection), a while loop keeps going until told to stop.
Infinite loops happen when the condition never becomes False and there is no break. Python runs forever until you force-stop it. Always make sure your loop has an exit condition.
count = 0
while count < 5:
if count == 3:
count += 1
continue # skip printing 3
print(count)
count += 1
while count < 5: → Keep repeating while count is less than 5
if count == 3: → Special case: when count hits 3
count += 1 → Increment first (so count moves past 3)
continue → Skip rest of this iteration, jump back to the condition
print(count) → Normal path: print the number
count += 1 → Increment counter (Python has no ++ operator)
The Input Loop Pattern
The while True + break pattern is in almost every interactive script Claude writes. It keeps asking for input until the user decides to stop.
tasks = []
while True:
user_input = input("Command: ").strip()
if not user_input:
continue
if user_input == "quit":
break
tasks.append(user_input)
print(f"Added: {user_input}")
while True: → Run forever (intentional) — break controls exit
.strip() → Remove leading/trailing spaces from user input
if not user_input: continue → User pressed Enter without typing — skip and ask again
if user_input == "quit": break → Exit condition: user typed "quit"
tasks.append(user_input) → Add their input to our list
while True + break is Python's version of a do-while loop — something many other languages have built-in but Python does not. When you see this pattern in Claude code, it means: run at least once, stop when the condition inside says to.
input()
Functions — Packaging Work
A function wraps a block of code into a named unit. Define it once, call it anytime. This is how Claude reuses logic across your program.
A function with no return statement gives back None. print() shows text on screen — it does not return anything. return sends a value to the caller. These are different operations.
def calculate_discount(price: float, pct: float = 0.1) -> float:
"""Apply a discount to a price."""
return price * (1 - pct)
sale = calculate_discount(100) # uses default pct=0.1
promo = calculate_discount(100, 0.25) # pct=0.25
named = calculate_discount(price=200, pct=0.15)
def → Define a function. Everything indented below belongs to it
price: float → Type hint: this expects a float. Optional label, not enforced
pct: float = 0.1 → Default parameter: if not provided, use 0.1
-> float → Type hint: this function returns a float
return price * (1 - pct) → Compute result and send it back to the caller
Calling with price=200 → Keyword argument: named by parameter name
Args, Kwargs — Flexible Functions
*args collects unlimited extra positional arguments into a tuple. **kwargs collects unlimited keyword arguments into a dict. Claude uses these to build flexible functions.
def log_event(level: str, message: str, *tags, **metadata):
print(f"[{level}] {message}")
if tags:
print(f" Tags: {', '.join(tags)}")
if metadata:
print(f" Data: {metadata}")
log_event("INFO", "User signed in")
log_event("ERROR", "Payment failed", "billing", "stripe",
user_id=42, amount=99.99)
level: str, message: str → Two required positional parameters
*tags → Collect any extra positional arguments into a tuple called tags
**metadata → Collect any extra keyword arguments into a dict called metadata
First call: no tags, no metadata — both are empty
Second call: "billing" and "stripe" go into tags tuple; user_id and amount go into metadata dict
func(100, 0.25)
func(price=100)
pct: float = 0.1
Lambda: a compact one-line function. double = lambda x: x * 2 is equivalent to a 3-line def. Claude uses lambdas for sort keys: sorted(users, key=lambda u: u['score']). Now you can read those lines.
Scope — What Can See What
Variables have scope — where they are visible. Python searches scopes in order: Local first, then Enclosing, then Global, then Built-in. This is called the LEGB rule.
Variables inside the current function. Exists only while the function runs.
Outer function variables. Inner functions can read (not modify) them by default.
Module level, the whole file. Modify with the global keyword inside a function.
Python built-ins: len, print, range — always available everywhere.
total = 0 # global scope
def add_amount(x):
global total # I mean the global, not a local
total += x
def make_counter():
count = 0 # enclosing scope
def increment():
nonlocal count # modify the enclosing count
count += 1
return count
return increment
total = 0 at module level → global variable, visible everywhere in the file
global total → Without this, total += x would create a new local variable and crash
count = 0 inside make_counter → local to make_counter, enclosing scope for increment
nonlocal count → Modify the enclosing count, not create a new local one
return increment → Return the inner function itself — used as a callable
Check Your Understanding
Claude wrote this loop. What does it print?
i = 0
while i < 4:
i += 1
if i == 2:
continue
print(i)
i = 0
while i < 4:
i += 1
if i == 2:
continue
print(i)You want to keep asking the user for input until they type "done." Which loop pattern is correct?
You see tasks.sort(key=lambda t: t["due_date"]) in Claude code. What does it do?
Building with Objects
Classes, inheritance, dataclasses — how Python bundles data and behavior
Classes — Blueprints for Objects
A class is a cookie cutter. Each instance is a cookie — same shape, different frosting.
Every library Claude uses — pandas, FastAPI, Pydantic — is built from classes. Reading a class stops feeling mysterious once you know this pattern.
class User:
def __init__(self, name: str, email: str):
self.name = name
self.email = email
self.role = "viewer" # default
def greet(self) -> str:
return f"Hello, {self.name}!"
alice = User("Alice", "alice@co.com")
print(alice.greet()) # "Hello, Alice!"
class User: → Define a blueprint named User
def __init__(self, name, email): → Constructor: runs automatically when User(...) is called
self.name = name → Store name on this specific instance
self.role = "viewer" → Default attribute — all new Users start as viewers
def greet(self): → Instance method — self is always first, always means "this object"
alice = User("Alice", ...) → Create one instance. Python passes alice as self automatically
alice.greet() → Calls greet on alice specifically
Inheritance — Is-A Relationships
A child class inherits everything from its parent. You only define what is different or new. super() calls the parent version of a method.
class Product:
def __init__(self, name: str, price: float):
self.name = name
self.price = price
def get_info(self) -> str:
return f"{self.name}: ${self.price:.2f}"
class PhysicalProduct(Product):
def __init__(self, name, price, weight_kg: float):
super().__init__(name, price) # run Product setup first
self.weight_kg = weight_kg
def shipping_cost(self) -> float:
return self.weight_kg * 2.5
class PhysicalProduct(Product): → PhysicalProduct IS-A Product. Inherits everything Product has
super().__init__(name, price) → Run the parent (Product) constructor first, then our extra setup
self.weight_kg = weight_kg → Add our own attribute after the parent is initialized
def shipping_cost(self): → New method only PhysicalProduct has — Product does not know about it
A PhysicalProduct can call both get_info() (from Product) and shipping_cost() (its own)
Every library Claude pulls in is a class hierarchy. A pandas DataFrame, a FastAPI HTTPException, a Pydantic BaseModel — all classes with inheritance. Understanding this means library code stops being magic.
Dataclasses — Less Boilerplate
@dataclass auto-generates the __init__, __repr__, and __eq__ methods from your field annotations. One decorator replaces 15 lines of boilerplate.
Without @dataclass
Write __init__ manually (8+ lines), __repr__ for printing (3 lines), __eq__ for comparisons (3 lines). Total: 15+ lines per class.
With @dataclass
Just list your fields with type annotations. The decorator generates everything else automatically.
from dataclasses import dataclass
@dataclass
class OrderItem:
product_name: str
quantity: int
unit_price: float
@property
def total(self) -> float:
return self.quantity * self.unit_price
item = OrderItem("Widget", 3, 9.99)
print(item.total) # 29.97 (no parentheses!)
print(item) # OrderItem(product_name='Widget', quantity=3, unit_price=9.99)
@dataclass → Decorator that auto-generates __init__, __repr__, and __eq__ from field annotations
product_name: str → Field declaration — becomes self.product_name in the auto-generated __init__
@property → Makes total behave like an attribute, not a method — call as item.total, not item.total()
print(item.total) → No parentheses! @property makes it look like a plain attribute
print(item) → Dataclass auto-generates a nice readable representation
Never use mutable defaults like tags: list = [] in a dataclass — the same list object is shared between ALL instances. Use field(default_factory=list) instead. Claude sometimes gets this wrong too.
Dunder Methods — Python Secret Language
Methods named with double underscores (__like_this__) are called automatically by Python when you use operators or built-in functions. They let your custom objects behave like native Python types.
print(obj)
len(obj)
obj1 == obj2
obj + other
item in obj
for x in obj
class Cart:
def __init__(self):
self._items = []
def add(self, item: str):
self._items.append(item)
def __len__(self) -> int:
return len(self._items)
def __str__(self) -> str:
return f"Cart with {len(self)} items"
def __contains__(self, item: str) -> bool:
return item in self._items
cart = Cart()
cart.add("apple")
cart.add("bread")
print(len(cart)) # 2
print(cart) # "Cart with 2 items"
print("apple" in cart) # True
__len__ → len(cart) calls this automatically. Return the integer size
__str__ → print(cart) calls this. Return a human-readable string
__contains__ → "apple" in cart calls this. Return True or False
These hooks make Cart feel like a native Python type
The double underscores are called dunders (double underscores). You say __str__ as dunder-str. When Claude generates a class with __repr__ or __post_init__, these are the same mechanism — hooking into Python operations.
Check Your Understanding
Two Product instances should be equal if they have the same sku field. Which dunder method do you implement?
class Dog(Animal): — in __init__, Dog calls super().__init__(name). What does this do?
Match each concept to its description:
Auto-generates constructor and repr from field annotations
Makes a method look like a plain attribute — no parentheses needed
Reference to the parent class for calling parent methods
The constructor — runs automatically when a new instance is created
The Outside World
Files, JSON, exceptions — how Python reads from and writes to the world beyond your script
Files — The Library Borrowing System
A context manager is like a library borrowing system — the book is checked out, used, and automatically returned when you are done. Even if you forget. Even if something goes wrong.
from pathlib import Path
data_file = Path("tasks.txt")
# Write
with open(data_file, "w") as f:
f.write("Buy milk\n")
f.write("Call Alice\n")
# Read
with open(data_file, "r") as f:
for line in f:
print(line.strip())
Path("tasks.txt") → A cross-platform path object — works the same on Mac, Windows, Linux
open(data_file, "w") → Open for writing — creates if not exists, overwrites if it does
with ... as f: → Context manager: f is the open file. When the block ends, f closes automatically
f.write("text\n") → Write text. \n is a newline character
open(data_file, "r") → Open for reading. "r" is the default mode
for line in f: → Iterate through the file line by line — memory efficient
Almost every practical script Claude writes for you touches files. The with open(...) pattern guarantees the file closes cleanly — no leaked file handles, no data corruption. Even if your code raises an exception inside the block.
Glossary: context manager · with statement · pathlib.Path · file mode
JSON — Python Meets the Web
JSON is how data travels on the internet. Python dicts and JSON look almost identical. The json module converts between them instantly.
When Claude writes code to handle an API response, every response is JSON. The moment you call response.json(), Python converts the JSON text to a Python dict — and you can access it with all the dict skills from Module 2.
import json
user = {
"name": "Alice",
"score": 95,
"tags": ["admin", "active"]
}
# Python dict → JSON string
json_str = json.dumps(user, indent=2)
# JSON string → Python dict
loaded = json.loads(json_str)
# Write to file
with open("user.json", "w") as f:
json.dump(user, f, indent=2)
# Read from file
with open("user.json", "r") as f:
data = json.load(f)
json.dumps(user) → "dump to string" — converts Python dict to a JSON-formatted string
indent=2 → Pretty-print with 2-space indentation — readable to humans and tools
json.loads(json_str) → "load from string" — converts JSON string back to Python dict
json.dump(user, f) → Write JSON directly to an open file (no intermediate string)
json.load(f) → Read JSON directly from a file — returns a Python dict
1. API response arrives
Raw JSON text comes back from the server as a string.
2. json.loads() converts
JSON text becomes a Python dict you can work with.
3. Access data
data["user"]["name"] — normal dict access.
4. Modify as dict
Update values using all your dict skills from Module 2.
5. json.dumps() converts back
Python dict becomes JSON text for storage or sending.
Glossary: JSON · json.dumps() · json.loads() · serialization
Try/Except — Expecting the Unexpected
External resources fail. Files go missing. APIs go down. try/except lets your program handle these failures gracefully instead of crashing.
try:
with open("config.json", "r") as f:
config = json.load(f)
except FileNotFoundError:
config = {"debug": False, "timeout": 30}
print("Config not found — using defaults")
except json.JSONDecodeError as e:
print(f"Invalid JSON: {e}")
config = {}
else:
print("Config loaded successfully")
finally:
print("Setup complete") # always runs
try: → Attempt this code — it might fail
except FileNotFoundError: → If the file does not exist, handle it here (no crash)
except json.JSONDecodeError as e: → If JSON is malformed, handle it here. e holds error details
else: → Runs ONLY if the try block completed with no exception
finally: → Runs no matter what — success, failure, or any exception. Use for cleanup
Glossary: exception · try/except · FileNotFoundError · finally
The Exception Hierarchy
Python exceptions form a tree. Catching a parent type catches all its children. Knowing the hierarchy helps you write precise error handling.
Custom exceptions let you create domain-specific error types. A payment app might define PaymentDeclined(ValueError). Claude often generates these in larger applications — now you know they are just subclasses of Exception.
Glossary: exception hierarchy · ValueError · TypeError · KeyError
Check Your Understanding
Claude wrote code with user["premium_tier"] from an API response. Some users do not have this field. What is the safest fix?
Trace this code. When the file does not exist, which blocks run?
try:
f = open("missing.txt")
data = f.read()
except FileNotFoundError:
data = "default"
else:
print("File loaded")
finally:
print("Done")
try:
f = open("missing.txt")
data = f.read()
except FileNotFoundError:
data = "default"
else:
print("File loaded")
finally:
print("Done")You want to append new log entries without overwriting old ones. Which file mode?
Trusting Your Code
pytest, fixtures, parametrize, mocking — how to verify your code works (and catch when Claude gets it wrong)
Why Tests Exist
Tests are a ratchet wrench — each green test is a click that locks in your progress. You cannot accidentally slip backward.
When Claude generates tests alongside code, it is creating a record of intended behavior. The test says: this function should do X. If anyone changes the function and breaks X, the test fails loudly. Tests are a contract written in executable form.
Glossary: test · regression · pytest · test suite
Writing Assertions
A pytest test is any function whose name starts with test_. Inside, you use assert to state what you expect. If the assertion fails, pytest tells you exactly what the actual value was.
pytest automatically discovers files named test_*.py or *_test.py
Any function starting with test_ is a test. No class or registration needed.
assert expected_value == actual_value. If False, pytest shows both values.
Wrap code that SHOULD raise an exception. The test fails if no exception is raised.
# test_calculator.py
from calculator import add, divide
def test_add_positive_numbers():
result = add(3, 4)
assert result == 7
def test_divide_by_zero_raises():
import pytest
with pytest.raises(ZeroDivisionError):
divide(10, 0)
def test_add_returns_correct_type():
result = add(1.5, 2.5)
assert isinstance(result, float)
assert result == 4.0
def test_add_positive_numbers(): → Any function starting with test_ is picked up by pytest automatically
assert result == 7 → "I expect result to equal 7. If not, stop and show me what it actually was."
def test_divide_by_zero_raises(): → A test specifically for error-handling behavior
with pytest.raises(ZeroDivisionError): → "I expect this code to raise ZeroDivisionError. No exception = test failure."
def test_add_returns_correct_type(): → A separate test for the return type
assert isinstance(result, float) → Check the type, not just the value
Common trap: tests that pass but test nothing. def test_something(): result = run(); assert result — this only checks that result is truthy. A real assertion checks the specific expected value: assert result == expected.
Glossary: assert · pytest.raises() · assertion · test discovery
Fixtures — Shared Setup
Fixtures eliminate repeated setup code. Write the setup once, mark it with @pytest.fixture, and pytest injects it automatically into every test that requests it.
import pytest
from app import User, is_admin
@pytest.fixture
def sample_user():
return User(name="Alice", role="admin", active=True)
def test_user_is_active(sample_user):
assert sample_user.active is True
def test_admin_check(sample_user):
assert is_admin(sample_user) is True
@pytest.mark.parametrize("a,b,expected", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
])
def test_add(a, b, expected):
from calculator import add
assert add(a, b) == expected
@pytest.fixture → Marks a function as a fixture. pytest calls it and passes the return value to any test that lists it as a parameter
def test_user_is_active(sample_user): → pytest sees sample_user in the signature and runs the fixture automatically
@pytest.mark.parametrize("a,b,expected", [...]) → Run this test multiple times with different inputs — no copy-pasting
The table (1,2,3), (0,0,0), (-1,1,0) runs the test 3 times, once per row, producing 3 separate test results
Parametrize is how Claude writes efficient tests. Instead of test_add_positives, test_add_zeros, test_add_negatives — three separate functions — it writes one parametrized test. When you see that decorator, it means one function tests many cases.
Glossary: fixture · @pytest.fixture · @pytest.mark.parametrize · scope
Mocking — Stub the World
Mocking replaces real dependencies (APIs, databases, files) with fake versions during tests. Your code runs in isolation, with full control over what the fakes return.
from unittest.mock import patch
import pytest
from weather_app import get_weather_summary
def test_weather_sunny():
fake_data = {"temp": 22, "condition": "sunny"}
with patch("weather_app.fetch_weather") as mock_fetch:
mock_fetch.return_value = fake_data
result = get_weather_summary("Tokyo")
assert "sunny" in result
mock_fetch.assert_called_once_with("Tokyo")
patch("weather_app.fetch_weather") → Replace fetch_weather in weather_app with a fake for the duration of the with block
mock_fetch.return_value = fake_data → When your code calls the fake, it returns this value
get_weather_summary("Tokyo") → Your real function runs — but the API call is intercepted
assert "sunny" in result → Verify the output contains the expected content
mock_fetch.assert_called_once_with("Tokyo") → Verify the mock was called exactly once with exactly this argument
A test that patches the wrong path silently passes for the wrong reason. Always patch where the name is USED, not where it is defined. If weather_app.py does from requests import get, patch weather_app.get, not requests.get.
Glossary: mock · patch · assert_called_once_with() · test isolation