What’s one thing you learned? What’s still confusing?
Python's Memory Model: Objects, References & Identity
Everything is an object: references, id(), integer caching, mutable vs immutable.
Python Internals: CPython, Bytecode & Memory
AST, bytecode, refcounting, cyclic GC, the GIL, integer caching, and cProfile.
Async/Await & Concurrency: Parallel Python
Threading, multiprocessing, asyncio, and concurrent.futures.
Interactive Labs for This Track
Loop Visualizer
You're a factory robot repeating the same task on an assembly line — watch how loops automate repetitive work
List Slicing
You have a playlist of 50 songs — grab just tracks 10 through 20 with a single slice expression
Sorting Algorithms
You're organizing a library of 10,000 books — which sorting method is fastest?
Ask questions, share insights
@app.get(...) in FastAPI, every @dataclass, every @property — they're all decorators. Generators power streaming token responses from every LLM API. with open(...) as f is a context manager. These three patterns separate intermediate Python from advanced Python — and they all rely on the same protocol.@decorator syntax is just shorthand.# Step 1: A decorator is just a function that takes a function
def shout(func):
"""A decorator that converts the result to uppercase."""
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result.upper()
return wrapper
# Step 2: Apply it manually (the old way)
def greet(name):
return f"Hello, {name}!"
greet = shout(greet) # wrap greet with shout
print(greet("Alice")) # HELLO, ALICE!
# Step 3: Apply it with @ syntax (the modern way -- identical result)
@shout
def greet_v2(name):
return f"Hello, {name}!"
print(greet_v2("Bob")) # HELLO, BOB!@shout above greet_v2 is exactly equivalent to writing greet_v2 = shout(greet_v2). The @ syntax is just cleaner.You define a decorator without @functools.wraps. What does the decorated function's __name__ return?
import time
def timer(func):
"""Measure how long a function takes to run."""
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} took {end - start:.4f} seconds")
return result
return wrapper
@timer
def slow_sum(n):
"""Sum numbers from 0 to n using a loop."""
total = 0
for i in range(n):
total += i
return total
@timer
def fast_sum(n):
"""Sum numbers from 0 to n using the formula."""
return n * (n - 1) // 2
slow_sum(1_000_000) # slow_sum took 0.0523 seconds
fast_sum(1_000_000) # fast_sum took 0.0000 secondsdef log_call(func):
"""Log every call to a function with its arguments."""
def wrapper(*args, **kwargs):
args_str = ", ".join([repr(a) for a in args])
kwargs_str = ", ".join([f"{k}={v!r}" for k, v in kwargs.items()])
all_args = ", ".join(filter(None, [args_str, kwargs_str]))
print(f"CALL: {func.__name__}({all_args})")
result = func(*args, **kwargs)
print(f" -> returned {result!r}")
return result
return wrapper
@log_call
def add(a, b):
return a + b
@log_call
def multiply(a, b):
return a * b
add(3, 5)
# CALL: add(3, 5)
# -> returned 8
multiply(4, 7)
# CALL: multiply(4, 7)
# -> returned 28def cache(func):
"""Cache results to avoid recomputing for the same inputs."""
memo = {}
def wrapper(*args):
if args in memo:
print(f" (cache hit for {args})")
return memo[args]
result = func(*args)
memo[args] = result
return result
return wrapper
@cache
def fibonacci(n):
"""Compute the nth Fibonacci number recursively."""
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(10)) # 55 (fast -- only computes each value once)
print(fibonacci(10)) # 55 (instant -- cache hit)fibonacci(30) would make over a million recursive calls. With the cache, it makes exactly 31 calls.@decorators and the order of application becomes its own puzzle: decorators are applied bottom-up at definition time, but control flows top-down through them at call time. The viz below makes both phases visible — toggle the modes and watch a cache HIT short-circuit before it ever reaches the inner function.import time
from functools import wraps
# Pattern: outer function takes arguments, returns the real decorator
def retry(max_retries: int = 3, delay: float = 1.0):
"""Decorator factory: retry a function on exception up to max_retries times."""
def decorator(func): # the real decorator — takes the function
@wraps(func) # always use @wraps to preserve metadata
def wrapper(*args, **kwargs):
for attempt in range(1, max_retries + 1):
try:
return func(*args, **kwargs)
except Exception as e:
print(f" Attempt {attempt}/{max_retries} failed: {e}")
if attempt < max_retries:
time.sleep(delay)
raise RuntimeError(f"{func.__name__} failed after {max_retries} attempts")
return wrapper
return decorator # return the decorator, not the wrapped function
# Usage: @retry(max_retries=4, delay=0.5) calls retry(4, 0.5), which returns decorator
@retry(max_retries=4, delay=0.1)
def unstable_api_call():
"""Simulates an API that sometimes fails."""
import random
if random.random() < 0.7:
raise ConnectionError("Server busy")
return "OK"
# Without arguments uses defaults: @retry is equivalent to @retry()
@retry()
def another_fn():
return 42
result = another_fn() # never retries since it always succeedsretry(args) → decorator(func) → wrapper(*args, **kwargs). The factory layer holds configuration, the decorator layer captures the function, the wrapper layer executes the logic.def retry(max_retries): # FACTORY
def decorator(func): # DECORATOR
def wrapper(*args, **kw): # WRAPPER
...
return wrapper
return decorator
@retry(max_retries=4) # rewritten as: api = retry(4)(api)
def api():
...
api() # finally invokes wrapper()retry(4) returns decorator), then the decorator runs (decorator(api) returns wrapper), and finally api is rebound to wrapper. Only later, at call time, does wrapper() actually execute. Each (arg) you see in @retry(4) is a real function call — not part of the @ syntax.Which of these does @functools.wraps(func) NOT copy from the original function to the wrapper?
yield, it pays to see the protocol generators implement. A for loop doesn't know or care whether you hand it a list, a generator, or a custom class with __next__. They all answer the same two questions: "Give me the next value" and "Are you done yet?" Step through the five presets below — especially C. Generator — to see how yield pauses a function's frame in place, and how next() resumes it. The rest of this section is just sugar on top.yield instead of return. Each time you call next() on it, it runs until the next yield, pauses, and gives you the value. This is called lazy evaluation -- values are produced on demand, not all at once.def count_up(n):
"""A generator that yields numbers from 1 to n."""
i = 1
while i <= n:
yield i
i += 1
# Using the generator
counter = count_up(5)
print(next(counter)) # 1
print(next(counter)) # 2
print(next(counter)) # 3
# Or use it in a for loop (most common)
for num in count_up(5):
print(num, end=" ")
# 1 2 3 4 5yield freeze the function mid-loop — i is preserved on the heap, the for loop in the caller pulls the next value, and execution resumes exactly where it paused.Edit the code, then click Trace it. Python actually runs in your browser — every line, every variable, every print.
Click Trace it to capture the execution trace. The scrubber below will let you step through every variable change line-by-line.
import sys
# List: stores ALL values in memory at once
numbers_list = [x ** 2 for x in range(1_000_000)]
print(f"List size: {sys.getsizeof(numbers_list):,} bytes")
# List size: 8,448,728 bytes (~8 MB)
# Generator: produces values one at a time
numbers_gen = (x ** 2 for x in range(1_000_000))
print(f"Generator size: {sys.getsizeof(numbers_gen):,} bytes")
# Generator size: 200 bytes (constant, regardless of how many items!)The generator uses virtually no memory because it only computes one value at a time. This is critical when working with datasets larger than your RAM.
def fibonacci_gen():
"""An infinite Fibonacci generator."""
a, b = 0, 1
while True:
yield a
a, b = b, a + b
# Get the first 10 Fibonacci numbers
fib = fibonacci_gen()
first_10 = [next(fib) for _ in range(10)]
print(first_10) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]yield from: Delegating to Another Generatoryield from is shorthand for iterating over a sub-generator and re-yielding each value. It is cleaner than a manual for loop and also handles .send() and .throw() correctly for advanced generator protocols:# Without yield from: manual delegation
def chain_manual(gen1, gen2):
for item in gen1:
yield item
for item in gen2:
yield item
# With yield from: delegate directly
def chain(gen1, gen2):
yield from gen1 # yield every value from gen1, then...
yield from gen2 # yield every value from gen2
gen = chain(range(3), range(10, 13))
print(list(gen)) # [0, 1, 2, 10, 11, 12]
# yield from works with any iterable, including lists and strings
def flatten(nested):
"""Recursively flatten arbitrarily nested lists using yield from."""
for item in nested:
if isinstance(item, list):
yield from flatten(item) # recurse and re-yield all values
else:
yield item
print(list(flatten([1, [2, [3, 4]], [5, 6]]))) # [1, 2, 3, 4, 5, 6]def read_lines(filename):
"""Yield lines from a file one at a time."""
with open(filename) as f:
for line in f:
yield line.strip()
def filter_nonempty(lines):
"""Yield only non-empty lines."""
for line in lines:
if line:
yield line
def to_uppercase(lines):
"""Yield each line in uppercase."""
for line in lines:
yield line.upper()
# Chain generators into a pipeline (nothing executes until you iterate!)
# pipeline = to_uppercase(filter_nonempty(read_lines("data.txt")))
# for line in pipeline:
# print(line)Each generator in the pipeline processes one line at a time. Even if the file has billions of lines, you never hold more than one line in memory.
__enter__ and __exit__ methods. The with statement calls __enter__ when entering the block and __exit__ when leaving -- even if an exception occurs.class Timer:
"""A context manager that times a code block."""
def __enter__(self):
import time
self.start = time.time()
print("Timer started...")
return self
def __exit__(self, exc_type, exc_val, exc_tb):
import time
elapsed = time.time() - self.start
print(f"Timer stopped: {elapsed:.4f} seconds")
return False # do not suppress exceptions
# Usage
with Timer():
total = sum(range(1_000_000))
print(f"Sum: {total}")
# Timer started...
# Sum: 499999500000
# Timer stopped: 0.0312 secondsclass DatabaseConnection:
"""Simulated database connection with automatic cleanup."""
def __init__(self, db_name):
self.db_name = db_name
self.connected = False
def __enter__(self):
print(f"Connecting to {self.db_name}...")
self.connected = True
return self
def __exit__(self, exc_type, exc_val, exc_tb):
print(f"Closing connection to {self.db_name}")
self.connected = False
if exc_type:
print(f" (error occurred: {exc_val})")
return False
def query(self, sql):
if not self.connected:
raise RuntimeError("Not connected!")
print(f" Executing: {sql}")
return [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
# The connection is guaranteed to close, even if query fails
with DatabaseConnection("ml_data.db") as db:
results = db.query("SELECT * FROM users")
print(f" Got {len(results)} results")
# Connecting to ml_data.db...
# Executing: SELECT * FROM users
# Got 2 results
# Closing connection to ml_data.dbcontextlib module lets you create context managers with a generator instead of a full class:from contextlib import contextmanager
import time
@contextmanager
def timer():
"""A timer context manager using contextlib."""
start = time.time()
print("Timer started...")
try:
yield # this is where the 'with' block runs
finally:
# try/finally is essential — without it, an exception inside the
# `with` block skips the cleanup code below `yield`.
elapsed = time.time() - start
print(f"Timer stopped: {elapsed:.4f} seconds")
with timer():
total = sum(range(1_000_000))
print(f"Sum: {total}")yield is the boundary between setup and cleanup. Everything before yield is __enter__, everything after is __exit__. Always wrap yield in try/finally so cleanup still runs if the with block raises.Here is a decorator that combines timing, logging, and caching:
import time
from functools import wraps
def smart_cache(func):
"""A decorator that caches results and logs timing."""
memo = {}
@wraps(func) # preserves the original function's name and docstring
def wrapper(*args):
if args in memo:
print(f" {func.__name__}{args}: cache hit")
return memo[args]
start = time.time()
result = func(*args)
elapsed = time.time() - start
memo[args] = result
print(f" {func.__name__}{args}: computed in {elapsed:.6f}s")
return result
return wrapper
@smart_cache
def expensive_calculation(n):
"""Simulate an expensive computation."""
total = 0
for i in range(n * 1000):
total += i ** 0.5
return total
print(expensive_calculation(100)) # computed in 0.012345s
print(expensive_calculation(100)) # cache hit (instant)
print(expensive_calculation(200)) # computed in 0.024567sTests · Build decorators, generators, and test lazy evaluation!
@decorator syntax above a function definition. The original function is unchanged; the wrapper adds timing, logging, caching, or any other cross-cutting concernyield for lazy evaluation -- they produce values one at a time, keeping memory usage constant even for infinite sequences. Use next() or a for loop to consume valueswith -- define __enter__ (setup) and __exit__ (teardown) to guarantee resources are released, even when exceptions occur. Use contextlib.contextmanager for a simpler generator-based approachfunctools.wraps preserves function metadata -- always use @wraps(func) in your decorator to keep the original function's __name__ and __doc__ intactWhat does a decorator do?