Teqvault.study โ€” Python Series

Phase 2: Pythonic Thinking

Stop writing Java with Python syntax. Learn the idioms, patterns, and power tools that make Python code actually feel like Python โ€” and unlock the whole ecosystem.

๐Ÿง  ๐Ÿ”ฎ โš™๏ธ ๐ŸŒ€ โœจ
Phase Progress
0%
LESSON 01

What Does "Pythonic" Actually Mean?

Every language has an idiomatic way of doing things. Java code and Python code can both work โ€” but only one of them looks like it belongs. This lesson explains the difference and why it matters.

๐Ÿง  The Core Idea

"Pythonic" means code that uses Python's features the way they were designed to be used โ€” not just code that runs in Python. It's the difference between a tourist who can speak the language and a local who thinks in it.

Pythonic code is:

๐Ÿ“–
Readable

A stranger should be able to read your function and understand what it does without comments.

โœ‚๏ธ
Concise

Not short for the sake of cleverness โ€” short because the language handles the boilerplate.

๐ŸŽฏ
Expressive

Code that says what it does, not how it does it. The how should be invisible.

๐Ÿ—๏ธ
Uses the Language

Uses built-ins, standard library, and Python's own protocols rather than reinventing them.

โš–๏ธ Pythonic vs Non-Pythonic โ€” Real Examples

These both work. One is Pythonic. Understanding why is the whole lesson.

โŒ Non-Pythonic
# Checking if a list is empty
if len(my_list) == 0:
    print("empty")

# Looping with index manually
i = 0
while i < len(items):
    print(items[i])
    i += 1

# Getting index + value
for i in range(len(items)):
    print(i, items[i])

# Swapping two values
temp = a
a = b
b = temp
โœ… Pythonic
# Checking if a list is empty
if not my_list:
    print("empty")

# Looping โ€” Python handles iteration
for item in items:
    print(item)

# Getting index + value
for i, item in enumerate(items):
    print(i, item)

# Swapping two values
a, b = b, a
๐Ÿ” Why it matters beyond style

The if not my_list difference

Python objects have a concept called truthiness โ€” every object knows whether it "counts as True" in a boolean context. Empty lists, empty strings, empty dicts, None, and zero all evaluate to False. When you write if not my_list, you're using Python's built-in protocol. When you write if len(my_list) == 0, you're fighting it. The Pythonic version is shorter AND works correctly with any sequence-like object, not just lists.

๐Ÿ“œ The Zen of Python โ€” The Guiding Principles

Python ships with a built-in philosophy document. Type import this in any Python REPL and it prints. These aren't just quotes โ€” they're design decisions baked into the language.

The Zen of Python (most relevant lines)
Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Readability counts.
Special cases aren't special enough to break the rules.
Errors should never pass silently.
There should be one obvious way to do it.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.

You'll make better design decisions in every language if you internalize these. "Errors should never pass silently" is why we'll talk about proper exception handling in Lesson 08.

LESSON 02

Dunder Methods โ€” The Magic Protocol

Methods with double underscores on both sides โ€” __init__, __str__, __len__ โ€” are how Python lets your objects plug into the language itself. This is one of Python's most powerful features.

๐Ÿ”ฎ What "Dunder" Really Means

"Dunder" is short for Double UNDERscore. These methods are also called magic methods or special methods. They're not magic โ€” they're a protocol. When Python evaluates an expression like len(my_obj), it calls my_obj.__len__() behind the scenes. When you write a + b, Python calls a.__add__(b).

This means: if you define these methods on your class, your objects behave like built-in Python objects. Your custom class can support len(), iteration, comparison, arithmetic, indexing, and the with statement โ€” just by implementing the right dunder methods.

โš™๏ธ Under the Hood
Python uses dunder methods to implement its data model. Every operator, every built-in function, and several language constructs (like for loops and with statements) ultimately call a dunder method on an object. This is why Python's syntax is so clean โ€” the language itself delegates to your objects rather than hard-coding behavior for specific types.
๐Ÿ“‹ The Most Important Dunders
MethodCalled whenโ€ฆJava equivalent
__init__Object is created: Foo()Constructor
__str__print(obj) or str(obj)toString()
__repr__REPL display, debugging, repr(obj)No direct equiv โ€” more detailed toString
__len__len(obj)No direct equiv โ€” implement .size()
__eq__obj1 == obj2equals()
__lt__obj1 < obj2compareTo()
__add__obj1 + obj2No direct equiv
__contains__x in objNo direct equiv โ€” implement .contains()
__iter__for x in objImplement Iterable
__getitem__obj[key]No direct equiv
__enter__ / __exit__with obj as x:Try-with-resources (partial)
__call__obj(args) โ€” call the object like a function!No equivalent
๐Ÿ—๏ธ Building a Class That Uses the Language

Let's build a Hand class for a card game. By implementing the right dunders, it works natively with len(), in, for, print(), and comparison โ€” all without calling any methods explicitly.

Python โ€” Dunder Methods in Practice
from dataclasses import dataclass

@dataclass
class Card:
    suit:  str
    value: int

    def __str__(self) -> str:
        # print(card) โ†’ "Aโ™ " not "Card(suit='spades', value=14)"
        symbols = {'spades':'โ™ ', 'hearts':'โ™ฅ', 'clubs':'โ™ฃ', 'diamonds':'โ™ฆ'}
        names   = {11:'J', 12:'Q', 13:'K', 14:'A'}
        face    = names.get(self.value, str(self.value))
        return f"{face}{symbols[self.suit]}"

    def __repr__(self) -> str:
        # repr() is for developers โ€” unambiguous, reproducible
        return f"Card(suit={self.suit!r}, value={self.value})"

    def __lt__(self, other: 'Card') -> bool:
        # Makes cards sortable with sorted() and min()/max()
        return self.value < other.value


class Hand:
    def __init__(self, cards: list[Card] = None):
        self._cards = cards or []

    # len(hand) โ†’ number of cards
    def __len__(self) -> int:
        return len(self._cards)

    # card in hand โ†’ True/False
    def __contains__(self, card: Card) -> bool:
        return card in self._cards

    # for card in hand: ... โ†’ iterate naturally
    def __iter__(self):
        return iter(self._cards)

    # hand[0] โ†’ first card
    def __getitem__(self, index: int) -> Card:
        return self._cards[index]

    # print(hand) โ†’ readable representation
    def __str__(self) -> str:
        return " | ".join(str(c) for c in self._cards)

    def add(self, card: Card) -> None:
        self._cards.append(card)


# โ”€โ”€ Now your class works like a built-in type โ”€โ”€
hand = Hand([
    Card('spades', 14),
    Card('hearts', 7),
    Card('clubs',  11),
])

print(hand)                           # Aโ™  | 7โ™ฅ | Jโ™ฃ
print(len(hand))                       # 3
print(Card('spades', 14) in hand)     # True
print(sorted(hand))                    # sorted by value โ€” works!
print(max(hand))                       # highest card โ€” works!
for card in hand:                      # for loop โ€” works!
    print(card)
๐Ÿ” __str__ vs __repr__ โ€” The Rule

When to use which one

__str__ is for humans โ€” it's what print() shows. Make it readable and friendly. __repr__ is for developers and debugging โ€” it should be unambiguous and ideally reproducible (i.e., you could paste the output back into Python and recreate the object). The rule: always define __repr__ on any custom class. If you only define one, Python falls back to __repr__ for both, so it's the more important one.

  • print(card) โ†’ calls __str__ โ†’ "Aโ™ "
  • REPL: just type card โ†’ calls __repr__ โ†’ Card(suit='spades', value=14)
  • f"{card!r}" โ†’ forces __repr__ even inside f-strings
๐Ÿ“ž __call__ โ€” Making Objects Callable

The __call__ dunder lets you call an object like a function. This sounds weird until you see where it's useful โ€” validators, middleware, configured function wrappers.

Python โ€” __call__ Example
class Multiplier:
    """A configured callable โ€” like a function with state."""
    def __init__(self, factor: int):
        self.factor = factor

    def __call__(self, value: int) -> int:
        return value * self.factor

# Create configured callable objects
double = Multiplier(2)
triple = Multiplier(3)

print(double(5))    # 10  โ€” called like a function!
print(triple(7))    # 21

# Can pass them anywhere a function is expected
nums = [1, 2, 3, 4]
print(list(map(double, nums)))  # [2, 4, 6, 8]
๐Ÿง  Quick Check
You write len(my_obj). What does Python actually call behind the scenes?
LESSON 03

Decorators โ€” Functions That Wrap Functions

You've seen @property and @staticmethod. Now understand what decorators actually are, how to write your own, and why they're everywhere in Python frameworks.

๐ŸŽ€ Building Up To Decorators Step by Step

Decorators build on two things you already know: functions are objects, and functions can take other functions as arguments. Let's build the concept from scratch.

Step 1 โ€” Functions returning functions
# Functions can return other functions
def make_greeter(greeting: str):
    def greet(name: str) -> str:
        return f"{greeting}, {name}!"
    return greet   # return the function itself, not a call

hello = make_greeter("Hello")
yo    = make_greeter("Yo")

print(hello("Jude"))   # "Hello, Jude!"
print(yo("Jude"))      # "Yo, Jude!"
# greet() "closes over" the greeting variable โ€” this is a CLOSURE
Step 2 โ€” A function that wraps another function
# A wrapper adds behavior before/after calling the original
def timer(func):
    import time
    def wrapper(*args, **kwargs):
        start  = time.time()
        result = func(*args, **kwargs)   # call the original
        end    = time.time()
        print(f"{func.__name__} took {end-start:.4f}s")
        return result
    return wrapper

def slow_add(a, b):
    import time; time.sleep(0.1)
    return a + b

# Manually wrap it
slow_add = timer(slow_add)
slow_add(3, 4)
# slow_add took 0.1003s
Step 3 โ€” The @ syntax (syntactic sugar)
# @timer is EXACTLY the same as: slow_add = timer(slow_add)
# It's just syntactic sugar โ€” cleaner to read

@timer
def slow_add(a: int, b: int) -> int:
    import time; time.sleep(0.1)
    return a + b

slow_add(3, 4)    # automatically timed, every call
โš ๏ธ The functools.wraps Fix โ€” Always Use This

When you wrap a function, the wrapper replaces it โ€” including its name and docstring. This breaks debugging, logging, and documentation tools. Fix it with @functools.wraps.

Python โ€” The Correct Decorator Pattern
import functools

def timer(func):
    @functools.wraps(func)   # โ† ALWAYS add this to your wrappers
    def wrapper(*args, **kwargs):
        import time
        start  = time.time()
        result = func(*args, **kwargs)
        print(f"{func.__name__} took {time.time()-start:.4f}s")
        return result
    return wrapper

# Without @functools.wraps:
# slow_add.__name__ โ†’ "wrapper"  โ† BAD
# With @functools.wraps:
# slow_add.__name__ โ†’ "slow_add" โ† CORRECT
๐Ÿ—๏ธ Real-World Decorator Examples
Python โ€” Decorators You'll Write and See Everywhere
import functools, time

# โ”€โ”€ 1. Retry decorator โ€” re-runs on failure โ”€โ”€
def retry(times: int = 3, delay: float = 1.0):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    print(f"Attempt {attempt} failed: {e}")
                    if attempt < times:
                        time.sleep(delay)
            raise RuntimeError(f"{func.__name__} failed after {times} attempts")
        return wrapper
    return decorator

@retry(times=3, delay=0.5)
def fetch_data(url: str) -> dict:
    # Automatically retried 3 times on any exception
    import requests
    return requests.get(url).json()

# โ”€โ”€ 2. Cache/memoize โ€” remember expensive results โ”€โ”€
from functools import lru_cache

@lru_cache(maxsize=128)   # built into Python stdlib!
def fibonacci(n: int) -> int:
    if n < 2: return n
    return fibonacci(n - 1) + fibonacci(n - 2)
    # Without @lru_cache: fib(40) calls itself ~100 million times
    # With @lru_cache:    fib(40) calls itself 40 times

# โ”€โ”€ 3. Validate arguments โ”€โ”€
def require_positive(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        for arg in args:
            if isinstance(arg, (int, float)) and arg <= 0:
                raise ValueError(f"All arguments must be positive, got {arg}")
        return func(*args, **kwargs)
    return wrapper

@require_positive
def calculate_area(width: float, height: float) -> float:
    return width * height
๐Ÿ”จ Try It

Write a @log_calls decorator

Create a decorator that prints the function name, its arguments, and its return value every time it's called. Example output:

  • โ†’ add(3, 4)
  • โ† add returned 7

Then add a parameter so you can do @log_calls(level="DEBUG") and the output includes the log level.

LESSON 04

Properties โ€” Smart Attribute Access

In Java you write getAge() and setAge() for every field. Python's @property decorator lets you access attributes like data while running code behind the scenes.

๐Ÿ  The Problem Properties Solve

You start with a simple class. Later you need to add validation, computation, or caching to an attribute. In Java, this requires changing your public API from a field to getter/setter methods โ€” a breaking change for all callers. In Python, you add @property and nothing that calls your class changes at all.

Java โ€” forced ceremony
public class Circle {
    private double radius;

    public double getRadius() {
        return radius;
    }
    public void setRadius(double r) {
        if (r < 0) throw new
            IllegalArgumentException();
        this.radius = r;
    }
    // always was getRadius() even
    // before validation existed
}
Python โ€” clean access, smart behavior
class Circle:
    def __init__(self, radius: float):
        self.radius = radius  # uses setter

    @property
    def radius(self) -> float:
        return self._radius

    @radius.setter
    def radius(self, value: float):
        if value < 0:
            raise ValueError("negative")
        self._radius = value
    # callers write: c.radius = 5
๐Ÿ”ง Full Property Pattern with Computed Properties
Python โ€” Properties in Practice
import math

class Circle:
    def __init__(self, radius: float):
        self.radius = radius   # this calls the setter below

    # โ”€โ”€ radius: validated read/write โ”€โ”€
    @property
    def radius(self) -> float:
        return self._radius

    @radius.setter
    def radius(self, value: float) -> None:
        if value < 0:
            raise ValueError(f"Radius must be โ‰ฅ 0, got {value}")
        self._radius = value

    # โ”€โ”€ computed properties โ€” read-only, no setter โ”€โ”€
    @property
    def area(self) -> float:
        # Calculated on demand โ€” not stored
        return math.pi * self._radius ** 2

    @property
    def circumference(self) -> float:
        return 2 * math.pi * self._radius

    @property
    def diameter(self) -> float:
        return self._radius * 2

    def __repr__(self) -> str:
        return f"Circle(radius={self._radius})"


# โ”€โ”€ Usage โ€” reads like attribute access, runs like methods โ”€โ”€
c = Circle(5)
print(c.radius)          # 5          โ€” property getter
print(c.area)            # 78.54...   โ€” computed, no ()!
print(c.circumference)   # 31.41...
c.radius = 10           # property setter โ€” validates
print(c.area)            # 314.15...  โ€” automatically updated
c.radius = -1           # ValueError: Radius must be โ‰ฅ 0, got -1
๐Ÿ’ก
The Naming ConventionThe private backing variable is _radius (single underscore). The public property is radius. The underscore signals "don't touch this directly" to other developers โ€” it's a convention, not enforcement. This is how Python handles encapsulation: trust, not locks.
LESSON 05

Context Managers โ€” Guaranteed Cleanup

The with statement is Python's answer to "always clean up, even if something goes wrong." Files, database connections, locks, timers โ€” anything that needs setup and teardown belongs in a context manager.

๐Ÿšช The Problem โ€” Cleanup That Might Never Run
โŒ Fragile โ€” leak if exception occurs
# If process_data() raises,
# the file never closes!
f = open("data.txt")
data = f.read()
process_data(data)
f.close()   # might never run
โœ… Safe โ€” always closes
# File ALWAYS closes โ€”
# even if an exception is raised
with open("data.txt") as f:
    data = f.read()
    process_data(data)
# f.close() called automatically
โš™๏ธ What with Actually Does
When Python enters a with block, it calls obj.__enter__() and assigns the result to the as variable. When the block exits โ€” for any reason, including exceptions โ€” it calls obj.__exit__(exc_type, exc_val, exc_tb). If __exit__ returns True, the exception is suppressed. If it returns None or False, the exception propagates. This guarantee is what makes context managers safe.
๐Ÿ”ง Writing Your Own Context Manager (Class-Based)
Python โ€” __enter__ and __exit__
import time

class Timer:
    """Context manager that measures elapsed time."""

    def __enter__(self) -> 'Timer':
        # Called when entering the with block
        self.start   = time.perf_counter()
        self.elapsed = 0.0
        return self    # this becomes the 'as' variable

    def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
        # Called when leaving the with block (always)
        self.elapsed = time.perf_counter() - self.start
        print(f"Elapsed: {self.elapsed:.4f}s")
        return False   # don't suppress exceptions


# Usage
with Timer() as t:
    time.sleep(0.25)
    print("doing work...")
# Elapsed: 0.2502s  (printed automatically)
print(f"Total was: {t.elapsed:.4f}s")   # accessible after block
โœจ The Easier Way โ€” @contextmanager

Python's contextlib module gives you a decorator that turns a generator function into a context manager. One function, no class needed โ€” everything before yield is setup, everything after is cleanup.

Python โ€” contextlib.contextmanager
from contextlib import contextmanager
import sqlite3

@contextmanager
def database_connection(db_path: str):
    """Manage a SQLite connection โ€” auto-commit or rollback."""
    conn = sqlite3.connect(db_path)
    try:
        yield conn          # โ† everything above is __enter__
        conn.commit()         # only reached if no exception
    except Exception:
        conn.rollback()     # roll back on error
        raise               # re-raise the exception
    finally:
        conn.close()        # โ† everything in finally is __exit__


# Clean, safe database operations
with database_connection("game.db") as conn:
    conn.execute("INSERT INTO players VALUES (?, ?)", ("Jude", 100))
# auto-committed if success, auto-rolled-back if error, always closed


# Another example โ€” temporarily change directory
import os

@contextmanager
def working_directory(path: str):
    original = os.getcwd()
    try:
        os.chdir(path)
        yield
    finally:
        os.chdir(original)   # ALWAYS restore, even on error

with working_directory("/tmp"):
    print(os.getcwd())   # /tmp
# Back to original directory automatically
LESSON 06

Generators & yield โ€” Lazy Sequences

A generator produces values one at a time, on demand, without building the whole sequence in memory. Once you understand this, you'll never build a million-item list again.

๐ŸŒ€ The Problem Generators Solve
โŒ Builds entire list in memory
def first_n_squares(n):
    results = []
    for i in range(n):
        results.append(i ** 2)
    return results

# 1 million squares = ~8MB of RAM
nums = first_n_squares(1_000_000)
โœ… Produces one value at a time
def first_n_squares(n):
    for i in range(n):
        yield i ** 2

# ~200 bytes regardless of n!
# Each value computed only when needed
nums = first_n_squares(1_000_000)
๐Ÿ” How yield works under the hood

Generator functions are pauseable

When Python sees yield in a function, it turns that function into a generator factory. Calling the function returns a generator object โ€” it doesn't run any code yet. Each time you call next() on the generator (which a for loop does automatically), the function runs until the next yield, produces that value, and then pauses โ€” preserving all its local variables exactly where it left off. The next next() call resumes from that exact point. When the function returns or runs out of yields, it raises StopIteration and the for loop exits.

๐Ÿ”ง Generator Patterns You'll Use Constantly
Python โ€” Generator Patterns
# โ”€โ”€ 1. Reading large files line by line โ”€โ”€
def read_large_file(filepath: str):
    """Read a file one line at a time โ€” no matter how big it is."""
    with open(filepath) as f:
        for line in f:
            yield line.strip()

# Process a 10GB log file with constant memory:
for line in read_large_file("huge.log"):
    if "ERROR" in line:
        print(line)


# โ”€โ”€ 2. Infinite sequence โ”€โ”€
def counter(start: int = 0):
    """Count forever โ€” only compute what you ask for."""
    n = start
    while True:
        yield n
        n += 1

from itertools import islice
first_10 = list(islice(counter(5), 10))
# [5, 6, 7, 8, 9, 10, 11, 12, 13, 14]


# โ”€โ”€ 3. Generator expression (like list comprehension, but lazy) โ”€โ”€
# List comprehension โ€” builds all results NOW
squares_list = [x**2 for x in range(1_000_000)]   # uses ~8MB

# Generator expression โ€” computes on demand
squares_gen  = (x**2 for x in range(1_000_000))   # uses ~200 bytes!
# Note: parentheses instead of brackets

# Both work identically in a for loop or sum()
total = sum(x**2 for x in range(1_000_000))  # no brackets needed inside ()


# โ”€โ”€ 4. Pipeline โ€” chain generators together โ”€โ”€
def parse_logs(lines):
    for line in lines:
        yield line.split(" | ")

def filter_errors(records):
    for record in records:
        if len(record) > 1 and record[0] == "ERROR":
            yield record

# Each stage only processes one item at a time โ€” memory stays flat
lines   = read_large_file("app.log")
records = parse_logs(lines)
errors  = filter_errors(records)
for err in errors:
    print(err)
๐Ÿง  Quick Check
What is the key memory difference between [x**2 for x in range(1000000)] and (x**2 for x in range(1000000))?
LESSON 07

Itertools โ€” The Most Underused Module

Python's itertools module is a toolkit of lazy, memory-efficient iterators. Most Python developers don't know it well โ€” which means knowing it well sets you apart.

โš™๏ธ The Essential Itertools Functions
Python โ€” itertools You'll Reach For
from itertools import (
    chain, islice, groupby, product,
    combinations, permutations,
    takewhile, dropwhile, cycle, repeat
)

# โ”€โ”€ chain โ€” flatten multiple iterables into one โ”€โ”€
a = [1, 2, 3]
b = [4, 5, 6]
c = [7, 8, 9]
list(chain(a, b, c))
# [1, 2, 3, 4, 5, 6, 7, 8, 9]

# โ”€โ”€ islice โ€” take first N items from any iterable โ”€โ”€
list(islice(range(1000), 5))
# [0, 1, 2, 3, 4]

# โ”€โ”€ groupby โ€” group consecutive items by a key โ”€โ”€
# IMPORTANT: input must be sorted by the key first!
data = [
    {"name": "Alice", "dept": "eng"},
    {"name": "Bob",   "dept": "eng"},
    {"name": "Carol", "dept": "hr"},
    {"name": "Dave",  "dept": "hr"},
]
data.sort(key=lambda x: x["dept"])
for dept, members in groupby(data, key=lambda x: x["dept"]):
    names = [m["name"] for m in members]
    print(f"{dept}: {names}")
# eng: ['Alice', 'Bob']
# hr:  ['Carol', 'Dave']

# โ”€โ”€ product โ€” cartesian product (nested loops collapsed) โ”€โ”€
colors = ["red", "blue"]
sizes  = ["S", "M", "L"]
list(product(colors, sizes))
# [('red','S'), ('red','M'), ('red','L'), ('blue','S'), ...]

# โ”€โ”€ combinations โ€” all unique pairs โ”€โ”€
players = ["Alice", "Bob", "Carol", "Dave"]
list(combinations(players, 2))
# All possible 2-player matchups โ€” 6 pairs

# โ”€โ”€ takewhile / dropwhile โ€” conditional slicing โ”€โ”€
data = [2, 4, 6, 7, 8, 10]
list(takewhile(lambda x: x % 2 == 0, data))  # [2, 4, 6]  โ€” stops at 7
list(dropwhile(lambda x: x % 2 == 0, data))  # [7, 8, 10] โ€” skips until 7

# โ”€โ”€ cycle โ€” repeat an iterable forever โ”€โ”€
colors_cycle = cycle(["red", "green", "blue"])
list(islice(colors_cycle, 7))
# ['red', 'green', 'blue', 'red', 'green', 'blue', 'red']
LESSON 08

Error Handling Done Right

Most Python developers either catch too broadly (hiding bugs) or not at all (crashing users). Here's the correct mental model โ€” custom exceptions, exception chaining, and when NOT to catch.

๐Ÿ›ก๏ธ Python's Exception Hierarchy

Python exceptions form a tree. When you catch a parent, you catch all its children. This matters for deciding what to catch.

Python โ€” Exception Hierarchy (partial)
BaseException
โ”œโ”€โ”€ SystemExit           # sys.exit() โ€” don't catch this
โ”œโ”€โ”€ KeyboardInterrupt    # Ctrl+C โ€” don't catch this either
โ””โ”€โ”€ Exception            # catch this or its children
    โ”œโ”€โ”€ ValueError       # wrong value type/range
    โ”œโ”€โ”€ TypeError        # wrong type entirely
    โ”œโ”€โ”€ KeyError         # dict key not found
    โ”œโ”€โ”€ IndexError       # list index out of range
    โ”œโ”€โ”€ AttributeError   # attribute doesn't exist on object
    โ”œโ”€โ”€ FileNotFoundError# subclass of OSError
    โ”œโ”€โ”€ PermissionError  # subclass of OSError
    โ”œโ”€โ”€ RuntimeError     # generic runtime error
    โ””โ”€โ”€ StopIteration    # iterator exhausted (don't catch)
โœ… The Correct Exception Handling Pattern
Python โ€” Full try/except/else/finally
def load_config(path: str) -> dict:
    try:
        with open(path) as f:
            data = json.load(f)

    except FileNotFoundError:
        # Specific โ€” file doesn't exist
        print(f"Config not found: {path}. Using defaults.")
        return {}

    except json.JSONDecodeError as e:
        # Specific โ€” file exists but isn't valid JSON
        raise ValueError(f"Config file corrupt: {path}") from e
        # โ†‘ "from e" attaches the original exception โ€” keeps context

    except PermissionError:
        # Can't recover โ€” re-raise with more info
        raise PermissionError(f"Cannot read config: {path}")

    else:
        # Runs ONLY if no exception was raised โ€” often overlooked!
        print(f"Config loaded: {len(data)} keys")
        return data

    finally:
        # Runs ALWAYS โ€” with or without exception
        print("load_config complete")


# โ”€โ”€ What NOT to do โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# NEVER do this โ€” hides ALL errors including bugs
try:
    do_something()
except:            # bare except catches EVERYTHING, even SystemExit
    pass

# NEVER do this โ€” too broad
try:
    do_something()
except Exception:  # hides bugs disguised as "errors"
    print("something went wrong")  # no info, no re-raise
โš ๏ธ
The Golden RuleOnly catch exceptions you know how to handle. If you catch an exception just to print it and move on, you're hiding a bug. If you need to catch broadly for a user-facing error message, log the full traceback and re-raise or exit cleanly. Silent failures are the hardest bugs to find.
๐Ÿ—๏ธ Custom Exception Hierarchy

For any real application, define your own exception classes. This lets callers catch your errors specifically and gives you meaningful error messages with structured data.

Python โ€” Custom Exceptions
# Define a hierarchy for your app
class AppError(Exception):
    """Base exception for all app errors."""
    pass

class ValidationError(AppError):
    """Raised when input data fails validation."""
    def __init__(self, field: str, message: str):
        self.field   = field
        self.message = message
        super().__init__(f"Validation failed for '{field}': {message}")

class NotFoundError(AppError):
    """Raised when a requested resource doesn't exist."""
    def __init__(self, resource: str, identifier):
        self.resource   = resource
        self.identifier = identifier
        super().__init__(f"{resource} '{identifier}' not found")


# โ”€โ”€ Usage โ”€โ”€
def get_player(player_id: int) -> dict:
    if not isinstance(player_id, int) or player_id <= 0:
        raise ValidationError("player_id", "must be a positive integer")
    player = db.find(player_id)
    if not player:
        raise NotFoundError("Player", player_id)
    return player

# โ”€โ”€ Callers can be very specific โ”€โ”€
try:
    player = get_player(42)
except ValidationError as e:
    print(f"Bad input: {e.field}")
except NotFoundError as e:
    print(f"Missing: {e.resource} {e.identifier}")
except AppError as e:
    print(f"App error: {e}")   # catches all app errors
LESSON 09

Type Hints Deep Dive

Phase 1 introduced basic type hints. Now go deeper โ€” Union types, TypeVar, Protocols, TypedDict, Literal, and how to use mypy to catch bugs before runtime.

๐Ÿ“ Advanced Type Hint Patterns
Python โ€” Type Hints You Need to Know
from typing import (
    Optional, Union, Any, TypeVar,
    Callable, Sequence, Mapping,
    TypedDict, Protocol, Literal, Final
)

# โ”€โ”€ Optional โ€” value or None (very common) โ”€โ”€
def find_user(user_id: int) -> Optional[dict]:
    # Python 3.10+ cleaner syntax: dict | None
    return db.get(user_id)   # might be None

# โ”€โ”€ Union โ€” one of several types โ”€โ”€
def parse_id(raw: Union[str, int]) -> int:
    # Python 3.10+: str | int
    return int(raw)

# โ”€โ”€ Callable โ€” a function type โ”€โ”€
def apply(
    func: Callable[[int, int], int],   # takes 2 ints, returns int
    a: int, b: int
) -> int:
    return func(a, b)

# โ”€โ”€ TypeVar โ€” generic functions โ”€โ”€
T = TypeVar('T')

def first(items: list[T]) -> T:
    # Return type is same T as element type
    return items[0]

first([1, 2, 3])         # inferred: int
first(["a", "b"])       # inferred: str

# โ”€โ”€ TypedDict โ€” dict with a known shape โ”€โ”€
class PlayerData(TypedDict):
    name:    str
    hp:      int
    level:   int
    alive:   bool

def display_player(p: PlayerData) -> None:
    # mypy knows p["name"] is a str, p["hp"] is an int
    print(f"{p['name']} โ€” {p['hp']} HP")

# โ”€โ”€ Literal โ€” only specific values allowed โ”€โ”€
Direction = Literal["north", "south", "east", "west"]

def move(direction: Direction) -> None:
    print(f"Moving {direction}")

move("north")   # โœ… valid
move("up")      # mypy error: not a valid Direction

# โ”€โ”€ Protocol โ€” structural subtyping ("duck typing with types") โ”€โ”€
class Drawable(Protocol):
    def draw(self) -> None: ...

def render_all(objects: list[Drawable]) -> None:
    for obj in objects:
        obj.draw()

# Any class with a draw() method satisfies Drawable
# โ€” no explicit inheritance needed. True duck typing + types.
๐Ÿ” Running mypy โ€” Catch Bugs Before Runtime
  1. Install: pip install mypy
  2. Run on a file: mypy your_file.py
  3. Run strictly: mypy --strict your_file.py โ€” catches more issues, requires all annotations
  4. Add a mypy.ini or pyproject.toml section to configure per-project settings
  5. In VS Code: install the Pylance extension โ€” gives you mypy-like feedback inline as you type
๐Ÿ’ก
Don't annotate everything at onceIf you're adding types to an existing project, start with function signatures only. Annotate return types first (most valuable), then parameters. Use # type: ignore sparingly for things you'll fix later. Progress over perfection.
LESSON 10

Dataclasses โ€” Structured Data Without Boilerplate

@dataclass is Python's answer to Java Records โ€” auto-generates __init__, __repr__, __eq__ and more from your field declarations. Use them constantly.

๐Ÿ“ฆ What @dataclass Generates For You
Without @dataclass โ€” all manual
class Player:
    def __init__(self, name, hp, level):
        self.name  = name
        self.hp    = hp
        self.level = level

    def __repr__(self):
        return (f"Player(name={self.name!r},"
                f" hp={self.hp},"
                f" level={self.level})")

    def __eq__(self, other):
        if not isinstance(other, Player):
            return NotImplemented
        return (self.name == other.name and
                self.hp   == other.hp   and
                self.level == other.level)
With @dataclass โ€” auto-generated
from dataclasses import dataclass

@dataclass
class Player:
    name:  str
    hp:    int
    level: int

# That's it. Python generates:
# โœ… __init__(self, name, hp, level)
# โœ… __repr__ with all fields
# โœ… __eq__ comparing all fields
# All correct. All free.
๐Ÿ”ง Advanced Dataclass Features
Python โ€” Dataclasses in Full
from dataclasses import dataclass, field, asdict, astuple
from typing import ClassVar
import datetime

@dataclass
class Task:
    # Required fields (no default) must come before optional ones
    title:    str
    priority: int

    # Optional fields with defaults
    done:     bool    = False
    tags:     list[str] = field(default_factory=list)
    # โ†‘ NEVER use default=[] โ€” mutable defaults are shared!
    #   default_factory creates a NEW list for each instance

    # Auto-set field โ€” not in __init__ signature
    created:  str    = field(
        default_factory=lambda: datetime.datetime.now().isoformat(),
        init=False    # excluded from constructor
    )

    # Class variable (shared, not per-instance)
    MAX_PRIORITY: ClassVar[int] = 5

    # You can still add methods normally
    def complete(self) -> None:
        self.done = True

    # __post_init__ โ€” runs after the generated __init__
    def __post_init__(self) -> None:
        if not 1 <= self.priority <= self.MAX_PRIORITY:
            raise ValueError(
                f"Priority must be 1โ€“{self.MAX_PRIORITY}, got {self.priority}"
            )
        self.title = self.title.strip()   # clean up input


# โ”€โ”€ Frozen dataclass โ€” immutable (like Java record) โ”€โ”€
@dataclass(frozen=True)
class Point:
    x: float
    y: float
    # frozen=True: auto-generates __hash__, prevents mutation
    # Can be used in sets and as dict keys

p = Point(3.0, 4.0)
print(asdict(p))      # {'x': 3.0, 'y': 4.0} โ€” to dict, free!
# p.x = 5  โ† FrozenInstanceError โ€” immutable


# โ”€โ”€ Ordered dataclass โ€” sortable โ”€โ”€
@dataclass(order=True)
class Score:
    value:      int
    player_name: str

scores = [Score(85, "Jude"), Score(92, "Ana"), Score(78, "Max")]
print(sorted(scores))  # sorted by value, then name โ€” free!
โš ๏ธ
The Mutable Default TrapNever write tags: list = [] in a dataclass. That single [] is created once at class definition time and shared between ALL instances โ€” modifying one modifies all. Always use field(default_factory=list) for mutable defaults like lists and dicts.
๐Ÿง  Quick Check
Why should you use field(default_factory=list) instead of = [] for a list field in a dataclass?
PHASE PROJECT ๐Ÿ

Rebuild the Task Manager โ€” Pythonically

Take the Phase 1 task manager and rebuild it from scratch using every concept from Phase 2. Same functionality, dramatically better code.

๐ŸŽฏ What to Build

The Phase 1 project was functional. This version should be Pythonic. Every lesson from Phase 2 should appear somewhere in the final code.

๐Ÿ“‹
Requirements Checklist
โœ… Dataclasses โ€” Task, Tag, Priority using @dataclass with proper defaults
โœ… Properties โ€” validated priority setter, computed is_overdue property
โœ… Dunder methods โ€” __str__, __repr__, __eq__, __lt__ on Task so they sort naturally
โœ… Context manager โ€” file-based persistence using @contextmanager
โœ… Generator โ€” pending_tasks() yields tasks lazily
โœ… Custom exceptions โ€” TaskNotFoundError, ValidationError
โœ… Decorators โ€” @log_calls on mutating methods
โœ… Type hints โ€” full annotation including Optional, Literal, and TypedDict
๐Ÿ’ก Reference Solution โ€” Study After Attempting
Python โ€” Pythonic Task Manager (Phase 2)
from __future__  import annotations
from dataclasses import dataclass, field, asdict
from contextlib  import contextmanager
from typing      import Iterator, Literal, Optional
from pathlib     import Path
import json, datetime, functools, logging

logging.basicConfig(level=logging.INFO,
                    format="%(levelname)s %(message)s")
log = logging.getLogger(__name__)


# โ”€โ”€โ”€ Custom Exceptions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TaskError(Exception): pass
class TaskNotFoundError(TaskError):
    def __init__(self, task_id: str):
        super().__init__(f"Task not found: {task_id!r}")
class ValidationError(TaskError):
    def __init__(self, field_name: str, msg: str):
        super().__init__(f"{field_name}: {msg}")


# โ”€โ”€โ”€ Logging decorator โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def log_mutation(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        log.info(f"{func.__name__} called")
        return result
    return wrapper


# โ”€โ”€โ”€ Priority type โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
Priority = Literal[1, 2, 3, 4, 5]
PRIORITY_LABELS = {1:"low", 2:"normal", 3:"high", 4:"urgent", 5:"critical"}


# โ”€โ”€โ”€ Task dataclass โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@dataclass(order=True)
class Task:
    title:    str
    _priority: int          = field(default=2, repr=False)
    done:     bool           = field(default=False, compare=False)
    tags:     list[str]      = field(default_factory=list, compare=False)
    due:      Optional[str]  = field(default=None, compare=False)
    created:  str            = field(
        default_factory=lambda: datetime.date.today().isoformat(),
        init=False, compare=False
    )

    def __post_init__(self) -> None:
        if not self.title.strip():
            raise ValidationError("title", "cannot be blank")
        self.title = self.title.strip()

    @property
    def priority(self) -> int:
        return self._priority

    @priority.setter
    def priority(self, value: int) -> None:
        if not 1 <= value <= 5:
            raise ValidationError("priority", "must be 1โ€“5")
        self._priority = value

    @property
    def is_overdue(self) -> bool:
        if not self.due: return False
        return datetime.date.fromisoformat(self.due) < datetime.date.today()

    def __str__(self) -> str:
        status  = "โœ“" if self.done else ("!" if self.is_overdue else "โ—‹")
        plabel  = PRIORITY_LABELS[self._priority]
        due_str = f" due:{self.due}" if self.due else ""
        return f"[{status}] {self.title} [{plabel}]{due_str}"


# โ”€โ”€โ”€ Context manager for file I/O โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@contextmanager
def task_store(path: Path) -> Iterator[list[Task]]:
    tasks: list[Task] = []
    if path.exists():
        raw = json.loads(path.read_text())
        tasks = [Task(**r) for r in raw]
    try:
        yield tasks
    finally:
        path.write_text(json.dumps([asdict(t) for t in tasks], indent=2))


# โ”€โ”€โ”€ TaskManager โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TaskManager:
    def __init__(self, path: str = "tasks.json"):
        self._path  = Path(path)
        self._tasks: list[Task] = self._load()

    def _load(self) -> list[Task]:
        if not self._path.exists(): return []
        return [Task(**r) for r in json.loads(self._path.read_text())]

    def _save(self) -> None:
        self._path.write_text(
            json.dumps([asdict(t) for t in self._tasks], indent=2))

    @log_mutation
    def add(self, title: str, priority: int = 2,
            tags: list[str] = None, due: Optional[str] = None) -> Task:
        task = Task(title=title, _priority=priority,
                    tags=tags or [], due=due)
        self._tasks.append(task)
        self._save()
        return task

    def pending(self) -> Iterator[Task]:
        """Generator โ€” yields pending tasks by priority (highest first)."""
        for task in sorted(self._tasks, reverse=True):
            if not task.done:
                yield task

    def overdue(self) -> Iterator[Task]:
        """Generator โ€” yields only overdue pending tasks."""
        return (t for t in self.pending() if t.is_overdue)

    def __len__(self) -> int:      return len(self._tasks)
    def __iter__(self):             return iter(self._tasks)
    def __repr__(self) -> str:      return f"TaskManager({len(self)} tasks)"


if __name__ == "__main__":
    mgr = TaskManager()
    mgr.add("Learn Phase 3 โ€” The Ecosystem", priority=5)
    mgr.add("Ship Death Road prototype",     priority=4, due="2026-07-01")
    mgr.add("Read The Pragmatic Programmer",  priority=2)
    print(f"\n{mgr}\n")
    for task in mgr.pending():
        print(task)
๐ŸŽฏ Extend the Challenge

After the baseline works, push further:

  • Add a search(query: str) method that uses a generator to filter across title and tags
  • Add a @dataclass TaskStats with computed fields (total, done, pending, overdue counts) using itertools
  • Write a bulk_complete(*titles: str) method using *args โ€” mark multiple tasks done at once
  • Add a @retry(times=3) decorator to the _save() method to handle I/O failures gracefully
๐Ÿš€
Phase 2 Complete. Compare your code to the Phase 1 version. It does the same thing โ€” but now it speaks Python natively. Decorators, generators, properties, context managers, dataclasses, custom exceptions. Every tool has a reason. That's Pythonic thinking. Phase 3 is The Ecosystem โ€” requests, pydantic, typer, and the libraries that make Python fast to ship with.
Roadmap