The problem: Production libraries often stream values lazily, wrap functions with extra behaviour, guarantee resource cleanup, and describe expected types. Without the underlying ideas, SDK and framework code can look like unfamiliar magic.
What you will learn: You will unpack iterators, generators, closures, decorators, context managers, type hints, dataclasses, enums, and pattern matching. Each topic includes when to use it and when simpler code is clearer. For example, generators can stream tokens and context managers can close connections, while type hints communicate expectations but do not validate runtime data by themselves.
Part A — Iteration
1. Iterables and Iterators
Many Python objects can answer the same simple request: “Give me the next
item.” This shared behaviour lets one for loop work with lists, files,
generators, database results, and streamed model events.
iterable ── iter(...) ─→ iterator
│
next(...) returns one item
│
no item left → StopIteration
An iterable can produce an iterator. An iterator remembers its current position while values are consumed. Keeping those roles separate explains why a list can be looped over repeatedly while a particular generator is normally used only once.
Iterable Versus Iterator
An iterable is anything you can loop over (list, dict, str, a
file). An iterator is the object that actually does the stepping —
it remembers “where it’s up to” and hands you one item at a time when
asked.
Why One Loop Works with Many Sources
Python needs one consistent protocol so that for x in anything: just
works, regardless of whether anything is a list, a file, a range of
numbers, or something entirely custom you built yourself.
Picture a Dispenser Producing One Item at a Time
An iterable is a book. An iterator is a bookmark — it remembers which page you’re on and gives you the next page when you ask, without needing you to hold the whole book open in your hands at once.
numbers = [1, 2, 3] # an iterable
iterator = iter(numbers) # get an iterator from it
print(next(iterator)) # 1
print(next(iterator)) # 2
print(next(iterator)) # 3
print(next(iterator)) # StopIteration error — nothing left
Expected Output:
1
2
3
Traceback (most recent call last):
...
StopIteration
🤖 How Is This Used in AI? A for loop over a list of documents, a
for loop reading lines from a file, a for loop over streamed API
chunks — all of these work because Python objects agree on this same
iterator protocol underneath.
2. Generators
What Is It?
A generator is a special kind of function that produces values one at a time, pausing between each, instead of computing and returning everything at once.
Why Does It Exist?
Imagine processing a 10-million-line document file, or streaming tokens from an LLM as they’re generated. Building the entire result in memory first (as a list) would be slow or even impossible. Generators let you process — and produce — one item at a time, using almost no memory.
🧠 Intuition
“Think of a generator as a water tap rather than a bucket. It gives you one item when you need it, instead of storing everything at once.”
A bucket (a list) is filled completely before you can use any of it. A tap (a generator) gives you water exactly when you turn it on — one “unit” at a time, on demand.
Real-World Analogy
A bucket approach to reading a huge book: photocopy every single page before reading page one. A generator approach: read one page, then turn to the next only when you’re ready — far less wasted effort if you might stop partway through.
Syntax and yield
def count_up_to(n):
current = 1
while current <= n:
yield current # pause here, hand back current, remember position
current += 1
for number in count_up_to(5):
print(number)
Expected Output:
1
2
3
4
5
How It Works
yieldis likereturn, but instead of ending the function, it pauses it — the function’s local state (current, in this case) is remembered.- Each time the loop asks for the next value, the function resumes right
after the last
yieldand runs until it hitsyieldagain (or ends). - A function containing
yieldbecomes a generator function — calling it doesn’t run the code immediately, it returns a generator object that runs lazily, step by step.
Bad / naive approach vs. generator approach
# Bad / naive approach — builds the ENTIRE list in memory first
def get_all_chunks_list(text, chunk_size=100):
chunks = []
for i in range(0, len(text), chunk_size):
chunks.append(text[i:i + chunk_size])
return chunks
# Better approach — yields one chunk at a time, using far less memory
def get_all_chunks_generator(text, chunk_size=100):
for i in range(0, len(text), chunk_size):
yield text[i:i + chunk_size]
text = "Python is great for AI. " * 1000
# Both are usable the same way in a loop:
for chunk in get_all_chunks_generator(text, chunk_size=50):
pass # process one chunk at a time, without ever holding them all
print("Done processing without storing every chunk in memory at once.")
Expected Output:
Done processing without storing every chunk in memory at once.
Why the better approach matters in AI
This exact “build a list first” vs. “yield one at a time” tradeoff is the difference between:
- Batch processing a huge document collection (loading everything into RAM — can crash on large datasets), and
- Streaming processing (handling one chunk/document/token at a time — scales to arbitrarily large input).
🤖 How Is This Used in AI?
This is the exact mechanism behind streaming LLM responses — instead of waiting for the model to finish an entire answer, the API sends tokens one at a time as they’re generated:
def stream_tokens(full_response):
"""Simulates an LLM API streaming tokens one at a time."""
for word in full_response.split():
yield word + " "
for token in stream_tokens("Retrieval augmented generation improves accuracy."):
print(token, end="", flush=True)
print()
Expected Output:
Retrieval augmented generation improves accuracy.
In a real chat UI, each yielded piece would be sent to the screen
immediately — which is why you see ChatGPT/Claude responses appear word
by word instead of all at once.
⚠️ Common Beginner Mistake: Trying to loop over a generator twice.
gen = count_up_to(3) for x in gen: print(x) for x in gen: # nothing prints — the generator is already exhausted! print(x)A generator can only be consumed once. If you need to reuse the data, convert it to a list (
list(gen)) — but only if it’s small enough to fit in memory comfortably; otherwise, call the generator function again to get a fresh generator.
When to use / when NOT to use generators
- Use when: processing large or unbounded data, streaming results, or when you only need to go through the data once, in order.
- Avoid when: you need to access items by index, loop over the data multiple times, or need to know the total count in advance — a plain list is simpler and clearer in those cases.
3. Generator Expressions
A compact, one-line way to create a generator — same syntax family as list
comprehensions, but with () instead of [].
scores = [0.9, 0.4, 0.75, 0.6, 0.85]
# List comprehension — builds the whole list immediately, in memory
high_scores_list = [s for s in scores if s >= 0.7]
# Generator expression — produces values lazily, one at a time
high_scores_gen = (s for s in scores if s >= 0.7)
print(high_scores_list)
print(next(high_scores_gen))
print(next(high_scores_gen))
Expected Output:
[0.9, 0.75, 0.85]
0.9
0.75
🤖 How Is This Used in AI? Filtering a huge stream of embeddings or log lines by a condition, without ever materializing the whole filtered result as a list in memory — common when processing large evaluation logs.
4. Closures
What Is It?
A closure is a function that remembers variables from the scope it was created in, even after that outer scope has finished running.
def make_prompt_builder(system_prompt):
def build(user_question):
return f"{system_prompt}\n\nUser: {user_question}"
return build # returns a function that "remembers" system_prompt
assistant_builder = make_prompt_builder("You are a helpful coding assistant.")
tutor_builder = make_prompt_builder("You are a patient math tutor.")
print(assistant_builder("How do I reverse a list?"))
print(tutor_builder("What is a derivative?"))
Expected Output:
You are a helpful coding assistant.
User: How do I reverse a list?
You are a patient math tutor.
User: What is a derivative?
🧠 Intuition
A closure is a function with a backpack — it carries along whatever variables existed around it when it was created, even after it travels elsewhere in your program.
🤖 How Is This Used in AI? This is exactly how you’d build multiple specialized prompt-builders or pre-configured API callers from one factory function — each remembers its own configuration without needing a full class.
5. Decorators
What Is It?
A decorator is a function that wraps another function, adding behavior before/after it runs — without modifying the original function’s code.
Why Does It Exist?
Certain behaviors — logging, timing, retrying, authentication checks —
apply to many different functions. Instead of copy-pasting that logic
into every function, a decorator lets you write it once and “attach” it
anywhere with a single line: @decorator_name.
🧠 Intuition
A decorator is gift wrapping — the original present (function) is unchanged inside, but the wrapping adds something extra around it (timing, logging, error handling) every time it’s “opened” (called).
Syntax
def my_decorator(func):
def wrapper(*args, **kwargs):
# do something BEFORE
result = func(*args, **kwargs)
# do something AFTER
return result
return wrapper
@my_decorator
def some_function():
...
Example — timing decorator
import time
def timer(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
elapsed = time.time() - start
print(f"{func.__name__} took {elapsed:.4f} seconds")
return result
return wrapper
@timer
def call_llm(prompt):
time.sleep(0.3) # pretend this simulates network latency
return f"Response to: {prompt}"
response = call_llm("What is a decorator?")
print(response)
Expected Output:
call_llm took 0.3001 seconds
Response to: What is a decorator?
How It Works
@timerabovedef call_llm(...)is exactly equivalent to writingcall_llm = timer(call_llm).- Calling
call_llm(...)now actually callswrapper(...), which runs your timing logic around a call to the real, originalcall_llm. *args, **kwargsinwrapper(recall Module 4) let the decorator work on any function, regardless of what arguments it takes.
Practical Decorator Examples
Retry decorator (a real, commonly-used pattern):
def retry(max_attempts=3):
def decorator(func):
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as e:
print(f"Attempt {attempt} failed: {e}")
if attempt == max_attempts:
raise
return wrapper
return decorator
attempt_counter = {"count": 0}
@retry(max_attempts=3)
def unreliable_api_call():
attempt_counter["count"] += 1
if attempt_counter["count"] < 3:
raise ConnectionError("Simulated network failure")
return "Success!"
print(unreliable_api_call())
Expected Output:
Attempt 1 failed: Simulated network failure
Attempt 2 failed: Simulated network failure
Success!
Logging decorator:
def log_call(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
return func(*args, **kwargs)
return wrapper
@log_call
def embed_text(text, model="text-embedding-3-small"):
return f"[embedding of: {text}]"
embed_text("Python is great for AI", model="text-embedding-3-large")
Expected Output:
Calling embed_text with args=('Python is great for AI',), kwargs={'model': 'text-embedding-3-large'}
🤖 How Is This Used in AI?
This is not a toy example — @retry, @log_call, and @timer-style
decorators are used constantly in real AI code, because API calls to
LLMs are exactly the kind of unreliable, slow, worth-monitoring operation
decorators were built for. Popular libraries like tenacity are entirely
built around exactly this retry-decorator pattern, just more feature-rich.
When to use / when NOT to use decorators
- Use when the same wrapping behavior (timing, logging, retrying, caching, auth checks) needs to apply across many different functions.
- Avoid when the behavior is only needed in one place — a decorator adds a layer of indirection that isn’t worth it for a single one-off case; just write the logic inline.
⚠️ Common Beginner Mistake: Forgetting
*args, **kwargsin the wrapper function — this breaks the decorator the moment you apply it to any function that takes arguments.
6. Context Managers and the with Statement
What Is It?
A context manager guarantees that setup happens before, and cleanup
happens after, a block of code — even if an error occurs in between.
You’ve already used one: with open(...) as f: from Module 7.
Why Does It Exist?
Resources like files, network connections, and database sessions need to
be properly closed/released. Manually remembering to call .close() every
time (and in every possible error path) is fragile. with guarantees it.
🧠 Intuition
A context manager is a rental agreement with automatic return — you “check out” a resource, use it, and it’s automatically “returned” the moment you’re done, no matter what happens while you’re using it.
Writing your own context manager
from contextlib import contextmanager
import time
@contextmanager
def timed_section(label):
start = time.time()
print(f"Starting: {label}")
yield # the code inside the `with` block runs here
elapsed = time.time() - start
print(f"Finished: {label} in {elapsed:.4f}s")
with timed_section("calling LLM API"):
time.sleep(0.2) # pretend this is an API call
print(" ...doing the work...")
Expected Output:
Starting: calling LLM API
...doing the work...
Finished: calling LLM API in 0.2001s
How It Works
- Code before
yieldruns when thewithblock starts. - Code after
yieldruns when thewithblock ends — even if an exception occurred inside it (with more advanced handling, which@contextmanagermanages for you here).
🤖 How Is This Used in AI?
with open(...) as f:— safely reading/writing documents (Module 7)- Database or vector-store connections:
with vector_db.connect() as conn: - Timing and logging a block of AI-pipeline code, exactly like the example above — very common around expensive LLM calls or embedding batches
- Temporarily overriding a setting (e.g., “run this block with
temperature=0just for a deterministic test”), then automatically restoring the original value afterward
When to use / when NOT to use
- Use whenever a resource needs guaranteed cleanup (files, connections, temporary settings changes).
- Avoid for simple logic with nothing to “clean up” — a context manager adds ceremony that isn’t needed for plain calculations.
7. Type Hints
What Is It?
Optional annotations that describe what type a variable, parameter, or return value is expected to be — Python does not enforce them at runtime by default, but tools and editors use them to catch mistakes early.
def build_prompt(context: str, question: str, max_length: int = 500) -> str:
prompt = f"Context: {context}\nQuestion: {question}"
return prompt[:max_length]
result: str = build_prompt("Paris info", "What is the capital?")
print(result)
Expected Output:
Context: Paris info
Question: What is the capital?
🧠 Intuition
Type hints are labels on a machine’s input/output slots — “this slot expects text, this one expects a number” — so both humans and tools can catch a mismatched plug before anything breaks.
🤖 How Is This Used in AI?
Type hints are the foundation Pydantic (used heavily for validating LLM structured output) is built on, and they make AI framework code — full of functions passing around embeddings, messages, and configs — vastly easier to read and safely modify.
from typing import List, Dict
def get_top_documents(results: List[Dict[str, float]], top_k: int = 3) -> List[str]:
sorted_results = sorted(results, key=lambda r: r["score"], reverse=True)
return [r["text"] for r in sorted_results[:top_k]]
Reading this signature alone tells you: “takes a list of dicts (each with string keys and float values), returns a list of strings” — without reading a single line of the function body.
8. Optional Types
from typing import Optional
def get_cached_response(prompt: str) -> Optional[str]:
"""Returns a cached response, or None if nothing is cached."""
cache = {"hello": "Hi there!"}
return cache.get(prompt) # returns None if prompt isn't in the cache
result = get_cached_response("hello")
print(result)
result2 = get_cached_response("unknown prompt")
print(result2)
Expected Output:
Hi there!
None
🧠 Intuition: Optional[str] means “either a string, or None —
nothing else.” It forces you (and anyone reading the signature) to
consciously handle the “nothing found” case, instead of being surprised by
it later.
🤖 How Is This Used in AI? Extremely common for cache lookups, optional config values, and functions that might legitimately have “no result” — e.g., “no document scored high enough to return.”
💡 Modern Type Hinting: The Pipe Operator (|)
In Python 3.10 and later, you no longer need to import Union or Optional from the typing module. Instead, you can use the pipe operator (|) to express “either-or” types:
Optional[str]becomesstr | NoneUnion[int, float]becomesint | float
def format_score(score: int | float) -> str | None:
if score is None:
return None
return f"{score:.2f}"
This modern syntax is cleaner, easier to read, and standard in all new Python codebases!
9. Dataclasses
What Is It?
A decorator (@dataclass) that automatically generates the boilerplate
(__init__, __repr__, __eq__) for classes whose main job is holding
data.
from dataclasses import dataclass
@dataclass
class Document:
text: str
score: float
source: str = "unknown" # default value
doc = Document(text="Python is great for AI.", score=0.91)
print(doc)
print(doc.text, doc.score, doc.source)
Expected Output:
Document(text='Python is great for AI.', score=0.91, source='unknown')
Document(text='Python is great for AI.', score=0.91, source='unknown')
Python is great for AI. 0.91 unknown
🧠 Intuition
Compare this to Module 5’s plain classes: without @dataclass, you’d
write __init__ by hand, and printing an instance would show something
unreadable like <Document object at 0x7f...> unless you also wrote
__str__ yourself. @dataclass gives you all of that automatically.
🤖 How Is This Used in AI?
Dataclasses are a lightweight, extremely common way to represent
structured records flowing through an AI pipeline — a retrieved document,
a chat message, a tool result — when you want the clarity of a class
without writing repetitive boilerplate by hand. (Pydantic’s BaseModel,
covered later in the course, adds validation on top of this same idea.)
When to use / when NOT to use
- Use for straightforward data-holding classes with little or no custom behavior.
- Prefer Pydantic instead when you need actual validation (e.g.,
rejecting a
scorethat’s not a valid float) — dataclasses don’t validate anything by default.
💡 Dataclasses vs. Pydantic Models
In professional AI applications, choosing between standard Python dataclasses and Pydantic models depends on whether you need strict data verification at runtime:
| Feature | Standard Dataclass (@dataclass) | Pydantic Model (BaseModel) |
|---|---|---|
| Source | Standard Library (built-in) | External Library (pip install pydantic) |
| Boilerplate reduction | Yes | Yes |
| Runtime Type Validation | No (type hints are ignored at runtime) | Yes (raises ValidationError on mismatch) |
| Easy JSON parsing | Manual (json.loads -> construct) | Built-in (model_validate_json()) |
| Field Constraints | No | Yes (e.g. Field(gt=0, lt=2.0)) |
| Best for | Internal data structures | API requests/responses & LLM outputs |
Here is a simplified example of how Pydantic operates in a real AI context:
from pydantic import BaseModel, Field, ValidationError
class UserProfile(BaseModel):
name: str
age: int = Field(gt=0, lt=120) # age must be between 1 and 119
skills: list[str] = []
# Validating correct inputs works seamlessly
user = UserProfile(name="Alice", age=30)
print(user.model_dump())
# Invalid inputs raise an error instantly at runtime (unlike dataclasses)
try:
UserProfile(name="Bob", age=-5)
except ValidationError as e:
print("Validation failed successfully as expected!")
Expected Output:
{'name': 'Alice', 'age': 30, 'skills': []}
Validation failed successfully as expected!
10. Enumerations
What Is It?
An Enum defines a fixed, named set of possible values — preventing typos
and invalid values that a plain string could accidentally contain.
from enum import Enum
class MessageRole(Enum):
SYSTEM = "system"
USER = "user"
ASSISTANT = "assistant"
def build_message(role: MessageRole, content: str) -> dict:
return {"role": role.value, "content": content}
message = build_message(MessageRole.USER, "What is an enum?")
print(message)
Expected Output:
{'role': 'user', 'content': 'What is an enum?'}
🧠 Intuition: Without an Enum, role="usr" (a typo) would silently
pass through as valid Python — nothing stops you. With MessageRole.USER,
a typo like MessageRole.USR fails immediately and loudly, because it
simply doesn’t exist.
🤖 How Is This Used in AI? Representing a fixed set of chat roles
(system/user/assistant), model providers, or status codes
("pending", "success", "failed") — anywhere a string could
accidentally be mistyped but really should only ever be one of a small,
known set of values.
11. Pattern Matching
What Is It?
match/case (Python 3.10+) lets you branch on the shape and value
of data, more expressively than a long chain of if/elif.
def handle_tool_result(result: dict):
match result:
case {"status": "success", "data": data}:
print(f"Success: {data}")
case {"status": "error", "message": message}:
print(f"Error occurred: {message}")
case {"status": "pending"}:
print("Still processing...")
case _:
print("Unknown result shape")
handle_tool_result({"status": "success", "data": "42"})
handle_tool_result({"status": "error", "message": "Timeout"})
handle_tool_result({"status": "pending"})
Expected Output:
Success: 42
Error occurred: Timeout
Still processing...
🧠 Intuition: match/case is like a much smarter if/elif chain
that can check a dictionary’s shape (which keys it has) and pull out
values in the same step, instead of manually checking and indexing.
🤖 How Is This Used in AI? Handling different response shapes from a tool call, an agent action, or an API result — where the “type” of result is encoded by which keys are present, a very common pattern in agent frameworks routing between different tool outputs.
When to use / when NOT to use
- Use when branching on the structure of data (dicts with different shapes, multiple possible types).
- Avoid for simple value comparisons — plain
if/elif(Module 3) is clearer for a single straightforward condition.
12. Advanced Function Techniques (recap and combination)
from dataclasses import dataclass
from typing import Optional
import time
@dataclass
class ToolResult:
success: bool
data: Optional[str] = None
def retry(max_attempts=3):
def decorator(func):
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
result = func(*args, **kwargs)
if result.success:
return result
print(f"Attempt {attempt} failed, retrying...")
return result
return wrapper
return decorator
call_count = {"n": 0}
@retry(max_attempts=3)
def call_tool(query: str) -> ToolResult:
call_count["n"] += 1
if call_count["n"] < 2:
return ToolResult(success=False)
return ToolResult(success=True, data=f"Result for: {query}")
result = call_tool("weather in Paris")
print(result)
Expected Output:
Attempt 1 failed, retrying...
ToolResult(success=True, data='Result for: weather in Paris')
This combines a dataclass (structured result), a decorator (retry logic), and type hints (clear function signatures) — exactly the toolbox real agent-framework tool-calling code is built from.
Important Boundaries
- An iterator is usually one-shot. After it is exhausted, loop over a new iterator rather than expecting the old one to restart.
- A generator is lazy, so errors inside it may appear only when the caller asks for the affected item—not when the generator object is created.
- A decorator should normally use
functools.wrapsso the wrapped function keeps its name and documentation. - Type hints help people, editors, and type checkers, but Python does not enforce them automatically at runtime. Use explicit validation when outside data must be checked.
- Dataclasses are convenient internal data containers. Pydantic is useful at an
untrusted boundary because it validates and converts input; in Pydantic v2,
model_validate()andmodel_validate_json()are the central validation methods.
These tools solve different problems. “Advanced” Python is not code with the most features; it is code that uses the smallest suitable feature clearly.
Module Summary
You’ve now covered the patterns that separate “I can write working scripts” from “I can read and extend real AI framework code”: generators for lazy/streaming data, closures and decorators for reusable cross-cutting behavior, context managers for guaranteed cleanup, and type hints, dataclasses, enums, and pattern matching for clear, safe, well-structured data.
AI Connection
Every one of these appears directly in production AI code: yield
streams LLM tokens to a chat UI, @retry-style decorators wrap unreliable
API calls, with blocks manage connections and timing, dataclasses and
type hints describe the shape of messages and tool results flowing
through an agent, and match/case routes between different kinds of
tool or API responses. This module is the missing bridge between “Python
scripts” and “Python frameworks.”
Mini Practice
- Write a generator function
stream_words(text)that yields one word at a time from a string, then loop over it and print each word. - Write a
@timerdecorator and apply it to a function that sleeps for 0.1 seconds, confirming it prints the elapsed time. - Write a context manager (using
@contextmanager) that prints"Connecting..."before and"Disconnected"after a block of code. - Define a
@dataclasscalledChatMessagewithrole: strandcontent: strfields, create an instance, and print it. - Write a
match/casefunction that handles three different dict shapes representing an agent’s next action:{"action": "search", "query": ...},{"action": "answer", "text": ...}, and any unrecognized shape.
Next: Module 11 — Working with APIs — HTTP fundamentals, and calling and parsing real LLM APIs from Python.
- Author
- TechByteByByte Editorial Team
- Reviewed by
- TechByteByByte Admin
- Published
- Last reviewed