INT108 · Python Programming

Advanced Python
Techniques & Applications

Unit III

Decorators · Generators · Iterators · Context Managers

Collections · Databases · GUI · NumPy · Testing

Course CodeINT108
Course TitlePython Programming
L : T : P3 : 0 : 2
Credits4
WeightageATT 5 · CA 50 · ETP 45
FocusEmployability · Skill Development
Course Outcomes Mapped to Unit III

Table of Contents

IClosures & Decorators3
IIIterators & Generators7
IIIContext Managers & Resource Handling11
IVThe collections Module14
VAdvanced Data Structures — Stack, Queue, Linked List, Tree18
VIAlgorithm Design — DP, Greedy, Backtracking23
VIIStructured Data — CSV, JSON, XML28
VIIIDatabase Connectivity with SQLite32
IXGUI Programming with Tkinter36
XIntroduction to NumPy & Matplotlib40
XITesting, Debugging & Profiling44
XIIIntegrated Mini Projects48
XIIISummary & Quick Reference Sheet52
XIVExam Tips & Practice Questions55
XVFull Solutions to Practice Questions58
XVIReferences, Key Takeaways & CO Mapping62
How to use these notes

Unit III covers the professional Python toolkit. Master decorators, generators and context managers first — they appear in almost every modern Python codebase. Then work through the mini projects: they combine everything from Units I–III. Every example is exam-ready and has been tested on Python 3.10+.

I. Closures & Decorators

1.1 First-Class Functions

In Python, functions are first-class objects: they can be assigned to variables, passed as arguments, returned from other functions, and stored in data structures.

def greet(name):
    return f"Hello, {name}"

# Assign to a variable
say_hi = greet
print(say_hi("Aarav"))            # Hello, Aarav

# Store in a data structure
funcs = [greet, len, str.upper]
print(funcs[0]("Diya"))           # Hello, Diya

# Pass as an argument
def apply(f, value):
    return f(value)

print(apply(greet, "Kabir"))      # Hello, Kabir

1.2 Nested Functions and Closures

Definition

Closure: an inner function that remembers the values of variables from its enclosing scope, even after the outer function has finished executing.

def make_multiplier(n):
    def multiply(x):
        return x * n          # 'n' is captured from the enclosing scope
    return multiply

times3 = make_multiplier(3)
times5 = make_multiplier(5)

print(times3(10))    # 30
print(times5(10))    # 50

# Inspecting the closure
print(times3.__closure__[0].cell_contents)   # 3
Closure trap — late binding
funcs = []
for i in range(3):
    funcs.append(lambda: i)      # all lambdas capture the SAME i
print([f() for f in funcs])      # [2, 2, 2] — NOT [0, 1, 2]

# Fix: use a default argument to capture the current value
funcs = []
for i in range(3):
    funcs.append(lambda i=i: i)
print([f() for f in funcs])      # [0, 1, 2]

1.3 Decorators — The Core Idea

Definition

A decorator is a function that takes another function as input, extends or modifies its behaviour without changing its source code, and returns a new function.

Decorator template

def decorator(func):
    def wrapper(*args, **kwargs):
        # before
        result = func(*args, **kwargs)
        # after
        return result
    return wrapper

def my_decorator(func):
    def wrapper(*args, **kwargs):
        print("--- Before ---")
        result = func(*args, **kwargs)
        print("--- After ---")
        return result
    return wrapper

@my_decorator                    # equivalent to: say = my_decorator(say)
def say(message):
    print(message)

say("Hello, World!")

# --- Before ---
# Hello, World!
# --- After ---

1.4 Preserving Metadata with functools.wraps

Problem

Without @wraps, the decorated function loses its original __name__ and __doc__:

print(say.__name__)    # 'wrapper' — WRONG!
from functools import wraps

def my_decorator(func):
    @wraps(func)                  # preserves metadata
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@my_decorator
def say(message):
    """Print a greeting."""
    print(message)

print(say.__name__)    # 'say'
print(say.__doc__)     # 'Print a greeting.'

1.5 Practical Decorators

Example 1.1 — Timing decorator
import time
from functools import wraps

def timer(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

@timer
def slow_sum(n):
    return sum(range(n))

print(slow_sum(1_000_000))
# slow_sum took 0.0231s
# 499999500000
Example 1.2 — Logging decorator
from functools import wraps

def log_calls(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        args_repr = [repr(a) for a in args]
        kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()]
        signature = ", ".join(args_repr + kwargs_repr)
        print(f"CALL {func.__name__}({signature})")
        return func(*args, **kwargs)
    return wrapper

@log_calls
def add(a, b):
    return a + b

print(add(3, 5))
# CALL add(3, 5)
# 8
Example 1.3 — Decorator with arguments

To pass parameters to a decorator, you need three levels of nesting.

from functools import wraps

def repeat(times):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)
def hello(name):
    print(f"Hello, {name}!")

hello("Aarav")
# Hello, Aarav!
# Hello, Aarav!
# Hello, Aarav!
Example 1.4 — Retry decorator (real-world pattern)
import time
from functools import wraps

def retry(max_attempts=3, delay=1):
    def decorator(func):
        @wraps(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
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(max_attempts=3, delay=0)
def unstable():
    import random
    if random.random() < 0.7:
        raise ConnectionError("Network glitch")
    return "Success"

print(unstable())

1.6 Built-in Decorators

DecoratorPurposeApplies to
@staticmethodNo implicit first argumentMethods
@classmethodReceives cls instead of selfMethods
@propertyTurn a method into a read-only attributeMethods
@functools.wrapsPreserve metadata in a wrapperDecorator inner function
@functools.lru_cacheMemoize function resultsFunctions
@dataclassAuto-generate __init__, __repr__, __eq__Classes
Example 1.5 — @property for computed attributes
class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):                 # getter
        return self._radius

    @radius.setter
    def radius(self, value):          # setter with validation
        if value < 0:
            raise ValueError("Radius cannot be negative")
        self._radius = value

    @property
    def area(self):                   # computed, read-only
        return 3.14159 * self._radius ** 2

c = Circle(5)
print(c.radius)          # 5
print(c.area)            # 78.53975
c.radius = 10
print(c.area)            # 314.159

try:
    c.radius = -1
except ValueError as e:
    print("Error:", e)
Example 1.6 — @dataclass for concise data classes
from dataclasses import dataclass, field

@dataclass(order=True)
class Student:
    name: str
    roll: int
    marks: list = field(default_factory=list)

    def average(self):
        return sum(self.marks) / len(self.marks) if self.marks else 0

s1 = Student("Aarav", 101, [88, 92, 79])
s2 = Student("Diya",  102, [95, 81, 90])

print(s1)                       # Student(name='Aarav', roll=101, marks=[88, 92, 79])
print(s1 == Student("Aarav", 101, [88, 92, 79]))   # True
print(sorted([s1, s2]))         # sorts by name, then roll
Exam tip

For decorator questions, always mention: (1) the three-level nesting if the decorator takes arguments, (2) @wraps to preserve metadata, (3) *args, **kwargs to accept any signature. A five-mark answer that includes a timing decorator with @wraps is usually full marks.

II. Iterators & Generators

2.1 The Iteration Protocol

Python's for loop works on any object that implements the iteration protocol — i.e., provides an __iter__ method returning an iterator, and the iterator provides __next__.

Definitions

Iterable: an object that can return an iterator (implements __iter__). Examples: list, tuple, set, dict, string, file.
Iterator: an object that produces the next value on demand (implements __next__) and raises StopIteration when exhausted.

nums = [10, 20, 30]
it = iter(nums)          # get the iterator

print(next(it))          # 10
print(next(it))          # 20
print(next(it))          # 30
# print(next(it))        # StopIteration

# A for loop does exactly this behind the scenes
for n in nums:
    print(n)

2.2 Building a Custom Iterator

class Countdown:
    """Iterator that counts down from n to 1."""
    def __init__(self, start):
        self.current = start

    def __iter__(self):
        return self

    def __next__(self):
        if self.current <= 0:
            raise StopIteration
        value = self.current
        self.current -= 1
        return value

for n in Countdown(5):
    print(n, end=" ")
# 5 4 3 2 1
Why use iterators?

Iterators are lazy: they produce values one at a time and do not store the entire sequence in memory. This makes them ideal for large data streams, files and infinite sequences.

2.3 Generators — The Easier Way

Definition

A generator is a function that uses yield instead of return. Calling it does not execute the body; it returns a generator object that produces values lazily.

Generator vs ordinary function

return ends the function permanently.  •  yield pauses the function, remembers its state, and resumes on the next next().

def countdown(n):
    while n > 0:
        yield n
        n -= 1

for value in countdown(5):
    print(value, end=" ")         # 5 4 3 2 1
print()

gen = countdown(3)
print(next(gen))     # 3
print(next(gen))     # 2
print(next(gen))     # 1

2.4 Generator Expressions

Like list comprehensions, but with parentheses — lazy and memory-efficient.

# List comprehension: builds the entire list
squares_list = [x ** 2 for x in range(1_000_000)]     # ~8 MB

# Generator expression: computes on demand
squares_gen = (x ** 2 for x in range(1_000_000))      # tiny memory

print(sum(squares_gen))              # 333332833333500000

# Chaining generators
def read_lines(filename):
    with open(filename) as f:
        for line in f:
            yield line.rstrip()

def non_empty(lines):
    for line in lines:
        if line:
            yield line

# for line in non_empty(read_lines("data.txt")):
#     print(line)

2.5 yield from and Generator Delegation

def chain(*iterables):
    for it in iterables:
        yield from it          # delegates to each sub-iterable

print(list(chain([1, 2], "ab", (3, 4))))
# [1, 2, 'a', 'b', 3, 4]

2.6 Sending Values — Coroutine-style Generators

def accumulator():
    total = 0
    while True:
        value = yield total
        if value is None:
            break
        total += value

acc = accumulator()
next(acc)              # prime the generator
print(acc.send(10))    # 10
print(acc.send(20))    # 30
print(acc.send(5))     # 35

2.7 Infinite Generators

def fibonacci():
    a, b = 0, 1
    while True:                # infinite!
        yield a
        a, b = b, a + b

fib = fibonacci()
for _ in range(10):
    print(next(fib), end=" ")
# 0 1 1 2 3 5 8 13 21 34
Example 2.1 — Reading a large file lazily
def read_large_file(filename):
    """Yield one line at a time without loading the whole file."""
    with open(filename, "r") as f:
        for line in f:
            yield line.rstrip()

total_words = 0
for line in read_large_file("bigdata.txt"):
    total_words += len(line.split())

print("Total words:", total_words)
Example 2.2 — Prime number generator
def primes(limit):
    """Yield all primes up to 'limit' using the sieve idea lazily."""
    for n in range(2, limit + 1):
        if all(n % d != 0 for d in range(2, int(n ** 0.5) + 1)):
            yield n

print(list(primes(30)))
# [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
Example 2.3 — Memory comparison
import sys

list_comp = [x for x in range(10_000)]
gen_expr  = (x for x in range(10_000))

print("List size :", sys.getsizeof(list_comp), "bytes")   # ~85 KB
print("Gen size  :", sys.getsizeof(gen_expr),  "bytes")   # ~200 bytes

2.8 Iterator vs Generator vs Iterable

ConceptImplementsReusable?Memory
Iterable (list, tuple)__iter__YesStores all elements
Iterator__iter__, __next__No — exhausts onceProduces on demand
GeneratorCreated by yieldNo — exhausts onceProduces on demand
Exam tip

When asked “why use generators?”, answer: lazy evaluation, low memory footprint, clean syntax for streaming data, and ability to represent infinite sequences. Contrast with lists which hold everything in memory.

III. Context Managers & Resource Handling

A context manager is an object that defines the runtime context for a with block — guaranteeing that setup and cleanup happen automatically, even if an exception occurs.

3.1 The with Statement

Syntax

with context_manager as variable:
    body

# Without context manager - manual cleanup (error-prone)
f = open("data.txt")
try:
    content = f.read()
finally:
    f.close()

# With context manager - automatic cleanup
with open("data.txt") as f:
    content = f.read()

3.2 The Context Manager Protocol

Any class implementing __enter__ and __exit__ can be used with with.

MethodCalled whenReturns
__enter__(self)Entering the blockValue bound to as variable
__exit__(self, exc_type, exc_val, tb)Leaving the block (normal or exception)True to suppress exception, False to propagate
Example 3.1 — Custom context manager for a timer
import time

class Timer:
    def __init__(self, label="Elapsed"):
        self.label = label

    def __enter__(self):
        self.start = time.perf_counter()
        return self

    def __exit__(self, exc_type, exc_val, tb):
        self.elapsed = time.perf_counter() - self.start
        print(f"{self.label}: {self.elapsed:.4f}s")
        return False       # don't suppress exceptions

with Timer("Sum computation"):
    total = sum(range(10_000_000))
print("Total =", total)
Example 3.2 — Database transaction context manager
import sqlite3

class Transaction:
    def __init__(self, db_path):
        self.db_path = db_path
        self.conn = None

    def __enter__(self):
        self.conn = sqlite3.connect(self.db_path)
        return self.conn

    def __exit__(self, exc_type, exc_val, tb):
        if exc_type is None:
            self.conn.commit()
            print("Transaction committed.")
        else:
            self.conn.rollback()
            print(f"Rolled back due to: {exc_val}")
        self.conn.close()
        return False

with Transaction("bank.db") as conn:
    cur = conn.cursor()
    cur.execute("CREATE TABLE IF NOT EXISTS accounts (id INTEGER, balance REAL)")
    cur.execute("INSERT INTO accounts VALUES (1, 5000)")
    cur.execute("UPDATE accounts SET balance = balance - 1000 WHERE id = 1")

3.3 The contextlib Module

Python's contextlib provides helpers that avoid writing a full class.

(a) @contextmanager decorator

from contextlib import contextmanager
import time

@contextmanager
def timer(label="Elapsed"):
    start = time.perf_counter()
    try:
        yield start          # everything before 'yield' is __enter__
    finally:
        elapsed = time.perf_counter() - start
        print(f"{label}: {elapsed:.4f}s")   # everything after is __exit__

with timer("Loop"):
    sum(range(1_000_000))

(b) contextlib.suppress

from contextlib import suppress
import os

with suppress(FileNotFoundError):
    os.remove("does_not_exist.txt")
print("Continued without error.")

(c) contextlib.ExitStack — dynamic resource management

from contextlib import ExitStack

filenames = ["a.txt", "b.txt", "c.txt"]
with ExitStack() as stack:
    files = [stack.enter_context(open(f, "w")) for f in filenames]
    for i, f in enumerate(files):
        f.write(f"File {i}\n")
# All files closed automatically on exit

(d) contextlib.redirect_stdout

import io
from contextlib import redirect_stdout

buffer = io.StringIO()
with redirect_stdout(buffer):
    print("This goes to the buffer")
    print("So does this")

print("Captured:")
print(buffer.getvalue())

3.4 Common Context Managers in the Standard Library

Context ManagerPurpose
open()File I/O with automatic close
threading.Lock()Acquire/release a thread lock
sqlite3.connect()Database transaction (commits on success)
decimal.localcontext()Temporary decimal precision
unittest.mock.patch()Temporary monkey-patching in tests
warnings.catch_warnings()Capture and manage warnings
Exam tip

When asked about context managers, mention both flavours: class-based (__enter__/__exit__) and generator-based (@contextmanager with yield). The latter is more concise; the former gives more control.

IV. The collections Module

The collections module provides specialised container data types that outperform generic lists, dicts and tuples for specific use cases.

4.1 Counter — Frequency Counting

from collections import Counter

text = "mississippi"
c = Counter(text)
print(c)                        # Counter({'i':4, 's':4, 'p':2, 'm':1})

print(c.most_common(2))         # [('i', 4), ('s', 4)]
print(c['i'])                   # 4
print(c['z'])                   # 0 (no KeyError!)

# Arithmetic
c1 = Counter("aabbc")
c2 = Counter("abcc")
print(c1 + c2)                  # Counter({'a':3, 'b':3, 'c':3})
print(c1 - c2)                  # Counter({'a':1, 'b':1})
print(c1 & c2)                  # Counter({'a':1, 'b':1, 'c':1})
print(c1 | c2)                  # Counter({'a':2, 'b':2, 'c':2})
Example 4.1 — Word frequency with Counter
from collections import Counter
import re

text = "the quick brown fox jumps over the lazy dog the fox"
words = re.findall(r"\w+", text.lower())

for word, n in Counter(words).most_common(3):
    print(f"{word:<8} {n}")

4.2 defaultdict — Auto-initialising Dictionary

Like a dictionary but inserts a default value automatically when a missing key is accessed.

from collections import defaultdict

# Group words by their first letter
words = ["apple", "banana", "avocado", "blueberry", "cherry"]
groups = defaultdict(list)

for w in words:
    groups[w[0]].append(w)

print(dict(groups))
# {'a': ['apple', 'avocado'], 'b': ['banana', 'blueberry'], 'c': ['cherry']}

# Count with int default
counts = defaultdict(int)
for ch in "abracadabra":
    counts[ch] += 1
print(dict(counts))     # {'a': 5, 'b': 2, 'r': 2, 'c': 1, 'd': 1}
Key difference

defaultdict(list) creates a new list for each missing key. Plain dict would raise KeyError. Common defaults: int (0), list ([]), set (set()), lambda: 0.

4.3 OrderedDict

Preserves insertion order (guaranteed since Python 3.7, so dict covers most cases). Still useful for: (1) explicit intent, (2) move_to_end, (3) order-sensitive equality.

from collections import OrderedDict

od = OrderedDict()
od["b"] = 2
od["a"] = 1
od["c"] = 3

print(list(od.keys()))         # ['b', 'a', 'c']
od.move_to_end("b")
print(list(od.keys()))         # ['a', 'c', 'b']
od.move_to_end("c", last=False)
print(list(od.keys()))         # ['c', 'a', 'b']

4.4 namedtuple

Tuple subclass with named fields — combines tuple immutability with attribute access.

from collections import namedtuple

Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)

print(p.x, p.y)              # 3 4
print(p[0], p[1])            # 3 4 (still indexable)
print(p)                     # Point(x=3, y=4)
print(p._asdict())           # {'x': 3, 'y': 4}
print(p._replace(x=10))      # Point(x=10, y=4)

4.5 deque — Double-Ended Queue

Fast \(O(1)\) appends and pops from both ends. Ideal for queues, sliding windows and undo stacks.

from collections import deque

d = deque([1, 2, 3])
d.append(4)                  # right side
d.appendleft(0)              # left side
print(d)                     # deque([0, 1, 2, 3, 4])

d.pop()                      # remove from right
d.popleft()                  # remove from left
print(d)                     # deque([1, 2, 3])

# Rotating
d.rotate(1)
print(d)                     # deque([3, 1, 2])

# Bounded deque — perfect for a sliding window
last3 = deque(maxlen=3)
for i in range(1, 7):
    last3.append(i)
    print(list(last3))
# [1] [1,2] [1,2,3] [2,3,4] [3,4,5] [4,5,6]

4.6 ChainMap — Layered Lookups

from collections import ChainMap

defaults = {"color": "black", "size": "M"}
user_pref = {"color": "red"}

combined = ChainMap(user_pref, defaults)
print(combined["color"])     # red (user_pref wins)
print(combined["size"])      # M (falls back to defaults)

4.7 Summary of collections

ClassPurposeReplaces
CounterCount hashable objectsManual dict counting
defaultdictDict with default factorydict.setdefault()
OrderedDictOrder-aware dictdict (mostly)
namedtupleLightweight recordsPlain tuples / small classes
dequeFast double-ended queueList used as queue
ChainMapLayered dict lookupsNested dict access
Exam tip

Know at least three: Counter (frequency), defaultdict (grouping), deque (queue/stack with O(1) ends). Show a 3–4 line example for each.

V. Advanced Data Structures — Stack, Queue, Linked List, Tree

Python's built-ins cover most needs, but interviews and DSA questions often require implementing classic data structures from scratch.

5.1 Stack (LIFO)

Last-In-First-Out. Push and pop from the same end.

class Stack:
    def __init__(self):
        self.items = []

    def push(self, item):
        self.items.append(item)

    def pop(self):
        if self.is_empty():
            raise IndexError("pop from empty stack")
        return self.items.pop()

    def peek(self):
        return self.items[-1] if self.items else None

    def is_empty(self):
        return len(self.items) == 0

    def __len__(self):
        return len(self.items)

s = Stack()
s.push(1); s.push(2); s.push(3)
print(s.peek())     # 3
print(s.pop())      # 3
print(len(s))       # 2

Classic application: balanced parentheses checker.

def is_balanced(expr):
    pairs = {")": "(", "]": "[", "}": "{"}
    stack = Stack()
    for ch in expr:
        if ch in "([{":
            stack.push(ch)
        elif ch in pairs:
            if stack.is_empty() or stack.pop() != pairs[ch]:
                return False
    return stack.is_empty()

print(is_balanced("{[()]}"))    # True
print(is_balanced("([)]"))      # False

5.2 Queue (FIFO)

First-In-First-Out. Insert at the rear, remove from the front.

from collections import deque

class Queue:
    def __init__(self):
        self.items = deque()

    def enqueue(self, item):
        self.items.append(item)

    def dequeue(self):
        if self.is_empty():
            raise IndexError("dequeue from empty queue")
        return self.items.popleft()

    def front(self):
        return self.items[0] if self.items else None

    def is_empty(self):
        return len(self.items) == 0

q = Queue()
q.enqueue("A"); q.enqueue("B"); q.enqueue("C")
print(q.dequeue())     # A
print(q.front())       # B
Never use list.pop(0)

list.pop(0) is \(O(n)\) because all elements shift. Use collections.deque for \(O(1)\) popleft.

5.3 Priority Queue (Heap)

import heapq

class PriorityQueue:
    def __init__(self):
        self.heap = []
        self.counter = 0            # tie-breaker for equal priorities

    def push(self, priority, item):
        heapq.heappush(self.heap, (priority, self.counter, item))
        self.counter += 1

    def pop(self):
        if not self.heap:
            raise IndexError("pop from empty priority queue")
        return heapq.heappop(self.heap)[2]

pq = PriorityQueue()
pq.push(3, "low")
pq.push(1, "high")
pq.push(2, "medium")

while pq.heap:
    print(pq.pop())
# high
# medium
# low

5.4 Singly Linked List

A chain of nodes where each node holds a value and a reference to the next node. Enables \(O(1)\) insertion at the head.

class Node:
    def __init__(self, data):
        self.data = data
        self.next = None

class LinkedList:
    def __init__(self):
        self.head = None

    def append(self, data):
        node = Node(data)
        if not self.head:
            self.head = node
            return
        current = self.head
        while current.next:
            current = current.next
        current.next = node

    def prepend(self, data):
        node = Node(data)
        node.next = self.head
        self.head = node

    def delete(self, data):
        if not self.head:
            return
        if self.head.data == data:
            self.head = self.head.next
            return
        current = self.head
        while current.next and current.next.data != data:
            current = current.next
        if current.next:
            current.next = current.next.next

    def __iter__(self):
        current = self.head
        while current:
            yield current.data
            current = current.next

    def __str__(self):
        return " -> ".join(map(str, self))

ll = LinkedList()
ll.append(1); ll.append(2); ll.append(3)
ll.prepend(0)
print(ll)                # 0 -> 1 -> 2 -> 3
ll.delete(2)
print(ll)                # 0 -> 1 -> 3

5.5 Binary Search Tree (BST)

Each node has at most two children: left values are smaller, right values are larger.

class BSTNode:
    def __init__(self, value):
        self.value = value
        self.left = None
        self.right = None

class BST:
    def __init__(self):
        self.root = None

    def insert(self, value):
        self.root = self._insert(self.root, value)

    def _insert(self, node, value):
        if node is None:
            return BSTNode(value)
        if value < node.value:
            node.left = self._insert(node.left, value)
        elif value > node.value:
            node.right = self._insert(node.right, value)
        return node

    def search(self, value):
        return self._search(self.root, value)

    def _search(self, node, value):
        if node is None or node.value == value:
            return node is not None
        if value < node.value:
            return self._search(node.left, value)
        return self._search(node.right, value)

    def inorder(self):
        """Returns sorted values."""
        result = []
        self._inorder(self.root, result)
        return result

    def _inorder(self, node, out):
        if node:
            self._inorder(node.left, out)
            out.append(node.value)
            self._inorder(node.right, out)

tree = BST()
for v in [50, 30, 70, 20, 40, 60, 80]:
    tree.insert(v)

print(tree.inorder())        # [20, 30, 40, 50, 60, 70, 80]
print(tree.search(60))       # True
print(tree.search(99))       # False

5.6 Complexity Comparison

StructureSearchInsertDeleteNotes
Stack (list)\(O(n)\)\(O(1)\) push\(O(1)\) popLIFO
Queue (deque)\(O(n)\)\(O(1)\)\(O(1)\)FIFO
Heap\(O(n)\)\(O(\log n)\)\(O(\log n)\) pop-minPriority queue
Linked List\(O(n)\)\(O(1)\) head\(O(n)\)No random access
BST (balanced)\(O(\log n)\)\(O(\log n)\)\(O(\log n)\)Inorder = sorted
BST (degenerate)\(O(n)\)\(O(n)\)\(O(n)\)Becomes a list
Exam tip

When asked to "implement a stack/queue", give both the class definition and at least two operations with sample output. For linked lists, remember that __iter__ with yield makes traversal clean.

VI. Algorithm Design — DP, Greedy, Backtracking

6.1 Divide and Conquer

Break the problem into independent subproblems, solve recursively, and combine.

def merge_sort(arr):
    if len(arr) <= 1:
        return arr
    mid = len(arr) // 2
    left  = merge_sort(arr[:mid])
    right = merge_sort(arr[mid:])
    return merge(left, right)

def merge(a, b):
    result, i, j = [], 0, 0
    while i < len(a) and j < len(b):
        if a[i] <= b[j]:
            result.append(a[i]); i += 1
        else:
            result.append(b[j]); j += 1
    result.extend(a[i:])
    result.extend(b[j:])
    return result

print(merge_sort([38, 27, 43, 3, 9, 82, 10]))
# [3, 9, 10, 27, 38, 43, 82]

6.2 Dynamic Programming

Break the problem into overlapping subproblems and store results to avoid recomputation. Two styles: top-down (memoization) and bottom-up (tabulation).

Example 6.1 — Fibonacci three ways
from functools import lru_cache

# Naive: O(2^n)
def fib_naive(n):
    return n if n <= 1 else fib_naive(n-1) + fib_naive(n-2)

# Top-down DP: O(n)
@lru_cache(maxsize=None)
def fib_memo(n):
    return n if n <= 1 else fib_memo(n-1) + fib_memo(n-2)

# Bottom-up DP: O(n), O(1) space
def fib_tab(n):
    if n <= 1:
        return n
    a, b = 0, 1
    for _ in range(2, n + 1):
        a, b = b, a + b
    return b

print(fib_naive(10), fib_memo(50), fib_tab(50))
Example 6.2 — 0/1 Knapsack

Given weights and values, select items to maximise value without exceeding capacity.

def knapsack(weights, values, capacity):
    n = len(weights)
    dp = [[0] * (capacity + 1) for _ in range(n + 1)]

    for i in range(1, n + 1):
        for w in range(capacity + 1):
            if weights[i-1] <= w:
                dp[i][w] = max(
                    dp[i-1][w],
                    dp[i-1][w - weights[i-1]] + values[i-1]
                )
            else:
                dp[i][w] = dp[i-1][w]
    return dp[n][capacity]

weights = [1, 3, 4, 5]
values  = [1, 4, 5, 7]
print(knapsack(weights, values, 7))    # 9
Example 6.3 — Longest Common Subsequence
def lcs(s1, s2):
    m, n = len(s1), len(s2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]

    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if s1[i-1] == s2[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])
    return dp[m][n]

print(lcs("AGGTAB", "GXTXAYB"))    # 4 (GTAB)

6.3 Greedy Algorithms

Make the locally optimal choice at each step, hoping it leads to a global optimum. Works only when the problem has the greedy-choice property.

Example 6.4 — Activity selection
def activity_selection(activities):
    """activities = list of (start, finish)."""
    activities.sort(key=lambda x: x[1])       # sort by finish time
    selected = [activities[0]]
    last_finish = activities[0][1]

    for start, finish in activities[1:]:
        if start >= last_finish:
            selected.append((start, finish))
            last_finish = finish
    return selected

acts = [(1, 4), (3, 5), (0, 6), (5, 7), (3, 9), (5, 9), (6, 10), (8, 11)]
print(activity_selection(acts))
# [(1, 4), (5, 7), (8, 11)]
Example 6.5 — Coin change (greedy when denominations allow)
def coin_change_greedy(coins, amount):
    coins.sort(reverse=True)
    result = []
    for c in coins:
        while amount >= c:
            amount -= c
            result.append(c)
    return result if amount == 0 else None

print(coin_change_greedy([1, 2, 5, 10, 20, 50], 93))
# [50, 20, 20, 2, 1]

Caution: greedy works for canonical systems like ₹1,2,5,10,20,50. For arbitrary denominations (e.g., [1,3,4] with target 6), DP is required.

6.4 Backtracking

Explore all possibilities by building a solution incrementally and abandoning a path (backtracking) as soon as it cannot lead to a valid solution.

Example 6.6 — N-Queens
def solve_n_queens(n):
    solutions = []
    board = [-1] * n            # board[row] = column of queen in that row

    def is_safe(row, col):
        for r in range(row):
            c = board[r]
            if c == col or abs(c - col) == abs(r - row):
                return False
        return True

    def place(row):
        if row == n:
            solutions.append(board[:])
            return
        for col in range(n):
            if is_safe(row, col):
                board[row] = col
                place(row + 1)
                board[row] = -1     # backtrack

    place(0)
    return solutions

sols = solve_n_queens(4)
print(f"Found {len(sols)} solutions for 4-Queens.")
for s in sols:
    print(s)
Example 6.7 — Sudoku solver (excerpt)
def solve_sudoku(board):
    def is_valid(r, c, val):
        for i in range(9):
            if board[r][i] == val or board[i][c] == val:
                return False
        br, bc = 3 * (r // 3), 3 * (c // 3)
        for i in range(br, br + 3):
            for j in range(bc, bc + 3):
                if board[i][j] == val:
                    return False
        return True

    def backtrack():
        for r in range(9):
            for c in range(9):
                if board[r][c] == 0:
                    for val in range(1, 10):
                        if is_valid(r, c, val):
                            board[r][c] = val
                            if backtrack():
                                return True
                            board[r][c] = 0     # undo
                    return False
        return True

    return backtrack()

6.5 Algorithm Design Paradigm Comparison

ParadigmKey ideaWhen it worksExample
Divide & ConquerSplit, solve, combineIndependent subproblemsMerge sort, quick sort
Dynamic ProgrammingStore results of overlapping subproblemsOptimal substructure + overlappingKnapsack, LCS, Fibonacci
GreedyLocally optimal choiceGreedy-choice propertyActivity selection, Huffman
BacktrackingExplore, undo on dead-endConstraint satisfactionN-Queens, Sudoku, maze
Exam tip

For DP questions, clearly state: (1) the state (what does dp[i][j] represent?), (2) the recurrence, (3) the base case, (4) the final answer. This structure alone earns most of the marks.

VII. Structured Data — CSV, JSON, XML

7.1 CSV — Comma-Separated Values

CSV is the simplest tabular format: rows are lines, columns are separated by commas. Python's csv module handles quoting, escaping and dialect differences.

Writing a CSV file

import csv

rows = [
    ["Name",   "Roll", "Marks"],
    ["Aarav",  101,    88],
    ["Diya",   102,    95],
    ["Kabir",  103,    72],
]

with open("students.csv", "w", newline="") as f:
    writer = csv.writer(f)
    writer.writerows(rows)

Reading a CSV file

import csv

with open("students.csv", "r") as f:
    reader = csv.reader(f)
    header = next(reader)          # consume header row
    for row in reader:
        print(row)                 # ['Aarav', '101', '88']

Using DictReader and DictWriter

import csv

with open("students.csv", "r") as f:
    reader = csv.DictReader(f)
    for row in reader:
        print(row["Name"], "->", row["Marks"])
# Aarav -> 88
# Diya -> 95
# Kabir -> 72

# Writing with field names
data = [
    {"Name": "Meera", "Roll": 104, "Marks": 81},
    {"Name": "Zara",  "Roll": 105, "Marks": 89},
]
with open("extra.csv", "w", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=["Name", "Roll", "Marks"])
    writer.writeheader()
    writer.writerows(data)
Always use newline=""

On Windows, opening a CSV file for writing without newline="" inserts extra blank lines. This is one of the most common file-handling bugs.

7.2 JSON — JavaScript Object Notation

JSON is the standard format for web APIs and configuration. It maps naturally to Python dicts, lists, strings, numbers, booleans and None.

JSONPython
object {...}dict
array [...]list
stringstr
numberint / float
true / falseTrue / False
nullNone
import json

data = {
    "course": "INT108",
    "students": [
        {"name": "Aarav", "marks": [88, 92, 79]},
        {"name": "Diya",  "marks": [95, 81, 90]},
    ],
    "credits": 4,
    "active": True,
}

# --- Write ---
with open("course.json", "w") as f:
    json.dump(data, f, indent=2)

# --- Read ---
with open("course.json", "r") as f:
    loaded = json.load(f)

print(loaded["course"])                  # INT108
print(loaded["students"][0]["name"])     # Aarav

# --- String conversions ---
json_string = json.dumps(data, indent=2)
parsed = json.loads(json_string)
print(parsed == data)                    # True
Example 7.1 — JSON configuration loader with defaults
import json

DEFAULTS = {"theme": "light", "font_size": 11, "autosave": True}

def load_config(path):
    try:
        with open(path) as f:
            user_cfg = json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        user_cfg = {}
    return {**DEFAULTS, **user_cfg}      # user overrides defaults

cfg = load_config("settings.json")
print(cfg)

7.3 XML — eXtensible Markup Language

XML uses nested tags. Python's xml.etree.ElementTree provides a simple API for reading and writing.

import xml.etree.ElementTree as ET

# --- Build a tree ---
root = ET.Element("library")
book = ET.SubElement(root, "book", id="1")
ET.SubElement(book, "title").text = "Python 101"
ET.SubElement(book, "author").text = "A. Kumar"
ET.SubElement(book, "year").text = "2024"

tree = ET.ElementTree(root)
tree.write("library.xml", encoding="utf-8", xml_declaration=True)

# --- Parse a tree ---
tree = ET.parse("library.xml")
root = tree.getroot()

for book in root.findall("book"):
    title = book.find("title").text
    author = book.find("author").text
    print(f"{book.get('id')}: {title} by {author}")
# 1: Python 101 by A. Kumar
Example 7.2 — Convert CSV to JSON
import csv, json

records = []
with open("students.csv", "r") as f:
    reader = csv.DictReader(f)
    for row in reader:
        row["Marks"] = int(row["Marks"])     # type conversion
        records.append(row)

with open("students.json", "w") as f:
    json.dump(records, f, indent=2)

print(f"Converted {len(records)} records.")

7.4 Format Comparison

AspectCSVJSONXML
StructureFlat tables onlyNested objects/arraysNested tags with attributes
ReadabilityHigh for tablesHighVerbose
TypesAll stringsNumbers, bools, nullAll text (needs schema)
Python modulecsvjsonxml.etree.ElementTree
Typical useSpreadsheets, exportsWeb APIs, configLegacy systems, SOAP, RSS

VIII. Database Connectivity with SQLite

SQLite is a serverless, file-based relational database built into Python's standard library through the sqlite3 module. It is perfect for small to medium applications, prototypes and embedded systems.

8.1 Connecting and Creating a Table

import sqlite3

conn = sqlite3.connect("school.db")     # creates the file if missing
cur = conn.cursor()

cur.execute("""
    CREATE TABLE IF NOT EXISTS students (
        roll    INTEGER PRIMARY KEY,
        name    TEXT NOT NULL,
        marks   REAL,
        branch  TEXT
    )
""")

conn.commit()
conn.close()

8.2 CRUD Operations

OperationSQLPython
CreateINSERT INTO ...cur.execute(sql, params)
ReadSELECT ... FROM ...cur.fetchall() / fetchone()
UpdateUPDATE ... SET ...cur.execute(...) then conn.commit()
DeleteDELETE FROM ...cur.execute(...) then conn.commit()
import sqlite3

conn = sqlite3.connect("school.db")
cur = conn.cursor()

# --- INSERT (parameterised — never use f-strings for user input) ---
cur.execute("INSERT INTO students VALUES (?, ?, ?, ?)",
            (101, "Aarav", 88.5, "CSE"))
cur.execute("INSERT INTO students VALUES (?, ?, ?, ?)",
            (102, "Diya", 95.0, "ECE"))

# Insert many at once
cur.executemany("INSERT INTO students VALUES (?, ?, ?, ?)", [
    (103, "Kabir", 72.0, "CSE"),
    (104, "Meera", 81.5, "MEC"),
])
conn.commit()

# --- SELECT ---
cur.execute("SELECT roll, name, marks FROM students ORDER BY marks DESC")
for row in cur.fetchall():
    print(row)

# --- UPDATE ---
cur.execute("UPDATE students SET marks = ? WHERE roll = ?", (90.0, 103))
conn.commit()

# --- DELETE ---
cur.execute("DELETE FROM students WHERE roll = ?", (104,))
conn.commit()

conn.close()
SQL injection

Never build SQL with string formatting:

cur.execute(f"SELECT * FROM users WHERE name = '{name}'")   # DANGEROUS

Use parameterised queries with ? placeholders:

cur.execute("SELECT * FROM users WHERE name = ?", (name,))  # SAFE

8.3 Fetching Styles

MethodReturns
fetchone()First row as a tuple, or None
fetchmany(n)Next n rows as a list
fetchall()All remaining rows as a list of tuples
row_factory = sqlite3.RowRows behave like dicts (access by column name)
conn = sqlite3.connect("school.db")
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute("SELECT * FROM students")

for row in cur:
    print(row["name"], row["marks"])

conn.close()

8.4 Transactions and Error Handling

import sqlite3

conn = sqlite3.connect("bank.db")
try:
    with conn:                          # transaction: commits on success, rolls back on exception
        cur = conn.cursor()
        cur.execute("UPDATE accounts SET balance = balance - 1000 WHERE id = 1")
        cur.execute("UPDATE accounts SET balance = balance + 1000 WHERE id = 2")
except sqlite3.Error as e:
    print("Database error:", e)
finally:
    conn.close()
Example 8.1 — Student manager with SQLite
import sqlite3

class StudentDB:
    def __init__(self, db="school.db"):
        self.conn = sqlite3.connect(db)
        self.conn.row_factory = sqlite3.Row
        self._create_table()

    def _create_table(self):
        with self.conn:
            self.conn.execute("""
                CREATE TABLE IF NOT EXISTS students (
                    roll INTEGER PRIMARY KEY,
                    name TEXT NOT NULL,
                    marks REAL
                )
            """)

    def add(self, roll, name, marks):
        with self.conn:
            self.conn.execute(
                "INSERT INTO students VALUES (?, ?, ?)", (roll, name, marks))

    def topper(self):
        cur = self.conn.execute(
            "SELECT * FROM students ORDER BY marks DESC LIMIT 1")
        return cur.fetchone()

    def average(self):
        cur = self.conn.execute("SELECT AVG(marks) FROM students")
        return cur.fetchone()[0]

    def close(self):
        self.conn.close()

db = StudentDB()
db.add(101, "Aarav", 88.5)
db.add(102, "Diya", 95.0)
db.add(103, "Kabir", 72.0)

print("Topper :", dict(db.topper()))
print(f"Average: {db.average():.2f}")
db.close()

8.5 SQLite vs Other Databases

FeatureSQLiteMySQL / PostgreSQL
SetupNone — single fileServer installation required
ConcurrencyLimited (file lock)High
Python modulesqlite3 (built-in)mysql-connector, psycopg2
Use casePrototypes, embedded appsProduction web apps
Exam tip

For DB questions, always show: (1) connect, (2) create cursor, (3) execute with parameterised query, (4) commit for writes, (5) close. Mention the parameterised ? as protection against SQL injection.

IX. GUI Programming with Tkinter

Tkinter is Python's standard GUI toolkit, bundled with every Python installation. It is ideal for small desktop tools, educational projects and prototypes.

9.1 Anatomy of a Tkinter App

Minimal app

import tkinter as tk
root = tk.Tk()
# add widgets here
root.mainloop()

import tkinter as tk

root = tk.Tk()
root.title("My First GUI")
root.geometry("320x200")

label = tk.Label(root, text="Hello, Tkinter!", font=("Arial", 14))
label.pack(pady=20)

button = tk.Button(root, text="Quit", command=root.destroy)
button.pack()

root.mainloop()

9.2 Common Widgets

WidgetPurpose
LabelDisplay static text or image
ButtonClickable button with a callback
EntrySingle-line text input
TextMulti-line text editor
FrameContainer to group widgets
CheckbuttonOn/off toggle
RadiobuttonOne of a mutually exclusive set
ListboxScrollable list of options
ComboboxDropdown list (ttk)
CanvasDrawing surface for shapes and graphics

9.3 Geometry Managers

ManagerBehaviourBest for
pack()Stacks widgets in a directionSimple vertical/horizontal layouts
grid()Places widgets in rows/columnsForm-like layouts
place()Absolute pixel coordinatesPrecise placement (rarely)
Never mix pack and grid

Using pack() and grid() for widgets in the same parent causes a TclError. Pick one and stick with it throughout the container.

Example 9.1 — Simple calculator
import tkinter as tk

def calculate():
    try:
        result = eval(entry.get())
        output.config(text=f"= {result}")
    except Exception as e:
        output.config(text="Error")

root = tk.Tk()
root.title("Calculator")
root.geometry("300x150")

tk.Label(root, text="Enter expression:").pack(pady=(10, 0))
entry = tk.Entry(root, font=("Consolas", 12), width=25)
entry.pack(pady=6)

tk.Button(root, text="Calculate", command=calculate).pack()
output = tk.Label(root, text="", font=("Arial", 12, "bold"), fg="teal")
output.pack(pady=8)

root.mainloop()

Caution: eval() is safe here only because it is a classroom demo. Never eval untrusted input in production.

Example 9.2 — Form with grid layout
import tkinter as tk
from tkinter import messagebox

def submit():
    name = name_var.get().strip()
    if not name:
        messagebox.showwarning("Input", "Name cannot be empty")
        return
    messagebox.showinfo("Hello", f"Hello, {name}!")

root = tk.Tk()
root.title("Registration")

name_var = tk.StringVar()
branch_var = tk.StringVar(value="CSE")

tk.Label(root, text="Name:").grid(row=0, column=0, sticky="e", padx=8, pady=6)
tk.Entry(root, textvariable=name_var, width=25).grid(row=0, column=1)

tk.Label(root, text="Branch:").grid(row=1, column=0, sticky="e", padx=8)
for i, branch in enumerate(["CSE", "ECE", "MEC", "CIVIL"]):
    tk.Radiobutton(root, text=branch, variable=branch_var,
                   value=branch).grid(row=1+i, column=1, sticky="w")

tk.Button(root, text="Submit", command=submit).grid(
    row=6, column=0, columnspan=2, pady=10)

root.mainloop()
Example 9.3 — To-do list app (OOP style)
import tkinter as tk
from tkinter import messagebox

class TodoApp:
    def __init__(self, root):
        self.root = root
        self.root.title("To-Do List")
        self.tasks = []

        self.entry = tk.Entry(root, width=40)
        self.entry.pack(pady=8, padx=10)

        tk.Button(root, text="Add Task", command=self.add).pack()

        self.listbox = tk.Listbox(root, width=50, height=12)
        self.listbox.pack(pady=8, padx=10)

        tk.Button(root, text="Delete Selected",
                  command=self.delete).pack(pady=(0, 10))

    def add(self):
        task = self.entry.get().strip()
        if task:
            self.tasks.append(task)
            self.listbox.insert(tk.END, task)
            self.entry.delete(0, tk.END)
        else:
            messagebox.showwarning("Input", "Please enter a task.")

    def delete(self):
        selection = self.listbox.curselection()
        if not selection:
            return
        idx = selection[0]
        self.listbox.delete(idx)
        self.tasks.pop(idx)

root = tk.Tk()
TodoApp(root)
root.mainloop()
Exam tip

For GUI questions, a compact 15–20 line example with at least two widgets and one event handler is usually sufficient. Mention the event loop (mainloop()) and the callback pattern (command=function).

X. Introduction to NumPy & Matplotlib

10.1 Why NumPy?

NumPy (Numerical Python) provides the ndarray — a fast, memory-efficient, N-dimensional array. It is the foundation of the Python data-science stack (pandas, scikit-learn, TensorFlow).

FeaturePython listNumPy array
Element typesMixedHomogeneous (single dtype)
MemoryPointer per elementContiguous block
SpeedInterpreted loopsVectorised C operations (10–100× faster)
Math operationsElement-by-element loopsDirect operators (a + b, a * 2)
DimensionsNested listsN-dimensional ndarray

10.2 Creating Arrays

import numpy as np

a = np.array([1, 2, 3, 4])
print(a)                       # [1 2 3 4]
print(a.dtype)                 # int64
print(a.shape)                 # (4,)
print(a.ndim)                  # 1

b = np.array([[1, 2, 3], [4, 5, 6]])
print(b.shape)                 # (2, 3)

# Convenience constructors
print(np.zeros((2, 3)))        # 2x3 of 0.0
print(np.ones((2, 2)))         # 2x2 of 1.0
print(np.full((2, 2), 7))      # 2x2 of 7
print(np.eye(3))               # 3x3 identity
print(np.arange(0, 10, 2))     # [0 2 4 6 8]
print(np.linspace(0, 1, 5))    # [0. 0.25 0.5 0.75 1.]
print(np.random.rand(2, 3))    # 2x3 uniform [0,1)

10.3 Vectorised Operations

import numpy as np

a = np.array([1, 2, 3, 4])
b = np.array([10, 20, 30, 40])

print(a + b)         # [11 22 33 44]
print(a * 2)         # [ 2  4  6  8]
print(a ** 2)        # [ 1  4  9 16]
print(np.sqrt(a))    # [1. 1.414 ... ]

# Aggregations
print(a.sum(), a.mean(), a.min(), a.max(), a.std())

# Boolean masking
print(a[a > 2])      # [3 4]

# Reshaping
m = np.arange(12).reshape(3, 4)
print(m.T)           # transpose
print(m.flatten())   # 1-D copy
Example 10.1 — Matrix multiplication
import numpy as np

A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])

print(A @ B)               # matrix product (Python 3.5+)
print(np.dot(A, B))        # same result
# [[19 22]
#  [43 50]]

10.4 Matplotlib Basics

Matplotlib is the standard plotting library for Python. Its pyplot interface works like MATLAB.

Example 10.2 — Line and scatter plots
import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(0, 2 * np.pi, 100)
y = np.sin(x)

plt.figure(figsize=(8, 4))
plt.plot(x, y, label="sin(x)", color="teal", linewidth=2)
plt.plot(x, np.cos(x), label="cos(x)", linestyle="--", color="orange")
plt.title("Trigonometric Functions")
plt.xlabel("x (radians)")
plt.ylabel("value")
plt.grid(True, alpha=0.3)
plt.legend()
plt.tight_layout()
plt.savefig("trig.png", dpi=120)
plt.show()
Example 10.3 — Histogram and bar chart
import matplotlib.pyplot as plt
import numpy as np

# Histogram of normally distributed data
data = np.random.normal(loc=170, scale=8, size=1000)   # heights in cm
plt.figure(figsize=(8, 4))
plt.hist(data, bins=30, color="steelblue", edgecolor="white")
plt.title("Height distribution")
plt.xlabel("Height (cm)")
plt.ylabel("Frequency")
plt.tight_layout()
plt.savefig("hist.png", dpi=120)

# Bar chart of categorical counts
subjects = ["Maths", "Physics", "Python", "Chemistry"]
marks = [92, 85, 98, 78]
plt.figure(figsize=(8, 4))
plt.bar(subjects, marks, color="teal")
plt.title("Marks by Subject")
plt.ylabel("Marks")
plt.tight_layout()
plt.savefig("bar.png", dpi=120)
Example 10.4 — Statistical analysis with NumPy
import numpy as np

marks = np.array([88, 92, 79, 95, 81, 90, 72, 85])

print(f"Mean    : {marks.mean():.2f}")
print(f"Median  : {np.median(marks):.2f}")
print(f"Std dev : {marks.std():.2f}")
print(f"Max/Min : {marks.max()} / {marks.min()}")
print(f"Above 90: {marks[marks > 90]}")
print(f"Percentile 25, 75: {np.percentile(marks, [25, 75])}")

10.5 Installation

# Via pip
pip install numpy matplotlib

# Or via conda (recommended in Anaconda environments)
conda install numpy matplotlib
Exam tip

For data-science questions, remember: NumPy array creation (array, zeros, arange, linspace), vectorised ops, and matplotlib's plot/bar/hist/scatter. Show at least one plot with title, axis labels and legend.

XI. Testing, Debugging & Profiling

11.1 The assert Statement

The simplest form of testing: assert condition, message. If the condition is false, Python raises AssertionError.

def divide(a, b):
    assert b != 0, "Denominator cannot be zero"
    return a / b

print(divide(10, 2))     # 5.0
# divide(10, 0)          # AssertionError: Denominator cannot be zero
assert is not for validation

Assertions are removed when Python runs with -O (optimised mode). Use them for internal sanity checks, not for validating user input — use if + raise for that.

11.2 unittest — The Standard Framework

import unittest

def add(a, b):
    return a + b

def is_even(n):
    return n % 2 == 0

class TestMathFunctions(unittest.TestCase):

    def setUp(self):
        """Runs before every test method."""
        self.values = [1, 2, 3, 4, 5]

    def test_add_positive(self):
        self.assertEqual(add(2, 3), 5)

    def test_add_negative(self):
        self.assertEqual(add(-1, -1), -2)

    def test_add_zero(self):
        self.assertEqual(add(0, 7), 7)

    def test_is_even(self):
        self.assertTrue(is_even(4))
        self.assertFalse(is_even(5))

    def test_sum_of_list(self):
        self.assertEqual(sum(self.values), 15)

if __name__ == "__main__":
    unittest.main()

Run from the terminal:

python -m unittest test_math.py -v

11.3 Common unittest Assertions

AssertionChecks
assertEqual(a, b)a == b
assertNotEqual(a, b)a != b
assertTrue(x) / assertFalse(x)Truth value
assertIs(a, b) / assertIsNotIdentity
assertIn(a, b) / assertNotInMembership
assertRaises(Exc)Exception is raised
assertAlmostEqual(a, b)Float equality within tolerance
Example 11.1 — Testing exceptions
import unittest

def safe_divide(a, b):
    if b == 0:
        raise ZeroDivisionError("Cannot divide by zero")
    return a / b

class TestDivide(unittest.TestCase):
    def test_valid(self):
        self.assertEqual(safe_divide(10, 2), 5)

    def test_zero_division(self):
        with self.assertRaises(ZeroDivisionError):
            safe_divide(10, 0)

if __name__ == "__main__":
    unittest.main()

11.4 doctest — Testing Documentation

Embed tests directly in docstrings; doctest extracts and runs them.

def factorial(n):
    """Return n! for a non-negative integer.

    >>> factorial(0)
    1
    >>> factorial(5)
    120
    >>> factorial(3)
    6
    """
    if n <= 1:
        return 1
    return n * factorial(n - 1)

if __name__ == "__main__":
    import doctest
    doctest.testmod()

11.5 Debugging Techniques

TechniqueWhen to use
print() statementsQuick inspection of variables
logging moduleStructured, level-based output for real apps
pdb (Python debugger)Step through code interactively
IDE breakpointsVisual debugging (VS Code, PyCharm)
Traceback analysisRead from bottom up to find the actual error

Using the debugger

import pdb

def buggy(x):
    pdb.set_trace()          # execution pauses here
    return x * 2 / (x - 3)

# In the debugger prompt:
# n       - next line
# s       - step into
# c       - continue
# p var   - print variable
# q       - quit

Using logging instead of print

import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s - %(levelname)s - %(message)s"
)

def process(amount):
    logging.debug(f"Received amount = {amount}")
    if amount < 0:
        logging.error("Negative amount!")
        return 0
    logging.info(f"Processing {amount}")
    return amount * 2

process(100)
process(-5)

11.6 Profiling — Finding Slow Code

import cProfile

def slow_function():
    total = 0
    for i in range(1_000_000):
        total += i ** 2
    return total

cProfile.run("slow_function()")

Or from the command line:

python -m cProfile -s cumtime my_script.py

11.7 Test Structure — Arrange, Act, Assert

AAA pattern

Arrange — set up data and objects.
Act — call the function under test.
Assert — verify the outcome.

def test_average():
    # Arrange
    marks = [80, 90, 100]

    # Act
    result = sum(marks) / len(marks)

    # Assert
    assert result == 90.0
Exam tip

For testing questions, show a unittest.TestCase with at least three test methods, one assertRaises and one assertAlmostEqual. Mention that tests are runnable via python -m unittest.

XII. Integrated Mini Projects

These projects combine concepts from Units I–III. They serve as portfolio pieces and practical-exam templates.

12.1 Expense Tracker (SQLite + CLI)

Project 1 — Personal expense manager
import sqlite3
from datetime import date

class ExpenseTracker:
    def __init__(self, db="expenses.db"):
        self.conn = sqlite3.connect(db)
        self.conn.row_factory = sqlite3.Row
        with self.conn:
            self.conn.execute("""
                CREATE TABLE IF NOT EXISTS expenses (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    date TEXT, category TEXT, amount REAL, note TEXT
                )
            """)

    def add(self, category, amount, note=""):
        with self.conn:
            self.conn.execute(
                "INSERT INTO expenses (date, category, amount, note) VALUES (?, ?, ?, ?)",
                (date.today().isoformat(), category, amount, note))

    def total_by_category(self):
        cur = self.conn.execute(
            "SELECT category, SUM(amount) AS total FROM expenses GROUP BY category ORDER BY total DESC")
        return [dict(r) for r in cur.fetchall()]

    def monthly_total(self, month_prefix):
        cur = self.conn.execute(
            "SELECT SUM(amount) FROM expenses WHERE date LIKE ?",
            (f"{month_prefix}%",))
        return cur.fetchone()[0] or 0

    def close(self):
        self.conn.close()

t = ExpenseTracker()
t.add("Food", 250.0, "Lunch")
t.add("Transport", 80.0, "Auto")
t.add("Food", 320.0, "Dinner")

print("By category:", t.total_by_category())
print("This month :", t.monthly_total(date.today().strftime("%Y-%m")))
t.close()

12.2 Student Result Dashboard (CSV + Matplotlib)

Project 2 — Read marks from CSV, plot a bar chart
import csv
import matplotlib.pyplot as plt

names, averages = [], []

with open("students.csv") as f:
    for row in csv.DictReader(f):
        marks = [int(row[c]) for c in ("Maths", "Physics", "Python")]
        names.append(row["Name"])
        averages.append(sum(marks) / len(marks))

plt.figure(figsize=(8, 4))
bars = plt.bar(names, averages, color="teal")
plt.title("Average Marks per Student")
plt.ylabel("Average")
plt.ylim(0, 100)
for bar, avg in zip(bars, averages):
    plt.text(bar.get_x() + bar.get_width()/2, avg + 1,
             f"{avg:.1f}", ha="center", fontsize=9)
plt.tight_layout()
plt.savefig("dashboard.png", dpi=120)
plt.show()

12.3 Password Manager (Decorators + JSON + Regex)

Project 3 — With strength checker and logging decorator
import json, re, hashlib
from functools import wraps

def log_action(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"[LOG] {func.__name__} called")
        return func(*args, **kwargs)
    return wrapper

def check_strength(pwd):
    score = 0
    score += bool(re.search(r"[a-z]", pwd))
    score += bool(re.search(r"[A-Z]", pwd))
    score += bool(re.search(r"\d", pwd))
    score += bool(re.search(r"[!@#$%^&*]", pwd))
    score += len(pwd) >= 12
    return ["Weak", "Weak", "Fair", "Good", "Strong", "Very Strong"][score]

class PasswordManager:
    def __init__(self, path="vault.json"):
        self.path = path
        try:
            with open(path) as f:
                self.vault = json.load(f)
        except FileNotFoundError:
            self.vault = {}

    @log_action
    def add(self, site, password):
        if site in self.vault:
            raise ValueError(f"{site} already exists")
        self.vault[site] = hashlib.sha256(password.encode()).hexdigest()
        self._save()
        return check_strength(password)

    def verify(self, site, password):
        hashed = hashlib.sha256(password.encode()).hexdigest()
        return self.vault.get(site) == hashed

    def _save(self):
        with open(self.path, "w") as f:
            json.dump(self.vault, f, indent=2)

pm = PasswordManager()
print("Strength:", pm.add("gmail", "Str0ng!Pass123"))
print("Verify  :", pm.verify("gmail", "Str0ng!Pass123"))
print("Wrong   :", pm.verify("gmail", "wrongpass"))

12.4 Quiz Generator (Regex + CSV + Generator)

Project 4 — Parse questions from a text file lazily
import re

def parse_questions(filename):
    """Generator that yields (question, answer) tuples lazily."""
    pattern = re.compile(r"^Q:\s*(.+?)\s*\|\s*A:\s*(.+)$")
    with open(filename) as f:
        for line in f:
            m = pattern.match(line.strip())
            if m:
                yield m.group(1), m.group(2)

def run_quiz(filename):
    score = 0
    total = 0
    for question, answer in parse_questions(filename):
        total += 1
        user = input(f"{question} = ").strip()
        if user.lower() == answer.lower():
            score += 1
            print("  Correct!")
        else:
            print(f"  Wrong. Answer: {answer}")
    print(f"\nScore: {score}/{total}")

# quiz.txt example:
# Q: 2 + 2 | A: 4
# Q: Capital of France | A: Paris
# run_quiz("quiz.txt")

12.5 Weather Data Analyser (NumPy + Matplotlib)

Project 5 — Simulated temperature analysis
import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)

days = np.arange(1, 31)
temps = 28 + 6 * np.sin(2 * np.pi * days / 30) + np.random.normal(0, 1.5, 30)

plt.figure(figsize=(9, 4))
plt.plot(days, temps, marker="o", color="teal", label="Daily temp")
plt.axhline(temps.mean(), color="orange", linestyle="--",
            label=f"Mean = {temps.mean():.1f}°C")
plt.fill_between(days, temps.mean() - temps.std(),
                 temps.mean() + temps.std(),
                 alpha=0.15, color="teal", label="±1σ")
plt.title("Simulated Monthly Temperature")
plt.xlabel("Day")
plt.ylabel("Temperature (°C)")
plt.legend()
plt.grid(alpha=0.3)
plt.tight_layout()
plt.savefig("weather.png", dpi=120)
plt.show()
Project exam strategy

In practical exams, structure your answer: (1) imports, (2) class or functions with docstrings, (3) main logic using the right data structure, (4) a small driver with sample input and expected output. Comments above each block earn extra marks. Always mention time/space complexity where relevant.

XIII. Summary & Quick Reference Sheet

13.1 Decorators & Generators Cheat Sheet

ConceptSyntax
Simple decoratordef deco(f): @wraps(f); def wrapper(*a, **k): return f(*a, **k); return wrapper
Decorator with argsdef deco(arg): def inner(f): ... return inner
Apply decorator@deco above the function definition
Generator functiondef g(): yield value
Generator expression(expr for x in iter if cond)
Delegateyield from iterable
Memoize@lru_cache(maxsize=None)

13.2 Context Manager Reference

ApproachWhen to use
Class with __enter__/__exit__Complex resources, custom cleanup
@contextmanagerSimple resources with a single yield
contextlib.suppressIgnore specific exceptions
contextlib.ExitStackDynamic number of resources

13.3 collections Cheat Sheet

ClassOne-line purpose
CounterCounter("aab").most_common(1)[('a', 2)]
defaultdictdd = defaultdict(list); dd[k].append(v)
OrderedDictOrdered mapping with move_to_end
namedtuplePoint = namedtuple("Point", "x y")
dequed.appendleft(x), d.popleft(), deque(maxlen=3)
ChainMapLayered lookups: ChainMap(user, defaults)

13.4 Data Structures Complexity

StructureSearchInsertDelete
Stack (list)\(O(n)\)\(O(1)\) push\(O(1)\) pop
Queue (deque)\(O(n)\)\(O(1)\)\(O(1)\)
Heap\(O(n)\)\(O(\log n)\)\(O(\log n)\)
Linked List\(O(n)\)\(O(1)\) head\(O(n)\)
BST (balanced)\(O(\log n)\)\(O(\log n)\)\(O(\log n)\)

13.5 Structured Data Comparison

FormatModuleBest for
CSVcsvFlat tables, spreadsheets
JSONjsonWeb APIs, nested config
XMLxml.etree.ElementTreeLegacy systems, RSS
SQLitesqlite3Relational queries, persistence
PicklepicklePython object snapshots

13.6 Libraries Quick Reference

LibraryPurposeKey API
collectionsSpecialised containersCounter, deque, defaultdict
functoolsHigher-order helperswraps, lru_cache, reduce
contextlibContext manager helperscontextmanager, suppress
heapqHeap operationsheappush, heappop
csv / jsonStructured data I/ODictReader, dump/load
sqlite3Embedded databaseconnect, execute
tkinterGUI toolkitTk, Label, Button, mainloop
numpyNumeric arraysarray, arange, linspace
matplotlibPlottingplot, bar, hist
unittestTestingTestCase, assertEqual

XIV. Exam Tips & Practice Questions

Top 10 Exam Tips

  1. Use @wraps in every decorator — it preserves __name__ and __doc__, and examiners look for it.
  2. Generators save memory. When asked to process large files, always prefer a generator over building a list.
  3. Context managers guarantee cleanup. Prefer with over manual close() in try/finally.
  4. Know three collections classes well: Counter, defaultdict, deque. They appear in many questions.
  5. Never use list.pop(0) for queues. Use collections.deque — \(O(1)\) vs \(O(n)\).
  6. For DP questions, state state, recurrence, base case, answer. This structure earns most of the marks even if the final code has a bug.
  7. Use parameterised SQL queries (? placeholders) to prevent SQL injection.
  8. In GUI code, mention mainloop() — it starts the event loop. Also mention you must never mix pack and grid in the same container.
  9. NumPy arrays are vectorised. Do not write explicit loops; use array operations. That's the whole point.
  10. Write tests using the AAA pattern (Arrange, Act, Assert) and mention python -m unittest to run them.

Practice Questions

Q1.Write a decorator @uppercase that converts the return value of a function to uppercase. Apply it to a function returning a string.Easy
Q2.Write a generator primes_up_to(n) that yields primes up to n. Print the first 10 primes.Medium
Q3.Implement a custom context manager DatabaseConnection that opens a SQLite connection, commits on success and rolls back on error.Medium
Q4.Use collections.Counter to find the three most common words in a paragraph. Ignore case and punctuation.Easy
Q5.Implement a stack class with push, pop, peek, is_empty and __len__. Use it to check whether an expression has balanced parentheses.Medium
Q6.Write a Python program that finds the length of the longest common subsequence of two strings using dynamic programming. Show the DP table for s1 = "ABCBDAB", s2 = "BDCABA".Hard
Q7.Given a CSV file with student names and marks, write a program that converts it to JSON and then back to CSV without losing data.Medium
Q8.Write a Tkinter application with an Entry for a number and a Button that displays its square in a Label.Medium
Q9.Using NumPy, generate 1000 samples from a normal distribution with mean 50 and standard deviation 10. Print the mean, median, and the number of samples above 60.Medium
Q10.Write a unittest test suite for a function is_palindrome(s) that should pass for "madam", "racecar", "noon" and fail for "hello", "python".Easy
Q11.Explain the difference between a class-based context manager and a generator-based one. Give one example of each.Medium
Q12.Write a @retry decorator that retries a function up to 3 times on failure with a 0.5-second delay between attempts. Demonstrate it on a function that fails randomly.Hard

XV. Full Solutions to Practice Questions

Solution 1 — Uppercase decorator

from functools import wraps

def uppercase(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result.upper()
    return wrapper

@uppercase
def greet(name):
    return f"hello, {name}"

print(greet("aarav"))     # HELLO, AARAV

Solution 2 — Prime generator

def primes_up_to(n):
    for num in range(2, n + 1):
        if all(num % d != 0 for d in range(2, int(num ** 0.5) + 1)):
            yield num

# Print the first 10 primes
count = 0
for prime in primes_up_to(100):
    print(prime, end=" ")
    count += 1
    if count == 10:
        break
print()
# 2 3 5 7 11 13 17 19 23 29

Solution 3 — DatabaseConnection context manager

import sqlite3

class DatabaseConnection:
    def __init__(self, db_path):
        self.db_path = db_path
        self.conn = None

    def __enter__(self):
        self.conn = sqlite3.connect(self.db_path)
        return self.conn

    def __exit__(self, exc_type, exc_val, tb):
        if exc_type is None:
            self.conn.commit()
            print("Committed.")
        else:
            self.conn.rollback()
            print(f"Rolled back: {exc_val}")
        self.conn.close()
        return False

with DatabaseConnection("test.db") as conn:
    conn.execute("CREATE TABLE IF NOT EXISTS t (id INTEGER, v TEXT)")
    conn.execute("INSERT INTO t VALUES (1, 'hello')")

Solution 4 — Top 3 words with Counter

import re
from collections import Counter

text = """
Python is great. Python is powerful and Python is easy to learn.
Data science with Python is a popular career path.
"""

words = re.findall(r"[a-z]+", text.lower())
top3 = Counter(words).most_common(3)

for word, n in top3:
    print(f"{word}: {n}")

Solution 5 — Stack with balanced parentheses

class Stack:
    def __init__(self):
        self.items = []

    def push(self, item):
        self.items.append(item)

    def pop(self):
        if self.is_empty():
            raise IndexError("pop from empty stack")
        return self.items.pop()

    def peek(self):
        return self.items[-1] if self.items else None

    def is_empty(self):
        return len(self.items) == 0

    def __len__(self):
        return len(self.items)

def is_balanced(expr):
    pairs = {")": "(", "]": "[", "}": "{"}
    stack = Stack()
    for ch in expr:
        if ch in "([{":
            stack.push(ch)
        elif ch in pairs:
            if stack.is_empty() or stack.pop() != pairs[ch]:
                return False
    return stack.is_empty()

print(is_balanced("{[()]}"))     # True
print(is_balanced("([)]"))       # False
print(is_balanced("((()))"))     # True

Solution 6 — Longest Common Subsequence with DP table

def lcs(s1, s2):
    m, n = len(s1), len(s2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]

    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if s1[i-1] == s2[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])
    return dp[m][n], dp

length, table = lcs("ABCBDAB", "BDCABA")
print("LCS length:", length)     # 4

# DP table (rows = s1, columns = s2)
print("    ''  B  D  C  A  B  A")
for i, row in enumerate(table):
    label = "''" if i == 0 else f" {s1[i-1]}"
    print(f"{label:>4}", row)

Answer: LCS length = 4 (e.g., BCBA or BDAB).

Solution 7 — CSV ↔ JSON roundtrip

import csv, json

# CSV -> JSON
records = []
with open("students.csv") as f:
    for row in csv.DictReader(f):
        row["Marks"] = int(row["Marks"])
        records.append(row)

with open("students.json", "w") as f:
    json.dump(records, f, indent=2)

# JSON -> CSV
with open("students.json") as f:
    data = json.load(f)

with open("students_out.csv", "w", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=["Name", "Roll", "Marks"])
    writer.writeheader()
    writer.writerows(data)

print(f"Roundtripped {len(data)} records.")

Solution 8 — Tkinter square calculator

import tkinter as tk

def compute_square():
    try:
        n = float(entry.get())
        result_label.config(text=f"Square = {n ** 2}")
    except ValueError:
        result_label.config(text="Please enter a valid number.")

root = tk.Tk()
root.title("Square Calculator")
root.geometry("300x160")

tk.Label(root, text="Enter a number:").pack(pady=(14, 2))
entry = tk.Entry(root, width=20)
entry.pack()

tk.Button(root, text="Compute Square", command=compute_square).pack(pady=10)

result_label = tk.Label(root, text="", font=("Arial", 12, "bold"), fg="teal")
result_label.pack()

root.mainloop()

Solution 9 — NumPy statistical analysis

import numpy as np

np.random.seed(0)
data = np.random.normal(loc=50, scale=10, size=1000)

print(f"Mean    : {data.mean():.2f}")
print(f"Median  : {np.median(data):.2f}")
print(f"Above 60: {(data > 60).sum()} samples")
print(f"Std dev : {data.std():.2f}")

Sample output: Mean ≈ 49.96, Median ≈ 49.97, Above 60 ≈ 158 samples, Std ≈ 9.95.

Solution 10 — unittest for palindrome

import unittest

def is_palindrome(s):
    s = s.lower().replace(" ", "")
    return s == s[::-1]

class TestPalindrome(unittest.TestCase):
    def test_palindromes(self):
        for word in ["madam", "racecar", "noon", "A man a plan a canal Panama"]:
            with self.subTest(word=word):
                self.assertTrue(is_palindrome(word))

    def test_non_palindromes(self):
        for word in ["hello", "python", "world"]:
            with self.subTest(word=word):
                self.assertFalse(is_palindrome(word))

    def test_empty_string(self):
        self.assertTrue(is_palindrome(""))

if __name__ == "__main__":
    unittest.main()

Solution 11 — Class-based vs generator-based context manager

# Class-based
class TimerClass:
    def __enter__(self):
        import time
        self.start = time.perf_counter()
        return self
    def __exit__(self, *exc):
        import time
        print(f"Class : {time.perf_counter() - self.start:.4f}s")

# Generator-based
from contextlib import contextmanager
import time

@contextmanager
def timer_gen():
    start = time.perf_counter()
    try:
        yield
    finally:
        print(f"Gen   : {time.perf_counter() - start:.4f}s")

with TimerClass():
    sum(range(100_000))

with timer_gen():
    sum(range(100_000))

Key difference: the class-based version is more verbose but gives full control (e.g., you can return a value from __enter__, store state, and decide whether to suppress exceptions). The generator-based version is concise and ideal for simple setup/teardown.

Solution 12 — Retry decorator

import time, random
from functools import wraps

def retry(max_attempts=3, delay=0.5):
    def decorator(func):
        @wraps(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}/{max_attempts} failed: {e}")
                    if attempt == max_attempts:
                        raise
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(max_attempts=3, delay=0.5)
def flaky():
    if random.random() < 0.7:
        raise ConnectionError("Simulated network failure")
    return "Success!"

random.seed(7)
try:
    print(flaky())
except ConnectionError:
    print("All attempts failed.")

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-2Fluent PythonLuciano RamalhoO'Reilly
R-3Python CookbookDavid Beazley & Brian K. JonesO'Reilly

16.2 Relevant Web Resources

CodeResourcePurpose
RW-1docs.python.org/3/library/Official standard library reference
RW-2realpython.comDeep-dive tutorials on decorators, generators
RW-3numpy.org/doc/NumPy documentation
RW-4matplotlib.org/stable/Matplotlib documentation
RW-5docs.python.org/3/library/tkinter.htmlTkinter reference
AV-1nptel.ac.in/courses/106106145Video lectures

16.3 Key Takeaways

  1. First-class functions make closures and decorators possible; closures capture variables from the enclosing scope.
  2. Decorators wrap functions to add behaviour without modifying them; always use @functools.wraps to preserve metadata.
  3. Generators produce values lazily with yield; they are memory-efficient and can represent infinite sequences.
  4. Context managers guarantee cleanup via __enter__/__exit__ or @contextmanager; always prefer with over manual cleanup.
  5. The collections module provides Counter, defaultdict, deque, namedtuple, OrderedDict and ChainMap — each solving a specific pattern elegantly.
  6. Classic data structures (stack, queue, heap, linked list, BST) can be built in Python using classes; each has well-defined complexity trade-offs.
  7. Algorithm design paradigms — divide-and-conquer, DP, greedy and backtracking — each apply to a specific class of problems.
  8. Structured data formats (CSV, JSON, XML) have standard library support; choose the format based on data shape and interoperability.
  9. SQLite provides a serverless relational database in the standard library; always use parameterised queries to prevent SQL injection.
  10. Tkinter is Python's standard GUI toolkit; mainloop() starts the event loop and callbacks respond to user actions.
  11. NumPy and Matplotlib form the base of the scientific Python stack — vectorised arrays and publication-quality plots.
  12. Testing with unittest and doctest catches regressions; use the AAA pattern and run with python -m unittest.

16.4 CO Mapping

Course OutcomeCovered in SectionsKey Deliverables
CO3 — Functions and recursionI, II, VIClosures, decorators, generators, recursion, DP
CO4 — Core data structuresIV, V, VICollections module, stacks, queues, heaps, linked lists, BST
CO5 — Object-oriented programmingI, III, XIIProperties, dataclasses, context managers, mini projects
CO6 — File handling and regexVII, VIII, IX, X, XICSV/JSON/XML, SQLite, Tkinter, NumPy, testing

16.5 Recommended Practice Order

WeekFocusSections
1Decorators, generatorsI, II
2Context managers, collectionsIII, IV
3Data structures & algorithmsV, VI
4Structured data, databasesVII, VIII
5GUI, NumPy, testingIX, X, XI
6–7Integrated mini projectsXII
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. The coding contests test problem-solving speed — practice one DSA-style problem and one Python-specific problem daily.

End of Unit III

Advanced Python Techniques & Applications

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

“Simplicity is the ultimate sophistication.” — Leonardo da Vinci