INT108 · Python Programming

Advanced Topics &
Professional Mastery

Unit VI

Graph Algorithms · Large-Scale Data · Deep Learning · LLM APIs

GraphQL · gRPC · Property Testing · System Design · Interviews

Course CodeINT108
Course TitlePython Programming
L : T : P3 : 0 : 2
Credits4
WeightageATT 5 · CA 50 · ETP 45
FocusEmployability · Skill Development
Unit VI — Integration & Extension

Table of Contents

IGraph Algorithms in Python3
IIAdvanced String Algorithms8
IIILarge-Scale Data — Dask, Polars, PySpark12
IVDeep Learning with PyTorch17
VLLM APIs & Generative AI22
VIGraphQL & gRPC27
VIIProperty-Based & Mutation Testing32
VIIISystem Design for Python Services36
IXAdvanced Patterns — CQRS, Event Sourcing, Saga41
XPython in the Cloud — Serverless, IaC46
XITechnical Interview Preparation50
XIICapstone — End-to-End Production System55
XIIISummary & Quick Reference Sheet59
XIVExam Tips & Practice Questions62
XVFull Solutions to Practice Questions65
XVIReferences, Key Takeaways & CO Mapping70
How to use these notes

Unit VI is a synthesis-extension unit: it consolidates Units I–V through integrated systems while introducing the advanced topics that appear in industry and placement interviews. Read §I–II for DSA mastery, §III–V for data/AI relevance, §VI–IX for modern software engineering, and §XI for interview preparation. Every example is tested on Python 3.11+.

I. Graph Algorithms in Python

1.1 Graph Representations

Definition

A graph \(G = (V, E)\) is a set of vertices \(V\) and edges \(E\) connecting them. Edges may be directed/undirected and weighted/unweighted.

RepresentationSpaceEdge checkIterate neighbours
Adjacency list\(O(V + E)\)\(O(\deg)\)\(O(\deg)\)
Adjacency matrix\(O(V^{2})\)\(O(1)\)\(O(V)\)
Edge list\(O(E)\)\(O(E)\)\(O(E)\)
# Adjacency list (most common in Python)
graph = {
    "A": ["B", "C"],
    "B": ["A", "D", "E"],
    "C": ["A", "F"],
    "D": ["B"],
    "E": ["B", "F"],
    "F": ["C", "E"],
}

# Weighted graph
weighted = {
    "A": [("B", 4), ("C", 2)],
    "B": [("A", 4), ("C", 5), ("D", 10)],
    "C": [("A", 2), ("B", 5), ("D", 3)],
    "D": [("B", 10), ("C", 3)],
}

1.2 Breadth-First Search (BFS)

Explores level by level using a queue. Finds shortest paths in unweighted graphs.

Complexity

Time \(O(V + E)\)  •  Space \(O(V)\)  •  Uses a FIFO queue

Example 1.1 — BFS with shortest-path reconstruction
from collections import deque

def bfs(graph, start):
    visited = {start}
    parent = {start: None}
    order = []
    q = deque([start])

    while q:
        node = q.popleft()
        order.append(node)
        for neighbour in graph[node]:
            if neighbour not in visited:
                visited.add(neighbour)
                parent[neighbour] = node
                q.append(neighbour)

    return order, parent

def shortest_path(parent, target):
    path = []
    while target is not None:
        path.append(target)
        target = parent[target]
    return path[::-1]

order, parent = bfs(graph, "A")
print("BFS order :", order)               # ['A', 'B', 'C', 'D', 'E', 'F']
print("A → F path:", shortest_path(parent, "F"))   # ['A', 'C', 'F']

1.3 Depth-First Search (DFS)

Explores as deep as possible before backtracking. Uses recursion or an explicit stack.

Example 1.2 — DFS iterative and recursive
def dfs_recursive(graph, node, visited=None):
    if visited is None:
        visited = set()
    visited.add(node)
    print(node, end=" ")
    for neighbour in graph[node]:
        if neighbour not in visited:
            dfs_recursive(graph, neighbour, visited)
    return visited

def dfs_iterative(graph, start):
    visited = set()
    stack = [start]
    order = []
    while stack:
        node = stack.pop()
        if node not in visited:
            visited.add(node)
            order.append(node)
            stack.extend(reversed(graph[node]))
    return order

print("Recursive:", end=" ")
dfs_recursive(graph, "A")           # A B D E F C
print()
print("Iterative:", dfs_iterative(graph, "A"))   # ['A', 'B', 'D', 'E', 'F', 'C']

1.4 Cycle Detection

def has_cycle_undirected(graph):
    visited = set()

    def dfs(node, parent):
        visited.add(node)
        for neighbour in graph[node]:
            if neighbour not in visited:
                if dfs(neighbour, node):
                    return True
            elif neighbour != parent:
                return True
        return False

    for node in graph:
        if node not in visited:
            if dfs(node, None):
                return True
    return False

def has_cycle_directed(graph):
    WHITE, GRAY, BLACK = 0, 1, 2
    color = {node: WHITE for node in graph}

    def dfs(node):
        color[node] = GRAY
        for neighbour in graph[node]:
            if color[neighbour] == GRAY:
                return True                 # back edge → cycle
            if color[neighbour] == WHITE and dfs(neighbour):
                return True
        color[node] = BLACK
        return False

    return any(color[n] == WHITE and dfs(n) for n in graph)

1.5 Topological Sort

Linear ordering of vertices such that for every edge \(u \to v\), \(u\) comes before \(v\). Only defined on DAGs (directed acyclic graphs).

Example 1.3 — Kahn's algorithm (BFS-based)
from collections import deque

def topo_sort(graph):
    indegree = {node: 0 for node in graph}
    for node in graph:
        for neighbour in graph[node]:
            indegree[neighbour] += 1

    q = deque([n for n, d in indegree.items() if d == 0])
    order = []

    while q:
        node = q.popleft()
        order.append(node)
        for neighbour in graph[node]:
            indegree[neighbour] -= 1
            if indegree[neighbour] == 0:
                q.append(neighbour)

    if len(order) != len(graph):
        raise ValueError("Graph has a cycle — no topological order")
    return order

dag = {
    "shirt": ["tie", "belt"],
    "tie": ["jacket"],
    "belt": ["jacket"],
    "jacket": [],
    "pants": ["belt", "shoes"],
    "shoes": [],
}
print(topo_sort(dag))
# ['shirt', 'pants', 'tie', 'belt', 'shoes', 'jacket']

1.6 Dijkstra's Shortest Path

Finds shortest paths from a source in a graph with non-negative weights.

Complexity (binary heap)

Time \(O((V + E)\log V)\)  •  Space \(O(V)\)

Example 1.4 — Dijkstra with heapq
import heapq

def dijkstra(graph, start):
    dist = {node: float("inf") for node in graph}
    dist[start] = 0
    parent = {start: None}
    heap = [(0, start)]

    while heap:
        d, node = heapq.heappop(heap)
        if d > dist[node]:
            continue                        # stale entry
        for neighbour, weight in graph[node]:
            new_dist = d + weight
            if new_dist < dist[neighbour]:
                dist[neighbour] = new_dist
                parent[neighbour] = node
                heapq.heappush(heap, (new_dist, neighbour))

    return dist, parent

d, p = dijkstra(weighted, "A")
for node in sorted(d):
    print(f"A → {node}: {d[node]}")

# A → A: 0
# A → B: 4
# A → C: 2
# A → D: 5   (via C)

1.7 Union–Find (Disjoint Set Union)

Efficiently tracks connectivity. Used in Kruskal's MST algorithm, cycle detection, and clustering.

class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank = [0] * n

    def find(self, x):
        while self.parent[x] != x:
            self.parent[x] = self.parent[self.parent[x]]  # path compression
            x = self.parent[x]
        return x

    def union(self, a, b):
        ra, rb = self.find(a), self.find(b)
        if ra == rb:
            return False
        if self.rank[ra] < self.rank[rb]:
            ra, rb = rb, ra
        self.parent[rb] = ra
        if self.rank[ra] == self.rank[rb]:
            self.rank[ra] += 1
        return True

    def connected(self, a, b):
        return self.find(a) == self.find(b)

uf = UnionFind(6)
uf.union(0, 1); uf.union(1, 2); uf.union(3, 4)
print(uf.connected(0, 2))   # True
print(uf.connected(0, 3))   # False

1.8 Minimum Spanning Tree (Kruskal)

def kruskal(n, edges):
    """edges = list of (weight, u, v)."""
    edges.sort()
    uf = UnionFind(n)
    mst = []
    total = 0
    for w, u, v in edges:
        if uf.union(u, v):
            mst.append((u, v, w))
            total += w
        if len(mst) == n - 1:
            break
    return mst, total

edges = [(1, 0, 1), (4, 0, 2), (2, 1, 2), (5, 1, 3),
         (3, 2, 3), (7, 2, 4), (6, 3, 4)]
mst, total = kruskal(5, edges)
print("MST:", mst)
print("Total weight:", total)      # 1 + 2 + 3 + 6 = 12

1.9 Algorithm Complexity Summary

AlgorithmTimeSpaceKey idea
BFS\(O(V+E)\)\(O(V)\)Queue, level order
DFS\(O(V+E)\)\(O(V)\)Stack, backtracking
Topological sort\(O(V+E)\)\(O(V)\)Kahn's or DFS finish times
Dijkstra\(O((V+E)\log V)\)\(O(V)\)Greedy + min-heap
Bellman–Ford\(O(VE)\)\(O(V)\)Handles negative weights
Floyd–Warshall\(O(V^3)\)\(O(V^2)\)All-pairs shortest paths
Kruskal MST\(O(E\log E)\)\(O(V)\)Union–Find + sorting
Prim MST\(O((V+E)\log V)\)\(O(V)\)Greedy growth from a node
Interview tip

90% of graph interview questions are BFS/DFS in disguise. State first whether the graph is weighted (Dijkstra) or unweighted (BFS), and whether a cycle is expected. Recognise "minimum steps" → BFS, "topological order" → Kahn's, "shortest path with weights" → Dijkstra.

II. Advanced String Algorithms

2.1 The Naive Pattern Search — \(O(nm)\)

def naive_search(text, pattern):
    n, m = len(text), len(pattern)
    matches = []
    for i in range(n - m + 1):
        if text[i:i + m] == pattern:
            matches.append(i)
    return matches

print(naive_search("ababcabcab", "abc"))    # [2, 5]

2.2 KMP (Knuth–Morris–Pratt) — \(O(n + m)\)

Preprocesses the pattern to build a failure function (LPS array) so that after a mismatch, the search resumes without re-checking characters.

Example 2.1 — KMP with failure function
def build_lps(pattern):
    """Longest proper prefix which is also a suffix, for each position."""
    lps = [0] * len(pattern)
    length = 0
    i = 1
    while i < len(pattern):
        if pattern[i] == pattern[length]:
            length += 1
            lps[i] = length
            i += 1
        elif length != 0:
            length = lps[length - 1]
        else:
            lps[i] = 0
            i += 1
    return lps

def kmp_search(text, pattern):
    if not pattern:
        return []
    lps = build_lps(pattern)
    matches = []
    i = j = 0
    while i < len(text):
        if text[i] == pattern[j]:
            i += 1; j += 1
            if j == len(pattern):
                matches.append(i - j)
                j = lps[j - 1]
        elif j != 0:
            j = lps[j - 1]
        else:
            i += 1
    return matches

print(build_lps("ABABCABAB"))     # [0,0,1,2,0,1,2,3,4]
print(kmp_search("ABABDABACDABABCABAB", "ABABCABAB"))   # [10]

2.3 Rabin–Karp — Rolling Hash

def rabin_karp(text, pattern, base=256, mod=10**9 + 7):
    n, m = len(text), len(pattern)
    if m > n:
        return []

    # Pre-compute base^(m-1) mod
    high = pow(base, m - 1, mod)
    ph = 0
    th = 0
    for i in range(m):
        ph = (ph * base + ord(pattern[i])) % mod
        th = (th * base + ord(text[i])) % mod

    matches = []
    for i in range(n - m + 1):
        if ph == th and text[i:i + m] == pattern:
            matches.append(i)
        if i < n - m:
            th = (th - ord(text[i]) * high) * base + ord(text[i + m])
            th %= mod
    return matches

print(rabin_karp("the quick brown fox", "quick"))   # [4]

2.4 Trie (Prefix Tree)

A tree where each node represents a prefix. Enables \(O(m)\) insert and search (where \(m\) is word length).

Example 2.2 — Trie with autocomplete
class TrieNode:
    __slots__ = ("children", "is_end")
    def __init__(self):
        self.children = {}
        self.is_end = False

class Trie:
    def __init__(self):
        self.root = TrieNode()

    def insert(self, word):
        node = self.root
        for ch in word:
            node = node.children.setdefault(ch, TrieNode())
        node.is_end = True

    def search(self, word):
        node = self._find(word)
        return node is not None and node.is_end

    def starts_with(self, prefix):
        return self._find(prefix) is not None

    def _find(self, s):
        node = self.root
        for ch in s:
            if ch not in node.children:
                return None
            node = node.children[ch]
        return node

    def autocomplete(self, prefix):
        node = self._find(prefix)
        if node is None:
            return []
        results = []
        self._collect(node, prefix, results)
        return results

    def _collect(self, node, prefix, out):
        if node.is_end:
            out.append(prefix)
        for ch, child in node.children.items():
            self._collect(child, prefix + ch, out)

t = Trie()
for w in ["cat", "car", "card", "care", "dog", "do"]:
    t.insert(w)

print(t.search("cat"))            # True
print(t.search("ca"))             # False
print(t.autocomplete("car"))      # ['car', 'card', 'care']
print(t.autocomplete("do"))       # ['do', 'dog']

2.5 Longest Palindromic Substring — Manacher's Algorithm

Naive \(O(n^3)\), DP \(O(n^2)\), Manacher's \(O(n)\). Manacher is rarely required in interviews but good to know.

def longest_palindrome(s):
    """DP O(n^2) approach — clean and interview-friendly."""
    n = len(s)
    if n < 2:
        return s
    dp = [[False] * n for _ in range(n)]
    start, max_len = 0, 1

    for i in range(n):
        dp[i][i] = True

    for i in range(n - 1):
        if s[i] == s[i + 1]:
            dp[i][i + 1] = True
            start, max_len = i, 2

    for length in range(3, n + 1):
        for i in range(n - length + 1):
            j = i + length - 1
            if s[i] == s[j] and dp[i + 1][j - 1]:
                dp[i][j] = True
                start, max_len = i, length

    return s[start:start + max_len]

print(longest_palindrome("babad"))    # 'bab' or 'aba'
print(longest_palindrome("cbbd"))     # 'bb'

2.6 Algorithm Comparison

AlgorithmPreprocessSearchUse when
Naive\(O(nm)\)Tiny inputs, simplest code
KMP\(O(m)\)\(O(n)\)Worst-case linear guarantee
Rabin–Karp\(O(m)\)\(O(n)\) avgMultiple pattern search; rolling hash
Boyer–Moore\(O(m + \sigma)\)\(O(n/m)\) bestLong patterns; practical speed
Trie\(O(\sum m_i)\)\(O(m)\)Prefix search / autocomplete
Aho–Corasick\(O(\sum m_i)\)\(O(n + \text{matches})\)Many patterns simultaneously

III. Large-Scale Data — Dask, Polars, PySpark

3.1 The Scaling Problem

When data exceeds available RAM, pandas fails. Three approaches scale Python data processing:

ToolScale up toModelBest for
Polars~100 GBSingle-machine, Rust, multi-threadedFast single-node analytics
Dask~TB on a clusterParallel pandas, lazyFamiliar pandas API at scale
PySparkPB on a clusterDistributed JVM + Python APIEnterprise big data
DuckDB~100 GBIn-process OLAP SQL engineSQL-style analytics on local files

3.2 Polars — Fast Single-Machine DataFrames

pip install polars

import polars as pl

# Lazy API — the key to efficiency
lf = (
    pl.scan_csv("sales.csv")          # lazy, doesn't load yet
    .filter(pl.col("amount") > 0)
    .group_by("region")
    .agg([
        pl.col("amount").sum().alias("total"),
        pl.col("amount").mean().alias("avg"),
        pl.len().alias("orders"),
    ])
    .sort("total", descending=True)
)

df = lf.collect()                     # executes the plan efficiently
print(df)
FeaturepandasPolars
BackendNumPy (single-thread)Rust (multi-thread)
Lazy evaluationNoYes (.lazy())
Query optimisationNoYes (predicate pushdown, projection)
Memory efficiencyModerateHigh (Arrow)
Typical speedup5–30×

3.3 Dask — Parallel pandas at Scale

pip install "dask[complete]"

import dask.dataframe as dd

# Dask reads CSV in chunks and builds a task graph
ddf = dd.read_csv("large_*.csv")

result = (
    ddf[ddf["amount"] > 0]
    .groupby("region")["amount"]
    .agg(["sum", "mean", "count"])
    .compute()                        # trigger execution
)
print(result)
When to choose Dask

You already have pandas code and it needs to scale beyond RAM. Dask mirrors the pandas API, so migration is often a one-line change (pd.read_csv → dd.read_csv).

3.4 PySpark — Distributed Big Data

pip install pyspark

from pyspark.sql import SparkSession
from pyspark.sql import functions as F

spark = (SparkSession.builder
         .appName("SalesAnalysis")
         .getOrCreate())

df = spark.read.csv("s3://bucket/sales/*.csv", header=True, inferSchema=True)

result = (
    df.filter(F.col("amount") > 0)
      .groupBy("region")
      .agg(
          F.sum("amount").alias("total"),
          F.avg("amount").alias("avg"),
          F.count("*").alias("orders"),
      )
      .orderBy(F.desc("total"))
)

result.show()
spark.stop()

3.5 Out-of-Core Processing with Generators

For simple cases, no framework is needed — process in chunks with a generator.

def read_in_chunks(filepath, chunk_size=100_000):
    with open(filepath) as f:
        chunk = []
        for line in f:
            chunk.append(line)
            if len(chunk) == chunk_size:
                yield chunk
                chunk = []
        if chunk:
            yield chunk

def count_errors(filepath):
    total = 0
    for chunk in read_in_chunks(filepath):
        total += sum(1 for line in chunk if "ERROR" in line)
    return total

# Memory usage stays constant regardless of file size.

3.6 DataFrame Libraries — Feature Comparison

FeaturepandasPolarsDaskPySpark
Single-machineYesYesYesNo (cluster)
DistributedNoNoYesYes
Lazy evalNoYesYesYes
API styleImperativeExpressionpandas-likeSQL-like
StreamingNoYesYesYes
Learning curveLowLowLowMedium
Exam tip

For scaling questions, state the trade-off clearly: Polars for speed on one machine, Dask for pandas code that outgrows RAM, PySpark when data lives in a Hadoop/S3 cluster. Mention lazy evaluation as the key optimisation mechanism.

IV. Deep Learning with PyTorch

4.1 Deep Learning vs Classical ML

AspectClassical ML (sklearn)Deep Learning (PyTorch)
FeaturesHand-engineeredLearned automatically
Data sizeSmall–mediumLarge
HardwareCPUGPU/TPU
InterpretabilityHighLow (black box)
Best forTabular dataImages, text, audio
pip install torch torchvision

4.2 Tensors — The Fundamental Data Structure

import torch

# Creation
a = torch.tensor([[1, 2, 3], [4, 5, 6]])
print(a.shape, a.dtype)              # torch.Size([2, 3]) torch.int64

# From NumPy
import numpy as np
b = torch.from_numpy(np.array([1.0, 2.0, 3.0]))
print(b.dtype)                       # torch.float32

# Zeros, ones, random
print(torch.zeros(2, 3))
print(torch.ones(2, 3))
print(torch.randn(2, 3))             # standard normal

# Operations (similar to NumPy)
x = torch.tensor([1.0, 2.0, 3.0])
y = torch.tensor([4.0, 5.0, 6.0])
print(x + y)                         # tensor([5., 7., 9.])
print(x * y)                         # element-wise
print(torch.dot(x, y))               # tensor(32.)

# GPU
device = "cuda" if torch.cuda.is_available() else "cpu"
x = x.to(device)

4.3 Autograd — Automatic Differentiation

import torch

x = torch.tensor(3.0, requires_grad=True)
y = x ** 2 + 2 * x + 1

y.backward()                         # computes dy/dx
print(x.grad)                        # tensor(8.)  →  2*3 + 2 = 8
Chain rule

If \(y = f(g(x))\), then \(\frac{dy}{dx} = f'(g(x)) \cdot g'(x)\). PyTorch records every operation and applies it in reverse (reverse-mode autodiff).

4.4 Building a Neural Network

Example 4.1 — Simple feedforward classifier
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset

# 1. Model
class Net(nn.Module):
    def __init__(self, in_features, hidden, out_classes):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(in_features, hidden),
            nn.ReLU(),
            nn.Linear(hidden, hidden),
            nn.ReLU(),
            nn.Linear(hidden, out_classes),
        )

    def forward(self, x):
        return self.net(x)

# 2. Data
X = torch.randn(1000, 20)
y = torch.randint(0, 3, (1000,))
loader = DataLoader(TensorDataset(X, y), batch_size=32, shuffle=True)

# 3. Training setup
device = "cuda" if torch.cuda.is_available() else "cpu"
model = Net(20, 64, 3).to(device)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=1e-3)

# 4. Training loop
for epoch in range(5):
    total_loss = 0.0
    for xb, yb in loader:
        xb, yb = xb.to(device), yb.to(device)
        optimizer.zero_grad()
        logits = model(xb)
        loss = criterion(logits, yb)
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
    print(f"Epoch {epoch + 1}: loss = {total_loss / len(loader):.4f}")

4.5 The Training Loop — Six Steps

Every PyTorch training iteration

1. Zero gradients → 2. Forward pass → 3. Compute loss → 4. loss.backward() → 5. optimizer.step() → 6. Log metrics.

4.6 Convolutional Neural Networks (CNN)

class ConvNet(nn.Module):
    def __init__(self, num_classes=10):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(3, 32, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.MaxPool2d(2),
            nn.Conv2d(32, 64, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.MaxPool2d(2),
        )
        self.classifier = nn.Sequential(
            nn.Flatten(),
            nn.Linear(64 * 8 * 8, 128),
            nn.ReLU(),
            nn.Dropout(0.3),
            nn.Linear(128, num_classes),
        )

    def forward(self, x):
        return self.classifier(self.features(x))

4.7 Saving and Loading Models

# Save state dict (recommended)
torch.save(model.state_dict(), "model.pt")

# Load
model = Net(20, 64, 3)
model.load_state_dict(torch.load("model.pt", map_location="cpu"))
model.eval()
Always call model.eval() for inference

It turns off dropout and batch-norm updates. Forgetting it is the #1 bug in PyTorch inference code. Use with torch.no_grad(): to skip gradient tracking and save memory.

Example 4.2 — Inference
model.eval()
with torch.no_grad():
    sample = torch.randn(1, 20)
    logits = model(sample)
    predicted_class = logits.argmax(dim=1).item()
    print("Predicted:", predicted_class)

4.8 Common Building Blocks

LayerPurpose
nn.LinearFully connected layer
nn.Conv2d2-D convolution (images)
nn.LSTM / nn.GRURecurrent layers (sequences)
nn.MultiheadAttentionTransformer attention
nn.ReLU, nn.GELU, nn.SigmoidActivation functions
nn.DropoutRegularisation
nn.BatchNorm2d, nn.LayerNormNormalisation

V. LLM APIs & Generative AI

5.1 What is an LLM?

Definition

A Large Language Model (LLM) is a neural network trained on massive text corpora to predict the next token. Modern LLMs follow a transformer architecture and can answer questions, generate code, summarise, translate and reason.

Model familyProviderTypical use
GPT-4 / GPT-4oOpenAIGeneral reasoning, coding, multimodal
ClaudeAnthropicLong-context, safe generation
GeminiGoogleMultimodal, search-augmented
LlamaMeta (open weights)Self-hosted, fine-tuning
Mistral / MixtralMistral AIEfficient open models

5.2 Basic LLM API Call (OpenAI-compatible)

pip install openai

import os
from openai import OpenAI

client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])

response = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[
        {"role": "system", "content": "You are a helpful Python tutor."},
        {"role": "user",   "content": "Explain list comprehensions briefly."},
    ],
    temperature=0.7,
    max_tokens=200,
)

print(response.choices[0].message.content)

5.3 Chat Roles and Message Format

RolePurpose
systemSets behaviour, tone, and constraints
userEnd-user input
assistantPrevious model responses (for multi-turn chat)
toolResults from tool/function calls

5.4 Streaming Responses

stream = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[{"role": "user", "content": "Write a haiku about Python."}],
    stream=True,
)

for chunk in stream:
    delta = chunk.choices[0].delta.content
    if delta:
        print(delta, end="", flush=True)
print()

5.5 Structured Output with Pydantic

Example 5.1 — Extract structured data from unstructured text
from pydantic import BaseModel
from openai import OpenAI

class Student(BaseModel):
    name: str
    roll: int
    cgpa: float

client = OpenAI()

text = "Aarav Kumar, roll number 101, has a CGPA of 8.7."

response = client.beta.chat.completions.parse(
    model="gpt-4o-mini",
    messages=[
        {"role": "system", "content": "Extract the student information."},
        {"role": "user",   "content": text},
    ],
    response_format=Student,
)

student = response.choices[0].message.parsed
print(student.name, student.roll, student.cgpa)    # Aarav Kumar 101 8.7

5.6 Function Calling (Tools)

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "Get current weather for a city",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {"type": "string"},
                    "unit": {"type": "string", "enum": ["c", "f"]},
                },
                "required": ["city"],
            },
        },
    }
]

response = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[{"role": "user", "content": "Weather in Delhi?"}],
    tools=tools,
)

tool_call = response.choices[0].message.tool_calls[0]
print(tool_call.function.name)              # get_weather
print(tool_call.function.arguments)         # {"city": "Delhi"}

# Call the actual function, then send the result back to the model.

5.7 Embeddings and Semantic Search

import numpy as np

def embed(texts):
    response = client.embeddings.create(
        model="text-embedding-3-small",
        input=texts,
    )
    return np.array([d.embedding for d in response.data])

docs = [
    "Python is a high-level programming language.",
    "Cats are common household pets.",
    "Machine learning is a subset of AI.",
]
doc_vecs = embed(docs)

query = "What is Python?"
q_vec = embed([query])[0]

# Cosine similarity
similarities = doc_vecs @ q_vec / (
    np.linalg.norm(doc_vecs, axis=1) * np.linalg.norm(q_vec)
)
best = int(np.argmax(similarities))
print("Best match:", docs[best])

5.8 Retrieval-Augmented Generation (RAG)

RAG pipeline

Documents → Chunks → Embeddings → Vector store → Retrieve top-k on query → Prompt LLM with context → Answer

def rag_answer(question, knowledge_base, k=3):
    # 1. Embed the question
    q_vec = embed([question])[0]

    # 2. Retrieve top-k chunks
    doc_vecs = embed(knowledge_base)
    sims = doc_vecs @ q_vec / (
        np.linalg.norm(doc_vecs, axis=1) * np.linalg.norm(q_vec)
    )
    top_k_idx = np.argsort(-sims)[:k]
    context = "\n\n".join(knowledge_base[i] for i in top_k_idx)

    # 3. Prompt the LLM with context
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system",
             "content": "Answer using ONLY the provided context."},
            {"role": "user",
             "content": f"Context:\n{context}\n\nQuestion: {question}"},
        ],
    )
    return response.choices[0].message.content

5.9 Prompt Engineering Basics

TechniqueDescription
System promptSets role and constraints up front
Few-shot examplesProvide input/output pairs to steer behaviour
Chain-of-thought"Think step by step" for reasoning tasks
Structured outputPydantic / JSON schema constraints
Retrieval (RAG)Inject factual context to reduce hallucination
GuardrailsValidate output against policy and schema
Cost and safety

VI. GraphQL & gRPC

6.1 REST, GraphQL and gRPC Compared

AspectRESTGraphQLgRPC
TransportHTTP/JSONHTTP/JSONHTTP/2 + Protobuf
SchemaOptional (OpenAPI)Strongly typed SDLProtobuf .proto
Over/under-fetchingCommonSolved (client asks for fields)No (typed messages)
CachingHTTP cache friendlyComplexManual
StreamingLimitedSubscriptionsBidirectional streams
Best forPublic APIs, CRUDComplex client-driven queriesInternal microservices, low-latency

6.2 GraphQL with Strawberry and FastAPI

pip install "strawberry-graphql[fastapi]" fastapi uvicorn

from typing import Optional
import strawberry
from strawberry.fastapi import GraphQLRouter
from fastapi import FastAPI

@strawberry.type
class Student:
    id: int
    name: str
    cgpa: float
    branch: str

DB = [
    Student(id=1, name="Aarav", cgpa=8.7, branch="CSE"),
    Student(id=2, name="Diya",  cgpa=9.3, branch="ECE"),
]

@strawberry.type
class Query:
    @strawberry.field
    def student(self, id: int) -> Optional[Student]:
        return next((s for s in DB if s.id == id), None)

    @strawberry.field
    def students(self, branch: Optional[str] = None) -> list[Student]:
        if branch:
            return [s for s in DB if s.branch == branch]
        return DB

@strawberry.type
class Mutation:
    @strawberry.mutation
    def add_student(self, name: str, cgpa: float, branch: str) -> Student:
        new = Student(id=len(DB) + 1, name=name, cgpa=cgpa, branch=branch)
        DB.append(new)
        return new

schema = strawberry.Schema(query=Query, mutation=Mutation)
graphql_app = GraphQLRouter(schema)

app = FastAPI()
app.include_router(graphql_app, prefix="/graphql")

# Run: uvicorn main:app --reload
# GraphiQL UI: http://localhost:8000/graphql

Sample GraphQL query

query {
  students(branch: "CSE") {
    id
    name
    cgpa
  }
}

Sample mutation

mutation {
  addStudent(name: "Kabir", cgpa: 7.9, branch: "MEC") {
    id
    name
  }
}

6.3 gRPC with Python

pip install grpcio grpcio-tools
// student.proto
syntax = "proto3";

package student;

service StudentService {
  rpc GetStudent (StudentRequest) returns (StudentReply);
  rpc ListStudents (Empty) returns (stream StudentReply);
}

message StudentRequest { int32 id = 1; }
message Empty {}

message StudentReply {
  int32 id = 1;
  string name = 2;
  float cgpa = 3;
}
# Generate Python stubs
python -m grpc_tools.protoc -I. \
    --python_out=. --grpc_python_out=. student.proto

# server.py
import grpc
from concurrent import futures
import student_pb2, student_pb2_grpc

class StudentServicer(student_pb2_grpc.StudentServiceServicer):
    def GetStudent(self, request, context):
        return student_pb2.StudentReply(id=request.id, name="Aarav", cgpa=8.7)

    def ListStudents(self, request, context):
        for name in ["Aarav", "Diya", "Kabir"]:
            yield student_pb2.StudentReply(id=0, name=name, cgpa=8.0)

server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
student_pb2_grpc.add_StudentServiceServicer_to_server(StudentServicer(), server)
server.add_insecure_port("[::]:50051")
server.start()
server.wait_for_termination()
# client.py
import grpc
import student_pb2, student_pb2_grpc

with grpc.insecure_channel("localhost:50051") as channel:
    stub = student_pb2_grpc.StudentServiceStub(channel)
    reply = stub.GetStudent(student_pb2.StudentRequest(id=1))
    print(reply.name, reply.cgpa)         # Aarav 8.7

    for s in stub.ListStudents(student_pb2.Empty()):
        print("Streamed:", s.name)

6.4 When to Choose What

ScenarioChoose
Public API, browser clients, cache-heavyREST
Mobile/SPA with varied data needsGraphQL
Internal microservices, low latencygRPC
Real-time bidirectional streamsgRPC or WebSockets
Rapid prototypingREST + FastAPI
Multiple backend services aggregating dataGraphQL gateway
Exam tip

Never claim one is "better". Say: REST for public/simple, GraphQL when clients need flexibility, gRPC for internal high-throughput typed services. This shows architectural maturity.

VII. Property-Based & Mutation Testing

7.1 The Limits of Example-Based Testing

Problem

Traditional tests check specific inputs. But real bugs hide in edge cases you did not think of. Property-based testing generates hundreds of random inputs and checks an invariant that must always hold.

7.2 Hypothesis — Property-Based Testing

pip install hypothesis
Example 7.1 — Testing invariants with Hypothesis
from hypothesis import given, strategies as st

def sort_list(xs):
    return sorted(xs)

@given(st.lists(st.integers()))
def test_sort_preserves_length(xs):
    assert len(sort_list(xs)) == len(xs)

@given(st.lists(st.integers()))
def test_sort_is_ordered(xs):
    result = sort_list(xs)
    for i in range(len(result) - 1):
        assert result[i] <= result[i + 1]

@given(st.lists(st.integers()))
def test_sort_preserves_multiset(xs):
    from collections import Counter
    assert Counter(sort_list(xs)) == Counter(xs)

@given(st.lists(st.integers()), st.lists(st.integers()))
def test_sort_idempotent(xs, ys):
    combined = xs + ys
    assert sort_list(combined) == sort_list(sort_list(combined))

Hypothesis automatically finds the smallest failing example (shrink) and reports it with a reproducible seed.

7.3 Strategies — Generating Data

StrategyGenerates
st.integers(min_value, max_value)Integers in a range
st.floats(allow_nan=False)Floating-point numbers
st.text(min_size=1)Unicode strings
st.lists(elements, max_size=10)Lists
st.dictionaries(keys, values)Dicts
st.tuples(a, b, c)Tuples of strategies
st.one_of(s1, s2)Pick one of the strategies
st.sampled_from([...])Random choice from a list
st.builds(MyClass, x=..., y=...)Build objects

7.4 Composite Strategies

from hypothesis import strategies as st
from hypothesis import given

@st.composite
def student(draw):
    return {
        "name":   draw(st.text(min_size=1, max_size=20)),
        "age":    draw(st.integers(min_value=15, max_value=100)),
        "cgpa":   draw(st.floats(min_value=0, max_value=10)),
    }

@given(student())
def test_student_has_valid_fields(s):
    assert 1 <= len(s["name"]) <= 20
    assert 15 <= s["age"] <= 100
    assert 0 <= s["cgpa"] <= 10

7.5 Mutation Testing — Testing the Tests

Definition

Mutation testing modifies ("mutates") the source code — flipping conditions, swapping operators, deleting lines — and checks whether the test suite catches each change. If tests still pass, they are insufficient.

pip install mutmut

mutmut run --paths-to-mutate=src/
mutmut results
mutmut show 3

Common mutation types

OriginalMutated
a < ba <= b
a + ba - b
return Truereturn False
if x:if not x:
Statement deleted

7.6 Fuzz Testing

pip install atheris

# Atheris is Google's coverage-guided fuzzer for Python.
# import atheris
# import sys
#
# def TestOneInput(data):
#     fdp = atheris.FuzzedDataProvider(data)
#     text = fdp.ConsumeUnicodeNoSurrogates(100)
#     # Call code that should not crash on any input.
#     try:
#         import json
#         json.loads(text)
#     except json.JSONDecodeError:
#         pass
#
# atheris.Setup(sys.argv, TestOneInput)
# atheris.Fuzz()

7.7 Testing Strategy — The Pyramid

LayerProportionExample
Unit70%test_sort()
Integration20%test_create_student_and_read_back()
End-to-End10%test_full_checkout_flow()
Property / FuzzSupplements allHypothesis, Atheris
Exam tip

Name at least three properties to test: invariants (always true), idempotence (f(f(x)) = f(x)), roundtrip (decode(encode(x)) = x), commutativity, associativity. Show one Hypothesis example and one mutation testing command.

VIII. System Design for Python Services

8.1 The System Design Interview

Structure (45 minutes)

1. Clarify requirements (functional + non-functional) — 5 min
2. Estimate scale (QPS, storage, bandwidth) — 5 min
3. High-level design (boxes and arrows) — 10 min
4. Deep dive into 1–2 components — 15 min
5. Bottlenecks, trade-offs, failure modes — 10 min

8.2 Back-of-the-Envelope Numbers

OperationLatency (order of magnitude)
L1 cache read1 ns
Main memory read100 ns
SSD random read100 μs
Disk seek10 ms
Same-datacenter round-trip0.5 ms
Cross-continent round-trip150 ms
ComponentTypical capacity
Single Python server (FastAPI)~1k–10k req/s
PostgreSQL (single node)~10k read QPS
Redis~100k ops/s
Kafka partition~10 MB/s
S3 object~5 TB max, 5 GB single PUT

8.3 Standard Building Blocks

ComponentRolePython tech
Load balancerDistribute trafficNginx, HAProxy, ALB
API gatewayAuth, routing, rate limitsKong, FastAPI gateway
Application serverBusiness logicFastAPI + Uvicorn
Relational DBACID transactional storagePostgreSQL, MySQL
NoSQL DBFlexible schema, high write throughputMongoDB, DynamoDB
CacheSub-ms reads, session storageRedis, Memcached
Message queueDecouple producers from consumersKafka, RabbitMQ, SQS
Search engineFull-text searchElasticsearch, Meilisearch
Object storageFiles, images, backupsS3, GCS, MinIO
CDNStatic content, edge cachingCloudFront, Cloudflare

8.4 Caching Strategies

StrategyBehaviourTrade-off
Cache-aside (lazy)Read from cache; on miss, read DB and populateSimple; stale on write
Write-throughWrite cache and DB togetherConsistent; slower writes
Write-behindWrite cache; flush to DB asyncFast writes; data-loss risk
Read-throughCache fetches from DB on missTransparent to app
import redis, json, hashlib

cache = redis.Redis(host="localhost", port=6379, decode_responses=True)

def get_student(db, student_id):
    key = f"student:{student_id}"
    cached = cache.get(key)
    if cached:
        return json.loads(cached)          # cache hit

    student = db.get(student_id)           # cache miss
    if student:
        cache.setex(key, 300, json.dumps(student))   # 5-min TTL
    return student
Cache invalidation

Cache invalidation is one of the two hard problems in CS. Strategies: TTL, write-through invalidation, versioned keys, or event-driven invalidation via a message queue.

8.5 Database Patterns

PatternDescription
IndexingB-tree indexes on frequently queried columns
ShardingSplit data across nodes by key (user_id, region)
ReplicationPrimary for writes, replicas for reads
Read replicasScale read throughput horizontally
PartitioningSplit large tables by date or range
Connection poolingReuse DB connections (SQLAlchemy pool, pgbouncer)
Materialised viewsPrecompute expensive joins
# SQLAlchemy connection pool
from sqlalchemy import create_engine

engine = create_engine(
    "postgresql://user:pass@host/db",
    pool_size=20,           # persistent connections
    max_overflow=10,        # extra connections under load
    pool_pre_ping=True,     # verify connection is alive before use
    pool_recycle=1800,      # recycle connections after 30 min
)

8.6 Resilience Patterns

PatternPurpose
Retry with backoffHandle transient failures
Circuit breakerStop calling a failing dependency
BulkheadIsolate resource pools per dependency
TimeoutNever wait forever
Idempotency keysSafe retries of write operations
Graceful degradationServe stale data / partial results
Example 8.1 — Circuit breaker with pybreaker
pip install pybreaker

import pybreaker
import requests

breaker = pybreaker.CircuitBreaker(fail_max=5, reset_timeout=30)

@breaker
def call_remote_api():
    r = requests.get("https://api.example.com/data", timeout=5)
    r.raise_for_status()
    return r.json()

# After 5 failures, the circuit opens for 30 seconds and calls fail fast.

8.7 Worked Design — URL Shortener

Example 8.2 — Design a URL shortener

Requirements: shorten URL, redirect on GET, 100M URLs, 10k QPS reads.

Design:

import string, hashlib

ALPHABET = string.digits + string.ascii_letters     # 62 chars

def encode_base62(num: int) -> str:
    if num == 0:
        return ALPHABET[0]
    out = []
    while num > 0:
        num, rem = divmod(num, 62)
        out.append(ALPHABET[rem])
    return "".join(reversed(out))

def shorten(long_url: str) -> str:
    digest = hashlib.sha256(long_url.encode()).hexdigest()
    numeric = int(digest[:16], 16)             # 64-bit slice
    return encode_base62(numeric)[:7]

print(shorten("https://example.com/very/long/path"))
# e.g., 'jK9aBc2'

IX. Advanced Patterns — CQRS, Event Sourcing, Saga

9.1 CQRS — Command Query Responsibility Segregation

Definition

CQRS separates writes (commands that change state) from reads (queries that return data). Each side can be optimised independently.

SideModelTypical storage
CommandWrite-optimised, normalisedPostgreSQL / event log
QueryRead-optimised, denormalisedElasticsearch / Redis / materialised views
class CommandHandler:
    """Handles writes: validates and persists."""
    def __init__(self, db, event_bus):
        self.db = db
        self.event_bus = event_bus

    def create_task(self, task_id, title, owner):
        self.db.execute(
            "INSERT INTO tasks (id, title, owner) VALUES (?, ?, ?)",
            (task_id, title, owner))
        self.event_bus.publish("task.created",
                               {"id": task_id, "title": title, "owner": owner})

class QueryHandler:
    """Handles reads: uses a denormalised projection."""
    def __init__(self, read_db):
        self.read_db = read_db

    def list_tasks(self, owner):
        return self.read_db.execute(
            "SELECT id, title FROM task_view WHERE owner = ?", (owner,)).fetchall()

9.2 Event Sourcing

Definition

Instead of storing only the current state, event sourcing stores every state-changing event. Current state is derived by replaying events. Full audit log and time-travel debugging come for free.

from dataclasses import dataclass, field
from typing import List
from datetime import datetime

@dataclass
class Event:
    type: str
    data: dict
    ts: datetime = field(default_factory=datetime.utcnow)

class BankAccount:
    def __init__(self, account_id):
        self.id = account_id
        self.balance = 0
        self.events: List[Event] = []

    def _apply(self, event: Event):
        if event.type == "deposited":
            self.balance += event.data["amount"]
        elif event.type == "withdrawn":
            self.balance -= event.data["amount"]

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit must be positive")
        ev = Event("deposited", {"amount": amount})
        self.events.append(ev)
        self._apply(ev)

    def withdraw(self, amount):
        if amount > self.balance:
            raise ValueError("Insufficient funds")
        ev = Event("withdrawn", {"amount": amount})
        self.events.append(ev)
        self._apply(ev)

    @classmethod
    def replay(cls, account_id, events):
        acc = cls(account_id)
        for ev in events:
            acc._apply(ev)
        acc.events = events
        return acc

acc = BankAccount("A1")
acc.deposit(1000)
acc.withdraw(300)
print(acc.balance)               # 700

# Rebuild the same state from the event log
rebuilt = BankAccount.replay("A1", acc.events)
print(rebuilt.balance)           # 700

9.3 Saga Pattern — Distributed Transactions

Long-running business processes that span multiple services must handle partial failure. A saga is a sequence of local transactions, each with a compensating action for rollback.

StyleCoordinationTrade-off
ChoreographyServices react to each other's eventsSimple, but hard to trace
OrchestrationA central coordinator drives stepsEasier to reason about, single point of failure
Example 9.1 — Orchestrated saga for order placement
class OrderSaga:
    def __init__(self, payment, inventory, shipping):
        self.payment = payment
        self.inventory = inventory
        self.shipping = shipping
        self.compensations = []

    def run(self, order):
        try:
            self.payment.charge(order)
            self.compensations.append(lambda: self.payment.refund(order))

            self.inventory.reserve(order)
            self.compensations.append(lambda: self.inventory.release(order))

            self.shipping.schedule(order)
            return "success"

        except Exception as e:
            print(f"Saga failed: {e}. Rolling back...")
            for undo in reversed(self.compensations):
                try:
                    undo()
                except Exception as undo_err:
                    print(f"Compensation failed: {undo_err}")
            return "rolled_back"

9.4 Outbox Pattern — Reliable Event Publishing

-- Write the domain change and the event in ONE transaction.
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  INSERT INTO outbox (event_type, payload, status)
       VALUES ('account.debited', '{"account_id": 1, "amount": 100}', 'pending');
COMMIT;

-- A separate worker publishes outbox rows to Kafka/RabbitMQ and marks them 'sent'.

Problem solved: without the outbox, a crash between DB write and message publish causes an event to be lost.

9.5 Pattern Selection Guide

NeedPattern
Audit log, replay, time travelEvent sourcing
Independent read/write scalingCQRS
Cross-service business transactionSaga
Reliable event publishing from a DB transactionOutbox
Reacting to state changes in another serviceEvent-driven architecture
Idempotent processingIdempotency keys + dedup store
Complexity warning

These patterns add significant complexity. Use them only when the problem demands it (high audit requirements, multi-service transactions). For a small CRUD API, plain REST + a relational DB is the right answer.

X. Python in the Cloud — Serverless, IaC

10.1 Compute Models in the Cloud

ModelYou manageExample
Bare metalEverythingOn-prem server
IaaSOS and aboveAWS EC2, GCP Compute Engine
PaaSApplication onlyAWS Elastic Beanstalk, Heroku, App Engine
CaaS (containers)Container imageECS, EKS, Cloud Run, AKS
FaaS (serverless)Function code onlyAWS Lambda, Azure Functions, Cloud Functions

10.2 AWS Lambda with Python

# handler.py
import json

def lambda_handler(event, context):
    """Entry point for AWS Lambda."""
    name = event.get("queryStringParameters", {}).get("name", "World")
    return {
        "statusCode": 200,
        "headers": {"Content-Type": "application/json"},
        "body": json.dumps({"message": f"Hello, {name}!"}),
    }
# Deployment with the AWS SAM CLI
# 1. Install SAM CLI, then initialise the project:
sam init --runtime python3.12

# 2. Build and deploy
sam build
sam deploy --guided

10.3 Infrastructure as Code with Terraform

# main.tf
provider "aws" { region = "ap-south-1" }

resource "aws_lambda_function" "hello" {
  function_name = "hello-python"
  role          = aws_iam_role.lambda_role.arn
  handler       = "handler.lambda_handler"
  runtime       = "python3.12"
  filename      = "package.zip"
  timeout       = 30
  memory_size   = 256
}

resource "aws_iam_role" "lambda_role" {
  name = "lambda-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action    = "sts:AssumeRole"
      Effect    = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
    }]
  })
}
terraform init
terraform plan
terraform apply

10.4 Serverless Framework

# serverless.yml
service: task-api
provider:
  name: aws
  runtime: python3.12
  region: ap-south-1
  environment:
    DATABASE_URL: ${env:DATABASE_URL}

functions:
  api:
    handler: handler.lambda_handler
    events:
      - httpApi:
          path: /{proxy+}
          method: ANY
serverless deploy
serverless logs -f api --tail

10.5 Cold Starts and Optimisation

Cause of slow cold startMitigation
Large dependency tree (pandas, torch)Move heavy imports inside handlers, or use Lambda layers
Large deployment packageTrim dependencies, use zip with tree-shaking
VPC-attached LambdasUse VPC endpoints or move to non-VPC when possible
Under-provisioned memoryIncrease memory (also increases CPU)
JVM / runtime initUse provisioned concurrency for latency-critical paths
# Lazy imports to reduce cold-start time
import json

def lambda_handler(event, context):
    # Only import heavy libraries when actually needed
    import pandas as pd
    df = pd.DataFrame({"a": [1, 2, 3]})
    return {"statusCode": 200, "body": json.dumps({"sum": int(df["a"].sum())})}

10.6 Cost Optimisation

LeverEffect
Right-size memoryLower per-invocation cost
Reduce cold startsProvisioned concurrency but costlier
Use ARM (Graviton)~20% cheaper, often faster
Reserved / Savings PlansUp to 70% savings on predictable workloads
Spot instancesUp to 90% savings for fault-tolerant work
Object storage lifecycleMove old S3 data to Glacier

10.7 Observability in the Cloud

# Structured logging for CloudWatch
import json, logging

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def lambda_handler(event, context):
    logger.info(json.dumps({
        "request_id": context.aws_request_id,
        "path":       event.get("path"),
        "method":     event.get("httpMethod"),
    }))
    return {"statusCode": 200, "body": "ok"}

AWS X-Ray, CloudWatch Logs Insights, and Datadog's Lambda extension provide tracing and metrics for serverless functions.

10.8 Choosing a Deployment Model

WorkloadRecommended
Steady web APIContainers (ECS, Cloud Run)
Event-driven, sporadicLambda
Long-running jobs (>15 min)ECS/Fargate tasks or batch jobs
ML inference with GPUGPU instances or SageMaker endpoints
Scheduled jobsLambda + EventBridge, or CronJob on K8s
Data pipelinesStep Functions, Airflow, Dagster
Exam tip

For cloud questions, mention: 12-factor config, IaC (Terraform/SAM), cold-start mitigation, structured logging, and cost levers. Name at least one AWS service per compute model (EC2 = IaaS, ECS = CaaS, Lambda = FaaS).

XI. Technical Interview Preparation

11.1 The Interview Loop

RoundFocusTypical duration
Online assessment2–3 DSA problems60–90 min
Technical phone screen1 DSA problem + Python questions45 min
Coding (onsite 1)DSA + problem-solving discussion60 min
Coding (onsite 2)Design or advanced Python60 min
System designArchitecture of a real system60 min
BehaviouralSTAR stories on collaboration45 min
Hiring managerFit, motivation, growth30 min

11.2 The DSA Problem-Solving Framework

UMPIRE

Understand → Match (patterns) → Plan → Implement → Review → Evaluate (complexity)

Problem cluePattern to reach for
Sorted array + searchBinary search
Contiguous subarray / substringSliding window
Two sorted arraysTwo pointers
Duplicates / missing numberHash set / XOR
Top kHeap / QuickSelect
All combinations / permutationsBacktracking
Optimal value with choicesDynamic programming
Graph traversalBFS / DFS
Shortest path (weighted)Dijkstra / Bellman–Ford
Dependencies / orderingTopological sort
Prefix search / autocompleteTrie
Connectivity / cyclesUnion–Find

11.3 Python-Specific Interview Questions

QuestionKey points
List vs tupleMutable vs immutable; tuple as dict key; memory/speed
Shallow vs deep copyInner objects shared vs independent; copy.deepcopy
is vs ==Identity vs value equality; interning of small ints/strings
GILOne bytecode thread at a time; I/O releases GIL; use processes for CPU
DecoratorsWrapper pattern; @wraps; three-level with args
GeneratorsLazy evaluation; yield; memory savings
ClosuresInner function remembers enclosing scope; late-binding trap
Context managers__enter__/__exit__; @contextmanager
MROC3 linearisation; super(); diamond inheritance
MetaclassesClass-of-class; used in Django ORM, Pydantic
__slots__Memory optimisation; removes per-instance dict
Exception hierarchyBaseExceptionExceptionValueError etc.
Mutable default argumentsTrap; use None and initialise inside
Python memory modelRefcount + generational GC; weakref
Async vs threadsEvent loop vs OS threads; async for I/O, threads for mixed
Example 11.1 — Classic mutable default argument trap
# BROKEN
def append_to(value, target=[]):
    target.append(value)
    return target

print(append_to(1))         # [1]
print(append_to(2))         # [1, 2]  ← surprise!

# FIXED
def append_to(value, target=None):
    if target is None:
        target = []
    target.append(value)
    return target

print(append_to(1))         # [1]
print(append_to(2))         # [2]

11.4 Behavioural Questions — STAR

LetterMeaning
SituationContext — where, when, who
TaskYour specific responsibility
ActionWhat you did and why
ResultMeasurable outcome

Common prompts

11.5 Coding Round Checklist

StepWhat to do
RestateSay the problem in your own words
ClarifyEdge cases, input size, return type
ExamplesWrite at least two test cases by hand
Brute forceState a simple solution and its complexity
OptimiseIdentify the pattern; propose a better algorithm
CodeWrite clean code with good names; comment the tricky bits
TestTrace through the examples; check edges
ComplexityState time and space explicitly

11.6 Recommended Practice Plan

WeekFocusProblems
1Arrays, strings, hashing20
2Two pointers, sliding window15
3Stacks, queues, heaps15
4Recursion, backtracking15
5Trees, BST, tries20
6Graphs (BFS, DFS, Dijkstra)20
7Dynamic programming20
8Mock interviews + system design5 + 3
Communication wins interviews

Even if you cannot finish the code, narrate your thinking. Interviewers hire for clarity, not perfection. State your plan, test your code aloud, and admit uncertainty explicitly — that is a strength, not a weakness.

XII. Capstone — End-to-End Production System

A complete reference project tying together every concept in Units I–VI. This is what a "professional Python engineer" ships.

12.1 System Overview

Project — Intelligent Document Q&A Service

A production FastAPI service that (1) ingests documents, (2) stores embeddings, (3) answers user questions via an LLM using RAG, and (4) logs every request for observability. Deployed with Docker to AWS ECS.

12.2 Architecture

Client → ALB → FastAPI (ECS) ─┬─ Postgres (metadata)
                              ├─ Redis (cache)
                              ├─ Qdrant / pgvector (embeddings)
                              └─ OpenAI API (LLM)

Background workers:
- Ingest worker  (queue-driven) → chunk + embed + upsert
- Cleanup worker (scheduled)     → prune old embeddings

Observability:
- Prometheus /metrics → Grafana
- Structured JSON logs → CloudWatch
- OpenTelemetry → Jaeger

12.3 Project Structure

doc-qa/
├── pyproject.toml
├── Dockerfile
├── docker-compose.yml
├── .env.example
├── src/
│   └── docqa/
│       ├── __init__.py
│       ├── main.py             # FastAPI app
│       ├── config.py           # Settings (pydantic-settings)
│       ├── database.py         # SQLAlchemy engine + session
│       ├── models.py           # ORM models
│       ├── schemas.py          # Pydantic
│       ├── rag.py              # Chunk + embed + retrieve + answer
│       ├── cache.py            # Redis wrapper
│       ├── metrics.py          # Prometheus instrumentation
│       ├── logging_config.py   # JSON logs
│       ├── workers/
│       │   ├── ingest.py
│       │   └── cleanup.py
│       └── auth.py             # JWT
└── tests/
    ├── conftest.py
    ├── test_api.py
    ├── test_rag.py
    └── test_auth.py

12.4 Configuration

# src/docqa/config.py
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url:   str = "postgresql://user:pass@db:5432/docqa"
    redis_url:      str = "redis://cache:6379/0"
    openai_api_key: str
    jwt_secret:     str
    jwt_expire_min: int = 60
    chunk_size:     int = 500
    chunk_overlap:  int = 80
    top_k:          int = 4
    log_level:      str = "INFO"

    class Config:
        env_file = ".env"

settings = Settings()

12.5 RAG Module

# src/docqa/rag.py
import numpy as np
from openai import OpenAI
from .config import settings

client = OpenAI(api_key=settings.openai_api_key)

def chunk(text, size=500, overlap=80):
    words = text.split()
    step  = size - overlap
    return [
        " ".join(words[i:i + size])
        for i in range(0, len(words), step)
        if words[i:i + size]
    ]

def embed(texts):
    resp = client.embeddings.create(
        model="text-embedding-3-small", input=texts)
    return np.array([d.embedding for d in resp.data])

def answer(question, documents):
    q_vec = embed([question])[0]
    d_vec = embed(documents)
    sims = d_vec @ q_vec / (
        np.linalg.norm(d_vec, axis=1) * np.linalg.norm(q_vec))
    top = np.argsort(-sims)[: settings.top_k]
    context = "\n\n".join(documents[i] for i in top)

    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system",
             "content": "Answer using ONLY the provided context."},
            {"role": "user",
             "content": f"Context:\n{context}\n\nQuestion: {question}"},
        ],
    )
    return resp.choices[0].message.content, list(top)

12.6 API Layer

# src/docqa/main.py
from fastapi import FastAPI, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from .config import settings
from .database import get_db, Base, engine
from .schemas import (
    IngestRequest, IngestResponse, QueryRequest, QueryResponse)
from .rag import chunk, embed, answer
from .metrics import REQUEST_COUNT, REQUEST_LATENCY
from .logging_config import logger, configure_logging
from .auth import current_user

Base.metadata.create_all(engine)
configure_logging()

app = FastAPI(title="Doc QA", version="1.0.0")

# In-memory doc store (in production: pgvector)
DOCS: dict[str, list[str]] = {}

@app.middleware("http")
async def metrics_middleware(request: Request, call_next):
    REQUEST_COUNT.labels(request.method, request.url.path).inc()
    with REQUEST_LATENCY.labels(request.url.path).time():
        response = await call_next(request)
    logger.info("http.request",
                extra={"method": request.method, "path": request.url.path,
                       "status": response.status_code})
    return response

@app.get("/health")
def health():
    return {"status": "ok"}

@app.post("/ingest", response_model=IngestResponse)
def ingest(payload: IngestRequest,
           user: str = Depends(current_user)):
    chunks = chunk(payload.text, settings.chunk_size, settings.chunk_overlap)
    DOCS[payload.doc_id] = chunks
    embed(chunks)         # persist embeddings in production
    return {"doc_id": payload.doc_id, "chunks": len(chunks)}

@app.post("/query", response_model=QueryResponse)
def query(payload: QueryRequest,
          user: str = Depends(current_user)):
    chunks = DOCS.get(payload.doc_id)
    if not chunks:
        raise HTTPException(404, "Document not found")
    text, indices = answer(payload.question, chunks)
    return {"answer": text, "source_chunks": indices}

12.7 Tests

# tests/test_api.py
from fastapi.testclient import TestClient
from unittest.mock import patch
from src.docqa.main import app

client = TestClient(app)

def auth_header(token="valid"):
    return {"Authorization": f"Bearer {token}"}

@patch("src.docqa.auth.current_user", return_value="test-user")
@patch("src.docqa.rag.embed")
def test_ingest(mock_embed, mock_user):
    mock_embed.return_value = [[0.0] * 8]
    r = client.post("/ingest",
                    json={"doc_id": "d1", "text": "Python is great. " * 20},
                    headers=auth_header())
    assert r.status_code == 200
    assert r.json()["doc_id"] == "d1"

@patch("src.docqa.auth.current_user", return_value="test-user")
@patch("src.docqa.rag.answer", return_value=("Python is great.", [0]))
def test_query(mock_answer, mock_user):
    r = client.post("/query",
                    json={"doc_id": "d1", "question": "What is Python?"},
                    headers=auth_header())
    assert r.status_code in (200, 404)

12.8 Docker Compose

version: "3.9"
services:
  api:
    build: .
    ports: ["8000:8000"]
    environment:
      DATABASE_URL: postgresql://user:pass@db:5432/docqa
      REDIS_URL: redis://cache:6379/0
      OPENAI_API_KEY: ${OPENAI_API_KEY}
      JWT_SECRET: ${JWT_SECRET}
    depends_on: [db, cache]
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: docqa
    volumes: [pgdata:/var/lib/postgresql/data]
  cache:
    image: redis:7-alpine
volumes:
  pgdata:

12.9 Production Checklist

AreaItem
CodeType hints, docstrings, ruff + mypy clean
ConfigEnvironment-based; secrets in a vault
DataMigrations (Alembic), backups, indexes
CacheRedis for hot queries; TTLs set
LLMPrompt-injection guards; token limits; retries
TestingUnit + integration + property tests, ≥80% coverage
ObservabilityMetrics, logs, traces, alerts
SecurityJWT, rate limits, input validation, dependency audit
DeployDocker image, CI/CD pipeline, healthchecks
ResilienceRetries, timeouts, circuit breakers
CostRight-sized instances, cache hit rate monitoring

XIII. Summary & Quick Reference Sheet

13.1 Graph Algorithms Cheat Sheet

AlgorithmTimeUse for
BFS\(O(V+E)\)Unweighted shortest path, level order
DFS\(O(V+E)\)Cycle detection, topological sort, components
Kahn's topo sort\(O(V+E)\)Task ordering, build systems
Dijkstra\(O((V+E)\log V)\)Weighted shortest path (non-negative)
Bellman–Ford\(O(VE)\)Negative weights, arbitrage detection
Floyd–Warshall\(O(V^{3})\)All-pairs shortest path
Kruskal MST\(O(E\log E)\)Minimum spanning tree
Union–Find~\(O(1)\) amortisedConnectivity queries

13.2 String Algorithms Cheat Sheet

AlgorithmComplexityKey idea
KMP\(O(n+m)\)Failure function (LPS)
Rabin–Karp\(O(n)\) avgRolling hash
Trie\(O(m)\) per opPrefix tree
Manacher\(O(n)\)Longest palindromic substring
Aho–Corasick\(O(n + \text{matches})\)Multi-pattern matching

13.3 Data Scale Cheat Sheet

Data sizeRecommended
Up to RAM (≤ 10 GB)pandas / Polars
Beyond RAM on one machinePolars streaming, DuckDB, Dask
Cluster-scale (TB–PB)PySpark
Simple large filesChunked generator processing

13.4 Deep Learning Cheat Sheet

ConceptPyTorch API
Tensor creationtorch.tensor, torch.randn, torch.zeros
Autogradrequires_grad=True, loss.backward()
Modelclass Net(nn.Module) with forward
Training loopzero_grad → forward → loss → backward → step
Inferencemodel.eval() + torch.no_grad()
Save / loadtorch.save(model.state_dict(), p)
Device.to("cuda")

13.5 API Paradigm Cheat Sheet

FeatureRESTGraphQLgRPC
TransportHTTP/JSONHTTP/JSONHTTP/2 + Protobuf
SchemaOptionalSDL.proto
StreamingLimitedSubscriptionsBidirectional
Best forPublic APIsClient-driven queriesMicroservices

13.6 System Design Cheat Sheet

NeedPattern
Scale readsCache (Redis) + read replicas
Scale writesSharding, partitioning
Decouple servicesMessage queue (Kafka, SQS)
Handle failureRetries with backoff, circuit breaker
Audit historyEvent sourcing
Cross-service transactionsSaga + outbox
Independent reads/writesCQRS

13.7 Testing Cheat Sheet

TypeTool
Unitpytest
Property-basedhypothesis
Mutationmutmut
Fuzzatheris
Loadlocust, k6
Coveragepytest-cov

13.8 Interview DSA Pattern Cheat Sheet

CluePattern
Sorted + searchBinary search
Contiguous windowSliding window
Two sorted arraysTwo pointers
DuplicatesHash set
Top kHeap
CombinationsBacktracking
Optimal with choicesDP
Graph traversalBFS / DFS
DependenciesTopological sort
ConnectivityUnion–Find

XIV. Exam Tips & Practice Questions

Top 12 Exam Tips

  1. Graphs: always state whether the graph is weighted and directed before choosing BFS/DFS/Dijkstra.
  2. Complexity first. Write the Big-O of every algorithm before you code it.
  3. KMP vs Rabin–Karp: KMP for worst-case guarantee, Rabin–Karp when searching many patterns or when the rolling hash gives practical speed.
  4. Scale questions: Polars for single-machine speed, Dask for pandas code outgrowing RAM, Spark for cluster-scale.
  5. PyTorch: the training loop has six steps; state them in order if asked.
  6. LLM APIs: mention temperature, max_tokens, streaming, and structured output (Pydantic).
  7. REST vs GraphQL vs gRPC: never claim one is universally better — state the trade-off.
  8. Property-based testing: name at least three properties — invariant, idempotence, roundtrip.
  9. System design: clarify requirements, estimate scale, then draw boxes. State the bottleneck.
  10. CQRS/Event Sourcing/Saga: only when the problem demands them. Explain why for smaller systems REST + RDBMS is simpler.
  11. Cloud: name one service per compute model (EC2 = IaaS, ECS = CaaS, Lambda = FaaS).
  12. Interviews: narrate your thinking. Communication is half the score.

Practice Questions

Q1.Write a Python function that finds the shortest path between two nodes in an unweighted graph. Return the path as a list.Medium
Q2.Given a directed graph, detect whether it contains a cycle. Use both colour-based DFS and Kahn's algorithm and compare.Medium
Q3.Implement Dijkstra's algorithm on a weighted graph and reconstruct the shortest path to a target node.Hard
Q4.Write a Trie class supporting insert, search, and autocomplete. Demonstrate it on a small dictionary.Medium
Q5.Implement KMP pattern matching. Show the LPS array for the pattern "ABABCABAB".Hard
Q6.Write a Polars lazy query that reads a CSV, filters rows by a condition, groups by a column, and aggregates two metrics.Medium
Q7.Compare pandas, Polars, Dask, and PySpark for a 500 GB dataset with daily aggregations. Which would you pick and why?Medium
Q8.Write a PyTorch training loop for a 3-layer classifier. Explain the six steps of each iteration.Hard
Q9.Design a Python function that calls an LLM API with structured output using Pydantic. Include a fallback for API errors.Medium
Q10.Explain the difference between REST, GraphQL, and gRPC. For each, give one realistic scenario where it is the best choice.Easy
Q11.Write a Hypothesis test that verifies an encode/decode pair is a roundtrip. Provide a failing case if the code is buggy.Hard
Q12.Design a URL shortener. Clarify requirements, estimate scale, propose a data model, and identify the bottleneck.Hard
Q13.Explain CQRS and event sourcing with a Python example. What problem does each solve?Hard
Q14.Compare AWS EC2, ECS, and Lambda for deploying a Python service. Give a use case for each and mention cold starts.Medium
Q15.Given an array of integers, find two numbers that sum to a target. Solve it in \(O(n)\) and explain the pattern used.Easy
Q16.Explain the mutable default argument bug. Show the broken code and the fix, and describe why Python behaves this way.Medium

XV. Full Solutions to Practice Questions

Solution 1 — Shortest path with BFS

from collections import deque

def shortest_path(graph, start, target):
    if start == target:
        return [start]

    parent = {start: None}
    q = deque([start])

    while q:
        node = q.popleft()
        for neighbour in graph[node]:
            if neighbour not in parent:
                parent[neighbour] = node
                if neighbour == target:
                    path = []
                    while neighbour is not None:
                        path.append(neighbour)
                        neighbour = parent[neighbour]
                    return path[::-1]
                q.append(neighbour)
    return None

g = {"A": ["B", "C"], "B": ["D"], "C": ["D", "E"], "D": ["F"], "E": ["F"], "F": []}
print(shortest_path(g, "A", "F"))       # ['A', 'B', 'D', 'F']

Solution 2 — Cycle detection (two approaches)

def has_cycle_dfs(graph):
    WHITE, GRAY, BLACK = 0, 1, 2
    color = {n: WHITE for n in graph}

    def dfs(n):
        color[n] = GRAY
        for m in graph[n]:
            if color[m] == GRAY:
                return True
            if color[m] == WHITE and dfs(m):
                return True
        color[n] = BLACK
        return False

    return any(color[n] == WHITE and dfs(n) for n in graph)

from collections import deque

def has_cycle_kahn(graph):
    indeg = {n: 0 for n in graph}
    for n in graph:
        for m in graph[n]:
            indeg[m] += 1
    q = deque([n for n, d in indeg.items() if d == 0])
    seen = 0
    while q:
        n = q.popleft(); seen += 1
        for m in graph[n]:
            indeg[m] -= 1
            if indeg[m] == 0:
                q.append(m)
    return seen != len(graph)

dag = {"A": ["B"], "B": ["C"], "C": []}
cyc = {"A": ["B"], "B": ["C"], "C": ["A"]}
print(has_cycle_dfs(dag),  has_cycle_kahn(dag))   # False False
print(has_cycle_dfs(cyc),  has_cycle_kahn(cyc))   # True True

Solution 3 — Dijkstra with path reconstruction

import heapq

def dijkstra_path(graph, start, target):
    dist = {n: float("inf") for n in graph}
    dist[start] = 0
    parent = {start: None}
    heap = [(0, start)]

    while heap:
        d, node = heapq.heappop(heap)
        if node == target:
            break
        if d > dist[node]:
            continue
        for nb, w in graph[node]:
            nd = d + w
            if nd < dist[nb]:
                dist[nb] = nd
                parent[nb] = node
                heapq.heappush(heap, (nd, nb))

    if dist[target] == float("inf"):
        return None, float("inf")

    path = []
    cur = target
    while cur is not None:
        path.append(cur)
        cur = parent[cur]
    return path[::-1], dist[target]

g = {
    "A": [("B", 4), ("C", 2)],
    "B": [("C", 5), ("D", 10)],
    "C": [("D", 3)],
    "D": [],
}
print(dijkstra_path(g, "A", "D"))    # (['A', 'C', 'D'], 5)

Solution 4 — Trie

class TrieNode:
    __slots__ = ("children", "is_end")
    def __init__(self):
        self.children = {}
        self.is_end = False

class Trie:
    def __init__(self):
        self.root = TrieNode()

    def insert(self, word):
        node = self.root
        for ch in word:
            node = node.children.setdefault(ch, TrieNode())
        node.is_end = True

    def search(self, word):
        node = self._find(word)
        return node is not None and node.is_end

    def _find(self, prefix):
        node = self.root
        for ch in prefix:
            if ch not in node.children:
                return None
            node = node.children[ch]
        return node

    def autocomplete(self, prefix):
        node = self._find(prefix)
        results = []
        if node:
            self._collect(node, prefix, results)
        return results

    def _collect(self, node, prefix, out):
        if node.is_end:
            out.append(prefix)
        for ch, child in node.children.items():
            self._collect(child, prefix + ch, out)

t = Trie()
for w in ["cat", "car", "card", "care", "dog"]:
    t.insert(w)
print(t.search("car"))                # True
print(t.autocomplete("car"))          # ['car', 'card', 'care']

Solution 5 — KMP

def build_lps(p):
    lps = [0] * len(p)
    length = 0; i = 1
    while i < len(p):
        if p[i] == p[length]:
            length += 1; lps[i] = length; i += 1
        elif length != 0:
            length = lps[length - 1]
        else:
            lps[i] = 0; i += 1
    return lps

def kmp(text, pattern):
    lps = build_lps(pattern)
    i = j = 0
    while i < len(text):
        if text[i] == pattern[j]:
            i += 1; j += 1
            if j == len(pattern):
                return i - j
        elif j != 0:
            j = lps[j - 1]
        else:
            i += 1
    return -1

print(build_lps("ABABCABAB"))
# [0, 0, 1, 2, 0, 1, 2, 3, 4]

Solution 6 — Polars lazy query

import polars as pl

result = (
    pl.scan_csv("sales.csv")
    .filter(pl.col("amount") > 100)
    .group_by("region")
    .agg([
        pl.col("amount").sum().alias("total"),
        pl.col("amount").mean().alias("avg"),
    ])
    .sort("total", descending=True)
    .collect()
)
print(result)

Solution 7 — Framework choice for 500 GB

ToolVerdict
pandas✗ — 500 GB won't fit in memory
Polars streaming△ — possible on a large single machine with streaming engine
Dask✓ — matches pandas API, parallelises on a cluster, good if the team already knows pandas
PySpark✓✓ — the industry default for multi-TB production pipelines; SQL interface and mature tooling

Recommendation: PySpark, deployed on a managed cluster (Databricks, EMR), with partitioning on the date column. Use Dask if the workload is lighter and the team is Python-first.

Solution 8 — PyTorch training loop

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset

class Net(nn.Module):
    def __init__(self, d_in, d_h, n_classes):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(d_in, d_h), nn.ReLU(),
            nn.Linear(d_h, d_h), nn.ReLU(),
            nn.Linear(d_h, n_classes))
    def forward(self, x):
        return self.net(x)

X = torch.randn(2000, 10)
y = torch.randint(0, 3, (2000,))
loader = DataLoader(TensorDataset(X, y), batch_size=32, shuffle=True)

model = Net(10, 64, 3)
opt   = optim.Adam(model.parameters(), lr=1e-3)
loss_fn = nn.CrossEntropyLoss()

for epoch in range(5):
    for xb, yb in loader:
        opt.zero_grad()                    # 1. zero gradients
        logits = model(xb)                 # 2. forward pass
        loss = loss_fn(logits, yb)         # 3. compute loss
        loss.backward()                    # 4. backward pass (autograd)
        opt.step()                         # 5. update parameters
    print(f"Epoch {epoch + 1}: loss = {loss.item():.4f}")   # 6. log

Solution 9 — LLM call with structured output and fallback

import os, time
from pydantic import BaseModel
from openai import OpenAI, APIError, RateLimitError

class Extracted(BaseModel):
    name: str
    roll: int
    cgpa: float

client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])

def extract(text, retries=3):
    for attempt in range(1, retries + 1):
        try:
            resp = client.beta.chat.completions.parse(
                model="gpt-4o-mini",
                messages=[
                    {"role": "system", "content": "Extract student info."},
                    {"role": "user",   "content": text},
                ],
                response_format=Extracted,
            )
            return resp.choices[0].message.parsed
        except RateLimitError:
            time.sleep(2 ** attempt)
        except APIError as e:
            print(f"API error: {e}")
            if attempt == retries:
                raise
    return None

Solution 10 — REST vs GraphQL vs gRPC scenarios

ParadigmScenario
RESTPublic blog API consumed by mobile apps and browsers; benefits from HTTP caching and OpenAPI docs
GraphQLInternal data-aggregation layer for a React SPA that needs different fields on different screens
gRPCInternal microservices communication with strict contracts, low latency, and bidirectional streaming

Solution 11 — Hypothesis roundtrip test

from hypothesis import given, strategies as st

def encode(s: str) -> bytes:
    return s.encode("utf-8")

def decode(b: bytes) -> str:
    return b.decode("utf-8")

@given(st.text())
def test_roundtrip(s):
    assert decode(encode(s)) == s

@given(st.text())
def test_encoded_is_bytes(s):
    assert isinstance(encode(s), bytes)

@given(st.text())
def test_length_grows_or_stays(s):
    # UTF-8 never shrinks a string's byte length below its character count
    assert len(encode(s)) >= len(s)

Solution 12 — URL shortener design

Functional requirements: create short URL, redirect, optional analytics.

Non-functional: 100M URLs total, 10k reads/s, 100 writes/s, sub-100 ms p99 reads.

Data model:

CREATE TABLE urls (
    short_code  VARCHAR(8) PRIMARY KEY,
    long_url    TEXT       NOT NULL,
    owner_id    BIGINT,
    created_at  TIMESTAMP  DEFAULT NOW(),
    clicks      BIGINT     DEFAULT 0
);
CREATE INDEX idx_urls_owner ON urls(owner_id);

ID generation: Snowflake-like ID (timestamp + machine + sequence) → Base62 encode → take first 7 characters.

Read path: check Redis; on hit, redirect; on miss, query Postgres, populate Redis with 24h TTL, redirect.

Write path: validate URL, generate code, insert with unique constraint, retry on collision.

Bottleneck: DB write throughput at scale — shard by hash of short code.

Trade-off: 301 (permanent) is cache-friendly but loses click data; 302 (temporary) tracks clicks but reduces CDN/browser caching.

Solution 13 — CQRS and Event Sourcing

from dataclasses import dataclass, field

@dataclass
class Event:
    type: str
    data: dict

class Account:
    def __init__(self, account_id):
        self.id = account_id
        self.balance = 0
        self.events: list[Event] = []

    def _apply(self, e):
        if e.type == "deposited":
            self.balance += e.data["amount"]
        elif e.type == "withdrawn":
            self.balance -= e.data["amount"]

    def deposit(self, amount):
        e = Event("deposited", {"amount": amount})
        self.events.append(e)
        self._apply(e)

    def withdraw(self, amount):
        if amount > self.balance:
            raise ValueError("Insufficient funds")
        e = Event("withdrawn", {"amount": amount})
        self.events.append(e)
        self._apply(e)

# --- Command side ---
acc = Account("A1")
acc.deposit(1000); acc.withdraw(250)

# --- Query side (projection) ---
def project(events):
    balance = 0
    for e in events:
        balance += e.data["amount"] if e.type == "deposited" else -e.data["amount"]
    return {"id": "A1", "balance": balance}

print(project(acc.events))     # {'id': 'A1', 'balance': 750}

CQRS solves: read and write workloads scale differently. Event sourcing solves: full audit trail and time travel, plus projection flexibility (multiple read models from the same events).

Solution 14 — EC2 vs ECS vs Lambda

ServiceWhen to useCold start
EC2Long-running services needing full OS control; predictable, steady loadNone (instances run continuously)
ECSContainerised services with autoscaling; predictable load or bursty with FargateMinutes (container start)
LambdaEvent-driven, sporadic workloads; IAM-integrated triggersHundreds of ms (Python); worse with large deps or VPC

Solution 15 — Two-sum in \(O(n)\)

def two_sum(nums, target):
    seen = {}
    for i, n in enumerate(nums):
        complement = target - n
        if complement in seen:
            return [seen[complement], i]
        seen[n] = i
    return []

print(two_sum([2, 7, 11, 15], 9))    # [0, 1]

Pattern: hash map for \(O(1)\) complement lookup. Time \(O(n)\), space \(O(n)\). This is the canonical "trade space for time" example.

Solution 16 — Mutable default argument bug

# BROKEN — the default list is created once and shared across calls
def add_item(item, target=[]):
    target.append(item)
    return target

print(add_item("a"))     # ['a']
print(add_item("b"))     # ['a', 'b']  ← surprise
print(add_item("c"))     # ['a', 'b', 'c']

# FIXED — use a sentinel, then create a fresh list inside
def add_item(item, target=None):
    if target is None:
        target = []
    target.append(item)
    return target

print(add_item("a"))     # ['a']
print(add_item("b"))     # ['b']

Why: default argument values are evaluated once at function definition time and stored on the function object (add_item.__defaults__). All calls without an explicit argument share that same list. Using None as a sentinel defers list creation to call time.

XVI. References, Key Takeaways & CO Mapping

16.1 Textbooks and References

CodeTitleAuthorPublisher
T-1Fundamentals of Python — First ProgramsKenneth A. LambertCengage Learning
R-1Python Programming: Using Problem Solving ApproachReema TharejaOxford University Press
R-2Introduction to Algorithms (CLRS, 4th ed.)Cormen et al.MIT Press
R-3Cracking the Coding Interview (6th ed.)Gayle Laakmann McDowellCareerCup
R-4Designing Data-Intensive ApplicationsMartin KleppmannO'Reilly
R-5Designing Machine Learning SystemsChip HuyenO'Reilly
R-6Deep Learning with PyTorchStevens, Antiga, ViehmannManning
R-7Building Microservices (2nd ed.)Sam NewmanO'Reilly
R-8Fundamentals of Software ArchitectureRichards & FordO'Reilly

16.2 Relevant Web Resources

CodeResourcePurpose
RW-1docs.python.org/3/library/Standard library reference
RW-2docs.pola.rsPolars documentation
RW-3docs.dask.orgDask documentation
RW-4spark.apache.org/docs/latest/api/python/PySpark docs
RW-5pytorch.org/docs/stable/PyTorch documentation
RW-6platform.openai.com/docsOpenAI API docs
RW-7strawberry.rocksStrawberry GraphQL docs
RW-8grpc.io/docs/languages/python/gRPC Python docs
RW-9hypothesis.readthedocs.ioHypothesis documentation
RW-10github.com/checkcheckzz/system-design-interviewSystem design primer
RW-11neetcode.ioDSA practice roadmap
RW-12roadmap.sh/pythonPython learning roadmap

16.3 Key Takeaways

  1. Graphs: BFS for unweighted shortest paths, DFS for connectivity/cycles, Dijkstra for weighted, topological sort for dependency order, Union–Find for dynamic connectivity.
  2. String algorithms: KMP guarantees linear time, Rabin–Karp uses rolling hashes, tries enable prefix search, and Aho–Corasick handles many patterns at once.
  3. Large-scale data: Polars for single-machine speed, Dask for pandas-like parallel workloads, PySpark for cluster-scale. Lazy evaluation is the key optimisation mechanism.
  4. Deep learning: PyTorch's training loop has six steps: zero_grad → forward → loss → backward → step → log. Always call model.eval() for inference.
  5. LLM APIs: structured output with Pydantic, streaming for UX, RAG for grounding, and function calling for tool use.
  6. API paradigms: REST for public, GraphQL for flexible clients, gRPC for high-performance internal services. Never universal superiority — always trade-offs.
  7. Property-based testing with Hypothesis finds edge cases that example tests miss. Mutation testing verifies test quality.
  8. System design is a structured conversation: clarify, estimate, design, deep-dive, discuss bottlenecks.
  9. Advanced patterns (CQRS, event sourcing, saga, outbox) solve real problems but add complexity — use them only when the problem demands it.
  10. Cloud deployment spans IaaS (EC2), CaaS (ECS, Cloud Run) and FaaS (Lambda). Match the compute model to the workload.
  11. Interview preparation is a muscle: 100+ DSA problems, mock interviews, and clean communication beat raw talent alone.
  12. Communication wins interviews. Narrate your thinking, clarify requirements, and state complexity explicitly.

16.4 CO Mapping

Course OutcomeCovered in SectionsKey Deliverables
CO3 — Functions & algorithmsI, II, VIIGraph and string algorithms; property-based testing
CO4 — Data structures at scaleI, IIIGraphs, tries, Polars, Dask, PySpark
CO5 — OOP & design patternsIX, X, XIICQRS, event sourcing, saga; cloud deployment
CO6 — Full-stack integrationIV–VI, VIII, XI, XIIPyTorch, LLM APIs, GraphQL/gRPC, system design, interviews

16.5 Recommended Learning Path

WeekFocusSections
1Graph algorithmsI
2String algorithmsII
3Large-scale dataIII
4Deep learningIV
5LLM APIs & RAGV
6GraphQL & gRPCVI
7Advanced testingVII
8System designVIII
9Advanced patternsIX
10Cloud deploymentX
11Interview practiceXI
12CapstoneXII

16.6 Course-Wide Integration Map

UnitCore themeWhere it lives in Unit VI
Unit IBasics, data structuresGraphs, tries, DSA patterns
Unit IIFunctions, recursion, OOPAdvanced patterns, event sourcing
Unit IIIDecorators, generators, filesTesting, cloud streaming, LLM APIs
Unit IVConcurrency, APIs, data, MLPyTorch, Polars, GraphQL/gRPC
Unit VMetaprogramming, web, productionSystem design, cloud, observability
Unit VIIntegration & extensionEnd-to-end capstone project
Assessment reminder

Course weightage: ATT 5 + CA 50 + ETP 45. Programming Practice requires solving at least 50% of the assigned coding problems and 50% of the MCQs to be eligible for marks. For Unit VI, prioritise: (1) 60–80 DSA problems across patterns, (2) one full-system capstone, (3) a written design doc for a system you've built.

End of Unit VI

Advanced Topics & Professional Mastery

INT108 · L:T:P 3:0:2 · 4 Credits

“First, solve the problem. Then, write the code.” — John Johnson

You have now completed the full INT108 · Python Programming arc — from first programs to production systems.