TechByteByByte

Advanced Python

Learn advanced Python patterns used inside real AI frameworks, including generators, decorators, closures, context managers, type hints, dataclasses, enums, and pattern matching.

#Python#Generators#Decorators#Context Managers#Type Hints#AI#Python for AI

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

  • yield is like return, 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 yield and runs until it hits yield again (or ends).
  • A function containing yield becomes 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

  • @timer above def call_llm(...) is exactly equivalent to writing call_llm = timer(call_llm).
  • Calling call_llm(...) now actually calls wrapper(...), which runs your timing logic around a call to the real, original call_llm.
  • *args, **kwargs in wrapper (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, **kwargs in 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 yield runs when the with block starts.
  • Code after yield runs when the with block ends — even if an exception occurred inside it (with more advanced handling, which @contextmanager manages 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=0 just 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] becomes str | None
  • Union[int, float] becomes int | 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 score that’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:

FeatureStandard Dataclass (@dataclass)Pydantic Model (BaseModel)
SourceStandard Library (built-in)External Library (pip install pydantic)
Boilerplate reductionYesYes
Runtime Type ValidationNo (type hints are ignored at runtime)Yes (raises ValidationError on mismatch)
Easy JSON parsingManual (json.loads -> construct)Built-in (model_validate_json())
Field ConstraintsNoYes (e.g. Field(gt=0, lt=2.0))
Best forInternal data structuresAPI 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.wraps so 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() and model_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

  1. 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.
  2. Write a @timer decorator and apply it to a function that sleeps for 0.1 seconds, confirming it prints the elapsed time.
  3. Write a context manager (using @contextmanager) that prints "Connecting..." before and "Disconnected" after a block of code.
  4. Define a @dataclass called ChatMessage with role: str and content: str fields, create an instance, and print it.
  5. Write a match/case function 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